Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions data/scenarios.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -74,7 +75,8 @@
"populationCap": 10
},
"maxBeaconParts": 3,
"survivalWinAfterDay": 45
"survivalWinAfterDay": 45,
"modifiers": { "dailyCreditsIncomeDelta": 7 }
}
],
"difficulties": [
Expand Down
21 changes: 21 additions & 0 deletions docs/balance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
41 changes: 41 additions & 0 deletions internal/game/baseline.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type BaselineOutcome struct {
type BaselineStrategy struct {
ID string
ScriptFile string
ScenarioID string // empty = "standard"
MinEndDay int
RequireAlive bool
MinBeaconParts int
Expand All @@ -42,6 +43,8 @@ func ReferenceStrategies() []BaselineStrategy {
noTradeSurvivalBaseline(),
beaconRushBaseline(),
survival45Baseline(),
dustSeasonPowerBufferBaseline(),
silentColonyRadioFirstBaseline(),
}
}

Expand Down Expand Up @@ -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}
Expand Down
9 changes: 8 additions & 1 deletion internal/game/baseline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/game/day_advance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
3 changes: 3 additions & 0 deletions internal/game/scenario.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -133,6 +134,7 @@ func mergeRunModifiers(base, overlay RunModifiers) RunModifiers {
out.EventGateSkipAbove = overlay.EventGateSkipAbove
}
out.DailyCreditsIncomeDelta += overlay.DailyCreditsIncomeDelta
out.DailyPowerDelta += overlay.DailyPowerDelta
return out
}

Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions internal/game/scenario_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
1 change: 1 addition & 0 deletions internal/game/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ type State struct {
SurvivalWinAfterDay int
EventGateSkipAbove int
DailyCreditsIncomeDelta int
DailyPowerDelta int
Content Content
SessionLog Recorder
Seed int64
Expand Down
16 changes: 16 additions & 0 deletions scripts/dust_season_power_buffer.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
13 changes: 13 additions & 0 deletions scripts/silent_colony_radio_first.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}