diff --git a/docs/balance/DOOM_STREAMS_v1.md b/docs/balance/DOOM_STREAMS_v1.md new file mode 100644 index 00000000..6b24fe72 --- /dev/null +++ b/docs/balance/DOOM_STREAMS_v1.md @@ -0,0 +1,96 @@ +# DOOM_STREAMS_v1 — ADR-0015 nine-stream doom coefficients + rationale + +**Lane:** doom-migration (ADR-0015 flagship) · **Branch:** `adr-0015-doom-streams` (base `l1-balance-calibration` @ `edb2e3a`) · **Date:** 2026-07-15 +**Function:** `godot/scripts/core/doom_system.gd` · **Pricing:** `godot/data/balance/defaults.json → doom.streams.*` · **Harness:** `godot/tests/manual/test_l1_month_sweep.gd` (72 runs) + +> **What this is.** ADR-0015 makes doom an ACCUMULATING RATE computed each day tick from a +> sum of NAMED STREAMS, each fed by a DQ-21 world-state intermediary. No action/event writes +> doom directly. This memo prices the streams so the calibration headline (`L1_CALIBRATION_2026-07-14.md`) +> reproduces within tolerance. **The function is structure; these numbers are the freely-tuned layer.** + +## 1. The streams (what each is, which intermediary feeds it) + +`doom_rate = Σ streams` (superposition — MAY be negative). `doom_level += doom_rate` each tick. + +| Stream | Intermediary (DQ-21) | Meaning | Sign | +|---|---|---|---| +| `baseline` | `ambient_risk` | year-keyed background floor (2017 low, climbs) | + | +| `overhang` | `frontier_capability` − `safety_absorption` | ACUTE hazard: frontier not matched by absorption; `max` over actors | + (clamped ≥0 v1) | +| `diffusion` | `general_capability` | chronic commoditized floor (ratchet) | + | +| `compute` | `dedicated_ai_compute` | the controllable fleet (v1: inert, `W_compute=0`) | + | +| `panic` | `global_panic` | social accelerant (reckless rival/player moves, risk shocks) | + | +| `alarm` | `global_alarm` | small standing RELIEF (productive concern) + damper gate | − | +| `ledger` | (routed input) | ADR-0003 bill teeth, routed via `add_stream_input` | + | +| `technical_debt` | `technical_debt` | GameState coupling (migrated from a direct write) | + | +| `pulse:*` | `doom_pulses` | ADR-0005 scheduled envelopes (v1: none wired) | ± | +| `momentum` | — | gated trend modifier on the sum (`doom.momentum_enabled/weight`) | ± | + +`political_pressure` and `global_compute` are **gate/derivation-only** — no stream of their own +(DQ-21 R2-Q2 / Q-SEM-COMPUTE). Typed dampers subtract from a specific stream (clamped ≥0 v1). + +## 2. Stream coefficients (`doom.streams.*`) — the v1 prices + +| Key | Value | What it does / why this size | +|---|---|---| +| `W_frontier` | **0.000039** | overhang weight on `max(frontier)−absorption`. Set so **do_nothing (rival-frontier only) lands at median 14**; larger → do_nothing dies faster. | +| `cap_frontier_gain` | **60.0** | player frontier gained per productive capability researcher / tick. Set so **greedy_overcommit dies ~7** off its own frontier while do_nothing is unaffected. | +| `safety_absorb_gain` | **8.0** | safety_absorption gained per productive safety researcher / tick. Offsets rival frontier in the overhang gap; set so **safety/reserve outlast passive but still die** (rivals eventually outpace absorption). | +| `alarm_gain` | **0.05** | global_alarm per productive safety researcher / tick. Small (DQ-21 §1.7 "alarm small by design"); large values drive a sustained-negative rate (trips the trend invariant). | +| `W_alarm` | **0.02** | alarm stream = −0.02·global_alarm — the natively-NEGATIVE relief component. | +| `W_panic` | **0.02** | panic stream weight; with `panic_decay` sets the steady rival-pressure floor. | +| `panic_per_capability_action` | **0.05** (`rivals.*`) | global_panic per reckless rival capability move → the steady early rival pressure floor (replaces the retired per-action doom literals). | +| `panic_decay` | **0.97** | habituation on panic (steady-state ≈ input/0.03). | +| `alarm_decay` | **0.985** | habituation on alarm. | +| `W_general` | **0.0005** | diffusion weight (small growing floor). | +| `diffusion_gain` | **0.00006** | general_capability per tick from Σ rival frontier (slow ratchet). | +| `W_compute` | **0.0** | compute stream inert in v1 (the ocean has no own doom term, DQ-21 Q-SEM-COMPUTE). | +| `leak_panic_scale` | **0.02** | researcher-leak / stationery-failure incidents → global_panic. | +| `action_*` (founder-action → intermediary scales) | **0.0** unless noted | founder safety/audit/paper actions priced at 0 in v1 (their influence already flows via the researcher advance; a follow-up prices them). Non-sweep levers (lobby/warning/acquire/sabotage/opensource/conference/cat) carry real nonzero scales. | + +## 3. Intermediary dynamics (flow → stock) + +Each tick, `DoomSystem._advance_intermediaries()`: +- **player frontier** += `cap_frontier_gain` × (productive capability researchers) +- **safety_absorption** += `safety_absorb_gain` × (productive safety researchers); **global_alarm** += `alarm_gain` × same +- **rival frontier** = each rival's `capability_progress` (already accumulates in `rivals.gd`); the overhang scalar is `max` over all actors +- **general_capability** += `diffusion_gain` × Σ rival frontier (ratchet) +- **ambient_risk** = `doom.base_per_turn` (0.06); **alarm/panic** decay by their `*_decay` + +"Productive" = managed AND compute-fed, mirroring `_step_researcher_productivity`. + +## 4. Regression — calibrated vs migrated (72-run sweep, same seeds/policies) + +Both measured on this machine. Calibrated = `l1-balance-calibration` baseline; migrated = this branch @ locked coefficients. + +| Policy | Calibrated median (range) | Migrated median (range) | death-cause (migrated) | contract | +|---|---|---|---|---| +| `do_nothing` | 14.0 (13–15) | **14.0 (14–15)** | 10/0/0 doom | ✅ median 14 | +| `safety_lean` | 16.5 (13–21) | 18.0 (17–26) | 8/0/0 doom | 🟠 +1.5 (in n=8 variance; ordering held) | +| `reserve_heavy` | 19.0 (15–45) | **19.5 (17–21)** | 8/0/0 doom | ✅ | +| `random_walk` | 8.0 (4–13) | 6.0 (4–13) | 0/0/30 ledger | 🟠 −2 (still ledger-rooted) | +| `greedy_overcommit` | 7.0 (6–13) | **7.5 (7–14)** | 8/0/0 doom | ✅ T4 + T9 (min 7) | +| `loan_desperation` | 7.0 (6–8) | **7.0 (6–7)** | 0/0/8 ledger | ✅ T5 + T9 (min 6) | + +`MORTALITY_CHECK: max_months=26, immortal_runs=0 → PASS` (calibrated max was 45; both « 400). + +**Contract status:** do_nothing median 14 ✅ · T9 floor ≥6 for all standard policies ✅ (greedy 7, +loan 6; random_walk min 4 is the fuzzer, matching the calibrated baseline) · mortality 0 immortal, +max 26 ✅ · loan_desperation ledger-rooted (8/8) ✅ · ordering `greedy≈loan≈random < do_nothing < +safety < reserve` ✅. + +**Deviations (precise):** +1. `safety_lean` 18.0 vs 16.5 (+1.5 months). Within the memo's stated n=8 variance (±2–3). Ordering + (safety > do_nothing) intact. Bringing it down further trades against the sustained-negative-rate + trend-invariant flag (over-suppression). Left slightly high rather than over-tuned. +2. `random_walk` 6.0 vs 8.0 (−2 months). Still 30/30 ledger-rooted — it dies of its ~40% reckless + ledger moves, not of engaging. The stock-based overhang makes reckless capability moves bite a + touch faster than the old flow model; consistent with the calibration's own "below-band, above the + reckless cluster" reading, just lower. + +## 5. What is NOT priced here (v1 hooks, follow-up lanes) + +- **Scheduled pulses** (`pulse:*`) — envelope schema wired, no content (DQ-21 R2-Q5/Q6). +- **Typed dampers** — engine + gate wired (`doom_dampers`, alarm/political_pressure), no content grants yet (R2-Q4 damper pricing deferred). +- **Founder safety-action intermediary influence** — priced at 0 (their effect flows via the researcher advance; a follow-up prices the founder actions). +- **Event-content doom (data/events/*.json)** — the un-migrated clobber remainder (Legacy #15 / memo §7.1); inert no-ops today, re-authored to intermediaries by a follow-up content lane. +- **R2-Q9 stream clamp** — hazard streams clamp ≥0 in v1; the natively-negative alarm stream is exempt. LOUD REVISIT MARKER in `_compute_streams`. diff --git a/godot/data/balance/defaults.json b/godot/data/balance/defaults.json index d3cffc99..b0b08fff 100644 --- a/godot/data/balance/defaults.json +++ b/godot/data/balance/defaults.json @@ -53,7 +53,21 @@ "alignment_base": -0.25 }, "unproductive_per_staff": 0.025, - "status_cutoffs": { "safe": 25.0, "warning": 50.0, "danger": 70.0, "critical": 90.0 } + "streams": { + "_description": "ADR-0015 nine-stream coefficients (doom_system.gd). doom_rate = sum of named streams read from the DQ-21 world-state intermediaries. These are the freely-tuned pricing layer; the stream FUNCTION is structure. W_* weight a stream's intermediary; *_gain set how fast an intermediary accumulates per day tick; *_decay fade the social stocks. Priced by the 72-run L1 sweep to reproduce the calibration headline (DOOM_STREAMS_v1.md).", + "W_frontier": 0.000039, + "W_general": 0.0005, + "W_compute": 0.0, + "W_panic": 0.02, + "W_alarm": 0.02, + "cap_frontier_gain": 60.0, + "safety_absorb_gain": 8.0, + "alarm_gain": 0.05, + "diffusion_gain": 0.00006, + "alarm_decay": 0.985, + "panic_decay": 0.97, + "leak_panic_scale": 0.02 + } }, "salaries": { "_description": "Annual salary bases. The /260 workday divisor is a TIME constant and stays in code until the L0 Clock lands (Clock seam wiring is an L9 follow-up).", @@ -77,9 +91,8 @@ "per_staff": 0.5 }, "rivals": { - "_description": "Rival lab coefficients (rivals.gd). Per-action magnitudes remain in code (follow-up batch). per_tick_doom_scale re-denominates the per-DAY-TICK rival doom to the month cadence (ADR-0009); it multiplies the summed per-action doom + overhang in turn_manager._step_process_rival_turns (1.0 = pre-L1 unscaled).", - "capability_overhang_doom_per_progress": 0.002, - "per_tick_doom_scale": 0.02, + "_description": "Rival lab coefficients (rivals.gd). ADR-0015: rivals no longer emit doom literals — capability_overhang_doom_per_progress and per_tick_doom_scale (the pre-L1 shim) are RETIRED (Legacy #7/#12). Rival capability_progress IS the actor's frontier_capability slice, converted by the DoomSystem overhang stream; panic_per_capability_action feeds global_panic when a rival makes a reckless capability move.", + "panic_per_capability_action": 0.05, "discovery": { "base_chance": 0.15, "chance_per_rep_above_threshold": 0.005 } }, "risk": { diff --git a/godot/scripts/core/actions.gd b/godot/scripts/core/actions.gd index d970e9b5..f335c1ce 100644 --- a/godot/scripts/core/actions.gd +++ b/godot/scripts/core/actions.gd @@ -290,10 +290,14 @@ static func execute_action(action_id: String, state: GameState) -> Dictionary: result["message"] = "Purchased compute (+50 compute)" "safety_research": - # Safety research reduces doom - var doom_reduction = state.safety_researchers * 1.0 - state.add_resources({"doom": -doom_reduction}) - result["message"] = "Safety research (-%0.1f doom)" % doom_reduction + # ADR-0015: safety research raises safety_absorption (the overhang stream reads it + # to offset frontier hazard) + global_alarm — never a direct doom write. Founder- + # action contribution priced at 0 in v1 (the productive-researcher advance already + # carries safety's absorption in the sweep); a follow-up lane prices the action. + var absorb = state.safety_researchers * Balance.num("doom.streams.action_safety_absorb", 0.0) + state.safety_absorption += absorb + state.global_alarm += state.safety_researchers * Balance.num("doom.streams.action_safety_alarm", 0.0) + result["message"] = "Safety research (+%0.1f safety absorption)" % absorb "capability_research": # Capability research generates research points @@ -308,11 +312,14 @@ static func execute_action(action_id: String, state: GameState) -> Dictionary: if researcher.has_trait("media_savvy"): media_savvy_bonus += 3 # +3 reputation per media savvy researcher var total_rep = 2 + media_savvy_bonus - state.add_resources({"papers": 1, "doom": -3, "reputation": total_rep}) + # ADR-0015: publishing safety work raises global_alarm (adopted safety concern, + # DQ-21 §1.7) instead of a printed -3 doom. Priced at 0 in v1 (a follow-up prices it). + state.add_resources({"papers": 1, "reputation": total_rep}) + state.global_alarm += Balance.num("doom.streams.action_paper_alarm", 0.0) if media_savvy_bonus > 0: - result["message"] = "Published paper (+1 paper, -3 doom, +%d reputation including media bonus)" % total_rep + result["message"] = "Published paper (+1 paper, +%d reputation including media bonus)" % total_rep else: - result["message"] = "Published paper (+1 paper, -3 doom, +2 reputation)" + result["message"] = "Published paper (+1 paper, +2 reputation)" "fundraise": # Submenu action - doesn't execute, opens dialog @@ -359,10 +366,13 @@ static func execute_action(action_id: String, state: GameState) -> Dictionary: result["message"] = "Accepted funding with strings (+$40k; a governance obligation bills later)" "desperation_lever": - state.add_resources({"doom": -10.0}) + # ADR-0015: the "-10 doom now" catch-up buys a brief safety_absorption reprieve (a + # temporary offset) instead of a printed doom write — and it was clobbered before + # (memo §7.1). Priced at 0 in v1; the SECRET compounding liability (the teeth) is intact. + state.safety_absorption += Balance.num("doom.streams.action_desperation_absorb", 0.0) if state.ledger: state.ledger.add(Ledger.desperation_payroll(state.rng)) - result["message"] = "Pulled a desperation lever (-10 doom now; a SECRET liability is planted)" + result["message"] = "Pulled a desperation lever (brief reprieve now; a SECRET liability is planted)" "staff_rider": state.action_points += 2 @@ -471,11 +481,12 @@ static func execute_action(action_id: String, state: GameState) -> Dictionary: var reduction = min(researcher.burnout, 15.0) researcher.reduce_burnout(reduction) burnout_reduced += reduction - state.add_resources({"reputation": 2, "doom": -1}) + # ADR-0015: team morale is not a printed doom write (it was clobbered anyway). + state.add_resources({"reputation": 2}) if burnout_reduced > 0: - result["message"] = "Team building event (+2 reputation, -1 doom, -%.0f team burnout)" % burnout_reduced + result["message"] = "Team building event (+2 reputation, -%.0f team burnout)" % burnout_reduced else: - result["message"] = "Team building event (+2 reputation, -1 doom)" + result["message"] = "Team building event (+2 reputation)" "media_campaign": var rep_gained = 10 + int(state.reputation * 0.1) @@ -483,19 +494,25 @@ static func execute_action(action_id: String, state: GameState) -> Dictionary: result["message"] = "Media campaign (+%d reputation)" % rep_gained "audit_safety": + # ADR-0015: a safety audit raises safety_absorption (adopted scrutiny), not a printed + # doom write. Priced at 0 in v1 (a follow-up prices the founder action). var doom_reduced = 5 + state.safety_researchers - state.add_resources({"doom": -doom_reduced, "reputation": 3}) - result["message"] = "Safety audit (-%d doom, +3 reputation)" % doom_reduced + state.safety_absorption += doom_reduced * Balance.num("doom.streams.action_audit_absorb", 0.0) + state.add_resources({"reputation": 3}) + result["message"] = "Safety audit (+scrutiny, +3 reputation)" "order_supplies": state.stationery = min(state.stationery + 50.0, 100.0) result["message"] = "Office supplies restocked (+50 stationery, now at %.0f)" % state.stationery "lobby_government": - # Lobbying reduces doom but costs reputation (fix #449) + # ADR-0015: lobbying pushes political_pressure toward governance + raises global_alarm + # (which gates typed dampers), never a printed doom write. Costs reputation (fix #449). var doom_reduction = 8 + (state.reputation * 0.1) - state.add_resources({"doom": -doom_reduction, "reputation": -10}) - result["message"] = "Government lobbying (-%0.1f doom, -10 reputation)" % doom_reduction + state.political_pressure += doom_reduction * Balance.num("doom.streams.action_lobby_pressure", 0.5) + state.global_alarm += doom_reduction * Balance.num("doom.streams.action_lobby_alarm", 0.5) + state.add_resources({"reputation": -10}) + result["message"] = "Government lobbying (+political pressure/alarm, -10 reputation)" "release_warning": # Risky: big doom reduction but reputation hit and random outcome @@ -508,8 +525,11 @@ static func execute_action(action_id: String, state: GameState) -> Dictionary: var doom_reduction = 15 + doom_roll var rep_change = rep_roll - state.add_resources({"doom": -doom_reduction, "reputation": rep_change}) - result["message"] = "Public warning issued (-%d doom, %+d reputation)" % [doom_reduction, rep_change] + # ADR-0015: a credible public warning raises global_alarm (productive concern), not a + # printed doom write. Alarm feeds the alarm stream (relief) + gates dampers (DQ-21 §1.7). + state.global_alarm += doom_reduction * Balance.num("doom.streams.action_warning_alarm", 0.6) + state.add_resources({"reputation": rep_change}) + result["message"] = "Public warning issued (+alarm, %+d reputation)" % rep_change "acquire_startup": # Gain staff and compute @@ -524,8 +544,9 @@ static func execute_action(action_id: String, state: GameState) -> Dictionary: result["message"] = "Acquired safety-focused startup (+2 safety researchers, +30 compute)" 1: state.capability_researchers += 2 - state.add_resources({"doom": 3}) - result["message"] = "Acquired capability startup (+2 cap researchers, +30 compute, +3 doom)" + # ADR-0015: capability acquisition raises the PLAYER frontier (overhang converts it). + state.frontier_capability["player"] = float(state.frontier_capability.get("player", 0.0)) + Balance.num("doom.streams.action_acquire_frontier", 60.0) + result["message"] = "Acquired capability startup (+2 cap researchers, +30 compute, +frontier)" 2: state.compute_engineers += 2 result["message"] = "Acquired compute startup (+2 compute engineers, +30 compute)" @@ -539,18 +560,34 @@ static func execute_action(action_id: String, state: GameState) -> Dictionary: VerificationTracker.record_rng_outcome("sabotage_success", sabotage_roll, state.turn) if sabotage_roll > 0.3: # 70% success - var doom_reduction = 20 - state.add_resources({"doom": -doom_reduction}) - result["message"] = "Sabotage successful! Competitor delayed (-%d doom)" % doom_reduction + # ADR-0015: successful sabotage DELAYS a rival — it lowers the top rival's frontier + # (capability_progress), which the overhang stream reads. No printed doom write. + var delay = Balance.num("doom.streams.action_sabotage_frontier", 200.0) + var top_rival = null + for rl in state.rival_labs: + if top_rival == null or rl.capability_progress > top_rival.capability_progress: + top_rival = rl + if top_rival != null: + top_rival.capability_progress = max(0.0, top_rival.capability_progress - delay) + result["message"] = "Sabotage successful! Competitor delayed" else: # 30% caught - state.add_resources({"doom": 10, "reputation": -25}) - result["message"] = "Sabotage EXPOSED! Major scandal (+10 doom, -25 reputation)" + # Caught: a scandal -> global_panic (reckless move the world reacts to) + rep hit. + state.global_panic += Balance.num("doom.streams.action_sabotage_panic", 5.0) + state.add_resources({"reputation": -25}) + result["message"] = "Sabotage EXPOSED! Major scandal (+panic, -25 reputation)" "open_source_release": - # Share research for global benefit + # ADR-0015: framed as a goodwill/transparency move -> global_alarm (norms shift). + # DESIGN TENSION FLAGGED (DQ-21 §1.1): open-sourcing capability really raises + # general_capability (diffusion = MORE hazard); the action's historical -doom framing + # is the kind of dishonest pipe ADR-0015 exists to expose. Routed to alarm for now + # (behaviour-preserving intent), general_capability contribution priced at 0 pending + # a design ruling. Non-sweep action; safe to make functional. var doom_reduction = 10 + (state.papers * 2) - state.add_resources({"doom": -doom_reduction, "reputation": 15}) - result["message"] = "Open source release! (-%d doom, +15 reputation)" % doom_reduction + state.global_alarm += doom_reduction * Balance.num("doom.streams.action_opensource_alarm", 0.6) + state.general_capability += doom_reduction * Balance.num("doom.streams.action_opensource_diffusion", 0.0) + state.add_resources({"reputation": 15}) + result["message"] = "Open source release! (+alarm, +15 reputation)" "emergency_pivot": # Convert capability researchers to safety researchers @@ -789,7 +826,10 @@ static func attend_conference_action(state: GameState, conf_id: String, travel_c if presenting_paper.is_safety_paper(): doom_reduction *= 1.5 - state.add_resources({"reputation": rep_gain, "doom": -doom_reduction}) + # ADR-0015: presenting (esp. a safety paper) raises global_alarm (adopted concern), + # not a printed doom write. Non-sweep path; safe to make functional. + state.global_alarm += doom_reduction * Balance.num("doom.streams.action_conference_alarm", 0.6) + state.add_resources({"reputation": rep_gain}) return { "success": true, diff --git a/godot/scripts/core/doom_system.gd b/godot/scripts/core/doom_system.gd index 66e95977..2d8e0f14 100644 --- a/godot/scripts/core/doom_system.gd +++ b/godot/scripts/core/doom_system.gd @@ -1,367 +1,451 @@ extends Node class_name DoomSystem -## Modular doom calculation system - designed for incremental complexity -## Start simple, add layers over time (Path of Exile style) +## ADR-0015 doom engine: doom is an ACCUMULATING RATE computed each day tick from a sum +## of NAMED STREAMS, each stream fed by a world-state intermediary (DQ-21). No action or +## event writes doom directly — they write intermediaries; the doom function reads them. +## +## The nine intermediary concepts (DQ-21, round-4 rulings): +## general_capability -> diffusion stream (chronic floor; ratchet) +## frontier_capability -> overhang stream (acute hazard = frontier - safety_absorption) +## global_compute -> (no own stream; feeds dedicated_ai_compute / diffusion pace) +## dedicated_ai_compute-> compute stream (the controllable fleet) +## ambient_risk -> baseline stream (year-keyed background trend) +## political_pressure -> (gate-only; no stream — gates typed dampers) +## global_alarm -> alarm stream (small NEGATIVE relief) AND damper gate +## global_panic -> panic stream (social accelerant) +## scheduled pulses -> pulse:* streams (ramp/spike/tail envelopes, ADR-0005) +## plus typed dampers (targeted, durationed reductions on specific streams) and a gated +## momentum modifier on the sum. doom_rate = sum(streams); doom_level += rate * dt. +## +## Single-authority discipline (calibration #638): ONLY this system writes state.doom. The +## ledger's bills and risk shocks arrive as STREAM INPUTS (add_stream_input), folded into the +## rate on the next resolve — never a parallel write to the level. +## +## Legibility (ADR-0015 §3 / L6 EE-8): every stream is named, so the delta chip / death +## attribution can say which stream (and the intermediary behind it) is killing or saving you. +## The "wiggly stock-market lines" are the emergent interference of legible streams, never noise. # ============================================================================ -# PHASE 1: DOOM MOMENTUM (Current Implementation) +# CORE STATE — the two instruments (DQ-21 display ruling) # ============================================================================ -## Core doom state +## The accumulated doom LEVEL (structural-dread surface; its crossing date is the badge). var current_doom: float = 50.0 -## Rival doom pending contribution — turn_manager sets this via set_rival_doom_contribution() -## BEFORE calculate_doom_change(); it must be re-applied in _calculate_rival_doom() AFTER the -## per-turn _reset_doom_sources(), or it gets wiped to 0 (the bug behind issue #562). -var _pending_rival_doom: float = 0.0 +## The most recent doom RATE (delta-doom; the high-frequency feedback surface). +var doom_rate: float = 0.0 -## Momentum mechanics - doom changes accumulate and compound -var doom_velocity: float = 0.0 # Current rate of doom change per turn -var doom_momentum: float = 0.0 # Accumulated momentum (compounds over time) +## Per-tick history of the rate — feeds the N=6-month trend telemetry invariant (DQ-21 R2-Q7). +var rate_history: Array[float] = [] -## Momentum tuning parameters — initialized from the Balance surface ("doom.*", -## L9 #621) in _init(); the literals here are the call-site fallbacks. -var momentum_accumulation_rate: float = 0.15 # How much of each doom change becomes momentum -var momentum_decay_rate: float = 0.92 # Momentum decay per turn (0.92 = 8% decay) -var momentum_cap: float = 8.0 # Maximum momentum (prevents infinite spirals) +# ============================================================================ +# STREAMS — doom_rate = sum of these named streams (superposition; MAY be negative) +# ============================================================================ +## Kept under the historical name `doom_sources` so existing consumers (dev overlays, the +## sweep's doom_src_at_death readout, GameState's technical_debt coupling) keep working; the +## KEYS are now the ADR-0015 stream vocabulary. +var doom_sources: Dictionary = { + "baseline": 0.0, # ambient_risk — year-keyed background floor + "overhang": 0.0, # frontier_capability - safety_absorption (acute) + "diffusion": 0.0, # general_capability (chronic floor) + "compute": 0.0, # dedicated_ai_compute pressure + "panic": 0.0, # global_panic (social accelerant) + "alarm": 0.0, # -global_alarm (small standing relief) + "ledger": 0.0, # routed ledger-bill stream input (ADR-0003 teeth) + "technical_debt": 0.0, # GameState tech-debt coupling (migrated from a direct write) + "momentum": 0.0, # gated trend modifier on the sum (not an intermediary) +} +## Buffered STREAM INPUTS from off-tick sources (ledger bills, risk shocks). Added via +## add_stream_input()/add_event_doom() during the turn, folded into the streams on the next +## calculate_doom_change(), then cleared — so nothing writes the LEVEL in parallel (#638). +var _pending_stream_inputs: Dictionary = {} -func _init() -> void: - momentum_accumulation_rate = Balance.num("doom.momentum_accumulation_rate", momentum_accumulation_rate) - momentum_decay_rate = Balance.num("doom.momentum_decay_rate", momentum_decay_rate) - momentum_cap = Balance.num("doom.momentum_cap", momentum_cap) +## Rival overhang contribution stashed by turn_manager. LEGACY SHIM RETIRED (ADR-0015): rivals +## now raise frontier_capability[actor] and the overhang stream converts it; this field is kept +## only as a harmless no-op sink so any un-migrated caller cannot crash. Always 0 in the new path. +var _pending_rival_doom: float = 0.0 # ============================================================================ -# PHASE 2: DOOM SOURCES (Tracking & Visualization) +# MOMENTUM — a stream-level trend modifier, gated behind a Balance switch (#638) # ============================================================================ +var doom_velocity: float = 0.0 +var doom_momentum: float = 0.0 +var momentum_accumulation_rate: float = 0.15 +var momentum_decay_rate: float = 0.92 +var momentum_cap: float = 8.0 -## Source tracking - know where doom is coming from -var doom_sources: Dictionary = { - "base": 0.0, # Base organizational risk - "capabilities": 0.0, # From capabilities research - "rivals": 0.0, # From competitor actions - "safety": 0.0, # From safety research (negative) - "unproductive": 0.0, # From idle employees - "events": 0.0, # From random events - "momentum": 0.0, # From accumulated momentum - # Future sources (placeholder): - "technical_debt": 0.0, # Phase 3 - "specializations": 0.0, # Phase 3 - "cascades": 0.0, # Phase 4 - "market_pressure": 0.0, # Phase 4 -} +# Multipliers/modifiers retained as an extension point (unused by the stream model in v1). +var doom_multipliers: Dictionary = {} +var doom_modifiers: Dictionary = {} # ============================================================================ -# PHASE 3: DOOM MODIFIERS (Future - Researcher Specs, Tech Debt) +# TREND TELEMETRY (DQ-21 R2-Q7, N=6) — instrumentation, NEVER a clamp # ============================================================================ +const TREND_MONTHS := 6 +const TICKS_PER_MONTH := 22 # approx workday ticks / fiction month (ADR-0009) +var trend_flag_active: bool = false +var _last_trend_flag_tick: int = -1 -## Multipliers for doom sources (1.0 = normal, >1.0 = worse, <1.0 = better) -var doom_multipliers: Dictionary = { - "capabilities": 1.0, # Modified by: researcher traits, market cycles - "safety": 1.0, # Modified by: researcher specializations, upgrades - "rivals": 1.0, # Modified by: scouting, intelligence actions - "events": 1.0, # Modified by: reputation, safety culture -} - -## Flat doom modifiers (added/subtracted from sources) -var doom_modifiers: Dictionary = { - "capabilities": 0.0, # e.g., "Safety Conscious" trait = -0.5 - "safety": 0.0, # e.g., "Specialization bonus" = +1.0 to reduction - "rivals": 0.0, # e.g., "Intelligence gathered" = -2.0 -} +func _init() -> void: + momentum_accumulation_rate = Balance.num("doom.momentum_accumulation_rate", momentum_accumulation_rate) + momentum_decay_rate = Balance.num("doom.momentum_decay_rate", momentum_decay_rate) + momentum_cap = Balance.num("doom.momentum_cap", momentum_cap) # ============================================================================ -# PHASE 4: DOOM AXES (Future - Multi-dimensional doom) +# STREAM COEFFICIENTS — the freely-tuned Balance layer (doom.streams.*). +# The FUNCTION is structure (ADR-0015 §4); these NUMBERS are priced by the sweep. # ============================================================================ - -## Future: Split doom into strategic categories -var doom_axes_enabled: bool = false -var doom_capability_risk: float = 50.0 -var doom_safety_gap: float = 50.0 -var doom_competitive_pressure: float = 50.0 +func _w(key: String, fallback: float) -> float: + return Balance.num("doom.streams." + key, fallback) # ============================================================================ -# CORE DOOM CALCULATION +# CORE: doom_rate = sum of named streams, computed each day tick # ============================================================================ func calculate_doom_change(state: GameState) -> Dictionary: - """ - Calculate doom change for this turn. - Returns Dictionary with breakdown and metadata. - - Design: Modular calculation that can be extended over time - """ - - # Reset source tracking - _reset_doom_sources() - - # === PHASE 1: BASIC DOOM SOURCES === - _calculate_base_doom() - _calculate_capability_doom(state) - _calculate_safety_doom(state) - _calculate_rival_doom(state) - _calculate_unproductive_doom(state) - - # === PHASE 2: SOURCE TRACKING === - var raw_doom_change = _sum_doom_sources() - - # === PHASE 3: APPLY MULTIPLIERS (future enhancement point) === - raw_doom_change = _apply_doom_multipliers(raw_doom_change) - - # === PHASE 1: MOMENTUM SYSTEM === - # Momentum is a low-commitment SWITCH (#638 review ruling): doom.momentum_enabled - # (0/1) turns the contribution off entirely; doom.momentum_weight scales it. The - # internal state (velocity/momentum accumulators) still ticks while disabled, so - # flipping the switch mid-run behaves sanely and the trend readout stays live. - var momentum_contribution = _calculate_momentum(raw_doom_change) + """ADR-0015 day tick: advance the world-state intermediaries, recompute every named + stream from them, sum to the rate, integrate into the level. Returns a breakdown dict + (same shape the message builder + sweep expect).""" + + # (0) Advance the hazard-state intermediaries this tick (flow -> stock). This is the + # ONLY place capability/absorption/social stocks tick; effects elsewhere seed them. + _advance_intermediaries(state) + + # (1) Recompute streams from the intermediaries. + var streams := _compute_streams(state) + + # (2) Fold in buffered off-tick stream inputs (ledger bills, risk shocks) — routed, not + # parallel-written. Attributed to their own named streams so L6 can see them. + for key in _pending_stream_inputs.keys(): + streams[key] = float(streams.get(key, 0.0)) + float(_pending_stream_inputs[key]) + _pending_stream_inputs.clear() + + # (3) GameState tech-debt coupling: migrated from a direct doom write to a named stream + # (Legacy #13). GameState writes doom_sources["technical_debt"]; carry it forward. + streams["technical_debt"] = float(doom_sources.get("technical_debt", 0.0)) + + # (4) Raw rate = superposition of hazard/relief streams (MAY be negative). + var raw_rate := 0.0 + for key in streams.keys(): + raw_rate += float(streams[key]) + + # (5) Momentum — a gated trend modifier ON TOP of the raw rate (#638 switch semantics: + # the accumulator keeps ticking while disabled; only the contribution is zeroed). + var momentum_contribution := _calculate_momentum(raw_rate) momentum_contribution *= Balance.num("doom.momentum_weight", 1.0) if Balance.num("doom.momentum_enabled", 1.0) < 0.5: momentum_contribution = 0.0 - doom_sources["momentum"] = momentum_contribution + streams["momentum"] = momentum_contribution + + var total_rate := raw_rate + momentum_contribution - # === FINAL DOOM CHANGE === - var total_doom_change = raw_doom_change + momentum_contribution + # (6) Integrate: the LEVEL accumulates the RATE. May FALL on net-negative ticks (legal — + # "pulled something impressive off"). Clamp 0..100 is a display/lethality bound. + # Snap the persisted scalars to the binary-exact grid (SAVE_QUANTUM) so save/load is + # LOSSLESS (the "next turn identical" replay must match bit-for-bit — see SAVE_QUANTUM). + doom_rate = _snap(total_rate) + current_doom = _snap(clamp(current_doom + total_rate, 0.0, 100.0)) + doom_velocity = _snap(doom_velocity) + doom_momentum = _snap(doom_momentum) - # Update doom - current_doom += total_doom_change - current_doom = clamp(current_doom, 0.0, 100.0) + # (7) Publish the streams for L6 chips / dev overlay / sweep, and run the trend invariant. + doom_sources = streams + rate_history.append(doom_rate) + _check_trend_invariant(state, streams) - # Return detailed breakdown return { - "total_change": total_doom_change, - "raw_change": raw_doom_change, + "total_change": total_rate, + "raw_change": raw_rate, "momentum": momentum_contribution, "velocity": doom_velocity, - "sources": doom_sources.duplicate(), + "sources": streams.duplicate(), + "streams": streams.duplicate(), "new_doom": current_doom, - "trend": _get_doom_trend() + "rate": total_rate, + "level": current_doom, + "trend": _get_doom_trend(), } # ============================================================================ -# PHASE 1 CALCULATIONS +# INTERMEDIARY ADVANCEMENT (flow -> stock) — the ratchet dynamics # ============================================================================ -func _calculate_base_doom(): - """Base doom increase - organizational risk""" - doom_sources["base"] = Balance.num("doom.base_per_turn", 1.0) - -func _calculate_capability_doom(state: GameState): - """Doom from capabilities research - Modified by specializations if using new system""" - # Let _calculate_safety_doom handle both if using researcher system - if state.researchers.size() > 0: - return # Will be calculated in _calculate_researcher_doom - - # Legacy fallback - doom_sources["capabilities"] = state.capability_researchers * Balance.num("doom.legacy_capability_per_researcher", 3.0) - -func _calculate_safety_doom(state: GameState): - """Doom reduction from safety research - Modified by researcher specializations""" - # Use new researcher system if available - if state.researchers.size() > 0: - _calculate_researcher_doom(state) +func _advance_intermediaries(state: GameState) -> void: + """Advance the stored world-state intermediaries one day tick. Rivals already accumulate + their own capability_progress (read directly as their frontier slice); here we advance the + PLAYER frontier, the safety_absorption stock, the diffusion floor, and decay the social + stocks. All magnitudes are Balance-priced (doom.streams.*).""" + if state == null: return - # Legacy fallback - # Only count productive safety researchers - var total_staff = state.safety_researchers + state.capability_researchers + state.compute_engineers - var management_capacity = state.get_management_capacity() - var managed = min(total_staff, management_capacity) - var available_compute = int(state.compute) - var productive = min(managed, available_compute) - - # Estimate productive safety researchers (proportional distribution) - var productive_safety = 0 - if total_staff > 0: - var safety_ratio = float(state.safety_researchers) / float(total_staff) - productive_safety = int(productive * safety_ratio) - - doom_sources["safety"] = -productive_safety * Balance.num("doom.legacy_safety_per_researcher", 3.5) - -func _calculate_researcher_doom(state: GameState): - """Calculate doom from individual researchers with specializations""" - var total_staff = state.researchers.size() - var management_capacity = state.get_management_capacity() - var managed = min(total_staff, management_capacity) - var available_compute = int(state.compute) - var productive_count = min(managed, available_compute) - - var safety_doom = 0.0 - var cap_doom = 0.0 - var spec_doom = 0.0 # Specialization modifiers - - # Calculate per-researcher contributions - var processed = 0 - for researcher in state.researchers: - # Only productive researchers contribute - if processed >= productive_count: - break - processed += 1 - - var productivity = researcher.get_effective_productivity() - - # Base doom contribution by specialization - match researcher.specialization: - "safety": - # Base -3.5, modified by specialization bonus (15%) - var base = Balance.num("doom.researcher.safety_base", -3.5) * productivity - var bonus = base * Researcher.SPECIALIZATIONS["safety"]["doom_reduction_bonus"] - safety_doom += base + bonus - - "capabilities": - # Base 3.0, with doom penalty (5%) - var base = Balance.num("doom.researcher.capabilities_base", 3.0) * productivity - var penalty = base * Researcher.SPECIALIZATIONS["capabilities"]["doom_per_research"] - cap_doom += base + penalty - - "interpretability": - # Counts as safety researcher - safety_doom += Balance.num("doom.researcher.interpretability_base", -3.0) * productivity - - "alignment": - # Counts as safety researcher with slight bonus - safety_doom += Balance.num("doom.researcher.alignment_base", -3.2) * productivity - - # Apply researcher's personal doom modifier (from traits) - spec_doom += researcher.get_doom_modifier() * productivity - - doom_sources["safety"] = safety_doom - doom_sources["capabilities"] = cap_doom - doom_sources["specializations"] = spec_doom - -func _calculate_rival_doom(_state: GameState): - """Doom from rival actions - computed by turn_manager, stashed in _pending_rival_doom. - Applied here (after _reset_doom_sources) so it survives into the source sum. (Issue #562)""" - doom_sources["rivals"] = _pending_rival_doom - -func _calculate_unproductive_doom(state: GameState): - """Doom from unproductive employees""" - # Use researchers array if available (new system), otherwise use legacy counts - var total_staff: int - if state.researchers.size() > 0: - total_staff = state.researchers.size() + # --- Player frontier + safety absorption + alarm, from the PRODUCTIVE researcher roster. + # Reuse the engine's productive-count discipline (managed AND compute-fed researchers work). + var cap_gain := _w("cap_frontier_gain", 0.9) # frontier per productive capability researcher / tick + var absorb_gain := _w("safety_absorb_gain", 1.15) # absorption per productive safety researcher / tick + var alarm_gain := _w("alarm_gain", 0.14) # global_alarm per productive safety researcher / tick + + var cap_workers := 0 + var safety_workers := 0 + if state.researchers.is_empty(): + # Legacy count fallback (pre-researcher-object sweeps / L2): proportion the productive + # staff by specialization count. + var mgmt := int(state.get_management_capacity()) + var avail := int(state.compute) + var total := state.safety_researchers + state.capability_researchers + state.compute_engineers + var prod: int = min(min(total, mgmt), avail) + if total > 0: + cap_workers = int(round(prod * float(state.capability_researchers) / float(total))) + safety_workers = int(round(prod * float(state.safety_researchers) / float(total))) else: - total_staff = state.safety_researchers + state.capability_researchers + state.compute_engineers + for r in _productive_researchers(state): + match r.specialization: + "capabilities": + cap_workers += 1 + "safety", "interpretability", "alignment": + safety_workers += 1 + + var pf: float = float(state.frontier_capability.get("player", 0.0)) + pf += cap_workers * cap_gain + state.frontier_capability["player"] = pf + state.safety_absorption += safety_workers * absorb_gain + state.global_alarm += safety_workers * alarm_gain + + # --- Rival frontier slices (read their accumulating capability_progress) + diffusion. + var rival_frontier_sum := 0.0 + for rival in state.rival_labs: + state.frontier_capability[rival.id] = rival.capability_progress + rival_frontier_sum += rival.capability_progress + # Diffusion floor: today's frontier becomes tomorrow's commodity, slowly (ratchet). + state.general_capability += _w("diffusion_gain", 0.00006) * rival_frontier_sum + + # --- Dedicated compute: the controllable fleet (player + rivals). Cheap proxy in v1. + state.dedicated_ai_compute = float(state.compute) + + # --- Ambient risk baseline: year-keyed later; v1 = the re-denominated base floor. + state.ambient_risk = Balance.num("doom.base_per_turn", 0.06) + + # --- Social stocks decay toward zero (habituation): alarm and panic both fade if unfed. + state.global_alarm *= _w("alarm_decay", 0.985) + state.global_panic *= _w("panic_decay", 0.97) + + # --- Snap every persisted intermediary to the binary-exact grid (SAVE_QUANTUM). The decay + # multiplies produce arbitrary-precision doubles that fail Godot's JSON parse round-trip + # (§7.2); keeping the stocks on a power-of-two grid makes save/load LOSSLESS (the + # "next turn identical" replay must match bit-for-bit). Gameplay-invisible (grid ~1e-6). + state.frontier_capability["player"] = _snap(float(state.frontier_capability["player"])) + state.safety_absorption = _snap(state.safety_absorption) + state.general_capability = _snap(state.general_capability) + state.dedicated_ai_compute = _snap(state.dedicated_ai_compute) + state.ambient_risk = _snap(state.ambient_risk) + state.global_alarm = _snap(state.global_alarm) + state.global_panic = _snap(state.global_panic) + + # --- political_pressure tracks the alarm/panic balance (signed disposition; gate only). + state.political_pressure = _snap(state.global_alarm - state.global_panic) + +func _compute_streams(state: GameState) -> Dictionary: + """Read the intermediaries and produce the named hazard/relief streams (all Balance-priced). + Hazard streams clamp at >= 0 in v1 (DQ-21 R2-Q9 — LOUD REVISIT MARKER below); the alarm + stream is natively NEGATIVE by design and is deliberately exempt from the clamp — that + clamp/negative-component boundary is the exact unsettled ground R2-Q9 flagged for revisit.""" + var s := {} + + # (a) baseline — ambient_risk, the always-there floor. + s["baseline"] = state.ambient_risk + + # (b) overhang — the ACUTE hazard: frontier not matched by safety absorption. Scalar the + # doom function reads is max over actors (DQ-21 §1.2). + var frontier_max := 0.0 + for actor in state.frontier_capability.keys(): + frontier_max = maxf(frontier_max, float(state.frontier_capability[actor])) + # vvv R2-Q9 v1 CLAMP (LOUD REVISIT MARKER — streams clamp at 0 in v1; NOT SETTLED) vvv + s["overhang"] = _w("W_frontier", 0.000145) * maxf(0.0, frontier_max - state.safety_absorption) + # ^^^ revisit together with the natively-negative alarm stream (DQ-21 R2-Q9) ^^^ + + # (c) diffusion — chronic floor from general_capability. + s["diffusion"] = _w("W_general", 0.02) * state.general_capability + + # (d) compute — dedicated_ai_compute fuel term (small in v1; the ocean has no own term). + s["compute"] = _w("W_compute", 0.0) * state.dedicated_ai_compute + + # (e) panic — additive social accelerant. + s["panic"] = _w("W_panic", 0.02) * state.global_panic + + # (f) alarm — small direct NEGATIVE relief (a genuinely alarmed world is slightly safer even + # before formal governance lands). Small by design; heavy lifting is in dampers. + s["alarm"] = -_w("W_alarm", 0.02) * state.global_alarm + + # (g) scheduled pulses — ADR-0005 schedule entries inject time-shaped rate bumps. v1 ships + # no pulse content; the hook is wired so the schema addition (R2-Q6) has a landing site. + for pulse in _active_pulses(state): + s["pulse:" + str(pulse.get("id", "?"))] = float(pulse.get("rate", 0.0)) + + # --- TYPED DAMPERS — targeted, durationed reductions on SPECIFIC streams, gated by + # alarm/political_pressure (DQ-21 §2). v1 clamps a damped hazard stream at >= 0. + for d in _active_dampers(state): + var tgt := str(d.get("target", "")) + if s.has(tgt): + s[tgt] = maxf(0.0, float(s[tgt]) - float(d.get("strength", 0.0))) + + return s - var management_capacity = state.get_management_capacity() - var managed = min(total_staff, management_capacity) - var available_compute = int(state.compute) - var productive = min(managed, available_compute) - - # Unproductive = total staff minus those who are both managed AND have compute - # This correctly counts: unmanaged + managed-but-no-compute - var total_unproductive = total_staff - productive +# ============================================================================ +# SCHEDULED PULSES + TYPED DAMPERS (v1 hooks; content lands in later lanes) +# ============================================================================ - doom_sources["unproductive"] = total_unproductive * Balance.num("doom.unproductive_per_staff", 0.5) +func _active_pulses(state: GameState) -> Array: + """ADR-0005 schedule entries with a pulse envelope active at the current tick. v1: none + (R2-Q5 ships no cyclic machinery); the read is here so content/schedule can populate it.""" + if state != null and "doom_pulses" in state and state.doom_pulses is Array: + return state.doom_pulses + return [] + +func _active_dampers(state: GameState) -> Array: + """Typed dampers currently in force (target_stream, strength, expires_turn). Granted by + completed workstreams / adopted safety work / governance wins, gated by alarm/pressure.""" + if state == null or not ("doom_dampers" in state) or not (state.doom_dampers is Array): + return [] + var live: Array = [] + for d in state.doom_dampers: + if int(d.get("expires_turn", 0)) >= int(state.turn): + live.append(d) + return live # ============================================================================ -# MOMENTUM SYSTEM (PHASE 1) +# STREAM INPUTS (ledger bills, risk shocks) — routed, not parallel-written # ============================================================================ -func _calculate_momentum(raw_doom_change: float) -> float: - """ - Calculate momentum contribution. +func add_stream_input(stream: String, amount: float) -> void: + """Buffer an off-tick contribution to a NAMED stream (ledger bill, risk shock). Folded into + the rate on the next calculate_doom_change() and attributed to `stream` — never a direct + write to the level (single-authority discipline, #638 / ADR-0015).""" + _pending_stream_inputs[stream] = float(_pending_stream_inputs.get(stream, 0.0)) + amount + +func add_event_doom(amount: float, reason: String = "") -> void: + """Back-compat shim for the ledger/risk callers. Routes to a named stream input instead of + the old `current_doom += amount` parallel write. Ledger bills -> `ledger` stream; risk + shocks -> a `panic`-flavored stream (a shock the world reacts to). Attribution preserved.""" + var stream := "ledger" + if reason.begins_with("risk"): + stream = "panic" + add_stream_input(stream, amount) + +func set_rival_doom_contribution(_amount: float) -> void: + """RETIRED (ADR-0015): rivals now raise frontier_capability and the overhang stream converts + it — there is no per-tick rival doom literal. Kept as a no-op sink so an un-migrated caller + cannot crash; the value is ignored.""" + _pending_rival_doom = 0.0 - Momentum accumulates from doom changes and compounds over time. - Creates "doom spiral" or "safety flywheel" effects. - """ +# ============================================================================ +# MOMENTUM (gated trend modifier) +# ============================================================================ - # Update velocity (smoothed doom change). Carry/gain are independent Balance keys - # (NOT computed as 1-carry) so the shipped doubles match the pre-L9 literals exactly. +func _calculate_momentum(raw_doom_change: float) -> float: doom_velocity = doom_velocity * Balance.num("doom.velocity_carry", 0.7) \ - + raw_doom_change * Balance.num("doom.velocity_gain", 0.3) # 30% new, 70% old - - # Accumulate momentum (portion of doom change becomes momentum) + + raw_doom_change * Balance.num("doom.velocity_gain", 0.3) doom_momentum += raw_doom_change * momentum_accumulation_rate - - # Cap momentum (prevent infinite spirals) doom_momentum = clamp(doom_momentum, -momentum_cap, momentum_cap) - - # Decay momentum slightly each turn (prevents permanent momentum) doom_momentum *= momentum_decay_rate - - # Return momentum contribution return doom_momentum # ============================================================================ -# PHASE 3: MULTIPLIERS & MODIFIERS (Extension Point) +# TREND TELEMETRY INVARIANT (DQ-21 R2-Q7, N=6) — LOUD FLAG, never a clamp # ============================================================================ -func _apply_doom_multipliers(raw_doom_change: float) -> float: - """ - Apply multipliers to doom sources. - - Extension point for: - - Researcher specializations - - Market cycles - - Technical debt effects - - Upgrade bonuses - """ - - # Future: Apply multipliers per source - # For now, just return raw value - return raw_doom_change - -func apply_doom_modifier(source: String, modifier: float): - """ - Add a modifier to a doom source. - - Examples: - - apply_doom_modifier("capabilities", -0.5) # Safety Conscious trait - - apply_doom_modifier("safety", 1.0) # Specialization bonus - """ - if doom_modifiers.has(source): - doom_modifiers[source] += modifier - -func set_doom_multiplier(source: String, multiplier: float): - """ - Set a multiplier for a doom source. - - Examples: - - set_doom_multiplier("rivals", 0.7) # Intelligence reduces rival doom 30% - - set_doom_multiplier("events", 1.5) # Market panic increases event doom 50% - """ - if doom_multipliers.has(source): - doom_multipliers[source] = multiplier +func _check_trend_invariant(state: GameState, streams: Dictionary) -> void: + """Flag (debug + telemetry) a SUSTAINED 6-month negative rate trend without a + sacred-object-grade cause. Single negative ticks are legal and unflagged. This NEVER + clamps the rate — a bot policy that sustains this state is an exploit-sweep gate failure.""" + var window := TREND_MONTHS * TICKS_PER_MONTH + if rate_history.size() < window: + trend_flag_active = false + return + var trend := 0.0 + for i in range(rate_history.size() - window, rate_history.size()): + trend += rate_history[i] + if trend < 0.0 and not _sacred_grade_cause_in_window(state, window): + trend_flag_active = true + if _last_trend_flag_tick != int(state.turn): + _last_trend_flag_tick = int(state.turn) + # Telemetry/debug LOG line (NOT push_warning): the invariant is an instrument, and + # a sustained decline is LEGAL play (only the exploit sweep gates on it). push_warning + # would surface this legal state as an engine fault (and GUT flags any pushed warning + # during a sim as a test failure); a tagged print keeps it loud in logs + telemetry. + print("[DOOM_TREND_INVARIANT] sustained %d-month negative doom trend (%.2f) without a sacred-object-grade cause at turn %d — streams=%s" % [ + TREND_MONTHS, trend, int(state.turn), str(streams)]) + else: + trend_flag_active = false + +func _sacred_grade_cause_in_window(state: GameState, window: int) -> bool: + """A sacred-object-grade cause = a completed gauntlet chain / sacrifice payment logged + recently (DQ-21 §2b). v1 has no such content wired, so this is always false; the hook is + here so the invariant is correct the moment sacred-chain content lands.""" + if state == null or not ("sacred_chain_log" in state): + return false + for entry in state.sacred_chain_log: + if int(state.turn) - int(entry.get("turn", -9999)) <= window: + return true + return false # ============================================================================ -# EVENT DOOM (Extension Point) +# PER-STREAM API (F3 overlay data wiring + two-instrument readouts, DQ-21 display) # ============================================================================ -func add_event_doom(amount: float, _reason: String = ""): - """ - Add doom from an event. - Useful for one-time doom spikes (breakthroughs, cascades, etc.) - """ - # TODO: Use _reason for logging/tracking - doom_sources["events"] += amount - current_doom += amount - current_doom = clamp(current_doom, 0.0, 100.0) - -func set_rival_doom_contribution(amount: float): - """Called by turn_manager after processing rival actions. - Stored (not written straight to doom_sources) so the next calculate_doom_change() - reset does not wipe it before _calculate_rival_doom() re-applies it. (Issue #562)""" - _pending_rival_doom = amount +func get_doom_rate() -> float: + """Instrument (a): the delta-doom RATE — rises/falls with player outputs.""" + return doom_rate + +func get_doom_level() -> float: + """Instrument (b): the accumulated doom LEVEL — the badge is its crossing date.""" + return current_doom + +func get_stream_contributions() -> Dictionary: + """F3 overlay data: the per-stream breakdown of the most recent rate (named streams -> + signed contributions). The 'wiggle' decomposed into legible components.""" + return doom_sources.duplicate() + +func get_dominant_stream() -> String: + """The single stream contributing the most (by magnitude) to the current rate — the chip + the delta-doom display would surface on a click.""" + var best := "" + var best_mag := 0.0 + for k in doom_sources.keys(): + var m: float = abs(float(doom_sources[k])) + if m > best_mag: + best_mag = m + best = k + return best # ============================================================================ -# UTILITY FUNCTIONS +# HELPERS # ============================================================================ -func _reset_doom_sources(): - """Reset source tracking for new turn""" - for key in doom_sources.keys(): - doom_sources[key] = 0.0 +func _productive_researchers(state: GameState) -> Array: + """Researchers that actually work this tick: managed AND compute-fed, in roster order + (mirrors _step_researcher_productivity's discipline so the intermediary advance matches + the productivity the rest of the engine sees).""" + var out: Array = [] + if state.researchers.is_empty(): + return out + var management_capacity := int(state.get_management_capacity()) + var available_compute := int(state.compute) + var idx := 0 + for r in state.researchers: + if idx >= management_capacity: + break + if available_compute <= 0: + break + available_compute -= 1 + out.append(r) + idx += 1 + return out -func _sum_doom_sources() -> float: - """Sum all doom sources except momentum (calculated separately)""" - var total = 0.0 +func _reset_doom_sources() -> void: for key in doom_sources.keys(): - if key != "momentum": - total += doom_sources[key] - return total + doom_sources[key] = 0.0 func _get_doom_trend() -> String: - """Get human-readable doom trend""" if doom_velocity < -2.0: return "strongly_decreasing" elif doom_velocity < -0.5: @@ -374,16 +458,10 @@ func _get_doom_trend() -> String: return "strongly_increasing" func get_doom_status() -> String: - """Doom tier id — ThemeManager's canonical band label, lowercased (L6 unification: - nominal/elevated/high/severe/extreme/catastrophic/terminal at 15/37/52/67/80/92). - Was a divergent 25/50/70/90 copy (safe/warning/danger/critical/catastrophic), and a - separate Balance "doom.status_cutoffs" copy (L9 #621) — both superseded so the doom - BANDS have one source (ThemeManager); doom MAGNITUDES still come from Balance. - Display-only consumers (dev overlays, state doom_status field).""" + """Doom tier id — ThemeManager's canonical band label, lowercased (display-only).""" return ThemeManager.get_doom_status_label(current_doom).to_lower() func get_momentum_description() -> String: - """Human-readable momentum description""" if abs(doom_momentum) < 0.5: return "neutral" elif doom_momentum > 0: @@ -391,68 +469,65 @@ func get_momentum_description() -> String: else: return "safety flywheel (%.1f)" % abs(doom_momentum) -# ============================================================================ -# FUTURE EXTENSION POINTS (Commented Examples) -# ============================================================================ +# Extension points retained for compatibility with callers that poke modifiers. +func apply_doom_modifier(source: String, modifier: float) -> void: + if doom_modifiers.has(source): + doom_modifiers[source] += modifier -# PHASE 3 EXAMPLE: Researcher Specialization -# func apply_researcher_specialization_effects(researchers: Array): -# for researcher in researchers: -# match researcher.specialization: -# "safety": -# apply_doom_modifier("safety", 0.15) # +15% safety effectiveness -# "capabilities": -# apply_doom_modifier("capabilities", 0.05) # +5% cap doom -# "interpretability": -# set_doom_multiplier("events", 0.9) # -10% event doom -# "alignment": -# apply_doom_modifier("rivals", -0.5) # Reduce rival doom - -# PHASE 3 EXAMPLE: Technical Debt -# func apply_technical_debt_effects(debt: int): -# var debt_multiplier = 1.0 + (debt * 0.05) # 5% per debt point -# set_doom_multiplier("rivals", debt_multiplier) -# doom_sources["technical_debt"] = debt * 0.2 # Direct doom from debt - -# PHASE 4 EXAMPLE: Multi-Axis Doom -# func enable_doom_axes(): -# doom_axes_enabled = true -# # Split current doom into axes -# doom_capability_risk = current_doom * 0.4 -# doom_safety_gap = current_doom * 0.4 -# doom_competitive_pressure = current_doom * 0.2 - -# PHASE 4 EXAMPLE: Cascade Events -# func trigger_cascade_event(cascade_type: String, severity: float): -# var cascade_doom = severity * 5.0 # 10-20 doom for big cascades -# doom_sources["cascades"] = cascade_doom -# add_event_doom(cascade_doom, "cascade_%s" % cascade_type) +func set_doom_multiplier(source: String, multiplier: float) -> void: + if doom_multipliers.has(source): + doom_multipliers[source] = multiplier # ============================================================================ -# SERIALIZATION (for save/load) +# SERIALIZATION (save/load) # ============================================================================ +## Doom-adjacent float quantum — a BINARY-EXACT grid (2^-20 ≈ 9.5e-7). Godot's JSON parse +## is not correctly-rounded (L1 calibration §7.2): a full-precision double can come back +## from a save 1 ulp off, breaking save/load deep-equality. The fix is to keep every +## doom-adjacent LIVE value on a power-of-two grid: snappedf(v, 2^-20) yields N·2^-20, which +## is an exactly-representable double whose decimal string round-trips through Godot's parser +## intact (0.25 survives, 0.008 does not — §7.2). A decimal quantum (1e-6) does NOT work: it +## sits between representable doubles, so a 1-ulp parse drift can cross a snap boundary and +## AMPLIFY into a full-quantum divergence. The grid is far below any gameplay scale (doom +## deaths at 100, streams ~0.01–10), so the live dynamics are unaffected (sweep-verified). +const SAVE_QUANTUM := 1.0 / 1048576.0 # == 2^-20, binary-exact (computed so the const is precise) + +static func _snap(v: float) -> float: + return snappedf(v, SAVE_QUANTUM) + +static func _snap_dict(d: Dictionary) -> Dictionary: + var out := {} + for k in d.keys(): + out[k] = snappedf(float(d[k]), SAVE_QUANTUM) + return out + func to_dict() -> Dictionary: - """Serialize doom system state""" + var hist: Array = [] + for v in rate_history: + hist.append(_snap(v)) return { - "current_doom": current_doom, - "doom_velocity": doom_velocity, - "doom_momentum": doom_momentum, - "pending_rival_doom": _pending_rival_doom, # L7 (#618): mid-turn buffer, 0 between turns - "doom_sources": doom_sources.duplicate(), - "doom_multipliers": doom_multipliers.duplicate(), - "doom_modifiers": doom_modifiers.duplicate() + "current_doom": _snap(current_doom), + "doom_rate": _snap(doom_rate), + "doom_velocity": _snap(doom_velocity), + "doom_momentum": _snap(doom_momentum), + "pending_rival_doom": _snap(_pending_rival_doom), + "pending_stream_inputs": _snap_dict(_pending_stream_inputs), + "doom_sources": _snap_dict(doom_sources), + "rate_history": hist, } -func from_dict(data: Dictionary): - """Deserialize doom system state (explicit float casts: JSON numbers arrive as float)""" - current_doom = float(data.get("current_doom", 50.0)) - doom_velocity = float(data.get("doom_velocity", 0.0)) - doom_momentum = float(data.get("doom_momentum", 0.0)) - _pending_rival_doom = float(data.get("pending_rival_doom", 0.0)) +func from_dict(data: Dictionary) -> void: + current_doom = _snap(float(data.get("current_doom", 50.0))) + doom_rate = _snap(float(data.get("doom_rate", 0.0))) + doom_velocity = _snap(float(data.get("doom_velocity", 0.0))) + doom_momentum = _snap(float(data.get("doom_momentum", 0.0))) + _pending_rival_doom = _snap(float(data.get("pending_rival_doom", 0.0))) + if data.has("pending_stream_inputs"): + _pending_stream_inputs = _snap_dict(data["pending_stream_inputs"]) if data.has("doom_sources"): - doom_sources = data["doom_sources"].duplicate() - if data.has("doom_multipliers"): - doom_multipliers = data["doom_multipliers"].duplicate() - if data.has("doom_modifiers"): - doom_modifiers = data["doom_modifiers"].duplicate() + doom_sources = _snap_dict(data["doom_sources"]) + if data.has("rate_history"): + rate_history.clear() + for v in data["rate_history"]: + rate_history.append(_snap(float(v))) diff --git a/godot/scripts/core/game_state.gd b/godot/scripts/core/game_state.gd index 5b68afc4..fdce43fa 100644 --- a/godot/scripts/core/game_state.gd +++ b/godot/scripts/core/game_state.gd @@ -16,6 +16,28 @@ var doom_history: Array[float] = [] # Per-turn doom snapshots for the trend gra # ADR-0002: area under the survival curve — sum of (100 - doom) over turns actually # survived. The lexicographic score tiebreaker ("doom-years averted"); accrues in-engine. var doom_integral: float = 0.0 + +# ADR-0015 / DQ-21 world-state intermediaries: doom is computed from THESE (never written +# directly). Actions/events/rivals write intermediaries; DoomSystem reads them each day tick +# and sums the named streams into the doom rate. Every one is L6-attributable (guard rule). +var ambient_risk: float = 0.0 # baseline stream — year-keyed background floor +var frontier_capability: Dictionary = {} # actor_id -> capability level; "player" is the named slice +var general_capability: float = 0.0 # diffusion stream — chronic commoditized floor (ratchet) +var global_compute: float = 0.0 # the slow ocean (schedule furniture; no own doom term) +var dedicated_ai_compute: float = 0.0 # compute stream — the controllable fleet +var safety_absorption: float = 0.0 # offsets frontier in the overhang gap (accumulated safety) +var global_alarm: float = 0.0 # alarm stream (small relief) + typed-damper gate +var global_panic: float = 0.0 # panic stream — social accelerant +var political_pressure: float = 0.0 # signed disposition; gate only (no stream of its own) +var doom_dampers: Array = [] # typed dampers: {target, strength, expires_turn} +var doom_pulses: Array = [] # ADR-0005 scheduled pulse envelopes (v1: none) +var sacred_chain_log: Array = [] # completed sacred-object chains (trend-invariant exemption) + +## player_frontier — the DQ-22-read alias for frontier_capability["player"]. +var player_frontier: float: + get: + return float(frontier_capability.get("player", 0.0)) + var action_points: int = 3 var max_action_points: int = 3 # Per-turn AP cap; difficulty modifiers adjust this (game_manager._apply_difficulty_settings) var stationery: float = 100.0 # Office supplies, depletes with staff usage @@ -207,6 +229,21 @@ func reset(): technical_debt = 0.0 # Reset tech debt (Issue #416) research_quality_mode = DEFAULT_RESEARCH_QUALITY # Issue #500 + # ADR-0015 / DQ-21: fresh world-state intermediaries. 2017 spawn starts low and slow + # (ambient_risk climbs with the schedule; frontier/absorption accumulate from play). + ambient_risk = Balance.num("doom.base_per_turn", 0.06) + frontier_capability = {"player": 0.0} + general_capability = 0.0 + global_compute = 0.0 + dedicated_ai_compute = compute + safety_absorption = 0.0 + global_alarm = 0.0 + global_panic = 0.0 + political_pressure = 0.0 + doom_dampers.clear() + doom_pulses.clear() + sacred_chain_log.clear() + safety_researchers = 0 capability_researchers = 0 compute_engineers = 0 @@ -331,6 +368,13 @@ func add_resources(gains: Dictionary): if gains.has("reputation"): reputation += gains["reputation"] if gains.has("doom"): + # ADR-0015 REMAINDER (Legacy #15 / memo §7.1): this generic doom sink is the un-migrated + # tail of the clobber-bug class — event/scenario CONTENT (data/events/*.json) still carries + # literal "doom" deltas that flow here. In the real turn loop this write is CLOBBERED by + # `state.doom = doom_system.current_doom` at resolve, so it is an inert no-op (Finding A); + # it survives only for the direct-state unit tests. The AUTHORITY is DoomSystem's streams. + # The follow-up content lane re-authors each event to write an INTERMEDIARY (frontier / + # panic / alarm / …) instead of doom, at which point this branch is deleted. doom += gains["doom"] doom = clamp(doom, 0.0, 100.0) if gains.has("technical_debt"): @@ -769,6 +813,14 @@ static func format_score(turns: int, integral: int) -> String: # fidelity for mid-game save/load (and later DQ-11 fork/divergence). # ============================================================================ +static func _snap_array(arr) -> Array: + ## Serialization-boundary float snap for arrays (see DoomSystem.SAVE_QUANTUM). + var out: Array = [] + for v in arr: + out.append(DoomSystem._snap(float(v))) + return out + + func to_dict() -> Dictionary: """Serialize state for UI + save/load (see convention block above)""" var rival_summaries = [] @@ -787,7 +839,7 @@ func to_dict() -> Dictionary: var doom_data = {} if doom_system: doom_data = doom_system.to_dict() - doom_data["doom"] = doom + doom_data["doom"] = DoomSystem._snap(doom) doom_data["doom_trend"] = doom_system._get_doom_trend() doom_data["doom_status"] = doom_system.get_doom_status() doom_data["momentum_description"] = doom_system.get_momentum_description() @@ -830,8 +882,26 @@ func to_dict() -> Dictionary: "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 + # Doom-adjacent floats are SNAPPED at the serialization boundary (both directions, + # same quantum as DoomSystem.SAVE_QUANTUM): the stream model produces full-precision + # doubles, and Godot's JSON parse is not correctly-rounded (calibration §7.2) — a + # 1-ulp corrupted parse re-snaps to the identical double, keeping save/load + # deep-equality byte-stable. Live dynamics never see the quantum. + "doom": DoomSystem._snap(doom), + "doom_history": _snap_array(doom_history), # #512 trend graph + # ADR-0015 / DQ-21 world-state intermediaries (doom is computed from these) + "ambient_risk": DoomSystem._snap(ambient_risk), + "frontier_capability": DoomSystem._snap_dict(frontier_capability), + "general_capability": DoomSystem._snap(general_capability), + "global_compute": DoomSystem._snap(global_compute), + "dedicated_ai_compute": DoomSystem._snap(dedicated_ai_compute), + "safety_absorption": DoomSystem._snap(safety_absorption), + "global_alarm": DoomSystem._snap(global_alarm), + "global_panic": DoomSystem._snap(global_panic), + "political_pressure": DoomSystem._snap(political_pressure), + "doom_dampers": doom_dampers.duplicate(true), + "doom_pulses": doom_pulses.duplicate(true), + "sacred_chain_log": sacred_chain_log.duplicate(true), "doom_system": doom_data, "risk_system": risk_data, "action_points": action_points, @@ -853,7 +923,7 @@ func to_dict() -> Dictionary: "management_capacity": get_management_capacity(), "unmanaged_count": get_unmanaged_count(), "turn": turn, - "doom_integral": doom_integral, + "doom_integral": DoomSystem._snap(doom_integral), "turn_display": get_turn_display(), "calendar": get_current_date(), "game_over": game_over, @@ -898,10 +968,27 @@ func from_dict(data: Dictionary) -> void: papers = float(data.get("papers", 0.0)) reputation = float(data.get("reputation", 10.0)) governance = float(data.get("governance", 50.0)) # was forgotten pre-L7 - doom = float(data.get("doom", 50.0)) + # Doom-adjacent floats re-snap on load (idempotent under the 1-ulp JSON parse + # corruption — see the to_dict comment + DoomSystem.SAVE_QUANTUM). + doom = DoomSystem._snap(float(data.get("doom", 50.0))) doom_history.clear() for d in data.get("doom_history", []): - doom_history.append(float(d)) + doom_history.append(DoomSystem._snap(float(d))) + # ADR-0015 / DQ-21 world-state intermediaries + ambient_risk = DoomSystem._snap(float(data.get("ambient_risk", Balance.num("doom.base_per_turn", 0.06)))) + frontier_capability = DoomSystem._snap_dict(data.get("frontier_capability", {"player": 0.0}) as Dictionary) + if not frontier_capability.has("player"): + frontier_capability["player"] = 0.0 + general_capability = DoomSystem._snap(float(data.get("general_capability", 0.0))) + global_compute = DoomSystem._snap(float(data.get("global_compute", 0.0))) + dedicated_ai_compute = DoomSystem._snap(float(data.get("dedicated_ai_compute", 0.0))) + safety_absorption = DoomSystem._snap(float(data.get("safety_absorption", 0.0))) + global_alarm = DoomSystem._snap(float(data.get("global_alarm", 0.0))) + global_panic = DoomSystem._snap(float(data.get("global_panic", 0.0))) + political_pressure = DoomSystem._snap(float(data.get("political_pressure", 0.0))) + doom_dampers = (data.get("doom_dampers", []) as Array).duplicate(true) + doom_pulses = (data.get("doom_pulses", []) as Array).duplicate(true) + sacred_chain_log = (data.get("sacred_chain_log", []) as Array).duplicate(true) action_points = int(data.get("action_points", 3)) max_action_points = int(data.get("max_action_points", 3)) committed_ap = int(data.get("committed_ap", 0)) @@ -918,7 +1005,7 @@ func from_dict(data: Dictionary) -> void: # Game state turn = int(data.get("turn", 0)) - doom_integral = float(data.get("doom_integral", 0.0)) + doom_integral = DoomSystem._snap(float(data.get("doom_integral", 0.0))) game_over = bool(data.get("game_over", false)) victory = bool(data.get("victory", false)) has_cat = bool(data.get("has_cat", false)) diff --git a/godot/scripts/core/ledger.gd b/godot/scripts/core/ledger.gd index 9f7685ad..ac82f5e7 100644 --- a/godot/scripts/core/ledger.gd +++ b/godot/scripts/core/ledger.gd @@ -242,21 +242,16 @@ func _apply_capped_doom(e: Entry, raw_doom: float, state) -> float: return applied -## Add doom so the bill actually LANDS. The turn loop overwrites `state.doom = -## doom_system.current_doom` each tick (_step_resolve_doom), so a bare `state.doom +=` from a -## ledger bill is silently CLOBBERED — the ledger's doom teeth were inert (probe-confirmed). -## Routing through the doom system's event channel makes the bill survive resolution and show -## up in the doom-source breakdown (loss legibility). Falls back to state.doom for the -## lightweight test doubles that carry no doom_system. +## Route a ledger bill's doom into the `ledger` STREAM (ADR-0015 single-authority discipline). +## A ledger default is not a direct write to the doom LEVEL — it is a contribution to a named +## stream that the doom function reads and integrates on the next tick, so DoomSystem stays the +## sole writer of state.doom and the bill shows up, attributed, in the stream breakdown (loss +## legibility). This also fixes the old clobber (Finding A): there is no parallel state.doom +## write for _step_resolve_doom's `state.doom = current_doom` to overwrite. Falls back to a +## direct write only for the lightweight test doubles that carry no doom_system. func _add_doom(state, amount: float) -> void: if "doom_system" in state and state.doom_system != null: - # Sync the doom-system authority FROM state.doom first, so the bill adds to the CURRENT - # doom regardless of any prior direct write (in the real loop the two are already equal - # at bill-time — the ledger bills in start_turn before doom resolution). Then bank the - # add on the authority so _step_resolve_doom's `state.doom = current_doom` can't clobber it. - state.doom_system.current_doom = state.doom - state.doom_system.add_event_doom(amount, "ledger_bill") - state.doom = state.doom_system.current_doom + state.doom_system.add_stream_input("ledger", amount) else: state.doom += amount diff --git a/godot/scripts/core/resource_accessor.gd b/godot/scripts/core/resource_accessor.gd index 2a4671da..1ae82d0b 100644 --- a/godot/scripts/core/resource_accessor.gd +++ b/godot/scripts/core/resource_accessor.gd @@ -71,6 +71,9 @@ static func add(state, resource_name: String, value) -> bool: "reputation": state.reputation += value "doom": + # ADR-0015 REMAINDER (Legacy #15 / memo §7.1): event-content doom sink. Clobbered by + # doom resolution in the real loop (inert no-op); the authority is DoomSystem's streams. + # Follow-up content lane re-authors events to write intermediaries instead. state.doom += value "compute_engineers": # Compute engineers use legacy count only (no Researcher object) diff --git a/godot/scripts/core/rivals.gd b/godot/scripts/core/rivals.gd index 2ff0c3f1..50a03d74 100644 --- a/godot/scripts/core/rivals.gd +++ b/godot/scripts/core/rivals.gd @@ -3,11 +3,10 @@ class_name RivalLabs ## Rival AI labs that compete with the player ## Issue #474: Organization discovery system -## Passive "capability-overhang" doom each rival applies per turn, scaling with its -## accumulated capability_progress — a GROWING pressure a fixed safety capacity cannot -## offset forever (the unbounded-mortality term for issue #562 / DQ-1). -## Placeholder magnitude — tuning is workshop #2. -const CAPABILITY_OVERHANG_DOOM_PER_PROGRESS: float = 0.025 +# ADR-0015 (Legacy #12): CAPABILITY_OVERHANG_DOOM_PER_PROGRESS is RETIRED. Capability-overhang +# hazard is no longer a rival-emitted doom literal — it is the DoomSystem `overhang` stream, +# which reads each rival's accumulated capability_progress (its frontier_capability slice) and +# converts frontier-minus-absorption into hazard. No rival code writes doom anymore. # Organization visibility states enum VisibilityState { @@ -145,7 +144,14 @@ static func check_discovery(rival: RivalLab, player_state: GameState, rng: Rando return result static func process_rival_turn(rival: RivalLab, player_state: GameState, rng: RandomNumberGenerator) -> Dictionary: + ## ADR-0015: rivals no longer emit a per-tick doom literal. A rival advancing the frontier + ## raises its capability_progress (the actor's frontier_capability slice — DoomSystem's + ## overhang stream converts that accumulated stock into hazard). Reckless, high-visibility + ## capability moves ALSO raise global_panic (the social accelerant, DQ-21 §1.8). The old + ## per-action doom + capability_overhang literals + the per_tick_doom_scale shim are RETIRED. + ## doom_contribution is retained at 0.0 for caller compatibility. var result = {"name": rival.name, "actions": [], "doom_contribution": 0.0, "visible": rival.is_visible_to_player()} + var panic_from_caps: float = Balance.num("rivals.panic_per_capability_action", 0.0) var num_actions = 1 if rival.funding < 100000 else (2 if rival.funding < 500000 else 3) for i in range(num_actions): var action = _choose_rival_action(rival, player_state, rng) @@ -155,32 +161,23 @@ static func process_rival_turn(rival: RivalLab, player_state: GameState, rng: Ra rival.funding -= 60000 if rival.aggression > 0.6: rival.capability_progress += 5.0 - result["doom_contribution"] += 2.0 else: rival.safety_progress += 3.0 - result["doom_contribution"] -= 0.5 "buy_compute": rival.funding -= 50000 - result["doom_contribution"] += 0.5 "publish_paper": rival.reputation += 5.0 + # An aggressive lab publishing a capability result is a reckless, high-visibility + # move -> global_panic (bad regulation + racing). A safety lab's paper does not. if rival.aggression > 0.6: - result["doom_contribution"] += 3.0 - else: - result["doom_contribution"] -= 2.0 + player_state.global_panic += panic_from_caps "fundraise": rival.funding += 150000 + (rival.reputation * 1000) "capability_research": rival.capability_progress += 10.0 - result["doom_contribution"] += 5.0 + player_state.global_panic += panic_from_caps "safety_research": rival.safety_progress += 8.0 - result["doom_contribution"] -= 3.0 - # Passive capability-overhang pressure: grows as this rival's capability_progress - # accumulates, so advanced rivals apply doom a fixed safety capacity cannot fully - # offset over time (issue #562 / DQ-1 mortality term). Scale from Balance (L9 #621). - result["doom_contribution"] += rival.capability_progress \ - * Balance.num("rivals.capability_overhang_doom_per_progress", CAPABILITY_OVERHANG_DOOM_PER_PROGRESS) return result static func _choose_rival_action(rival: RivalLab, _player_state: GameState, rng: RandomNumberGenerator) -> String: diff --git a/godot/scripts/core/turn_manager.gd b/godot/scripts/core/turn_manager.gd index 548329e7..2d597b34 100644 --- a/godot/scripts/core/turn_manager.gd +++ b/godot/scripts/core/turn_manager.gd @@ -287,18 +287,18 @@ func _step_researcher_productivity() -> Dictionary: researcher_index += 1 - # Apply doom effects - state.add_resources({"doom": -doom_reduction_from_safety}) - state.add_resources({"doom": doom_from_capabilities}) + # ADR-0015: researcher hazard is no longer a direct doom write. Capability work raises the + # PLAYER frontier and safety work raises safety_absorption + global_alarm — the DoomSystem + # intermediary advance (_advance_intermediaries) reads the productive roster each tick and + # does that accounting as the overhang/alarm streams. The old add_resources({"doom": ...}) + # writes here were silently clobbered (Finding A) AND double-counted that same influence; + # both are removed. A researcher LEAK is a discrete reckless incident -> global_panic. if leak_occurred: - state.add_resources({"doom": leak_doom}) + state.global_panic += leak_doom * Balance.num("doom.streams.leak_panic_scale", 0.02) # Unmanaged researchers cause doom var unmanaged_employees = unmanaged_count var total_unproductive = (researcher_count - productive_count) - if total_unproductive > 0: - var doom_penalty = total_unproductive * 0.5 - state.add_resources({"doom": doom_penalty}) # Apply research gains state.add_resources({"research": research_from_employees}) @@ -353,9 +353,11 @@ func _step_consume_stationery() -> Dictionary: # Penalties when out of stationery var stationery_doom_penalty = 0.0 if state.stationery <= 0: - # Safety researchers are most impacted - can't document properly + # Safety researchers can't document properly -> a mild hazard. ADR-0015: not a direct + # doom write (was clobbered/inert) — routed to global_panic (a documentation-failure + # incident the world reacts to). Kept in the return dict for the turn message. stationery_doom_penalty = safety_count * 0.3 - state.add_resources({"doom": stationery_doom_penalty}) + state.global_panic += stationery_doom_penalty * Balance.num("doom.streams.leak_panic_scale", 0.02) # Supply automation: auto-order when low var supply_auto_ordered = false @@ -558,21 +560,20 @@ func _step_publish_papers(results: Array) -> void: }) func _step_process_rival_turns(results: Array) -> float: - """=== RIVAL LABS TAKE ACTIONS === Returns this turn's rival doom contribution. + """=== RIVAL LABS TAKE ACTIONS === - L1 balance (ADR-0009 re-denomination): rival per-action doom + the capability-overhang - term were sized for the pre-L1 STRATEGIC turn, but under the month cycle this step bills - once per DAY-TICK (~22/month). rivals.per_tick_doom_scale re-denominates that per-tick - pressure to the month cadence (fallback 1.0 = unscaled/pre-L1 behavior).""" - var rival_doom_contribution = 0.0 + ADR-0015 (Legacy #7): the per-action rival doom literals AND the rivals.per_tick_doom_scale + shim are RETIRED. Rivals act on their own state — capability_research/hire raise their + capability_progress (their frontier_capability slice, which the overhang stream converts to + hazard), reckless capability moves raise global_panic. No rival doom is returned; the doom + comes out of the intermediaries on the next DoomSystem tick. Returns 0.0 (compat).""" for rival in state.rival_labs: var rival_result = RivalLabs.process_rival_turn(rival, state, state.rng) - rival_doom_contribution += rival_result["doom_contribution"] results.append({ "success": true, "message": "%s: %s" % [rival_result["name"], ", ".join(rival_result["actions"])] }) - return rival_doom_contribution * Balance.num("rivals.per_tick_doom_scale", 1.0) + return 0.0 func _step_check_rival_discovery(results: Array) -> void: """=== ORGANIZATION DISCOVERY (Issue #474) ===""" diff --git a/godot/scripts/core/upgrades.gd b/godot/scripts/core/upgrades.gd index 18f22070..24a1e9d4 100644 --- a/godot/scripts/core/upgrades.gd +++ b/godot/scripts/core/upgrades.gd @@ -120,9 +120,11 @@ static func purchase_upgrade(upgrade_id: String, state: GameState) -> Dictionary result["message"] += " Research scales with compute!" "cat_adoption": - state.add_resources({"doom": -5}) + # ADR-0015: the cat's morale boost raises global_alarm (a happier, more careful org), + # not a printed doom write. + state.global_alarm += Balance.num("doom.streams.upgrade_cat_alarm", 5.0) # Note: Cat-related state is tracked separately - result["message"] += " -5 doom! The cat has arrived!" + result["message"] += " The cat has arrived!" "supply_automation": # Passive effect: auto-orders supplies when below 30 diff --git a/godot/tests/unit/test_actions.gd b/godot/tests/unit/test_actions.gd index f65dc01d..9a0203c1 100644 --- a/godot/tests/unit/test_actions.gd +++ b/godot/tests/unit/test_actions.gd @@ -95,15 +95,19 @@ func test_hire_alignment_researcher_execution(): assert_eq(state.get_researcher_count_by_spec("alignment"), before + 1, "Alignment researcher should join") func test_safety_research_execution(): - # Test safety research action - doom reduction scales with safety_researchers count - # Need at least 1 safety researcher for any doom reduction + # ADR-0015: no action writes doom. Safety research raises the safety_absorption + # intermediary (Balance-scaled); the overhang stream converts it on the doom tick. state.safety_researchers = 1 state.research = 20.0 # Ensure enough research to pay cost var initial_doom = state.doom + var initial_absorb = state.safety_absorption var result = GameActions.execute_action("safety_research", state) - assert_lt(state.doom, initial_doom, "Doom should decrease when safety researchers > 0") + assert_eq(state.doom, initial_doom, "No printed doom delta (ADR-0015)") + assert_almost_eq(state.safety_absorption, + initial_absorb + 1.0 * Balance.num("doom.streams.action_safety_absorb", 0.0), 0.0001, + "Safety research raises safety_absorption by the Balance-priced amount") func test_team_building_execution(): # Test team building action @@ -113,7 +117,7 @@ func test_team_building_execution(): var result = GameActions.execute_action("team_building", state) assert_gt(state.reputation, initial_reputation, "Reputation should increase") - assert_lt(state.doom, initial_doom, "Doom should decrease") + assert_eq(state.doom, initial_doom, "No printed doom delta (ADR-0015)") func test_media_campaign_execution(): # Test media campaign action @@ -170,8 +174,8 @@ func test_safety_audit_execution(): var result = GameActions.execute_action("audit_safety", state) - # Safety audit should reduce doom and increase reputation - assert_lt(state.doom, initial_doom, "Doom should decrease") + # ADR-0015: the audit raises safety_absorption (Balance-priced), never doom directly + assert_eq(state.doom, initial_doom, "No printed doom delta (ADR-0015)") assert_gt(state.reputation, initial_reputation, "Reputation should increase") func test_action_execution_returns_result(): diff --git a/godot/tests/unit/test_doom_system.gd b/godot/tests/unit/test_doom_system.gd index bf48ae69..257f76c5 100644 --- a/godot/tests/unit/test_doom_system.gd +++ b/godot/tests/unit/test_doom_system.gd @@ -1,425 +1,240 @@ extends GutTest -## Unit tests for DoomSystem class +## Unit tests for DoomSystem — ADR-0015 nine-stream accumulating-rate model. +## doom_rate = sum of NAMED STREAMS read from the DQ-21 world-state intermediaries; no +## action/event writes doom directly (ledger/risk arrive as routed STREAM INPUTS). # ============================================================================ -# HELPER FUNCTIONS +# HELPERS # ============================================================================ func _create_doom_system() -> DoomSystem: - """Create a fresh DoomSystem for testing""" - var doom_system = DoomSystem.new() - return doom_system + return DoomSystem.new() func _create_minimal_game_state() -> GameState: - """Create a minimal GameState for doom calculations""" - var state = GameState.new("test_seed") - return state + return GameState.new("test_seed") # ============================================================================ -# DOOM CLAMPING TESTS (Issue #488) +# DOOM CLAMPING (Issue #488) — via calculate_doom_change (the single authority) # ============================================================================ func test_doom_clamped_to_upper_bound(): - # Test that doom is clamped to 100 maximum - var doom_system = _create_doom_system() - - doom_system.current_doom = 95.0 - doom_system.add_event_doom(20.0, "test_event") - - assert_eq(doom_system.current_doom, 100.0, "Doom should be clamped to 100") + # A routed stream input that would push past 100 clamps on the next tick. + var ds = _create_doom_system() + var state = _create_minimal_game_state() + ds.current_doom = 95.0 + ds.add_stream_input("ledger", 20.0) + ds.calculate_doom_change(state) + assert_eq(ds.current_doom, 100.0, "Doom level clamps to 100") func test_doom_clamped_to_lower_bound(): - # Test that doom is clamped to 0 minimum - var doom_system = _create_doom_system() - - doom_system.current_doom = 5.0 - doom_system.add_event_doom(-20.0, "test_reduction") - - assert_eq(doom_system.current_doom, 0.0, "Doom should be clamped to 0") + var ds = _create_doom_system() + var state = _create_minimal_game_state() + ds.current_doom = 5.0 + ds.add_stream_input("ledger", -20.0) + ds.calculate_doom_change(state) + assert_eq(ds.current_doom, 0.0, "Doom level clamps to 0") func test_doom_clamping_in_calculate_doom_change(): - # Test that calculate_doom_change also clamps doom - var doom_system = _create_doom_system() + var ds = _create_doom_system() var state = _create_minimal_game_state() - - # Set up state to generate doom that pushes past the ceiling (magnitudes are Balance-driven - # post-L1 re-denomination, so start near the cap and let any positive change clamp). - doom_system.current_doom = 99.9 - state.capability_researchers = 10 # any positive per-researcher doom now tips it over 100 - - var result = doom_system.calculate_doom_change(state) - - assert_eq(doom_system.current_doom, 100.0, "Doom should be clamped to 100 after calculation") - assert_eq(result["new_doom"], 100.0, "Result should report clamped doom value") + ds.current_doom = 99.9 + # A large frontier makes the overhang stream tip it over 100. + state.frontier_capability = {"player": 1000000.0} + var result = ds.calculate_doom_change(state) + assert_eq(ds.current_doom, 100.0, "Doom clamps to 100 after calculation") + assert_eq(result["new_doom"], 100.0, "Result reports clamped value") # ============================================================================ -# CALCULATE_DOOM_CHANGE TESTS (Issue #488) +# CALCULATE_DOOM_CHANGE STRUCTURE + STREAMS # ============================================================================ func test_calculate_doom_change_returns_expected_structure(): - # Test that calculate_doom_change returns all expected keys - var doom_system = _create_doom_system() + var ds = _create_doom_system() var state = _create_minimal_game_state() + var result = ds.calculate_doom_change(state) + for key in ["total_change", "raw_change", "momentum", "velocity", "sources", "streams", "new_doom", "rate", "level", "trend"]: + assert_has(result, key, "Result should have %s" % key) - var result = doom_system.calculate_doom_change(state) - - assert_has(result, "total_change", "Result should have total_change") - assert_has(result, "raw_change", "Result should have raw_change") - assert_has(result, "momentum", "Result should have momentum") - assert_has(result, "velocity", "Result should have velocity") - assert_has(result, "sources", "Result should have sources") - assert_has(result, "new_doom", "Result should have new_doom") - assert_has(result, "trend", "Result should have trend") - -func test_calculate_doom_change_base_doom(): - # Base doom equals the Balance coefficient (L1 re-denominated it off the strategic-turn - # magnitude; assert the wiring, not a frozen literal). - var doom_system = _create_doom_system() +func test_baseline_stream_equals_ambient_risk(): + # The baseline stream reads the ambient_risk intermediary (seeded from doom.base_per_turn). + var ds = _create_doom_system() var state = _create_minimal_game_state() + var result = ds.calculate_doom_change(state) + assert_almost_eq(result["sources"]["baseline"], Balance.num("doom.base_per_turn", 0.06), 0.0001, "baseline stream == ambient_risk") - var result = doom_system.calculate_doom_change(state) - - assert_almost_eq(result["sources"]["base"], Balance.num("doom.base_per_turn", 0.06), 0.0001, "Base doom matches Balance base_per_turn") - -func test_calculate_doom_change_capability_researchers(): - # Capability researchers add per-researcher doom (Balance-driven magnitude). - var doom_system = _create_doom_system() +func test_overhang_stream_grows_with_frontier(): + # Overhang converts accumulated frontier (minus absorption) into hazard. + var ds = _create_doom_system() var state = _create_minimal_game_state() - - state.capability_researchers = 2 # 2 * per-researcher coefficient - - var result = doom_system.calculate_doom_change(state) - - assert_almost_eq(result["sources"]["capabilities"], 2.0 * Balance.num("doom.legacy_capability_per_researcher", 0.15), 0.0001, "Capabilities doom = 2 x per-researcher coefficient") + state.frontier_capability = {"player": 5000.0} + state.safety_absorption = 0.0 + var result = ds.calculate_doom_change(state) + assert_gt(result["sources"]["overhang"], 0.0, "overhang > 0 when frontier exceeds absorption") + +func test_safety_absorption_reduces_overhang(): + # Absorption offsets frontier in the overhang gap (and clamps the stream at 0). + var ds = _create_doom_system() + var state = _create_minimal_game_state() + state.frontier_capability = {"rivalX": 3000.0} + state.safety_absorption = 100000.0 # absorption exceeds frontier -> clamped to 0 + var result = ds.calculate_doom_change(state) + assert_eq(result["sources"]["overhang"], 0.0, "overhang clamps at 0 when absorption exceeds frontier (v1 R2-Q9)") + +func test_alarm_stream_is_negative_relief(): + # global_alarm produces a small NEGATIVE stream (the natively-negative component). + var ds = _create_doom_system() + var state = _create_minimal_game_state() + state.global_alarm = 50.0 + var result = ds.calculate_doom_change(state) + assert_lt(result["sources"]["alarm"], 0.0, "alarm stream is a negative relief") # ============================================================================ -# APPLY_DOOM_MODIFIER TESTS (Issue #488) +# STREAM INPUTS (ledger/risk routed, not parallel-written) — ADR-0015 / #638 # ============================================================================ -func test_apply_doom_modifier_adds_to_existing(): - # Test that apply_doom_modifier accumulates modifiers - var doom_system = _create_doom_system() - - doom_system.apply_doom_modifier("capabilities", -0.5) - assert_eq(doom_system.doom_modifiers["capabilities"], -0.5, "First modifier should be applied") - - doom_system.apply_doom_modifier("capabilities", -0.3) - assert_eq(doom_system.doom_modifiers["capabilities"], -0.8, "Modifiers should accumulate") - -func test_apply_doom_modifier_ignores_invalid_source(): - # Test that apply_doom_modifier ignores invalid sources - var doom_system = _create_doom_system() - - var original_mods = doom_system.doom_modifiers.duplicate() - doom_system.apply_doom_modifier("invalid_source", 5.0) - - assert_eq(doom_system.doom_modifiers, original_mods, "Invalid source should not modify anything") +func test_stream_input_is_buffered_not_written_directly(): + var ds = _create_doom_system() + ds.current_doom = 50.0 + ds.add_stream_input("ledger", 10.0) + assert_eq(ds.current_doom, 50.0, "stream input does NOT write the level directly") -func test_set_doom_multiplier(): - # Test that set_doom_multiplier sets the value correctly - var doom_system = _create_doom_system() - - doom_system.set_doom_multiplier("rivals", 0.7) - assert_eq(doom_system.doom_multipliers["rivals"], 0.7, "Multiplier should be set to 0.7") +func test_stream_input_lands_on_next_tick(): + var ds = _create_doom_system() + var state = _create_minimal_game_state() + ds.current_doom = 50.0 + ds.add_stream_input("ledger", 10.0) + var result = ds.calculate_doom_change(state) + assert_almost_eq(result["sources"]["ledger"], 10.0, 0.01, "ledger input appears in its named stream") + assert_gt(ds.current_doom, 59.0, "level integrated the ledger input") + +func test_add_event_doom_routes_risk_to_panic(): + var ds = _create_doom_system() + var state = _create_minimal_game_state() + ds.add_event_doom(5.0, "risk_capability") + var result = ds.calculate_doom_change(state) + assert_almost_eq(result["sources"]["panic"], 5.0, 0.01, "risk shocks route to the panic stream") - doom_system.set_doom_multiplier("rivals", 1.5) - assert_eq(doom_system.doom_multipliers["rivals"], 1.5, "Multiplier should be replaced with 1.5") +func test_set_rival_doom_contribution_is_retired_noop(): + # Rivals no longer emit direct doom; the shim is a no-op sink. + var ds = _create_doom_system() + var state = _create_minimal_game_state() + ds.set_rival_doom_contribution(7.0) + var result = ds.calculate_doom_change(state) + assert_false(result["sources"].has("rivals"), "there is no 'rivals' doom stream anymore") # ============================================================================ -# MOMENTUM SYSTEM TESTS +# MOMENTUM (gated trend modifier) # ============================================================================ func test_momentum_capped_positive(): - # Test that momentum is capped at momentum_cap - var doom_system = _create_doom_system() - - # Force high momentum by directly setting it - doom_system.doom_momentum = 10.0 # Above cap - - # Calculate momentum to apply capping - doom_system._calculate_momentum(5.0) - - assert_true(doom_system.doom_momentum <= doom_system.momentum_cap, "Momentum should be capped at momentum_cap") + var ds = _create_doom_system() + ds.doom_momentum = 10.0 + ds._calculate_momentum(5.0) + assert_true(ds.doom_momentum <= ds.momentum_cap, "Momentum capped at momentum_cap") func test_momentum_capped_negative(): - # Test that negative momentum is also capped - var doom_system = _create_doom_system() - - doom_system.doom_momentum = -10.0 # Below negative cap - - doom_system._calculate_momentum(-5.0) - - assert_true(doom_system.doom_momentum >= -doom_system.momentum_cap, "Negative momentum should be capped") + var ds = _create_doom_system() + ds.doom_momentum = -10.0 + ds._calculate_momentum(-5.0) + assert_true(ds.doom_momentum >= -ds.momentum_cap, "Negative momentum capped") # ============================================================================ -# STATUS AND TREND TESTS +# STATUS BANDS (ThemeManager canonical bands) # ============================================================================ -# L6 unification: doom statuses are ThemeManager's canonical bands, lowercased -# (nominal/elevated/high/severe/extreme/catastrophic/terminal at 15/37/52/67/80/92). -# The former 25/50/70/90 safe/warning/danger/critical/catastrophic copy is gone. - func test_get_doom_status_nominal(): - var doom_system = _create_doom_system() - - doom_system.current_doom = 10.0 - assert_eq(doom_system.get_doom_status(), "nominal", "Doom < 15 should be 'nominal'") + var ds = _create_doom_system(); ds.current_doom = 10.0 + assert_eq(ds.get_doom_status(), "nominal", "Doom < 15 is 'nominal'") func test_get_doom_status_elevated(): - var doom_system = _create_doom_system() - - doom_system.current_doom = 20.0 - assert_eq(doom_system.get_doom_status(), "elevated", "Doom 15-36 should be 'elevated'") - -func test_get_doom_status_high(): - var doom_system = _create_doom_system() - - doom_system.current_doom = 40.0 - assert_eq(doom_system.get_doom_status(), "high", "Doom 37-51 should be 'high'") - -func test_get_doom_status_severe(): - var doom_system = _create_doom_system() - - doom_system.current_doom = 60.0 - assert_eq(doom_system.get_doom_status(), "severe", "Doom 52-66 should be 'severe'") - -func test_get_doom_status_extreme(): - var doom_system = _create_doom_system() - - doom_system.current_doom = 70.0 - assert_eq(doom_system.get_doom_status(), "extreme", "Doom 67-79 should be 'extreme'") - -func test_get_doom_status_catastrophic(): - var doom_system = _create_doom_system() - - doom_system.current_doom = 80.0 - assert_eq(doom_system.get_doom_status(), "catastrophic", "Doom 80-91 should be 'catastrophic'") + var ds = _create_doom_system(); ds.current_doom = 20.0 + assert_eq(ds.get_doom_status(), "elevated", "Doom 15-36 is 'elevated'") func test_get_doom_status_terminal(): - var doom_system = _create_doom_system() - - doom_system.current_doom = 95.0 - assert_eq(doom_system.get_doom_status(), "terminal", "Doom >= 92 should be 'terminal'") + var ds = _create_doom_system(); ds.current_doom = 95.0 + assert_eq(ds.get_doom_status(), "terminal", "Doom >= 92 is 'terminal'") # ============================================================================ -# ADD_EVENT_DOOM TESTS +# PER-STREAM API (F3 overlay data + two instruments, DQ-21) # ============================================================================ -func test_add_event_doom_increases_doom(): - # Test that add_event_doom adds to current doom - var doom_system = _create_doom_system() - - doom_system.current_doom = 50.0 - doom_system.add_event_doom(10.0, "test_event") - - assert_eq(doom_system.current_doom, 60.0, "Doom should increase by event amount") - -func test_add_event_doom_tracks_source(): - # Test that add_event_doom updates the events source - var doom_system = _create_doom_system() - - doom_system.add_event_doom(5.0, "event1") - doom_system.add_event_doom(3.0, "event2") +func test_two_instrument_readouts(): + var ds = _create_doom_system() + var state = _create_minimal_game_state() + ds.calculate_doom_change(state) + assert_eq(ds.get_doom_level(), ds.current_doom, "get_doom_level == accumulated level") + assert_eq(ds.get_doom_rate(), ds.doom_rate, "get_doom_rate == most recent rate") - assert_eq(doom_system.doom_sources["events"], 8.0, "Event doom should accumulate") +func test_stream_contributions_and_dominant(): + var ds = _create_doom_system() + var state = _create_minimal_game_state() + state.frontier_capability = {"player": 50000.0} # make overhang the dominant stream + ds.calculate_doom_change(state) + var contribs = ds.get_stream_contributions() + assert_has(contribs, "overhang", "stream contributions include overhang") + assert_eq(ds.get_dominant_stream(), "overhang", "overhang dominates with a large frontier") # ============================================================================ -# SERIALIZATION TESTS +# TREND TELEMETRY INVARIANT (N=6) — instrumentation, never a clamp # ============================================================================ -func test_to_dict_serialization(): - # Test that to_dict properly serializes state - var doom_system = _create_doom_system() - - doom_system.current_doom = 75.0 - doom_system.doom_velocity = 2.5 - doom_system.doom_momentum = 3.0 - - var dict = doom_system.to_dict() - - assert_eq(dict["current_doom"], 75.0, "Serialized doom should match") - assert_eq(dict["doom_velocity"], 2.5, "Serialized velocity should match") - assert_eq(dict["doom_momentum"], 3.0, "Serialized momentum should match") - assert_has(dict, "doom_sources", "Should include doom_sources") - assert_has(dict, "doom_multipliers", "Should include doom_multipliers") - assert_has(dict, "doom_modifiers", "Should include doom_modifiers") - -func test_from_dict_deserialization(): - # Test that from_dict properly deserializes state - var doom_system = _create_doom_system() - - var data = { - "current_doom": 65.0, - "doom_velocity": -1.5, - "doom_momentum": -2.0, - "doom_sources": {"base": 1.0, "events": 5.0}, - "doom_multipliers": {"capabilities": 1.2}, - "doom_modifiers": {"safety": 0.5} - } - - doom_system.from_dict(data) - - assert_eq(doom_system.current_doom, 65.0, "Deserialized doom should match") - assert_eq(doom_system.doom_velocity, -1.5, "Deserialized velocity should match") - assert_eq(doom_system.doom_momentum, -2.0, "Deserialized momentum should match") - -func test_from_dict_handles_missing_keys(): - # Test that from_dict handles partial data gracefully - var doom_system = _create_doom_system() - - var data = { - "current_doom": 40.0 - } - - doom_system.from_dict(data) - - assert_eq(doom_system.current_doom, 40.0, "Should set current_doom from partial data") - assert_eq(doom_system.doom_velocity, 0.0, "Should use default for missing velocity") - assert_eq(doom_system.doom_momentum, 0.0, "Should use default for missing momentum") +func test_trend_invariant_never_clamps_negative_rate(): + # A sustained negative rate is LEGAL (the engine never forces the sum positive). + var ds = _create_doom_system() + var state = _create_minimal_game_state() + state.global_alarm = 10000.0 # a big alarm relief -> negative rate + var result = ds.calculate_doom_change(state) + assert_lt(result["rate"], 0.0, "negative rate is permitted (no clamp)") # ============================================================================ -# DOOM TREND TESTS +# TREND READOUT # ============================================================================ func test_get_doom_trend_stable(): - # Test doom trend when velocity is near zero - var doom_system = _create_doom_system() - - doom_system.doom_velocity = 0.0 - assert_eq(doom_system._get_doom_trend(), "stable", "Near-zero velocity should be 'stable'") + var ds = _create_doom_system(); ds.doom_velocity = 0.0 + assert_eq(ds._get_doom_trend(), "stable", "Near-zero velocity is 'stable'") func test_get_doom_trend_increasing(): - # Test doom trend when velocity is moderately positive - var doom_system = _create_doom_system() - - doom_system.doom_velocity = 1.0 - assert_eq(doom_system._get_doom_trend(), "increasing", "Positive velocity should be 'increasing'") - -func test_get_doom_trend_strongly_increasing(): - # Test doom trend when velocity is highly positive - var doom_system = _create_doom_system() - - doom_system.doom_velocity = 3.0 - assert_eq(doom_system._get_doom_trend(), "strongly_increasing", "High velocity should be 'strongly_increasing'") - -func test_get_doom_trend_decreasing(): - # Test doom trend when velocity is moderately negative - var doom_system = _create_doom_system() - - doom_system.doom_velocity = -1.0 - assert_eq(doom_system._get_doom_trend(), "decreasing", "Negative velocity should be 'decreasing'") + var ds = _create_doom_system(); ds.doom_velocity = 1.0 + assert_eq(ds._get_doom_trend(), "increasing", "Positive velocity is 'increasing'") func test_get_doom_trend_strongly_decreasing(): - # Test doom trend when velocity is highly negative - var doom_system = _create_doom_system() - - doom_system.doom_velocity = -3.0 - assert_eq(doom_system._get_doom_trend(), "strongly_decreasing", "High negative velocity should be 'strongly_decreasing'") + var ds = _create_doom_system(); ds.doom_velocity = -3.0 + assert_eq(ds._get_doom_trend(), "strongly_decreasing", "High negative velocity is 'strongly_decreasing'") # ============================================================================ -# UNPRODUCTIVE DOOM TESTS (Issue #424) +# SERIALIZATION # ============================================================================ -func test_unproductive_doom_no_staff(): - # Test that no staff means no unproductive doom - var doom_system = _create_doom_system() - var state = _create_minimal_game_state() - - doom_system._calculate_unproductive_doom(state) - - assert_eq(doom_system.doom_sources["unproductive"], 0.0, "No staff should mean no unproductive doom") - -func test_unproductive_doom_all_productive(): - # Test that fully productive staff generates no unproductive doom - var doom_system = _create_doom_system() - var state = _create_minimal_game_state() - - # Add 5 researchers (under management capacity of 9, compute is 100) - state.safety_researchers = 5 - - doom_system._calculate_unproductive_doom(state) - - assert_eq(doom_system.doom_sources["unproductive"], 0.0, "All productive staff should mean no unproductive doom") - -func test_unproductive_doom_unmanaged(): - # Test that unmanaged employees contribute to doom (Issue #424 fix) - var doom_system = _create_doom_system() - var state = _create_minimal_game_state() - - # Add 12 researchers with 0 managers (capacity = 9, so 3 unmanaged) - state.safety_researchers = 12 - state.managers = 0 - - doom_system._calculate_unproductive_doom(state) - - # 3 unmanaged * per-staff coefficient (Balance-driven) - assert_almost_eq(doom_system.doom_sources["unproductive"], 3.0 * Balance.num("doom.unproductive_per_staff", 0.025), 0.0001, "Unmanaged employees each contribute the per-staff coefficient") - -func test_unproductive_doom_no_compute(): - # Test that employees without compute are unproductive - var doom_system = _create_doom_system() - var state = _create_minimal_game_state() - - # 5 researchers, but only 2 compute available - state.safety_researchers = 5 - state.compute = 2.0 - - doom_system._calculate_unproductive_doom(state) - - # 3 without compute * per-staff coefficient (Balance-driven) - assert_almost_eq(doom_system.doom_sources["unproductive"], 3.0 * Balance.num("doom.unproductive_per_staff", 0.025), 0.0001, "Employees without compute contribute the per-staff coefficient") - -func test_unproductive_doom_no_double_counting(): - # Test that unmanaged employees are not double-counted (Issue #424 fix) - var doom_system = _create_doom_system() - var state = _create_minimal_game_state() - - # 15 researchers with 0 managers (capacity = 9) - # 6 unmanaged, 9 managed - # With 100 compute, all 9 managed can work - state.safety_researchers = 15 - state.managers = 0 - state.compute = 100.0 - - doom_system._calculate_unproductive_doom(state) - - # Only 6 unproductive (not 6 + 6 from double counting) * per-staff coefficient (Balance-driven) - assert_almost_eq(doom_system.doom_sources["unproductive"], 6.0 * Balance.num("doom.unproductive_per_staff", 0.025), 0.0001, "Unmanaged should not be double-counted") - -# ============================================================================ -# RIVAL DOOM CONTRIBUTION TESTS (Issue #562) -# ============================================================================ +func test_to_dict_serialization(): + var ds = _create_doom_system() + ds.current_doom = 75.0 + ds.doom_velocity = 2.5 + ds.doom_momentum = 3.0 + var dict = ds.to_dict() + assert_eq(dict["current_doom"], 75.0, "Serialized doom matches") + assert_eq(dict["doom_velocity"], 2.5, "Serialized velocity matches") + assert_eq(dict["doom_momentum"], 3.0, "Serialized momentum matches") + assert_has(dict, "doom_sources", "Includes doom_sources (streams)") + assert_has(dict, "rate_history", "Includes rate_history") -func test_rival_doom_survives_source_reset(): - # Regression: turn_manager sets rival doom BEFORE calculate_doom_change(), whose - # _reset_doom_sources() used to wipe it to 0 before the sum. - var doom_system = _create_doom_system() - var state = _create_minimal_game_state() - doom_system.set_rival_doom_contribution(7.0) - var result = doom_system.calculate_doom_change(state) - assert_eq(result["sources"]["rivals"], 7.0, "Rival doom must survive the per-turn source reset (issue #562)") +func test_from_dict_deserialization(): + var ds = _create_doom_system() + var data = { + "current_doom": 65.0, + "doom_velocity": -1.5, + "doom_momentum": -2.0, + "doom_sources": {"baseline": 0.06, "ledger": 5.0}, + "rate_history": [1.0, 2.0], + } + ds.from_dict(data) + assert_eq(ds.current_doom, 65.0, "Deserialized doom matches") + assert_eq(ds.doom_velocity, -1.5, "Deserialized velocity matches") + assert_eq(ds.doom_momentum, -2.0, "Deserialized momentum matches") -func test_rival_doom_zero_when_unset(): - var doom_system = _create_doom_system() - var state = _create_minimal_game_state() - var result = doom_system.calculate_doom_change(state) - assert_eq(result["sources"]["rivals"], 0.0, "Rival doom is 0 when no contribution set") - -func test_rival_capability_overhang_scales_with_progress(): - # The passive overhang term grows with accumulated capability_progress, so an advanced - # rival applies strictly more doom than an identical low-progress one (same rng seed). - var low = RivalLabs.RivalLab.new("LowLab", 0.9) - low.capability_progress = 0.0 - var high = RivalLabs.RivalLab.new("HighLab", 0.9) - high.capability_progress = 200.0 - var state = _create_minimal_game_state() - var rng_low = RandomNumberGenerator.new() - rng_low.seed = 12345 - var rng_high = RandomNumberGenerator.new() - rng_high.seed = 12345 - var low_result = RivalLabs.process_rival_turn(low, state, rng_low) - var high_result = RivalLabs.process_rival_turn(high, state, rng_high) - # Overhang coefficient is Balance-driven (L1 re-denominated it); read it, don't freeze it. - var expected_delta = 200.0 * Balance.num("rivals.capability_overhang_doom_per_progress", RivalLabs.CAPABILITY_OVERHANG_DOOM_PER_PROGRESS) - assert_almost_eq(high_result["doom_contribution"] - low_result["doom_contribution"], expected_delta, 0.001, "Overhang doom scales with capability_progress") - assert_gt(high_result["doom_contribution"], low_result["doom_contribution"], "Higher-capability rival contributes more doom") +func test_from_dict_handles_missing_keys(): + var ds = _create_doom_system() + ds.from_dict({"current_doom": 40.0}) + assert_eq(ds.current_doom, 40.0, "Sets current_doom from partial data") + assert_eq(ds.doom_velocity, 0.0, "Default for missing velocity") + assert_eq(ds.doom_momentum, 0.0, "Default for missing momentum") diff --git a/godot/tests/unit/test_ledger_actions.gd b/godot/tests/unit/test_ledger_actions.gd index fdcb68d1..75bb7103 100644 --- a/godot/tests/unit/test_ledger_actions.gd +++ b/godot/tests/unit/test_ledger_actions.gd @@ -23,11 +23,18 @@ func test_funding_strings_bills_governance(): assert_eq(s.money, before + 40000, "funding pays out") assert_eq(s.ledger.entries[0].currency, "governance", "strings bill in governance") -func test_desperation_lever_suppresses_doom_and_plants_secret(): +func test_desperation_lever_plants_secret_without_printed_doom(): + # ADR-0015: no action writes doom. The lever's catch-up benefit is now a + # safety_absorption reprieve (Balance doom.streams.action_desperation_absorb, + # priced 0 in v1); the SECRET compounding liability (the real teeth) is unchanged. var s = _fresh_state() var before_doom = s.doom + var before_absorb = s.safety_absorption GameActions.execute_action("desperation_lever", s) - assert_lt(s.doom, before_doom, "desperation suppresses doom now") + assert_eq(s.doom, before_doom, "no printed doom delta (ADR-0015)") + assert_almost_eq(s.safety_absorption, + before_absorb + Balance.num("doom.streams.action_desperation_absorb", 0.0), 0.0001, + "the catch-up benefit is a Balance-priced absorption reprieve") assert_eq(s.ledger.secret_entries().size(), 1, "desperation plants a SECRET liability") func test_staff_rider_grants_ap_and_rider(): diff --git a/godot/tests/unit/test_liability_ledger.gd b/godot/tests/unit/test_liability_ledger.gd index aaf9dfe0..40d4777c 100644 --- a/godot/tests/unit/test_liability_ledger.gd +++ b/godot/tests/unit/test_liability_ledger.gd @@ -12,6 +12,15 @@ func _fresh_state(seed_str: String): s.money = 245000.0 s.governance = 50.0 s.reputation = 50.0 + # ADR-0015: these tests exercise the LEDGER's own conversion contract in isolation + # (synchronous doom arithmetic, controlled soak). With a doom_system attached the + # ledger routes doom as a buffered STREAM INPUT (applied on the next doom tick) — + # correct in the real loop, but it would decouple the soak's hand-rolled doom math. + # Detach the authority so Ledger._add_doom takes its documented lightweight-double + # fallback (state.doom +=). Stream routing is covered by test_doom_system + the sweep. + if s.doom_system: + s.doom_system.free() + s.doom_system = null return s diff --git a/godot/tests/unit/test_turn_manager.gd b/godot/tests/unit/test_turn_manager.gd index e4873eab..c2e785a0 100644 --- a/godot/tests/unit/test_turn_manager.gd +++ b/godot/tests/unit/test_turn_manager.gd @@ -434,14 +434,13 @@ func test_insider_threat_key_resignation_removes_a_researcher(): assert_string_contains(joined, "Fickle Fran", "Departure is named in the risk-event log line") func test_insider_threat_key_resignation_safe_with_no_researchers(): - # No researchers on staff -> the scalar effects (research/reputation/doom) - # still apply, but the staffing loss is a safe no-op (no crash, no phantom note). - # Doom for risk events routes through state.doom_system (synced to state.doom - # later in the turn pipeline, not within _step_process_risk_pools itself), so - # assert on doom_system.current_doom directly rather than state.doom. + # No researchers on staff -> the scalar effects (research/reputation/doom) still apply, + # but the staffing loss is a safe no-op (no crash, no phantom note). + # ADR-0015: a risk shock's doom is not a direct level write — it routes as a buffered + # `panic` STREAM INPUT (add_event_doom -> add_stream_input) that the DoomSystem folds into + # the rate on the next tick. So it lands as a pending panic input here, not on current_doom. assert_eq(state.researchers.size(), 0, "Start empty") var initial_reputation = state.reputation - var initial_doom: float = state.doom_system.current_doom if state.doom_system else state.doom state.risk_system.pools["insider_threat"] = 55.0 @@ -449,6 +448,12 @@ func test_insider_threat_key_resignation_safe_with_no_researchers(): turn_manager._step_process_risk_pools(results) assert_eq(state.researchers.size(), 0, "Nobody to remove, nobody removed") - var after_doom: float = state.doom_system.current_doom if state.doom_system else state.doom - assert_gt(after_doom, initial_doom, "Scalar doom effect still applies") + var pending_panic: float = float(state.doom_system._pending_stream_inputs.get("panic", 0.0)) if state.doom_system else 0.0 + assert_gt(pending_panic, 0.0, "Risk-shock doom routes to a buffered panic stream input (ADR-0015)") + # Confirm it actually LANDS on the next doom tick (raising the level). + var before_level: float = state.doom_system.current_doom if state.doom_system else state.doom + if state.doom_system: + state.doom_system.calculate_doom_change(state) + var after_level: float = state.doom_system.current_doom if state.doom_system else state.doom + assert_gt(after_level, before_level, "the buffered risk shock lands on the next tick") assert_lt(state.reputation, initial_reputation, "Scalar reputation effect still applies")