From 15b9b879c12dc74485a273af15bb0a576f94c9eb Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Fri, 22 May 2026 01:55:39 -0400 Subject: [PATCH] =?UTF-8?q?Colony=20log=20as=20primary=20aesthetic=20layer?= =?UTF-8?q?=20=E2=80=94=20quiet-day=20story=20beats=20(#97).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - data/quiet_beats.json: 12 telemetry lines for quiet days (embedded) - emitQuietBeat(): fires LogSystem line when no random event triggers on next_day - randomEventRollOccurs: negative EventGateSkipAbove = never fire (test helper) - Updated beacon_rush baseline outcomes (RNG draw shift from new quiet beat call) - docs/context.md: colony log design goal and quiet beats documented Assisted-by: Crush:claude-sonnet-4-5 --- data/embed.go | 2 +- data/quiet_beats.json | 14 ++++++++++++++ docs/context.md | 12 ++++++++++++ internal/game/baseline.go | 8 ++++---- internal/game/colony_log.go | 11 +++++++++++ internal/game/colony_log_test.go | 28 ++++++++++++++++++++++++++++ internal/game/content.go | 7 ++++--- internal/game/day_balance.go | 5 ++++- internal/game/tick.go | 3 +++ internal/game/types.go | 5 +++-- 10 files changed, 84 insertions(+), 11 deletions(-) create mode 100644 data/quiet_beats.json diff --git a/data/embed.go b/data/embed.go index 545bd79..cbceedd 100644 --- a/data/embed.go +++ b/data/embed.go @@ -4,5 +4,5 @@ import "embed" // Files holds default game content shipped in the binary. // -//go:embed buildings.json events.json scenarios.json +//go:embed buildings.json events.json scenarios.json quiet_beats.json var Files embed.FS diff --git a/data/quiet_beats.json b/data/quiet_beats.json new file mode 100644 index 0000000..f731864 --- /dev/null +++ b/data/quiet_beats.json @@ -0,0 +1,14 @@ +[ + "Systems nominal. Colonists on shift.", + "Dust readings normal. Beacon array holding steady.", + "Night cycle quiet. Power draw within range.", + "No anomalies logged. Supply stores unchanged.", + "Routine maintenance completed. No issues reported.", + "Temperature variance within tolerance. All clear.", + "Comms silence — nothing inbound. Colony pressing on.", + "Shift change. The next team knows what to do.", + "Diagnostics green across the board.", + "Morale steady. Rations distributed.", + "Water reclaim cycle completed. Reserves stable.", + "Solar output within forecast. Charging nominal." +] diff --git a/docs/context.md b/docs/context.md index ff6e11e..203fbf7 100644 --- a/docs/context.md +++ b/docs/context.md @@ -76,3 +76,15 @@ Examples: - achievements - save files - Steam-like terminal polish eventually, but keep scope small now + +## Colony log as primary narrative layer + +**Design goal:** the colony log is the game's voice. Every `next_day` should give the player at least one readable beat — an event, a milestone, a warning escalation, or a quiet telemetry line when nothing dramatic happened. Players should be able to answer "what happened yesterday?" from the log alone. + +### Quiet beats + +When no random event fires on a given day, a short neutral telemetry line is emitted (prefixed `·`, `LogSystem`). These are data-driven — drawn randomly from `data/quiet_beats.json`. They signal calm without being silent, and make the log feel alive even during uneventful stretches. + +### Layout principle + +The event log viewport should be at least as prominent as the resource stat panel in the TUI layout. Stats are scoreboard; the log is story. diff --git a/internal/game/baseline.go b/internal/game/baseline.go index e847ef2..009a181 100644 --- a/internal/game/baseline.go +++ b/internal/game/baseline.go @@ -129,11 +129,11 @@ func beaconRushBaseline() BaselineStrategy { MinBeaconParts: 2, Expected: map[int64]BaselineOutcome{ 1: {Day: 6, GameOver: false, Won: false, BeaconParts: 3}, - 7: {Day: 6, GameOver: false, Won: false, BeaconParts: 3}, - 42: {Day: 6, GameOver: false, Won: false, BeaconParts: 3}, - 99: {Day: 6, GameOver: false, Won: false, BeaconParts: 3}, + 7: {Day: 6, GameOver: false, Won: false, BeaconParts: 4}, + 42: {Day: 6, GameOver: false, Won: false, BeaconParts: 4}, + 99: {Day: 6, GameOver: false, Won: false, BeaconParts: 4}, 100: {Day: 6, GameOver: false, Won: false, BeaconParts: 3}, - 101: {Day: 6, GameOver: false, Won: false, BeaconParts: 4}, + 101: {Day: 6, GameOver: false, Won: false, BeaconParts: 3}, 1779403310247544000: {Day: 6, GameOver: false, Won: false, BeaconParts: 3}, }, } diff --git a/internal/game/colony_log.go b/internal/game/colony_log.go index c7dc5ac..f3f085e 100644 --- a/internal/game/colony_log.go +++ b/internal/game/colony_log.go @@ -117,6 +117,17 @@ func (s State) SessionSummary() []string { return lines } +// emitQuietBeat logs a telemetry line on days when no random event fires. +func (s *State) emitQuietBeat() { + beats := s.Content.QuietBeats + if len(beats) == 0 { + return + } + s.ensureRNG() + beat := beats[s.rngIntn(len(beats))] + s.AddLogKind(LogSystem, beat) +} + func collapseCause(s State) string { switch { case s.Power <= 0: diff --git a/internal/game/colony_log_test.go b/internal/game/colony_log_test.go index b3bfc23..5569f87 100644 --- a/internal/game/colony_log_test.go +++ b/internal/game/colony_log_test.go @@ -89,3 +89,31 @@ func logContainsAll(s string, parts ...string) bool { func joinStrings(parts []string) string { return strings.Join(parts, "\n") } + +func TestNextDay_QuietBeat_AppearsWhenNoEvent(t *testing.T) { + content, err := LoadEmbeddedContent() + if err != nil { + t.Fatalf("LoadEmbeddedContent: %v", err) + } + s := NewState(content) + // Set gate so event roll never fires (-1 = never, overrides fallback) + s.EventGateSkipAbove = -1 + logBefore := len(s.Log) + s.NextDay() + newCount := len(s.Log) - logBefore + if newCount <= 0 { + t.Fatal("no log lines added on quiet day") + } + // Newest lines are prepended; grab the new ones + added := s.Log[:newCount] + found := false + for _, line := range added { + if strings.HasPrefix(line, string(LogSystem)+" ") { + found = true + break + } + } + if !found { + t.Fatalf("no quiet beat (· prefix) in quiet-day log: %v", added) + } +} diff --git a/internal/game/content.go b/internal/game/content.go index 5d947ca..567cb57 100644 --- a/internal/game/content.go +++ b/internal/game/content.go @@ -25,7 +25,8 @@ func LoadContentFS(fsys fs.FS) (Content, error) { if err != nil { return Content{}, err } - return Content{Buildings: buildings, Events: events}, nil + quietBeats, _ := loadJSONFS[[]string](fsys, "quiet_beats.json") + return Content{Buildings: buildings, Events: events, QuietBeats: quietBeats}, nil } func LoadContent(dir string) (Content, error) { @@ -38,8 +39,8 @@ func LoadContent(dir string) (Content, error) { if err != nil { return Content{}, err } - - return Content{Buildings: buildings, Events: events}, nil + quietBeats, _ := loadJSON[[]string](filepath.Join(dir, "quiet_beats.json")) + return Content{Buildings: buildings, Events: events, QuietBeats: quietBeats}, nil } func loadJSON[T any](path string) (T, error) { diff --git a/internal/game/day_balance.go b/internal/game/day_balance.go index b197108..d817765 100644 --- a/internal/game/day_balance.go +++ b/internal/game/day_balance.go @@ -47,7 +47,10 @@ func RandomEventRollOccurs(roll int) bool { func (s State) randomEventRollOccurs(roll int) bool { limit := s.EventGateSkipAbove - if limit <= 0 { + if limit < 0 { + return false // explicitly disabled + } + if limit == 0 { limit = RandomEventRollSkipAbove } return roll <= limit diff --git a/internal/game/tick.go b/internal/game/tick.go index 340db4f..51eab41 100644 --- a/internal/game/tick.go +++ b/internal/game/tick.go @@ -21,6 +21,9 @@ func (s *State) nextDayWithDetail(replayDetail map[string]any) nextDayOutcome { if eventID == "" { eventID = s.TriggerRandomEvent() } + if eventID == "" { + s.emitQuietBeat() + } s.Clamp() return nextDayOutcome{ diff --git a/internal/game/types.go b/internal/game/types.go index 4e5a86a..5d2c003 100644 --- a/internal/game/types.go +++ b/internal/game/types.go @@ -35,8 +35,9 @@ type EventDef struct { } type Content struct { - Buildings []BuildingDef - Events []EventDef + Buildings []BuildingDef + Events []EventDef + QuietBeats []string } func (c Content) FindBuilding(id string) (BuildingDef, bool) {