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
1 change: 1 addition & 0 deletions internal/game/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 16 additions & 1 deletion internal/game/autosave.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
100 changes: 100 additions & 0 deletions internal/game/milestones.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
157 changes: 157 additions & 0 deletions internal/game/milestones_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
1 change: 1 addition & 0 deletions internal/game/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down