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) {