| """Stakeholder presentations — PRESENTATION_SYSTEM.md.""" |
| from __future__ import annotations |
|
|
| import random |
|
|
| from . import economy, fallbacks, llm, prompts, validator |
| from .state import TOTAL_CRISES, GameState |
| from .trace import trace |
|
|
|
|
| def start(state: GameState) -> None: |
| extended = state.scrutiny_high_streak >= 3 or state.morale < 20 |
| presenting_npc, npc_state = _presence(state) |
| |
| |
| |
| |
| topics = list(state.event_log) |
| random.shuffle(topics) |
| state.presentation = { |
| "round": 0, |
| "total_rounds": 4 if extended else 3, |
| "extended": extended, |
| "transcript": [], |
| "presenting_npc": presenting_npc, |
| "npc_state": npc_state, |
| "wrong_slide_pending": npc_state == "romance", |
| "final": state.crisis_number == TOTAL_CRISES, |
| "score": None, |
| "topics": topics, |
| } |
|
|
|
|
| def _presence(state: GameState) -> tuple[str, str]: |
| """Pick the presenting NPC and their state per PRESENTATION_SYSTEM.md.""" |
| for npc_id, npc in state.npcs.items(): |
| if npc.romance_active: |
| return npc_id, "romance" |
| for npc_id, npc in state.npcs.items(): |
| if npc.personal_situation: |
| return npc_id, "grief" |
| for npc_id, npc in state.npcs.items(): |
| if npc.consecutive_praise >= 2: |
| return npc_id, "overprepared" |
| for npc_id, npc in state.npcs.items(): |
| if npc.relationship < 30: |
| return npc_id, "bare_minimum" |
| for npc_id, npc in state.npcs.items(): |
| if npc.relationship > 65 and npc.gifts_received > 0: |
| return npc_id, "advocate" |
| return "kevin", "normal" |
|
|
|
|
| _PRESENCE_NOTES = { |
| "romance": "The presenting NPC is romantically involved with the player. " |
| "Their deck contains a wrong slide: a photo of the player with " |
| "hand-drawn hearts. The board has seen it. Round 1 is about it.", |
| "grief": "The presenting NPC is going through something personal. Grey " |
| "slides, melancholy titles, trailing off mid-sentence. The board " |
| "may ask if the team is okay before asking about numbers.", |
| "overprepared": "The presenting NPC has received excessive praise and " |
| "produced far more slides than requested. They will not " |
| "be redirected easily.", |
| "bare_minimum": "The presenting NPC was treated harshly this quarter. " |
| "Three slides where eight were expected. One-sentence " |
| "answers. The board notices the energy.", |
| "advocate": "The presenting NPC has a strong relationship with the player. " |
| "Their section is unusually strong and advocates for the " |
| "player's leadership unprompted.", |
| "normal": "The presenting NPC prepared the slides. The slides are wrong " |
| "in the normal way: confidently.", |
| } |
|
|
|
|
| def advance(state: GameState, response_type: str, text: str) -> dict: |
| """Record the player's answer (if any) and produce the next round — |
| or the final outcome after the last round.""" |
| p = state.presentation |
| if p is None: |
| raise ValueError("no active presentation") |
|
|
| if p["round"] > 0: |
| if not text and not response_type: |
| |
| |
| if p.get("last_round"): |
| trace("flow", f"presentation round {p['round']} re-served " |
| "(duplicate start call)") |
| return p["last_round"] |
| trace("flow", f"presentation answer r{p['round']} [{response_type}]" |
| + (f" \"{text}\"" if text else "")) |
| p["transcript"][-1]["player_response"] = text or response_type |
|
|
| if p["round"] >= p["total_rounds"]: |
| return _finish(state) |
|
|
| p["round"] += 1 |
| round_no = p["round"] |
| closing = round_no >= 3 |
| call_type = "presentation_closing" if closing else "presentation_round" |
|
|
| |
| topics = p.get("topics") or [] |
| covered = topics[:round_no - 1] |
| topic = topics[round_no - 1] if (not closing and round_no - 1 < len(topics)) \ |
| else None |
| system, user = prompts.presentation_prompt( |
| state, round_no, p["total_rounds"], p["transcript"], |
| _PRESENCE_NOTES[p["npc_state"]], topic=topic, covered=covered) |
| prev_dialogues = tuple(t["board_dialogue"] for t in p["transcript"]) |
| payload = llm.call_validated( |
| state, call_type, system, user, |
| lambda pl: validator.validate_presentation(pl, state, round_no, |
| closing, prev_dialogues), |
| round_no=round_no, transcript=p["transcript"]) |
| if payload is None: |
| state.fallback_count += 1 |
| trace("flow", f"FALLBACK presentation round {round_no} " |
| f"(#{state.fallback_count} this session)") |
| last = state.event_log[-1] if state.event_log else "the quarter so far" |
| payload = fallbacks.presentation_fallback(round_no, last) |
| trace("flow", f"board r{round_no}/{p['total_rounds']} tone={payload['board_tone']} " |
| f"diff={payload['round_difficulty']} " |
| f"ref=\"{str(payload['event_referenced'])[:60]}\"") |
|
|
| p["transcript"].append({ |
| "round": round_no, |
| "board_dialogue": payload["board_dialogue"], |
| "player_response": None, |
| }) |
| if closing and "cumulative_score" in payload: |
| p["score"] = payload["cumulative_score"] |
|
|
| wrong_slide = p["wrong_slide_pending"] and round_no == 1 |
| if wrong_slide: |
| p["wrong_slide_pending"] = False |
|
|
| p["last_round"] = { |
| "kind": "round", |
| "round": round_no, |
| "total_rounds": p["total_rounds"], |
| "board_tone": payload["board_tone"], |
| "board_dialogue": payload["board_dialogue"], |
| "option_a": payload.get("option_a"), |
| "option_b": payload.get("option_b"), |
| "input_only": closing, |
| "presenting_npc": p["presenting_npc"], |
| "npc_state": p["npc_state"], |
| "wrong_slide": wrong_slide, |
| "is_last_round": round_no >= p["total_rounds"], |
| } |
| return p["last_round"] |
|
|
|
|
| def _finish(state: GameState) -> dict: |
| p = state.presentation |
| score = p["score"] if p["score"] is not None else 50 |
|
|
| |
| |
| |
| answers = [t.get("player_response") or "" for t in p["transcript"]] |
| substantive = sum(1 for a in answers if len(a) >= 30) |
| floor = min(72, 40 + 11 * substantive) |
| if score < floor: |
| trace("vald", f"score floor: model said {score}, {substantive} " |
| f"substantive answers -> floor {floor}") |
| score = floor |
|
|
| |
| if p["total_rounds"] == 4 and p["transcript"]: |
| final_answer = p["transcript"][-1].get("player_response") or "" |
| if len(final_answer) > 60: |
| state.morale = min(100, state.morale + 5) |
| else: |
| state.morale = max(0, state.morale - 5) |
|
|
| swing = int((score - 50) / 50 * 200_000) |
| applied = economy.apply_revenue(state, swing) |
| budget_unlock = 0 |
| if score >= 70: |
| budget_unlock = 10_000 |
| state.company_budget += budget_unlock |
| if score >= 75: |
| economy.lower_scrutiny(state) |
| elif score <= 35: |
| economy.raise_scrutiny(state) |
| if score >= 60: |
| state.morale = min(100, state.morale + 4) |
| elif score <= 40: |
| state.morale = max(0, state.morale - 6) |
|
|
| titles = { |
| (75, 101): "Quarterly Survivor, Decorated", |
| (50, 75): "Presenter of Acceptable Truths", |
| (25, 50): "Director of Damage Adjacent", |
| (0, 25): "Subject of a Drafted Document", |
| } |
| for (lo, hi), title in titles.items(): |
| if lo <= score < hi: |
| state.boss_title = title |
| break |
|
|
| sign = "+" if applied >= 0 else "-" |
| log = (f"Stakeholder presentation at event {state.crisis_number}: " |
| f"scored {score}/100. {sign}${abs(applied) // 1000}K.") |
| state.log(f"Event {state.crisis_number} — {log}") |
| state.trail("board", log, applied) |
|
|
| final = p["final"] |
| trace("flow", f"presentation DONE: score={score} swing={applied:+,} " |
| f"budget+{budget_unlock} scrutiny={state.board_scrutiny} " |
| f"morale={state.morale}") |
| state.presentation = None |
| state.current_event = None |
| state.phase = "review" if final else "free_roam" |
|
|
| return { |
| "kind": "outcome", |
| "score": score, |
| "revenue_delta": applied, |
| "budget_unlock": budget_unlock, |
| "board_scrutiny_public": state.board_scrutiny in ("high", "critical"), |
| "boss_title": state.boss_title, |
| "final": final, |
| } |
|
|
|
|
| def quarterly_review(state: GameState) -> dict: |
| tier = economy.ending_tier(state) |
| system, user = prompts.verdict_prompt(state, tier) |
| payload, _live = llm.call_model(state, "verdict", system, user, tier=tier) |
| payload = validator.validate_verdict(payload) if payload else None |
| if payload is None: |
| state.fallback_count += 1 |
| trace("flow", "FALLBACK verdict") |
| payload = fallbacks.verdict_fallback(tier) |
| trace("flow", f"QUARTER OVER: tier={tier} revenue=${state.revenue:,} " |
| f"fallbacks={state.fallback_count} morale={state.morale}") |
|
|
| highlights = sorted(state.paper_trail, key=lambda e: abs(e["delta"]), |
| reverse=True)[:5] |
| review = { |
| "tier": tier, |
| "final_revenue": state.revenue, |
| "target": state.target, |
| "gap": state.revenue - state.target, |
| "boss_title": state.boss_title, |
| "crises_survived": state.crisis_number, |
| "press_disasters": state.newspaper_count, |
| "highlights": highlights, |
| "verdict": payload["verdict"], |
| } |
| state.review = review |
| state.game_over = True |
| state.phase = "review" |
| return review |
|
|