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
2 changes: 1 addition & 1 deletion data/embed.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 14 additions & 0 deletions data/quiet_beats.json
Original file line number Diff line number Diff line change
@@ -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."
]
12 changes: 12 additions & 0 deletions docs/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 4 additions & 4 deletions internal/game/baseline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
},
}
Expand Down
11 changes: 11 additions & 0 deletions internal/game/colony_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
28 changes: 28 additions & 0 deletions internal/game/colony_log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
7 changes: 4 additions & 3 deletions internal/game/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion internal/game/day_balance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions internal/game/tick.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
5 changes: 3 additions & 2 deletions internal/game/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down