From 82bede65a5dab62c64b6add7f81ea8468985cde0 Mon Sep 17 00:00:00 2001 From: Pip Date: Mon, 13 Jul 2026 15:34:02 +1000 Subject: [PATCH 1/6] =?UTF-8?q?feat(L1):=20month=20plan=20layer=20?= =?UTF-8?q?=E2=80=94=20Attention=20economy,=20crisp=20reserve,=20calendar?= =?UTF-8?q?=20badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0009 #612: introduce the MONTH as the plan cadence over the day-grained resolution tick (turn stays a workday; badge = calendar date, days-survived scoring unchanged). New MonthPlan (RefCounted): founder currency Attention (~20/month, Balance-driven), explicit reserve that evaporates crisply at month end (no banking), and duration-bearing queued strategic actions (nothing strategic resolves instantly). Composed onto GameState + serialized (L7). - Clock: month_index/is_month_boundary/month_ordinal/month_label helpers; annual_to_per_month; fix the L0 latent leap-day infinite loop (#620 note 2 — a date landing exactly on Feb 29 hung the rollover) across the 2017-2040 window. - HUD badge: retire 'Week 12 | ... | Day 3/5' (hung attention on the day tick, violating the guard rule) for the plan-month date badge. - Balance: attention.per_month, events.window_demand_budget(+endgame), unanswered_window_rep_penalty, delivery-tier/class defaults. Attention is introduced ALONGSIDE the legacy AP pool (L2 deletes AP and migrates cost dicts) — seam left clean. 20 new GUT tests (MonthPlan + Clock month/leap). Co-Authored-By: Claude Fable 5 --- godot/data/balance/defaults.json | 14 +- godot/scripts/core/clock.gd | 105 +++++++++++--- godot/scripts/core/game_state.gd | 36 +++-- godot/scripts/core/month_plan.gd | 167 +++++++++++++++++++++++ godot/scripts/core/month_plan.gd.uid | 1 + godot/tests/unit/test_clock_month.gd | 70 ++++++++++ godot/tests/unit/test_clock_month.gd.uid | 1 + godot/tests/unit/test_month_plan.gd | 122 +++++++++++++++++ godot/tests/unit/test_month_plan.gd.uid | 1 + 9 files changed, 488 insertions(+), 29 deletions(-) create mode 100644 godot/scripts/core/month_plan.gd create mode 100644 godot/scripts/core/month_plan.gd.uid create mode 100644 godot/tests/unit/test_clock_month.gd create mode 100644 godot/tests/unit/test_clock_month.gd.uid create mode 100644 godot/tests/unit/test_month_plan.gd create mode 100644 godot/tests/unit/test_month_plan.gd.uid diff --git a/godot/data/balance/defaults.json b/godot/data/balance/defaults.json index f23c67c4..eba7dd98 100644 --- a/godot/data/balance/defaults.json +++ b/godot/data/balance/defaults.json @@ -1,9 +1,19 @@ { "_description": "Central gameplay tunables (L9, issue #621). These are the shipped defaults — identical to the pre-L9 inline literals, so this file is behavior-neutral until a sweep varies it. Tune by editing here or by dropping a user://balance_overrides.json with the same shape (deep-merged on top). Consumers pass the same values as call-site fallbacks, so a missing file degrades safely.", "events": { - "_description": "Event pacing (#568): no events before first_event_turn; at most max_new_events_per_turn new events fire per turn (excess re-roll/re-evaluate later).", + "_description": "Event pacing. first_event_turn/max_new_events_per_turn are the pre-L1 day-tick throttle (#568) — kept for the resolution tick. The MONTH plan layer (ADR-0009/workshop#3) governs decision load via the window DEMAND budget below, not an information budget: only 'window'-tier events demand a decision. Tiers per genre live on the event definitions (delivery_tier: ambient|feed|window).", "first_event_turn": 2, - "max_new_events_per_turn": 2 + "max_new_events_per_turn": 2, + "default_delivery_tier": "feed", + "window_demand_budget": 3, + "window_demand_budget_endgame": 6, + "endgame_turn": 200, + "unanswered_window_rep_penalty": 2.0, + "default_event_class": "deferrable" + }, + "attention": { + "_description": "Founder currency (workshop#3 addendum #5 / ADR-0011): ~N decisions per PLAN MONTH; admin is painful overhead spent here. Staff spend a SEPARATE per-person 'actions' currency — there is no global pool (L2 deletes the legacy AP pool; L1 introduces Attention alongside it). Unspent reserve evaporates at month end (ADR-0009 §4 — no banking).", + "per_month": 20 }, "papers": { "_description": "Paper-decision pacing (#630 stopgap): at most max_decisions_per_turn paper accept/reject decisions surface per turn; clustered decision_turns otherwise flood a click-through wall. Surplus stays UNDER_REVIEW and resolves on later turns (never dropped). 0 = unlimited (pre-#630). Superseded by ADR-0009/0012/0014 response windows.", diff --git a/godot/scripts/core/clock.gd b/godot/scripts/core/clock.gd index 09c4c848..a6c0f5ca 100644 --- a/godot/scripts/core/clock.gd +++ b/godot/scripts/core/clock.gd @@ -25,13 +25,22 @@ static func weeks_to_turns(weeks: int) -> int: static func months_per_turn() -> float: """Calendar months represented by one turn. Fixed at 1.0 until the variable - game-length system lands (see docs/design/TWO_ACT_STRUCTURE.md).""" + game-length system lands (see docs/design/TWO_ACT_STRUCTURE.md). + + L1/ADR-0009 note: the resolution tick stays day-grained (turn = 1 workday); + the MONTH is the PLAN cadence, a layer above the tick (MonthPlan / Clock month + helpers below). Badge = calendar date, days-survived scoring stays fine-grained + (ADR-0009 §6). This value is unchanged so risk/doom calibration and recorded + replays are untouched by the month plan layer.""" return 1.0 static func annual_to_per_turn(annual_amount: float) -> float: """Convert an annual money magnitude (e.g. salary) to one turn's bill. - Turn = 1 workday, ~260 workdays/yr (#573: was /12, a month billed every day).""" + Turn = 1 workday, ~260 workdays/yr (#573: was /12, a month billed every day). + Billed per workday-turn, ~21.7 workdays/month, so a full month of billing already + sums to ~annual/12 — monthly payroll emerges from the tick grain (ADR-0009 + re-denomination is behaviour-neutral here; see MonthPlan doc).""" return annual_amount / WORKDAYS_PER_YEAR @@ -67,21 +76,24 @@ static func date_for_turn(turn: int, start_year: int, start_month: int, start_da var month = start_month var day = start_day + total_days - # Roll over months and years - while day > DAYS_IN_MONTH[month - 1]: - # Check for leap year February - var feb_days = 28 + # Roll over months and years. + # L0 leap-day fix (#620 note 2): the old loop condition compared `day` against the + # NON-leap DAYS_IN_MONTH[1]=28 while the body used a leap-adjusted month_days=29, so a + # date landing exactly on Feb 29 satisfied the while-condition (29>28) but never + # decremented (29>29 is false) — an infinite hang. The month-turn fiction window + # (2017-2040) crosses six Feb-29 dates, so this was latent→live. Compute the + # leap-adjusted length ONCE and use it in both the test and the decrement. + while true: + var month_days = DAYS_IN_MONTH[month - 1] if month == 2 and is_leap_year(year): - feb_days = 29 - - var month_days = DAYS_IN_MONTH[month - 1] if month != 2 else feb_days - - if day > month_days: - day -= month_days - month += 1 - if month > 12: - month = 1 - year += 1 + month_days = 29 + if day <= month_days: + break + day -= month_days + month += 1 + if month > 12: + month = 1 + year += 1 @warning_ignore("integer_division") var quarter = ((month - 1) / 3) + 1 @@ -99,3 +111,64 @@ static func date_for_turn(turn: int, start_year: int, start_month: int, start_da static func is_leap_year(year: int) -> bool: return (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0) + + +# ============================================================================ +# MONTH PLAN LAYER (L1 / ADR-0009) +# +# The MONTH is the plan cadence; the day-turn is the resolution tick beneath it. +# These helpers let the engine detect month boundaries (when a fresh plan phase +# opens and reserve evaporates) and label the badge without any consumer needing +# calendar arithmetic. All derive from date_for_turn — one source of truth. +# ============================================================================ + +const MONTH_NAMES := ["January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December"] +const MONTH_ABBR := ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + +const MONTHS_PER_YEAR: int = 12 + + +static func month_index(turn: int, start_year: int, start_month: int, start_day: int) -> int: + """Absolute month ordinal (year*12 + month-1) for the turn's calendar date. + Monotonic across year rollover, so `month_index(turn) != month_index(turn-1)` + is the month-boundary test. This is the plan-turn counter (ADR-0009: ~114-270 + of these to the loss anchors).""" + var d := date_for_turn(turn, start_year, start_month, start_day) + return int(d.year) * MONTHS_PER_YEAR + (int(d.month) - 1) + + +static func is_month_boundary(turn: int, start_year: int, start_month: int, start_day: int) -> bool: + """True when `turn` opens a new calendar month relative to `turn - 1` (a fresh + plan phase / reserve evaporation point). Turn 0 is treated as a boundary (the + first plan phase). Days are physics; the boundary is where routine decisions live.""" + if turn <= 0: + return true + return month_index(turn, start_year, start_month, start_day) \ + != month_index(turn - 1, start_year, start_month, start_day) + + +static func month_ordinal_since_start(turn: int, start_year: int, start_month: int, start_day: int) -> int: + """0-based count of plan-months elapsed since the run's start month. This is the + league/month plan-turn number that stamps the replay artifact (ADR-0016).""" + var start_idx := start_year * MONTHS_PER_YEAR + (start_month - 1) + return month_index(turn, start_year, start_month, start_day) - start_idx + + +static func month_name(turn: int, start_year: int, start_month: int, start_day: int) -> String: + var d := date_for_turn(turn, start_year, start_month, start_day) + return MONTH_NAMES[int(d.month) - 1] + + +static func month_label(turn: int, start_year: int, start_month: int, start_day: int) -> String: + """Badge label — the exact plan month, e.g. 'March 2034' (ADR-0009 §6).""" + var d := date_for_turn(turn, start_year, start_month, start_day) + return "%s %d" % [MONTH_NAMES[int(d.month) - 1], int(d.year)] + + +static func annual_to_per_month(annual_amount: float) -> float: + """Convert an annual magnitude to a single month's bill (annual/12). Provided for + consumers that bill once per plan-month; the per-workday payroll path already sums + to this over a month (see annual_to_per_turn).""" + return annual_amount / float(MONTHS_PER_YEAR) diff --git a/godot/scripts/core/game_state.gd b/godot/scripts/core/game_state.gd index 1dac08a1..fc6c438f 100644 --- a/godot/scripts/core/game_state.gd +++ b/godot/scripts/core/game_state.gd @@ -29,6 +29,11 @@ var governance: float = 50.0 # per game in reset(); compounding payables are the mortality guarantee (ADR-0002). var ledger: Ledger +# The month plan layer (L1 / ADR-0009): the founder currency Attention, the crisp reserve, +# and duration-bearing queued strategic actions. The plan cadence is monthly; the turn above +# is the day-grained resolution tick. Rebuilt per game in reset(). +var month_plan: MonthPlan + # EE-8 (ADR-0012): chronological CONTRIBUTING-CAUSE log for root-cause death # attribution. Ledger defaults, governance deficits, secret exposures, rep collapse # and funding starvation are appended here with turn stamps, so a death can be traced @@ -185,6 +190,10 @@ func reset(): stationery = Balance.num("starting_resources.stationery", 100.0) governance = Balance.num("starting_resources.governance", 50.0) ledger = Ledger.new() # ADR-0003: fresh ledger per game + # L1/ADR-0009: fresh month plan, opened with the first month's Attention grant. The plan + # month ordinal is derived from the calendar (turn 0 -> ordinal 0). + month_plan = MonthPlan.new() + month_plan.begin_month(Balance.inum("attention.per_month", 20), 0) cause_log.clear() # EE-8: fresh attribution trail per game rep_collapse_noted = false funding_starvation_noted = false @@ -677,19 +686,18 @@ func get_formatted_date() -> String: return "%s %d, %d" % [month_names[date.month - 1], date.day, date.year] func get_turn_display() -> String: - """Get the full turn display string for UI""" + """Badge/HUD string. ADR-0009 §1-2/§6: the plan cadence is the MONTH and the badge + is the calendar date; the day is a resolution tick, not a decision unit. The old + 'Week 12 | ... | Day 3/5' framing hung the player's attention on the day tick and is + retired — routine decisions live at the month boundary now. Primary is the plan month; + the calendar day rides along as playback progress, not a decision counter.""" var date = get_current_date() - var month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - # Format: "Week 12 | Wed Mar 20, 2024 | Day 3/5" - return "Week %d | %s %s %d, %d | Day %d/%d" % [ - date.week_number, - date.weekday.substr(0, 3), # Mon, Tue, Wed, etc. - month_names[date.month - 1], - date.day, + # e.g. "March 2034 · Mar 20" — month is the plan unit, date is the badge. + return "%s %d · %s %d" % [ + Clock.MONTH_NAMES[date.month - 1], date.year, - date.day_of_week, - Clock.TURNS_PER_WEEK + Clock.MONTH_ABBR[date.month - 1], + date.day ] func record_doom_history() -> void: @@ -813,6 +821,7 @@ func to_dict() -> Dictionary: "reputation": reputation, "governance": governance, "ledger": ledger.to_dict() if ledger else {}, + "month_plan": month_plan.to_dict() if month_plan else {}, # L1/ADR-0009 Attention + reserve + WIP "cause_log": cause_log.duplicate(true), # EE-8 attribution trail "doom": doom, "doom_history": doom_history.duplicate(), # #512 trend graph @@ -972,6 +981,11 @@ func from_dict(data: Dictionary) -> void: ledger = Ledger.new() ledger.from_dict(data.get("ledger", {})) + # Restore the month plan layer (L1/ADR-0009: Attention, reserve, in-flight strategic WIP) + if month_plan == null: + month_plan = MonthPlan.new() + month_plan.from_dict(data.get("month_plan", {})) + # Restore doom system if doom_system and data.has("doom_system"): doom_system.from_dict(data["doom_system"]) diff --git a/godot/scripts/core/month_plan.gd b/godot/scripts/core/month_plan.gd new file mode 100644 index 00000000..4275e2e2 --- /dev/null +++ b/godot/scripts/core/month_plan.gd @@ -0,0 +1,167 @@ +class_name MonthPlan +extends RefCounted +## The month plan layer (L1 / ADR-0009). The plan turn is a MONTH; the day-turn is the +## resolution tick beneath it (GameState.turn keeps counting workday ticks — this object +## layers the monthly decision cadence on top without re-grainng the sim substrate). +## +## Holds the founder currency **Attention** (workshop#3 addendum #5): ~N decisions/month, +## admin as painful overhead. Staff spend a SEPARATE per-person `actions` currency — there +## is no global pool here (this is NOT the legacy AP pool; L2 deletes that and migrates cost +## dicts onto Attention/staff — L1 introduces Attention alongside, seam left clean). +## +## Attention splits three ways within a month: +## available — free to fund queued strategic actions (plan speed) +## reserved — explicitly set aside at plan time for response windows (instant speed); +## this is ADR-0009's CRISP reserve — the gamble that makes windows interlock +## spent — already committed to queued strategic work +## Unspent reserve EVAPORATES at month end (ADR-0009 §4 — no banking, ever): begin_month() +## resets the pools, discarding any carry. +## +## Strategic actions carry DURATIONS (ADR-0009 §5 — nothing strategic resolves instantly); +## queued items land on a future resolution tick. This is the seam L2 workstreams extend. + +# --- Attention accounting (all integer Attention units) --- +var attention_total: int = 0 # granted this plan-month (Balance attention.per_month) +var attention_spent: int = 0 # committed to queued strategic actions +var attention_reserved: int = 0 # explicitly held for response windows (the crisp reserve) +var reserve_used: int = 0 # reserve consumed by HANDLE-from-reserve this month + +# Which plan-month this is (0-based from run start) — stamps the replay artifact (ADR-0016). +var month_ordinal: int = 0 + +# Queued strategic actions with durations. Each entry: +# {action_id: String, attention_cost: int, resolves_on_turn: int, queued_on_turn: int} +# Nothing resolves instantly — the MonthController lands these when state.turn reaches +# resolves_on_turn (mid-period or at month review). +var queued_strategic: Array = [] + + +func begin_month(attention_per_month: int, ordinal: int) -> void: + """Open a fresh plan phase. Crisp reserve evaporation happens HERE by construction: + the pools reset, so last month's unspent reserve is simply gone (ADR-0009 §4).""" + attention_total = attention_per_month + attention_spent = 0 + attention_reserved = 0 + reserve_used = 0 + month_ordinal = ordinal + # In-flight strategic actions persist across the boundary (they have durations); + # resolved ones are pruned by the controller, not here. + + +func available() -> int: + """Attention free to fund new plan-speed commitments (not spent, not reserved).""" + return attention_total - attention_spent - attention_reserved + + +func reserve_remaining() -> int: + """Reserve still available for response windows this month.""" + return attention_reserved - reserve_used + + +func set_reserve(amount: int) -> bool: + """Explicitly hold `amount` Attention for response windows (plan-time decision). + Can raise or lower the reserve as long as it stays within what is unspent and what + has already been drawn from reserve (reserve_used) this month.""" + if amount < reserve_used: + return false # can't reserve less than already drawn from reserve + # The new reserve must fit within total minus what's spent on strategic work. + if amount > attention_total - attention_spent: + return false + attention_reserved = amount + return true + + +func can_queue(attention_cost: int) -> bool: + return available() >= attention_cost + + +func queue_strategic(action_id: String, attention_cost: int, duration_ticks: int, current_turn: int) -> bool: + """Queue a strategic action at plan speed. Spends Attention now; the EFFECT lands + `duration_ticks` resolution ticks later (ADR-0009 §5). duration_ticks <= 0 is coerced + to 1 — nothing strategic resolves on the same tick it was queued.""" + if not can_queue(attention_cost): + return false + var ticks: int = max(1, duration_ticks) + attention_spent += attention_cost + queued_strategic.append({ + "action_id": action_id, + "attention_cost": attention_cost, + "resolves_on_turn": current_turn + ticks, + "queued_on_turn": current_turn, + }) + return true + + +func take_due_strategic(current_turn: int) -> Array: + """Pop and return strategic items whose duration has elapsed (resolves_on_turn <= + current_turn). The caller applies their effects on this tick.""" + var due: Array = [] + var still_pending: Array = [] + for item in queued_strategic: + if int(item.get("resolves_on_turn", 0)) <= current_turn: + due.append(item) + else: + still_pending.append(item) + queued_strategic = still_pending + return due + + +# --- Response-window payment sources (ADR-0009 §3) --- + +func pay_from_reserve(cost: int) -> bool: + """HANDLE from reserve — painless, what the reserve gamble was for. Draws from the + explicitly-held reserve pool only.""" + if reserve_remaining() < cost: + return false + reserve_used += cost + return true + + +func pay_by_cannibalizing(cost: int) -> Dictionary: + """HANDLE by cannibalizing — pay a window out of un-reserved capacity, and if that is + short, DELAY/KILL planned WIP to free the Attention it holds (ADR-0009 §3). Returns + {paid: bool, cancelled: Array[String]} listing action_ids sacrificed (LIFO).""" + var cancelled: Array = [] + var free: int = available() + # Cancel most-recent queued strategic WIP until we can cover the cost from free Attention. + while free < cost and not queued_strategic.is_empty(): + var victim: Dictionary = queued_strategic.pop_back() + attention_spent -= int(victim.get("attention_cost", 0)) + cancelled.append(String(victim.get("action_id", ""))) + free = available() + if free < cost: + # Roll back nothing was actually spent; report failure. (attention_spent already + # reflects the cancellations, which is correct — cancelling WIP is a real refund.) + return {"paid": false, "cancelled": cancelled} + attention_spent += cost # the window consumes this much of the freed/available capacity + return {"paid": true, "cancelled": cancelled} + + +# --- Serialization (L7 save/load convention) --- + +func to_dict() -> Dictionary: + return { + "attention_total": attention_total, + "attention_spent": attention_spent, + "attention_reserved": attention_reserved, + "reserve_used": reserve_used, + "month_ordinal": month_ordinal, + "queued_strategic": queued_strategic.duplicate(true), + } + + +func from_dict(data: Dictionary) -> void: + attention_total = int(data.get("attention_total", 0)) + attention_spent = int(data.get("attention_spent", 0)) + attention_reserved = int(data.get("attention_reserved", 0)) + reserve_used = int(data.get("reserve_used", 0)) + month_ordinal = int(data.get("month_ordinal", 0)) + queued_strategic = [] + for item in data.get("queued_strategic", []): + if item is Dictionary: + var c: Dictionary = item.duplicate(true) + c["attention_cost"] = int(c.get("attention_cost", 0)) + c["resolves_on_turn"] = int(c.get("resolves_on_turn", 0)) + c["queued_on_turn"] = int(c.get("queued_on_turn", 0)) + c["action_id"] = String(c.get("action_id", "")) + queued_strategic.append(c) diff --git a/godot/scripts/core/month_plan.gd.uid b/godot/scripts/core/month_plan.gd.uid new file mode 100644 index 00000000..e12302af --- /dev/null +++ b/godot/scripts/core/month_plan.gd.uid @@ -0,0 +1 @@ +uid://ct1t3vu50c5wl diff --git a/godot/tests/unit/test_clock_month.gd b/godot/tests/unit/test_clock_month.gd new file mode 100644 index 00000000..4c1f70c0 --- /dev/null +++ b/godot/tests/unit/test_clock_month.gd @@ -0,0 +1,70 @@ +extends GutTest +## L1 (#612 / ADR-0009): Clock month-plan helpers + the L0 leap-day fix (#620 note 2). + +# --- Leap-day infinite-loop fix ------------------------------------------------- +# The old date_for_turn compared `day` against the NON-leap DAYS_IN_MONTH[1]=28 in the +# while-condition while the body used a leap-adjusted 29, so a date landing exactly on +# Feb 29 entered the loop but never decremented — an infinite hang. If this regresses, +# these two calls hang the whole suite (a hard, if blunt, guard). + +func test_date_landing_exactly_on_feb29_does_not_hang(): + # start 2020-02-01 (leap), turn 20 -> total_days 28 -> day 29, month 2. This is the + # EXACT condition that hung the old loop (29 > 28 true, 29 > 29 false -> no progress). + var d := Clock.date_for_turn(20, 2020, 2, 1) + assert_eq(int(d.month), 2, "lands in February") + assert_eq(int(d.day), 29, "lands exactly on the leap day without hanging") + + +func test_date_rolls_past_feb29_into_march(): + var d := Clock.date_for_turn(21, 2020, 2, 1) # one workday past -> March 1 + assert_eq(int(d.month), 3, "rolls over the leap February") + assert_eq(int(d.day), 1, "into March 1") + + +func test_all_dates_valid_across_the_fiction_window(): + # Sweep the whole 2017->2040 window at the day grain; every date must be well-formed. + for turn in range(0, 3000, 7): + var d := Clock.date_for_turn(turn, 2017, 7, 3) + var m := int(d.month) + assert_between(m, 1, 12, "month in range at turn %d" % turn) + var month_len: int = Clock.DAYS_IN_MONTH[m - 1] + if m == 2 and Clock.is_leap_year(int(d.year)): + month_len = 29 + assert_between(int(d.day), 1, month_len, "day within month length at turn %d" % turn) + + +# --- Month plan-layer helpers --------------------------------------------------- + +func test_month_index_is_monotonic_and_detects_boundaries(): + var prev := Clock.month_index(0, 2017, 7, 3) + var boundaries := 0 + for turn in range(1, 400): + var idx := Clock.month_index(turn, 2017, 7, 3) + assert_true(idx >= prev, "month index never decreases (turn %d)" % turn) + if idx != prev: + boundaries += 1 + assert_true(Clock.is_month_boundary(turn, 2017, 7, 3), + "a change in month index IS a month boundary (turn %d)" % turn) + prev = idx + assert_true(boundaries >= 10, "several month boundaries occur over 400 workday ticks") + + +func test_turn_zero_is_a_boundary(): + assert_true(Clock.is_month_boundary(0, 2017, 7, 3), "turn 0 opens the first plan phase") + + +func test_month_ordinal_counts_plan_months_from_start(): + assert_eq(Clock.month_ordinal_since_start(0, 2017, 7, 3), 0, "run starts at plan-month 0") + # A turn a couple of calendar months in should read ordinal >= 1. + var later := Clock.month_ordinal_since_start(60, 2017, 7, 3) + assert_true(later >= 1, "ordinal advances with calendar months") + + +func test_month_label_is_the_badge_date(): + var label := Clock.month_label(0, 2017, 7, 3) + assert_string_contains(label, "July", "badge names the plan month") + assert_string_contains(label, "2017", "badge carries the year") + + +func test_annual_to_per_month_is_twelfth(): + assert_almost_eq(Clock.annual_to_per_month(120000.0), 10000.0, 0.01, "annual/12 per month") diff --git a/godot/tests/unit/test_clock_month.gd.uid b/godot/tests/unit/test_clock_month.gd.uid new file mode 100644 index 00000000..54637813 --- /dev/null +++ b/godot/tests/unit/test_clock_month.gd.uid @@ -0,0 +1 @@ +uid://qcadf6lp3m32 diff --git a/godot/tests/unit/test_month_plan.gd b/godot/tests/unit/test_month_plan.gd new file mode 100644 index 00000000..c7cb1963 --- /dev/null +++ b/godot/tests/unit/test_month_plan.gd @@ -0,0 +1,122 @@ +extends GutTest +## L1 (#612 / ADR-0009): the month plan layer — Attention economy, the crisp reserve, +## strategic-action durations, and window payment sources. + +func _plan(total: int = 20) -> MonthPlan: + var p := MonthPlan.new() + p.begin_month(total, 0) + return p + + +func test_begin_month_grants_attention(): + var p := _plan(20) + assert_eq(p.attention_total, 20, "month opens with the Attention grant") + assert_eq(p.available(), 20, "all of it is available before anything is committed") + assert_eq(p.reserve_remaining(), 0, "no reserve held until the player sets it") + + +func test_queue_spends_available_attention(): + var p := _plan(20) + assert_true(p.queue_strategic("fundraise", 5, 3, 10), "queue succeeds within budget") + assert_eq(p.available(), 15, "queuing spends Attention") + assert_eq(p.queued_strategic.size(), 1, "the item is recorded") + + +func test_cannot_overspend_available(): + var p := _plan(4) + assert_false(p.queue_strategic("big", 5, 2, 1), "cannot queue beyond available Attention") + assert_eq(p.available(), 4, "a rejected queue spends nothing") + + +func test_strategic_actions_have_duration_and_never_resolve_instantly(): + var p := _plan(20) + p.queue_strategic("hire_search", 3, 4, 10) # queued on turn 10, 4-tick duration + assert_eq(p.take_due_strategic(10).size(), 0, "nothing resolves on the tick it was queued") + assert_eq(p.take_due_strategic(13).size(), 0, "nor before the duration elapses") + var due := p.take_due_strategic(14) + assert_eq(due.size(), 1, "it lands after its duration") + assert_eq(String(due[0].action_id), "hire_search") + assert_eq(p.queued_strategic.size(), 0, "resolved items are removed") + + +func test_zero_duration_coerced_to_one_tick(): + var p := _plan(20) + p.queue_strategic("instant_attempt", 1, 0, 5) + assert_eq(p.take_due_strategic(5).size(), 0, "even duration 0 does not resolve same-tick") + assert_eq(p.take_due_strategic(6).size(), 1, "it resolves on the next tick") + + +func test_reserve_is_explicit_and_bounded(): + var p := _plan(20) + assert_true(p.set_reserve(6), "player holds 6 for windows") + assert_eq(p.reserve_remaining(), 6, "reserve is held") + assert_eq(p.available(), 14, "reserve is not available for plan work") + assert_false(p.set_reserve(25), "cannot reserve more than exists") + assert_eq(p.reserve_remaining(), 6, "a rejected reserve change leaves it untouched") + + +func test_reserve_and_plan_compete_for_the_same_pool(): + var p := _plan(10) + p.set_reserve(4) + assert_false(p.queue_strategic("x", 8, 2, 1), "can't queue into reserved Attention") + assert_true(p.queue_strategic("x", 6, 2, 1), "can queue up to the un-reserved remainder") + + +func test_reserve_evaporates_at_month_end_no_banking(): + # ADR-0009 §4: unspent reserve does NOT carry. begin_month resets the pools. + var p := _plan(20) + p.set_reserve(10) + p.pay_from_reserve(2) # spent 2 of the 10 + assert_eq(p.reserve_remaining(), 8, "8 reserve unspent this month") + p.begin_month(20, 1) # next month opens + assert_eq(p.reserve_remaining(), 0, "the unspent 8 evaporated — no banking") + assert_eq(p.available(), 20, "next month is a fresh full grant") + assert_eq(p.month_ordinal, 1, "ordinal advanced") + + +func test_pay_from_reserve_draws_only_reserve(): + var p := _plan(20) + p.set_reserve(3) + assert_true(p.pay_from_reserve(3), "reserve covers the window") + assert_eq(p.reserve_remaining(), 0, "reserve consumed") + assert_false(p.pay_from_reserve(1), "no reserve left to draw") + + +func test_cannibalize_eats_free_capacity_then_kills_wip(): + var p := _plan(10) + p.queue_strategic("wip_a", 4, 3, 1) # spent 4 + p.set_reserve(0) + # available now 6. A window costing 3 pays from free capacity, no WIP killed. + var r1 := p.pay_by_cannibalizing(3) + assert_true(r1.paid, "free capacity covers a small window") + assert_eq((r1.cancelled as Array).size(), 0, "no WIP sacrificed when free capacity suffices") + # available now 3, WIP still queued (cost 4). A window costing 5 must kill the WIP. + var r2 := p.pay_by_cannibalizing(5) + assert_true(r2.paid, "cannibalizing WIP frees enough Attention") + assert_true((r2.cancelled as Array).has("wip_a"), "the planned WIP was sacrificed") + assert_eq(p.queued_strategic.size(), 0, "the killed WIP is gone") + + +func test_cannibalize_fails_when_even_killing_all_wip_is_short(): + var p := _plan(3) + var r := p.pay_by_cannibalizing(9) + assert_false(r.paid, "a window bigger than the whole month cannot be cannibalized") + + +func test_serialization_round_trips(): + var p := _plan(20) + p.set_reserve(5) + p.pay_from_reserve(2) + p.queue_strategic("workstream", 4, 6, 12) + var restored := MonthPlan.new() + # JSON hop to mimic the real save path (untyped floats/arrays come back). + var json_str := JSON.stringify(p.to_dict()) + restored.from_dict(JSON.parse_string(json_str)) + assert_eq(restored.attention_total, p.attention_total) + assert_eq(restored.attention_spent, p.attention_spent) + assert_eq(restored.attention_reserved, p.attention_reserved) + assert_eq(restored.reserve_used, p.reserve_used) + assert_eq(restored.available(), p.available(), "available Attention survives the round trip") + assert_eq(restored.reserve_remaining(), p.reserve_remaining(), "reserve survives") + assert_eq(restored.queued_strategic.size(), 1, "in-flight WIP survives") + assert_eq(int(restored.queued_strategic[0].resolves_on_turn), 18, "duration timing preserved") diff --git a/godot/tests/unit/test_month_plan.gd.uid b/godot/tests/unit/test_month_plan.gd.uid new file mode 100644 index 00000000..badeef60 --- /dev/null +++ b/godot/tests/unit/test_month_plan.gd.uid @@ -0,0 +1 @@ +uid://rh65qm6c11j8 From 2863f0871580816d6006717bfed2d869b3d9d3df Mon Sep 17 00:00:00 2001 From: Pip Date: Mon, 13 Jul 2026 15:35:04 +1000 Subject: [PATCH 2/6] feat(L1): event delivery tiers + response-window resolution + replay schema bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workshop#3 addendum #1-2, ADR-0009 §3, ADR-0012. The #630 flood fix made structural: every event genre is classified ambient / feed / window; only WINDOWS demand a decision, so the flood ceiling becomes a demand budget, not an information budget. - EventTiers: delivery-tier + event-class classifier (un-snoozable/deferrable/ standing/no-action), source_id provenance, legal-response derivation, and a partition() that splits a fired-event stream into the three tiers. - WindowResolver: the costed menu — HANDLE-from-reserve / HANDLE-by-cannibalizing (delays/kills planned WIP) / DEFER (mints a Ledger entry, deferrable class only) / IGNORE (list price). Unanswered windows auto-resolve as IGNORE + a mild, data-driven rep penalty; unignorable windows refuse DEFER and auto-ignore. Attention (via MonthPlan) is the window currency; legacy AP costs stripped. Routes doom only through option effects + ledger factories — ADR-0015 seam clean. - VerificationTracker: record_window_response appends {t,k:'w',ev,resp,pay} — the schema bump carrying payment_source. Replay artifact stamps the ADR-0016 league id beside seed+version. Format tag held at v1 (additive; unknown keys ignored). 15 new GUT tests. No regressions (70/70 touched suites). Co-Authored-By: Claude Fable 5 --- godot/autoload/verification_tracker.gd | 31 ++- godot/scripts/core/event_tiers.gd | 121 ++++++++++++ godot/scripts/core/event_tiers.gd.uid | 1 + godot/scripts/core/window_resolver.gd | 189 +++++++++++++++++++ godot/scripts/core/window_resolver.gd.uid | 1 + godot/tests/unit/test_event_tiers.gd | 55 ++++++ godot/tests/unit/test_event_tiers.gd.uid | 1 + godot/tests/unit/test_window_resolver.gd | 100 ++++++++++ godot/tests/unit/test_window_resolver.gd.uid | 1 + 9 files changed, 499 insertions(+), 1 deletion(-) create mode 100644 godot/scripts/core/event_tiers.gd create mode 100644 godot/scripts/core/event_tiers.gd.uid create mode 100644 godot/scripts/core/window_resolver.gd create mode 100644 godot/scripts/core/window_resolver.gd.uid create mode 100644 godot/tests/unit/test_event_tiers.gd create mode 100644 godot/tests/unit/test_event_tiers.gd.uid create mode 100644 godot/tests/unit/test_window_resolver.gd create mode 100644 godot/tests/unit/test_window_resolver.gd.uid diff --git a/godot/autoload/verification_tracker.gd b/godot/autoload/verification_tracker.gd index f4332b1e..8844749d 100644 --- a/godot/autoload/verification_tracker.gd +++ b/godot/autoload/verification_tracker.gd @@ -53,8 +53,13 @@ var replay_log: Array = [] # producer never emitted the `schedule` key replay_simulator.gd reads). var event_schedule: Array = [] +# ADR-0016 league metabolism: a run belongs to a monthly LEAGUE (the real-world month the +# baseline seed represents). L1 stamps it alongside (seed, game_version) so the artifact +# carries which league it was produced in — cross-version/cross-league boards (DQ-3) read it. +var league_id: String = "" -func start_tracking(seed: String, version: String = "unknown", schedule: Array = []): + +func start_tracking(seed: String, version: String = "unknown", schedule: Array = [], league: String = ""): """ Initialize verification hash for new game. @@ -67,6 +72,7 @@ func start_tracking(seed: String, version: String = "unknown", schedule: Array = game_seed = seed game_version = version event_schedule = schedule.duplicate(true) + league_id = league tracking_enabled = true replay_log.clear() @@ -159,6 +165,25 @@ func record_event_response(event_id: String, response_id: String, turn: int): print("[VerificationTracker] Response: %s → %s → %s..." % [event_id, response_id, verification_hash.substr(0, 16)]) +func record_window_response(event_id: String, response: String, payment_source: String, turn: int): + """Update hash + replay log when a player (or auto-resolution) answers a response window + (L1 / ADR-0009 §3). Schema bump: window records carry the PAYMENT SOURCE + (reserve / cannibalize / defer / ignore), the datum the exploit-finder's response-policy + axis reads. `k:"w"` — the v1 replay simulator ignores unknown keys, so this is additive + and pre-L1 artifacts stay verifiable. + Entry: {"t": turn, "k": "w", "ev": event_id, "resp": response, "pay": payment_source}""" + if not tracking_enabled: + return + + var data = "%s|window:%s->%s@%s|t%d" % [verification_hash, event_id, response, payment_source, turn] + verification_hash = data.sha256_text() + + replay_log.append({"t": turn, "k": "w", "ev": event_id, "resp": response, "pay": payment_source}) + + if debug_mode: + print("[VerificationTracker] Window: %s -> %s (%s) -> %s..." % [event_id, response, payment_source, verification_hash.substr(0, 16)]) + + func record_rng_outcome(rng_type: String, value: float, turn: int): """ Update hash for significant RNG outcomes. @@ -249,6 +274,8 @@ func get_replay() -> Dictionary: # DQ-6 (#620 item 4): the verifier reads `schedule` (replay_simulator.gd) — # emit it. [] for unscheduled runs, so pre-L0 artifacts stay verifiable. "schedule": event_schedule.duplicate(true), + # ADR-0016: which monthly league produced this run, carried beside seed+version. + "league": league_id, "log": replay_log.duplicate(true) } @@ -277,6 +304,7 @@ func snapshot() -> Dictionary: "game_seed": game_seed, "game_version": game_version, "event_schedule": event_schedule.duplicate(true), + "league_id": league_id, "replay_log": replay_log.duplicate(true) } @@ -288,6 +316,7 @@ func restore(snap: Dictionary) -> void: game_seed = snap.get("game_seed", "") game_version = snap.get("game_version", "") event_schedule = (snap.get("event_schedule", []) as Array).duplicate(true) + league_id = String(snap.get("league_id", "")) replay_log = (snap.get("replay_log", []) as Array).duplicate(true) diff --git a/godot/scripts/core/event_tiers.gd b/godot/scripts/core/event_tiers.gd new file mode 100644 index 00000000..1332364e --- /dev/null +++ b/godot/scripts/core/event_tiers.gd @@ -0,0 +1,121 @@ +class_name EventTiers +extends RefCounted +## Event delivery-tier classification (L1 / workshop#3 addendum #1-2, ADR-0012). +## +## The structural #630 fix: the flood ceiling was an INFORMATION budget (cap how many +## events fire). Under the month plan it becomes a DEMAND budget — only one tier demands +## a decision. Every event genre is classified: +## ambient — board state mutates, no notification (the 2017 civilian-awareness floor) +## feed — readable, pull, no acknowledgment; carries a source_id (a named character +## who plausibly owns the information — provenance now, UI later) +## window — the ONLY tier that demands a decision (a costed response menu opens) +## +## Windows additionally carry an event CLASS (ADR-0012 taxonomy) governing which response +## verbs are legal: +## un-snoozable — HANDLE or IGNORE only; DEFER is not for sale (keeps reserve worth holding) +## deferrable — DEFER mints a Ledger entry (ADR-0013 carrying cost) +## standing — open for expiry_turns, then evaporates to no-engage (NO ledger entry) +## no-action — taking no action is legitimately correct; never punished +## +## Classification lives on the event DATA (delivery_tier / event_class / source_id / +## unignorable / expiry_turns / window{}); this module only reads it, applying Balance +## defaults so un-annotated legacy events degrade to a sane tier. Content wiring is L4. + +const TIER_AMBIENT := "ambient" +const TIER_FEED := "feed" +const TIER_WINDOW := "window" + +const CLASS_UNSNOOZABLE := "un-snoozable" +const CLASS_DEFERRABLE := "deferrable" +const CLASS_STANDING := "standing" +const CLASS_NO_ACTION := "no-action" + +const VALID_TIERS := [TIER_AMBIENT, TIER_FEED, TIER_WINDOW] + + +static func default_tier() -> String: + return Balance.table("events", {}).get("default_delivery_tier", TIER_FEED) + + +static func default_class() -> String: + return Balance.table("events", {}).get("default_event_class", CLASS_DEFERRABLE) + + +static func tier_of(event: Dictionary) -> String: + """Delivery tier of an event, defaulting via Balance for un-annotated legacy defs. + An event carrying its own `options` but no explicit tier is treated as a window — + pre-L1 popups all demanded a decision, so that is the behaviour-preserving default + for anything option-bearing; genuinely ambient/feed genres opt out via delivery_tier.""" + var t := String(event.get("delivery_tier", "")) + if t in VALID_TIERS: + return t + # Legacy popup with options == a decision was demanded pre-L1 -> window. + if String(event.get("type", "")) == "popup" and not event.get("options", []).is_empty(): + return TIER_WINDOW + return default_tier() + + +static func class_of(event: Dictionary) -> String: + var c := String(event.get("event_class", "")) + if c != "": + return c + return default_class() + + +static func source_id_of(event: Dictionary) -> String: + """Provenance (addendum #2): the named character who owns this information. Falls back + to any category/type hint, then 'unknown'. Feed items should always carry one.""" + var s := String(event.get("source_id", "")) + if s != "": + return s + return String(event.get("category", "unknown")) + + +static func is_window(event: Dictionary) -> bool: + return tier_of(event) == TIER_WINDOW + + +static func is_unignorable(event: Dictionary) -> bool: + """Legally unignorable windows cannot auto-resolve to IGNORE; the player must engage + (addendum #1). Un-snoozable class is NOT automatically unignorable — that's the DEFER + ban; unignorable is a stronger explicit flag.""" + return bool(event.get("unignorable", false)) + + +static func defer_allowed(event: Dictionary) -> bool: + """DEFER (mint a ledger entry) is sold only on the deferrable class (ADR-0012 §1-2).""" + return class_of(event) == CLASS_DEFERRABLE + + +static func expiry_turns(event: Dictionary) -> int: + """Standing offers stay open this many resolution ticks, then evaporate. 0 = closes + the same month it opened (un-snoozable-style single-tick window).""" + return int(event.get("expiry_turns", 0)) + + +static func legal_responses(event: Dictionary) -> Array: + """The response verbs a window legally offers, by class (ADR-0012). Always a subset of + [handle_reserve, handle_cannibalize, defer, ignore].""" + var verbs := ["handle_reserve", "handle_cannibalize"] + if defer_allowed(event): + verbs.append("defer") + if not is_unignorable(event): + verbs.append("ignore") + return verbs + + +static func partition(events: Array) -> Dictionary: + """Split a fired-event list into the three tiers. Windows are what the demand budget + throttles and what auto-pauses day-tick playback; ambient/feed never interrupt.""" + var out := {"ambient": [], "feed": [], "windows": []} + for ev in events: + if not ev is Dictionary: + continue + match tier_of(ev): + TIER_AMBIENT: + out["ambient"].append(ev) + TIER_WINDOW: + out["windows"].append(ev) + _: + out["feed"].append(ev) + return out diff --git a/godot/scripts/core/event_tiers.gd.uid b/godot/scripts/core/event_tiers.gd.uid new file mode 100644 index 00000000..3e73f14c --- /dev/null +++ b/godot/scripts/core/event_tiers.gd.uid @@ -0,0 +1 @@ +uid://cxchjmp2iij2b diff --git a/godot/scripts/core/window_resolver.gd b/godot/scripts/core/window_resolver.gd new file mode 100644 index 00000000..53dea615 --- /dev/null +++ b/godot/scripts/core/window_resolver.gd @@ -0,0 +1,189 @@ +class_name WindowResolver +extends RefCounted +## Response-window resolution (L1 / ADR-0009 §3, ADR-0012). A window is the only event +## tier that demands a decision; this resolves the costed menu: +## handle_reserve — HANDLE, paid painlessly from the crisp reserve (Attention) +## handle_cannibalize — HANDLE, paid by eating un-reserved capacity / killing planned WIP +## defer — mints a Liability Ledger entry (deferrable class only) +## ignore — the stated list-price consequence +## auto_ignore — an UNANSWERED window auto-resolves as IGNORE + a mild rep penalty +## (nonresponse annoys the offerer, addendum #1); illegal on +## unignorable windows. +## +## Attention (the founder decision currency) is paid via MonthPlan; the chosen option's own +## in-fiction resource costs (money/etc.) still apply through GameEvents.execute_event_choice. +## Legacy action_point costs on window options are STRIPPED — Attention replaces AP as the +## window decision currency (L1 introduces Attention; L2 deletes the AP pool). Seam left clean +## for ADR-0015: this resolver never reads/writes doom directly — it routes through option +## effects and ledger factories, both of which L2 migrates onto intermediaries. + +const Events = preload("res://scripts/core/events.gd") + +const DEFAULT_ATTENTION_COST := 1 + + +static func window_config(event: Dictionary) -> Dictionary: + return event.get("window", {}) if event.get("window", {}) is Dictionary else {} + + +static func attention_cost(event: Dictionary) -> int: + return int(window_config(event).get("attention_cost", DEFAULT_ATTENTION_COST)) + + +static func handle_option_id(event: Dictionary) -> String: + """Option applied on HANDLE. Defaults to the first option (pre-L1 popups led with the + primary beneficial choice).""" + var cfg := window_config(event) + if cfg.has("handle_option"): + return String(cfg["handle_option"]) + var options: Array = event.get("options", []) + if not options.is_empty() and options[0] is Dictionary: + return String(options[0].get("id", "")) + return "" + + +static func ignore_option_id(event: Dictionary) -> String: + """Option applied on IGNORE (list price). Defaults to a zero-cost 'continue/do nothing' + option if one exists, else the last option, else '' (no effect).""" + var cfg := window_config(event) + if cfg.has("ignore_option"): + return String(cfg["ignore_option"]) + var options: Array = event.get("options", []) + for opt in options: + if opt is Dictionary and (opt.get("costs", {}) as Dictionary).is_empty(): + return String(opt.get("id", "")) + if not options.is_empty() and options[-1] is Dictionary: + return String(options[-1].get("id", "")) + return "" + + +static func resolve(state: GameState, plan: MonthPlan, event: Dictionary, response: String, rng: RandomNumberGenerator = null) -> Dictionary: + """Apply a window response. Returns a result dict: + {success, response, payment_source, attention_paid, cancelled_wip, ledger_source, + message, deltas}. Records the response into the replay artifact at this choke point.""" + var event_id := String(event.get("id", "")) + var cost := attention_cost(event) + var result := { + "success": false, + "response": response, + "payment_source": "", + "attention_paid": 0, + "cancelled_wip": [], + "ledger_source": "", + "message": "", + "deltas": {}, + } + + match response: + "handle_reserve": + if not plan.pay_from_reserve(cost): + result["message"] = "Insufficient reserve to handle from reserve" + return result + result["payment_source"] = "reserve" + result["attention_paid"] = cost + _merge_option(result, _apply_option(state, event, handle_option_id(event))) + result["success"] = true + + "handle_cannibalize": + var pay := plan.pay_by_cannibalizing(cost) + result["cancelled_wip"] = pay.get("cancelled", []) + if not pay.get("paid", false): + result["message"] = "Insufficient capacity to handle by cannibalizing" + return result + result["payment_source"] = "cannibalize" + result["attention_paid"] = cost + _merge_option(result, _apply_option(state, event, handle_option_id(event))) + result["success"] = true + + "defer": + if not EventTiers.defer_allowed(event): + result["message"] = "This window cannot be deferred (%s class)" % EventTiers.class_of(event) + return result + var entry = _mint_deferral(event, rng) + if entry != null: + state.ledger.add(entry) + result["ledger_source"] = entry.source + result["payment_source"] = "defer" + result["message"] = "Deferred — minted ledger entry" + result["success"] = true + + "ignore", "auto_ignore": + if response == "auto_ignore" and EventTiers.is_unignorable(event): + result["message"] = "Unignorable window cannot auto-resolve to ignore" + return result + result["payment_source"] = "ignore" + _merge_option(result, _apply_option(state, event, ignore_option_id(event))) + if response == "auto_ignore": + # Nonresponse annoys the offerer: a mild, data-driven reputation penalty + # (addendum #1). Applied on top of the list-price consequence. + var pen := Balance.num("events.unanswered_window_rep_penalty", 2.0) + state.reputation = max(0.0, state.reputation - pen) + var d: Dictionary = result["deltas"] + d["reputation"] = float(d.get("reputation", 0.0)) - pen + result["message"] = "Window lapsed — auto-ignored (-%.1f reputation)" % pen + result["success"] = true + + _: + result["message"] = "Unknown window response: %s" % response + return result + + # Record into the replay artifact at the choke point (schema bump: window responses + # carry the payment source — ADR-0009 consequence). + if result["success"] and typeof(VerificationTracker) != TYPE_NIL: + VerificationTracker.record_window_response(event_id, response, String(result["payment_source"]), state.turn) + return result + + +static func _apply_option(state: GameState, event: Dictionary, option_id: String) -> Dictionary: + """Apply an event option by id, STRIPPING any legacy action_point cost (windows spend + Attention, not AP). Returns the execute_event_choice result (or an empty success if the + option id is blank — a no-op ignore).""" + if option_id == "": + return {"success": true, "message": "No action", "deltas": {}} + var cleaned := _strip_ap(event) + return Events.execute_event_choice(cleaned, option_id, state) + + +static func _strip_ap(event: Dictionary) -> Dictionary: + """Shallow-clone an event with action_points removed from every option's costs.""" + var clone := event.duplicate(true) + for opt in clone.get("options", []): + if opt is Dictionary and opt.has("costs") and opt["costs"] is Dictionary: + opt["costs"].erase("action_points") + return clone + + +static func _merge_option(result: Dictionary, opt_result: Dictionary) -> void: + if opt_result.get("message", "") != "": + result["message"] = opt_result["message"] + if opt_result.has("deltas"): + result["deltas"] = opt_result["deltas"] + if opt_result.has("messages"): + result["messages"] = opt_result["messages"] + # Surface an option-level failure (e.g. couldn't afford the in-fiction money cost). + if not opt_result.get("success", true): + result["option_failed"] = true + + +static func _mint_deferral(event: Dictionary, rng: RandomNumberGenerator): + """Mint a Ledger entry for a DEFER, from the event's window.defer config. Defaults to a + loan sized by the handle option's money cost (deferring a bill you didn't pay). Content + tuning is L4/ADR-0013 — this is the intake valve.""" + var cfg = window_config(event).get("defer", {}) + var factory: String = String(cfg.get("factory", "loan")) if cfg is Dictionary else "loan" + var amount: float = float(cfg.get("amount", 0.0)) if cfg is Dictionary else 0.0 + if amount <= 0.0: + # Fall back to the handle option's money cost as the deferred principal. + for opt in event.get("options", []): + if opt is Dictionary and String(opt.get("id", "")) == handle_option_id(event): + amount = float((opt.get("costs", {}) as Dictionary).get("money", 0.0)) + break + if amount <= 0.0: + amount = 1000.0 # a nominal carried obligation so DEFER always has teeth + match factory: + "funding_strings": + return Ledger.funding_with_strings(amount) + "desperation_payroll": + return Ledger.desperation_payroll(rng if rng != null else RandomNumberGenerator.new()) + _: + return Ledger.loan(amount) diff --git a/godot/scripts/core/window_resolver.gd.uid b/godot/scripts/core/window_resolver.gd.uid new file mode 100644 index 00000000..98c4504f --- /dev/null +++ b/godot/scripts/core/window_resolver.gd.uid @@ -0,0 +1 @@ +uid://brvy17lrgxgc7 diff --git a/godot/tests/unit/test_event_tiers.gd b/godot/tests/unit/test_event_tiers.gd new file mode 100644 index 00000000..dad95c47 --- /dev/null +++ b/godot/tests/unit/test_event_tiers.gd @@ -0,0 +1,55 @@ +extends GutTest +## L1 (#612 / workshop#3 addendum #1-2, ADR-0012): event delivery-tier classification. + +func test_explicit_tiers_classify(): + assert_eq(EventTiers.tier_of({"delivery_tier": "ambient"}), "ambient") + assert_eq(EventTiers.tier_of({"delivery_tier": "feed"}), "feed") + assert_eq(EventTiers.tier_of({"delivery_tier": "window"}), "window") + + +func test_legacy_popup_with_options_defaults_to_window(): + # Behaviour-preserving: pre-L1 popups all demanded a decision. + var ev := {"type": "popup", "options": [{"id": "ok"}]} + assert_eq(EventTiers.tier_of(ev), "window", "un-annotated popups stay decision-demanding") + + +func test_source_id_provenance_with_fallback(): + assert_eq(EventTiers.source_id_of({"source_id": "gov_liaison"}), "gov_liaison") + assert_eq(EventTiers.source_id_of({"category": "funding"}), "funding", "falls back to category") + assert_eq(EventTiers.source_id_of({}), "unknown", "last-resort provenance") + + +func test_class_governs_legal_responses(): + var unsnoozable := {"delivery_tier": "window", "event_class": "un-snoozable"} + var verbs := EventTiers.legal_responses(unsnoozable) + assert_false(verbs.has("defer"), "un-snoozable does not sell DEFER") + assert_true(verbs.has("ignore"), "but IGNORE at list price is available") + + var deferrable := {"delivery_tier": "window", "event_class": "deferrable"} + assert_true(EventTiers.legal_responses(deferrable).has("defer"), "deferrable sells DEFER") + + +func test_unignorable_removes_ignore(): + var ev := {"delivery_tier": "window", "event_class": "un-snoozable", "unignorable": true} + var verbs := EventTiers.legal_responses(ev) + assert_false(verbs.has("ignore"), "an unignorable window cannot be ignored") + assert_true(EventTiers.is_unignorable(ev)) + + +func test_defer_allowed_only_for_deferrable(): + assert_true(EventTiers.defer_allowed({"event_class": "deferrable"})) + assert_false(EventTiers.defer_allowed({"event_class": "un-snoozable"})) + assert_false(EventTiers.defer_allowed({"event_class": "standing"})) + + +func test_partition_splits_the_stream(): + var events := [ + {"delivery_tier": "ambient"}, + {"delivery_tier": "feed", "source_id": "a"}, + {"delivery_tier": "feed", "source_id": "b"}, + {"type": "popup", "options": [{"id": "x"}]}, # -> window + ] + var parts := EventTiers.partition(events) + assert_eq((parts.ambient as Array).size(), 1, "one ambient") + assert_eq((parts.feed as Array).size(), 2, "two feed items") + assert_eq((parts.windows as Array).size(), 1, "one window demands a decision") diff --git a/godot/tests/unit/test_event_tiers.gd.uid b/godot/tests/unit/test_event_tiers.gd.uid new file mode 100644 index 00000000..eeb03c91 --- /dev/null +++ b/godot/tests/unit/test_event_tiers.gd.uid @@ -0,0 +1 @@ +uid://v1p8hewwrojw diff --git a/godot/tests/unit/test_window_resolver.gd b/godot/tests/unit/test_window_resolver.gd new file mode 100644 index 00000000..611a6e44 --- /dev/null +++ b/godot/tests/unit/test_window_resolver.gd @@ -0,0 +1,100 @@ +extends GutTest +## L1 (#612 / ADR-0009 §3, ADR-0012): response-window resolution — the costed menu +## HANDLE-from-reserve / HANDLE-by-cannibalizing / DEFER / IGNORE, and the auto-IGNORE +## of unanswered windows with a mild reputation penalty. + +func _state() -> GameState: + var s := GameState.new("window-resolver-seed") + s.money = 245000.0 + s.reputation = 50.0 + return s + + +func _deferrable_window() -> Dictionary: + return { + "id": "vendor_dispute", + "type": "popup", + "delivery_tier": "window", + "event_class": "deferrable", + "source_id": "legal_counsel", + "options": [ + {"id": "settle", "costs": {"money": 20000, "action_points": 1}, "effects": {"reputation": 5}, "message": "Settled"}, + {"id": "let_it_ride", "costs": {}, "effects": {"reputation": -3}, "message": "Ignored the notice"}, + ], + "window": {"attention_cost": 2, "handle_option": "settle", "ignore_option": "let_it_ride", "defer": {"factory": "loan", "amount": 20000}}, + } + + +func test_handle_from_reserve_pays_reserve_and_applies_handle_effect(): + var s := _state() + s.month_plan.set_reserve(3) + var money0 := s.money + var r := WindowResolver.resolve(s, s.month_plan, _deferrable_window(), "handle_reserve") + assert_true(r.success, "reserve covers the handle") + assert_eq(String(r.payment_source), "reserve") + assert_eq(int(r.attention_paid), 2, "the window's Attention cost is drawn") + assert_eq(s.month_plan.reserve_remaining(), 1, "reserve consumed by the amount") + assert_eq(s.money, money0 - 20000.0, "the in-fiction money cost of the handle option applies") + assert_almost_eq(s.reputation, 55.0, 0.01, "the handle effect (+5 rep) applies") + + +func test_handle_from_reserve_fails_without_enough_reserve(): + var s := _state() + s.month_plan.set_reserve(1) # window costs 2 + var r := WindowResolver.resolve(s, s.month_plan, _deferrable_window(), "handle_reserve") + assert_false(r.success, "insufficient reserve cannot handle from reserve") + + +func test_handle_by_cannibalizing_ignores_legacy_ap(): + var s := _state() + s.action_points = 0 # prove Attention, not AP, is the window currency (AP stripped) + s.month_plan.set_reserve(0) # 20 available + var r := WindowResolver.resolve(s, s.month_plan, _deferrable_window(), "handle_cannibalize") + assert_true(r.success, "cannibalizing free capacity handles the window even with zero AP") + assert_eq(String(r.payment_source), "cannibalize") + assert_almost_eq(s.reputation, 55.0, 0.01, "handle effect applied") + + +func test_defer_mints_a_ledger_entry(): + var s := _state() + var entries0: int = s.ledger.entries.size() + var money0 := s.money + var r := WindowResolver.resolve(s, s.month_plan, _deferrable_window(), "defer") + assert_true(r.success, "a deferrable window can be deferred") + assert_eq(s.ledger.entries.size(), entries0 + 1, "DEFER mints a ledger entry") + assert_eq(s.money, money0, "deferring pays no money now — that's the point") + assert_eq(String(r.payment_source), "defer") + + +func test_defer_refused_on_un_snoozable(): + var s := _state() + var ev := _deferrable_window() + ev["event_class"] = "un-snoozable" + var r := WindowResolver.resolve(s, s.month_plan, ev, "defer") + assert_false(r.success, "un-snoozable windows do not sell DEFER") + assert_eq(s.ledger.entries.size(), 0, "no entry minted on a refused defer") + + +func test_ignore_applies_list_price(): + var s := _state() + var r := WindowResolver.resolve(s, s.month_plan, _deferrable_window(), "ignore") + assert_true(r.success) + assert_almost_eq(s.reputation, 47.0, 0.01, "IGNORE applies the list-price consequence (-3 rep)") + + +func test_auto_ignore_adds_mild_rep_penalty(): + var s := _state() + var r := WindowResolver.resolve(s, s.month_plan, _deferrable_window(), "auto_ignore") + assert_true(r.success, "an unanswered window auto-resolves") + # list price -3, plus the default unanswered penalty -2 (Balance events.*) = -5. + assert_almost_eq(s.reputation, 45.0, 0.01, "auto-IGNORE = list price + mild nonresponse penalty") + + +func test_auto_ignore_refused_on_unignorable(): + var s := _state() + var ev := _deferrable_window() + ev["unignorable"] = true + var rep0 := s.reputation + var r := WindowResolver.resolve(s, s.month_plan, ev, "auto_ignore") + assert_false(r.success, "an unignorable window cannot lapse into auto-ignore") + assert_almost_eq(s.reputation, rep0, 0.01, "no penalty applied on a refused auto-ignore") diff --git a/godot/tests/unit/test_window_resolver.gd.uid b/godot/tests/unit/test_window_resolver.gd.uid new file mode 100644 index 00000000..86091ea2 --- /dev/null +++ b/godot/tests/unit/test_window_resolver.gd.uid @@ -0,0 +1 @@ +uid://tb23f2pqtskg From 6ffa78e016fc5e09187a724f5c373229c53c6ac4 Mon Sep 17 00:00:00 2001 From: Pip Date: Mon, 13 Jul 2026 16:52:42 +1000 Subject: [PATCH 3/6] feat(L1): month-loop playback controller + plan-layer API + pause-save-load ADR-0009 UI/acceptance: MonthController drives day-tick playback within a plan month, delegating the heavy sim to TurnManager and PAUSING on any window that demands a decision (auto-pause-on-window). Ambient/feed events never interrupt. - Window DEMAND budget enforced here (workshop#3 addendum #1): only N windows/month demand a decision (2-3 early, 5-6 endgame, Balance-driven); excess window-tier events downgrade to the feed. The structural #630 fix. - Month boundaries open a fresh plan phase: new Attention grant, crisp reserve evaporation, budget reset, duration-elapsed strategic WIP released (L2 seam). - Unanswered windows auto-resolve as IGNORE + mild rep penalty; unignorable refuse. - Open windows mirror into serialized pending_events so pause->save->load captures the pause point; rehydrate_from_state re-enters it after load. - GameManager: thin plan-layer API (get_month_plan / set_attention_reserve / queue_strategic_action / resolve_window) + replay league stamp. 9 new GUT tests incl. a headless playable-month-loop smoke and pause->save->load. Co-Authored-By: Claude Fable 5 --- godot/scripts/core/month_controller.gd | 196 ++++++++++++++++++ godot/scripts/core/month_controller.gd.uid | 1 + godot/scripts/game_manager.gd | 44 +++- godot/tests/unit/test_month_controller.gd | 113 ++++++++++ godot/tests/unit/test_month_controller.gd.uid | 1 + godot/tests/unit/test_month_save_load.gd | 68 ++++++ godot/tests/unit/test_month_save_load.gd.uid | 1 + 7 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 godot/scripts/core/month_controller.gd create mode 100644 godot/scripts/core/month_controller.gd.uid create mode 100644 godot/tests/unit/test_month_controller.gd create mode 100644 godot/tests/unit/test_month_controller.gd.uid create mode 100644 godot/tests/unit/test_month_save_load.gd create mode 100644 godot/tests/unit/test_month_save_load.gd.uid diff --git a/godot/scripts/core/month_controller.gd b/godot/scripts/core/month_controller.gd new file mode 100644 index 00000000..aeee7839 --- /dev/null +++ b/godot/scripts/core/month_controller.gd @@ -0,0 +1,196 @@ +class_name MonthController +extends RefCounted +## Day-tick playback within a plan month, with auto-pause-on-window (L1 / ADR-0009 §UI). +## +## The plan cadence is the month; beneath it the day-turn is the resolution tick. This +## driver advances ticks (delegating the heavy sim to TurnManager), routes each tick's +## fired events by delivery tier, and PAUSES playback whenever a window demands a decision +## — the auto-pause-on-window the ADR calls for. Ambient/feed events never interrupt. +## +## The window DEMAND budget (workshop#3 addendum #1) is enforced here: only N windows per +## month may demand a decision (2-3 early, more in endgame — Balance-driven); window-tier +## events beyond the budget are downgraded to the feed (readable, no decision) rather than +## piling onto the player. That is the structural #630 fix: a demand budget, not an +## information budget. +## +## Month boundaries (Clock.is_month_boundary) open a fresh plan phase: a new Attention +## grant, the crisp reserve evaporates (MonthPlan.begin_month), the demand budget resets, +## and duration-elapsed strategic WIP is released. Unanswered windows left open when the +## player advances the month auto-resolve as IGNORE + a mild rep penalty (unless unignorable). + +enum Status { READY, PAUSED_ON_WINDOW } + +var state: GameState +var turn_manager # TurnManager (untyped to avoid class_name load-order coupling in tests) + +# Windows awaiting a decision on the current tick (auto-pause holds here). +var window_queue: Array = [] +# Feed items surfaced this run (pull, no acknowledgment) — provenance-stamped. +var feed_log: Array = [] +var windows_surfaced_this_month: int = 0 +var current_month_index: int = -1 +var status: int = Status.READY +# Strategic WIP released this tick (duration elapsed) — surfaced for the caller/L2 to apply +# effects; the controller does not reach into GameActions itself (clean seam). +var last_released_strategic: Array = [] + + +func _init(game_state: GameState, tm = null) -> void: + state = game_state + turn_manager = tm + current_month_index = Clock.month_index(state.turn, state.start_year, state.start_month, state.start_day) + + +func window_demand_budget() -> int: + """Windows allowed to DEMAND a decision this month. Scales up in endgame (addendum #1).""" + var endgame_turn := Balance.inum("events.endgame_turn", 200) + if state.turn >= endgame_turn: + return Balance.inum("events.window_demand_budget_endgame", 6) + return Balance.inum("events.window_demand_budget", 3) + + +func is_paused() -> bool: + return status == Status.PAUSED_ON_WINDOW + + +func advance_tick() -> Dictionary: + """Run one resolution tick. Returns {status, month_opened, windows, feed, released}. + If a window surfaces, playback PAUSES (status 'paused_on_window') and execute_turn is + deferred until the queue is answered (resolve_current_window / skip_current_window).""" + if is_paused(): + return {"status": "paused_on_window", "windows": window_queue, "message": "resolve open windows first"} + + var month_opened := false + if turn_manager != null: + turn_manager.start_turn() # increments state.turn, fires events into pending_events + + # A new calendar month = a fresh plan phase (Attention grant, reserve evaporates, budget resets). + var mi := Clock.month_index(state.turn, state.start_year, state.start_month, state.start_day) + if mi != current_month_index: + _open_plan_month(mi) + month_opened = true + + # Release duration-elapsed strategic WIP (seam: caller/L2 applies their effects). + last_released_strategic = state.month_plan.take_due_strategic(state.turn) + + var fired: Array = state.pending_events.duplicate() + state.pending_events.clear() + var surfaced := _dispatch(fired) + + if not window_queue.is_empty(): + status = Status.PAUSED_ON_WINDOW + # Mirror the open windows into the serialized pending_events so a pause->save->load + # captures them (GameState owns save fidelity; window_queue is the live view). + _sync_pending() + state.current_phase = GameState.TurnPhase.TURN_START + state.can_end_turn = false + return { + "status": "paused_on_window", + "month_opened": month_opened, + "windows": window_queue.duplicate(), + "feed": surfaced.get("feed", []), + "released": last_released_strategic, + } + + _complete_tick() + return { + "status": "ready", + "month_opened": month_opened, + "windows": [], + "feed": surfaced.get("feed", []), + "released": last_released_strategic, + } + + +func _dispatch(events: Array) -> Dictionary: + """Route a tick's fired events by tier. Windows within the demand budget enqueue for a + decision; window-tier events beyond the budget downgrade to feed. Returns the feed items + surfaced this call.""" + var parts := EventTiers.partition(events) + var surfaced_feed: Array = [] + for f in parts.feed: + var item := {"event": f, "source_id": EventTiers.source_id_of(f)} + feed_log.append(item) + surfaced_feed.append(item) + # Ambient: board-state mutation, no notification. v1 has no separate ambient effect + # payload, so this is an acknowledged no-op — the tier is honoured, content is L4. + for w in parts.windows: + if windows_surfaced_this_month < window_demand_budget(): + window_queue.append(w) + windows_surfaced_this_month += 1 + else: + # Over budget -> downgrade to feed rather than demand another decision. + var item := {"event": w, "source_id": EventTiers.source_id_of(w), "over_budget": true} + feed_log.append(item) + surfaced_feed.append(item) + return {"feed": surfaced_feed} + + +func resolve_current_window(response: String) -> Dictionary: + """Answer the head window with a costed response (ADR-0009 §3). When the queue empties, + the paused tick completes (execute_turn runs).""" + if not is_paused() or window_queue.is_empty(): + return {"success": false, "message": "no open window"} + var window: Dictionary = window_queue.pop_front() + var result := WindowResolver.resolve(state, state.month_plan, window, response, state.rng) + _sync_pending() + if window_queue.is_empty(): + status = Status.READY + _complete_tick() + return result + + +func skip_current_window() -> Dictionary: + """Leave the head window unanswered — auto-resolves as IGNORE + a mild rep penalty + (unignorable windows refuse this and stay queued).""" + if not is_paused() or window_queue.is_empty(): + return {"success": false, "message": "no open window"} + var window: Dictionary = window_queue[0] + if EventTiers.is_unignorable(window): + return {"success": false, "message": "window is unignorable — must be handled"} + window_queue.pop_front() + var result := WindowResolver.resolve(state, state.month_plan, window, "auto_ignore", state.rng) + _sync_pending() + if window_queue.is_empty(): + status = Status.READY + _complete_tick() + return result + + +func _complete_tick() -> void: + """Finish a tick once no window is pending: run the consequence phase.""" + state.current_phase = GameState.TurnPhase.ACTION_SELECTION + state.can_end_turn = true + if turn_manager != null: + turn_manager.execute_turn() + + +func _sync_pending() -> void: + """Keep the serialized pending_events in step with the live window queue, so save/load + captures an open pause point (ADR-0009 UI: day-tick playback resumes at the window). + pending_events is Array[Dictionary] — build a typed array, don't assign an untyped one.""" + var typed: Array[Dictionary] = [] + for w in window_queue: + if w is Dictionary: + typed.append(w.duplicate(true)) + state.pending_events = typed + + +func rehydrate_from_state() -> void: + """Rebuild the live pause state from a freshly-loaded GameState. If the save was taken + while a window was open, pending_events still holds it — re-enter the paused state so + playback resumes exactly where it stopped.""" + current_month_index = Clock.month_index(state.turn, state.start_year, state.start_month, state.start_day) + window_queue = [] + for ev in state.pending_events: + if ev is Dictionary and EventTiers.is_window(ev): + window_queue.append(ev) + status = Status.PAUSED_ON_WINDOW if not window_queue.is_empty() else Status.READY + + +func _open_plan_month(mi: int) -> void: + """Open a new plan month: fresh Attention, crisp reserve evaporation, budget reset.""" + current_month_index = mi + windows_surfaced_this_month = 0 + var ordinal := Clock.month_ordinal_since_start(state.turn, state.start_year, state.start_month, state.start_day) + state.month_plan.begin_month(Balance.inum("attention.per_month", 20), ordinal) diff --git a/godot/scripts/core/month_controller.gd.uid b/godot/scripts/core/month_controller.gd.uid new file mode 100644 index 00000000..f4e4126e --- /dev/null +++ b/godot/scripts/core/month_controller.gd.uid @@ -0,0 +1 @@ +uid://dv4a8jt6a0tuf diff --git a/godot/scripts/game_manager.gd b/godot/scripts/game_manager.gd index 2f065d56..c6b27935 100644 --- a/godot/scripts/game_manager.gd +++ b/godot/scripts/game_manager.gd @@ -43,8 +43,12 @@ func start_new_game(game_seed: String = ""): # Start verification tracking. The event schedule travels with the artifact # (ADR-0005: seed = RNG seed + schedule; DQ-6 fix, L0 #620 item 4). var game_version = GameConfig.CURRENT_VERSION + # ADR-0016 league metabolism: stamp the artifact with its league (the baseline month). + # Placeholder until the league pipeline lands — the run's start month is a stable id + # carried beside (seed, game_version) so cross-league boards (DQ-3) can key on it. + var league_id := "%04d-%02d" % [state.start_year, state.start_month] VerificationTracker.enable_debug() # Enable verbose logging - VerificationTracker.start_tracking(game_seed, game_version, state.event_schedule) + VerificationTracker.start_tracking(game_seed, game_version, state.event_schedule, league_id) print("[GameManager] Verification tracking enabled (debug mode: ON)") # Start baseline simulation in background if appropriate (Issue #372) @@ -656,3 +660,41 @@ func _apply_scenario_overrides(): state.set_meta("scenario_events", custom_events) print("[GameManager] Scenario applied successfully") + + +# ============================================================================ +# MONTH PLAN LAYER API (L1 / ADR-0009) +# +# Thin delegates exposing the plan-turn layer to callers (the new plan screen speaks +# ONLY through here — main_ui.gd is left to die by attrition, per the LET-DIE map). +# The founder currency Attention lives on state.month_plan; response windows resolve +# through WindowResolver. The legacy per-turn AP loop above is untouched (L2 removes it). +# ============================================================================ + +func get_month_plan() -> MonthPlan: + """The current month's plan (Attention, reserve, in-flight strategic WIP), or null.""" + return state.month_plan if state else null + + +func set_attention_reserve(amount: int) -> bool: + """Explicitly hold `amount` Attention for response windows this month (plan-time).""" + if state == null or state.month_plan == null: + return false + return state.month_plan.set_reserve(amount) + + +func queue_strategic_action(action_id: String, attention_cost: int, duration_ticks: int) -> bool: + """Queue a strategic action at plan speed — spends Attention now, lands after its + duration (ADR-0009 §5, nothing strategic resolves instantly).""" + if state == null or state.month_plan == null: + return false + return state.month_plan.queue_strategic(action_id, attention_cost, duration_ticks, state.turn) + + +func resolve_window(event: Dictionary, response: String) -> Dictionary: + """Resolve a response window with a costed menu choice (handle_reserve / + handle_cannibalize / defer / ignore). Delegates to WindowResolver; records the + payment source into the replay artifact.""" + if state == null: + return {"success": false, "message": "no active game"} + return WindowResolver.resolve(state, state.month_plan, event, response, state.rng) diff --git a/godot/tests/unit/test_month_controller.gd b/godot/tests/unit/test_month_controller.gd new file mode 100644 index 00000000..5cef9276 --- /dev/null +++ b/godot/tests/unit/test_month_controller.gd @@ -0,0 +1,113 @@ +extends GutTest +## L1 (#612 / ADR-0009): day-tick playback within a month — auto-pause-on-window, the +## window demand budget, month-boundary plan reset, and a headless playable-loop smoke. + +func _state() -> GameState: + var s := GameState.new("month-controller-seed") + s.money = 245000.0 + s.reputation = 50.0 + return s + + +func _window(id: String, unignorable := false) -> Dictionary: + return { + "id": id, + "type": "popup", + "delivery_tier": "window", + "event_class": "deferrable", + "unignorable": unignorable, + "options": [ + {"id": "handle", "costs": {}, "effects": {"reputation": 1}, "message": "handled"}, + {"id": "ignore", "costs": {}, "effects": {"reputation": -1}, "message": "ignored"}, + ], + "window": {"attention_cost": 1, "handle_option": "handle", "ignore_option": "ignore"}, + } + + +func test_demand_budget_downgrades_excess_windows_to_feed(): + var s := _state() + var mc := MonthController.new(s, null) + var events := [] + for i in range(6): + events.append(_window("w%d" % i)) + mc._dispatch(events) # budget default 3 + assert_eq(mc.window_queue.size(), 3, "only the budget's worth demand a decision") + assert_true(mc.feed_log.size() >= 3, "the excess windows downgrade to the feed") + + +func test_feed_items_carry_provenance(): + var s := _state() + var mc := MonthController.new(s, null) + mc._dispatch([{"delivery_tier": "feed", "id": "memo", "source_id": "board_chair"}]) + assert_eq(mc.feed_log.size(), 1) + assert_eq(String(mc.feed_log[0].source_id), "board_chair", "feed item keeps its source") + + +func test_advance_tick_pauses_on_window_then_resumes(): + var s := _state() + var mc := MonthController.new(s, null) # null tm: no sim, drives dispatch/pause only + s.pending_events.assign([_window("crisis")]) + var r := mc.advance_tick() + assert_eq(String(r.status), "paused_on_window", "a window auto-pauses playback") + assert_true(mc.is_paused()) + assert_eq((r.windows as Array).size(), 1) + var res := mc.resolve_current_window("ignore") + assert_true(res.success, "resolving the window succeeds") + assert_false(mc.is_paused(), "playback resumes once the queue empties") + + +func test_skip_unanswered_window_auto_ignores_with_penalty(): + var s := _state() + var mc := MonthController.new(s, null) + s.pending_events.assign([_window("lapsing")]) + mc.advance_tick() + var rep0 := s.reputation + var res := mc.skip_current_window() + assert_true(res.success, "an ignorable window can lapse") + assert_true(s.reputation < rep0, "auto-ignore applies the list price + mild penalty") + + +func test_unignorable_window_refuses_skip(): + var s := _state() + var mc := MonthController.new(s, null) + s.pending_events.assign([_window("subpoena", true)]) + mc.advance_tick() + var res := mc.skip_current_window() + assert_false(res.success, "an unignorable window cannot be skipped") + assert_true(mc.is_paused(), "it stays open, still demanding a decision") + + +func test_month_boundary_opens_fresh_plan_phase(): + var s := _state() + var mc := MonthController.new(s, null) + s.month_plan.queue_strategic("wip", 8, 2, 0) # spend 8 Attention this month + assert_eq(s.month_plan.available(), 12, "8 spent this month") + # Jump the tick into a later calendar month, then advance: a new plan month opens. + s.turn = 40 + var r := mc.advance_tick() + assert_true(r.month_opened, "crossing the calendar month opens a new plan phase") + assert_eq(s.month_plan.available(), 20, "fresh full Attention grant — reserve evaporated") + + +func test_headless_playable_month_loop_runs(): + # The acceptance smoke: drive real ticks through TurnManager, answering any window that + # fires, and confirm the month loop advances without crashing and rolls the plan month. + var s := _state() + var tm = TurnManager.new(s) + var mc := MonthController.new(s, tm) + var guard := 0 + var ticks := 0 + while ticks < 40 and guard < 500: + guard += 1 + var r := mc.advance_tick() + if String(r.status) == "paused_on_window": + # Answer every open window (ignore is always safe) to resume playback. + var wguard := 0 + while mc.is_paused() and wguard < 20: + wguard += 1 + mc.resolve_current_window("ignore") + else: + ticks += 1 + assert_true(s.turn >= 30, "the loop advanced many day ticks (turn=%d)" % s.turn) + assert_true(s.month_plan.month_ordinal >= 1, "at least one plan-month boundary rolled") + assert_false(s.game_over and s.turn < 5, "did not die instantly / hang") diff --git a/godot/tests/unit/test_month_controller.gd.uid b/godot/tests/unit/test_month_controller.gd.uid new file mode 100644 index 00000000..981280f4 --- /dev/null +++ b/godot/tests/unit/test_month_controller.gd.uid @@ -0,0 +1 @@ +uid://cveihq7ynx3qh diff --git a/godot/tests/unit/test_month_save_load.gd b/godot/tests/unit/test_month_save_load.gd new file mode 100644 index 00000000..0c578282 --- /dev/null +++ b/godot/tests/unit/test_month_save_load.gd @@ -0,0 +1,68 @@ +extends GutTest +## L1 (#612 / ADR-0009, L7): save/load must survive the month plan layer, INCLUDING a +## save taken while day-tick playback is PAUSED on an open response window. + +const TEST_SAVE_NAME := "test_l1_month_pause.json" +const TEST_SAVE_PATH := "user://saves/" + TEST_SAVE_NAME + + +func after_each(): + var dir := DirAccess.open("user://saves") + if dir and dir.file_exists(TEST_SAVE_NAME): + dir.remove(TEST_SAVE_NAME) + + +func _window(id: String) -> Dictionary: + return { + "id": id, "type": "popup", "delivery_tier": "window", "event_class": "deferrable", + "options": [ + {"id": "handle", "costs": {}, "effects": {"reputation": 1}, "message": "handled"}, + {"id": "ignore", "costs": {}, "effects": {"reputation": -1}, "message": "ignored"}, + ], + "window": {"attention_cost": 2, "handle_option": "handle", "ignore_option": "ignore"}, + } + + +func test_month_plan_survives_save_load(): + var s := GameState.new("l1-month-save") + s.month_plan.set_reserve(6) + s.month_plan.pay_from_reserve(2) + s.month_plan.queue_strategic("fundraise_campaign", 5, 4, s.turn) + + SaveLoad.save_game(s, TEST_SAVE_PATH) + var restored := SaveLoad.restore_state(SaveLoad.load_envelope(TEST_SAVE_PATH)) + + assert_eq(restored.month_plan.attention_total, s.month_plan.attention_total, "Attention grant survives") + assert_eq(restored.month_plan.reserve_remaining(), s.month_plan.reserve_remaining(), "reserve survives") + assert_eq(restored.month_plan.available(), s.month_plan.available(), "available Attention survives") + assert_eq(restored.month_plan.queued_strategic.size(), 1, "in-flight strategic WIP survives") + assert_eq(int(restored.month_plan.queued_strategic[0].resolves_on_turn), + int(s.month_plan.queued_strategic[0].resolves_on_turn), "WIP duration timing survives") + + +func test_pause_on_window_survives_save_load(): + # Drive a tick into a paused-on-window state, save mid-pause, load into a fresh state, + # and confirm the open window and plan state are recovered so playback can resume. + var s := GameState.new("l1-pause-save") + s.money = 245000.0 + s.month_plan.set_reserve(4) + var mc := MonthController.new(s, null) + s.pending_events.assign([_window("audit_notice")]) + var r := mc.advance_tick() + assert_eq(String(r.status), "paused_on_window", "we are paused on a window before saving") + assert_eq(s.pending_events.size(), 1, "the open window is mirrored into serialized state") + + SaveLoad.save_game(s, TEST_SAVE_PATH) + var restored := SaveLoad.restore_state(SaveLoad.load_envelope(TEST_SAVE_PATH)) + + assert_eq(restored.pending_events.size(), 1, "the open window survived the round trip") + assert_eq(restored.month_plan.reserve_remaining(), 4, "the reserve held for it survived") + + # A fresh controller rehydrates the pause and resumes exactly where it stopped. + var mc2 := MonthController.new(restored, null) + mc2.rehydrate_from_state() + assert_true(mc2.is_paused(), "playback re-enters the paused state after load") + assert_eq(mc2.window_queue.size(), 1, "the window is back in the live queue") + var res := mc2.resolve_current_window("handle_reserve") # reserve 4 covers the cost-2 window + assert_true(res.success, "the recovered window can be resolved after load") + assert_false(mc2.is_paused(), "playback resumes") diff --git a/godot/tests/unit/test_month_save_load.gd.uid b/godot/tests/unit/test_month_save_load.gd.uid new file mode 100644 index 00000000..c173f0dc --- /dev/null +++ b/godot/tests/unit/test_month_save_load.gd.uid @@ -0,0 +1 @@ +uid://cgy4dk7rntr3t From e4cd4087391e6cf25d41baf5b26ec83190522b70 Mon Sep 17 00:00:00 2001 From: Pip Date: Mon, 13 Jul 2026 21:36:19 +1000 Subject: [PATCH 4/6] =?UTF-8?q?feat(L1):=20playable=20month=20path=20?= =?UTF-8?q?=E2=80=94=20End=20Turn=20commits=20the=20plan=20and=20plays=20t?= =?UTF-8?q?he=20month=20out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playtest follow-up: the month engine existed but was unreachable — main_ui's End Turn still called end_turn() (single day-step), so the playtest exercised zero L1 behavior. The button now drives the month loop end to end: - GameManager.end_month(): commit queued actions as the month plan, default the reserve to all unspent Attention (implicit v1 — the plan screen makes it a dial), execute the open plan turn, then hand control to MonthController playback. - Day ticks advance visibly (day_tick_seconds, test-tunable); windows auto-pause and present through the EXISTING event_dialog (its extraction docstring promised exactly this reuse) with AP pre-stripped so displayed costs match what resolution charges (Attention). resolve_event routes paused-window choices through MonthController.resolve_current_window_option: the event's ignore option maps to IGNORE; any other option is a HANDLE paid reserve-first then by cannibalizing — payment_source still lands in the replay artifact. - Month boundary: the boundary tick is HELD OPEN as the new plan phase (month_open_pending) — never auto-executed, or its consequence steps would double-run when the plan commits. A plain month-review dialog (synthetic event, intercepted in resolve_event) closes into planning. - Feed items surface as log lines; ambient stays silent. Guard rule holds: only windows pause the day ticks. - Old single day-step survives ONLY behind the DEV MODE overlay (relabelled 'Day step (dev — old path)'); end_turn() itself is unchanged (tests pin it). - Save loaded mid-pause: controller rehydrates; answering the window resumes playback (resolve_event branch not gated on playback_active). Tests: new test_month_button_path.gd — headless smoke driving a FULL month via the exact button API (plan commit -> ticks -> auto-pause -> review -> next plan phase), with a doom clamp so pre-rebalance drift can't kill the run before the boundary. +2 controller tests (boundary hold-open, option->verb mapping). Full unit suite: 402/402 passing, 39 GUT scripts (no #590 hidden parse failures). Co-Authored-By: Claude Fable 5 --- godot/scripts/core/month_controller.gd | 63 +++++++- godot/scripts/core/window_resolver.gd | 66 +++++++- godot/scripts/debug/dev_mode_overlay.gd | 4 +- godot/scripts/game_manager.gd | 146 +++++++++++++++++- godot/scripts/ui/main_ui.gd | 11 +- godot/tests/unit/test_month_button_path.gd | 97 ++++++++++++ .../tests/unit/test_month_button_path.gd.uid | 1 + godot/tests/unit/test_month_controller.gd | 44 +++++- 8 files changed, 420 insertions(+), 12 deletions(-) create mode 100644 godot/tests/unit/test_month_button_path.gd create mode 100644 godot/tests/unit/test_month_button_path.gd.uid diff --git a/godot/scripts/core/month_controller.gd b/godot/scripts/core/month_controller.gd index aeee7839..926075a0 100644 --- a/godot/scripts/core/month_controller.gd +++ b/godot/scripts/core/month_controller.gd @@ -33,6 +33,13 @@ var status: int = Status.READY # Strategic WIP released this tick (duration elapsed) — surfaced for the caller/L2 to apply # effects; the controller does not reach into GameActions itself (clean seam). var last_released_strategic: Array = [] +# True while the month-boundary tick is HELD OPEN as the new month's plan phase. The engine +# convention is an OPEN turn during planning (started, not executed — start_new_game leaves +# turn 1 open the same way); auto-executing the boundary tick would double-run its +# consequence steps when the plan later commits. advance_tick() sets this on a boundary and +# skips _complete_tick; the caller shows the month review and waits for the plan commit +# (GameManager.end_month executes the held-open turn). +var month_open_pending: bool = false func _init(game_state: GameState, tm = null) -> void: @@ -61,6 +68,7 @@ func advance_tick() -> Dictionary: return {"status": "paused_on_window", "windows": window_queue, "message": "resolve open windows first"} var month_opened := false + month_open_pending = false if turn_manager != null: turn_manager.start_turn() # increments state.turn, fires events into pending_events @@ -69,6 +77,7 @@ func advance_tick() -> Dictionary: if mi != current_month_index: _open_plan_month(mi) month_opened = true + month_open_pending = true # Release duration-elapsed strategic WIP (seam: caller/L2 applies their effects). last_released_strategic = state.month_plan.take_due_strategic(state.turn) @@ -92,6 +101,19 @@ func advance_tick() -> Dictionary: "released": last_released_strategic, } + if month_open_pending: + # The boundary tick is HELD OPEN as the new month's plan phase — no execute_turn. + # The plan-commit path (GameManager.end_month) executes it, matching the engine's + # open-turn-during-planning convention (avoids double-running consequence steps). + _hold_open_for_planning() + return { + "status": "month_open", + "month_opened": true, + "windows": [], + "feed": surfaced.get("feed", []), + "released": last_released_strategic, + } + _complete_tick() return { "status": "ready", @@ -136,7 +158,27 @@ func resolve_current_window(response: String) -> Dictionary: _sync_pending() if window_queue.is_empty(): status = Status.READY - _complete_tick() + _finish_paused_tick() + return result + + +func resolve_current_window_option(option_id: String) -> Dictionary: + """Answer the head window by choosing one of the event's own options (the v1 dialog + path — the event_dialog presents the event's options, not the four verbs). The chosen + option maps onto the verb menu: the window's ignore option resolves as IGNORE (list + price, no Attention); any other option is a HANDLE paid reserve-first, falling back to + cannibalizing (WindowResolver.resolve_chosen_option). Payment source still lands in the + replay artifact. The explicit four-verb menu (incl. DEFER) is the plan-screen UI's job.""" + if not is_paused() or window_queue.is_empty(): + return {"success": false, "message": "no open window"} + var window: Dictionary = window_queue[0] + var result := WindowResolver.resolve_chosen_option(state, state.month_plan, window, option_id, state.rng) + if result.get("success", false): + window_queue.pop_front() + _sync_pending() + if window_queue.is_empty(): + status = Status.READY + _finish_paused_tick() return result @@ -153,10 +195,20 @@ func skip_current_window() -> Dictionary: _sync_pending() if window_queue.is_empty(): status = Status.READY - _complete_tick() + _finish_paused_tick() return result +func _finish_paused_tick() -> void: + """The last open window was answered: either complete the tick (execute consequences) + or, when the pause happened ON the month boundary, hold the tick open as the new plan + phase instead (see month_open_pending).""" + if month_open_pending: + _hold_open_for_planning() + else: + _complete_tick() + + func _complete_tick() -> void: """Finish a tick once no window is pending: run the consequence phase.""" state.current_phase = GameState.TurnPhase.ACTION_SELECTION @@ -165,6 +217,13 @@ func _complete_tick() -> void: turn_manager.execute_turn() +func _hold_open_for_planning() -> void: + """Leave the boundary tick OPEN (started, not executed): this is the month's plan phase. + The player queues actions against it; the next end_month() executes it.""" + state.current_phase = GameState.TurnPhase.ACTION_SELECTION + state.can_end_turn = true + + func _sync_pending() -> void: """Keep the serialized pending_events in step with the live window queue, so save/load captures an open pause point (ADR-0009 UI: day-tick playback resumes at the window). diff --git a/godot/scripts/core/window_resolver.gd b/godot/scripts/core/window_resolver.gd index 53dea615..0868be2b 100644 --- a/godot/scripts/core/window_resolver.gd +++ b/godot/scripts/core/window_resolver.gd @@ -134,18 +134,78 @@ static func resolve(state: GameState, plan: MonthPlan, event: Dictionary, respon return result +static func resolve_chosen_option(state: GameState, plan: MonthPlan, event: Dictionary, option_id: String, rng: RandomNumberGenerator = null) -> Dictionary: + """Resolve a window via one of the event's OWN options (the v1 dialog path: the + event_dialog presents the event's options, not the four verbs). Mapping onto the + ADR-0009 menu: + - the window's ignore option -> IGNORE (list price, no Attention drawn) + - any other option -> HANDLE, paid reserve-first, falling back to + cannibalizing un-reserved capacity/WIP + The chosen option's own in-fiction costs/effects apply (AP stripped); the real payment + source (reserve/cannibalize/ignore) lands in the replay artifact. The explicit + four-verb menu incl. DEFER is the plan-screen UI's job — DEFER is not reachable from + this v1 path.""" + var event_id := String(event.get("id", "")) + if option_id == ignore_option_id(event) and not EventTiers.is_unignorable(event): + return resolve(state, plan, event, "ignore", rng) + + # HANDLE with the chosen option. Check the option's own (AP-stripped) costs BEFORE + # drawing Attention, so a failed money check doesn't consume the reserve. + var cleaned := strip_ap(event) + var chosen: Dictionary = {} + for opt in cleaned.get("options", []): + if opt is Dictionary and String(opt.get("id", "")) == option_id: + chosen = opt + break + if chosen.is_empty(): + return {"success": false, "message": "Unknown option: %s" % option_id} + if not state.can_afford(chosen.get("costs", {})): + return {"success": false, "message": "Cannot afford this choice"} + + var cost := attention_cost(event) + var payment := "" + var cancelled: Array = [] + if plan.pay_from_reserve(cost): + payment = "reserve" + else: + var pay := plan.pay_by_cannibalizing(cost) + cancelled = pay.get("cancelled", []) + if not pay.get("paid", false): + return {"success": false, "message": "Not enough Attention to handle this window", "cancelled_wip": cancelled} + payment = "cannibalize" + + var opt_result := Events.execute_event_choice(cleaned, option_id, state) + var result := { + "success": opt_result.get("success", false), + "response": "handle", + "payment_source": payment, + "attention_paid": cost, + "cancelled_wip": cancelled, + "ledger_source": "", + "message": opt_result.get("message", ""), + "deltas": opt_result.get("deltas", {}), + } + if opt_result.has("messages"): + result["messages"] = opt_result["messages"] + if result["success"] and typeof(VerificationTracker) != TYPE_NIL: + VerificationTracker.record_window_response(event_id, "handle", payment, state.turn) + return result + + static func _apply_option(state: GameState, event: Dictionary, option_id: String) -> Dictionary: """Apply an event option by id, STRIPPING any legacy action_point cost (windows spend Attention, not AP). Returns the execute_event_choice result (or an empty success if the option id is blank — a no-op ignore).""" if option_id == "": return {"success": true, "message": "No action", "deltas": {}} - var cleaned := _strip_ap(event) + var cleaned := strip_ap(event) return Events.execute_event_choice(cleaned, option_id, state) -static func _strip_ap(event: Dictionary) -> Dictionary: - """Shallow-clone an event with action_points removed from every option's costs.""" +static func strip_ap(event: Dictionary) -> Dictionary: + """Clone an event with action_points removed from every option's costs. Public: month + playback presents windows through the event_dialog with AP already stripped, so the + dialog's affordability display matches what resolution will actually charge.""" var clone := event.duplicate(true) for opt in clone.get("options", []): if opt is Dictionary and opt.has("costs") and opt["costs"] is Dictionary: diff --git a/godot/scripts/debug/dev_mode_overlay.gd b/godot/scripts/debug/dev_mode_overlay.gd index 9f41df03..0634f4df 100644 --- a/godot/scripts/debug/dev_mode_overlay.gd +++ b/godot/scripts/debug/dev_mode_overlay.gd @@ -174,7 +174,9 @@ func _build_controls() -> Control: col.add_child(HSeparator.new()) col.add_child(_section_label("TRIGGERS")) - col.add_child(_action_button("⏭ Advance turn (dev)", _advance_turn)) + # L1: the single day-step is DEV-ONLY now — the game's End Turn plays a whole month + # (game_manager.end_month). This button remains the debugging escape hatch. + col.add_child(_action_button("⏭ Day step (dev — old path)", _advance_turn)) _event_dropdown = OptionButton.new() _event_dropdown.focus_mode = Control.FOCUS_NONE _populate_event_dropdown() diff --git a/godot/scripts/game_manager.gd b/godot/scripts/game_manager.gd index c6b27935..e50ae4d2 100644 --- a/godot/scripts/game_manager.gd +++ b/godot/scripts/game_manager.gd @@ -13,6 +13,17 @@ var state: GameState var turn_manager: TurnManager var is_initialized: bool = false +# L1 (ADR-0009): the month playback driver. Created with the game; the End Turn button +# routes through end_month() -> MonthController day ticks (auto-pause-on-window). The old +# single day-step (end_turn) survives ONLY behind the DEV MODE overlay. +var month_controller: MonthController = null +var month_playback_active: bool = false +# Seconds between visible day ticks during month playback. var (not const) so headless +# smokes/tests can run a month fast; a player-facing speed control is future UI work. +var day_tick_seconds: float = 0.2 +# Synthetic month-review dialog id — intercepted by resolve_event before any engine path. +const MONTH_REVIEW_EVENT_ID := "__month_review__" + func _ready(): print("[GameManager] Pure GDScript version ready") @@ -38,6 +49,9 @@ func start_new_game(game_seed: String = ""): _apply_difficulty_settings() turn_manager = TurnManager.new(state) + # L1: the month playback driver rides the same state + turn manager. + month_controller = MonthController.new(state, turn_manager) + month_playback_active = false is_initialized = true # Start verification tracking. The event schedule travels with the artifact @@ -478,6 +492,102 @@ func start_next_turn(): var actions = turn_manager.get_available_actions() actions_available.emit(actions) +# ============================================================================ +# MONTH LOOP (L1 / ADR-0009) — the playable turn path +# +# The End Turn button routes HERE. end_turn() above is the pre-L1 single day-step +# and survives only behind the DEV MODE overlay (debug escape hatch). Flow: +# plan commit -> execute the open plan turn -> day-tick playback (visible date +# advance, auto-pause on response windows presented via the event_dialog) -> +# month boundary -> review dialog -> next plan phase (boundary tick held open). +# Guard rule (sacred): no routine decision hangs on a day tick — only windows pause. +# ============================================================================ + +func end_month(): + """Commit the queued actions as this month's plan and play the month out day by day.""" + if not is_initialized: + error_occurred.emit("Cannot end month: Game not initialized") + return + if month_playback_active: + return # already playing the month out + if state.queued_actions.is_empty(): + error_occurred.emit("No actions queued") + return + + # Implicit reserve (v1): every Attention point not spent on strategic work guards this + # month's response windows. The full plan screen makes this an explicit dial. + if state.month_plan != null: + state.month_plan.set_reserve(state.month_plan.attention_total - state.month_plan.attention_spent) + + print("[GameManager] Committing month plan (%d actions)..." % state.queued_actions.size()) + turn_phase_changed.emit("turn_end") + + # Execute the OPEN plan turn (started at the previous boundary / game start). + state.action_points -= state.committed_ap + state.committed_ap = 0 + var result = turn_manager.execute_turn() + if result.has("action_results"): + for action_result in result["action_results"]: + action_executed.emit(action_result) + game_state_updated.emit(state.to_dict()) + if state.game_over: + return + + month_playback_active = true + _run_month_playback() # async — runs day ticks until window-pause or month boundary + + +func _run_month_playback() -> void: + """Advance visible day ticks until a window pauses playback or the month boundary is + reached. Feed items surface as log lines (pull, no acknowledgment); only windows + interrupt. Resumes from resolve_event when a pause is answered.""" + while month_playback_active and state != null and not state.game_over: + await get_tree().create_timer(day_tick_seconds).timeout + if not month_playback_active or state == null or state.game_over: + break + var r: Dictionary = month_controller.advance_tick() + game_state_updated.emit(state.to_dict()) + for item in r.get("feed", []): + var fev: Dictionary = item.get("event", {}) + action_executed.emit({"success": true, "message": "[color=gray]FEED · %s — %s[/color]" % [ + String(item.get("source_id", "?")), String(fev.get("name", fev.get("id", "update")))]}) + match String(r.get("status", "")): + "paused_on_window": + turn_phase_changed.emit("turn_start") + for w in month_controller.window_queue: + # AP pre-stripped so the dialog's cost display matches what window + # resolution actually charges (Attention, not AP). + event_triggered.emit(WindowResolver.strip_ap(w)) + return # playback resumes via resolve_event once answered + "month_open": + _finish_month_playback() + return + # Loop left by game-over/teardown. + month_playback_active = false + if state != null: + game_state_updated.emit(state.to_dict()) + + +func _finish_month_playback() -> void: + """Month boundary reached: stop playback and present the month review (a plain dialog, + v1). The boundary tick is HELD OPEN as the new month's plan phase — the next + end_month() executes it (MonthController.month_open_pending).""" + month_playback_active = false + var label := Clock.month_label(state.turn, state.start_year, state.start_month, state.start_day) + var attention_now: int = state.month_plan.available() if state.month_plan else 0 + var review := { + "id": MONTH_REVIEW_EVENT_ID, + "name": "Month Review — %s" % label, + "description": "%s begins.\n\nAttention: %d fresh decisions this month (last month's unspent reserve evaporated — no banking).\nFunds: %s · Doom: %.1f%% · Staff: %d\n\nQueue this month's actions, then End Turn to play the month out." % [ + label, attention_now, GameConfig.format_money(state.money), state.doom, state.get_total_staff()], + "type": "popup", + "options": [ + {"id": "begin_planning", "text": "Begin planning %s" % label, "costs": {}, "effects": {}} + ], + } + event_triggered.emit(review) + + func get_game_state() -> Dictionary: if state: return state.to_dict() @@ -523,6 +633,11 @@ func load_saved_game(path: String = SaveLoad.QUICKSAVE_PATH) -> bool: if custom_events.size() > 0: state.set_meta("scenario_events", custom_events) turn_manager = TurnManager.new(state) + # L1: rebuild the month playback driver; if the save was taken paused on a window, + # rehydrate re-enters the paused state so playback resumes at that window. + month_controller = MonthController.new(state, turn_manager) + month_controller.rehydrate_from_state() + month_playback_active = false is_initialized = true # NOTE: replay verification rebuilds from turn 0 (ADR-0006); a loaded session # is a snapshot continuation, so tracking restarts here only to keep the @@ -543,11 +658,40 @@ func load_saved_game(path: String = SaveLoad.QUICKSAVE_PATH) -> bool: return true func resolve_event(event: Dictionary, choice_id: String): - """Handle player's event choice - FIX #418: Use TurnManager""" + """Handle player's event choice - FIX #418: Use TurnManager. + L1: month-review dialogs and paused-playback windows route to the month loop first; + only plan-phase events (game start / legacy path) reach TurnManager below.""" if not is_initialized: error_occurred.emit("Game not initialized") return + # L1: the synthetic month-review dialog just closes into the new plan phase. + if String(event.get("id", "")) == MONTH_REVIEW_EVENT_ID: + action_executed.emit({"success": true, "message": "[color=cyan]— %s —[/color]" % String(event.get("name", "New month"))}) + game_state_updated.emit(state.to_dict()) + turn_phase_changed.emit("action_selection") + actions_available.emit(turn_manager.get_available_actions()) + return + + # L1: while day-tick playback is paused on response windows, choices route through the + # month controller (Attention payment, replay payment_source), not the legacy path. + # Also covers a save loaded mid-pause (controller rehydrated paused, playback inactive). + if month_controller != null and month_controller.is_paused(): + var wresult: Dictionary = month_controller.resolve_current_window_option(choice_id) + if wresult.get("success", false): + action_executed.emit(wresult) + game_state_updated.emit(state.to_dict()) + if not month_controller.is_paused(): + # Queue answered: either the boundary plan phase opens, or ticking resumes. + if month_controller.month_open_pending: + _finish_month_playback() + else: + month_playback_active = true + _run_month_playback() + else: + error_occurred.emit(String(wresult.get("message", "Window resolution failed"))) + return + # Use TurnManager's resolve_event which handles phase transitions var result = turn_manager.resolve_event(event, choice_id) diff --git a/godot/scripts/ui/main_ui.gd b/godot/scripts/ui/main_ui.gd index 108bc6c7..fa952595 100644 --- a/godot/scripts/ui/main_ui.gd +++ b/godot/scripts/ui/main_ui.gd @@ -528,13 +528,16 @@ func _on_end_turn_button_pressed(): log_message("[color=gray]Press Space/Enter again to confirm, or C to revise queue[/color]") # Note: Simplified version - in full implementation, would require double-confirm - log_message("[color=cyan]Committing %d actions...[/color]" % queued_actions.size()) + log_message("[color=cyan]Committing month plan (%d actions) — playing the month out...[/color]" % queued_actions.size()) # Clear queued actions (will be repopulated after turn processes) queued_actions.clear() update_queued_actions_display() - game_manager.end_turn() + # L1 (ADR-0009): End Turn commits the MONTH plan and hands control to day-tick + # playback (auto-pause on response windows, month review at the boundary). The old + # single day-step lives on ONLY behind the DEV MODE overlay ("Day step (dev)"). + game_manager.end_month() func _on_commit_plan_button_pressed(): """Commit queued actions AND reserve remaining AP (no warnings)""" @@ -568,8 +571,8 @@ func _on_commit_plan_button_pressed(): queued_actions.clear() update_queued_actions_display() - # Commit the plan - game_manager.end_turn() + # Commit the plan — the L1 month path (see _on_end_turn_button_pressed). + game_manager.end_month() func _on_employee_tab_button_pressed(): """Switch to employee management screen - DISABLED: employee info moving to main UI""" diff --git a/godot/tests/unit/test_month_button_path.gd b/godot/tests/unit/test_month_button_path.gd new file mode 100644 index 00000000..1e677185 --- /dev/null +++ b/godot/tests/unit/test_month_button_path.gd @@ -0,0 +1,97 @@ +extends GutTest +## L1 follow-up (#612): the PLAYABLE month path. main_ui's End Turn button calls +## game_manager.end_month(); this smoke drives that exact API headlessly through a full +## month: plan commit -> day-tick playback -> auto-pause windows (event_triggered -> +## resolve_event round-trip, the event_dialog wiring) -> month review dialog -> next plan +## phase. The old end_turn() day-step is untested here on purpose — it is DEV-overlay-only. + +var game_manager +var _review_event: Dictionary = {} +var _windows_answered: int = 0 +var _saved_historical_events: Array = [] + + +func before_each(): + var GameManagerScript = load("res://scripts/game_manager.gd") + game_manager = GameManagerScript.new() + add_child_autofree(game_manager) + if EventService: + _saved_historical_events = EventService.transformed_events.duplicate() + EventService.transformed_events.clear() + _review_event = {} + _windows_answered = 0 + + +func after_each(): + if EventService: + EventService.transformed_events = _saved_historical_events + + +func _auto_respond(event: Dictionary) -> void: + """Play the event_dialog's role: answer every surfaced window with its first option; + capture the month-review dialog instead of answering it (the assertion target).""" + if String(event.get("id", "")) == game_manager.MONTH_REVIEW_EVENT_ID: + _review_event = event + return + var options: Array = event.get("options", []) + var choice_id := "" + if options.size() > 0 and options[0] is Dictionary: + choice_id = String(options[0].get("id", "")) + _windows_answered += 1 + # Deferred, as the real dialog answers on a later frame than the emission. + game_manager.resolve_event.call_deferred(event, choice_id) + + +func _keep_alive(_state_dict: Dictionary) -> void: + """Harness intervention: pre-rebalance doom drift kills bot runs by turn ~4-12 (the + L0 pacing datum), which would end the run before the month boundary. Clamp doom so + the WIRING under test — a full month of playback — is reachable. Balance is G1's job.""" + if game_manager.state != null and not game_manager.state.game_over: + game_manager.state.doom = 5.0 + if game_manager.state.doom_system != null: + game_manager.state.doom_system.current_doom = 5.0 + + +func test_end_month_button_path_plays_a_full_month(): + game_manager.start_new_game("l1-button-month-smoke") + game_manager.day_tick_seconds = 0.01 # fast playback for the headless smoke + game_manager.event_triggered.connect(_auto_respond) + game_manager.game_state_updated.connect(_keep_alive) + _keep_alive({}) + + # Resolve any game-start events on the legacy plan-phase path (emitted before our + # listener connected — they still sit in pending_events). + var guard := 0 + while game_manager.state.pending_events.size() > 0 and guard < 20: + guard += 1 + var ev: Dictionary = game_manager.state.pending_events[0] + var opts: Array = ev.get("options", []) + var cid := String(opts[0].get("id", "")) if opts.size() > 0 else "" + game_manager.resolve_event(ev, cid) + + var turn0: int = game_manager.state.turn + + # The commit-plan path with an empty queue: the pass action IS the month plan. + game_manager.state.queued_actions.append(GameActions.PASS_ACTION_ID) + game_manager.end_month() + assert_true(game_manager.month_playback_active or not _review_event.is_empty(), + "end_month hands control to month playback") + + # Wait for the month boundary's review dialog (windows auto-answered along the way). + await wait_until(func(): return not _review_event.is_empty() or game_manager.state.game_over, 30) + + assert_false(game_manager.state.game_over, "the kept-alive run survives to the boundary") + assert_false(_review_event.is_empty(), "the month review dialog surfaced at the boundary") + assert_string_contains(String(_review_event.get("name", "")), "Month Review", + "the review dialog is the month review") + assert_true(game_manager.month_controller.month_open_pending, + "the boundary tick is held open as the new plan phase") + assert_gte(game_manager.state.turn - turn0, 15, + "a calendar month of workday ticks played out (got %d)" % (game_manager.state.turn - turn0)) + assert_false(game_manager.month_playback_active, "playback stopped at the boundary") + + # Closing the review opens the plan phase — the loop is closed: plan, play, review, plan. + game_manager.resolve_event(_review_event, "begin_planning") + assert_eq(game_manager.state.current_phase, GameState.TurnPhase.ACTION_SELECTION, + "back in the plan phase after the review") + assert_true(game_manager.state.can_end_turn, "the next month plan can be committed") diff --git a/godot/tests/unit/test_month_button_path.gd.uid b/godot/tests/unit/test_month_button_path.gd.uid new file mode 100644 index 00000000..abc3f5f7 --- /dev/null +++ b/godot/tests/unit/test_month_button_path.gd.uid @@ -0,0 +1 @@ +uid://dv8hr1r2aqti4 diff --git a/godot/tests/unit/test_month_controller.gd b/godot/tests/unit/test_month_controller.gd index 5cef9276..16ebaf35 100644 --- a/godot/tests/unit/test_month_controller.gd +++ b/godot/tests/unit/test_month_controller.gd @@ -87,11 +87,45 @@ func test_month_boundary_opens_fresh_plan_phase(): var r := mc.advance_tick() assert_true(r.month_opened, "crossing the calendar month opens a new plan phase") assert_eq(s.month_plan.available(), 20, "fresh full Attention grant — reserve evaporated") + # The boundary tick is HELD OPEN as the plan phase (playable path: the next + # end_month() executes it — never auto-executed, or consequences would double-run). + assert_eq(String(r.status), "month_open", "boundary tick reports the plan phase opening") + assert_true(mc.month_open_pending, "boundary tick held open for planning") + assert_eq(s.current_phase, GameState.TurnPhase.ACTION_SELECTION, "plan phase is open") + assert_true(s.can_end_turn, "the player can commit the next month plan") + + +func test_resolve_window_by_option_pays_reserve_first(): + # The v1 dialog path: the player picks one of the event's own options; a non-ignore + # option is a HANDLE paid reserve-first (payment source still hits the replay artifact). + var s := _state() + var mc := MonthController.new(s, null) + s.month_plan.set_reserve(3) + s.pending_events.assign([_window("opt_crisis")]) + mc.advance_tick() + var res := mc.resolve_current_window_option("handle") + assert_true(res.success, "choosing the handle option resolves the window") + assert_eq(String(res.payment_source), "reserve", "paid from the crisp reserve first") + assert_eq(s.month_plan.reserve_remaining(), 2, "the window's Attention cost (1) was drawn") + assert_false(mc.is_paused(), "playback resumes") + + +func test_resolve_window_by_ignore_option_maps_to_ignore(): + var s := _state() + var mc := MonthController.new(s, null) + s.month_plan.set_reserve(3) + s.pending_events.assign([_window("opt_pass")]) + mc.advance_tick() + var res := mc.resolve_current_window_option("ignore") + assert_true(res.success, "the window's ignore option resolves as IGNORE") + assert_eq(String(res.payment_source), "ignore", "recorded as ignore, not handle") + assert_eq(s.month_plan.reserve_remaining(), 3, "IGNORE draws no Attention") func test_headless_playable_month_loop_runs(): # The acceptance smoke: drive real ticks through TurnManager, answering any window that - # fires, and confirm the month loop advances without crashing and rolls the plan month. + # fires and committing the plan at each held-open boundary (simulating the End Turn + # button), and confirm the month loop advances and rolls the plan month. var s := _state() var tm = TurnManager.new(s) var mc := MonthController.new(s, tm) @@ -106,6 +140,14 @@ func test_headless_playable_month_loop_runs(): while mc.is_paused() and wguard < 20: wguard += 1 mc.resolve_current_window("ignore") + if mc.month_open_pending: + tm.execute_turn() # plan commit for a boundary that paused on a window + ticks += 1 + elif String(r.status) == "month_open": + # Boundary tick held open for planning — commit an (empty) plan and play on, + # mirroring GameManager.end_month(). + tm.execute_turn() + ticks += 1 else: ticks += 1 assert_true(s.turn >= 30, "the loop advanced many day ticks (turn=%d)" % s.turn) From a4e7661a7eb36ce28697e64c8e9eebda7ce7077d Mon Sep 17 00:00:00 2001 From: Pip Date: Mon, 13 Jul 2026 21:38:06 +1000 Subject: [PATCH 5/6] =?UTF-8?q?fix(dev):=20honest=20build=20badge=20?= =?UTF-8?q?=E2=80=94=20live=20git=20HEAD=20in=20dev,=20stamped=20fallback?= =?UTF-8?q?=20marked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The baked build_stamp.txt (fd60eb6 · 2026-07-11) was two days stale on every branch and cost a playtest session to build confusion. Dev checkouts now read the REAL identity at runtime: BuildInfo.get_live_git_stamp() shells git -C rev-parse (gated on a .git dir/file existing, so exported builds never try; cached after one probe; worktrees resolve correctly since git handles the .git file). Badge forms, each self-identifying: - dev checkout: 'DEV BUILD v0.11.0 · l1-month-turn-engine@e4cd408 · live' - exported/no git: '... · fd60eb6 · 2026-07-11 (stamp)' - never stamped: '... · unstamped' Acceptance: two different checkouts can never show the same badge silently — live HEAD differs per checkout, and a stamped badge visibly declares it is a stamp. A stale stamp additionally prints a console note (print, not push_warning: in a dev checkout the stamp is stale after every commit by construction; GUT/CI must not count that as an engine error). +2 badge tests (live identity present in a git checkout; every badge form declares its provenance). Co-Authored-By: Claude Fable 5 --- godot/scripts/core/build_info.gd | 69 ++++++++++++++++++-- godot/tests/unit/test_dev_build_indicator.gd | 20 ++++++ 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/godot/scripts/core/build_info.gd b/godot/scripts/core/build_info.gd index cc6977ab..07035692 100644 --- a/godot/scripts/core/build_info.gd +++ b/godot/scripts/core/build_info.gd @@ -51,17 +51,76 @@ static func get_build_date() -> String: static func is_dev_build() -> bool: return DEV_BUILD -## Compact commit+date stamp, e.g. "fd60eb6 · 2026-07-11". Always non-empty: -## falls back to the date, then to "unstamped", so the overlay never shows blank. +# --- Live git identity (L1 follow-up: the stamp went stale and cost a playtest) -------- +# +# The baked stamp is written at package time and silently rots in a dev checkout (it read +# "fd60eb6 · 2026-07-11" on every branch for two days). Dev builds now read the REAL HEAD +# from git at runtime; the baked stamp is only the fallback for exported builds (no .git, +# no git binary) and is explicitly marked "(stamp)" so it can never pass as live. +# Acceptance: two different checkouts can never show the same badge silently — live HEAD +# differs per checkout, and a stamped badge visibly says it is a stamp. + +## Cache: "" = not probed yet; "-" = probed, git unavailable (don't re-shell every frame). +static var _live_git_cache: String = "" + + +## Live "branch@shortsha" read from git at runtime, or "" when this is not a git checkout +## (exported build) or git is not installed. Cached after the first probe. Also emits a +## LOUD stale-stamp warning when the baked stamp disagrees with the live HEAD. +static func get_live_git_stamp() -> String: + if _live_git_cache != "": + return "" if _live_git_cache == "-" else _live_git_cache + _live_git_cache = "-" + + # Only shell out when this looks like a git checkout: the repo root is one level above + # the godot project dir; .git is a dir in a normal clone and a FILE in a git worktree. + var root := ProjectSettings.globalize_path("res://").path_join("..") + var dotgit := root.path_join(".git") + if not (DirAccess.dir_exists_absolute(dotgit) or FileAccess.file_exists(dotgit)): + return "" + + var out: Array = [] + if OS.execute("git", ["-C", root, "rev-parse", "--short", "HEAD"], out) != 0 or out.is_empty(): + return "" + var sha := String(out[0]).strip_edges() + if sha.is_empty(): + return "" + + var branch := "" + var bout: Array = [] + if OS.execute("git", ["-C", root, "rev-parse", "--abbrev-ref", "HEAD"], bout) == 0 and not bout.is_empty(): + branch = String(bout[0]).strip_edges() + if branch == "HEAD": + branch = "detached" + _live_git_cache = ("%s@%s" % [branch, sha]) if not branch.is_empty() else sha + + # The baked stamp exists AND disagrees with reality -> say so in the console. The badge + # shows live git regardless, so a stale stamp can never be mistaken for the build + # identity. (print, not push_warning: in a dev checkout the stamp is stale after every + # commit by construction — GUT/CI must not count that as an engine error.) + var stamped := get_commit() + if not stamped.is_empty() and not sha.begins_with(stamped) and not stamped.begins_with(sha): + print("[BuildInfo] NOTE: build_stamp.txt is stale (stamp %s vs live HEAD %s) — badge uses live git" % [stamped, sha]) + + return _live_git_cache + + +## Compact build-identity stamp. Dev checkouts: live git, e.g. +## "l1-month-turn-engine@a1b2c3d · live". Exported/no-git: the baked stamp explicitly +## marked "(stamp)", e.g. "fd60eb6 · 2026-07-11 (stamp)". Always non-empty: degrades to +## the date, then "unstamped", so the overlay never shows blank. static func get_stamp() -> String: + var live := get_live_git_stamp() + if not live.is_empty(): + return "%s · live" % live var commit := get_commit() var date := get_build_date() if not commit.is_empty() and not date.is_empty(): - return "%s · %s" % [commit, date] + return "%s · %s (stamp)" % [commit, date] if not commit.is_empty(): - return commit + return "%s (stamp)" % commit if not date.is_empty(): - return date + return "%s (stamp)" % date return "unstamped" ## One-line badge text for the corner indicator, e.g. diff --git a/godot/tests/unit/test_dev_build_indicator.gd b/godot/tests/unit/test_dev_build_indicator.gd index e386ccc4..de9632f5 100644 --- a/godot/tests/unit/test_dev_build_indicator.gd +++ b/godot/tests/unit/test_dev_build_indicator.gd @@ -28,3 +28,23 @@ func test_open_ledger_keybind_registered_on_L(): func test_open_ledger_keybind_has_readable_name(): assert_eq(KeybindManager.get_key_name("open_ledger"), "L", "open_ledger should surface a human-readable key name") + +# --- Honest badge (L1 follow-up: a stale baked stamp cost a playtest session) --------- + +func test_live_git_stamp_reflects_this_checkout(): + # This suite runs inside a git checkout (clone or worktree), so the live probe must + # find HEAD — the dev badge can therefore never be a silently stale baked stamp. + var live := BuildInfo.get_live_git_stamp() + if live.is_empty(): + pending("git unavailable here — exported-build fallback path (stamp marked '(stamp)')") + return + assert_string_contains(live, "@", "live identity carries branch@sha") + assert_string_contains(BuildInfo.get_stamp(), "live", + "a dev checkout's badge is marked live, not a baked stamp") + +func test_stamped_fallback_is_visibly_a_stamp_never_live(): + # The fallback format must self-identify: when the badge is NOT live git, it says + # "(stamp)" (or "unstamped") — two checkouts can never show the same badge silently. + var stamp := BuildInfo.get_stamp() + assert_true(stamp.contains("live") or stamp.contains("(stamp)") or stamp == "unstamped", + "every badge form declares its provenance (live / stamp / unstamped), got: %s" % stamp) From 4269c0b0e2af0360a93205a8f24a9845a2340fdf Mon Sep 17 00:00:00 2001 From: Pip Date: Tue, 14 Jul 2026 09:51:41 +1000 Subject: [PATCH 6/6] =?UTF-8?q?fix(L1):=20pop=20window=20only=20after=20pa?= =?UTF-8?q?yment=20validates=20=E2=80=94=20failed=20verb=20keeps=20it=20op?= =?UTF-8?q?en?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Balance-sweep finding: MonthController.resolve_current_window() (the four-verb path) popped the window off the queue BEFORE WindowResolver.resolve validated payment, and never re-pushed on failure — handle_reserve with an empty reserve silently dropped the window (no effect, no charge, no window, playback stuck paused on an empty queue). Latent in the shipped v1 dialog (which routes through resolve_current_window_option, whose guard is correct) but fatal to the future plan-screen UI. Mirror the option path's guard: peek the head, resolve, pop only on success. skip_current_window hardened with the same shape (its unignorable pre-check made it safe today; the pop-before-validate trap is now gone everywhere). Regression test: handle_reserve with empty reserve -> fails, window still queued AND still in the serialized pause point, then resolves via handle_cannibalize. Full unit suite: 403/403 passing, 39 GUT scripts. Co-Authored-By: Claude Fable 5 --- godot/scripts/core/month_controller.gd | 14 +++++++++++--- godot/tests/unit/test_month_controller.gd | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/godot/scripts/core/month_controller.gd b/godot/scripts/core/month_controller.gd index 926075a0..169670d9 100644 --- a/godot/scripts/core/month_controller.gd +++ b/godot/scripts/core/month_controller.gd @@ -150,11 +150,17 @@ func _dispatch(events: Array) -> Dictionary: func resolve_current_window(response: String) -> Dictionary: """Answer the head window with a costed response (ADR-0009 §3). When the queue empties, - the paused tick completes (execute_turn runs).""" + the paused tick completes (execute_turn runs). The window is popped only AFTER the + resolver validates payment — a failed verb (e.g. handle_reserve with an empty reserve) + leaves the window open and still demanding a decision, resolvable via another verb + (pop-before-validate silently dropped it: no effect, no charge, no window).""" if not is_paused() or window_queue.is_empty(): return {"success": false, "message": "no open window"} - var window: Dictionary = window_queue.pop_front() + var window: Dictionary = window_queue[0] var result := WindowResolver.resolve(state, state.month_plan, window, response, state.rng) + if not result.get("success", false): + return result # window stays queued; playback stays paused + window_queue.pop_front() _sync_pending() if window_queue.is_empty(): status = Status.READY @@ -190,8 +196,10 @@ func skip_current_window() -> Dictionary: var window: Dictionary = window_queue[0] if EventTiers.is_unignorable(window): return {"success": false, "message": "window is unignorable — must be handled"} - window_queue.pop_front() var result := WindowResolver.resolve(state, state.month_plan, window, "auto_ignore", state.rng) + if not result.get("success", false): + return result # same pop-only-on-success guard as resolve_current_window + window_queue.pop_front() _sync_pending() if window_queue.is_empty(): status = Status.READY diff --git a/godot/tests/unit/test_month_controller.gd b/godot/tests/unit/test_month_controller.gd index 16ebaf35..629f94ea 100644 --- a/godot/tests/unit/test_month_controller.gd +++ b/godot/tests/unit/test_month_controller.gd @@ -122,6 +122,26 @@ func test_resolve_window_by_ignore_option_maps_to_ignore(): assert_eq(s.month_plan.reserve_remaining(), 3, "IGNORE draws no Attention") +func test_failed_verb_leaves_window_open_and_resolvable(): + # Regression (balance-sweep finding): the four-verb path popped the window BEFORE the + # resolver validated payment, so handle_reserve with an empty reserve silently dropped + # the window — no effect, no charge, no window. Latent in the v1 dialog (the option + # path guards correctly) but fatal to the future plan-screen UI. + var s := _state() + var mc := MonthController.new(s, null) + s.month_plan.set_reserve(0) # nothing held — handle_reserve must fail + s.pending_events.assign([_window("underfunded")]) + mc.advance_tick() + var res := mc.resolve_current_window("handle_reserve") # window costs 1, reserve 0 + assert_false(res.success, "handle_reserve fails with an empty reserve") + assert_true(mc.is_paused(), "playback stays paused on the unanswered window") + assert_eq(mc.window_queue.size(), 1, "the window was NOT silently dropped") + assert_eq(s.pending_events.size(), 1, "the serialized pause point still holds it") + var res2 := mc.resolve_current_window("handle_cannibalize") + assert_true(res2.success, "the same window resolves via another verb") + assert_false(mc.is_paused(), "playback resumes after the real resolution") + + func test_headless_playable_month_loop_runs(): # The acceptance smoke: drive real ticks through TurnManager, answering any window that # fires and committing the plan at each held-open boundary (simulating the End Turn