From ead5121166a83a8775097c88c19dd1222e921a40 Mon Sep 17 00:00:00 2001 From: rwrife Date: Thu, 9 Jul 2026 22:25:43 +0000 Subject: [PATCH] Add species selection (fern/cactus/bonsai/sunflower) (#9) --- README.md | 23 ++ cmd/root.go | 48 ++- internal/plant/plant.go | 62 ++- internal/plant/species_tuning_test.go | 52 +++ internal/render/render.go | 13 +- internal/render/species_render_test.go | 60 +++ internal/species/art.go | 511 +++++++++++++++++++++++++ internal/species/species.go | 127 ++++++ internal/species/species_test.go | 130 +++++++ internal/store/store.go | 7 + 10 files changed, 1017 insertions(+), 16 deletions(-) create mode 100644 internal/plant/species_tuning_test.go create mode 100644 internal/render/species_render_test.go create mode 100644 internal/species/art.go create mode 100644 internal/species/species.go create mode 100644 internal/species/species_test.go diff --git a/README.md b/README.md index 6220ff0..cee2127 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ commit-sprout prompt # compact glyph for your shell prompt / tmux status commit-sprout --prompt # same thing, as a flag (handy inside a prompt string) commit-sprout water # buy a grace day before wilting (weekends / PTO) commit-sprout garden # a row of plants, one per repo (a windowsill) +commit-sprout --species cactus # pick your plant's species (remembered as default) ``` `status` prints a plain, script-friendly summary: @@ -132,6 +133,26 @@ Details: (honoring `$COLUMNS`), and color / `--no-color` / `NO_COLOR` behave exactly like the single-plant view. +### Species + +Pick what kind of plant you're growing. Each species has its own full art set +(across every growth stage and wilt state), and one has a gameplay twist. + +```bash +commit-sprout --species fern # the default: upright and leafy +commit-sprout --species cactus # blocky and spiky — and drought-hardy +commit-sprout --species bonsai # a gnarled, sculptural little tree +commit-sprout --species sunflower # a tall stalk that ends in a big bloom +``` + +- **Remembered:** the species you choose is saved as your default (in the same + state file as everything else, under `species`), so later flag-less runs keep + rendering it. Pass `--species` again anytime to switch. +- **Cactus twist:** a cactus tolerates much longer commit gaps before it wilts + (roughly a week and a half of silence vs. a few days for the leafier species). + It only bends the *health* thresholds — growth and streak still come from real + commits, so a cactus can't fake progress, just survive a drought. + ## Install ### Prebuilt binary (recommended) @@ -255,6 +276,8 @@ See [PLAN.md](./PLAN.md) for the full plan, milestones (M1–M6), and the v0.2+ - **Watering can** (`commit-sprout water`) — buy bounded grace days for planned breaks. - **Multi-repo garden** (`commit-sprout garden`) — a windowsill of plants, one per repo, with a saveable repo set and per-repo author identity. +- **Species selection** (`commit-sprout --species`) — fern / cactus / bonsai / sunflower, + each with its own art set; the cactus survives droughts longer as a gameplay twist. ## License diff --git a/cmd/root.go b/cmd/root.go index ed10e09..5d7f93b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,12 +11,14 @@ import ( "errors" "fmt" "os" + "strings" "time" "github.com/mattn/go-isatty" "github.com/rwrife/commit-sprout/internal/gitstat" "github.com/rwrife/commit-sprout/internal/plant" "github.com/rwrife/commit-sprout/internal/render" + "github.com/rwrife/commit-sprout/internal/species" "github.com/rwrife/commit-sprout/internal/store" "github.com/spf13/cobra" ) @@ -43,6 +45,13 @@ var noSave bool // for the `prompt` subcommand so it drops cleanly into a shell prompt string. var promptMode bool +// speciesFlag backs the --species flag: the chosen plant species (fern / +// cactus / bonsai / sunflower). Empty means "unset", in which case the saved +// default from state (or the built-in default species) is used. When set to a +// valid name it both renders as that species and is remembered as the new +// default for future flag-less runs. +var speciesFlag string + // rootCmd is the base command invoked as `commit-sprout` with no subcommand. var rootCmd = &cobra.Command{ Use: "commit-sprout", @@ -79,6 +88,7 @@ type pipelineResult struct { pathErr error plant plant.PlantState now time.Time + species species.Kind } // runPipeline performs the shared read half of every command: read git @@ -104,7 +114,36 @@ func runPipeline() (pipelineResult, error) { } now := time.Now() - ps := plant.Compute(act, persisted.Plant(now, act.LastCommit), now) + + // Resolve the species: an explicit --species wins, otherwise fall back to + // the saved default, otherwise the built-in default. An unrecognized flag + // value is reported so a typo is not silently ignored. + sp := species.Default + if saved, ok := species.Parse(persisted.Species); ok { + sp = saved + } + if speciesFlag != "" { + chosen, ok := species.Parse(speciesFlag) + if !ok { + return pipelineResult{}, fmt.Errorf("unknown species %q (choose one of: %s)", + speciesFlag, strings.Join(species.Names(), ", ")) + } + sp = chosen + // Remember the newly chosen species as the default for later runs. + persisted.Species = sp.String() + } + + // Species can widen how long the plant tolerates a dry spell (the cactus + // twist). Growth is unaffected; only wilt thresholds move. + tune := plant.DefaultHealthTuning() + if st, ok := species.TuningFor(sp); ok { + tune = plant.HealthTuning{ + ThirstyAfterDays: st.ThirstyAfterDays, + WiltingAfterDays: st.WiltingAfterDays, + } + } + + ps := plant.ComputeWith(act, persisted.Plant(now, act.LastCommit), now, tune) return pipelineResult{ activity: act, @@ -113,6 +152,7 @@ func runPipeline() (pipelineResult, error) { pathErr: pathErr, plant: ps, now: now, + species: sp, }, nil } @@ -165,6 +205,7 @@ func renderPlant(cmd *cobra.Command) error { Color: useColor(cmd), Now: r.now, LastCommit: r.activity.LastCommit, + Species: r.species, }) if _, perr := fmt.Fprintln(out, frame); perr != nil { return perr @@ -253,6 +294,11 @@ func init() { // drops straight into a shell prompt string. rootCmd.Flags().BoolVar(&promptMode, "prompt", false, "emit a one-line glyph for shell prompt / tmux / starship") + // --species picks the plant's art set (and, for the cactus, its drought + // tolerance). A chosen species is remembered as the default for later runs. + rootCmd.PersistentFlags().StringVar(&speciesFlag, "species", "", + "plant species: "+strings.Join(species.Names(), " / ")+" (remembered as default)") + // Hidden M2 bridge: dump parsed git activity. Removed once M3+ consume it. rootCmd.Flags().BoolVar(&showActivity, "activity", false, "print parsed git activity (debug)") _ = rootCmd.Flags().MarkHidden("activity") diff --git a/internal/plant/plant.go b/internal/plant/plant.go index d811b2b..05880ca 100644 --- a/internal/plant/plant.go +++ b/internal/plant/plant.go @@ -212,9 +212,41 @@ type PlantState struct { // below its remembered peak, so neglect eventually shows in growth too -- but // never below Sprout once anything has ever grown. func Compute(act gitstat.Activity, st State, now time.Time) PlantState { + return ComputeWith(act, st, now, DefaultHealthTuning()) +} + +// HealthTuning carries the idle-day thresholds that decide when a plant turns +// thirsty and then wilts. It exists so a species can bend the wilt rules (e.g. +// a drought-hardy cactus) without the pure plant package importing the species +// package, which would create an import cycle. Callers that have no species in +// hand should pass DefaultHealthTuning(). +type HealthTuning struct { + // ThirstyAfterDays is the last idle day count still considered Healthy. + ThirstyAfterDays int + // WiltingAfterDays is the last idle day count still considered Thirsty; + // beyond it the plant Wilts. + WiltingAfterDays int +} + +// DefaultHealthTuning returns the shared, historical wilt thresholds used when +// a species offers no override. These match the package's original constants so +// a default plant behaves exactly as before. +func DefaultHealthTuning() HealthTuning { + return HealthTuning{ + ThirstyAfterDays: thirstyAfterDays, + WiltingAfterDays: wiltingAfterDays, + } +} + +// ComputeWith is Compute with an explicit HealthTuning so a species can widen +// (or tighten) how long the plant tolerates a dry spell before wilting. Growth +// (stage/streak) is unaffected -- only the health thresholds move -- so a +// species twist can never fake progress, only resilience. It remains pure and +// deterministic. +func ComputeWith(act gitstat.Activity, st State, now time.Time, tune HealthTuning) PlantState { days := daysSinceCommit(act, now) grace := clampGrace(st.GraceDays) - health := healthFor(days, act.HasCommits, grace) + health := healthForTuned(days, act.HasCommits, grace, tune) live := liveStage(act) // Remember the tallest we've ever been (peak of memory and live growth). @@ -306,33 +338,37 @@ func dayStart(t time.Time) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, t.Location()) } -// healthFor maps days-since-last-commit to a Health. With no commits at all the -// plant is a fresh Seed and reported Healthy (there is nothing to wilt yet). -// -// grace is the number of watering-can days in effect (already clamped): it -// shifts the idle count back so a watered plant tolerates that many extra quiet -// days before drying out. Grace only moves the health thresholds; the caller -// keeps Stage and Streak untouched, so watering can never fake growth. -func healthFor(days int, hasCommits bool, grace int) Health { +// healthForTuned is healthFor with explicit thresholds supplied by the caller +// (typically from a species). See healthFor for the shared-default behavior. +func healthForTuned(days int, hasCommits bool, grace int, tune HealthTuning) Health { if !hasCommits || days < 0 { return Healthy } - // Grace effectively rewinds the dry spell: two watered days make a - // four-day gap feel like two. Never let it drop below "committed today". effective := days - grace if effective < 0 { effective = 0 } switch { - case effective <= thirstyAfterDays: + case effective <= tune.ThirstyAfterDays: return Healthy - case effective <= wiltingAfterDays: + case effective <= tune.WiltingAfterDays: return Thirsty default: return Wilting } } +// healthFor maps days-since-last-commit to a Health. With no commits at all the +// plant is a fresh Seed and reported Healthy (there is nothing to wilt yet). +// +// grace is the number of watering-can days in effect (already clamped): it +// shifts the idle count back so a watered plant tolerates that many extra quiet +// days before drying out. Grace only moves the health thresholds; the caller +// keeps Stage and Streak untouched, so watering can never fake growth. +func healthFor(days int, hasCommits bool, grace int) Health { + return healthForTuned(days, hasCommits, grace, DefaultHealthTuning()) +} + // clampGrace bounds a raw grace-day count into the supported [0, MaxGraceDays] // range so neither a negative nor an over-large (hand-edited) value can distort // the health calculation. diff --git a/internal/plant/species_tuning_test.go b/internal/plant/species_tuning_test.go new file mode 100644 index 0000000..532da62 --- /dev/null +++ b/internal/plant/species_tuning_test.go @@ -0,0 +1,52 @@ +package plant + +import ( + "testing" + "time" + + "github.com/rwrife/commit-sprout/internal/gitstat" +) + +// TestComputeWithDefaultMatchesCompute ensures ComputeWith with the default +// tuning is identical to Compute, so the species hook is a pure superset that +// changes nothing for the default plant. +func TestComputeWithDefaultMatchesCompute(t *testing.T) { + now := time.Date(2026, 7, 9, 12, 0, 0, 0, time.UTC) + act := gitstat.Activity{ + HasCommits: true, + Streak: 5, + TotalInWindow: 5, + LastCommit: now.AddDate(0, 0, -4), // 4 idle days + } + st := State{HighestStage: Tall} + + want := Compute(act, st, now) + got := ComputeWith(act, st, now, DefaultHealthTuning()) + if got != want { + t.Errorf("ComputeWith(default) = %+v, want %+v", got, want) + } +} + +// TestComputeWithDroughtTuningSurvivesLonger verifies a more forgiving tuning +// (the cactus twist) keeps a plant healthier for the same idle gap: where the +// default plant would wilt, the drought-hardy tuning is merely thirsty or fine. +func TestComputeWithDroughtTuningSurvivesLonger(t *testing.T) { + now := time.Date(2026, 7, 9, 12, 0, 0, 0, time.UTC) + // 6 idle days: well past the default wilting threshold (day 4+). + act := gitstat.Activity{ + HasCommits: true, + Streak: 0, + LastCommit: now.AddDate(0, 0, -6), + } + st := State{HighestStage: Leafy} + + def := Compute(act, st, now) + if def.Health != Wilting { + t.Fatalf("precondition: default plant should be Wilting at 6 idle days, got %v", def.Health) + } + + hardy := ComputeWith(act, st, now, HealthTuning{ThirstyAfterDays: 4, WiltingAfterDays: 9}) + if hardy.Health == Wilting { + t.Errorf("drought-hardy plant should not be Wilting at 6 idle days, got %v", hardy.Health) + } +} diff --git a/internal/render/render.go b/internal/render/render.go index effab2f..9aeb62e 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -17,6 +17,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/rwrife/commit-sprout/internal/plant" + "github.com/rwrife/commit-sprout/internal/species" ) // seedling is the original M1 hard-coded plant: a tiny sprout in a pot. It is @@ -50,6 +51,10 @@ type Options struct { // ignores it. Zero or negative means "unknown", and Garden falls back to // DefaultGardenWidth. Width int + + // Species selects which art set to render. The zero value is the default + // species (fern), so callers that never set it get the historical art. + Species species.Kind } // Seedling returns the hard-coded ASCII seedling frame (the healthy Seed art). @@ -68,7 +73,7 @@ func Seedling() string { // Rendering is deterministic given ps and opt. When opt.Color is false the // output is plain ASCII suitable for pipes, files, and CI snapshots. func Frame(ps plant.PlantState, opt Options) string { - art := artFor(ps.Stage, ps.Health) + art := species.ArtFor(opt.Species, ps.Stage, ps.Health) caption := caption(ps, opt) var b strings.Builder @@ -386,6 +391,10 @@ type GardenPlant struct { // LastCommit is the repo's most recent commit time, used for this // plant's compact caption. The zero value renders as "no commits yet". LastCommit time.Time + + // Species selects this plant's art set. The zero value is the default + // species (fern), so existing callers are unaffected. + Species species.Kind } // Garden renders plants as one or more rows of side-by-side cells, wrapping to @@ -423,7 +432,7 @@ func Garden(plants []GardenPlant, opt Options) string { // rectangle (all lines padded to equal width) so cells align cleanly when set // beside one another. func gardenCell(p GardenPlant, opt Options) string { - art := artFor(p.State.Stage, p.State.Health) + art := species.ArtFor(p.Species, p.State.Stage, p.State.Health) if opt.Color { art = colorizeArt(art, p.State) } diff --git a/internal/render/species_render_test.go b/internal/render/species_render_test.go new file mode 100644 index 0000000..41ce21e --- /dev/null +++ b/internal/render/species_render_test.go @@ -0,0 +1,60 @@ +package render + +import ( + "strings" + "testing" + + "github.com/rwrife/commit-sprout/internal/plant" + "github.com/rwrife/commit-sprout/internal/species" +) + +// TestFrameSpeciesSelectsArtSet verifies that setting Options.Species changes +// the rendered art: a cactus frame must differ from the default fern frame for +// the same stage/health. +func TestFrameSpeciesSelectsArtSet(t *testing.T) { + ps := plant.PlantState{ + Stage: plant.Blooming, + Health: plant.Healthy, + Streak: 20, + } + + fern := Frame(ps, Options{}) // default species + cactus := Frame(ps, Options{Species: species.Cactus}) // explicit cactus + + if fern == cactus { + t.Fatal("cactus frame matched the default fern frame; species not applied") + } + // Sanity: the fern still contains its signature pot line. + if !strings.Contains(fern, "( )") { + t.Error("fern frame missing expected pot art") + } +} + +// TestFrameDefaultSpeciesIsFern confirms the zero-value species renders the +// original art, so existing callers that never set Species are unaffected. +func TestFrameDefaultSpeciesIsFern(t *testing.T) { + ps := plant.PlantState{Stage: plant.Leafy, Health: plant.Healthy} + + zero := Frame(ps, Options{}) + explicit := Frame(ps, Options{Species: species.Fern}) + if zero != explicit { + t.Error("zero-value species did not match explicit Fern; default is not fern") + } +} + +// TestGardenPlantSpecies verifies a garden cell honors its per-plant species. +func TestGardenPlantSpecies(t *testing.T) { + ps := plant.PlantState{Stage: plant.Tall, Health: plant.Healthy} + out := Garden([]GardenPlant{ + {Label: "fern-repo", State: ps}, + {Label: "cactus-repo", State: ps, Species: species.Cactus}, + }, Options{Width: 200}) + + if strings.TrimSpace(out) == "" { + t.Fatal("garden rendered empty") + } + // The cactus tall art has a distinctive block trunk row absent from the fern. + if !strings.Contains(out, "|_| | |_|") { + t.Error("garden output missing cactus art; per-plant species not applied") + } +} diff --git a/internal/species/art.go b/internal/species/art.go new file mode 100644 index 0000000..a34a963 --- /dev/null +++ b/internal/species/art.go @@ -0,0 +1,511 @@ +// Species art sets: a full stage x health ASCII table per species. +// +// Each species owns a complete table so the render layer can select art purely +// by (species, stage, health) with no fallbacks that would hide a gap. The Fern +// table is byte-for-byte the original commit-sprout art, so choosing the +// default species (or passing no --species) renders exactly as the tool always +// has. +// +// Art is intentionally plain data here; the render package handles layout, +// captions, and color. +package species + +import "github.com/rwrife/commit-sprout/internal/plant" + +// artSets maps each species to its stage x health art table. Every species in +// All() must have a complete entry (guarded by a test) so ArtFor never has to +// fall back across species. +var artSets = map[Kind]map[plant.Stage]map[plant.Health]string{ + Fern: fernArt, + Cactus: cactusArt, + Bonsai: bonsaiArt, + Sunflower: sunflowerArt, +} + +// ArtFor returns the ASCII frame for a (species, stage, health) triple. It +// resolves the species table (falling back to the default species only if an +// unknown Kind slips through), then the stage, then the health, using the +// species' Healthy frame as a last resort so a caller always gets art. +func ArtFor(k Kind, stage plant.Stage, health plant.Health) string { + table, ok := artSets[k] + if !ok { + table = artSets[Default] + } + byHealth, ok := table[stage] + if !ok { + byHealth = table[plant.Seed] + } + if art, ok := byHealth[health]; ok { + return art + } + return byHealth[plant.Healthy] +} + +// fernArt is the original commit-sprout art, preserved exactly so the default +// species is a no-op visual change. +var fernArt = map[plant.Stage]map[plant.Health]string{ + plant.Seed: { + plant.Healthy: " \\|/\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " .|.\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " .,.\n" + + " .\n" + + " _._\n" + + " ( )\n" + + " '-'", + }, + plant.Sprout: { + plant.Healthy: " ,\n" + + " /|\n" + + " ' |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " .\n" + + " \\|\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: "\n" + + " \\,\n" + + " `.\n" + + " _|_\n" + + " ( )\n" + + " '-'", + }, + plant.Leafy: { + plant.Healthy: " \\ | /\n" + + " \\|/\n" + + " --+--\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " \\ | \n" + + " \\| \n" + + " --+ \n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " . . .\n" + + " \\ / \n" + + " +._ \n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + }, + plant.Tall: { + plant.Healthy: " \\\\|//\n" + + " \\|/\n" + + " --+--\n" + + " /|\\\n" + + " |\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " \\\\| \n" + + " \\| \n" + + " --+- \n" + + " /| \n" + + " |\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " . . .\n" + + " \\|/ \n" + + " ._+ \n" + + " \\\n" + + " |\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + }, + plant.Blooming: { + plant.Healthy: " @ \\|/ @\n" + + " \\\\|//\n" + + " @--+--@\n" + + " /|\\\n" + + " |\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " @ \\| \n" + + " \\\\| \n" + + " @--+- \n" + + " /| \n" + + " |\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " * . . \n" + + " \\\\|/ \n" + + " .--+ \n" + + " /\\\n" + + " |\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + }, +} + +// cactusArt is a blocky, spiky desert plant. Its wilt frames droop far less +// dramatically than the fern's -- a cactus that has gone dry looks a little +// shrunken and dotted rather than collapsed -- reinforcing its drought-hardy +// personality (the real gameplay twist lives in its wilt thresholds). +var cactusArt = map[plant.Stage]map[plant.Health]string{ + plant.Seed: { + plant.Healthy: " .\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " ,\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " .\n" + + " _._\n" + + " ( )\n" + + " '-'", + }, + plant.Sprout: { + plant.Healthy: " _|_\n" + + " | | |\n" + + " |_|_|\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " .|.\n" + + " | | |\n" + + " |_|_|\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " . .\n" + + " |_|_|\n" + + " _|_\n" + + " ( )\n" + + " '-'", + }, + plant.Leafy: { + plant.Healthy: " _ | _\n" + + "| |_|_| |\n" + + "|_| | |_|\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " . | .\n" + + "| |_|_| |\n" + + "|_| | |_|\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " . | .\n" + + "|_|_|_|_|\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + }, + plant.Tall: { + plant.Healthy: " _ | _\n" + + "| |_|_| |\n" + + "| | | | |\n" + + "|_| | |_|\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " . | .\n" + + "| |_|_| |\n" + + "| | | | |\n" + + "|_| | |_|\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " . | .\n" + + "| | | | |\n" + + "|_|_|_|_|\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + }, + plant.Blooming: { + plant.Healthy: " @ | @\n" + + "| |_|_| |\n" + + "| | | | |\n" + + "|_| | |_|\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " o | o\n" + + "| |_|_| |\n" + + "| | | | |\n" + + "|_| | |_|\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " . | .\n" + + "| | | | |\n" + + "|_|_|_|_|\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + }, +} + +// bonsaiArt is a gnarled, sculptural little tree: a leaning trunk with a +// compact canopy that fills out as it grows. +var bonsaiArt = map[plant.Stage]map[plant.Health]string{ + plant.Seed: { + plant.Healthy: " ~\n" + + " \\\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " .\n" + + " \\\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " .\n" + + " ,\n" + + " _._\n" + + " ( )\n" + + " '-'", + }, + plant.Sprout: { + plant.Healthy: " (~)\n" + + " \\\n" + + " /\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " (.)\n" + + " \\\n" + + " /\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " ...\n" + + " \\\n" + + " ,\n" + + " _|_\n" + + " ( )\n" + + " '-'", + }, + plant.Leafy: { + plant.Healthy: " (~~~)\n" + + " \\_\n" + + " _/\n" + + " \\\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " (~..)\n" + + " \\_\n" + + " _/\n" + + " \\\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " (. .)\n" + + " \\_\n" + + " ,\n" + + " \\\n" + + " _|_\n" + + " ( )\n" + + " '-'", + }, + plant.Tall: { + plant.Healthy: " (~~~~~)\n" + + " \\_/\n" + + " _/\n" + + " \\_\n" + + " \\\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " (~~..~)\n" + + " \\_/\n" + + " _/\n" + + " \\_\n" + + " \\\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " (. . .)\n" + + " \\_/\n" + + " ,/\n" + + " \\_\n" + + " \\\n" + + " _|_\n" + + " ( )\n" + + " '-'", + }, + plant.Blooming: { + plant.Healthy: " (*~~~*)\n" + + " \\_*_/\n" + + " _/\n" + + " \\_\n" + + " \\\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " (o~~~o)\n" + + " \\_o_/\n" + + " _/\n" + + " \\_\n" + + " \\\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " (. * .)\n" + + " \\_._/\n" + + " ,/\n" + + " \\_\n" + + " \\\n" + + " _|_\n" + + " ( )\n" + + " '-'", + }, +} + +// sunflowerArt is a tall single stalk that ends in a large round bloom as it +// matures -- showy and unmistakable at the blooming stage. +var sunflowerArt = map[plant.Stage]map[plant.Health]string{ + plant.Seed: { + plant.Healthy: " o\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " .\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " ,\n" + + " .\n" + + " _._\n" + + " ( )\n" + + " '-'", + }, + plant.Sprout: { + plant.Healthy: " (o)\n" + + " |\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " (.)\n" + + " |\n" + + " \\\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " ,.,\n" + + " \\\n" + + " .\n" + + " _|_\n" + + " ( )\n" + + " '-'", + }, + plant.Leafy: { + plant.Healthy: " \\(o)/\n" + + " |\n" + + " -|\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " .(o).\n" + + " |\n" + + " -|\n" + + " \\\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " .(.).\n" + + " \\\n" + + " .|\n" + + " .\n" + + " _|_\n" + + " ( )\n" + + " '-'", + }, + plant.Tall: { + plant.Healthy: " \\(oOo)/\n" + + " |\n" + + " -|-\n" + + " |\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " .(oOo).\n" + + " |\n" + + " -|-\n" + + " \\\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " .(. .).\n" + + " \\\n" + + " .|.\n" + + " .\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + }, + plant.Blooming: { + plant.Healthy: " \\(@O@)/\n" + + " \\|/\n" + + " -|-\n" + + " |\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Thirsty: " .(@O@).\n" + + " \\|/\n" + + " -|-\n" + + " \\\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + plant.Wilting: " .(o.o).\n" + + " .|.\n" + + " .|.\n" + + " .\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'", + }, +} diff --git a/internal/species/species.go b/internal/species/species.go new file mode 100644 index 0000000..deda62a --- /dev/null +++ b/internal/species/species.go @@ -0,0 +1,127 @@ +// Package species defines the pluggable "art set + personality" for a plant. +// +// A Species bundles two things: a full stage x health table of ASCII art, and a +// small set of tuning overrides that give the species a gameplay quirk (e.g. a +// cactus tolerates longer dry spells before it wilts). Everything here is pure +// data plus tiny lookups -- no I/O -- so it stays cheap to test and safe to +// depend on from both the pure plant brain and the render layer. +// +// The default species (Fern) intentionally reuses the original art the tool +// shipped with, so existing renders are byte-for-byte unchanged when no +// --species is chosen. +package species + +import "strings" + +// Kind identifies a species by a short, stable, lowercase name. The name is +// part of the CLI/config contract (it is what --species and the state file +// store) so keep the strings stable. +type Kind int + +const ( + // Fern is the default species: the original commit-sprout art, upright + // and leafy. Its zero value makes "unset" mean "fern", which keeps old + // state files and flag-less runs on the historical art. + Fern Kind = iota + // Cactus is the drought-hardy species: blocky, spiky art and a gameplay + // twist -- it tolerates noticeably longer commit gaps before it wilts. + Cactus + // Bonsai is the slow, sculptural species: a gnarled trunk with a compact + // canopy. + Bonsai + // Sunflower is the showy species: a tall stalk that ends in a big bloom. + Sunflower +) + +// String returns the stable lowercase name for the species. These names are +// the --species / config contract, so keep them stable. +func (k Kind) String() string { + switch k { + case Fern: + return "fern" + case Cactus: + return "cactus" + case Bonsai: + return "bonsai" + case Sunflower: + return "sunflower" + default: + return "fern" + } +} + +// Default is the species used when none is chosen (flag empty, config empty, +// or an unrecognized name). It is Fern so the historical art is the fallback. +const Default = Fern + +// Parse resolves a species name (case-insensitive, surrounding space trimmed) +// into a Kind. It returns ok=false for empty or unknown names so callers can +// distinguish "use the default" from "the user typed something valid". +func Parse(name string) (Kind, bool) { + switch strings.ToLower(strings.TrimSpace(name)) { + case "fern": + return Fern, true + case "cactus": + return Cactus, true + case "bonsai": + return Bonsai, true + case "sunflower": + return Sunflower, true + default: + return Default, false + } +} + +// All returns every species in a stable order, handy for help text, tests, and +// completeness checks. +func All() []Kind { + return []Kind{Fern, Cactus, Bonsai, Sunflower} +} + +// Names returns the stable name of every species, in the same order as All. +func Names() []string { + ks := All() + out := make([]string, len(ks)) + for i, k := range ks { + out[i] = k.String() + } + return out +} + +// Tuning is the per-species gameplay override for health (wilt) thresholds. +// The plant brain reads these instead of its own hard-coded constants so a +// species can, for example, survive droughts longer. Growth (stage/streak) +// tuning is deliberately left shared across species; only health tolerance +// varies today. +// +// The two fields mirror plant's own health thresholds: idle days at or below +// ThirstyAfterDays are Healthy, at or below WiltingAfterDays are Thirsty, and +// beyond that the plant Wilts. +type Tuning struct { + // ThirstyAfterDays is the last idle day count that is still Healthy. + ThirstyAfterDays int + // WiltingAfterDays is the last idle day count that is still Thirsty; + // beyond it the plant Wilts. + WiltingAfterDays int +} + +// tunings holds each species' health tuning. A species absent from this map +// uses the caller's shared defaults, so only species with a real twist need an +// entry. The cactus is the gameplay twist called out in the feature: it stays +// healthy and merely thirsty far longer than the leafier species before it +// finally wilts. +var tunings = map[Kind]Tuning{ + Cactus: { + ThirstyAfterDays: 4, // healthy for the better part of a week + WiltingAfterDays: 9, // only truly wilts after ~10 idle days + }, +} + +// TuningFor returns the health tuning for a species and whether a species +// override exists. When ok is false the caller should keep its shared default +// thresholds, so this reports "does this species bend the rules?" rather than +// silently substituting zeros. +func TuningFor(k Kind) (Tuning, bool) { + t, ok := tunings[k] + return t, ok +} diff --git a/internal/species/species_test.go b/internal/species/species_test.go new file mode 100644 index 0000000..40519e2 --- /dev/null +++ b/internal/species/species_test.go @@ -0,0 +1,130 @@ +package species + +import ( + "strings" + "testing" + + "github.com/rwrife/commit-sprout/internal/plant" +) + +// TestParseRoundTrip verifies every species name parses back to its Kind and +// that String/Parse are inverses for the canonical names. +func TestParseRoundTrip(t *testing.T) { + for _, k := range All() { + name := k.String() + got, ok := Parse(name) + if !ok { + t.Errorf("Parse(%q) reported not-ok for a canonical name", name) + } + if got != k { + t.Errorf("Parse(%q) = %v, want %v", name, got, k) + } + } +} + +// TestParseCaseAndSpace checks that parsing is case-insensitive and trims +// surrounding whitespace, matching how a user might type the flag. +func TestParseCaseAndSpace(t *testing.T) { + cases := map[string]Kind{ + " Cactus ": Cactus, + "FERN": Fern, + "BonSai": Bonsai, + "sunflower": Sunflower, + } + for in, want := range cases { + got, ok := Parse(in) + if !ok || got != want { + t.Errorf("Parse(%q) = (%v, %v), want (%v, true)", in, got, ok, want) + } + } +} + +// TestParseUnknownFallsBackToDefault ensures unknown/empty names report not-ok +// and return the default species rather than a garbage Kind. +func TestParseUnknownFallsBackToDefault(t *testing.T) { + for _, in := range []string{"", " ", "oak", "weed"} { + got, ok := Parse(in) + if ok { + t.Errorf("Parse(%q) unexpectedly reported ok", in) + } + if got != Default { + t.Errorf("Parse(%q) = %v, want default %v", in, got, Default) + } + } +} + +// TestArtCompleteForEverySpecies asserts every species has a non-empty frame +// for every (stage x health) combination the state machine can produce, so +// ArtFor never has to fall back and hide a gap. +func TestArtCompleteForEverySpecies(t *testing.T) { + stages := []plant.Stage{plant.Seed, plant.Sprout, plant.Leafy, plant.Tall, plant.Blooming} + healths := []plant.Health{plant.Healthy, plant.Thirsty, plant.Wilting} + + for _, k := range All() { + for _, s := range stages { + for _, h := range healths { + art := ArtFor(k, s, h) + if strings.TrimSpace(art) == "" { + t.Errorf("species %s stage %s health %s has empty art", k, s, h) + } + } + } + } +} + +// TestArtDiffersAcrossSpecies is a light guard that the species art sets are +// genuinely distinct and not accidental copies of the default. +func TestArtDiffersAcrossSpecies(t *testing.T) { + base := ArtFor(Fern, plant.Blooming, plant.Healthy) + for _, k := range []Kind{Cactus, Bonsai, Sunflower} { + if ArtFor(k, plant.Blooming, plant.Healthy) == base { + t.Errorf("species %s blooming/healthy art matches the fern; expected distinct art", k) + } + } +} + +// TestCactusToleratesLongerDrought is the gameplay-twist assertion: the cactus +// must have wilt thresholds strictly more forgiving than the shared defaults, +// so it survives droughts longer than the leafier species. +func TestCactusToleratesLongerDrought(t *testing.T) { + tune, ok := TuningFor(Cactus) + if !ok { + t.Fatal("cactus has no tuning override; expected a drought-hardy twist") + } + def := plant.DefaultHealthTuning() + if tune.WiltingAfterDays <= def.WiltingAfterDays { + t.Errorf("cactus WiltingAfterDays=%d not more forgiving than default=%d", + tune.WiltingAfterDays, def.WiltingAfterDays) + } + if tune.ThirstyAfterDays < def.ThirstyAfterDays { + t.Errorf("cactus ThirstyAfterDays=%d should be >= default=%d", + tune.ThirstyAfterDays, def.ThirstyAfterDays) + } +} + +// TestNonCactusUseSharedDefaults confirms only the cactus bends the wilt rules +// today; the other species report no override and thus keep shared thresholds. +func TestNonCactusUseSharedDefaults(t *testing.T) { + for _, k := range []Kind{Fern, Bonsai, Sunflower} { + if _, ok := TuningFor(k); ok { + t.Errorf("species %s unexpectedly has a tuning override", k) + } + } +} + +// TestFernArtIsOriginal pins the fern's blooming/healthy frame to the original +// commit-sprout art so the default species is a guaranteed no-op visual change. +func TestFernArtIsOriginal(t *testing.T) { + const original = " @ \\|/ @\n" + + " \\\\|//\n" + + " @--+--@\n" + + " /|\\\n" + + " |\n" + + " |\n" + + " _|_\n" + + " ( )\n" + + " '-'" + if got := ArtFor(Fern, plant.Blooming, plant.Healthy); got != original { + t.Errorf("fern blooming/healthy art drifted from the original:\n%s", got) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index cb81d73..07832f6 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -102,6 +102,13 @@ type State struct { // is persisted here (the garden is a read-only glance), which keeps this // additive and backward-compatible with existing state files. GardenRepos []string `json:"garden_repos,omitempty"` + + // Species is the remembered default species name (e.g. "cactus") set via + // `commit-sprout --species `. It is stored by its stable lowercase + // name so the file stays readable and resilient to enum reordering. Empty + // means "unset", which the caller resolves to the default species. It is + // additive and optional, so older state files load unchanged. + Species string `json:"species,omitempty"` } // MaxWateredDatesKept bounds how many watering dates are retained on disk. Only