diff --git a/docs/game-design/RESEARCHER_QUIRKS.md b/docs/game-design/RESEARCHER_QUIRKS.md new file mode 100644 index 00000000..40936c06 --- /dev/null +++ b/docs/game-design/RESEARCHER_QUIRKS.md @@ -0,0 +1,135 @@ +# Researcher Quirks + +*The hidden-but-true rider layer on researchers. Replaces the retired placeholder +"trait / perk" system.* + +Status: implemented on `feat/hiring-phase-b-pipeline` (PR #664). Data: +`godot/data/researchers/quirks.json`. Loader: `godot/scripts/core/quirk_catalogue.gd`. +Model: `godot/scripts/core/researcher.gd` (`quirk`, `quirk_known`, `quirk_effect()`). + +## What a quirk is + +A **quirk** is a rare, thematically-grounded lab archetype riding on a researcher: a +philosophical stance, a working style, a political tic. Each carries a **small, legible +mechanical effect** and is **hidden but TRUE** -- it is real from the moment the researcher +is created; play only *reveals* it. The sim never lies (BUILD_BRIEF_HIRING_PIPELINE.md: +"Hidden info is TRUE-but-incomplete -- interviewing reveals, it does not fabricate"). A +leaker leaks from day one; you just do not yet know *who* or *why*. + +Quirks sit alongside, but are distinct from, the five **appetites** +(compute / prestige / mentees / money / mission_purity, ADR-0011 sec.8). An appetite is a +0..1 negotiation hunger revealed by *interviewing*; a quirk is a discrete rider revealed by +an *exposure* (an incident, or simply time on the team) -- **never** by the interview ladder +(A2). A fully-interviewed hire can still be hiding a quirk. + +Register is held to Papers-Please deadpan: **"bother," not HR gravity** (WORLD_AND_LORE.md). +Quirks are archetype-flavours, never portraits of real people (the event-horizon guardrail). + +## The catalogue (14 quirks) + +Valence key: `+` positive, `-` negative, `+/-` double-edged (the best ones cut both ways). + +| # | id | name | v | one-line flavour | hidden effect (legible once revealed) | appetite | reveal | +|---|----|------|---|------------------|----------------------------------------|----------|--------| +| 1 | `secret_successionist` | Secret Successionist | - | Believes the machines should inherit the earth -- and is quietly at peace with it. | ships fast (+12% own productivity) but quietly discounts safety (+0.04 doom) | mission_purity | incident (an old commit's half-joke manifesto) | +| 2 | `doom_absolutist` | Doom Absolutist | +/- | P(doom) is 0.99 and they will cc the whole company to say so. | their diligence lowers doom (-0.06) but the moralizing drags the room (-3% team) | mission_purity | tenure (~8 turns) | +| 3 | `e_acc_sympathizer` | e/acc Sympathizer | - | Wants to feel the AGI. "Also helps safety, really." | burns compute, ships (+15% own) but pushes danger (+0.05 doom) | compute | incident (unsanctioned scaling run) | +| 4 | `open_science_zealot` | Open-Science Zealot | +/- | Information wants to be free; the NDA was a suggestion. | spreads knowledge (+2% team) but leaks on principle (3%/turn) | prestige | leak (a preprint legal never saw) | +| 5 | `secrecy_maximalist` | Secrecy Maximalist | +/- | Compartmentalizes everything, including from you. | heads-down focus (+5% own) but silos the team (-4% team) | mission_purity | tenure (~6 turns) | +| 6 | `empire_builder` | Empire Builder | +/- | Every problem is solved by hiring three more reports. | grows the team (+4% team) but drives everyone hard (+0.3 burnout/turn) | prestige | tenure (~7 turns) | +| 7 | `runs_hot` | Runs Hot | +/- | Ships twice the work and burns at twice the rate. | +20% own productivity, +2.0 burnout/turn | -- | tenure (~6 turns) | +| 8 | `lab_parent` | Lab Parent | + | The one who actually onboards the juniors nobody else will. | +6% productivity to the whole team | mentees | tenure (~5 turns) | +| 9 | `loose_lips` | Loose Lips | - | A delightful conversationalist with a poor sense of what was confidential. | 5%/turn chance to leak research | prestige | leak (a detail in a rival deck) | +| 10 | `sponge` | Sponge | + | Absorbs a whole subfield over a long weekend. | 2.5x skill-growth rate | mentees | tenure (~10 turns) | +| 11 | `cat_whisperer` | Cat Whisperer | + | The office cat is calmest at their desk -- a small mercy against the doom. | +3% team, -0.5 burnout/turn (cancels the base) | -- | tenure (~4 turns) | +| 12 | `glory_hound` | Glory Hound | - | First author or no author; co-authors are load-bearing furniture. | +10% own but demoralizes co-authors (-4% team) | prestige | incident (authorship dispute) | +| 13 | `quiet_quitter` | Quiet Quitter | - | Present, paid, and quietly checked out. | -15% own productivity, -1 loyalty/turn (flight risk) | money | tenure (~6 turns) | +| 14 | `true_believer` | True Believer | + | Would do this for free, and nearly does; recruiters bounce off. | -0.04 doom, +1 loyalty/turn (poach-resistant) | mission_purity | tenure (~8 turns) | + +Deliberate spread: 4 positive, 5 negative, 5 double-edged. The two secrecy poles +(`open_science_zealot` vs `secrecy_maximalist`) and the three mission-purity registers +(`true_believer` / `doom_absolutist` / `secret_successionist`) map the doomer-vs-accelerationist +axis onto real hiring dilemmas. + +## Effect channels + +A quirk's effect is a small fixed set of keys the sim reads through +`Researcher.quirk_effect(key, default)`. No stat soup -- each quirk touches one or two: + +| channel | default | where it hooks | sign meaning | +|---------|---------|----------------|--------------| +| `self_productivity_mult` | 1.0 | `get_effective_productivity()` | >1 faster, <1 slower | +| `burnout_per_turn_add` | 0.0 | `process_turn()` (on top of base 0.5) | + burns, - relieves | +| `doom_mod_add` | 0.0 | `get_doom_modifier()` | + raises doom, - lowers | +| `leak_chance` | 0.0 | `turn_manager` roster loop | per-turn leak probability | +| `team_productivity_add` | 0.0 | `turn_manager` team bonus | + lifts team, - drags it | +| `skill_growth_mult` | 1.0 | `process_turn()` skill roll | >1 grows faster | +| `loyalty_per_turn_add` | 0 | `process_turn()` loyalty drift | + retains, - flight-risk | + +These are the **exact hook points the retired traits used**, so wiring quirks in was a swap, +not new sim surface. The effect is live even while the quirk is hidden -- the player observes +the *output* (a leak, a productivity dip) before learning the *cause*. + +## Reveal mechanics + +Quirks flip `quirk_known` (never fabricated -- the value pre-exists) via: + +1. **Tenure fallback (deterministic, always present).** Every quirk carries + `reveal.after_turns`; once `turns_employed` reaches it, `process_turn()` surfaces the + quirk (`maybe_reveal_quirk_by_tenure()`). This guarantees no rider stays invisible + forever, and is fully seed-reproducible (ADR-0006). +2. **Leak incident.** When a leak-risk quirk (`leak_chance > 0`) actually fires a leak, + `turn_manager` calls `expose_quirk()` that turn -- the incident names the culprit. +3. **Exposure events.** `reveal.via = "incident"` quirks are tagged for the existing + ADR-0003 exposure-event genre (`expose_quirk()`); the tenure fallback still guarantees + eventual reveal until a bespoke event exists. + +Interviewing (the reveal ladder) deliberately does **not** surface quirks (A2 contract, +`test_quirk_hidden_even_when_fully_interviewed`). + +## Starters (the "starter pokemon") + +The four turn-0 founders each get a **guaranteed hidden rider** (`game_state.gd +_assign_guaranteed_rider`): a coin-flip between a strong appetite and a **catalogue quirk** +(`QuirkCatalogue.pick_id`). Riders start hidden; the depth is latent for late-game drama. + +## Determinism (ADR-0006) + +- Assignment draws only from the seeded RNG. `QuirkCatalogue.pick_id()` indexes a **sorted** + id list, so the pick is independent of JSON/Dictionary iteration order. +- In `generate_random`, the quirk draw reuses the same two hidden-RNG draws (chance + + index) the old pool used, so the RNG stream is byte-identical downstream -- only the + resulting id changes. +- `quirk` / `quirk_known` serialize in `to_dict`/`from_dict` and round-trip through JSON. +- Replay verification (`test_replay_verification`) is run-vs-replay, and both tiers pass. + +## Legacy traits: retired vs kept-reframed + +The old `POSITIVE_TRAITS` / `NEGATIVE_TRAITS` dicts, the `traits` array, `add_trait` / +`has_trait` / `get_trait_description`, and the dead `_assign_random_traits` assigner are +**removed**. Their effect hooks were repointed at the quirk channels above. Pip: "happy to +move away from the perk thinking ... very placeholdery and shallowly designed." + +**Kept by reframing into a quirk** (the ones worth keeping): + +| legacy trait | -> quirk | why kept | +|--------------|----------|----------| +| `workaholic` (+prod, +burnout) | `runs_hot` | the cleanest double-edged; classic | +| `team_player` (+team prod) | `lab_parent` | team-wide lift, now with a mentees tie-in | +| `leak_prone` (leaks) | `loose_lips` | anchors the secrecy/leaks theme | +| `fast_learner` (skill growth) | `sponge` | keeps the growth-over-time hook | +| `safety_conscious` (-doom) | folded into `true_believer` | doom-diligence, now with loyalty | + +**Retired outright** (shallow / redundant / tied to other systems): +`prima_donna` (salary-threshold micro-penalty), `burnout_prone` (subsumed by `runs_hot` / +per-quirk burnout), `pessimist` (flat morale hit, no depth), `media_savvy` (paper-quality / +reputation bonus -- a press-facing quirk can re-add it later via a dedicated channel), +`road_warrior` (jet-lag-specific; the jet-lag system stands on its own). + +## Follow-ups (not in this pass) + +- Bespoke exposure *events* per `incident` quirk (currently tenure-fallback only). +- The placeholder staff-perks panels (`staff_perks_panel.gd`, `staff_perks_compact.gd`) + are decoupled from the removed `traits` field and now show empty slots; a future pass can + rebuild them on the quirk layer (or delete them). +- A press/reputation channel to re-home the retired `media_savvy` effect. diff --git a/godot/autoload/event_service.gd b/godot/autoload/event_service.gd index 2e875c9c..6ab2de8a 100644 --- a/godot/autoload/event_service.gd +++ b/godot/autoload/event_service.gd @@ -463,6 +463,15 @@ func _transform_event(raw: Dictionary) -> Dictionary: "cooldown_turns": rarity_settings.get("cooldown_turns", 20) } + # P0 feed-flooding fix (playtest 2026-07-17): the entire arxiv/technical-research-breakthrough + # deck (200+ entries) fires as flavour and drowns real, actionable events in the feed. Demote + # that stream to its own low-severity "flavour" channel on the FEED tier so it never demands a + # decision and can be filtered out of the default feed view. Conservative match: only arxiv_* + # ids and the technical_research_breakthrough category (genuine policy/incident events untouched). + if _is_flavour_event(raw, category): + game_event["delivery_tier"] = "feed" + game_event["channel"] = "flavour" + # Add reactions if present (for UI display) if raw.has("safety_researcher_reaction"): game_event["safety_researcher_reaction"] = raw["safety_researcher_reaction"] @@ -476,6 +485,15 @@ func _transform_event(raw: Dictionary) -> Dictionary: return game_event +func _is_flavour_event(raw: Dictionary, category: String) -> bool: + """True for the low-stakes arxiv/research-breakthrough flavour stream that floods the feed. + Kept deliberately narrow so real events (policy, incidents, org founding) are never demoted.""" + if category.to_lower() == "technical_research_breakthrough": + return true + var id_str := str(raw.get("id", "")) + return id_str.begins_with("arxiv") + + func _calculate_significance(raw: Dictionary) -> int: """Calculate significance score from impacts array""" if not raw.has("impacts") or not raw["impacts"] is Array: diff --git a/godot/data/actions/core.json b/godot/data/actions/core.json index 0b501e5b..92c56458 100644 --- a/godot/data/actions/core.json +++ b/godot/data/actions/core.json @@ -126,6 +126,41 @@ "costs": {}, "category": "influence", "is_submenu": true + }, + { + "id": "advertise", + "name": "Advertise a Role", + "description": "SOURCE: spend money + Attention to run a hiring campaign. Candidates trickle into the pool over the coming months (also raises your visibility).", + "costs": {}, + "category": "hiring" + }, + { + "id": "use_connections", + "name": "Work Your Connections", + "description": "SOURCE: spend a favor (reputation) + Attention for one fast, pre-vetted lead. Success scales with your standing; the favor is spent whether or not it lands.", + "costs": {}, + "category": "hiring" + }, + { + "id": "interview_next", + "name": "Interview a Candidate", + "description": "INTERVIEW: spend Attention to interview the least-known pool candidate (triage). Reveals more of their card after a few days. Hiring blind is legal but leaves more hidden.", + "costs": {}, + "category": "hiring" + }, + { + "id": "hire_best", + "name": "Make an Offer", + "description": "OFFER: offer at expectation to the strongest available candidate. They decide after a couple of days; if in range they accept, then onboard before becoming productive.", + "costs": {}, + "category": "hiring" + }, + { + "id": "onboard_next", + "name": "Onboard New Hires", + "description": "ONBOARD: spend Attention (+ money) on the checklist (laptop, visa, mentoring). Un-onboarded hires are barely productive; skipping mentoring risks early attrition.", + "costs": {}, + "category": "hiring" } ] } diff --git a/godot/data/balance/defaults.json b/godot/data/balance/defaults.json index b0b08fff..bfe96f7b 100644 --- a/godot/data/balance/defaults.json +++ b/godot/data/balance/defaults.json @@ -32,7 +32,23 @@ "desperation_payroll": { "severity_base": 1200.0, "severity_spread": 800.0, "fuse_turns": 55, "interest_rate": 0.008 }, "staff_rider": { "principal": 1200.0, "fuse_turns": 88, "interest_rate": 0.001 }, "blackmail": { "principal_multiplier": 1.5, "fuse_turns": 22, "interest_rate": 0.02 }, - "expose": { "rep_per_1000": 2.5, "governance_multiplier": 0.5 } + "expose": { "rep_per_1000": 2.5, "governance_multiplier": 0.5 }, + "promise": { + "_description": "fix/promise-currency (Option B): appetite promises cost a FUTURE OBLIGATION IN THEIR OWN DOMAIN, never raw reputation. The old reputation-currency principal vs a starting rep of 50 (death at rep<=0) was an instant-death trap. Magnitudes are small/survivable; interest 0.0 (obligations, not compounding mortality levers). first_authorship->papers, compute_budget->compute, mission/mentorship->governance.", + "first_authorship": { "principal": 1.0, "fuse_turns": 10, "interest_rate": 0.0 }, + "compute_budget": { "principal": 20.0, "fuse_turns": 8, "interest_rate": 0.0 }, + "mission_charter": { "principal": 6.0, "fuse_turns": 12, "interest_rate": 0.0 }, + "mentorship": { "principal": 4.0, "fuse_turns": 10, "interest_rate": 0.0 } + }, + "resentment_debt": { "principal": 1500.0, "fuse_turns": 6, "interest_rate": 0.02 } + }, + "hiring": { + "_description": "Hiring pipeline (BUILD_BRIEF_HIRING_PIPELINE Phase B): source -> interview -> offer -> onboard. Every stage is Attention-gated and duration-bearing (ADR-0009). advertise = money + slow multi-month trickle; connections = a reputation favor for one fast pre-vetted lead (success scales with standing). offer.* is the hidden self-worth range; onboarding.* gates a new hire's productivity.", + "advertise": { "cost_money": 8000.0, "cost_attention": 3, "campaign_months": 3, "per_month_min": 0, "per_month_max": 2, "reputation_gain": 1.0 }, + "connections": { "cost_reputation": 6.0, "cost_attention": 2, "duration_ticks": 2, "rep_for_certainty": 80.0, "min_success": 0.05, "max_success": 0.95, "prevet_skill_min": 5, "prevet_skill_max": 8 }, + "interview": { "cost_attention": 2, "duration_ticks": 3, "reveal_step": 1 }, + "offer": { "cost_attention": 1, "duration_ticks": 2, "base_tolerance": 0.15, "money_appetite_weight": 0.5, "promise_discount": 0.20, "reject_scale": 2.0, "resentment_loyalty_penalty": 15, "read_spread": 0.06, "recruiter_min_skill": 4 }, + "onboarding": { "foreign_modulo": 4, "laptop_money": 3000.0, "laptop_attention": 1, "visa_money": 5000.0, "visa_attention": 2, "mentoring_money": 0.0, "mentoring_attention": 2, "unproductive_multiplier": 0.4, "skimped_multiplier": 0.85, "attrition_risk": 0.15 } }, "doom": { "_description": "Doom momentum + per-source coefficients (doom_system.gd) and player-facing status cutoffs. Momentum is a low-commitment SWITCH (#638 ruling): momentum_enabled 0/1 kills the contribution, momentum_weight scales it; internal accumulators keep ticking while disabled so mid-run toggles behave.", diff --git a/godot/data/researchers/quirks.json b/godot/data/researchers/quirks.json new file mode 100644 index 00000000..840ff484 --- /dev/null +++ b/godot/data/researchers/quirks.json @@ -0,0 +1,119 @@ +{ + "_doc": "Researcher QUIRK catalogue. Hidden-but-TRUE riders on the Phase-A ability layer (researcher.gd). Each quirk is real from creation; play REVEALS it (the sim never lies -- BUILD_BRIEF_HIRING_PIPELINE.md 'true-but-incomplete'). Design + rationale: docs/game-design/RESEARCHER_QUIRKS.md. Register: 'bother', Papers-Please deadpan (WORLD_AND_LORE.md). Effect keys are a small fixed set the sim reads through Researcher.quirk_effect(); see RESEARCHER_QUIRKS.md for the channel list. Reveal.after_turns is a deterministic tenure fallback so every quirk surfaces in play (ADR-0006); reveal.via names the narrative trigger (leak-quirks also self-surface the turn a leak fires).", + "version": 1, + "quirk_chance": 0.15, + "quirks": { + "secret_successionist": { + "name": "Secret Successionist", + "flavour": "Believes the machines should inherit the earth -- and is quietly at peace with it.", + "valence": "negative", + "appetite": "mission_purity", + "effects": { "self_productivity_mult": 1.12, "doom_mod_add": 0.04 }, + "reveal": { "via": "incident", "after_turns": 12, "hint": "A half-joking manifesto surfaces in an old commit message." } + }, + "doom_absolutist": { + "name": "Doom Absolutist", + "flavour": "P(doom) is 0.99 and they will cc the whole company to say so.", + "valence": "double_edged", + "appetite": "mission_purity", + "effects": { "doom_mod_add": -0.06, "team_productivity_add": -0.03 }, + "reveal": { "via": "tenure", "after_turns": 8, "hint": "The all-hands rants stop being one-offs and become a pattern." } + }, + "e_acc_sympathizer": { + "name": "e/acc Sympathizer", + "flavour": "Wants to feel the AGI. 'Also helps safety, really.'", + "valence": "negative", + "appetite": "compute", + "effects": { "self_productivity_mult": 1.15, "doom_mod_add": 0.05 }, + "reveal": { "via": "incident", "after_turns": 10, "hint": "Caught mid unsanctioned scaling run on the good cluster." } + }, + "open_science_zealot": { + "name": "Open-Science Zealot", + "flavour": "Information wants to be free; the NDA was more of a suggestion.", + "valence": "double_edged", + "appetite": "prestige", + "effects": { "leak_chance": 0.03, "team_productivity_add": 0.02 }, + "reveal": { "via": "leak", "after_turns": 9, "hint": "A preprint goes up before legal had seen it." } + }, + "secrecy_maximalist": { + "name": "Secrecy Maximalist", + "flavour": "Compartmentalizes everything, including from you.", + "valence": "double_edged", + "appetite": "mission_purity", + "effects": { "self_productivity_mult": 1.05, "team_productivity_add": -0.04 }, + "reveal": { "via": "tenure", "after_turns": 6, "hint": "Nobody can find where they filed anything." } + }, + "empire_builder": { + "name": "Empire Builder", + "flavour": "Every problem is solved by hiring three more reports.", + "valence": "double_edged", + "appetite": "prestige", + "effects": { "team_productivity_add": 0.04, "burnout_per_turn_add": 0.3 }, + "reveal": { "via": "tenure", "after_turns": 7, "hint": "The org chart quietly grows a new branch under them." } + }, + "runs_hot": { + "name": "Runs Hot", + "flavour": "Ships twice the work and burns at twice the rate.", + "valence": "double_edged", + "appetite": "", + "effects": { "self_productivity_mult": 1.20, "burnout_per_turn_add": 2.0 }, + "reveal": { "via": "tenure", "after_turns": 6, "hint": "The 3am commits stop being occasional." } + }, + "lab_parent": { + "name": "Lab Parent", + "flavour": "The one who actually onboards the juniors nobody else will.", + "valence": "positive", + "appetite": "mentees", + "effects": { "team_productivity_add": 0.06 }, + "reveal": { "via": "tenure", "after_turns": 5, "hint": "Half the team lists them as their real manager." } + }, + "loose_lips": { + "name": "Loose Lips", + "flavour": "A delightful conversationalist with a poor sense of what was confidential.", + "valence": "negative", + "appetite": "prestige", + "effects": { "leak_chance": 0.05 }, + "reveal": { "via": "leak", "after_turns": 8, "hint": "A specific detail turns up in a competitor's slide deck." } + }, + "sponge": { + "name": "Sponge", + "flavour": "Absorbs a whole subfield over a long weekend.", + "valence": "positive", + "appetite": "mentees", + "effects": { "skill_growth_mult": 2.5 }, + "reveal": { "via": "tenure", "after_turns": 10, "hint": "Their skill curve bends visibly upward." } + }, + "cat_whisperer": { + "name": "Cat Whisperer", + "flavour": "The office cat is calmest at their desk -- a small mercy against the doom.", + "valence": "positive", + "appetite": "", + "effects": { "team_productivity_add": 0.03, "burnout_per_turn_add": -0.5 }, + "reveal": { "via": "tenure", "after_turns": 4, "hint": "The cat has chosen a lap, and it is theirs." } + }, + "glory_hound": { + "name": "Glory Hound", + "flavour": "First author or no author; co-authors are load-bearing furniture.", + "valence": "negative", + "appetite": "prestige", + "effects": { "self_productivity_mult": 1.10, "team_productivity_add": -0.04 }, + "reveal": { "via": "incident", "after_turns": 9, "hint": "An authorship dispute lands on your desk." } + }, + "quiet_quitter": { + "name": "Quiet Quitter", + "flavour": "Present, paid, and quietly checked out.", + "valence": "negative", + "appetite": "money", + "effects": { "self_productivity_mult": 0.85, "loyalty_per_turn_add": -1 }, + "reveal": { "via": "tenure", "after_turns": 6, "hint": "The output flatlines while the salary does not." } + }, + "true_believer": { + "name": "True Believer", + "flavour": "Would do this for free, and nearly does; recruiters bounce off.", + "valence": "positive", + "appetite": "mission_purity", + "effects": { "doom_mod_add": -0.04, "loyalty_per_turn_add": 1 }, + "reveal": { "via": "tenure", "after_turns": 8, "hint": "Three recruiters have failed to move them." } + } + } +} diff --git a/godot/scenes/config_confirmation.tscn b/godot/scenes/config_confirmation.tscn index ac62420b..dbccd239 100644 --- a/godot/scenes/config_confirmation.tscn +++ b/godot/scenes/config_confirmation.tscn @@ -238,7 +238,7 @@ theme_override_font_sizes/font_size = 24 theme_override_styles/normal = SubResource("StyleBoxFlat_hover") theme_override_styles/hover = SubResource("StyleBoxFlat_focus") theme_override_styles/pressed = SubResource("StyleBoxFlat_pressed") -text = "▶ INITIALIZE LAB" +text = ">> INITIALIZE LAB" [node name="CustomizeButton" type="Button" parent="Panel/VBox/ButtonRow"] custom_minimum_size = Vector2(150, 60) diff --git a/godot/scenes/pregame_setup.tscn b/godot/scenes/pregame_setup.tscn index eac079bc..d9f363b3 100644 --- a/godot/scenes/pregame_setup.tscn +++ b/godot/scenes/pregame_setup.tscn @@ -282,7 +282,7 @@ theme_override_font_sizes/font_size = 24 theme_override_styles/normal = SubResource("StyleBoxFlat_hover") theme_override_styles/hover = SubResource("StyleBoxFlat_focus") theme_override_styles/pressed = SubResource("StyleBoxFlat_pressed") -text = "▶ INITIALIZE LAB" +text = ">> INITIALIZE LAB" [node name="CancelButton" type="Button" parent="Panel/VBox/ButtonRow"] custom_minimum_size = Vector2(150, 60) diff --git a/godot/scripts/core/actions.gd b/godot/scripts/core/actions.gd index f335c1ce..84148b4f 100644 --- a/godot/scripts/core/actions.gd +++ b/godot/scripts/core/actions.gd @@ -276,6 +276,37 @@ static func execute_action(action_id: String, state: GameState) -> Dictionary: state.add_resources(action["costs"]) result["message"] = hire_result["message"] + # --- Hiring pipeline (Phase B): source -> interview -> offer -> onboard. Each routes + # to state.hiring, spends Attention there, and (except onboarding) enqueues a duration + # job that resolves on a later tick (ADR-0009). These no-target menu drivers let the + # action menu + sweep bots exercise the whole fishing-line. --- + "advertise": + var r_adv: Dictionary = state.hiring.advertise(state) + result["success"] = bool(r_adv.get("success", false)) + result["message"] = String(r_adv.get("message", "")) + + "use_connections": + var r_con: Dictionary = state.hiring.use_connections(state) + result["success"] = bool(r_con.get("success", false)) + result["message"] = String(r_con.get("message", "")) + + "interview_next": + var r_int: Dictionary = state.hiring.interview_next(state) + result["success"] = bool(r_int.get("success", false)) + result["message"] = String(r_int.get("message", "")) + + "hire_best": + # Offer at expectation to the best available candidate (accepts after its + # duration; onboarding still gates productivity). + var r_off: Dictionary = state.hiring.offer_best(state) + result["success"] = bool(r_off.get("success", false)) + result["message"] = String(r_off.get("message", "")) + + "onboard_next": + var r_onb: Dictionary = state.hiring.onboard_all(state) + result["success"] = bool(r_onb.get("success", false)) + result["message"] = String(r_onb.get("message", "")) + "hire_compute_engineer": state.compute_engineers += 1 result["message"] = "Hired compute engineer (+1 compute staff)" @@ -306,20 +337,13 @@ static func execute_action(action_id: String, state: GameState) -> Dictionary: result["message"] = "Capability research (+%0.1f research)" % research_gained "publish_paper": - # Check for media_savvy researchers (bonus reputation) - var media_savvy_bonus = 0 - for researcher in state.researchers: - if researcher.has_trait("media_savvy"): - media_savvy_bonus += 3 # +3 reputation per media savvy researcher - var total_rep = 2 + media_savvy_bonus # 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}) + # (The retired media_savvy trait's reputation bonus is gone; a press-facing quirk + # could re-add it later via a dedicated channel.) + state.add_resources({"papers": 1, "reputation": 2}) state.global_alarm += Balance.num("doom.streams.action_paper_alarm", 0.0) - if media_savvy_bonus > 0: - result["message"] = "Published paper (+1 paper, +%d reputation including media bonus)" % total_rep - else: - result["message"] = "Published paper (+1 paper, +2 reputation)" + result["message"] = "Published paper (+1 paper, +2 reputation)" "fundraise": # Submenu action - doesn't execute, opens dialog @@ -647,20 +671,6 @@ static func _apply_risk_contributions(action_id: String, state: GameState) -> vo state.risk_system.add_risk_multi(entry.get("pools", {}), str(entry.get("source", action_id)), state.turn) -static func _assign_random_traits(researcher: Researcher, rng: RandomNumberGenerator): - """Assign random traits to a new researcher""" - # 40% chance of one positive trait - if rng.randf() < 0.40: - var positive_traits = ["workaholic", "team_player", "media_savvy", "safety_conscious", "fast_learner"] - var trait_id = positive_traits[rng.randi() % positive_traits.size()] - researcher.add_trait(trait_id) - - # 25% chance of one negative trait - if rng.randf() < 0.25: - var negative_traits = ["prima_donna", "leak_prone", "burnout_prone", "pessimist"] - var trait_id = negative_traits[rng.randi() % negative_traits.size()] - researcher.add_trait(trait_id) - static func _hire_from_pool(state: GameState, specialization: String) -> Dictionary: """Try to hire a candidate from the pool with matching specialization""" var candidate: Researcher = null @@ -689,9 +699,11 @@ static func _hire_from_pool(state: GameState, specialization: String) -> Diction # Hire the selected candidate state.hire_candidate(candidate) - var trait_info = "" - if candidate.traits.size() > 0: - trait_info = " [%s]" % candidate.get_trait_description() + # A quirk (if any) stays HIDDEN at hire time -- only show it once an exposure has + # surfaced it (quirk_known), keeping the true-but-incomplete contract. + var quirk_info = "" + if candidate.quirk_known and candidate.quirk != "": + quirk_info = " [%s]" % candidate.quirk_display_name() var spec_name = specialization.capitalize() return { @@ -700,7 +712,7 @@ static func _hire_from_pool(state: GameState, specialization: String) -> Diction candidate.researcher_name, spec_name, candidate.skill_level, - trait_info + quirk_info ], "hired_researcher": candidate } @@ -723,10 +735,9 @@ static func submit_paper_to_conference(state: GameState, conf_id: String, topic: if state.action_points < 1: return {"success": false, "message": "Not enough action points"} - # Calculate paper quality - var has_media_savvy = lead_researcher != null and lead_researcher.has_trait("media_savvy") + # Calculate paper quality (media_savvy trait retired -> no press bonus for now) var lead_skill = lead_researcher.skill_level if lead_researcher != null else 3 - var quality = PaperSubmissions.calculate_paper_quality(research_amount, lead_skill, 0, has_media_savvy) + var quality = PaperSubmissions.calculate_paper_quality(research_amount, lead_skill, 0, false) # Create paper submission var paper = PaperSubmissions.PaperSubmission.new() diff --git a/godot/scripts/core/game_state.gd b/godot/scripts/core/game_state.gd index 02e86b83..5f8c3598 100644 --- a/godot/scripts/core/game_state.gd +++ b/godot/scripts/core/game_state.gd @@ -63,6 +63,11 @@ var financing_offers: Array = [] # is the day-grained resolution tick. Rebuilt per game in reset(). var month_plan: MonthPlan +# The hiring pipeline (Phase B / BUILD_BRIEF_HIRING_PIPELINE): source -> interview -> offer +# -> onboard. Instance state (campaigns, in-flight duration jobs), rebuilt per game in +# reset(). Deterministic; spends Attention through month_plan and mints promises on the ledger. +var hiring: HiringPipeline + # EE-8 (ADR-0012): chronological CONTRIBUTING-CAUSE log for root-cause death # attribution. Ledger defaults, governance deficits, secret exposures, rep collapse # and funding starvation are appended here with turn stamps, so a death can be traced @@ -223,6 +228,8 @@ func reset(): # month ordinal is derived from the calendar (turn 0 -> ordinal 0). month_plan = MonthPlan.new() month_plan.begin_month(Balance.inum("attention.per_month", 20), 0) + hiring = HiringPipeline.new() # Phase B: fresh pipeline per game (created BEFORE candidates + # are populated so add_candidate can stamp their ids) cause_log.clear() # EE-8: fresh attribution trail per game rep_collapse_noted = false funding_starvation_noted = false @@ -537,13 +544,19 @@ func get_researcher_count_by_spec(spec: String) -> int: count += 1 return count -func add_researcher(researcher: Researcher): - """Add a researcher to the team""" - # Phase A hiring model: an employed person is fully known on their card (skill, - # appetites, loyalty-risk all revealed) and marked EMPLOYED. The QUIRK deliberately - # stays hidden until an exposure event -- employing someone does not surface it (A2). - researcher.set_reveal_level(Researcher.MAX_REVEAL) +func add_researcher(researcher: Researcher, full_reveal: bool = true): + """Add a researcher to the team. + Phase A hiring model: a DIRECTLY-hired person is fully known on their card (skill, + appetites, loyalty-risk all revealed) and marked EMPLOYED. The QUIRK deliberately + stays hidden until an exposure event -- employing someone does not surface it (A2). + Phase B: a PIPELINE hire passes full_reveal=false -- a blind hire (skipped interviews) + keeps whatever stayed hidden (the scouting gamble). Ensure a stable candidate_id so the + pipeline can reference the employee (onboarding, recruiter reads).""" + if full_reveal: + researcher.set_reveal_level(Researcher.MAX_REVEAL) researcher.hire_state = Researcher.HireState.EMPLOYED + if researcher.candidate_id == "" and hiring != null: + hiring.stamp_candidate(researcher) researchers.append(researcher) # Update legacy counts for backward compatibility @@ -572,6 +585,11 @@ func remove_researcher(researcher: Researcher): func add_candidate(candidate: Researcher): """Add a candidate to the hiring pool""" + # Phase B: stamp a stable id (+ deterministic visa flag) so the pipeline can reference + # this candidate across save/load. Covers every creation path (initial pool, turn trickle, + # sourcing channels). + if hiring != null: + hiring.stamp_candidate(candidate) if candidate_pool.size() < MAX_CANDIDATES: candidate_pool.append(candidate) @@ -594,31 +612,44 @@ func get_candidates_by_spec(spec: String) -> Array[Researcher]: matches.append(candidate) return matches +# Threshold at/above which an appetite counts as a "strong/notable" hidden rider. The +# guaranteed-rider assignment sets its chosen appetite at or above this, and tests key off it. +const STARTER_STRONG_APPETITE: float = 0.8 + func _populate_initial_candidates(): - """Generate 2-3 starting candidates (lower quality for early game)""" - # Always at least 1 safety researcher to start - var safety_candidate = Researcher.new() - safety_candidate.generate_random(rng) - safety_candidate.specialization = "safety" - # Lower skill for starting candidates - safety_candidate.skill_level = rng.randi_range(1, 3) - add_candidate(safety_candidate) - - # Second candidate - 50% safety, 50% capabilities - var second = Researcher.new() - second.generate_random(rng) - second.specialization = "safety" if rng.randf() < 0.5 else "capabilities" - second.skill_level = rng.randi_range(1, 3) - add_candidate(second) - - # Third candidate (50% chance) + """Seed the turn-0 founding team: EXACTLY 4 starter candidates, each GUARANTEED a hidden + rider (Pip ruling). Framing (Pip): these are the "starter pokemon" -- they accrue the most + experience and become the deepest in trust/seniority late-game, so a guaranteed latent rider + creates long-term narrative drama. The rider is either a rare quirk OR a strong appetite; it + starts HIDDEN (quirk_known stays false; a strong appetite only surfaces at the appetite + reveal layer) and reveal_level stays REVEAL_UNINTERVIEWED. Deterministic from the seeded rng + (replay-safe, ADR-0006). Normal sourced candidates keep their existing chance-based riders -- + only these four founders are guaranteed one.""" + var specs := ["safety", "capabilities", "interpretability", "alignment"] + for i in range(4): + var cand := Researcher.new() + cand.generate_random(rng) + # First starter is always a safety anchor; the others rotate the lanes. + cand.specialization = "safety" if i == 0 else specs[rng.randi() % specs.size()] + # Lower skill for early-game starting candidates. + cand.skill_level = rng.randi_range(1, 3) + cand.base_productivity = 0.5 + (cand.skill_level * 0.1) + _assign_guaranteed_rider(cand) + add_candidate(cand) + +func _assign_guaranteed_rider(candidate: Researcher) -> void: + """Give a founding-team starter a GUARANTEED, still-HIDDEN rider, drawn from the seeded rng + (deterministic). Coin-flip between the two rider kinds so the four founders vary: + - quirk rider: a rare quirk (hidden until an exposure event; quirk_known stays false) + - appetite rider: one appetite pushed to STARTER_STRONG_APPETITE+ (hidden until the + appetite reveal layer, revealed only by interviewing) + Either way the card shows nothing extra at reveal 0 -- the depth is latent.""" if rng.randf() < 0.5: - var third = Researcher.new() - third.generate_random(rng) - var specs = ["safety", "capabilities", "interpretability", "alignment"] - third.specialization = specs[rng.randi() % specs.size()] - third.skill_level = rng.randi_range(1, 3) - add_candidate(third) + candidate.quirk = QuirkCatalogue.pick_id(rng) # from the data-driven catalogue + candidate.quirk_known = false + else: + var key: String = Researcher.APPETITE_KEYS[rng.randi() % Researcher.APPETITE_KEYS.size()] + candidate.appetites[key] = clampf(STARTER_STRONG_APPETITE + rng.randf() * 0.2, STARTER_STRONG_APPETITE, 1.0) func get_management_capacity() -> int: """How many employees can current managers handle?""" @@ -893,6 +924,7 @@ func to_dict() -> Dictionary: "governance": governance, "ledger": ledger.to_dict() if ledger else {}, "month_plan": month_plan.to_dict() if month_plan else {}, # L1/ADR-0009 Attention + reserve + WIP + "hiring": hiring.to_dict() if hiring else {}, # Phase B pipeline: campaigns + in-flight jobs "cause_log": cause_log.duplicate(true), # EE-8 attribution trail # Doom-adjacent floats are SNAPPED at the serialization boundary (both directions, # same quantum as DoomSystem.SAVE_QUANTUM): the stream model produces full-precision @@ -1092,6 +1124,13 @@ func from_dict(data: Dictionary) -> void: month_plan = MonthPlan.new() month_plan.from_dict(data.get("month_plan", {})) + # Restore the hiring pipeline (Phase B: campaigns + in-flight duration jobs + id serial). + # Candidate/employee onboarding + reveal state ride on the Researcher records themselves, + # restored below; this restores the campaign/job bookkeeping and the id counter. + if hiring == null: + hiring = HiringPipeline.new() + hiring.from_dict(data.get("hiring", {})) + # Restore doom system if doom_system and data.has("doom_system"): doom_system.from_dict(data["doom_system"]) diff --git a/godot/scripts/core/hiring_pipeline.gd b/godot/scripts/core/hiring_pipeline.gd new file mode 100644 index 00000000..d16bb104 --- /dev/null +++ b/godot/scripts/core/hiring_pipeline.gd @@ -0,0 +1,661 @@ +extends RefCounted +class_name HiringPipeline +## The hiring pipeline (BUILD_BRIEF_HIRING_PIPELINE "Phase B") -- the fishing-line: +## source -> interview -> offer -> onboard. Every stage is an Attention-gated, DURATION +## action (ADR-0009: nothing strategic resolves instantly): the player casts effort out +## now and the reward lands later. +## +## DESIGN GUARDS honored here: +## - Deterministic / replay-safe (WS-0, ADR-0006): every draw comes from state.rng and +## only ever happens when a pipeline action is actually taken, or when a campaign/job/ +## un-mentored hire is actually live. With no pipeline activity the RNG stream is +## byte-unchanged, so pre-existing replays still verify. +## - Hidden info is TRUE-but-incomplete: interviewing REVEALS the Phase-A card (never +## fabricates); the negotiation self-worth range is the one genuinely-hidden function. +## - Comp is NOT hidden (Pip ruling): salary_expectation reveals at interview level 1; +## the hidden thing is the self-worth *floor* the offer must clear (self_worth_floor). +## - `loyalty` (dynamic) stays distinct from `loyalty_risk` (hidden predisposition). +## - Appetites are the negotiation CURRENCY (ADR-0011): a promise to feed a hunger buys +## the candidate down in cash and MINTS A LEDGER ENTRY (ADR-0003) -- a real future bill. +## +## Instance state lives on GameState.hiring (mirrors state.ledger / state.month_plan), is +## reset per game, and round-trips through to_dict/from_dict. Attention is spent through +## state.month_plan (the founder currency); money/reputation through the normal resources. + +# --- Persistent state (serialized) --- +var next_serial: int = 0 # monotonic id source for candidates + jobs (deterministic) +# Advertise campaigns: each trickles candidates into the pool at month boundaries. +# {months_remaining:int, per_month_min:int, per_month_max:int} +var campaigns: Array = [] +# In-flight duration jobs (interview / connections / offer). Each: +# {job_id:String, kind:String, candidate_id:String, resolves_on_turn:int, params:Dictionary} +var jobs: Array = [] +# Transient log of what resolved on the most recent on_tick / on_month_boundary (for the +# turn feed + tests). NOT serialized -- it is a per-call readout, not durable state. +var last_events: Array = [] + +const KIND_INTERVIEW := "interview" +const KIND_CONNECTIONS := "connections" +const KIND_OFFER := "offer" + +# The lanes a sourced candidate can land in (identity is decoupled from ability, Phase A). +const SPEC_POOL := ["safety", "capabilities", "interpretability", "alignment"] + +# Promise ids the player can attach to an offer (appetite -> the hunger it feeds). +const PROMISE_APPETITE := { + "first_authorship": "prestige", + "mentorship": "mentees", + "compute_budget": "compute", + "mission_charter": "mission_purity", +} + + +# ============================================================================ +# IDENTITY +# ============================================================================ + +func next_id(prefix: String) -> String: + """A deterministic, unique id (candidate or job). Pure counter -- no RNG.""" + next_serial += 1 + return "%s_%d" % [prefix, next_serial] + + +func stamp_candidate(candidate: Researcher) -> void: + """Give a freshly-created candidate a stable id + a deterministic visa flag (foreign/ + remote hires need a visa at onboard). No RNG: the flag rides the serial so it is + reproducible and does not perturb the Phase-A hidden-layer stream.""" + if candidate == null: + return + if candidate.candidate_id == "": + candidate.candidate_id = next_id("cand") + var modulo := Balance.inum("hiring.onboarding.foreign_modulo", 4) + if modulo > 0: + var serial := int(candidate.candidate_id.get_slice("_", 1)) + candidate.needs_visa = (serial % modulo == 0) + + +# ============================================================================ +# SOURCE (two channels, distinct pricing) +# ============================================================================ + +func advertise(state) -> Dictionary: + """ADVERTISE channel: money + Attention -> a campaign that trickles UNVETTED candidates + into the pool over the next few months (also a light reputation/discovery bump -- the + market learns you're hiring). Slow, high-volume, cheap-on-favors. The trickle lands at + month boundaries, so nothing arrives instantly (ADR-0009).""" + last_events = [] + var cost_money := Balance.num("hiring.advertise.cost_money", 8000.0) + var cost_att := Balance.inum("hiring.advertise.cost_attention", 3) + if state.money < cost_money: + return {"success": false, "message": "Can't afford the ad spend ($%d)" % int(cost_money)} + if state.month_plan == null or not state.month_plan.spend_attention(cost_att): + return {"success": false, "message": "Not enough Attention to launch an ad campaign (%d needed)" % cost_att} + state.money -= cost_money + # Light discovery effect: NPC awareness of you (reputation). + state.reputation += Balance.num("hiring.advertise.reputation_gain", 1.0) + campaigns.append({ + "months_remaining": Balance.inum("hiring.advertise.campaign_months", 3), + "per_month_min": Balance.inum("hiring.advertise.per_month_min", 0), + "per_month_max": Balance.inum("hiring.advertise.per_month_max", 2), + }) + return {"success": true, "message": "Launched a hiring ad campaign (candidates trickle in over the coming months)."} + + +func use_connections(state) -> Dictionary: + """CONNECTIONS channel: spend a favor (reputation) + Attention -> a FAST, short-duration + job that (on success) yields ONE PRE-VETTED candidate (skill + comp already known, + reveal_level = SKILL). Success scales with relative-rep flattery: your standing vs the + target's desirability -- a low-rep lab often calls in the favor and still gets a no. The + favor is spent whether or not it lands (that's the gamble).""" + last_events = [] + var cost_rep := Balance.num("hiring.connections.cost_reputation", 6.0) + var cost_att := Balance.inum("hiring.connections.cost_attention", 2) + if state.reputation < cost_rep: + return {"success": false, "message": "Not enough standing to call in a favor (%d rep)" % int(cost_rep)} + if state.month_plan == null or not state.month_plan.spend_attention(cost_att): + return {"success": false, "message": "Not enough Attention to work your connections (%d needed)" % cost_att} + state.reputation -= cost_rep + var job_id := next_id("job") + jobs.append({ + "job_id": job_id, + "kind": KIND_CONNECTIONS, + "candidate_id": "", + "resolves_on_turn": int(state.turn) + Balance.inum("hiring.connections.duration_ticks", 2), + "params": {}, + }) + return {"success": true, "message": "Reached out through your network -- a pre-vetted lead may surface shortly."} + + +func _connection_success_chance(state) -> float: + """Relative-rep flattery: your reputation vs a desirability scale. Higher standing -> + the introduction lands more reliably. Bounded so it is never a sure thing either way.""" + var certainty := Balance.num("hiring.connections.rep_for_certainty", 80.0) + var lo := Balance.num("hiring.connections.min_success", 0.05) + var hi := Balance.num("hiring.connections.max_success", 0.95) + if certainty <= 0.0: + return hi + return clampf(state.reputation / certainty, lo, hi) + + +func _spawn_candidate(state, prevet: bool) -> Researcher: + """Create one random candidate deterministically (draws from state.rng -> replay-safe). + Pre-vetted candidates land higher-skill and partially revealed (skill + comp already + known); advertised candidates arrive uninterviewed (reveal 0).""" + var spec: String = SPEC_POOL[state.rng.randi() % SPEC_POOL.size()] + var c := Researcher.new(spec) + c.generate_random(state.rng) + c.specialization = spec + if prevet: + var lo := Balance.inum("hiring.connections.prevet_skill_min", 5) + var hi := Balance.inum("hiring.connections.prevet_skill_max", 8) + c.skill_level = state.rng.randi_range(lo, hi) + c.base_productivity = 0.5 + (c.skill_level * 0.1) + c.set_reveal_level(Researcher.REVEAL_SKILL) # pre-vetted: skill + comp known up front + return c + + +func _add_sourced_candidate(state, candidate: Researcher) -> bool: + """Stamp + admit a sourced candidate, respecting the pool cap. Returns false if full.""" + stamp_candidate(candidate) + if state.candidate_pool.size() >= state.MAX_CANDIDATES: + return false + state.candidate_pool.append(candidate) + return true + + +# ============================================================================ +# INTERVIEW (Attention-gated triage; reveals the Phase-A card) +# ============================================================================ + +func launch_interview(state, candidate_id: String) -> Dictionary: + """Queue an interview against a specific candidate (TRIAGE -- you can't screen the whole + pool). Spends Attention now; the reveal lands after a few ticks (ADR-0009). Interviewing + is the ONLY way to peel the reveal ladder; it never fabricates (ADR-0004).""" + last_events = [] + var cand := find_pool_candidate(state, candidate_id) + if cand == null: + return {"success": false, "message": "No such candidate in the pool."} + if cand.reveal_level >= Researcher.MAX_REVEAL: + return {"success": false, "message": "%s is already fully interviewed." % cand.researcher_name} + if _has_job_for(candidate_id, KIND_INTERVIEW): + return {"success": false, "message": "%s already has an interview scheduled." % cand.researcher_name} + var cost_att := Balance.inum("hiring.interview.cost_attention", 2) + if state.month_plan == null or not state.month_plan.spend_attention(cost_att): + return {"success": false, "message": "Not enough Attention to interview (%d needed)" % cost_att} + jobs.append({ + "job_id": next_id("job"), + "kind": KIND_INTERVIEW, + "candidate_id": candidate_id, + "resolves_on_turn": int(state.turn) + Balance.inum("hiring.interview.duration_ticks", 3), + "params": {}, + }) + return {"success": true, "message": "Interview scheduled with %s." % cand.researcher_name} + + +# ============================================================================ +# OFFER + NEGOTIATE (no minigame; the hidden self-worth range is the mechanic) +# ============================================================================ + +func self_worth_floor(candidate: Researcher, promises: Array = []) -> float: + """The hidden acceptance FLOOR -- the lowest cash the candidate will take. Built from the + (revealed-at-L1) salary_expectation E and the hidden appetites: + floor = E * (1 - tolerance) tolerance shrinks as the MONEY appetite grows + Promises feed a hunger and buy the floor DOWN in proportion to the matching appetite + (appetites as negotiation currency, ADR-0011). This is the one genuinely-hidden function + the offer must land inside.""" + var e: float = candidate.salary_expectation + var money_ap: float = float(candidate.appetites.get("money", 0.0)) + var base_tol := Balance.num("hiring.offer.base_tolerance", 0.15) + var money_weight := Balance.num("hiring.offer.money_appetite_weight", 0.5) + var tolerance: float = base_tol * (1.0 - money_weight * money_ap) + var raw_floor: float = e * (1.0 - tolerance) + var discount := 0.0 + var per_promise := Balance.num("hiring.offer.promise_discount", 0.20) + for p in promises: + var appetite_key: String = PROMISE_APPETITE.get(p, "") + if appetite_key != "": + discount += per_promise * float(candidate.appetites.get(appetite_key, 0.0)) * e + return maxf(0.0, raw_floor - discount) + + +func negotiation_read(state, candidate: Researcher, promises: Array = []) -> Dictionary: + """The recruiter/lieutenant read ("we think X will take ~$Y / $Y+-") -- personified SA. + With a recruiter on staff the visible band narrows tightly around the true floor; without + one the player sees only a wide guess. Never leaks the exact number (still a judgement).""" + var e: float = candidate.salary_expectation + var floor: float = self_worth_floor(candidate, promises) + var recruiter := _best_recruiter(state) + if recruiter == null: + # No read: a wide, uninformative band around the expectation. + return { + "has_recruiter": false, + "low": e * 0.70, "high": e * 1.10, "mid": e, + "text": "No recruiter read -- somewhere around $%d (wide guess)." % int(e), + } + # Recruiter read: a tight band around the real floor; skill sharpens it. + var spread := Balance.num("hiring.offer.read_spread", 0.06) * e + return { + "has_recruiter": true, + "recruiter": recruiter.researcher_name, + "low": maxf(0.0, floor - spread), "high": floor + spread, "mid": floor, + "text": "%s thinks %s takes about $%d (+-$%d)." % [ + recruiter.researcher_name, candidate.researcher_name, int(floor), int(spread)], + } + + +func make_offer(state, candidate_id: String, cash_offer: float, promises: Array = []) -> Dictionary: + """Extend an offer (Attention-gated, short duration). The candidate transitions to + OFFERED now; the accept/reject decision lands after a couple ticks (ADR-0009). Promises + are recorded on the job and only MINT their ledger bills if the offer is accepted.""" + last_events = [] + var cand := find_pool_candidate(state, candidate_id) + if cand == null: + return {"success": false, "message": "No such candidate in the pool."} + if _has_job_for(candidate_id, KIND_OFFER): + return {"success": false, "message": "%s already has an offer out." % cand.researcher_name} + var cost_att := Balance.inum("hiring.offer.cost_attention", 1) + if state.month_plan == null or not state.month_plan.spend_attention(cost_att): + return {"success": false, "message": "Not enough Attention to make an offer (%d needed)" % cost_att} + if not cand.transition_hire_state(Researcher.HireState.OFFERED): + # spend already happened; refund the attention since we can't proceed + state.month_plan.attention_spent -= cost_att + return {"success": false, "message": "%s can't be offered from their current state." % cand.researcher_name} + jobs.append({ + "job_id": next_id("job"), + "kind": KIND_OFFER, + "candidate_id": candidate_id, + "resolves_on_turn": int(state.turn) + Balance.inum("hiring.offer.duration_ticks", 2), + "params": {"cash": roundf(cash_offer), "promises": promises.duplicate()}, + }) + return {"success": true, "message": "Offer out to %s ($%d)." % [cand.researcher_name, int(cash_offer)]} + + +func _resolve_offer(state, cand: Researcher, cash: float, promises: Array) -> Dictionary: + """Land an offer decision. In range -> accept; below floor -> roll reject vs resentment. + Accept employs the candidate (keeping whatever stayed hidden -- blind hires are legal), + mints promise ledger entries, and starts onboarding.""" + var floor: float = self_worth_floor(cand, promises) + if cash >= floor: + return _accept_offer(state, cand, cash, promises, false) + # Below the floor: how far below sets the reject probability. + var reject_scale := Balance.num("hiring.offer.reject_scale", 2.0) + var shortfall_frac: float = (floor - cash) / maxf(1.0, floor) + var prob_reject: float = clampf(shortfall_frac * reject_scale, 0.0, 1.0) + if state.rng.randf() < prob_reject: + # Declined -- candidate returns to the pool (still selectable / re-offerable). + cand.transition_hire_state(Researcher.HireState.CANDIDATE_IN_POOL) + return {"success": true, "outcome": "rejected", + "message": "%s declined the offer (lowball) -- back in the pool." % cand.researcher_name} + # Accepted, but resentful: employed with a loyalty debt (ADR-0003). + return _accept_offer(state, cand, cash, promises, true) + + +func _accept_offer(state, cand: Researcher, cash: float, promises: Array, resentful: bool) -> Dictionary: + """Employ the candidate. Pipeline hires are NOT force-revealed (a blind hire keeps its + hidden layer) and start UN-onboarded (not yet productive). Promises + resentment mint + their ledger bills here, at the moment they become real obligations.""" + cand.transition_hire_state(Researcher.HireState.OFFERED) # ensure OFFERED before EMPLOYED + state.remove_candidate(cand) + cand.current_salary = cash + # Employ WITHOUT forcing full reveal (blind hires stay partly hidden, per the brief). + state.add_researcher(cand, false) + # Begin onboarding: not productive until the checklist clears. + cand.onboarded = false + cand.laptop_done = false + cand.visa_done = not cand.needs_visa + cand.mentoring_done = false + cand.mentoring_skipped = false + var minted: Array = [] + if state.ledger: + for p in promises: + if PROMISE_APPETITE.has(p): + state.ledger.add(Ledger.appetite_promise(cand.researcher_name, String(p))) + minted.append(p) + var msg := "%s accepted ($%d)." % [cand.researcher_name, int(cash)] + if minted.size() > 0: + msg += " Promised: %s (a ledger obligation)." % ", ".join(minted) + if resentful: + var penalty := Balance.inum("hiring.offer.resentment_loyalty_penalty", 15) + cand.loyalty = maxi(0, cand.loyalty - penalty) + if state.ledger: + state.ledger.add(Ledger.resentment_debt(cand.researcher_name)) + msg += " They took it resentfully -- a loyalty debt is on the books." + return {"success": true, "outcome": "resentful_accept" if resentful else "accepted", "message": msg} + + +# ============================================================================ +# ONBOARD (predictable checklist + situational events; gates productivity) +# ============================================================================ + +func onboarding_required(candidate: Researcher) -> Array: + """The HARD checklist that gates productivity: a laptop, plus a visa for foreign/remote + hires. Mentoring is a soft (recommended-not-required) item handled separately.""" + var items: Array = ["laptop"] + if candidate.needs_visa: + items.append("visa") + return items + + +func onboarding_status(candidate: Researcher) -> Dictionary: + """A readout of onboarding progress for the UI / tests.""" + return { + "onboarded": candidate.onboarded, + "needs_visa": candidate.needs_visa, + "laptop_done": candidate.laptop_done, + "visa_done": candidate.visa_done, + "mentoring_done": candidate.mentoring_done, + "mentoring_skipped": candidate.mentoring_skipped, + "required": onboarding_required(candidate), + } + + +func onboard_step(state, candidate_id: String, item: String) -> Dictionary: + """Complete one onboarding item, paying its Attention (+ money) cost. When the hard + checklist clears, the hire becomes productive. Mentoring is optional: doing it avoids the + lasting skimped-productivity debuff + the early-attrition risk (slack-as-insurance).""" + last_events = [] + var cand := find_employed(state, candidate_id) + if cand == null: + return {"success": false, "message": "No such onboarding hire."} + if cand.onboarded and item != "mentoring": + return {"success": false, "message": "%s is already onboarded." % cand.researcher_name} + match item: + "laptop": + if cand.laptop_done: + return {"success": false, "message": "Laptop already issued."} + if not _pay_onboard(state, Balance.num("hiring.onboarding.laptop_money", 3000.0), Balance.inum("hiring.onboarding.laptop_attention", 1)): + return {"success": false, "message": "Can't afford to kit out %s yet." % cand.researcher_name} + cand.laptop_done = true + "visa": + if not cand.needs_visa: + return {"success": false, "message": "%s doesn't need a visa." % cand.researcher_name} + if cand.visa_done: + return {"success": false, "message": "Visa already sorted."} + if not _pay_onboard(state, Balance.num("hiring.onboarding.visa_money", 5000.0), Balance.inum("hiring.onboarding.visa_attention", 2)): + return {"success": false, "message": "Can't afford the visa sponsorship yet."} + cand.visa_done = true + "mentoring": + if cand.mentoring_done: + return {"success": false, "message": "Mentoring already done."} + if not _pay_onboard(state, Balance.num("hiring.onboarding.mentoring_money", 0.0), Balance.inum("hiring.onboarding.mentoring_attention", 2)): + return {"success": false, "message": "Not enough Attention to mentor %s." % cand.researcher_name} + cand.mentoring_done = true + cand.mentoring_skipped = false + _: + return {"success": false, "message": "Unknown onboarding item '%s'." % item} + _maybe_finish_onboarding(cand) + return {"success": true, "message": "%s: %s done." % [cand.researcher_name, item], + "onboarded": cand.onboarded} + + +func skip_mentoring(state, candidate_id: String) -> Dictionary: + """Explicitly skip mentoring (slack-as-insurance, slow and tempting): saves the Attention + now, but stamps a lasting productivity debuff + arms the early-attrition risk.""" + last_events = [] + var cand := find_employed(state, candidate_id) + if cand == null: + return {"success": false, "message": "No such onboarding hire."} + cand.mentoring_skipped = true + cand.mentoring_done = false + _maybe_finish_onboarding(cand) + return {"success": true, "message": "Skipped mentoring for %s (attrition risk + a productivity debuff)." % cand.researcher_name} + + +func _pay_onboard(state, money_cost: float, att_cost: int) -> bool: + """Charge an onboarding item (money + Attention), all-or-nothing.""" + if state.money < money_cost: + return false + if att_cost > 0 and (state.month_plan == null or state.month_plan.available() < att_cost): + return false + state.money -= money_cost + if att_cost > 0: + state.month_plan.spend_attention(att_cost) + return true + + +func _maybe_finish_onboarding(cand: Researcher) -> void: + """Flip the hire to productive once the hard checklist (laptop + any visa) is clear. + Mentoring is not required to become productive -- only to avoid the debuffs.""" + var ready := cand.laptop_done and (cand.visa_done or not cand.needs_visa) + if ready: + cand.onboarded = true + + +# ============================================================================ +# TICKING (duration resolution + monthly trickle) -- deterministic, guarded +# ============================================================================ + +func on_tick(state) -> Array: + """Resolve any duration jobs due at state.turn. Called once per resolution tick. RNG is + touched ONLY when a job actually resolves (offers roll; connections roll), so a tick with + no due jobs leaves the deterministic stream untouched. Returns this tick's events.""" + last_events = [] + if jobs.is_empty(): + return last_events + var still: Array = [] + for job in jobs: + if int(job.get("resolves_on_turn", 0)) > int(state.turn): + still.append(job) + continue + _resolve_job(state, job) + jobs = still + return last_events + + +func _resolve_job(state, job: Dictionary) -> void: + var kind := String(job.get("kind", "")) + match kind: + KIND_INTERVIEW: + var cand := find_pool_candidate(state, String(job.get("candidate_id", ""))) + if cand != null: + var before := cand.reveal_level + cand.reveal_more(Balance.inum("hiring.interview.reveal_step", 1)) + last_events.append({"kind": "interview_done", "candidate": cand.researcher_name, + "reveal_level": cand.reveal_level, "was": before}) + KIND_CONNECTIONS: + if state.rng.randf() < _connection_success_chance(state): + var c := _spawn_candidate(state, true) + if _add_sourced_candidate(state, c): + last_events.append({"kind": "connection_hit", "candidate": c.researcher_name}) + else: + last_events.append({"kind": "connection_pool_full"}) + else: + last_events.append({"kind": "connection_miss"}) + KIND_OFFER: + var oc := find_pool_candidate(state, String(job.get("candidate_id", ""))) + if oc != null: + var params: Dictionary = job.get("params", {}) + var r := _resolve_offer(state, oc, float(params.get("cash", 0.0)), params.get("promises", [])) + last_events.append({"kind": "offer_" + String(r.get("outcome", "?")), + "candidate": oc.researcher_name, "message": r.get("message", "")}) + + +func on_month_boundary(state) -> Array: + """New plan month: advertise campaigns trickle candidates, and un-mentored recent hires + face an early-attrition roll. RNG is touched only when a campaign is live or an at-risk + hire exists, so a boundary with neither is stream-neutral (pre-existing month-loop + replays unaffected). Returns this boundary's events.""" + last_events = [] + _tick_campaigns(state) + _tick_attrition(state) + return last_events + + +func _tick_campaigns(state) -> void: + if campaigns.is_empty(): + return + var still: Array = [] + for camp in campaigns: + var lo := int(camp.get("per_month_min", 0)) + var hi := int(camp.get("per_month_max", 2)) + var n: int = state.rng.randi_range(lo, hi) + for i in range(n): + var c := _spawn_candidate(state, false) + if _add_sourced_candidate(state, c): + last_events.append({"kind": "advertise_hit", "candidate": c.researcher_name}) + var rem := int(camp.get("months_remaining", 0)) - 1 + if rem > 0: + camp["months_remaining"] = rem + still.append(camp) + campaigns = still + + +func _tick_attrition(state) -> void: + """Un-mentored, still-at-risk hires can quit early (skimping bites). Deterministic.""" + var risk := Balance.num("hiring.onboarding.attrition_risk", 0.15) + # Snapshot: _tick_attrition may remove from state.researchers as it goes. + var at_risk: Array = [] + for r in state.researchers: + if r.hire_state == Researcher.HireState.EMPLOYED and r.mentoring_skipped and not r.mentoring_done: + at_risk.append(r) + for r in at_risk: + if state.rng.randf() < risk: + r.mentoring_skipped = false # one roll per skimped hire; consume the flag + r.transition_hire_state(Researcher.HireState.DEPARTED) + state.remove_researcher(r) + if state.ledger: + state.ledger.add(Ledger.staff_rider(r.researcher_name)) # disgruntled-departure rider + last_events.append({"kind": "early_attrition", "candidate": r.researcher_name}) + + +# ============================================================================ +# LOOKUPS +# ============================================================================ + +func find_pool_candidate(state, candidate_id: String) -> Researcher: + if candidate_id == "": + return null + for c in state.candidate_pool: + if c.candidate_id == candidate_id: + return c + return null + + +func find_employed(state, candidate_id: String) -> Researcher: + if candidate_id == "": + return null + for r in state.researchers: + if r.candidate_id == candidate_id: + return r + return null + + +func _has_job_for(candidate_id: String, kind: String) -> bool: + for j in jobs: + if String(j.get("candidate_id", "")) == candidate_id and String(j.get("kind", "")) == kind: + return true + return false + + +func _best_recruiter(state) -> Researcher: + """The most senior employed, onboarded researcher acts as the recruiter/lieutenant that + sharpens the negotiation read. null if the founder is still solo.""" + var best: Researcher = null + var min_skill := Balance.inum("hiring.offer.recruiter_min_skill", 4) + for r in state.researchers: + if r.hire_state != Researcher.HireState.EMPLOYED or not r.onboarded: + continue + if r.skill_level < min_skill: + continue + if best == null or r.skill_level > best.skill_level: + best = r + return best + + +# ============================================================================ +# CONVENIENCE (no-target) DRIVERS -- for the action-menu + sweep bots +# ============================================================================ + +func interview_next(state) -> Dictionary: + """Interview the least-revealed pool candidate that isn't already scheduled. Lets the + action menu + bots drive the triage stage without a target-picker UI.""" + var target: Researcher = null + for c in state.candidate_pool: + if c.reveal_level >= Researcher.MAX_REVEAL: + continue + if _has_job_for(c.candidate_id, KIND_INTERVIEW): + continue + if target == null or c.reveal_level < target.reveal_level: + target = c + if target == null: + return {"success": false, "message": "No un-interviewed candidate to screen."} + return launch_interview(state, target.candidate_id) + + +func offer_best(state) -> Dictionary: + """Make a fair-value offer (at expectation) to the highest-skill candidate not already + under offer -- the bot/menu 'just hire someone' driver. Uses no promises.""" + var target: Researcher = null + for c in state.candidate_pool: + if c.hire_state != Researcher.HireState.CANDIDATE_IN_POOL: + continue + if _has_job_for(c.candidate_id, KIND_OFFER): + continue + if target == null or c.skill_level > target.skill_level: + target = c + if target == null: + return {"success": false, "message": "No candidate available to offer."} + # Offer at the (revealed-or-not) expectation -> comfortably in range. + return make_offer(state, target.candidate_id, target.salary_expectation, []) + + +func onboard_all(state) -> Dictionary: + """Advance every onboarding hire by one available checklist step (laptop -> visa -> + mentoring). The bot/menu driver for the onboard stage.""" + var acted := 0 + for r in state.researchers: + if r.onboarded and r.mentoring_done: + continue + if r.hire_state != Researcher.HireState.EMPLOYED: + continue + var step := "" + if not r.laptop_done: + step = "laptop" + elif r.needs_visa and not r.visa_done: + step = "visa" + elif not r.mentoring_done: + step = "mentoring" + if step != "": + var res := onboard_step(state, r.candidate_id, step) + if res.get("success", false): + acted += 1 + if acted == 0: + return {"success": false, "message": "Nothing to onboard (or can't afford it)."} + return {"success": true, "message": "Advanced onboarding for %d hire(s)." % acted} + + +# ============================================================================ +# SERIALIZATION (mirrors Ledger / MonthPlan; JSON numbers come back as float) +# ============================================================================ + +func to_dict() -> Dictionary: + return { + "next_serial": next_serial, + "campaigns": campaigns.duplicate(true), + "jobs": jobs.duplicate(true), + } + + +func from_dict(data: Dictionary) -> void: + next_serial = int(data.get("next_serial", 0)) + campaigns = [] + for camp in data.get("campaigns", []): + if camp is Dictionary: + var c: Dictionary = camp.duplicate(true) + c["months_remaining"] = int(c.get("months_remaining", 0)) + c["per_month_min"] = int(c.get("per_month_min", 0)) + c["per_month_max"] = int(c.get("per_month_max", 0)) + campaigns.append(c) + jobs = [] + for job in data.get("jobs", []): + if job is Dictionary: + var j: Dictionary = job.duplicate(true) + j["job_id"] = String(j.get("job_id", "")) + j["kind"] = String(j.get("kind", "")) + j["candidate_id"] = String(j.get("candidate_id", "")) + j["resolves_on_turn"] = int(j.get("resolves_on_turn", 0)) + var params: Dictionary = j.get("params", {}) + if params.has("cash"): + params["cash"] = roundf(float(params["cash"])) + j["params"] = params + jobs.append(j) diff --git a/godot/scripts/core/hiring_pipeline.gd.uid b/godot/scripts/core/hiring_pipeline.gd.uid new file mode 100644 index 00000000..8145a137 --- /dev/null +++ b/godot/scripts/core/hiring_pipeline.gd.uid @@ -0,0 +1 @@ +uid://3uty2kqvcyqa diff --git a/godot/scripts/core/ledger.gd b/godot/scripts/core/ledger.gd index ac82f5e7..a973ae33 100644 --- a/godot/scripts/core/ledger.gd +++ b/godot/scripts/core/ledger.gd @@ -124,6 +124,58 @@ static func staff_rider(name: String) -> Entry: Balance.inum("ledger.staff_rider.fuse_turns", 8), Balance.num("ledger.staff_rider.interest_rate", 0.02)) +## Hiring Phase B: a PROMISE made to win a candidate on appetite instead of cash (ADR-0003 +## "every mitigation is a loan"). Each promise -> a FUTURE OBLIGATION IN ITS OWN DOMAIN, keyed +## by the appetite it feeds. currency/noun/magnitude live here; Balance overrides per key. +## +## fix/promise-currency (Option B): the promise cost was RE-HOMED off raw reputation. The old +## version minted a reputation-currency principal (~2000, or a money-scaled salary discount) +## while reputation STARTS AT 50 and death fires at reputation <= 0 (game_state.gd) -- so a +## single promise's bill zeroed the whole rep bar and instantly killed the player (a real run +## died on turn 14 this way). Now each promise bills the thing it actually promised: +## first_authorship -> a paper / first-author slot OWED (currency "papers") +## compute_budget -> compute committed to this hire (currency "compute") +## mission_charter -> a standing governance/mission constraint (currency "governance") +## mentorship -> a mentee commitment, also a standing governance obligation +## Magnitudes are small and survivable (ADR-0003: heterogeneous, not an inevitability queue); +## the discount-to-self_worth_floor negotiation currency (ADR-0011) is UNCHANGED -- only the +## COST side moved off reputation. +const PROMISE_SPEC := { + "first_authorship": {"currency": "papers", "noun": "first-author paper slot", "principal": 1.0, "fuse": 10}, + "compute_budget": {"currency": "compute", "noun": "compute", "principal": 20.0, "fuse": 8}, + "mission_charter": {"currency": "governance", "noun": "governance (mission constraint)", "principal": 6.0, "fuse": 12}, + "mentorship": {"currency": "governance", "noun": "governance (mentee commitment)", "principal": 4.0, "fuse": 10}, +} + +static func appetite_promise(name: String, promise: String) -> Entry: + # interest_rate defaults to 0.0 (these are obligations, not compounding mortality levers) -- + # 0.0 is trivially JSON-print->parse-safe (see the ledger _description GOTCHA). Principals are + # integer-valued so they round-trip exactly too. + var spec: Dictionary = PROMISE_SPEC.get(promise, PROMISE_SPEC["mission_charter"]) + return Entry.new("promise:%s:%s" % [promise, name], String(spec["currency"]), + Balance.num("ledger.promise.%s.principal" % promise, float(spec["principal"])), + Balance.inum("ledger.promise.%s.fuse_turns" % promise, int(spec["fuse"])), + Balance.num("ledger.promise.%s.interest_rate" % promise, 0.0)) + +## Legibility (fix/promise-currency): the one-line COST a ticked promise will incur, surfaced in +## the offer flow BEFORE the player commits (main_ui.gd) so the obligation is never opaque. +static func appetite_promise_cost_text(promise: String) -> String: + if not PROMISE_SPEC.has(promise): + return "" + var spec: Dictionary = PROMISE_SPEC[promise] + var principal := int(roundf(Balance.num("ledger.promise.%s.principal" % promise, float(spec["principal"])))) + var fuse := Balance.inum("ledger.promise.%s.fuse_turns" % promise, int(spec["fuse"])) + return "owes %d %s in ~%d turns" % [principal, String(spec["noun"]), fuse] + +## Hiring Phase B: a lowball offer taken RESENTFULLY plants a loyalty debt -- a governance +## liability that bills later (the resentment festering into a governance problem, ADR-0003). +static func resentment_debt(name: String) -> Entry: + # interest_rate 0.02 is on the probe-verified-safe list (see the ledger _description GOTCHA). + return Entry.new("resentment:" + name, "governance", + Balance.num("ledger.resentment_debt.principal", 1500.0), + Balance.inum("ledger.resentment_debt.fuse_turns", 6), + Balance.num("ledger.resentment_debt.interest_rate", 0.02)) + # ---- Turn processing: the mortality guarantee lives here ---- ## Called once per turn (after WS-C scheduled causes). Compounds interest on live @@ -207,6 +259,19 @@ func _bill(e: Entry, state) -> void: var applied: float = _apply_capped_doom(e, e.principal, state) _attribute(e, applied, state) _note(state, "ledger_doom_bill", e.source, {"doom": applied}) + "papers": + # fix/promise-currency: a first-authorship OWED. Honoured out of produced papers when + # it lands; a shortfall lapses (no doom/rep teeth) -- a promise is a survivable future + # obligation in its OWN domain, never a mortality lever (the rep-bomb is gone). + var papers_paid: float = minf(state.papers, e.principal) + state.papers = max(0.0, state.papers - e.principal) + _note(state, "ledger_papers_bill", e.source, {"papers": -papers_paid}) + "compute": + # fix/promise-currency: compute committed to this hire, drawn from the fleet when due. + # Survivable (compute is not a death condition); any shortfall simply lapses. + var compute_paid: float = minf(state.compute, e.principal) + state.compute = max(0.0, state.compute - e.principal) + _note(state, "ledger_compute_bill", e.source, {"compute": -compute_paid}) "action_points": state.action_points = max(0, state.action_points - int(e.principal)) "equity", "board_seat", "agenda": diff --git a/godot/scripts/core/month_controller.gd b/godot/scripts/core/month_controller.gd index 169670d9..5fef80d0 100644 --- a/godot/scripts/core/month_controller.gd +++ b/godot/scripts/core/month_controller.gd @@ -82,6 +82,12 @@ func advance_tick() -> Dictionary: # Release duration-elapsed strategic WIP (seam: caller/L2 applies their effects). last_released_strategic = state.month_plan.take_due_strategic(state.turn) + # Hiring pipeline (Phase B): resolve any interview/offer/connections jobs due this tick. + # Stream-neutral when no jobs are in flight (guarded inside on_tick), so pre-existing + # replays are unaffected. + if state.hiring != null: + state.hiring.on_tick(state) + var fired: Array = state.pending_events.duplicate() state.pending_events.clear() var surfaced := _dispatch(fired) @@ -261,3 +267,32 @@ func _open_plan_month(mi: int) -> void: windows_surfaced_this_month = 0 var ordinal := Clock.month_ordinal_since_start(state.turn, state.start_year, state.start_month, state.start_day) state.month_plan.begin_month(Balance.inum("attention.per_month", 20), ordinal) + # Hiring pipeline (Phase B): advertise campaigns trickle candidates, un-mentored hires face + # their attrition roll. Stream-neutral when no campaign/at-risk hire is live (guarded). + if state.hiring != null: + var hiring_events: Array = state.hiring.on_month_boundary(state) + _surface_hiring_notifications(hiring_events) + + +func _surface_hiring_notifications(events: Array) -> void: + """Surface month-boundary hiring outcomes to the player FEED (EventTiers TIER_FEED -- the + established readable/no-decision channel; not a new channel). Ad campaigns trickling a + candidate in used to be silent; the player now learns the campaign paid off. Batched: one + feed line per month, pluralized, so several arrivals don't spam the feed.""" + var ad_hits := 0 + for e in events: + if e is Dictionary and String(e.get("kind", "")) == "advertise_hit": + ad_hits += 1 + if ad_hits <= 0: + return + var msg := "An applicant responded to your ad campaign." if ad_hits == 1 \ + else "%d applicants responded to your ad campaign." % ad_hits + var feed_event := { + "id": "hiring_ad_response", + "name": "Ad campaign response", + "delivery_tier": EventTiers.TIER_FEED, + "source_id": "hiring", + "message": msg, + "count": ad_hits, + } + feed_log.append({"event": feed_event, "source_id": EventTiers.source_id_of(feed_event)}) diff --git a/godot/scripts/core/month_plan.gd b/godot/scripts/core/month_plan.gd index 4275e2e2..7f706be6 100644 --- a/godot/scripts/core/month_plan.gd +++ b/godot/scripts/core/month_plan.gd @@ -75,6 +75,20 @@ func can_queue(attention_cost: int) -> bool: return available() >= attention_cost +func spend_attention(cost: int) -> bool: + """Spend `cost` Attention from the AVAILABLE (un-reserved) pool at plan speed, without + minting a queued-strategic WIP entry. This is the primitive the hiring pipeline (and + other duration subsystems that track their own jobs) use: the founder currency is still + debited here, but the duration/target bookkeeping lives in the subsystem. Returns false + (no charge) if the cost doesn't fit within available Attention.""" + if cost <= 0: + return true + if available() < cost: + return false + attention_spent += cost + return true + + func queue_strategic(action_id: String, attention_cost: int, duration_ticks: int, current_turn: int) -> bool: """Queue a strategic action at plan speed. Spends Attention now; the EFFECT lands `duration_ticks` resolution ticks later (ADR-0009 §5). duration_ticks <= 0 is coerced diff --git a/godot/scripts/core/quirk_catalogue.gd b/godot/scripts/core/quirk_catalogue.gd new file mode 100644 index 00000000..3e6f49da --- /dev/null +++ b/godot/scripts/core/quirk_catalogue.gd @@ -0,0 +1,85 @@ +extends RefCounted +class_name QuirkCatalogue +## Data-driven researcher QUIRK catalogue (replaces the retired hardcoded trait system and +## the placeholder QUIRK_POOL). Loads res://data/researchers/quirks.json once and exposes a +## small read-only API the sim reads through Researcher.quirk_effect(). +## +## DETERMINISM (ADR-0006): pick_id() indexes a SORTED id list, so the draw is a pure function +## of the seeded rng and is independent of JSON key / Dictionary iteration order. Definitions +## are hidden-but-TRUE: a quirk's effect is live from creation; play only REVEALS it. +## Design + rationale: docs/game-design/RESEARCHER_QUIRKS.md. + +const Definitions = preload("res://scripts/data/definition_loader.gd") +const QUIRKS_PATH := "res://data/researchers/quirks.json" + +static var _loaded: bool = false +static var _defs: Dictionary = {} +static var _ids: Array[String] = [] + +static func _ensure_loaded() -> void: + if _loaded: + return + _loaded = true + var data := Definitions.load_object(QUIRKS_PATH, "QuirkCatalogue") + _defs = data.get("quirks", {}) + if _defs.is_empty(): + push_error("[QuirkCatalogue] No quirks loaded from %s" % QUIRKS_PATH) + _ids = [] + for k in _defs.keys(): + _ids.append(String(k)) + # Sort so pick_id indexing is deterministic regardless of parse/dict order (ADR-0006). + _ids.sort() + +static func ids() -> Array[String]: + _ensure_loaded() + return _ids.duplicate() + +static func size() -> int: + _ensure_loaded() + return _ids.size() + +static func has(id: String) -> bool: + _ensure_loaded() + return _defs.has(id) + +static func get_def(id: String) -> Dictionary: + _ensure_loaded() + return _defs.get(id, {}) + +static func effect(id: String, key: String, default_value): + """Return quirk `id`'s effect-channel `key`, or default_value if the quirk (or key) is + absent. The only channels the sim honours are listed in RESEARCHER_QUIRKS.md.""" + _ensure_loaded() + var eff: Dictionary = _defs.get(id, {}).get("effects", {}) + return eff.get(key, default_value) + +static func reveal_after_turns(id: String, default_value: int = 6) -> int: + """Deterministic tenure fallback: the turn count at/after which employment surfaces the + quirk even absent a bespoke incident. Guarantees every quirk reveals in play.""" + _ensure_loaded() + var rv: Dictionary = _defs.get(id, {}).get("reveal", {}) + return int(rv.get("after_turns", default_value)) + +static func reveal_via(id: String) -> String: + """Narrative trigger tag ('tenure' | 'incident' | 'leak'). Telemetry / flavour only; the + deterministic reveal is driven by reveal_after_turns (+ leak self-surfacing).""" + _ensure_loaded() + var rv: Dictionary = _defs.get(id, {}).get("reveal", {}) + return String(rv.get("via", "tenure")) + +static func display_name(id: String) -> String: + _ensure_loaded() + if id == "": + return "none" + return String(_defs.get(id, {}).get("name", id.capitalize())) + +static func flavour(id: String) -> String: + _ensure_loaded() + return String(_defs.get(id, {}).get("flavour", "")) + +static func pick_id(rng: RandomNumberGenerator) -> String: + """Deterministically draw a quirk id from the seeded rng (sorted-index, ADR-0006).""" + _ensure_loaded() + if _ids.is_empty(): + return "" + return _ids[rng.randi() % _ids.size()] diff --git a/godot/scripts/core/researcher.gd b/godot/scripts/core/researcher.gd index e91d67d7..43680b7d 100644 --- a/godot/scripts/core/researcher.gd +++ b/godot/scripts/core/researcher.gd @@ -1,6 +1,6 @@ extends Resource class_name Researcher -## Individual researcher with specialization, traits, and burnout +## Individual researcher with specialization, hidden quirk, and burnout ## Based on Python src/core/researchers.py # ============================================================================ @@ -23,8 +23,28 @@ var turns_employed: int = 0 var jet_lag_turns: int = 0 # Turns remaining with jet lag var jet_lag_severity: float = 0.0 # 0.0-1.0, productivity penalty during jet lag -# Traits (positive and negative) -var traits: Array[String] = [] +# NOTE: the legacy positive/negative "trait" system (workaholic/leak_prone/...) has been +# RETIRED. Its shallow, hardcoded placeholders are replaced by the data-driven QUIRK +# catalogue (see `quirk` below + res://data/researchers/quirks.json). The good ones were +# reframed as quirks (runs_hot <- workaholic, loose_lips <- leak_prone, lab_parent <- +# team_player, sponge <- fast_learner, true_believer <- safety_conscious). See +# docs/game-design/RESEARCHER_QUIRKS.md. + +# ============================================================================ +# HIRING PIPELINE — IDENTITY + ONBOARDING (Phase B) +# Spec: docs/game-design/BUILD_BRIEF_HIRING_PIPELINE.md "Phase B". These ride ON the +# Phase-A hidden layer below. `candidate_id` is a stable handle the pipeline references +# across save/load (object identity doesn't survive a JSON hop). The onboarding flags gate +# productivity: a pipeline hire starts UN-onboarded and only becomes fully productive once +# the checklist clears (skimping mentoring leaves a lasting debuff + attrition risk). +# ============================================================================ +var candidate_id: String = "" # stable id assigned when the candidate is sourced +var needs_visa: bool = false # foreign/remote hire -> a situational onboarding item +var onboarded: bool = true # DEFAULT true: legacy/direct hires are productive at once +var laptop_done: bool = false # hard checklist item +var visa_done: bool = false # hard checklist item (only when needs_visa) +var mentoring_done: bool = false # soft item: skipping it debuffs + arms attrition +var mentoring_skipped: bool = false # player explicitly skimped mentoring (slack-as-insurance) # ============================================================================ # HIRING PIPELINE — HIDDEN ABILITY LAYER (Phase A) @@ -71,13 +91,11 @@ const HIDDEN_PLACEHOLDER := "??? (interview to reveal)" # id drawn from a pool, deliberately UNCORRELATED with ability. const IDENTITY_POOL_SIZE := 24 -# Rare quirk riders (ADR-0011 section 8): philosophical stances etc. Stay hidden until an -# EXPOSURE event flips quirk_known (ADR-0003 machinery) -- NOT revealed by interviewing. +# Rare quirk riders (ADR-0011 section 8): thematically-grounded lab archetypes carrying a +# small, TRUE, hidden mechanical effect. They stay hidden until an EXPOSURE surfaces them +# (ADR-0003 machinery) -- NOT revealed by interviewing (A2). The catalogue is data-driven +# (res://data/researchers/quirks.json via QuirkCatalogue); see docs/game-design/RESEARCHER_QUIRKS.md. const QUIRK_CHANCE := 0.15 -const QUIRK_POOL := [ - "secret_successionist", "doom_absolutist", "e_acc_sympathizer", - "publish_everything", "secrecy_maximalist", "empire_builder" -] # --- Identity (ability-UNCORRELATED) --- @export var appearance_id: String = "" # sprite/appearance handle; diverse, not a stat tell @@ -123,65 +141,20 @@ const SPECIALIZATIONS = { } # ============================================================================ -# TRAITS +# QUIRK EFFECTS (data-driven; replaces the retired trait system) # ============================================================================ - -const POSITIVE_TRAITS = { - "workaholic": { - "name": "Workaholic", - "productivity_bonus": 0.20, # +20% productivity - "burnout_rate": 2.0 # +2 burnout per turn - }, - "team_player": { - "name": "Team Player", - "team_productivity_bonus": 0.10, # +10% to ALL researchers - "description": "Boosts entire team morale" - }, - "media_savvy": { - "name": "Media Savvy", - "reputation_on_publish": 3, # +3 reputation when publishing - "description": "Great at public communication" - }, - "safety_conscious": { - "name": "Safety Conscious", - "doom_reduction": 0.10, # -10% doom from their work - "description": "Extra careful about AI risks" - }, - "fast_learner": { - "name": "Fast Learner", - "skill_growth_rate": 1.5, # Skill improves 50% faster - "description": "Rapidly improves over time" - }, - "road_warrior": { - "name": "Road Warrior", - "jet_lag_reduction": 0.5, # 50% less jet lag duration - "description": "Recovers quickly from travel" - } -} - -const NEGATIVE_TRAITS = { - "prima_donna": { - "name": "Prima Donna", - "salary_penalty_threshold": 0.9, # Must be paid 90%+ of expectation - "team_productivity_penalty": -0.10, # -10% team productivity if underpaid - "description": "Demands high salary or causes problems" - }, - "leak_prone": { - "name": "Leak Prone", - "leak_chance_per_turn": 0.05, # 5% chance to leak research - "description": "Sometimes shares confidential info" - }, - "burnout_prone": { - "name": "Burnout Prone", - "burnout_accumulation_multiplier": 1.5, # 50% faster burnout - "description": "Burns out more easily" - }, - "pessimist": { - "name": "Pessimist", - "morale_penalty": -5, # Reduces team morale - "description": "Brings down team mood" - } -} +# A quirk's mechanical effect is read through quirk_effect() from the JSON catalogue. The +# effect is TRUE from creation (hidden-but-real); play reveals it. Effect channels are a +# small fixed set (see docs/game-design/RESEARCHER_QUIRKS.md): self_productivity_mult, +# burnout_per_turn_add, doom_mod_add, leak_chance, team_productivity_add, skill_growth_mult, +# loyalty_per_turn_add. + +func quirk_effect(key: String, default_value): + """This researcher's value for a quirk effect channel, or default_value if they carry no + quirk (or the quirk does not touch that channel). Effect is live even while hidden.""" + if quirk == "": + return default_value + return QuirkCatalogue.effect(quirk, key, default_value) # ============================================================================ # INITIALIZATION @@ -243,9 +216,10 @@ func generate_random(rng: RandomNumberGenerator): # Loyalty-risk: hidden flight predisposition (revealed only at deep reveal). loyalty_risk = hidden_rng.randf() # Rare quirk rider: most candidates carry none; when present it stays hidden until - # an exposure event (A2) -- interviewing never surfaces it. + # an exposure event (A2) -- interviewing never surfaces it. Drawn from the data-driven + # catalogue (same two hidden_rng draws as before -> byte-identical stream, ADR-0006). if hidden_rng.randf() < QUIRK_CHANCE: - quirk = QUIRK_POOL[hidden_rng.randi() % QUIRK_POOL.size()] + quirk = QuirkCatalogue.pick_id(hidden_rng) else: quirk = "" quirk_known = false @@ -287,20 +261,25 @@ func get_effective_productivity() -> float: if jet_lag_turns > 0: effective *= (1.0 - jet_lag_severity) - # Trait bonuses - if "workaholic" in traits: - effective *= 1.20 + # Quirk self-effect (retired-trait replacement): a single legible productivity multiplier + # drawn from the catalogue (runs_hot 1.20, quiet_quitter 0.85, ...). TRUE even while the + # quirk is still hidden -- the player sees the output, not yet the cause. + effective *= float(quirk_effect("self_productivity_mult", 1.0)) - # Salary satisfaction (prima donna trait) - if "prima_donna" in traits: - if current_salary < (salary_expectation * 0.9): - effective *= 0.80 # -20% productivity if underpaid + # Hiring onboarding (Phase B): a not-yet-onboarded pipeline hire is barely productive + # until their checklist clears; a hire whose mentoring was skimped carries a lasting + # (smaller) debuff. Both default off (onboarded=true, mentoring_skipped=false), so legacy + # and directly-hired staff are unaffected. + if not onboarded: + effective *= Balance.num("hiring.onboarding.unproductive_multiplier", 0.4) + elif mentoring_skipped: + effective *= Balance.num("hiring.onboarding.skimped_multiplier", 0.85) # Minimum 10% productivity (even totally burned out) return max(effective, 0.1) func get_doom_modifier() -> float: - """Get doom modification from this researcher's specialization and traits""" + """Get doom modification from this researcher's specialization and quirk""" var modifier = 0.0 # Specialization effects @@ -310,9 +289,9 @@ func get_doom_modifier() -> float: "capabilities": modifier += SPECIALIZATIONS["capabilities"]["doom_per_research"] - # Trait effects - if "safety_conscious" in traits: - modifier -= POSITIVE_TRAITS["safety_conscious"]["doom_reduction"] + # Quirk effect (retired-trait replacement): true_believer/doom_absolutist lower doom, + # e_acc_sympathizer/secret_successionist raise it. Live even while the quirk is hidden. + modifier += float(quirk_effect("doom_mod_add", 0.0)) return modifier @@ -321,16 +300,10 @@ func get_doom_modifier() -> float: # ============================================================================ func accumulate_burnout(amount: float): - """Add burnout (typically called each turn)""" - var multiplier = 1.0 - - # Trait modifiers - if "burnout_prone" in traits: - multiplier = NEGATIVE_TRAITS["burnout_prone"]["burnout_accumulation_multiplier"] - if "workaholic" in traits: - amount += POSITIVE_TRAITS["workaholic"]["burnout_rate"] - - burnout += amount * multiplier + """Add burnout (typically called each turn). Quirk burnout drift is applied by the caller + (process_turn) via quirk_effect('burnout_per_turn_add') so a negative-drift quirk like + cat_whisperer can also relieve burnout.""" + burnout += amount burnout = clamp(burnout, 0.0, 100.0) func reduce_burnout(amount: float): @@ -383,10 +356,6 @@ func apply_jet_lag(location_tier: int, travel_class: String = "economy"): # Scale by distance (domestic = 60%, international = 100%) var distance_multiplier = 0.6 if location_tier == 2 else 1.0 - # Road warrior trait reduces duration - if "road_warrior" in traits: - base_turns = int(base_turns * POSITIVE_TRAITS["road_warrior"]["jet_lag_reduction"]) - jet_lag_turns = int(base_turns * distance_multiplier) jet_lag_severity = base_severity * distance_multiplier @@ -408,31 +377,23 @@ func get_jet_lag_status() -> String: return "Jet lagged (%d turns, -%.0f%% productivity)" % [jet_lag_turns, jet_lag_severity * 100] # ============================================================================ -# TRAIT MANAGEMENT +# QUIRK MANAGEMENT (replaces the retired trait system) # ============================================================================ -func add_trait(trait_id: String): - """Add a trait if not already present""" - if trait_id not in traits: - traits.append(trait_id) - -func has_trait(trait_id: String) -> bool: - """Check if researcher has a trait""" - return trait_id in traits - -func get_trait_description() -> String: - """Get human-readable trait list""" - if traits.size() == 0: - return "No special traits" - - var descriptions = [] - for trait_id in traits: - if POSITIVE_TRAITS.has(trait_id): - descriptions.append(POSITIVE_TRAITS[trait_id]["name"]) - elif NEGATIVE_TRAITS.has(trait_id): - descriptions.append(NEGATIVE_TRAITS[trait_id]["name"]) - - return ", ".join(descriptions) +func quirk_display_name() -> String: + """Human-readable quirk name ('none' if the person carries no quirk).""" + return QuirkCatalogue.display_name(quirk) + +func maybe_reveal_quirk_by_tenure() -> bool: + """Deterministic tenure-based reveal: once employed long enough, the quirk surfaces (flips + quirk_known). No-op if no quirk, already known, or not yet at the catalogue threshold. + Returns true iff this call newly exposed the quirk.""" + if quirk == "" or quirk_known: + return false + if turns_employed >= QuirkCatalogue.reveal_after_turns(quirk): + quirk_known = true + return true + return false # ============================================================================ # TURN PROCESSING @@ -442,25 +403,34 @@ func process_turn(rng: RandomNumberGenerator = null): """Called each turn - handle burnout, skill growth, jet lag recovery, etc.""" turns_employed += 1 - # Base burnout accumulation (working is stressful!) - accumulate_burnout(0.5) + # Base burnout accumulation (working is stressful!) plus any quirk burnout drift + # (runs_hot/empire_builder push it up; cat_whisperer relieves it). Kept as ONE call so the + # net per-turn burnout is legible. + accumulate_burnout(0.5 + float(quirk_effect("burnout_per_turn_add", 0.0))) # Jet lag recovery (Issue #469) recover_jet_lag() - # Skill growth (very slow - 1 point per ~20 turns) + # Skill growth (very slow - 1 point per ~20 turns). The sponge quirk multiplies the roll + # chance (skill_growth_mult). # WS-0 determinism: use provided seeded RNG only; no global-RNG fallback (1.0 => no growth) var skill_roll: float = rng.randf() if rng != null else 1.0 - if skill_roll < 0.05: # 5% chance per turn + if skill_roll < 0.05 * float(quirk_effect("skill_growth_mult", 1.0)): # base 5% chance per turn skill_level = min(skill_level + 1, 10) base_productivity = 0.5 + (skill_level * 0.1) - # Loyalty changes based on satisfaction + # Loyalty changes based on satisfaction, plus quirk loyalty drift (true_believer holds, + # quiet_quitter drifts out). var salary_ratio = current_salary / salary_expectation if salary_ratio >= 1.0: loyalty = min(loyalty + 1, 100) elif salary_ratio < 0.8: loyalty = max(loyalty - 2, 0) + loyalty = clampi(loyalty + int(quirk_effect("loyalty_per_turn_add", 0)), 0, 100) + + # Tenure reveal (deterministic fallback): time on the team eventually surfaces the quirk + # even absent a bespoke incident, so a hidden rider never stays invisible forever (ADR-0006). + maybe_reveal_quirk_by_tenure() # ============================================================================ # UTILITY FUNCTIONS @@ -637,7 +607,6 @@ func to_dict() -> Dictionary: "loyalty": loyalty, "burnout": burnout, "turns_employed": turns_employed, - "traits": traits.duplicate(), "jet_lag_turns": jet_lag_turns, "jet_lag_severity": jet_lag_severity, # --- Hiring pipeline hidden-ability layer (Phase A) --- @@ -651,7 +620,15 @@ func to_dict() -> Dictionary: "appetites": DoomSystem._snap_dict(appetites), "quirk": quirk, "quirk_known": quirk_known, - "loyalty_risk": DoomSystem._snap(loyalty_risk) + "loyalty_risk": DoomSystem._snap(loyalty_risk), + # --- Hiring pipeline identity + onboarding (Phase B) --- + "candidate_id": candidate_id, + "needs_visa": needs_visa, + "onboarded": onboarded, + "laptop_done": laptop_done, + "visa_done": visa_done, + "mentoring_done": mentoring_done, + "mentoring_skipped": mentoring_skipped, } func from_dict(data: Dictionary): @@ -667,11 +644,8 @@ func from_dict(data: Dictionary): turns_employed = int(data.get("turns_employed", 0)) jet_lag_turns = int(data.get("jet_lag_turns", 0)) jet_lag_severity = float(data.get("jet_lag_severity", 0.0)) - - if data.has("traits"): - traits.clear() - for trait_name in data["traits"]: - traits.append(trait_name) + # NOTE: legacy "traits" key (retired system) is intentionally ignored on load -- old saves + # carrying it drop it silently; the quirk layer below is the replacement. # --- Hiring pipeline hidden-ability layer (Phase A). Explicit casts: JSON hands # every number back as float and every enum as float. Missing keys fall back to the @@ -689,3 +663,13 @@ func from_dict(data: Dictionary): if appetite_data is Dictionary: for k in appetite_data.keys(): appetites[String(k)] = DoomSystem._snap(float(appetite_data[k])) + + # --- Hiring pipeline identity + onboarding (Phase B). Missing keys fall back to the + # legacy defaults (onboarded=true) so pre-Phase-B saves load productive. --- + candidate_id = String(data.get("candidate_id", "")) + needs_visa = bool(data.get("needs_visa", false)) + onboarded = bool(data.get("onboarded", true)) + laptop_done = bool(data.get("laptop_done", false)) + visa_done = bool(data.get("visa_done", false)) + mentoring_done = bool(data.get("mentoring_done", false)) + mentoring_skipped = bool(data.get("mentoring_skipped", false)) diff --git a/godot/scripts/core/turn_manager.gd b/godot/scripts/core/turn_manager.gd index 2d597b34..56f06139 100644 --- a/godot/scripts/core/turn_manager.gd +++ b/godot/scripts/core/turn_manager.gd @@ -7,91 +7,15 @@ var state: GameState func _init(game_state: GameState): state = game_state -func _generate_random_candidate() -> Researcher: - """Generate a random candidate for the hiring pool""" - # Random specialization (weighted towards safety early game) - var specializations = ["safety", "capabilities", "interpretability", "alignment"] - var weights = [0.35, 0.25, 0.20, 0.20] # Safety most common - - var roll = state.rng.randf() - - # Record RNG outcome for verification - VerificationTracker.record_rng_outcome("candidate_spec", roll, state.turn) - - var cumulative = 0.0 - var spec = "safety" - for i in range(specializations.size()): - cumulative += weights[i] - if roll < cumulative: - spec = specializations[i] - break - - # Create researcher with random name and stats - var researcher = Researcher.new() - researcher.generate_random(state.rng) - researcher.specialization = spec - - # Assign random traits - _assign_candidate_traits(researcher) - - return researcher - -func _assign_candidate_traits(researcher: Researcher): - """Assign random traits to a candidate (40% positive, 25% negative)""" - # 40% chance of one positive trait - var positive_roll = state.rng.randf() - VerificationTracker.record_rng_outcome("trait_positive", positive_roll, state.turn) - - if positive_roll < 0.40: - var positive_traits = ["workaholic", "team_player", "media_savvy", "safety_conscious", "fast_learner"] - var trait_index = state.rng.randi() % positive_traits.size() - VerificationTracker.record_rng_outcome("trait_positive_select", float(trait_index), state.turn) - var trait_id = positive_traits[trait_index] - researcher.add_trait(trait_id) - - # 25% chance of one negative trait - var negative_roll = state.rng.randf() - VerificationTracker.record_rng_outcome("trait_negative", negative_roll, state.turn) - - if negative_roll < 0.25: - var negative_traits = ["prima_donna", "leak_prone", "burnout_prone", "pessimist"] - var trait_index = state.rng.randi() % negative_traits.size() - VerificationTracker.record_rng_outcome("trait_negative_select", float(trait_index), state.turn) - var trait_id = negative_traits[trait_index] - researcher.add_trait(trait_id) - -func _populate_candidate_pool() -> int: - """Add new candidates to the pool (called each turn)""" - var added = 0 - - # Base 30% chance to add a candidate, plus 10% per empty slot - var empty_slots = state.MAX_CANDIDATES - state.candidate_pool.size() - var chance = 0.30 + (empty_slots * 0.10) - - # Higher reputation = better candidates appear more often - if state.reputation > 60: - chance += 0.10 - - # Roll for new candidate - var candidate_roll = state.rng.randf() - VerificationTracker.record_rng_outcome("candidate_spawn", candidate_roll, state.turn) - - if candidate_roll < chance and empty_slots > 0: - var candidate = _generate_random_candidate() - state.add_candidate(candidate) - added += 1 - - # Small chance for a second candidate if pool is very empty - if empty_slots > 3: - var second_roll = state.rng.randf() - VerificationTracker.record_rng_outcome("candidate_second", second_roll, state.turn) - - if second_roll < 0.20: - var second = _generate_random_candidate() - state.add_candidate(second) - added += 1 - - return added +# NOTE: the free per-turn candidate-pool refill was REMOVED (hiring-pipeline redesign). It +# was a placeholder from before sourcing existed: it kept the 6-slot pool full for free every +# turn, so paid advertised/connections candidates were silently discarded (pool already at +# cap), undermining the whole source->interview->offer pipeline. Candidates now come ONLY from +# the turn-0 founding-team seed (GameState._populate_initial_candidates) and from sourcing +# (HiringPipeline advertise / use_connections). The legacy instant-hire from the existing pool +# is unaffected. This also drops the per-turn candidate_spawn/trait RNG draws from the turn +# stream -- deterministic replays re-simulate against the same new stream, so they stay +# self-consistent (WS-0 / ADR-0006). func start_turn() -> Dictionary: """ @@ -108,12 +32,11 @@ func start_turn() -> Dictionary: var ledger_result: Dictionary = _step_ledger_tick_and_bill() var total_staff: int = state.get_total_staff() var max_ap: int = _step_grant_action_points(total_staff) - var new_candidates: int = _populate_candidate_pool() _step_process_researcher_lifecycles() var staff_salaries: float = _step_pay_salaries(total_staff) var prod: Dictionary = _step_researcher_productivity() var stationery: Dictionary = _step_consume_stationery() - var messages: Array = _build_start_turn_messages(max_ap, total_staff, staff_salaries, new_candidates, prod, stationery, ledger_result) + var messages: Array = _build_start_turn_messages(max_ap, total_staff, staff_salaries, prod, stationery, ledger_result) var triggered_events: Array[Dictionary] = _step_check_events(messages) return { @@ -220,12 +143,17 @@ func _step_researcher_productivity() -> Dictionary: var leak_occurred = false var leak_doom = 0.0 - # Calculate team_player bonus (10% per team player present) - var team_player_count = 0 + # Team quirk bonus (retired team_player trait -> quirk team_productivity_add channel). + # lab_parent / empire_builder lift the whole team; secrecy_maximalist / glory_hound / + # doom_absolutist drag it. Effect is live even while the quirk is hidden. + var team_quirk_bonus = 1.0 + var team_quirk_count = 0 for researcher in state.researchers: - if researcher.has_trait("team_player"): - team_player_count += 1 - var team_player_bonus = 1.0 + (team_player_count * 0.10) + var contrib := float(researcher.quirk_effect("team_productivity_add", 0.0)) + if contrib != 0.0: + team_quirk_count += 1 + team_quirk_bonus += contrib + team_quirk_bonus = maxf(team_quirk_bonus, 0.1) # never invert or zero out the team # Process researchers in order (first N are managed) var researcher_index = 0 @@ -242,8 +170,8 @@ func _step_researcher_productivity() -> Dictionary: compute_consumed += compute_request productive_count += 1 - # Get effective productivity (accounts for burnout, traits) - var productivity = researcher.get_effective_productivity() * team_player_bonus + # Get effective productivity (accounts for burnout + quirk self-effect) + var productivity = researcher.get_effective_productivity() * team_quirk_bonus # Research generation based on productivity var research_roll = state.rng.randf() @@ -264,9 +192,8 @@ func _step_researcher_productivity() -> Dictionary: "safety": # Safety research reduces doom doom_reduction_from_safety += 0.3 * productivity - # Apply safety conscious trait - if researcher.has_trait("safety_conscious"): - doom_reduction_from_safety += 0.1 * productivity + # (Safety-diligence quirks now reduce doom via get_doom_modifier's + # doom_mod_add channel -- true_believer/doom_absolutist -- not here.) "interpretability": # Standard research, unlocks special actions (handled elsewhere) pass @@ -276,14 +203,18 @@ func _step_researcher_productivity() -> Dictionary: research_from_employees += base_research - # Check for leak_prone trait (1% chance per turn) - if researcher.has_trait("leak_prone"): + # Leak-risk quirks (retired leak_prone trait -> quirk leak_chance channel: + # loose_lips 0.05, open_science_zealot 0.03). Only quirk-carriers draw. A leak is + # a natural INCIDENT that surfaces the quirk -- expose it the turn it fires. + var leak_chance := float(researcher.quirk_effect("leak_chance", 0.0)) + if leak_chance > 0.0: var leak_roll = state.rng.randf() VerificationTracker.record_rng_outcome("leak_check_%d" % researcher_index, leak_roll, state.turn) - if leak_roll < 0.01: + if leak_roll < leak_chance: leak_occurred = true leak_doom += 3.0 # Leak causes doom increase + researcher.expose_quirk() # the incident reveals the culprit's quirk researcher_index += 1 @@ -321,8 +252,8 @@ func _step_researcher_productivity() -> Dictionary: "doom_from_capabilities": doom_from_capabilities, "leak_occurred": leak_occurred, "leak_doom": leak_doom, - "team_player_count": team_player_count, - "team_player_bonus": team_player_bonus, + "team_quirk_count": team_quirk_count, + "team_quirk_bonus": team_quirk_bonus, "unmanaged_employees": unmanaged_employees, "total_unproductive": total_unproductive, } @@ -373,7 +304,7 @@ func _step_consume_stationery() -> Dictionary: "supply_auto_ordered": supply_auto_ordered, } -func _build_start_turn_messages(max_ap: int, total_staff: int, staff_salaries: float, new_candidates: int, prod: Dictionary, stationery: Dictionary, ledger_result: Dictionary) -> Array: +func _build_start_turn_messages(max_ap: int, total_staff: int, staff_salaries: float, prod: Dictionary, stationery: Dictionary, ledger_result: Dictionary) -> Array: """Assemble the turn-start message list. Pure reads — no sim mutation, no RNG.""" var billed_entries: Array = ledger_result.get("billed", []) var exposed_entries: Array = ledger_result.get("exposed", []) @@ -383,8 +314,8 @@ func _build_start_turn_messages(max_ap: int, total_staff: int, staff_salaries: f var doom_from_capabilities: float = prod["doom_from_capabilities"] var leak_occurred: bool = prod["leak_occurred"] var leak_doom: float = prod["leak_doom"] - var team_player_count: int = prod["team_player_count"] - var team_player_bonus: float = prod["team_player_bonus"] + var team_quirk_count: int = prod["team_quirk_count"] + var team_quirk_bonus: float = prod["team_quirk_bonus"] var unmanaged_employees: int = prod["unmanaged_employees"] var total_unproductive: int = prod["total_unproductive"] var stationery_consumption: float = stationery["stationery_consumption"] @@ -453,8 +384,10 @@ func _build_start_turn_messages(max_ap: int, total_staff: int, staff_salaries: f if leak_occurred: messages.append("WARNING: Research leak detected! (+%.1f doom)" % leak_doom) - if team_player_count > 0: - messages.append("Team player bonus: +%d%% productivity" % int((team_player_bonus - 1.0) * 100)) + if team_quirk_count > 0 and team_quirk_bonus > 1.0: + messages.append("Team chemistry: +%d%% productivity" % int((team_quirk_bonus - 1.0) * 100)) + elif team_quirk_count > 0 and team_quirk_bonus < 1.0: + messages.append("Team friction: %d%% productivity" % int((team_quirk_bonus - 1.0) * 100)) if unmanaged_employees > 0: messages.append("WARNING: %d unmanaged researchers (need more managers!)" % unmanaged_employees) @@ -472,10 +405,10 @@ func _build_start_turn_messages(max_ap: int, total_staff: int, staff_salaries: f if supply_auto_ordered: messages.append("Supply automation ordered stationery ($2k, +50 supplies)") - # Candidate pool messages - if new_candidates > 0: - messages.append("%d new candidate(s) available for hire (%d total in pool)" % [new_candidates, state.candidate_pool.size()]) - elif state.candidate_pool.size() == 0: + # Candidate pool status. No free per-turn refill anymore (hiring-pipeline redesign): + # new candidates arrive only via sourcing (advertise / connections), surfaced through the + # pipeline's own feed notifications, not here. + if state.candidate_pool.size() == 0: messages.append("No candidates in hiring pool") return messages diff --git a/godot/scripts/game_manager.gd b/godot/scripts/game_manager.gd index ba93459b..8dad1ebc 100644 --- a/godot/scripts/game_manager.gd +++ b/godot/scripts/game_manager.gd @@ -542,17 +542,12 @@ func end_month(): error_occurred.emit("No actions queued") return - # Implicit reserve (v1): every Attention point not spent on strategic work guards this - # month's response windows. The full plan screen makes this an explicit dial. - if state.month_plan != null: - state.month_plan.set_reserve(state.month_plan.attention_total - state.month_plan.attention_spent) - print("[GameManager] Committing month plan (%d actions)..." % state.queued_actions.size()) turn_phase_changed.emit("turn_end") # Execute the OPEN plan turn (started at the previous boundary / game start). - # L2 (ADR-0011): Attention was spent at queue time; the implicit reserve above already - # banked the unspent remainder. No per-turn AP pool debit here. + # L2 (ADR-0011): Attention was spent at queue time; per-action self-charging (the hiring + # pipeline, etc.) also debits Attention HERE, at execution. No per-turn AP pool debit. var result = turn_manager.execute_turn() if result.has("action_results"): for action_result in result["action_results"]: @@ -561,6 +556,16 @@ func end_month(): if state.game_over: return + # Implicit reserve (v1): every Attention point NOT spent by this month's executed actions + # now guards the response windows during day-tick playback (_run_month_playback, below). + # This MUST run AFTER execute_turn: execution-time self-charging actions (hiring pipeline) + # spend from `available` (= total - spent - reserved); setting the reserve first drives + # available to 0 and starves them. Reserving the post-execution remainder is both the fix + # and the correct semantics -- the reserve protects what's left once commitments have run. + # The full plan screen makes this an explicit dial. + if state.month_plan != null: + state.month_plan.set_reserve(state.month_plan.attention_total - state.month_plan.attention_spent) + month_playback_active = true _run_month_playback() # async — runs day ticks until window-pause or month boundary @@ -577,7 +582,11 @@ func _run_month_playback() -> void: game_state_updated.emit(state.to_dict()) for item in r.get("feed", []): var fev: Dictionary = item.get("event", {}) - action_executed.emit({"success": true, "message": "[color=gray]FEED · %s — %s[/color]" % [ + # P0: carry the event's channel so the feed UI can filter the low-severity + # arxiv/flavour stream out of the default view (EventService tags it "flavour"). + action_executed.emit({"success": true, + "channel": String(fev.get("channel", "normal")), + "message": "[color=gray]FEED · %s — %s[/color]" % [ String(item.get("source_id", "?")), String(fev.get("name", fev.get("id", "update")))]}) match String(r.get("status", "")): "paused_on_window": @@ -890,3 +899,60 @@ func resolve_window(event: Dictionary, response: String) -> Dictionary: if state == null: return {"success": false, "message": "no active game"} return WindowResolver.resolve(state, state.month_plan, event, response, state.rng) + + +# ============================================================================ +# HIRING PIPELINE (Phase B) — thin delegates for the plan-screen hiring panel + tests. +# The mechanics live on state.hiring (HiringPipeline); these just forward through the +# GameManager the UI already speaks to. Targeted (candidate-specific) stages take a +# candidate_id; the no-target menu drivers live in GameActions (advertise/interview_next/...). +# ============================================================================ + +func get_hiring() -> HiringPipeline: + return state.hiring if state else null + + +func hiring_advertise() -> Dictionary: + """SOURCE (advertise channel): launch a money-funded campaign that trickles candidates.""" + if state == null or state.hiring == null: + return {"success": false, "message": "no active game"} + return state.hiring.advertise(state) + + +func hiring_use_connections() -> Dictionary: + """SOURCE (connections channel): spend a favor for one fast pre-vetted lead.""" + if state == null or state.hiring == null: + return {"success": false, "message": "no active game"} + return state.hiring.use_connections(state) + + +func hiring_interview(candidate_id: String) -> Dictionary: + """INTERVIEW a specific pool candidate (triage) -> reveals their card after a delay.""" + if state == null or state.hiring == null: + return {"success": false, "message": "no active game"} + return state.hiring.launch_interview(state, candidate_id) + + +func hiring_offer(candidate_id: String, cash: float, promises: Array = []) -> Dictionary: + """OFFER a specific candidate `cash`, optionally attaching appetite promises (which mint + ledger obligations on acceptance).""" + if state == null or state.hiring == null: + return {"success": false, "message": "no active game"} + return state.hiring.make_offer(state, candidate_id, cash, promises) + + +func hiring_read(candidate_id: String, promises: Array = []) -> Dictionary: + """The recruiter/lieutenant negotiation read for a pool candidate (narrowed visible band).""" + if state == null or state.hiring == null: + return {"success": false, "message": "no active game"} + var cand := state.hiring.find_pool_candidate(state, candidate_id) + if cand == null: + return {"success": false, "message": "no such candidate"} + return state.hiring.negotiation_read(state, cand, promises) + + +func hiring_onboard_step(candidate_id: String, item: String) -> Dictionary: + """ONBOARD: complete one checklist item (laptop / visa / mentoring) for a new hire.""" + if state == null or state.hiring == null: + return {"success": false, "message": "no active game"} + return state.hiring.onboard_step(state, candidate_id, item) diff --git a/godot/scripts/ui/employee_panel.gd b/godot/scripts/ui/employee_panel.gd index 24fcb160..be9969be 100644 --- a/godot/scripts/ui/employee_panel.gd +++ b/godot/scripts/ui/employee_panel.gd @@ -186,10 +186,8 @@ func show_staff_id_card(data: Dictionary) -> void: researcher.jet_lag_turns = data.get("jet_lag_turns", 0) researcher.jet_lag_severity = data.get("jet_lag_severity", 0.0) - # Copy traits - var traits = data.get("traits", []) - for trait_id in traits: - researcher.traits.append(trait_id) + # (Legacy traits retired -> the hidden quirk layer is restored via Researcher.from_dict + # on the real load path; this lightweight card builder just skips it.) # Add blocker behind panel var blocker = ColorRect.new() diff --git a/godot/scripts/ui/game_over_screen.gd b/godot/scripts/ui/game_over_screen.gd index 8e819632..b383d865 100644 --- a/godot/scripts/ui/game_over_screen.gd +++ b/godot/scripts/ui/game_over_screen.gd @@ -120,7 +120,10 @@ func show_game_over(is_victory: bool, final_state: Dictionary): else: title_label.text = "DEFEAT" title_label.add_theme_color_override("font_color", Color(1.0, 0.2, 0.2)) # Red - subtitle_label.text = "The AI Destroyed Humanity" + # P0 (playtest 2026-07-17): the subtitle used to hardcode "The AI Destroyed Humanity" + # even when the run actually died of rep-collapse (doom was only 50). Title the defeat + # by its ACTUAL death cause so the headline never lies about what killed you. + subtitle_label.text = _get_defeat_title(final_state) subtitle_label.add_theme_color_override("font_color", Color(1.0, 0.6, 0.6)) # Build statistics display @@ -218,6 +221,22 @@ func _get_doom_display_color(doom: float) -> String: green/yellow/orange/red copy).""" return "#" + ThemeManager.get_doom_stroke_color(doom).to_html(false) +func _get_defeat_title(final_state: Dictionary) -> String: + """Short honest defeat headline, keyed to the ACTUAL death cause (P0 fix). + Order mirrors GameState.check_win_lose(): doom >= 100, then reputation <= 0, then the + bankruptcy fallback — so the subtitle always names the counter that ended the run.""" + var doom = final_state.get("doom", 0) + var reputation = final_state.get("reputation", 100) + + if doom >= 100.0: + return "The AI Destroyed Humanity" + elif reputation <= 0.0: + return "You Lost All Credibility" + elif final_state.get("money", 0) < 0: + return "The Lab Went Bankrupt" + else: + return "The Experiment Ended" + func _get_defeat_reason(final_state: Dictionary) -> String: """Generate defeat reason based on final state. Order mirrors GameState.check_win_lose(): doom >= 100, then reputation <= 0.""" diff --git a/godot/scripts/ui/main_ui.gd b/godot/scripts/ui/main_ui.gd index 81b80070..25901e50 100644 --- a/godot/scripts/ui/main_ui.gd +++ b/godot/scripts/ui/main_ui.gd @@ -61,6 +61,7 @@ var event_dialog # #622 L10: event dialog presenter (script-instantiated child) var ledger_screen # #622 L10: Liability Ledger UI (leather palette + summary button + screen builder) var employee_panel # #622 L10: employee roster + staff ID card (becomes L2's assignment surface) var screen_mode: ScreenModeController # Lane 1 / Phase A: PLAN<->WATCH two-screen mode controller +var _inflight_hiring_box: VBoxContainer # in-flight hiring jobs + progress, mounted under the queue (VIEW-only) # EE-7 (ADR-0012 loss legibility): per-resource "last turn" delta chips beside the # money/compute/reputation/doom readouts. Snapshot at each turn boundary; a chip shows # the change over the last completed turn, green=helped red=hurt (doom inverted). @@ -73,6 +74,12 @@ var _seen_unlocked_actions: Dictionary = {} # #578: action ids seen unlocked, t var _actions_primed: bool = false # skip fanfare on the very first action population (baseline) var current_turn_phase: String = "NOT_STARTED" +# P0 feed filter (playtest 2026-07-17): the arxiv/technical-research flavour deck floods the +# feed. Each logged line is recorded here with its channel; the "flavour" channel is hidden +# by default so real, actionable events aren't crowded out. The toggle flips this. +var _feed_lines: Array = [] # [{text: String, channel: String}, ...] +var _feed_important_only: bool = true # default view hides the flavour spam + # Active dialog state for keyboard shortcuts var active_dialog: Control = null var active_dialog_buttons: Array = [] @@ -84,6 +91,13 @@ func _ready(): # (L0 #620/#608: the duplicate scene-local node was removed from main.tscn) game_manager = GameManager + # P0 rage-quit friction (playtest 2026-07-17): during a run, a window-close (X / Alt+F4) + # should return to the Main Menu instead of quitting straight to desktop. We take over the + # tree's close handling here and restore the default in _exit_tree so the menu screens + # (which have their own explicit Quit) still close the app normally. The deliberate + # quit-to-desktop paths (pause menu, main menu) are untouched. + get_tree().set_auto_accept_quit(false) + # Connect to GameManager signals game_manager.game_state_updated.connect(_on_game_state_updated) game_manager.turn_phase_changed.connect(_on_turn_phase_changed) @@ -91,6 +105,11 @@ func _ready(): game_manager.error_occurred.connect(_on_error_occurred) game_manager.actions_available.connect(_on_actions_available) + # P0 feed filter: the WATCH screen owns the "Hide arxiv flood" toggle; re-render on flip. + if watch_screen and watch_screen.has_signal("feed_filter_changed"): + watch_screen.feed_filter_changed.connect(_on_feed_filter_changed) + _feed_important_only = watch_screen.feed_filter_button.button_pressed + # #622 L10: event dialog presenter (script-instantiated child, same mount pattern as # the #500 selector). Choices route back through game_manager.resolve_event so the # presenter stays reusable for the L1 rewrite's mid-month response windows. @@ -177,6 +196,16 @@ func _ready(): # Built AFTER all panels exist so it can register them for per-mode visibility. _setup_plan_watch_scaffold() + # In-flight hiring tracker: a lightweight Gantt-ish list mounted just under the + # committed-month queue in the SHARED instrument column (visible in PLAN and WATCH, + # since jobs cook during day-tick playback). Populated by _update_inflight_hiring_display. + _inflight_hiring_box = VBoxContainer.new() + _inflight_hiring_box.name = "InFlightHiring" + _inflight_hiring_box.add_theme_constant_override("separation", 2) + _inflight_hiring_box.visible = false + instruments.add_child(_inflight_hiring_box) + instruments.move_child(_inflight_hiring_box, instruments.queue_panel.get_index() + 1) + # Always-visible DEV BUILD corner badge so a playtester can confirm exactly which # build he's running (version + git stamp). Draws on its own CanvasLayer over the UI. add_child(DevBuildBadge.new()) @@ -381,6 +410,14 @@ func _input(event: InputEvent): get_viewport().set_input_as_handled() return + # Manual PLAN<->WATCH view toggle (V). VIEW-only quick-win: lets the player flip + # screens at will to look things over. Works in any phase; never touches the sim. + if event.keycode == KEY_V: + if screen_mode: + screen_mode.toggle_mode() + get_viewport().set_input_as_handled() + return + # Main game shortcuts (when no dialog is active) # Number keys 1-9 for action shortcuts if event.keycode >= KEY_1 and event.keycode <= KEY_9: @@ -698,15 +735,13 @@ func _on_game_state_updated(state: Dictionary): if plan_screen: plan_screen.update_reserve_gauge(state) - # Update resource displays (Issue #472 - enhanced turn display with calendar) - var turn_display = state.get("turn_display", "") - if turn_display != "": - turn_label.text = turn_display - else: - turn_label.text = "Turn: %d" % state.get("turn", 0) - # Playtest (Pip): the month-year/date badge above has no single number to track - # state by. This is a plain "Turn N" counter next to it (#F6 HUD lane). - turn_count_label.text = "Turn %d" % state.get("turn", 0) + # Turn/time (Pip: "count turns and tell us the date"). ONE tidy element: + # "Turn 14 - Fri 21 Jul 2017" + # turn = the plan/decision period (count it); the calendar date is the human "when". + # The old split (month-year badge + separate "Turn N") is folded into this single + # label and the now-redundant TurnCountLabel is hidden. VIEW-only (ADR-0006). + turn_label.text = _format_turn_datetime(state) + turn_count_label.visible = false money_label.text = "💰 %s" % GameConfig.format_money(state.get("money", 0)) compute_label.text = "🖥️ %.1f" % state.get("compute", 0) research_label.text = "🔬 %.1f" % state.get("research", 0) @@ -716,6 +751,10 @@ func _on_game_state_updated(state: Dictionary): # EE-7: refresh the per-resource "last turn" delta chips at turn boundaries _update_delta_chips(state) + # Surface in-flight hiring durations (interview/offer/networking) + onboarding + # checklists with progress in the instrument column. VIEW-only (reads state). + _update_inflight_hiring_display(state) + # Add employee blob display to AP label (using BBCode for RichTextLabel) var safety = state.get("safety_researchers", 0) var capability = state.get("capability_researchers", 0) @@ -955,7 +994,13 @@ func _on_action_executed(result: Dictionary): print("[MainUI] Action executed: ", result) var message = result.get("message", "Action completed") - log_message("[color=lime]" + message + "[/color]") + # P0: FEED items carry a channel ("flavour" for the arxiv stream) so the feed filter can + # collapse the spam. FEED lines are already BBCode-coloured; don't re-wrap them in lime. + var channel := String(result.get("channel", "normal")) + if channel != "normal": + log_message(message, channel) + else: + log_message("[color=lime]" + message + "[/color]") # EE-7: resource-affecting events/actions state their applied deltas explicitly var deltas: Dictionary = result.get("deltas", {}) @@ -978,14 +1023,39 @@ func _on_error_occurred(error_msg: String): print("[MainUI] Error: ", error_msg) log_message("[color=red]ERROR: " + error_msg + "[/color]") -func log_message(text: String): +func _notification(what: int) -> void: + # P0 rage-quit friction: intercept the window-manager close during a run and route to the + # Main Menu instead of quitting to desktop. Quit-to-desktop stays available from the pause + # menu ("Quit to Desktop") and the main menu itself. + if what == NOTIFICATION_WM_CLOSE_REQUEST: + print("[MainUI] Window close during run -> returning to main menu (rage-quit friction)") + GameConfig.save_config() + get_tree().paused = false + get_tree().set_auto_accept_quit(true) # menu screen should close the app normally + get_tree().change_scene_to_file("res://scenes/welcome.tscn") + +func _exit_tree() -> void: + # Restore default close handling when leaving the run (Main Menu / defeat / quit), so the + # menu screens close the app on X as expected. + if is_inside_tree() and get_tree() != null: + get_tree().set_auto_accept_quit(true) + +func log_message(text: String, channel: String = "normal"): """Add a message to the log with an in-game date stamp (playtest: real-seconds timestamps were meaningless to players — show the calendar date instead, reusing - GameState.get_formatted_date(), the same helper the HUD date badge uses).""" + GameState.get_formatted_date(), the same helper the HUD date badge uses). + + P0 feed filter: every line is recorded with its channel so the "Hide arxiv flood" + toggle can suppress the low-severity `flavour` stream from the default view without + losing it. Only lines that pass the current filter are appended to the visible log.""" var date_stamp := "?" if game_manager != null and game_manager.state != null: date_stamp = game_manager.state.get_formatted_date() - message_log.text += "\n[color=gray][%s][/color] %s" % [date_stamp, text] + var line := "[color=gray][%s][/color] %s" % [date_stamp, text] + _feed_lines.append({"text": line, "channel": channel}) + if not _feed_passes_filter(channel): + return # recorded but hidden under the current filter + message_log.text += "\n" + line # Auto-scroll to bottom await get_tree().process_frame @@ -993,6 +1063,27 @@ func log_message(text: String): if scroll: scroll.scroll_vertical = scroll.get_v_scroll_bar().max_value +func _feed_passes_filter(channel: String) -> bool: + """A line is visible unless the 'important only' filter is on and it's flavour spam.""" + return not (_feed_important_only and channel == "flavour") + +func _render_feed() -> void: + """Rebuild the visible feed from the recorded lines under the current filter (called when + the player flips the 'Hide arxiv flood' toggle).""" + var text := "" + for entry in _feed_lines: + if _feed_passes_filter(String(entry.get("channel", "normal"))): + text += "\n" + String(entry.get("text", "")) + message_log.text = text + await get_tree().process_frame + var scroll = message_log.get_parent() as ScrollContainer + if scroll: + scroll.scroll_vertical = scroll.get_v_scroll_bar().max_value + +func _on_feed_filter_changed(important_only: bool) -> void: + _feed_important_only = important_only + _render_feed() + func _on_actions_available(actions: Array): """Populate action list with icon buttons in a grid layout""" print("[MainUI] Populating ", actions.size(), " actions as icon buttons") @@ -1412,254 +1503,461 @@ func _calculate_queued_costs() -> Dictionary: return total_costs func _show_hiring_submenu(): - """Show popup dialog with candidate pool - shows actual available candidates""" - print("[MainUI] === HIRING SUBMENU STARTING ===") - - # Close any existing dialog first + """Phase-B hiring pipeline panel (source -> interview -> offer -> onboard). PURE VIEW: + every action routes through the existing GameManager.hiring_* delegates and the panel + reads the live Researcher objects in state.candidate_pool / state.researchers, so + reveal-gated card data (get_card_data) shows exactly what interviewing has earned.""" if active_dialog != null and is_instance_valid(active_dialog): - print("[MainUI] Closing existing dialog...") active_dialog.queue_free() active_dialog = null active_dialog_buttons = [] - # Use Panel - position to the right of the left panel buttons - var dialog = Panel.new() - dialog.custom_minimum_size = Vector2(450, 400) - dialog.size = Vector2(450, 400) - # Position to the right of the left panel (icon stack) - dialog.position = Vector2(90, 60) # Just right of the 80px wide left panel - print("[MainUI] Created Panel, size: %s, position: %s" % [dialog.size, dialog.position]) + var st = game_manager.state + if st == null: + return - # Create main container - var margin = MarginContainer.new() + var dialog := Panel.new() + var dsize := Vector2(580, 640) + dialog.custom_minimum_size = dsize + dialog.size = dsize + var vp := get_viewport().get_visible_rect().size + dialog.position = Vector2((vp.x - dsize.x) / 2.0, max(40.0, (vp.y - dsize.y) / 2.0)) + + var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 12) margin.add_theme_constant_override("margin_right", 12) margin.add_theme_constant_override("margin_top", 10) margin.add_theme_constant_override("margin_bottom", 10) + margin.set_anchors_preset(Control.PRESET_FULL_RECT) dialog.add_child(margin) - var main_vbox = VBoxContainer.new() - main_vbox.add_theme_constant_override("separation", 8) - margin.add_child(main_vbox) + var root := VBoxContainer.new() + root.add_theme_constant_override("separation", 8) + margin.add_child(root) - # Header - var header = Label.new() - header.text = "CANDIDATE POOL" + var header := Label.new() + header.text = "HIRING PIPELINE" header.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - header.add_theme_font_size_override("font_size", 14) + header.add_theme_font_size_override("font_size", 15) header.add_theme_color_override("font_color", Color(0.3, 0.8, 0.3)) - main_vbox.add_child(header) - - # Get current state and candidate pool - var current_state = game_manager.get_game_state() - var candidate_pool = current_state.get("candidate_pool", []) - - var buttons = [] # Store buttons for keyboard access - var dialog_key_labels = ["1", "2", "3", "4", "5", "6"] - - if candidate_pool.size() == 0: - # No candidates available - var empty_label = Label.new() - empty_label.text = "No candidates available.\nNew candidates arrive each turn." - empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - empty_label.add_theme_font_size_override("font_size", 12) - empty_label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6)) - main_vbox.add_child(empty_label) + root.add_child(header) + + var att: int = st.get_available_ap() + var sub := Label.new() + sub.text = "Attention available: %d | Money: %s | Reputation: %d" % [att, GameConfig.format_money(st.money), int(st.reputation)] + sub.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + sub.add_theme_font_size_override("font_size", 10) + sub.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7)) + root.add_child(sub) + + # --- SOURCE row (two channels) --- + var source_box := HBoxContainer.new() + source_box.add_theme_constant_override("separation", 8) + root.add_child(source_box) + + var ad_btn := Button.new() + ad_btn.text = "Advertise\n($8k + 3 Att)" + ad_btn.tooltip_text = "Launch an ad campaign: candidates trickle into the pool over the coming months." + ad_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + ad_btn.focus_mode = Control.FOCUS_NONE + ad_btn.pressed.connect(_on_hiring_advertise_pressed) + source_box.add_child(ad_btn) + + var conn_btn := Button.new() + conn_btn.text = "Use Connections\n(6 rep + 2 Att)" + conn_btn.tooltip_text = "Call in a favor for one fast, pre-vetted lead (success scales with your reputation)." + conn_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + conn_btn.focus_mode = Control.FOCUS_NONE + conn_btn.pressed.connect(_on_hiring_connections_pressed) + source_box.add_child(conn_btn) + + # --- Scrollable body: candidate pool + onboarding --- + var scroll := ScrollContainer.new() + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + root.add_child(scroll) + + var body := VBoxContainer.new() + body.add_theme_constant_override("separation", 6) + body.size_flags_horizontal = Control.SIZE_EXPAND_FILL + scroll.add_child(body) + + var pool_hdr := Label.new() + pool_hdr.text = "CANDIDATE POOL (%d/%d)" % [st.candidate_pool.size(), st.MAX_CANDIDATES] + pool_hdr.add_theme_font_size_override("font_size", 12) + pool_hdr.add_theme_color_override("font_color", Color(0.6, 0.85, 0.6)) + body.add_child(pool_hdr) + + if st.candidate_pool.is_empty(): + var empty := Label.new() + empty.text = "No candidates yet. Advertise or use connections to source some." + empty.add_theme_font_size_override("font_size", 10) + empty.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6)) + body.add_child(empty) else: - # Create scrollable list for candidates - var scroll = ScrollContainer.new() - scroll.custom_minimum_size = Vector2(0, 280) - scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - main_vbox.add_child(scroll) - - var candidate_list = VBoxContainer.new() - candidate_list.add_theme_constant_override("separation", 6) - scroll.add_child(candidate_list) - - # Specialization colors - var spec_colors = { - "safety": Color(0.3, 0.8, 0.3), - "capabilities": Color(0.8, 0.3, 0.3), - "interpretability": Color(0.7, 0.3, 0.8), - "alignment": Color(0.3, 0.7, 0.8) - } - - var button_index = 0 - for candidate in candidate_pool: - var candidate_panel = PanelContainer.new() - candidate_panel.custom_minimum_size = Vector2(0, 50) - - var hbox = HBoxContainer.new() - hbox.add_theme_constant_override("separation", 8) - candidate_panel.add_child(hbox) - - # Keyboard shortcut indicator - var key_label = Label.new() - key_label.text = "[%s]" % dialog_key_labels[button_index] if button_index < dialog_key_labels.size() else "" - key_label.add_theme_font_size_override("font_size", 10) - key_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5)) - key_label.custom_minimum_size = Vector2(25, 0) - hbox.add_child(key_label) - - # Specialization color indicator - var spec = candidate.get("specialization", "safety") - var indicator = Label.new() - indicator.text = "●" - indicator.add_theme_color_override("font_color", spec_colors.get(spec, Color.WHITE)) - indicator.add_theme_font_size_override("font_size", 14) - hbox.add_child(indicator) - - # Candidate info VBox - var info_vbox = VBoxContainer.new() - info_vbox.add_theme_constant_override("separation", 2) - info_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL - hbox.add_child(info_vbox) - - # Name and specialization - var name_label = Label.new() - var spec_name = spec.capitalize() - name_label.text = "%s - %s" % [candidate.get("name", "Unknown"), spec_name] - name_label.add_theme_font_size_override("font_size", 11) - info_vbox.add_child(name_label) - - # Stats line: Skill, Salary, Traits - var stats_label = Label.new() - var skill = candidate.get("skill_level", 5) - var salary = candidate.get("current_salary", 60000) - var traits = candidate.get("traits", []) - var trait_text = "" - if traits.size() > 0: - var trait_names = [] - for trait_id in traits: - # Get display name for trait - if Researcher.POSITIVE_TRAITS.has(trait_id): - trait_names.append(Researcher.POSITIVE_TRAITS[trait_id]["name"]) - elif Researcher.NEGATIVE_TRAITS.has(trait_id): - trait_names.append(Researcher.NEGATIVE_TRAITS[trait_id]["name"]) - else: - trait_names.append(trait_id.capitalize()) - trait_text = " [%s]" % ", ".join(trait_names) - - stats_label.text = "Skill %d | %s/yr%s" % [skill, GameConfig.format_money(salary), trait_text] - stats_label.add_theme_font_size_override("font_size", 9) - stats_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7)) - info_vbox.add_child(stats_label) - - # Hire button - var hire_btn = Button.new() - hire_btn.text = "Hire" - hire_btn.custom_minimum_size = Vector2(50, 30) - hire_btn.focus_mode = Control.FOCUS_NONE - - # Get standard hiring cost from action definitions - var action_id = "hire_%s_researcher" % spec - var hire_cost = 60000 # Default - var hiring_options = GameActions.get_hiring_options() - for option in hiring_options: - if option.get("id") == action_id: - hire_cost = option.get("costs", {}).get("money", 60000) - break - - var can_afford = current_state.get("money", 0) >= hire_cost and current_state.get("action_points", 0) >= 1 - - if not can_afford: - hire_btn.disabled = true - hire_btn.modulate = Color(0.5, 0.5, 0.5) + for cand in st.candidate_pool: + body.add_child(_build_candidate_card(cand)) + + var onboarding := [] + for r in st.researchers: + if r.hire_state == Researcher.HireState.EMPLOYED and (not r.onboarded or (not r.mentoring_done and not r.mentoring_skipped)): + onboarding.append(r) + if not onboarding.is_empty(): + var ob_hdr := Label.new() + ob_hdr.text = "ONBOARDING (%d)" % onboarding.size() + ob_hdr.add_theme_font_size_override("font_size", 12) + ob_hdr.add_theme_color_override("font_color", Color(0.85, 0.75, 0.5)) + body.add_child(ob_hdr) + for r in onboarding: + body.add_child(_build_onboarding_card(r)) - hire_btn.tooltip_text = "Hire for %s + 1 AP" % GameConfig.format_money(hire_cost) - - # Store candidate reference for hire action - var candidate_ref = candidate - hire_btn.pressed.connect(func(): _on_candidate_hired(candidate_ref, dialog)) - - hbox.add_child(hire_btn) - buttons.append(hire_btn) - - candidate_list.add_child(candidate_panel) - button_index += 1 - - # Pool status - var status_label = Label.new() - status_label.text = "Pool: %d/6 candidates | New arrivals each turn" % candidate_pool.size() - status_label.add_theme_font_size_override("font_size", 10) - status_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5)) - status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - main_vbox.add_child(status_label) - - # Store dialog state for keyboard handling in MainUI._input() - print("[MainUI] Setting active_dialog and active_dialog_buttons...") + _add_submenu_close_affordance(dialog) active_dialog = dialog - active_dialog_buttons = buttons - print("[MainUI] active_dialog is now: %s" % (active_dialog != null)) - print("[MainUI] Hiring submenu opened with %d candidates" % candidate_pool.size()) - - # Add dialog to TabManager (parent) so it overlays everything without shifting layout - print("[MainUI] Adding dialog to TabManager as overlay...") + active_dialog_buttons = [] tab_manager.add_child(dialog) dialog.visible = true - dialog.z_index = 1000 # Very high z-index to ensure it's on top - dialog.z_as_relative = false # Absolute z-index, not relative to parent - print("[MainUI] Dialog added and made visible: %s" % dialog.visible) + dialog.z_index = 1000 + dialog.z_as_relative = false - # Wait one frame for dialog to be ready - print("[MainUI] Waiting one frame...") - await get_tree().process_frame - print("[MainUI] Frame passed, dialog still visible: %s" % dialog.visible) - print("[MainUI] === HIRING SUBMENU SETUP COMPLETE ===") +func _build_candidate_card(cand) -> PanelContainer: + """One pool candidate: reveal-gated card fields (get_card_data -> hidden fields render as + the ??? placeholder) + Interview / Make Offer actions wired to the hiring_* delegates.""" + var c: Dictionary = cand.get_card_data() + var panel := PanelContainer.new() + var vb := VBoxContainer.new() + vb.add_theme_constant_override("separation", 2) + panel.add_child(vb) + + var title := Label.new() + title.text = "%s - %s [%s]" % [c["name"], c["lane"], c["hire_state"]] + title.add_theme_font_size_override("font_size", 12) + vb.add_child(title) + + var reveal: int = int(c.get("reveal_level", 0)) + var stats := Label.new() + stats.add_theme_font_size_override("font_size", 9) + stats.add_theme_color_override("font_color", Color(0.75, 0.75, 0.75)) + var skill_txt = str(c["skill_level"]) + var comp_txt = ("$%d/yr" % int(c["salary_expectation"])) if (c["salary_expectation"] is float or c["salary_expectation"] is int) else str(c["salary_expectation"]) + stats.text = "Seniority: %s Skill: %s Comp: %s (reveal %d/%d)" % [c["seniority_band"], skill_txt, comp_txt, reveal, Researcher.MAX_REVEAL] + vb.add_child(stats) + + var deep := Label.new() + deep.add_theme_font_size_override("font_size", 9) + deep.add_theme_color_override("font_color", Color(0.6, 0.6, 0.72)) + var appetite_txt = "" + if c["appetites"] is Dictionary: + var parts := [] + for k in Researcher.APPETITE_KEYS: + parts.append("%s %d%%" % [k, int(round(float(c["appetites"][k]) * 100.0))]) + appetite_txt = ", ".join(parts) + else: + appetite_txt = str(c["appetites"]) + var loyalty_txt = ("%d%%" % int(round(float(c["loyalty_risk"]) * 100.0))) if c["loyalty_risk"] is float else str(c["loyalty_risk"]) + deep.text = "Appetites: %s\nLoyalty risk: %s Quirk: %s" % [appetite_txt, loyalty_txt, str(c["quirk"])] + vb.add_child(deep) + + var status_txt := _hiring_job_status(cand.candidate_id) + if status_txt != "": + var jl := Label.new() + jl.text = status_txt + jl.add_theme_font_size_override("font_size", 9) + jl.add_theme_color_override("font_color", Color(0.5, 0.7, 0.9)) + vb.add_child(jl) + + var actions := HBoxContainer.new() + actions.add_theme_constant_override("separation", 6) + vb.add_child(actions) + var cid: String = cand.candidate_id + + var iv := Button.new() + iv.text = "Interview (2 Att)" + iv.focus_mode = Control.FOCUS_NONE + iv.add_theme_font_size_override("font_size", 10) + if cand.reveal_level >= Researcher.MAX_REVEAL: + iv.disabled = true + iv.tooltip_text = "Already fully interviewed." + elif _has_hiring_job(cid, "interview"): + iv.disabled = true + iv.tooltip_text = "Interview already scheduled." + else: + iv.tooltip_text = "Interview this candidate: reveals the next card layer after a few turns." + iv.pressed.connect(_on_hiring_interview_pressed.bind(cid)) + actions.add_child(iv) + + var offer := Button.new() + offer.text = "Make Offer (1 Att)" + offer.focus_mode = Control.FOCUS_NONE + offer.add_theme_font_size_override("font_size", 10) + if cand.hire_state != Researcher.HireState.CANDIDATE_IN_POOL: + offer.disabled = true + offer.tooltip_text = "Not available for an offer right now." + elif _has_hiring_job(cid, "offer"): + offer.disabled = true + offer.tooltip_text = "Offer already out." + offer.pressed.connect(_show_offer_dialog.bind(cid)) + actions.add_child(offer) + + return panel + +func _build_onboarding_card(r) -> PanelContainer: + """One onboarding hire: checklist state, the productivity debuff made legible, and a + button per pending step (laptop / visa / mentoring / skip). Calls hiring_onboard_step.""" + var st = game_manager.state + var status: Dictionary = st.hiring.onboarding_status(r) + var panel := PanelContainer.new() + var vb := VBoxContainer.new() + vb.add_theme_constant_override("separation", 2) + panel.add_child(vb) + + var title := Label.new() + title.text = "%s - %s" % [r.researcher_name, r.get_specialization_name()] + title.add_theme_font_size_override("font_size", 12) + vb.add_child(title) + + var prod := Label.new() + prod.add_theme_font_size_override("font_size", 9) + if not r.onboarded: + prod.text = "NOT PRODUCTIVE until checklist clears (currently x0.4 output)." + prod.add_theme_color_override("font_color", Color(0.9, 0.4, 0.4)) + elif r.mentoring_skipped: + prod.text = "Mentoring skipped: lasting x0.85 output + attrition risk." + prod.add_theme_color_override("font_color", Color(0.9, 0.7, 0.4)) + else: + prod.text = "Productive. Mentoring still recommended." + prod.add_theme_color_override("font_color", Color(0.5, 0.8, 0.5)) + vb.add_child(prod) + + var check := Label.new() + check.add_theme_font_size_override("font_size", 9) + check.add_theme_color_override("font_color", Color(0.75, 0.75, 0.75)) + var laptop_mark = "[x]" if status["laptop_done"] else "[ ]" + var visa_line = "" + if status["needs_visa"]: + var visa_mark = "[x]" if status["visa_done"] else "[ ]" + visa_line = " Visa %s" % visa_mark + var ment_mark = "[x]" if status["mentoring_done"] else ("SKIPPED" if status["mentoring_skipped"] else "[ ]") + check.text = "Laptop %s%s Mentoring %s" % [laptop_mark, visa_line, ment_mark] + vb.add_child(check) + + var actions := HBoxContainer.new() + actions.add_theme_constant_override("separation", 6) + vb.add_child(actions) + var cid: String = r.candidate_id + + if not r.laptop_done: + var b := Button.new() + b.text = "Laptop ($3k,1Att)" + b.focus_mode = Control.FOCUS_NONE + b.add_theme_font_size_override("font_size", 10) + b.pressed.connect(_on_hiring_onboard_pressed.bind(cid, "laptop")) + actions.add_child(b) + if status["needs_visa"] and not r.visa_done: + var b2 := Button.new() + b2.text = "Visa ($5k,2Att)" + b2.focus_mode = Control.FOCUS_NONE + b2.add_theme_font_size_override("font_size", 10) + b2.pressed.connect(_on_hiring_onboard_pressed.bind(cid, "visa")) + actions.add_child(b2) + if not r.mentoring_done and not r.mentoring_skipped: + var b3 := Button.new() + b3.text = "Mentor (2Att)" + b3.focus_mode = Control.FOCUS_NONE + b3.add_theme_font_size_override("font_size", 10) + b3.pressed.connect(_on_hiring_onboard_pressed.bind(cid, "mentoring")) + actions.add_child(b3) + var b4 := Button.new() + b4.text = "Skip mentoring" + b4.focus_mode = Control.FOCUS_NONE + b4.add_theme_font_size_override("font_size", 10) + b4.tooltip_text = "Save the Attention now, but arm a productivity debuff + early-attrition risk." + b4.pressed.connect(_on_hiring_skip_mentoring_pressed.bind(cid)) + actions.add_child(b4) + + return panel + +func _has_hiring_job(candidate_id: String, kind: String) -> bool: + """True if a pipeline job of `kind` is already in flight for this candidate.""" + var h = game_manager.state.hiring + if h == null: + return false + for j in h.jobs: + if String(j.get("candidate_id", "")) == candidate_id and String(j.get("kind", "")) == kind: + return true + return false + +func _hiring_job_status(candidate_id: String) -> String: + """Short 'X in progress (resolves in ~N turns)' line for any in-flight job, else ''.""" + var h = game_manager.state.hiring + if h == null: + return "" + var turn := int(game_manager.state.turn) + for j in h.jobs: + if String(j.get("candidate_id", "")) != candidate_id: + continue + var kind := String(j.get("kind", "")) + var eta := int(j.get("resolves_on_turn", 0)) - turn + return ">> %s in progress (resolves in ~%d turn(s))" % [kind.capitalize(), max(0, eta)] + return "" + +func _hiring_action_result(result: Dictionary, verb: String) -> void: + """Log a hiring delegate's result, refresh the HUD (attention/money changed), and rebuild + the pipeline panel in place so new reveal / job state is visible immediately.""" + var ok := bool(result.get("success", false)) + var msg := String(result.get("message", "")) + var color := "cyan" if ok else "red" + log_message("[color=%s]%s: %s[/color]" % [color, verb, msg]) + _on_game_state_updated(game_manager.get_game_state()) + _show_hiring_submenu() -func _on_candidate_hired(candidate: Dictionary, dialog: Control): - """Handle hiring a specific candidate from the pool. - #622 L10: routed through GameManager.queue_candidate_hire — the engine owns the - pending_hire_queue/candidate-pool writes and select_action owns affordability - (its error_occurred signal logs the reason), so the old UI pre-checks are gone.""" - dialog.queue_free() +func _on_hiring_advertise_pressed() -> void: + _hiring_action_result(game_manager.hiring_advertise(), "Advertise") - # Clear active dialog state - active_dialog = null - active_dialog_buttons = [] +func _on_hiring_connections_pressed() -> void: + _hiring_action_result(game_manager.hiring_use_connections(), "Connections") - # Get the candidate's specialization to determine which hire action to use - var spec = candidate.get("specialization", "safety") - var action_id = "hire_%s_researcher" % spec - var candidate_name = candidate.get("name", "Unknown") +func _on_hiring_interview_pressed(candidate_id: String) -> void: + _hiring_action_result(game_manager.hiring_interview(candidate_id), "Interview") - if not game_manager.queue_candidate_hire(candidate_name, spec): - return # rejected — error_occurred already logged the reason +func _on_hiring_onboard_pressed(candidate_id: String, item: String) -> void: + _hiring_action_result(game_manager.hiring_onboard_step(candidate_id, item), "Onboard") - log_message("[color=cyan]Hiring: %s (%s)[/color]" % [candidate_name, spec.capitalize()]) - - # Mirror the engine-accepted action in the local queue display - queued_actions.append({"id": action_id, "name": "Hire " + candidate_name}) - update_queued_actions_display() +func _on_hiring_skip_mentoring_pressed(candidate_id: String) -> void: + var st = game_manager.state + _hiring_action_result(st.hiring.skip_mentoring(st, candidate_id), "Onboard") -func _on_hiring_option_selected(action_id: String, action_name: String, dialog: Control): - """Handle hiring submenu selection""" - dialog.queue_free() +func _show_offer_dialog(candidate_id: String) -> void: + """Per-candidate OFFER flow: the recruiter negotiation read (band, personified SA), a cash + field, and appetite-promise toggles that re-read the band live. Sends via hiring_offer.""" + var st = game_manager.state + var cand = st.hiring.find_pool_candidate(st, candidate_id) + if cand == null: + return + if active_dialog != null and is_instance_valid(active_dialog): + active_dialog.queue_free() + active_dialog = null + active_dialog_buttons = [] - # Clear active dialog state - active_dialog = null - active_dialog_buttons = [] + var dialog := Panel.new() + var dsize := Vector2(460, 470) + dialog.custom_minimum_size = dsize + dialog.size = dsize + var vp := get_viewport().get_visible_rect().size + dialog.position = Vector2((vp.x - dsize.x) / 2.0, max(40.0, (vp.y - dsize.y) / 2.0)) - # Check if action can be afforded before adding to UI queue (#456) - var action_def = _get_action_by_id(action_id) - var ap_cost = action_def.get("costs", {}).get("action_points", 0) - var available_ap = game_manager.state.get_available_ap() + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 14) + margin.add_theme_constant_override("margin_right", 14) + margin.add_theme_constant_override("margin_top", 12) + margin.add_theme_constant_override("margin_bottom", 12) + margin.set_anchors_preset(Control.PRESET_FULL_RECT) + dialog.add_child(margin) - if available_ap < ap_cost: - log_message("[color=red]Not enough AP: need %d, have %d[/color]" % [ap_cost, available_ap]) - return + var vb := VBoxContainer.new() + vb.add_theme_constant_override("separation", 8) + margin.add_child(vb) + + var hdr := Label.new() + hdr.text = "OFFER: %s" % cand.researcher_name + hdr.add_theme_font_size_override("font_size", 14) + hdr.add_theme_color_override("font_color", Color(0.3, 0.8, 0.3)) + vb.add_child(hdr) + + var read: Dictionary = game_manager.hiring_read(candidate_id, []) + var read_lbl := Label.new() + read_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + read_lbl.text = String(read.get("text", "")) + read_lbl.add_theme_font_size_override("font_size", 10) + read_lbl.add_theme_color_override("font_color", Color(0.8, 0.8, 0.6)) + vb.add_child(read_lbl) + + var band_lbl := Label.new() + band_lbl.text = "Read band: $%d .. $%d" % [int(read.get("low", 0)), int(read.get("high", 0))] + band_lbl.add_theme_font_size_override("font_size", 9) + band_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6)) + vb.add_child(band_lbl) + + var cash_row := HBoxContainer.new() + cash_row.add_theme_constant_override("separation", 8) + vb.add_child(cash_row) + var cash_caption := Label.new() + cash_caption.text = "Cash offer ($/yr):" + cash_caption.add_theme_font_size_override("font_size", 10) + cash_row.add_child(cash_caption) + var cash_spin := SpinBox.new() + cash_spin.min_value = 0 + cash_spin.max_value = maxf(200000.0, float(read.get("high", 0)) * 1.5) + cash_spin.step = 1000 + cash_spin.value = float(read.get("mid", read.get("high", 60000))) + cash_spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL + cash_row.add_child(cash_spin) + + var promise_hdr := Label.new() + promise_hdr.text = "Promises (buy the ask down; each mints a ledger obligation on accept):" + promise_hdr.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + promise_hdr.add_theme_font_size_override("font_size", 9) + promise_hdr.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7)) + vb.add_child(promise_hdr) + + var promise_labels := { + "first_authorship": "First authorship (prestige)", + "mentorship": "Mentorship (mentees)", + "compute_budget": "Compute budget (compute)", + "mission_charter": "Mission charter (mission purity)", + } + var promise_boxes := {} + for pid in promise_labels: + var cb := CheckBox.new() + # Legibility (fix/promise-currency): show the future obligation each promise costs BEFORE + # the player commits, so the ledger cost is never opaque (e.g. "owes 1 first-author paper + # slot in ~10 turns"). Cost text is data-driven from the Ledger promise spec. + var promise_cost: String = Ledger.appetite_promise_cost_text(pid) + cb.text = promise_labels[pid] if promise_cost == "" else "%s -- %s" % [promise_labels[pid], promise_cost] + cb.add_theme_font_size_override("font_size", 10) + cb.focus_mode = Control.FOCUS_NONE + cb.toggled.connect(_on_offer_promise_toggled.bind(candidate_id, promise_boxes, read_lbl, band_lbl)) + vb.add_child(cb) + promise_boxes[pid] = cb + + var btn_row := HBoxContainer.new() + btn_row.add_theme_constant_override("separation", 8) + vb.add_child(btn_row) + var send := Button.new() + send.text = "Send Offer (1 Att)" + send.size_flags_horizontal = Control.SIZE_EXPAND_FILL + send.focus_mode = Control.FOCUS_NONE + send.pressed.connect(_on_hiring_send_offer_pressed.bind(candidate_id, cash_spin, promise_boxes)) + btn_row.add_child(send) + var cancel := Button.new() + cancel.text = "Back" + cancel.focus_mode = Control.FOCUS_NONE + cancel.pressed.connect(_show_hiring_submenu) + btn_row.add_child(cancel) - if not game_manager.state.can_afford(action_def.get("costs", {})): - log_message("[color=red]Cannot afford: %s[/color]" % action_name) - return + _add_submenu_close_affordance(dialog) + active_dialog = dialog + active_dialog_buttons = [] + tab_manager.add_child(dialog) + dialog.visible = true + dialog.z_index = 1000 + dialog.z_as_relative = false - log_message("[color=cyan]Hiring: %s[/color]" % action_name) +func _selected_promises(promise_boxes: Dictionary) -> Array: + var promises := [] + for pid in promise_boxes: + if promise_boxes[pid].button_pressed: + promises.append(pid) + return promises - # Queue the actual hiring action - queued_actions.append({"id": action_id, "name": action_name}) - update_queued_actions_display() +func _on_offer_promise_toggled(_pressed: bool, candidate_id: String, promise_boxes: Dictionary, read_lbl: Label, band_lbl: Label) -> void: + """Re-read the negotiation band as promises toggle, so the player sees the ask move.""" + var read: Dictionary = game_manager.hiring_read(candidate_id, _selected_promises(promise_boxes)) + read_lbl.text = String(read.get("text", "")) + band_lbl.text = "Read band: $%d .. $%d" % [int(read.get("low", 0)), int(read.get("high", 0))] - game_manager.select_action(action_id) +func _on_hiring_send_offer_pressed(candidate_id: String, cash_spin: SpinBox, promise_boxes: Dictionary) -> void: + var promises := _selected_promises(promise_boxes) + _hiring_action_result(game_manager.hiring_offer(candidate_id, cash_spin.value, promises), "Offer") func _show_fundraising_submenu(): """Show popup dialog with fundraising options with keyboard support - icon grid layout""" @@ -2852,6 +3150,122 @@ func _show_conference_attendance_dialog(): dialog.z_index = 1000 dialog.z_as_relative = false +func _format_turn_datetime(state: Dictionary) -> String: + """ONE tidy turn/time string: "Turn 14 - Fri 21 Jul 2017". The turn is the plan + period (counted); the calendar date is the human "when". Pure formatting off the + state payload (turn + calendar dict) -- VIEW-only, no sim/clock mutation. ASCII.""" + var turn_n := int(state.get("turn", 0)) + var cal: Dictionary = state.get("calendar", {}) + if cal.is_empty(): + return "Turn %d" % turn_n + var wd := String(cal.get("weekday", "")) + var wd_abbr := wd.substr(0, 3) if wd.length() >= 3 else wd + var day := int(cal.get("day", 0)) + var mi := int(cal.get("month", 1)) - 1 + var mon: String = Clock.MONTH_ABBR[mi] if mi >= 0 and mi < 12 else "?" + var year := int(cal.get("year", 0)) + return "Turn %d - %s %d %s %d" % [turn_n, wd_abbr, day, mon, year] + + +func _update_inflight_hiring_display(state: Dictionary) -> void: + """Surface in-flight hiring durations + onboarding checklists with progress, in the + shared instrument column. VIEW-only (ADR-0006): reads the state payload only (hiring + jobs, candidate pool, roster); never touches the sim / RNG / turn loop.""" + if _inflight_hiring_box == null: + return + for child in _inflight_hiring_box.get_children(): + child.queue_free() + + var turn_now := int(state.get("turn", 0)) + var hiring: Dictionary = state.get("hiring", {}) + var jobs: Array = hiring.get("jobs", []) + var pool: Array = state.get("candidate_pool", []) + var staff: Array = state.get("researchers", []) + + # candidate_id -> display name (pool candidates + employed onboarding hires) + var name_by_id := {} + for c in pool: + name_by_id[String(c.get("candidate_id", ""))] = String(c.get("name", "?")) + for r in staff: + name_by_id[String(r.get("candidate_id", ""))] = String(r.get("name", "?")) + + # Each row: {title, done, total, unit} + var rows: Array = [] + + for job in jobs: + var kind := String(job.get("kind", "")) + var cid := String(job.get("candidate_id", "")) + var who := String(name_by_id.get(cid, "")) + var resolves := int(job.get("resolves_on_turn", 0)) + var remaining: int = max(0, resolves - turn_now) + var total := 1 + var title := "" + match kind: + "interview": + total = Balance.inum("hiring.interview.duration_ticks", 3) + title = "Interview: %s" % (who if who != "" else "candidate") + "offer": + total = Balance.inum("hiring.offer.duration_ticks", 2) + title = "Offer: %s" % (who if who != "" else "candidate") + "connections": + total = Balance.inum("hiring.connections.duration_ticks", 2) + title = "Networking: sourcing a lead" + _: + total = max(1, remaining) + title = kind + var done: int = clampi(total - remaining, 0, total) + rows.append({"title": title, "done": done, "total": total, "unit": "ticks"}) + + # Onboarding hires (checklist, not tick-timed): laptop [+ visa]. Legacy/direct hires + # default onboarded=true, so only pipeline hires still cooking show here. + for r in staff: + if bool(r.get("onboarded", true)): + continue + var need_visa := bool(r.get("needs_visa", false)) + var steps_total := 2 if need_visa else 1 + var steps_done := 0 + if bool(r.get("laptop_done", false)): + steps_done += 1 + if need_visa and bool(r.get("visa_done", false)): + steps_done += 1 + rows.append({"title": "Onboarding: %s" % String(r.get("name", "?")), + "done": steps_done, "total": steps_total, "unit": "steps"}) + + if rows.is_empty(): + _inflight_hiring_box.visible = false + return + _inflight_hiring_box.visible = true + + var header := Label.new() + header.text = "IN-FLIGHT HIRING" + header.add_theme_font_size_override("font_size", 11) + header.add_theme_color_override("font_color", Color(0.7, 0.85, 1.0)) + _inflight_hiring_box.add_child(header) + + for row in rows: + var line := HBoxContainer.new() + line.add_theme_constant_override("separation", 6) + var lbl := Label.new() + lbl.text = row["title"] + lbl.add_theme_font_size_override("font_size", 10) + lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL + line.add_child(lbl) + var bar := ProgressBar.new() + bar.min_value = 0 + bar.max_value = maxi(1, int(row["total"])) + bar.value = int(row["done"]) + bar.show_percentage = false + bar.custom_minimum_size = Vector2(60, 12) + bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER + line.add_child(bar) + var ticks := Label.new() + ticks.text = "%d/%d %s" % [int(row["done"]), int(row["total"]), row["unit"]] + ticks.add_theme_font_size_override("font_size", 10) + ticks.add_theme_color_override("font_color", Color(0.8, 0.8, 0.6)) + line.add_child(ticks) + _inflight_hiring_box.add_child(line) + + func update_queued_actions_display(): """Update the visual queue display and message log""" # Clear existing queue items (except hint label) diff --git a/godot/scripts/ui/screen_mode.gd b/godot/scripts/ui/screen_mode.gd index 14153f5b..b12ac55e 100644 --- a/godot/scripts/ui/screen_mode.gd +++ b/godot/scripts/ui/screen_mode.gd @@ -25,6 +25,7 @@ var current_mode: int = Mode.PLAN var banner: PanelContainer var watch_bar: PanelContainer var _banner_label: RichTextLabel +var _toggle_button: Button # manual PLAN<->WATCH switch, lives in the banner var _day_label: Label var _reserve_label: Label @@ -56,12 +57,25 @@ func build_banner() -> PanelContainer: margin.add_theme_constant_override("margin_top", 2) margin.add_theme_constant_override("margin_bottom", 2) banner.add_child(margin) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + margin.add_child(row) _banner_label = RichTextLabel.new() _banner_label.bbcode_enabled = true _banner_label.fit_content = true _banner_label.scroll_active = false + _banner_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _banner_label.size_flags_vertical = Control.SIZE_SHRINK_CENTER _banner_label.add_theme_font_override("normal_font", TerminalTheme.mono_font()) - margin.add_child(_banner_label) + row.add_child(_banner_label) + # Manual view toggle (quick-win): flip PLAN<->WATCH at will so the player can look + # things over. Text shows the target view; also bound to V in main_ui. VIEW-only. + _toggle_button = Button.new() + _toggle_button.focus_mode = Control.FOCUS_NONE + _toggle_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER + _toggle_button.tooltip_text = "Switch between PLAN and WATCH views (V) -- look things over at will." + _toggle_button.pressed.connect(toggle_mode) + row.add_child(_toggle_button) return banner @@ -129,6 +143,13 @@ func enter_plan() -> void: func enter_watch() -> void: set_mode(Mode.WATCH) +func toggle_mode() -> void: + """Manual PLAN<->WATCH switch (quick-win). VIEW-only: flips which screen subtree is + visible so the player can look things over; never touches the turn loop / RNG. The + game's own phase transitions (COMMIT / month review) still drive the mode as before; + this just lets the player peek in between.""" + set_mode(Mode.WATCH if current_mode == Mode.PLAN else Mode.PLAN) + func set_mode(m: int) -> void: current_mode = m @@ -164,6 +185,9 @@ func update_from_state(state: Dictionary) -> void: func _refresh_banner() -> void: + if _toggle_button != null: + # Button shows the view it switches TO. + _toggle_button.text = "WATCH >" if current_mode == Mode.PLAN else "< PLAN" if _banner_label == null: return if current_mode == Mode.PLAN: diff --git a/godot/scripts/ui/staff_perks_compact.gd b/godot/scripts/ui/staff_perks_compact.gd index 3a2a586e..77a3749b 100644 --- a/godot/scripts/ui/staff_perks_compact.gd +++ b/godot/scripts/ui/staff_perks_compact.gd @@ -72,10 +72,12 @@ func set_researcher(researcher: Researcher): else: prod_label.add_theme_color_override("font_color", Color(0.8, 0.3, 0.3)) - # Update tier slots based on skill level (simplified unlock check) - _update_tier_slot(tier1_slot, 1, researcher.skill_level >= 3, researcher.traits) - _update_tier_slot(tier2_slot, 2, researcher.skill_level >= 6, researcher.traits) - _update_tier_slot(tier3_slot, 3, researcher.skill_level >= 8, researcher.traits) + # Update tier slots based on skill level (simplified unlock check). Legacy traits retired + # -> pass an empty equipped list; the slots reflect unlock state only for now. + var no_perks: Array = [] + _update_tier_slot(tier1_slot, 1, researcher.skill_level >= 3, no_perks) + _update_tier_slot(tier2_slot, 2, researcher.skill_level >= 6, no_perks) + _update_tier_slot(tier3_slot, 3, researcher.skill_level >= 8, no_perks) func _show_empty(): name_label.text = "Empty" diff --git a/godot/scripts/ui/staff_perks_panel.gd b/godot/scripts/ui/staff_perks_panel.gd index fea2f664..dc564e67 100644 --- a/godot/scripts/ui/staff_perks_panel.gd +++ b/godot/scripts/ui/staff_perks_panel.gd @@ -366,10 +366,11 @@ func _check_perk_requirements(perk: Dictionary, researcher: Researcher) -> bool: return true -func _is_perk_equipped(perk: Dictionary, researcher: Researcher) -> bool: - """Check if this perk is currently equipped by the researcher""" - # For now, use traits array as proxy for equipped perks - return researcher.traits.has(perk.get("id", "")) +func _is_perk_equipped(_perk: Dictionary, _researcher: Researcher) -> bool: + """Check if this perk is currently equipped by the researcher. The legacy trait system + that backed this placeholder panel is RETIRED (replaced by hidden quirks), so nothing is + 'equipped' here yet -- a future pass can wire this to the quirk layer.""" + return false func _get_tier_color(tier: int) -> Color: match tier: @@ -378,33 +379,13 @@ func _get_tier_color(tier: int) -> Color: 3: return Color(0.8, 0.4, 0.9) # Purple _: return Color.WHITE -func _update_equipped_slots(researcher: Researcher): - """Update the equipped bar at bottom""" - # For now, show traits as "equipped perks" - var traits = researcher.traits - - # Try to find matching perks for display +func _update_equipped_slots(_researcher: Researcher): + """Update the equipped bar at bottom. Legacy traits (the old 'equipped perks' proxy) are + RETIRED; show empty slots until this placeholder panel is rebuilt on the quirk layer.""" equipped_slot1_name.text = "---" equipped_slot2_name.text = "---" equipped_slot3_name.text = "---" - for trait_id in traits: - # Check tier 1 - for perk in TIER_1_PERKS: - if perk["id"] == trait_id: - equipped_slot1_name.text = perk["name"].to_upper() - equipped_slot1_name.add_theme_color_override("font_color", _get_tier_color(1)) - # Check tier 2 - for perk in TIER_2_PERKS: - if perk["id"] == trait_id: - equipped_slot2_name.text = perk["name"].to_upper() - equipped_slot2_name.add_theme_color_override("font_color", _get_tier_color(2)) - # Check tier 3 - for perk in TIER_3_PERKS: - if perk["id"] == trait_id: - equipped_slot3_name.text = perk["name"].to_upper() - equipped_slot3_name.add_theme_color_override("font_color", _get_tier_color(3)) - # ============================================================================ # INTERACTION HANDLERS # ============================================================================ diff --git a/godot/scripts/ui/watch_screen.gd b/godot/scripts/ui/watch_screen.gd index 250a1ac5..e89800c4 100644 --- a/godot/scripts/ui/watch_screen.gd +++ b/godot/scripts/ui/watch_screen.gd @@ -11,9 +11,25 @@ extends VBoxContainer @onready var message_scroll: ScrollContainer = $MessageScroll @onready var message_log: RichTextLabel = $MessageScroll/MessageLog +## P0 feed filter (playtest 2026-07-17): toggled ON = hide the low-severity arxiv/flavour +## stream that floods the feed; OFF = show everything. main_ui listens and re-renders. +signal feed_filter_changed(important_only: bool) +var feed_filter_button: CheckButton + func _ready() -> void: # The feed reads in the terminal register: monospace, dim phosphor text. TerminalTheme.style_feed(message_log) message_log_label.add_theme_color_override("font_color", TerminalTheme.GREEN_DIM) message_log_label.text = "Feed — the month as it happens:" + + # P0: a cheap "All / Important" filter for the feed. Default ON so the arxiv-flavour + # spam is collapsed and real events stay legible; the player can flip it to see the lot. + feed_filter_button = CheckButton.new() + feed_filter_button.text = "Hide arxiv flood" + feed_filter_button.button_pressed = true + feed_filter_button.tooltip_text = "Collapse the low-severity arxiv/research-flavour feed stream" + feed_filter_button.add_theme_color_override("font_color", TerminalTheme.GREEN_DIM) + feed_filter_button.toggled.connect(func(on: bool): feed_filter_changed.emit(on)) + add_child(feed_filter_button) + move_child(feed_filter_button, message_log_label.get_index() + 1) diff --git a/godot/tests/test_researcher_system.gd b/godot/tests/test_researcher_system.gd index ca992046..53e1dbd5 100644 --- a/godot/tests/test_researcher_system.gd +++ b/godot/tests/test_researcher_system.gd @@ -58,48 +58,60 @@ func test_burnout_clamping(): researcher.accumulate_burnout(20.0) assert_eq(researcher.burnout, 100.0, "Burnout should cap at 100") -func test_workaholic_trait_productivity(): +# --- Quirk mechanical effects (replaces the retired trait tests) -------------- + +func test_runs_hot_quirk_boosts_productivity(): + # runs_hot is the reframed workaholic: self_productivity_mult 1.20 (see quirks.json). var researcher = Researcher.new("safety") researcher.base_productivity = 1.0 researcher.burnout = 0.0 - researcher.add_trait("workaholic") + researcher.quirk = "runs_hot" var productivity = researcher.get_effective_productivity() - assert_almost_eq(productivity, 1.2, 0.05, "Workaholic = +20% productivity") + assert_almost_eq(productivity, 1.2, 0.05, "runs_hot = +20% productivity") -func test_workaholic_burnout_rate(): +func test_runs_hot_quirk_adds_burnout_per_turn(): + # runs_hot burnout_per_turn_add 2.0 on top of the base 0.5/turn. + var rng := RandomNumberGenerator.new() + rng.seed = 7 var researcher = Researcher.new("safety") - researcher.add_trait("workaholic") + researcher.quirk = "runs_hot" researcher.burnout = 0.0 - researcher.accumulate_burnout(1.0) - # Base 1.0 + workaholic 2.0 = 3.0 total - assert_eq(researcher.burnout, 3.0, "Workaholic accumulates extra burnout") + researcher.process_turn(rng) + assert_almost_eq(researcher.burnout, 2.5, 0.001, "base 0.5 + runs_hot 2.0 burnout per turn") -func test_prima_donna_salary_penalty(): +func test_cat_whisperer_quirk_relieves_burnout(): + # cat_whisperer burnout_per_turn_add -0.5 exactly cancels the base 0.5/turn. + var rng := RandomNumberGenerator.new() + rng.seed = 7 var researcher = Researcher.new("safety") - researcher.add_trait("prima_donna") - researcher.base_productivity = 1.0 + researcher.quirk = "cat_whisperer" researcher.burnout = 0.0 - researcher.salary_expectation = 60000.0 - # Paid well - no penalty - researcher.current_salary = 60000.0 - assert_almost_eq(researcher.get_effective_productivity(), 1.0, 0.05) + researcher.process_turn(rng) + assert_almost_eq(researcher.burnout, 0.0, 0.001, "cat_whisperer cancels base burnout") + +func test_quiet_quitter_quirk_drags_productivity(): + # quiet_quitter self_productivity_mult 0.85 (a negative quirk). + var researcher = Researcher.new("safety") + researcher.base_productivity = 1.0 + researcher.burnout = 0.0 + researcher.quirk = "quiet_quitter" - # Underpaid - productivity penalty - researcher.current_salary = 50000.0 # < 90% of expectation var productivity = researcher.get_effective_productivity() - assert_lt(productivity, 1.0, "Prima donna should lose productivity when underpaid") - assert_almost_eq(productivity, 0.8, 0.05, "Should be 20% penalty") + assert_almost_eq(productivity, 0.85, 0.001, "quiet_quitter = -15% productivity") -func test_safety_conscious_trait_doom_modifier(): - var researcher = Researcher.new("safety") - researcher.add_trait("safety_conscious") +func test_true_believer_quirk_reduces_doom_modifier(): + # true_believer doom_mod_add -0.04 stacks on the safety spec's -0.15. + var plain = Researcher.new("safety") + var believer = Researcher.new("safety") + believer.quirk = "true_believer" - var modifier = researcher.get_doom_modifier() - assert_lt(modifier, 0.0, "Safety conscious should reduce doom") - assert_almost_eq(modifier, -0.10, 0.01, "Should be -10%") + assert_lt(believer.get_doom_modifier(), plain.get_doom_modifier(), + "true_believer lowers doom further than a plain safety researcher") + assert_almost_eq(believer.get_doom_modifier(), plain.get_doom_modifier() - 0.04, 0.001, + "true_believer contributes exactly -0.04 doom_mod_add") func test_safety_specialization_doom_modifier(): var safety_researcher = Researcher.new("safety") @@ -242,7 +254,8 @@ func test_researcher_serialization(): var researcher = Researcher.new("safety", "Test Name") researcher.skill_level = 7 researcher.burnout = 25.0 - researcher.add_trait("workaholic") + researcher.quirk = "runs_hot" + researcher.quirk_known = true var data = researcher.to_dict() @@ -253,7 +266,8 @@ func test_researcher_serialization(): assert_eq(researcher2.specialization, "safety") assert_eq(researcher2.skill_level, 7) assert_eq(researcher2.burnout, 25.0) - assert_true(researcher2.has_trait("workaholic")) + assert_eq(researcher2.quirk, "runs_hot", "quirk round-trips") + assert_true(researcher2.quirk_known, "quirk_known round-trips") func test_hiring_action_creates_researcher(): var state = GameState.new("test_hiring") diff --git a/godot/tests/unit/test_actions.gd b/godot/tests/unit/test_actions.gd index 9a0203c1..389ec0cd 100644 --- a/godot/tests/unit/test_actions.gd +++ b/godot/tests/unit/test_actions.gd @@ -18,8 +18,10 @@ func test_all_actions_have_required_fields(): func test_action_count(): # Test that we have expected number of actions + # 13 pre-existing (incl. BL-1 Financing submenu) + 5 hiring-pipeline stage actions + # (advertise / use_connections / interview_next / hire_best / onboard_next, Phase B). var actions = GameActions.get_all_actions() - assert_eq(actions.size(), 13, "Should have 13 main actions (incl. BL-1 Financing submenu)") + assert_eq(actions.size(), 18, "Should have 18 main actions (13 base + 5 hiring-pipeline stages)") func test_hiring_options_exist(): # Test that hiring submenu has 7 options (safety, capability, compute, manager, ethicist, @@ -58,7 +60,13 @@ func test_hire_safety_researcher_execution(): assert_lt(state.money, 245000.0, "Money should decrease from starting 245000") func test_hire_capability_researcher_execution(): - # Test hiring capability researcher + # Test hiring capability researcher. Seed a matching candidate explicitly so the test is + # seed-independent (same #561 pattern as the interpretability/alignment tests below): the + # turn-0 founding team is now a deterministic four whose lane mix varies by seed, so we no + # longer rely on "test_seed" happening to roll a capabilities starter into the pool. + var c = Researcher.new() + c.specialization = "capabilities" + state.add_candidate(c) var initial_staff = state.capability_researchers var result = GameActions.execute_action("hire_capability_researcher", state) diff --git a/godot/tests/unit/test_game_start_actionable.gd b/godot/tests/unit/test_game_start_actionable.gd new file mode 100644 index 00000000..3d1eac39 --- /dev/null +++ b/godot/tests/unit/test_game_start_actionable.gd @@ -0,0 +1,76 @@ +extends GutTest +## HOTFIX repro/regression (#664): a fresh game must reach an actionable state. +## +## The ship-blocking bug: after pregame setup the player reaches MainUI but the +## first turn never becomes actionable -- the INIT button does nothing and every +## action button is greyed out. This drives the REAL scene (main.tscn boots MainUI, +## whose _ready auto-inits via _on_init_button_pressed) and asserts the game either +## presents an initial event dialog OR emits a non-empty actions_available list and +## enables the action buttons. + +var _actions_emitted: Array = [] +var _actions_signal_count: int = 0 +var _events_emitted: Array = [] + + +func _reset_capture() -> void: + _actions_emitted = [] + _actions_signal_count = 0 + _events_emitted = [] + + +func _on_actions(actions: Array) -> void: + _actions_signal_count += 1 + _actions_emitted = actions + + +func _on_event(event: Dictionary) -> void: + _events_emitted.append(event) + + +func test_fresh_game_reaches_actionable_state() -> void: + _reset_capture() + + # Boot the real scene tree exactly as the game does: main.tscn -> TabManager -> + # MainUI, whose _ready() awaits a frame then calls _on_init_button_pressed(). + var scene: PackedScene = load("res://scenes/main.tscn") + assert_not_null(scene, "main.tscn must load") + var root: Node = scene.instantiate() + add_child_autofree(root) + + # Grab the live GameManager the MainUI created and listen for the actionable signals. + var main_ui: Node = root.find_child("MainUI", true, false) + assert_not_null(main_ui, "MainUI node must exist under the scene root") + + # Let _ready run (it awaits one process_frame before auto-init), then give the + # start_new_game path several frames to emit its terminal signal. + await get_tree().process_frame + var gm = main_ui.game_manager + assert_not_null(gm, "MainUI must have created a GameManager") + gm.actions_available.connect(_on_actions) + gm.event_triggered.connect(_on_event) + + # start_new_game may already have fired before we connected (auto-init runs on the + # first frame). Fall back to the game state itself as ground truth. + for _i in range(10): + await get_tree().process_frame + + var state = gm.state + assert_not_null(state, "GameManager.state must exist after auto-init") + assert_true(gm.is_initialized, "GameManager must be initialized after auto-init") + + # Ground-truth actionability: either events are pending (a dialog should be up) OR + # the game is in ACTION_SELECTION with a non-empty available-action list. + var available: Array = gm.turn_manager.get_available_actions() if gm.turn_manager else [] + var pending: int = state.pending_events.size() + var in_action_phase: bool = state.current_phase == GameState.TurnPhase.ACTION_SELECTION + + gut.p("phase=%s pending_events=%d available_actions=%d actions_signals=%d events_signals=%d" % [ + GameState.TurnPhase.keys()[state.current_phase], pending, available.size(), + _actions_signal_count, _events_emitted.size()]) + + var actionable: bool = (pending > 0) or (in_action_phase and available.size() > 0) + assert_true(actionable, + "Fresh game must be actionable: pending events OR ACTION_SELECTION with actions. " + + "phase=%s pending=%d actions=%d" % [ + GameState.TurnPhase.keys()[state.current_phase], pending, available.size()]) diff --git a/godot/tests/unit/test_hiring_data_model.gd b/godot/tests/unit/test_hiring_data_model.gd index cff91cbd..0d298613 100644 --- a/godot/tests/unit/test_hiring_data_model.gd +++ b/godot/tests/unit/test_hiring_data_model.gd @@ -149,6 +149,40 @@ func test_starting_candidates_are_uninterviewed(): assert_eq(cand.hire_state, Researcher.HireState.CANDIDATE_IN_POOL, "Pool candidates in pool state") assert_ne(cand.appearance_id, "", "Candidate carries an identity") +# --- Turn-0 founding team: exactly four starters, each with a GUARANTEED hidden rider -------- + +func _starter_has_strong_appetite(cand: Researcher) -> bool: + for k in Researcher.APPETITE_KEYS: + if float(cand.appetites.get(k, 0.0)) >= GameState.STARTER_STRONG_APPETITE: + return true + return false + +func test_starter_pool_is_exactly_four(): + # Pip ruling: the founding team is EXACTLY four "starter pokemon". + var state := GameState.new("starter_count_seed") + assert_eq(state.candidate_pool.size(), 4, "the turn-0 founding team is exactly four starters") + +func test_each_starter_carries_a_hidden_rider(): + var state := GameState.new("starter_riders_seed") + assert_eq(state.candidate_pool.size(), 4, "four starters") + for cand in state.candidate_pool: + # Rider present: a rare quirk OR a strong appetite. + assert_true(cand.has_quirk() or _starter_has_strong_appetite(cand), + "each starter is GUARANTEED a rider (a quirk or a strong appetite)") + # ...but it starts HIDDEN. + assert_false(cand.quirk_known, "the quirk rider starts hidden (quirk_known false)") + assert_eq(cand.reveal_level, Researcher.REVEAL_UNINTERVIEWED, "starter starts at reveal 0") + +func test_starter_riders_are_deterministic_from_seed(): + # Same seed -> byte-identical founding team (replay-safe, ADR-0006). + var a := GameState.new("starter_determinism_seed") + var b := GameState.new("starter_determinism_seed") + assert_eq(a.candidate_pool.size(), b.candidate_pool.size(), "same seed -> same starter count") + for i in range(a.candidate_pool.size()): + assert_eq(a.candidate_pool[i].quirk, b.candidate_pool[i].quirk, "same seed -> same rider quirk") + assert_eq(a.candidate_pool[i].appetites, b.candidate_pool[i].appetites, "same seed -> same appetites") + assert_eq(a.candidate_pool[i].researcher_name, b.candidate_pool[i].researcher_name, "same seed -> same names") + # --- Save / load round-trip of the new fields ------------------------------- func test_researcher_new_fields_round_trip(): diff --git a/godot/tests/unit/test_hiring_pipeline.gd b/godot/tests/unit/test_hiring_pipeline.gd new file mode 100644 index 00000000..f9ed2b93 --- /dev/null +++ b/godot/tests/unit/test_hiring_pipeline.gd @@ -0,0 +1,445 @@ +extends GutTest +## Phase B hiring pipeline: source -> interview -> offer -> onboard (the fishing-line). +## Spec: docs/game-design/BUILD_BRIEF_HIRING_PIPELINE.md "Phase B". Every stage is an +## Attention-gated, duration-bearing action (ADR-0009); the whole thing is deterministic +## (WS-0) and round-trips through save/load (L7). + +const AdvertiseFloor := 8000.0 + + +func _new_state(seed_str: String = "phaseB") -> GameState: + var s := GameState.new(seed_str) + # GameState.new -> reset() opens the first month with the full Attention grant, so + # month_plan.available() is the per-month budget (20). Plenty for the pipeline stages. + s.turn = 1 + return s + + +func _make_candidate(state: GameState, spec: String, skill: int, expectation: float, appetites: Dictionary = {}) -> Researcher: + """A hand-built candidate placed in the pool (id stamped by add_candidate).""" + var c := Researcher.new(spec) + c.skill_level = skill + c.base_productivity = 0.5 + skill * 0.1 + c.salary_expectation = expectation + c.current_salary = expectation + for k in Researcher.APPETITE_KEYS: + c.appetites[k] = float(appetites.get(k, 0.0)) + state.candidate_pool.clear() + state.add_candidate(c) + return c + + +func _advance_and_tick(state: GameState, ticks: int) -> void: + """Simulate `ticks` resolution ticks passing, resolving due pipeline jobs each tick + (mirrors what MonthController.advance_tick does for the live game).""" + for i in range(ticks): + state.turn += 1 + state.hiring.on_tick(state) + + +# --- SOURCE ------------------------------------------------------------------ + +func test_advertise_launches_a_campaign_and_spends_money_and_attention(): + var s := _new_state() + var money0: float = s.money + var att0: int = s.month_plan.available() + var r := s.hiring.advertise(s) + assert_true(r.get("success", false), "advertise succeeds with funds + attention") + assert_eq(s.hiring.campaigns.size(), 1, "a campaign is created") + assert_lt(s.money, money0, "money was spent on the ad") + assert_lt(s.month_plan.available(), att0, "attention was spent on the ad") + + +func test_advertise_trickles_candidates_over_months(): + # The trickle MECHANISM: inject a guaranteed campaign (per_month_min=1) so the assertion + # is seed-independent, then run its boundaries. Each boundary spawns >=1 candidate (until + # the pool caps) and the campaign expires after months_remaining. + var s := _new_state("advertise_trickle") + s.candidate_pool.clear() + s.hiring.campaigns.append({"months_remaining": 2, "per_month_min": 1, "per_month_max": 2}) + var total_hits := 0 + for m in range(3): + var ev := s.hiring.on_month_boundary(s) + for e in ev: + if String(e.get("kind", "")) == "advertise_hit": + total_hits += 1 + assert_gt(total_hits, 0, "the campaign trickled candidates into the pool over its run") + assert_gt(s.candidate_pool.size(), 0, "candidates actually landed in the pool") + assert_eq(s.hiring.campaigns.size(), 0, "the campaign expired after months_remaining") + + +func test_ad_campaign_response_surfaces_a_player_feed_notification(): + # The ad trickle used to be SILENT. It now emits a player-facing FEED notification (the + # established readable/no-decision channel) via the MonthController when a campaign sources + # a candidate at a month boundary. Drive the real boundary path (MonthController), not the + # pipeline in isolation, to prove the wiring. + var s := _new_state("ad_feed_seed") + var mc := MonthController.new(s, null) # null tm: exercise the boundary/dispatch path only + s.candidate_pool.clear() + # A guaranteed campaign (per_month_min=1) so the boundary sources >=1 candidate seed-independently. + s.hiring.campaigns.append({"months_remaining": 2, "per_month_min": 1, "per_month_max": 2}) + var feed0: int = mc.feed_log.size() + s.turn = 40 # jump into a later calendar month so advance_tick crosses a boundary + mc.advance_tick() + assert_gt(mc.feed_log.size(), feed0, "the ad campaign response surfaced a feed notification") + var found := false + for item in mc.feed_log: + var ev: Dictionary = item.get("event", {}) + if String(ev.get("id", "")) == "hiring_ad_response": + found = true + assert_eq(EventTiers.tier_of(ev), EventTiers.TIER_FEED, "the notification is a feed-tier item") + assert_true(String(ev.get("message", "")).to_lower().contains("ad campaign"), + "the message tells the player their ad campaign paid off") + assert_true(found, "an ad-response feed notification was emitted when the campaign sourced a candidate") + # And no notification fires on a boundary with no campaign activity (stays silent). + var mc2 := MonthController.new(GameState.new("ad_feed_quiet"), null) + mc2.state.candidate_pool.clear() + mc2.state.hiring.campaigns.clear() + var quiet0: int = mc2.feed_log.size() + mc2.state.turn = 40 + mc2.advance_tick() + for item in mc2.feed_log: + assert_ne(String((item.get("event", {}) as Dictionary).get("id", "")), "hiring_ad_response", + "no ad-response notification without a live campaign") + assert_eq(mc2.feed_log.size(), quiet0, "a quiet boundary stays silent") + + +func test_ad_campaign_response_batches_multiple_arrivals(): + # Several candidates landing the same month collapse into ONE pluralized feed line. + var s := _new_state("ad_feed_batch") + var mc := MonthController.new(s, null) + s.candidate_pool.clear() + # per_month_min == per_month_max == 3 -> exactly three arrivals this boundary. + s.hiring.campaigns.append({"months_remaining": 1, "per_month_min": 3, "per_month_max": 3}) + s.turn = 40 + mc.advance_tick() + var ad_items := 0 + var count_field := 0 + for item in mc.feed_log: + var ev: Dictionary = item.get("event", {}) + if String(ev.get("id", "")) == "hiring_ad_response": + ad_items += 1 + count_field = int(ev.get("count", 0)) + assert_eq(ad_items, 1, "multiple arrivals batch into a single feed line") + assert_eq(count_field, 3, "the batched line reports all three arrivals") + + +func test_advertise_action_configures_a_balance_driven_campaign(): + # The public advertise() wires the campaign from the Balance surface. + var s := _new_state("advertise_cfg") + s.hiring.advertise(s) + assert_eq(s.hiring.campaigns.size(), 1, "advertise created a campaign") + assert_eq(int(s.hiring.campaigns[0]["months_remaining"]), Balance.inum("hiring.advertise.campaign_months", 3), + "campaign length comes from Balance") + + +func test_connections_costs_reputation_and_yields_prevetted_on_success(): + var s := _new_state("connections_seed") + s.reputation = 1000.0 # ample standing: success chance sits at the cap (0.95) + s.candidate_pool.clear() + var rep0: float = s.reputation + # Launch several attempts so at least one lands (0.05^N miss is negligible; deterministic + # for the fixed seed). Each spends its own reputation + attention. + var launched := 0 + for i in range(6): + if s.hiring.use_connections(s).get("success", false): + launched += 1 + assert_gt(launched, 0, "connections launches with standing + attention") + assert_lt(s.reputation, rep0, "each favor cost reputation") + # Resolve the short connections jobs. + _advance_and_tick(s, 3) + assert_gt(s.candidate_pool.size(), 0, "at least one pre-vetted lead surfaced") + # Every connections lead is pre-vetted (skill + comp already known). + for c in s.candidate_pool: + assert_eq(c.reveal_level, Researcher.REVEAL_SKILL, + "connections lead is pre-vetted (reveal = SKILL)") + + +func test_two_sourcing_channels_are_mechanically_distinct(): + # Advertise spends MONEY and leaves an unvetted (reveal 0) trickle; connections spends + # REPUTATION for a pre-vetted (reveal 1) lead. Assert the cost sources differ. + var s := _new_state("channels") + var money0: float = s.money + var rep0: float = s.reputation + s.hiring.advertise(s) + assert_lt(s.money, money0, "advertise spends money") + assert_almost_eq(s.reputation, rep0 + Balance.num("hiring.advertise.reputation_gain", 1.0), 0.001, + "advertise nudges reputation UP (discovery), not down") + var rep1: float = s.reputation + s.hiring.use_connections(s) + assert_lt(s.reputation, rep1, "connections spends reputation (a favor)") + + +# --- INTERVIEW --------------------------------------------------------------- + +func test_interview_raises_reveal_after_its_duration(): + var s := _new_state("interview") + var c := _make_candidate(s, "safety", 6, 60000.0) + assert_eq(c.reveal_level, Researcher.REVEAL_UNINTERVIEWED, "starts uninterviewed") + var r := s.hiring.launch_interview(s, c.candidate_id) + assert_true(r.get("success", false), "interview scheduled") + # Not resolved yet (duration): reveal unchanged mid-flight. + assert_eq(c.reveal_level, Researcher.REVEAL_UNINTERVIEWED, "reveal unchanged before the duration elapses") + _advance_and_tick(s, Balance.inum("hiring.interview.duration_ticks", 3)) + assert_eq(c.reveal_level, Researcher.REVEAL_SKILL, "interview revealed the next layer") + + +func test_interview_requires_attention(): + var s := _new_state("interview_att") + var c := _make_candidate(s, "safety", 6, 60000.0) + # Drain all attention. + s.month_plan.spend_attention(s.month_plan.available()) + var r := s.hiring.launch_interview(s, c.candidate_id) + assert_false(r.get("success", false), "no attention -> interview fails") + + +# --- OFFER + NEGOTIATE ------------------------------------------------------- + +func test_offer_in_range_is_accepted_and_employs(): + var s := _new_state("offer_accept") + var c := _make_candidate(s, "safety", 6, 60000.0, {"money": 0.5}) + var floor: float = s.hiring.self_worth_floor(c, []) + var r := s.hiring.make_offer(s, c.candidate_id, floor + 1000.0, []) + assert_true(r.get("success", false), "offer goes out") + assert_eq(c.hire_state, Researcher.HireState.OFFERED, "candidate is now OFFERED") + _advance_and_tick(s, Balance.inum("hiring.offer.duration_ticks", 2)) + assert_eq(c.hire_state, Researcher.HireState.EMPLOYED, "in-range offer accepted -> employed") + assert_true(s.researchers.has(c), "hire joined the team") + assert_false(s.candidate_pool.has(c), "and left the pool") + assert_false(c.onboarded, "a pipeline hire starts un-onboarded (not yet productive)") + + +func test_offer_below_floor_risks_rejection_or_resentment(): + # A hard lowball never cleanly accepts: it either bounces back to the pool or is taken + # resentfully (a loyalty debt). Assert the outcome is one of those two. + var s := _new_state("offer_lowball") + var c := _make_candidate(s, "safety", 6, 60000.0, {"money": 0.9}) + var floor: float = s.hiring.self_worth_floor(c, []) + s.hiring.make_offer(s, c.candidate_id, floor * 0.4, []) # well below the floor + var ledger0: int = s.ledger.entries.size() + _advance_and_tick(s, Balance.inum("hiring.offer.duration_ticks", 2)) + var events: Array = s.hiring.last_events + var outcome := "" + for e in events: + if String(e.get("kind", "")).begins_with("offer_"): + outcome = String(e.get("kind", "")) + assert_true(outcome == "offer_rejected" or outcome == "offer_resentful_accept", + "lowball resolves as rejection or resentful accept (got '%s')" % outcome) + if outcome == "offer_rejected": + assert_eq(c.hire_state, Researcher.HireState.CANDIDATE_IN_POOL, "declined -> back in the pool") + else: + assert_eq(c.hire_state, Researcher.HireState.EMPLOYED, "resentful accept -> employed") + assert_gt(s.ledger.entries.size(), ledger0, "resentment minted a loyalty-debt ledger entry") + + +func test_first_authorship_promise_buys_the_floor_down_and_mints_a_ledger_entry(): + # A prestige-hungry candidate takes less cash for a first-authorship promise; the promise + # mints a ledger obligation (ADR-0003). + var s := _new_state("promise") + var c := _make_candidate(s, "safety", 7, 80000.0, {"prestige": 1.0, "money": 0.0}) + var bare_floor: float = s.hiring.self_worth_floor(c, []) + var promised_floor: float = s.hiring.self_worth_floor(c, ["first_authorship"]) + assert_lt(promised_floor, bare_floor, "the promise lowers the acceptance floor") + # Offer BELOW the bare floor but at/above the promised floor -> only the promise makes it land. + var cash: float = promised_floor + 500.0 + assert_lt(cash, bare_floor, "the cash alone would not have sufficed") + var ledger0: int = s.ledger.entries.size() + s.hiring.make_offer(s, c.candidate_id, cash, ["first_authorship"]) + _advance_and_tick(s, Balance.inum("hiring.offer.duration_ticks", 2)) + assert_eq(c.hire_state, Researcher.HireState.EMPLOYED, "promise carried the offer to acceptance") + assert_gt(s.ledger.entries.size(), ledger0, "the promise minted a ledger entry") + var found_promise := false + for e in s.ledger.entries: + if String(e.source).begins_with("promise:first_authorship"): + found_promise = true + assert_true(found_promise, "the minted entry is the first-authorship promise") + + +func test_negotiation_read_narrows_with_a_recruiter(): + var s := _new_state("read") + var c := _make_candidate(s, "safety", 6, 60000.0) + var no_read := s.hiring.negotiation_read(s, c, []) + assert_false(no_read.get("has_recruiter", true), "solo founder gets only a wide guess") + # Add a senior, onboarded employee to act as the recruiter. + var recruiter := Researcher.new("safety") + recruiter.skill_level = 8 + recruiter.onboarded = true + s.add_researcher(recruiter) + var read := s.hiring.negotiation_read(s, c, []) + assert_true(read.get("has_recruiter", false), "a recruiter sharpens the read") + var wide: float = float(no_read["high"]) - float(no_read["low"]) + var tight: float = float(read["high"]) - float(read["low"]) + assert_lt(tight, wide, "the recruiter read is a narrower band") + + +# --- ONBOARD ----------------------------------------------------------------- + +func test_onboarding_checklist_gates_productivity(): + var s := _new_state("onboard") + var c := _make_candidate(s, "safety", 6, 60000.0, {"money": 0.3}) + # Employ via an in-range offer. + var floor: float = s.hiring.self_worth_floor(c, []) + s.hiring.make_offer(s, c.candidate_id, floor + 2000.0, []) + _advance_and_tick(s, Balance.inum("hiring.offer.duration_ticks", 2)) + assert_false(c.onboarded, "starts un-onboarded") + var prod_before: float = c.get_effective_productivity() + # Work the hard checklist (laptop + visa if needed). + for item in s.hiring.onboarding_required(c): + var res := s.hiring.onboard_step(s, c.candidate_id, item) + assert_true(res.get("success", false), "onboard step '%s' applied" % item) + assert_true(c.onboarded, "hard checklist cleared -> onboarded") + var prod_after: float = c.get_effective_productivity() + assert_gt(prod_after, prod_before, "onboarding restored productivity") + + +func test_skipping_mentoring_debuffs_and_arms_attrition(): + var s := _new_state("skimp") + var c := _make_candidate(s, "safety", 6, 60000.0) + var floor: float = s.hiring.self_worth_floor(c, []) + s.hiring.make_offer(s, c.candidate_id, floor + 2000.0, []) + _advance_and_tick(s, Balance.inum("hiring.offer.duration_ticks", 2)) + s.hiring.onboard_step(s, c.candidate_id, "laptop") + if c.needs_visa: + s.hiring.onboard_step(s, c.candidate_id, "visa") + assert_true(c.onboarded, "productive after the hard checklist") + var full_prod: float = c.get_effective_productivity() + s.hiring.skip_mentoring(s, c.candidate_id) + assert_true(c.mentoring_skipped, "mentoring marked skipped") + assert_lt(c.get_effective_productivity(), full_prod, "skimping mentoring is a lasting debuff") + + +# --- DETERMINISM + SAVE/LOAD ------------------------------------------------- + +func test_pipeline_is_deterministic(): + var a := _run_scripted(_new_state("determinism_x")) + var b := _run_scripted(_new_state("determinism_x")) + assert_eq(a, b, "same seed + same pipeline script -> identical outcome") + + +func _run_scripted(s: GameState) -> String: + # A fixed script exercising each channel; the resulting pool/team signature is the fingerprint. + s.candidate_pool.clear() + s.hiring.advertise(s) + s.hiring.use_connections(s) + for m in range(3): + s.hiring.on_month_boundary(s) + _advance_and_tick(s, 3) + var names: Array = [] + for c in s.candidate_pool: + names.append(c.researcher_name) + names.sort() + return "%d|%s" % [s.hiring.next_serial, ",".join(names)] + + +## Integration: drive the pipeline through the REAL month loop (action-id -> execute_turn -> +## MonthController tick hooks), synchronously via advance_tick (the same call the async +## playback wraps). Proves the wiring, not just the direct HiringPipeline API. +func test_pipeline_runs_through_the_real_month_loop(): + var GameManagerScript = load("res://scripts/game_manager.gd") + var gm = GameManagerScript.new() + add_child_autofree(gm) + gm.start_new_game("hiring-integration") + var st = gm.state + # A known candidate to interview. + var c := Researcher.new("safety") + c.skill_level = 6 + st.candidate_pool.clear() + st.add_candidate(c) + var cid: String = c.candidate_id + + # Queue the SOURCE + INTERVIEW stage actions as this month's plan; execute the open plan + # turn (what end_month does first) -> the action-ids route to state.hiring. + st.queued_actions.append("advertise") + st.queued_actions.append("interview_next") + gm.turn_manager.execute_turn() + assert_eq(st.hiring.campaigns.size(), 1, "advertise ran through the action path") + assert_gt(st.hiring.jobs.size(), 0, "interview_next scheduled a job through the action path") + assert_eq(c.reveal_level, Researcher.REVEAL_UNINTERVIEWED, "interview hasn't resolved yet (duration)") + + # Play the month out tick-by-tick (advance_tick is synchronous; the async loop just paces + # it). Clamp doom so the short run survives, and auto-skip any response windows. + var guard := 0 + while guard < 30: + guard += 1 + st.doom = 5.0 + if st.doom_system != null: + st.doom_system.current_doom = 5.0 + if gm.month_controller.is_paused(): + gm.month_controller.skip_current_window() + continue + var r: Dictionary = gm.month_controller.advance_tick() + if String(r.get("status", "")) == "month_open": + break + if st.game_over: + break + # The interview job resolved via the MonthController on_tick hook. + assert_gt(c.reveal_level, Researcher.REVEAL_UNINTERVIEWED, + "the interview resolved through the real month loop (reveal rose)") + + +func test_end_month_executes_before_setting_the_implicit_reserve(): + # REGRESSION (#664): end_month() must EXECUTE the plan turn BEFORE it sets the implicit + # Attention reserve. The reserve is `attention_total - attention_spent`; if it is set + # first, month_plan.available() is driven to 0 and every execution-time self-charging + # pipeline action (which spends via month_plan.spend_attention) fails "not enough + # Attention" despite a full budget. The sibling test above calls execute_turn() DIRECTLY, + # so it never runs end_month's reserve block and cannot catch this ordering bug. + var GameManagerScript = load("res://scripts/game_manager.gd") + var gm = GameManagerScript.new() + add_child_autofree(gm) + gm.start_new_game("hiring-reserve-order") + var st = gm.state + var full_attention: int = st.month_plan.attention_total + assert_gt(full_attention, 0, "the month opened with a real Attention budget") + + # Queue a SOURCE action that self-charges Attention at EXECUTION time, then drive the + # real End-Turn path (end_month), not execute_turn directly. + st.queued_actions.append("advertise") + gm.end_month() + + # If the reserve were set before execution, advertise would have been starved and no + # campaign created. Post-fix: it runs, and the reserve banks only what execution left. + assert_eq(st.hiring.campaigns.size(), 1, + "advertise executed through end_month (reserve did NOT starve its Attention)") + var adv_cost: int = Balance.inum("hiring.advertise.cost_attention", 3) + assert_eq(st.month_plan.attention_spent, adv_cost, + "execution charged advertise's Attention exactly once (no double / no starve)") + assert_eq(st.month_plan.attention_reserved, full_attention - adv_cost, + "the implicit reserve banked the post-execution remainder") + + +func test_save_load_round_trips_pipeline_state(): + var s := _new_state("saveload") + # Seed some durable pipeline state: a live campaign, an in-flight interview job, and an + # employed-but-onboarding hire. + s.hiring.advertise(s) + var c := _make_candidate(s, "safety", 6, 60000.0) + s.hiring.launch_interview(s, c.candidate_id) + var floor: float = s.hiring.self_worth_floor(c, []) + # Employ a second candidate mid-onboard. + var h := Researcher.new("alignment") + h.skill_level = 5 + s.add_candidate(h) + s.hiring.make_offer(s, h.candidate_id, s.hiring.self_worth_floor(h, []) + 1000.0, []) + _advance_and_tick(s, Balance.inum("hiring.offer.duration_ticks", 2)) + s.hiring.onboard_step(s, h.candidate_id, "laptop") + + var blob := JSON.stringify(s.to_dict()) + var parsed = JSON.parse_string(blob) + assert_not_null(parsed, "state serializes to JSON") + + var s2 := GameState.new("saveload_reload") + s2.from_dict(parsed) + assert_eq(s2.hiring.next_serial, s.hiring.next_serial, "id serial round-trips") + assert_eq(s2.hiring.campaigns.size(), s.hiring.campaigns.size(), "campaigns round-trip") + assert_eq(s2.hiring.jobs.size(), s.hiring.jobs.size(), "in-flight jobs round-trip") + # The onboarding hire's flags survived. + var h2: Researcher = null + for r in s2.researchers: + if r.candidate_id == h.candidate_id: + h2 = r + assert_not_null(h2, "the onboarding hire round-tripped") + if h2 != null: + assert_eq(h2.laptop_done, h.laptop_done, "laptop_done round-trips") + assert_eq(h2.onboarded, h.onboarded, "onboarded flag round-trips") + assert_eq(h2.needs_visa, h.needs_visa, "needs_visa round-trips") diff --git a/godot/tests/unit/test_hiring_pipeline.gd.uid b/godot/tests/unit/test_hiring_pipeline.gd.uid new file mode 100644 index 00000000..6968f126 --- /dev/null +++ b/godot/tests/unit/test_hiring_pipeline.gd.uid @@ -0,0 +1 @@ +uid://lfukp3edvmq7 diff --git a/godot/tests/unit/test_promise_currency.gd b/godot/tests/unit/test_promise_currency.gd new file mode 100644 index 00000000..85536e30 --- /dev/null +++ b/godot/tests/unit/test_promise_currency.gd @@ -0,0 +1,171 @@ +extends GutTest +## fix/promise-currency (Option B): appetite promises cost a FUTURE OBLIGATION IN THEIR OWN +## DOMAIN, never raw reputation. +## +## THE BUG (confirmed from a real playtest death, turn 14): the old appetite_promise minted a +## REPUTATION-currency principal (~2000) while reputation STARTS AT 50 and death fires at +## reputation <= 0 (game_state.gd check_win_lose). A single promise's bill zeroed the whole +## reputation bar -> instant death. The headline negotiation mechanic was a mis-scaled +## instant-death trap. +## +## THE FIX: re-home each promise's cost onto the thing it actually promises -- +## first_authorship -> a paper / first-author slot OWED (currency "papers") +## compute_budget -> compute committed to the hire (currency "compute") +## mission_charter / mentorship -> a standing governance/mission obligation (currency "governance") +## Magnitudes are small + survivable; NONE bill reputation. The discount-to-self_worth_floor +## negotiation currency (ADR-0011) is unchanged -- only the COST side moved. +## +## DESIGN GUARDRAIL (Pip): a new player must NOT be able to LOSE within the first ~6 turns via +## promises used as intended. Reputation must not be instantly zeroable by a single promise. + + +func _new_state(seed_str: String = "promise_currency") -> GameState: + var s := GameState.new(seed_str) + s.turn = 1 + return s + + +func _make_candidate(state: GameState, spec: String, skill: int, expectation: float, appetites: Dictionary = {}) -> Researcher: + var c := Researcher.new(spec) + c.skill_level = skill + c.base_productivity = 0.5 + skill * 0.1 + c.salary_expectation = expectation + c.current_salary = expectation + for k in Researcher.APPETITE_KEYS: + c.appetites[k] = float(appetites.get(k, 0.0)) + state.candidate_pool.clear() + state.add_candidate(c) + return c + + +func _advance_and_tick(state: GameState, ticks: int) -> void: + for i in range(ticks): + state.turn += 1 + state.hiring.on_tick(state) + + +# --- The rep-bomb is gone ------------------------------------------------------- + +func test_no_promise_mints_a_reputation_currency_entry(): + # Every promise id (incl. mentorship) must bill in its OWN domain, never reputation. + for pid in Ledger.PROMISE_SPEC: + var e: Ledger.Entry = Ledger.appetite_promise("Alice", pid) + assert_ne(e.currency, "reputation", + "promise '%s' must NOT be a reputation-currency entry (the old rep-bomb)" % pid) + assert_eq(e.currency, String(Ledger.PROMISE_SPEC[pid]["currency"]), + "promise '%s' bills in its domain currency" % pid) + assert_true(String(e.source).begins_with("promise:%s:" % pid), + "source keeps the promise:: tag for attribution") + + +func test_promise_bill_does_not_zero_reputation(): + # Billing a promise (even at its full principal) leaves reputation untouched -- the exact + # failure the fix targets. Bill the first-authorship promise directly. + var s := _new_state("rep_untouched") + var rep0: float = s.reputation + var e: Ledger.Entry = Ledger.appetite_promise("Alice", "first_authorship") + e.fuse = 0 # due now + s.ledger.add(e) + s.ledger.tick_and_bill(s) + s.check_win_lose() + assert_almost_eq(s.reputation, rep0, 0.001, "a promise bill leaves reputation unchanged") + assert_false(s.game_over, "no death from a promise bill") + + +func test_promise_bills_in_its_domain_resource(): + # first_authorship draws a paper; compute_budget draws compute; mission_charter draws + # governance -- all survivable, none touching reputation. + var s := _new_state("domain_bill") + s.papers = 3.0 + var compute0: float = s.compute + var gov0: float = s.governance + for pid in ["first_authorship", "compute_budget", "mission_charter", "mentorship"]: + var e: Ledger.Entry = Ledger.appetite_promise("Alice", pid) + e.fuse = 0 + s.ledger.add(e) + s.ledger.tick_and_bill(s) + assert_lt(s.papers, 3.0, "first_authorship consumed a paper slot") + assert_lt(s.compute, compute0, "compute_budget drew committed compute") + assert_lt(s.governance, gov0, "mission/mentorship charged governance (a standing obligation)") + + +# --- Guardrail: promises cannot lose the game in the first 6 turns -------------- + +func test_promise_heavy_hire_cannot_lose_within_6_turns(): + # A promise-hungry candidate hired on ALL promises (as intended), then six turns of ledger + # billing. The player must survive -- reputation must not be zeroable by promises. + var s := _new_state("six_turn_safety") + var c := _make_candidate(s, "safety", 7, 80000.0, { + "prestige": 1.0, "compute": 1.0, "mission_purity": 1.0, "mentees": 1.0, "money": 0.0, + }) + var all_promises := ["first_authorship", "compute_budget", "mission_charter", "mentorship"] + var promised_floor: float = s.hiring.self_worth_floor(c, all_promises) + s.hiring.make_offer(s, c.candidate_id, promised_floor + 500.0, all_promises) + _advance_and_tick(s, Balance.inum("hiring.offer.duration_ticks", 2)) + assert_eq(c.hire_state, Researcher.HireState.EMPLOYED, "the promise-heavy offer was accepted") + # Confirm the promises minted domain obligations (not reputation). + var promise_entries := 0 + for e in s.ledger.entries: + if String(e.source).begins_with("promise:"): + promise_entries += 1 + assert_ne(e.currency, "reputation", "no minted promise is a reputation entry") + assert_eq(promise_entries, all_promises.size(), "each promise minted its obligation") + + # Six turns of ledger billing from turn 1. The player must not lose. + var start_turn: int = s.turn + for t in range(6): + s.turn += 1 + s.ledger.tick_and_bill(s) + s.check_win_lose() + assert_false(s.game_over, + "promises used as intended must not cause a loss by turn %d" % (s.turn - start_turn)) + assert_gt(s.reputation, 0.0, "reputation is never zeroed by hiring promises") + + +func test_worst_case_all_promises_bill_immediately_is_survivable(): + # Stress: force EVERY promise to bill on turn 1 (fuse 0) from fresh starting resources. + # Even this worst case must be survivable (defends the guardrail against future fuse tuning). + var s := _new_state("worst_case") + for pid in Ledger.PROMISE_SPEC: + var e: Ledger.Entry = Ledger.appetite_promise("Alice", pid) + e.fuse = 0 + s.ledger.add(e) + s.ledger.tick_and_bill(s) + s.check_win_lose() + assert_false(s.game_over, "all promises billing at once is survivable") + assert_gt(s.reputation, 0.0, "reputation survives a full promise barrage") + + +# --- Serialization round-trip --------------------------------------------------- + +func test_promise_obligations_serialize_round_trip(): + var s := _new_state("promise_saveload") + for pid in Ledger.PROMISE_SPEC: + s.ledger.add(Ledger.appetite_promise("Alice", pid)) + var before := [] + for e in s.ledger.entries: + before.append({"source": e.source, "currency": e.currency, "principal": e.principal, "fuse": e.fuse}) + + var blob := JSON.stringify(s.to_dict()) + var parsed = JSON.parse_string(blob) + assert_not_null(parsed, "state with promise obligations serializes to JSON") + + var s2 := GameState.new("promise_reload") + s2.from_dict(parsed) + assert_eq(s2.ledger.entries.size(), s.ledger.entries.size(), "all promise entries round-trip") + for i in range(before.size()): + var e2: Ledger.Entry = s2.ledger.entries[i] + assert_eq(e2.source, before[i]["source"], "source round-trips") + assert_eq(e2.currency, before[i]["currency"], "domain currency round-trips") + assert_almost_eq(e2.principal, before[i]["principal"], 0.001, "principal round-trips exactly") + assert_eq(e2.fuse, before[i]["fuse"], "fuse round-trips") + + +# --- Legibility: the cost is surfaced before commit ----------------------------- + +func test_each_promise_exposes_a_cost_line(): + # The offer flow (main_ui.gd) reads this to show the obligation BEFORE the player commits. + for pid in Ledger.PROMISE_SPEC: + var text := Ledger.appetite_promise_cost_text(pid) + assert_true(text.length() > 0, "promise '%s' exposes a human cost line" % pid) + assert_true(text.contains("owes"), "the cost line names the obligation for '%s'" % pid) diff --git a/godot/tests/unit/test_quirk_catalogue.gd b/godot/tests/unit/test_quirk_catalogue.gd new file mode 100644 index 00000000..3d350478 --- /dev/null +++ b/godot/tests/unit/test_quirk_catalogue.gd @@ -0,0 +1,131 @@ +extends GutTest +## Data-driven researcher QUIRK catalogue (retires the legacy trait system). +## Covers: catalogue loads + is well-formed; deterministic assignment from a seed; the +## effect accessor; tenure/exposure reveal flipping quirk_known; save/load round-trip; and +## that the four founding starters draw their quirk from the catalogue. +## Design: docs/game-design/RESEARCHER_QUIRKS.md. Determinism contract: ADR-0006. + +# --- Catalogue loads + shape ------------------------------------------------ + +func test_catalogue_loads_expected_size(): + var ids := QuirkCatalogue.ids() + assert_gte(ids.size(), 12, "catalogue has ~12-16 quirks") + assert_true(QuirkCatalogue.has("runs_hot"), "runs_hot present (reframed workaholic)") + assert_true(QuirkCatalogue.has("secret_successionist"), "canonical reference quirk present") + assert_false(QuirkCatalogue.has("workaholic"), "legacy trait id is NOT a quirk") + +func test_every_quirk_is_well_formed(): + for id in QuirkCatalogue.ids(): + var d := QuirkCatalogue.get_def(id) + assert_true(d.has("name") and String(d["name"]) != "", "%s has a name" % id) + assert_true(d.has("flavour") and String(d["flavour"]) != "", "%s has flavour" % id) + assert_true(d.get("effects", {}) is Dictionary, "%s has an effects dict" % id) + assert_false(d.get("effects", {}).is_empty(), "%s has at least one effect channel" % id) + # reveal fallback must be a positive turn count (guarantees it surfaces in play) + assert_gt(QuirkCatalogue.reveal_after_turns(id), 0, "%s has a tenure reveal fallback" % id) + +func test_effect_accessor_returns_value_or_default(): + # runs_hot self_productivity_mult is 1.20 in the data. + assert_almost_eq(float(QuirkCatalogue.effect("runs_hot", "self_productivity_mult", 1.0)), 1.20, 0.001) + # A channel the quirk does not touch falls back to the default. + assert_eq(QuirkCatalogue.effect("runs_hot", "leak_chance", 0.0), 0.0, "untouched channel -> default") + # An unknown quirk id -> default. + assert_eq(QuirkCatalogue.effect("no_such_quirk", "self_productivity_mult", 1.0), 1.0) + +# --- Deterministic assignment ----------------------------------------------- + +func test_pick_id_is_deterministic_from_seed(): + var a := RandomNumberGenerator.new(); a.seed = 4242 + var b := RandomNumberGenerator.new(); b.seed = 4242 + for i in range(20): + assert_eq(QuirkCatalogue.pick_id(a), QuirkCatalogue.pick_id(b), "same seed -> same pick") + +func test_pick_id_always_in_catalogue(): + var rng := RandomNumberGenerator.new(); rng.seed = 99 + for i in range(50): + assert_true(QuirkCatalogue.has(QuirkCatalogue.pick_id(rng)), "pick_id yields a real quirk id") + +func test_generated_quirk_is_catalogue_member_and_deterministic(): + # Same seed -> same quirk on the researcher (hidden-layer child rng, ADR-0006). + var r1 := Researcher.new("safety") + var r2 := Researcher.new("safety") + var rng1 := RandomNumberGenerator.new(); rng1.seed = 31337 + var rng2 := RandomNumberGenerator.new(); rng2.seed = 31337 + r1.generate_random(rng1) + r2.generate_random(rng2) + assert_eq(r1.quirk, r2.quirk, "same seed -> same quirk") + if r1.quirk != "": + assert_true(QuirkCatalogue.has(r1.quirk), "assigned quirk is a catalogue id") + +# --- Effect wiring on the researcher ---------------------------------------- + +func test_researcher_quirk_effect_reads_catalogue(): + var r := Researcher.new("safety") + assert_eq(float(r.quirk_effect("self_productivity_mult", 1.0)), 1.0, "no quirk -> default") + r.quirk = "runs_hot" + assert_almost_eq(float(r.quirk_effect("self_productivity_mult", 1.0)), 1.20, 0.001, "quirk -> catalogue value") + +# --- Reveal flips quirk_known ----------------------------------------------- + +func test_tenure_reveal_flips_quirk_known_at_threshold(): + var r := Researcher.new("safety") + r.quirk = "cat_whisperer" # after_turns = 4 in the data + r.quirk_known = false + var threshold := QuirkCatalogue.reveal_after_turns("cat_whisperer") + + r.turns_employed = threshold - 1 + assert_false(r.maybe_reveal_quirk_by_tenure(), "not yet at threshold") + assert_false(r.quirk_known, "still hidden before threshold") + + r.turns_employed = threshold + assert_true(r.maybe_reveal_quirk_by_tenure(), "reveals at threshold") + assert_true(r.quirk_known, "quirk_known flipped true") + +func test_expose_quirk_event_path_flips_known(): + var r := Researcher.new("safety") + r.quirk = "doom_absolutist" + assert_false(r.quirk_known) + r.expose_quirk() + assert_true(r.quirk_known, "exposure event surfaces the quirk") + +func test_no_quirk_never_reveals_by_tenure(): + var r := Researcher.new("safety") # quirk == "" + r.turns_employed = 999 + assert_false(r.maybe_reveal_quirk_by_tenure(), "no quirk -> nothing to reveal") + +# --- Save / load round-trip of a catalogue quirk ---------------------------- + +func test_catalogue_quirk_round_trips_through_json(): + var r := Researcher.new("safety") + r.quirk = "empire_builder" + r.quirk_known = true + var parsed = JSON.parse_string(JSON.stringify(r.to_dict())) + assert_not_null(parsed, "serializes to JSON") + var r2 := Researcher.new() + r2.from_dict(parsed) + assert_eq(r2.quirk, "empire_builder", "quirk id round-trips") + assert_true(r2.quirk_known, "quirk_known round-trips") + # And the effect is still live after the hop. + assert_almost_eq(float(r2.quirk_effect("team_productivity_add", 0.0)), 0.04, 0.001) + +# --- Starters draw their quirk from the catalogue --------------------------- + +func test_starter_quirks_are_catalogue_members(): + var state := GameState.new("quirk_starter_seed") + assert_eq(state.candidate_pool.size(), 4, "four founding starters") + for cand in state.candidate_pool: + if cand.quirk != "": + assert_true(QuirkCatalogue.has(cand.quirk), + "starter quirk '%s' is drawn from the catalogue" % cand.quirk) + +func test_starter_quirk_riders_exist_across_seeds(): + # Across a spread of seeds, guaranteed riders that land on the quirk branch must all be + # catalogue members (the appetite-branch starters simply carry no quirk). + var seen_a_quirk := false + for s in ["seedA", "seedB", "seedC", "seedD", "seedE", "seedF"]: + var state := GameState.new(s) + for cand in state.candidate_pool: + if cand.quirk != "": + seen_a_quirk = true + assert_true(QuirkCatalogue.has(cand.quirk), "%s: catalogue quirk" % s) + assert_true(seen_a_quirk, "at least one starter across seeds took the quirk branch") diff --git a/godot/tests/unit/test_turn_manager.gd b/godot/tests/unit/test_turn_manager.gd index c2e785a0..851dbbfc 100644 --- a/godot/tests/unit/test_turn_manager.gd +++ b/godot/tests/unit/test_turn_manager.gd @@ -457,3 +457,35 @@ func test_insider_threat_key_resignation_safe_with_no_researchers(): 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") + +# --- No free per-turn candidate refill (hiring-pipeline redesign) ------------------------- + +func test_no_free_per_turn_candidate_refill(): + # The old placeholder topped the 6-slot pool back up for FREE every turn; that is REMOVED. + # With no sourcing (advertise/connections) active, an emptied pool must STAY empty as turns + # advance -- new candidates now come only from the turn-0 seed + the paid pipeline. + state.candidate_pool.clear() + for i in range(8): + state.doom = 5.0 # keep the short run alive + if state.doom_system != null: + state.doom_system.current_doom = 5.0 + turn_manager.start_turn() + # Clear any events so execute_turn doesn't stall on pending windows. + state.pending_events.clear() + turn_manager.execute_turn() + assert_eq(state.candidate_pool.size(), 0, + "an empty pool does NOT auto-refill on turn %d (no free per-turn top-up)" % state.turn) + +func test_starting_pool_does_not_grow_across_turns(): + # The turn-0 founding team is fixed at four; advancing turns must not silently grow it. + var start_size: int = state.candidate_pool.size() + assert_eq(start_size, 4, "the founding team seeds four starters") + for i in range(6): + state.doom = 5.0 + if state.doom_system != null: + state.doom_system.current_doom = 5.0 + turn_manager.start_turn() + state.pending_events.clear() + turn_manager.execute_turn() + assert_eq(state.candidate_pool.size(), start_size, + "pool size is unchanged across turns without sourcing (no free refill)")