brad-did-something / game /presentation.py
qFelix's picture
Brad Did Something - Gradio/FastAPI Space on HF
78c0f6e
Raw
History Blame Contribute Delete
10.5 kB
"""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