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/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/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/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/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/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_controller.gd b/godot/scripts/core/month_controller.gd new file mode 100644 index 00000000..169670d9 --- /dev/null +++ b/godot/scripts/core/month_controller.gd @@ -0,0 +1,263 @@ +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 = [] +# 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: + 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 + month_open_pending = 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 + 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) + + 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, + } + + 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", + "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). 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[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 + _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 + + +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"} + 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 + _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 + state.can_end_turn = true + if turn_manager != null: + 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). + 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/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/scripts/core/window_resolver.gd b/godot/scripts/core/window_resolver.gd new file mode 100644 index 00000000..0868be2b --- /dev/null +++ b/godot/scripts/core/window_resolver.gd @@ -0,0 +1,249 @@ +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 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) + return Events.execute_event_choice(cleaned, option_id, state) + + +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: + 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/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 2f065d56..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,13 +49,20 @@ 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 # (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) @@ -474,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() @@ -519,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 @@ -539,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) @@ -656,3 +804,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/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_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_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) 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_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 new file mode 100644 index 00000000..629f94ea --- /dev/null +++ b/godot/tests/unit/test_month_controller.gd @@ -0,0 +1,175 @@ +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") + # 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_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 + # 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) + 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") + 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) + 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_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 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 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