From bb990dcdc5c393ebf4e22fa33199f948fdb17758 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Fri, 22 May 2026 01:28:30 -0400 Subject: [PATCH] Add edge-triggered win-path milestone colony log beats (#99). Survival pacing at days 15/30, imminent-win cues, and beacon emphasis lines fire once per run; MilestonesSeen persists in autosave. --- internal/game/actions.go | 1 + internal/game/autosave.go | 17 +++- internal/game/milestones.go | 100 ++++++++++++++++++++ internal/game/milestones_test.go | 157 +++++++++++++++++++++++++++++++ internal/game/types.go | 1 + 5 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 internal/game/milestones.go create mode 100644 internal/game/milestones_test.go diff --git a/internal/game/actions.go b/internal/game/actions.go index 5101fc6..1a3d41a 100644 --- a/internal/game/actions.go +++ b/internal/game/actions.go @@ -14,6 +14,7 @@ func (s *State) doAction(typ string, initial map[string]any, fn func(detail map[ s.CheckEnd() if !s.GameOver { s.syncWarnings() + s.syncMilestones() } s.recordAction(typ, detail, before, s.snapshot()) if typ == "next_day" && !s.GameOver { diff --git a/internal/game/autosave.go b/internal/game/autosave.go index 7b23d1a..d4d8f25 100644 --- a/internal/game/autosave.go +++ b/internal/game/autosave.go @@ -34,7 +34,8 @@ type AutosaveFile struct { GameOver bool `json:"game_over"` Won bool `json:"won"` Message string `json:"message,omitempty"` - WarningLevels map[string]int `json:"warning_levels,omitempty"` + WarningLevels map[string]int `json:"warning_levels,omitempty"` + MilestonesSeen map[string]bool `json:"milestones_seen,omitempty"` MinPowerSeen int `json:"min_power_seen,omitempty"` MinFoodSeen int `json:"min_food_seen,omitempty"` MinMoraleSeen int `json:"min_morale_seen,omitempty"` @@ -72,6 +73,12 @@ func (s *State) toAutosaveFile() AutosaveFile { for id, sev := range s.WarningLevels { warn[id] = int(sev) } + seen := make(map[string]bool, len(s.MilestonesSeen)) + for id, ok := range s.MilestonesSeen { + if ok { + seen[id] = true + } + } return AutosaveFile{ Version: AutosaveVersion, Day: s.Day, @@ -97,6 +104,7 @@ func (s *State) toAutosaveFile() AutosaveFile { Won: s.Won, Message: s.Message, WarningLevels: warn, + MilestonesSeen: seen, MinPowerSeen: s.MinPowerSeen, MinFoodSeen: s.MinFoodSeen, MinMoraleSeen: s.MinMoraleSeen, @@ -149,6 +157,12 @@ func stateFromAutosave(file AutosaveFile, content Content, profiles RunProfiles) for id, sev := range file.WarningLevels { warn[id] = WarningSeverity(sev) } + seen := make(map[string]bool, len(file.MilestonesSeen)) + for id, ok := range file.MilestonesSeen { + if ok { + seen[id] = true + } + } s := State{ Day: file.Day, Power: file.Power, @@ -174,6 +188,7 @@ func stateFromAutosave(file AutosaveFile, content Content, profiles RunProfiles) Won: file.Won, Message: file.Message, WarningLevels: warn, + MilestonesSeen: seen, MinPowerSeen: file.MinPowerSeen, MinFoodSeen: file.MinFoodSeen, MinMoraleSeen: file.MinMoraleSeen, diff --git a/internal/game/milestones.go b/internal/game/milestones.go new file mode 100644 index 0000000..10a091d --- /dev/null +++ b/internal/game/milestones.go @@ -0,0 +1,100 @@ +package game + +import "fmt" + +// Milestone IDs for edge-triggered colony log beats (see issue #99). +const ( + MilestoneIDDay15 = "day_15" + MilestoneIDDay30 = "day_30" + MilestoneIDSurvivalImminent = "survival_imminent" + MilestoneIDBeaconOneLeft = "beacon_one_left" +) + +func beaconEmphasisPart(max int) int { + if max >= 5 { + return 3 + } + if max <= 1 { + return 0 + } + return (max + 1) / 2 +} + +func milestoneBeaconEmphasisID(parts, max int) string { + return fmt.Sprintf("beacon_emphasis_%d_%d", parts, max) +} + +// syncMilestones edge-triggers milestone log lines for survival pacing and beacon progress. +func (s *State) syncMilestones() { + if s.GameOver { + return + } + if s.MilestonesSeen == nil { + s.MilestonesSeen = make(map[string]bool) + } + target := s.survivalWinAfterDay() + + for _, day := range []int{15, 30} { + if day > target { + continue + } + var id string + switch day { + case 15: + id = MilestoneIDDay15 + case 30: + id = MilestoneIDDay30 + default: + id = fmt.Sprintf("day_%d", day) + } + if s.Day == day { + s.emitMilestoneOnce(id, dayMilestoneMessage(day)) + } + } + + if s.Day == target { + s.emitMilestoneOnce(MilestoneIDSurvivalImminent, + fmt.Sprintf("Survival window closes tomorrow — hold vitals through day %d.", target+1)) + } + + if s.BeaconParts > 0 && s.BeaconParts == s.MaxBeaconParts-1 { + s.emitMilestoneOnce(MilestoneIDBeaconOneLeft, + fmt.Sprintf("One beacon part remains — finish %d/%d before collapse.", s.BeaconParts, s.MaxBeaconParts)) + } + + emph := beaconEmphasisPart(s.MaxBeaconParts) + if emph > 0 && s.BeaconParts == emph { + id := milestoneBeaconEmphasisID(s.BeaconParts, s.MaxBeaconParts) + s.emitMilestoneOnce(id, beaconEmphasisMessage(s.BeaconParts, s.MaxBeaconParts)) + } +} + +func (s *State) emitMilestoneOnce(id, message string) { + if s.MilestonesSeen[id] { + return + } + s.MilestonesSeen[id] = true + s.AddLogKind(LogMilestone, message) +} + +func dayMilestoneMessage(day int) string { + switch day { + case 15: + return "Day 15 — two weeks holding; the colony is still standing." + case 30: + return "Day 30 — a month out; beacon progress or endurance will decide the run." + default: + return fmt.Sprintf("Day %d — the colony endures.", day) + } +} + +func beaconEmphasisMessage(parts, max int) string { + switch { + case max >= 5 && parts == 3: + return "Beacon three-fifths charged — rescue signal strengthening." + case max == 3 && parts == 2: + return "Beacon two-thirds charged — one part left for rescue." + default: + return fmt.Sprintf("Beacon %d/%d — halfway to full signal.", parts, max) + } +} diff --git a/internal/game/milestones_test.go b/internal/game/milestones_test.go new file mode 100644 index 0000000..a4efe50 --- /dev/null +++ b/internal/game/milestones_test.go @@ -0,0 +1,157 @@ +package game + +import ( + "strings" + "testing" +) + +func milestoneLines(s State) []string { + var out []string + for _, line := range s.Log { + if strings.HasPrefix(line, "* ") { + out = append(out, line) + } + } + return out +} + +func countMilestoneContaining(s State, sub string) int { + n := 0 + for _, line := range milestoneLines(s) { + if strings.Contains(line, sub) { + n++ + } + } + return n +} + +func TestSyncMilestones_Day15OnNextDay(t *testing.T) { + s := newTestState() + s.Day = 14 + s.Power = 50 + s.Food = 50 + s.Morale = 50 + + s.NextDay() + + if s.Day != 15 { + t.Fatalf("Day = %d, want 15", s.Day) + } + if count := countMilestoneContaining(s, "Day 15"); count != 1 { + t.Fatalf("Day 15 milestone count = %d, want 1; milestones %v", count, milestoneLines(s)) + } +} + +func TestSyncMilestones_Day30OnNextDay(t *testing.T) { + s := newTestState() + s.Day = 29 + s.Power = 50 + s.Food = 50 + s.Morale = 50 + + s.NextDay() + + if s.Day != 30 { + t.Fatalf("Day = %d, want 30", s.Day) + } + if count := countMilestoneContaining(s, "Day 30"); count != 1 { + t.Fatalf("Day 30 milestone count = %d, want 1; milestones %v", count, milestoneLines(s)) + } +} + +func TestSyncMilestones_Day15NoDuplicateOnSecondNextDay(t *testing.T) { + s := newTestState() + s.Day = 14 + s.Power = 50 + s.Food = 50 + s.Morale = 50 + + s.NextDay() + before := countMilestoneContaining(s, "Day 15") + s.Power = 50 + s.Food = 50 + s.Morale = 50 + s.NextDay() + after := countMilestoneContaining(s, "Day 15") + if before != 1 || after != 1 { + t.Fatalf("Day 15 milestones before=%d after=%d, want 1 each", before, after) + } +} + +func TestSyncMilestones_BeaconPartAddsMilestoneNoDuplicateWithoutNewPart(t *testing.T) { + s := newTestState() + s.Power = 50 + s.Credits = 100 + + s.WorkOnBeacon() + if countMilestoneContaining(s, "Signal Beacon part completed") != 1 { + t.Fatalf("expected part-completed milestone, got %v", milestoneLines(s)) + } + + before := len(s.Log) + s.Power = 50 + s.Food = 50 + s.Morale = 50 + s.NextDay() + for _, line := range s.Log { + if strings.Contains(line, "Signal Beacon part completed") { + if len(s.Log) != before { + t.Fatal("unexpected duplicate beacon part milestone without new part") + } + } + } + if countMilestoneContaining(s, "Signal Beacon part completed") != 1 { + t.Fatalf("beacon part milestone count changed without new part") + } +} + +func TestSyncMilestones_BeaconEmphasisAtThreeOfFive(t *testing.T) { + s := newTestState() + s.Power = 50 + s.Credits = 200 + s.BeaconParts = 2 + + s.WorkOnBeacon() + + if s.BeaconParts != 3 { + t.Fatalf("BeaconParts = %d, want 3", s.BeaconParts) + } + if countMilestoneContaining(s, "three-fifths") != 1 { + t.Fatalf("expected three-fifths emphasis, got %v", milestoneLines(s)) + } +} + +func TestSyncMilestones_SurvivalImminentAtTargetDay(t *testing.T) { + s := newTestState() + s.Day = SurvivalWinAfterDay + s.Power = 50 + s.Food = 50 + s.Morale = 50 + + s.syncMilestones() + + if countMilestoneContaining(s, "day 46") != 1 { + t.Fatalf("expected survival imminent milestone, got %v", milestoneLines(s)) + } +} + +func TestNewRun_BeaconRush_EmphasisAtTwoOfThree(t *testing.T) { + content := testContent() + profiles, err := LoadEmbeddedRunProfiles() + if err != nil { + t.Skip("embedded run profiles unavailable") + } + s := NewRun(content, profiles, 1, "beacon_rush", "normal") + s.Power = 50 + s.Credits = 200 + s.BeaconParts = 1 + + s.WorkOnBeacon() + + if s.BeaconParts != 2 || s.MaxBeaconParts != 3 { + t.Fatalf("beacon %d/%d, want 2/3", s.BeaconParts, s.MaxBeaconParts) + } + if countMilestoneContaining(s, "two-thirds") != 1 { + t.Fatalf("expected two-thirds emphasis for beacon rush, got %v", milestoneLines(s)) + } +} diff --git a/internal/game/types.go b/internal/game/types.go index 9fd1d16..250c2d2 100644 --- a/internal/game/types.go +++ b/internal/game/types.go @@ -81,6 +81,7 @@ type State struct { Won bool Message string WarningLevels map[string]WarningSeverity + MilestonesSeen map[string]bool MinPowerSeen int MinFoodSeen int MinMoraleSeen int