"""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) # pre-assign DISTINCT logged events as per-round topics. Left to its own # devices the model re-asks the same question every round; pinning each # option round to a different event (and telling it what's already covered) # is what actually stops the repeats. 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" # Kevin always has slides. Kevin IS slides. _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: # repeated "start" call (client retry/double-fire): re-serve the # current round instead of advancing on an empty answer 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" # round 1 & 2 each get a distinct assigned topic; closing rounds synthesize 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 # sanity floor: the model scores conservatively (~50) even for strong # answers, so substantive answers earn a rising baseline — this is what # lets good presentations actually swing positive instead of netting zero. 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 # round 4 self-awareness adjustment 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