From 13919b3a52c899443eba94d52ce2be796ae2a246 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Fri, 22 May 2026 01:49:35 -0400 Subject: [PATCH] =?UTF-8?q?Scenario=20strategic=20identity=20=E2=80=94=20d?= =?UTF-8?q?istinct=20openers=20for=20Dust=20Season,=20Silent=20Colony,=20B?= =?UTF-8?q?eacon=20Rush=20(#98).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RunModifiers: add DailyPowerDelta field; Dust Season uses +3/day (rewards power buffer / repair spend) - Silent Colony: dailyCreditsIncomeDelta -8 (Radio Tower purchase is now a real economic unlock) - Beacon Rush: dailyCreditsIncomeDelta +7 (viable to skip Hydro L3 and rush beacon) - New scenario scripts: dust_season_power_buffer.json, silent_colony_radio_first.json - BaselineStrategy.ScenarioID field; TestBalanceBaseline uses SimulateRun for scenario strategies - Two new reference strategies in ReferenceStrategies(); all baselines pass all seeds - docs/balance.md §Scenarios — strategic identity section added Assisted-by: Crush:claude-sonnet-4-5 --- data/scenarios.json | 12 ++++---- docs/balance.md | 21 +++++++++++++ internal/game/baseline.go | 41 ++++++++++++++++++++++++++ internal/game/baseline_test.go | 9 +++++- internal/game/day_advance.go | 2 +- internal/game/scenario.go | 3 ++ internal/game/scenario_test.go | 41 ++++++++++++++++++++++++++ internal/game/types.go | 1 + scripts/dust_season_power_buffer.json | 16 ++++++++++ scripts/silent_colony_radio_first.json | 13 ++++++++ 10 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 scripts/dust_season_power_buffer.json create mode 100644 scripts/silent_colony_radio_first.json diff --git a/data/scenarios.json b/data/scenarios.json index 82fbcdf..b6ccee3 100644 --- a/data/scenarios.json +++ b/data/scenarios.json @@ -44,12 +44,12 @@ }, "maxBeaconParts": 5, "survivalWinAfterDay": 45, - "modifiers": { "eventGateSkipAbove": 52 } + "modifiers": { "eventGateSkipAbove": 52, "dailyPowerDelta": 3 } }, { "id": "silent_colony", "name": "Silent Colony", - "description": "Isolated start; trader signals need a Radio Tower on the air.", + "description": "Isolated start; trader signals need a Radio Tower on the air. Supply runs cost more — earn the antenna.", "starting": { "power": 60, "food": 58, @@ -59,12 +59,13 @@ "populationCap": 10 }, "maxBeaconParts": 5, - "survivalWinAfterDay": 45 + "survivalWinAfterDay": 45, + "modifiers": { "dailyCreditsIncomeDelta": -8 } }, { "id": "beacon_rush", "name": "Beacon Rush", - "description": "Finish the Signal Beacon in 3 parts instead of 5.", + "description": "Finish the Signal Beacon in 3 parts instead of 5. Supply runners are motivated — credits flow faster.", "starting": { "power": 65, "food": 60, @@ -74,7 +75,8 @@ "populationCap": 10 }, "maxBeaconParts": 3, - "survivalWinAfterDay": 45 + "survivalWinAfterDay": 45, + "modifiers": { "dailyCreditsIncomeDelta": 7 } } ], "difficulties": [ diff --git a/docs/balance.md b/docs/balance.md index a7bccd5..ebd5a31 100644 --- a/docs/balance.md +++ b/docs/balance.md @@ -79,3 +79,24 @@ Override the minimum locally with `MIN_GAME_COVERAGE=85 ./scripts/check-game-cov ## Building daily production `data/buildings.json` may include `dailyEffects` (per-level per day). `advanceDay` applies them **before** power/food upkeep and morale drift (`applyBuildingProduction` in `internal/game/tick.go`). One-time build bonuses stay in `effects`. + +## Scenarios — strategic identity + +Each non-standard scenario has a situational mechanic that rewards a distinct opener. + +| Scenario | Strategic hook | Good opener | +|----------|----------------|-------------| +| **Dust Season** | Extra `dailyPowerDelta: +3` drain from regolith storms; higher event frequency (`eventGateSkipAbove: 52`). Power buffer depletes 3× faster, and damaged buildings halve output. | Build solar array first, repair aggressively rather than saving credits. The `dust_season_power_buffer.json` script demonstrates this. | +| **Silent Colony** | `dailyCreditsIncomeDelta: -8` — supply runs arrive sporadically until the radio is on air. Credit flow is tight until a Radio Tower is built (which also unlocks `trader_arrives` events). | Radio Tower early, before a second hydro or solar upgrade. `silent_colony_radio_first.json` demonstrates the cost/benefit. | +| **Beacon Rush** | `maxBeaconParts: 3` and `dailyCreditsIncomeDelta: +7` — motivated supply runners. You can reach a win-beacon faster without grinding Hydro L3, because the credit income more than offsets skipping one food upgrade. | Skip Hydro/Solar L3 and divert credits to beacon work. The standard `beacon_rush.json` still validates this path. | +| **First Landing** | Onboarding cushion — extra credits and slightly better morale/food. No strategic hook; same viable openers as Standard Run. | + +### Scenario simulate scripts + +```bash +# Dust Season — solar-first, repair-heavy opener +outpost -simulate scripts/dust_season_power_buffer.json -seeds 1,7,42 + +# Silent Colony — radio tower early +outpost -simulate scripts/silent_colony_radio_first.json -seeds 1,7,42 +``` diff --git a/internal/game/baseline.go b/internal/game/baseline.go index 2047217..e847ef2 100644 --- a/internal/game/baseline.go +++ b/internal/game/baseline.go @@ -29,6 +29,7 @@ type BaselineOutcome struct { type BaselineStrategy struct { ID string ScriptFile string + ScenarioID string // empty = "standard" MinEndDay int RequireAlive bool MinBeaconParts int @@ -42,6 +43,8 @@ func ReferenceStrategies() []BaselineStrategy { noTradeSurvivalBaseline(), beaconRushBaseline(), survival45Baseline(), + dustSeasonPowerBufferBaseline(), + silentColonyRadioFirstBaseline(), } } @@ -136,6 +139,44 @@ func beaconRushBaseline() BaselineStrategy { } } +func dustSeasonPowerBufferBaseline() BaselineStrategy { + return BaselineStrategy{ + ID: "dust_season_power_buffer", + ScriptFile: "dust_season_power_buffer.json", + ScenarioID: "dust_season", + MinEndDay: 6, + RequireAlive: true, + Expected: map[int64]BaselineOutcome{ + 1: {Day: 6, GameOver: false, Won: false, BeaconParts: 0}, + 7: {Day: 6, GameOver: false, Won: false, BeaconParts: 0}, + 42: {Day: 6, GameOver: false, Won: false, BeaconParts: 0}, + 99: {Day: 6, GameOver: false, Won: false, BeaconParts: 0}, + 100: {Day: 6, GameOver: false, Won: false, BeaconParts: 0}, + 101: {Day: 6, GameOver: false, Won: false, BeaconParts: 0}, + 1779403310247544000: {Day: 6, GameOver: false, Won: false, BeaconParts: 0}, + }, + } +} + +func silentColonyRadioFirstBaseline() BaselineStrategy { + return BaselineStrategy{ + ID: "silent_colony_radio_first", + ScriptFile: "silent_colony_radio_first.json", + ScenarioID: "silent_colony", + MinEndDay: 6, + RequireAlive: true, + Expected: map[int64]BaselineOutcome{ + 1: {Day: 6, GameOver: false, Won: false, BeaconParts: 0}, + 7: {Day: 6, GameOver: false, Won: false, BeaconParts: 0}, + 42: {Day: 6, GameOver: false, Won: false, BeaconParts: 0}, + 99: {Day: 6, GameOver: false, Won: false, BeaconParts: 0}, + 100: {Day: 6, GameOver: false, Won: false, BeaconParts: 0}, + 101: {Day: 6, GameOver: false, Won: false, BeaconParts: 0}, + 1779403310247544000: {Day: 6, GameOver: false, Won: false, BeaconParts: 0}, + }, + } +} + func survival45Baseline() BaselineStrategy { winDay := SurvivalWinAfterDay + 1 win := BaselineOutcome{Day: winDay, GameOver: true, Won: true, BeaconParts: 0} diff --git a/internal/game/baseline_test.go b/internal/game/baseline_test.go index 9de513d..accb9f2 100644 --- a/internal/game/baseline_test.go +++ b/internal/game/baseline_test.go @@ -34,7 +34,14 @@ func TestBalanceBaseline_ReferenceStrategies(t *testing.T) { for _, seed := range ReferenceSeeds { t.Run(seedLabel(seed), func(t *testing.T) { - final, err := Simulate(content, seed, script.Actions) + var final State + var err error + if strategy.ScenarioID != "" { + setup := RunSetup{Seed: seed, ScenarioID: strategy.ScenarioID} + final, err = SimulateRun(content, setup, script.Actions) + } else { + final, err = Simulate(content, seed, script.Actions) + } if err != nil { t.Fatalf("Simulate: %v", err) } diff --git a/internal/game/day_advance.go b/internal/game/day_advance.go index 1f95852..273e3eb 100644 --- a/internal/game/day_advance.go +++ b/internal/game/day_advance.go @@ -11,7 +11,7 @@ func (s *State) advanceDay() { } func (s *State) applyDailyUpkeep() { - s.Power -= DailyPowerUpkeep(s.Population) + s.Power -= DailyPowerUpkeep(s.Population) + s.DailyPowerDelta s.Food -= DailyFoodUpkeep(s.Population) s.Credits += s.dailyCreditsIncome() } diff --git a/internal/game/scenario.go b/internal/game/scenario.go index dcecd57..f9a6a20 100644 --- a/internal/game/scenario.go +++ b/internal/game/scenario.go @@ -23,6 +23,7 @@ type StartingResources struct { type RunModifiers struct { EventGateSkipAbove int `json:"eventGateSkipAbove,omitempty"` DailyCreditsIncomeDelta int `json:"dailyCreditsIncomeDelta,omitempty"` + DailyPowerDelta int `json:"dailyPowerDelta,omitempty"` } // ScenarioDef is a selectable run profile from data/scenarios.json. @@ -133,6 +134,7 @@ func mergeRunModifiers(base, overlay RunModifiers) RunModifiers { out.EventGateSkipAbove = overlay.EventGateSkipAbove } out.DailyCreditsIncomeDelta += overlay.DailyCreditsIncomeDelta + out.DailyPowerDelta += overlay.DailyPowerDelta return out } @@ -217,6 +219,7 @@ func NewRun(content Content, profiles RunProfiles, seed int64, scenarioID, diffi s.EventGateSkipAbove = mods.EventGateSkipAbove } s.DailyCreditsIncomeDelta = mods.DailyCreditsIncomeDelta + s.DailyPowerDelta = mods.DailyPowerDelta s.Seed = seed s.rngDrawMods = nil diff --git a/internal/game/scenario_test.go b/internal/game/scenario_test.go index c2be602..6ec3af6 100644 --- a/internal/game/scenario_test.go +++ b/internal/game/scenario_test.go @@ -63,3 +63,44 @@ func testRunProfiles() RunProfiles { } return p } + +func TestDustSeason_DailyPowerDrainHigherThanStandard(t *testing.T) { + std := NewRun(testContent(), testRunProfiles(), 1, "standard", "normal") + dust := NewRun(testContent(), testRunProfiles(), 1, "dust_season", "normal") + if dust.DailyPowerDelta <= std.DailyPowerDelta { + t.Fatalf("dust_season DailyPowerDelta %d want > standard %d", dust.DailyPowerDelta, std.DailyPowerDelta) + } +} + +func TestSilentColony_LowerDailyCredits(t *testing.T) { + std := NewRun(testContent(), testRunProfiles(), 1, "standard", "normal") + silent := NewRun(testContent(), testRunProfiles(), 1, "silent_colony", "normal") + if silent.DailyCreditsIncomeDelta >= std.DailyCreditsIncomeDelta { + t.Fatalf("silent_colony DailyCreditsIncomeDelta %d want < standard %d", silent.DailyCreditsIncomeDelta, std.DailyCreditsIncomeDelta) + } +} + +func TestBeaconRush_HigherDailyCredits(t *testing.T) { + std := NewRun(testContent(), testRunProfiles(), 1, "standard", "normal") + br := NewRun(testContent(), testRunProfiles(), 1, "beacon_rush", "normal") + if br.DailyCreditsIncomeDelta <= std.DailyCreditsIncomeDelta { + t.Fatalf("beacon_rush DailyCreditsIncomeDelta %d want > standard %d", br.DailyCreditsIncomeDelta, std.DailyCreditsIncomeDelta) + } +} + +func TestDustSeason_PowerDrainAppliedEachDay(t *testing.T) { + std := NewRun(testContent(), testRunProfiles(), 1, "standard", "normal") + dust := NewRun(testContent(), testRunProfiles(), 1, "dust_season", "normal") + // remove rng to avoid event noise + std.rngDrawMods = []int{99, 99, 99} + dust.rngDrawMods = []int{99, 99, 99} + stdPower := std.Power + dustPower := dust.Power + std.NextDay() + dust.NextDay() + stdDrain := stdPower - std.Power + dustDrain := dustPower - dust.Power + if dustDrain <= stdDrain { + t.Fatalf("dust_season power drain %d want > standard %d", dustDrain, stdDrain) + } +} diff --git a/internal/game/types.go b/internal/game/types.go index 250c2d2..4e5a86a 100644 --- a/internal/game/types.go +++ b/internal/game/types.go @@ -72,6 +72,7 @@ type State struct { SurvivalWinAfterDay int EventGateSkipAbove int DailyCreditsIncomeDelta int + DailyPowerDelta int Content Content SessionLog Recorder Seed int64 diff --git a/scripts/dust_season_power_buffer.json b/scripts/dust_season_power_buffer.json new file mode 100644 index 0000000..46e8262 --- /dev/null +++ b/scripts/dust_season_power_buffer.json @@ -0,0 +1,16 @@ +{ + "scenario": "dust_season", + "description": "Dust Season opener: solar first for power buffer, repair aggressively, skip hydro upgrade until power is stable.", + "actions": [ + { "type": "build", "building_id": "solar_array" }, + { "type": "build", "building_id": "hydroponics" }, + { "type": "next_day" }, + { "type": "repair" }, + { "type": "next_day" }, + { "type": "repair" }, + { "type": "next_day" }, + { "type": "next_day" }, + { "type": "repair" }, + { "type": "next_day" } + ] +} diff --git a/scripts/silent_colony_radio_first.json b/scripts/silent_colony_radio_first.json new file mode 100644 index 0000000..e271eb9 --- /dev/null +++ b/scripts/silent_colony_radio_first.json @@ -0,0 +1,13 @@ +{ + "scenario": "silent_colony", + "description": "Silent Colony opener: radio tower early to unlock trader events and restore credit income before economy stalls.", + "actions": [ + { "type": "build", "building_id": "solar_array" }, + { "type": "next_day" }, + { "type": "next_day" }, + { "type": "next_day" }, + { "type": "build", "building_id": "radio_tower" }, + { "type": "next_day" }, + { "type": "next_day" } + ] +}