tinyworld / campaign.py
sush0401's picture
TinyWorld + Crisis Mode, ZeroGPU in-process inference
d3a7a1c verified
Raw
History Blame Contribute Delete
8.94 kB
"""TinyWorld Crisis Mode — campaign state machine.
A *campaign* chains crises into a story ("Season 1 · The Long Summer"). Each
*chapter* wraps one crisis with narrative beats and a meta-mystery clue. The town
survives the campaign by keeping its Town Resilience above zero; the finale reveals
that the disasters were never random — the saboteur is the player.
This module is pure orchestration: it never calls the LLM itself. The UI computes
reactions with ``agents.react`` and hands them to :func:`resolve_round`, which keeps
the engine testable offline with scripted reactions.
"""
from dataclasses import dataclass, field
import crisis as crisis_mod
import world_state
# Resilience cost of finishing a crisis at each grade (a clean run barely dents it).
GRADE_COST = {"A": 0, "B": 3, "C": 7, "D": 12, "F": 25}
@dataclass
class Chapter:
id: str
crisis_id: str
intro: str = ""
outro_win: str = ""
outro_loss: str = ""
clue: str = ""
@dataclass
class Campaign:
id: str
title: str
chapters: list = field(default_factory=list)
finale_reveal: str = ""
# --------------------------------------------------------------------------- loading
def get_campaign_def(world):
data = world.get("campaign")
if not data:
return None
chapters = [Chapter(**ch) for ch in data.get("chapters", [])]
return Campaign(
id=data["id"],
title=data["title"],
chapters=chapters,
finale_reveal=data.get("finale_reveal", ""),
)
def current_chapter(world):
camp = get_campaign_def(world)
if not camp:
return None
idx = world_state.get_campaign(world["id"])["chapter_index"]
if 0 <= idx < len(camp.chapters):
return camp.chapters[idx]
return None
def current_crisis(world):
state = world_state.get_campaign(world["id"])
if not state.get("crisis_id"):
return None
return crisis_mod.get_crisis(world, state["crisis_id"])
# --------------------------------------------------------------------------- lifecycle
def start(world):
"""Begin the campaign at chapter 1 and inject its crisis."""
wid = world["id"]
world_state.reset_campaign(wid)
camp = get_campaign_def(world)
if not camp or not camp.chapters:
return view(world)
first = camp.chapters[0]
world_state.campaign_update(
wid,
active=True,
campaign_id=camp.id,
chapter_index=0,
status="playing",
)
_inject(world, first)
return view(world)
def _inject(world, chapter):
wid = world["id"]
world_state.campaign_update(
wid,
crisis_id=chapter.crisis_id,
crisis_round=0,
crisis_met=set(),
last_dispatch="",
)
world_state.reset_chaos(wid)
def round_event_text(world):
"""The event string to feed agents this round (carries prior-round feedback)."""
cr = current_crisis(world)
if not cr:
return ""
state = world_state.get_campaign(world["id"])
if state["crisis_round"] == 0:
return crisis_mod.build_event_text(cr)
# rebuild a progress-shaped object from the sticky met set for the feedback text
prior = crisis_mod.CrisisProgress(
crisis_id=cr.id,
met=state["crisis_met"],
unmet=[r for r in cr.requirements if r.id not in state["crisis_met"]],
)
return crisis_mod.build_event_text(cr, prior_progress=prior)
# --------------------------------------------------------------------------- a round
def resolve_round(world, reactions):
"""Advance the campaign by one player round given the residents' reactions."""
wid = world["id"]
state = world_state.get_campaign(wid)
cr = current_crisis(world)
if not cr or state["status"] not in ("playing",):
return {"outcome": "idle", **view(world)}
round_no = state["crisis_round"] + 1
prog = crisis_mod.evaluate(cr, reactions, world, prior_met=state["crisis_met"])
world_state.campaign_update(wid, crisis_met=prog.met, crisis_round=round_no)
if prog.mvp:
tally = state["mvp_tally"]
tally[prog.mvp] = tally.get(prog.mvp, 0) + 1
world_state.campaign_update(wid, mvp_tally=tally)
if prog.resolved:
grade = _grade(round_no, world_state.get_chaos(wid), won=True)
dispatch = crisis_mod.narrate(cr, prog, round_no=round_no)
return _finish_chapter(world, cr, grade, prog, dispatch, won=True)
# not resolved — chaos ramps with each round so the time limit is the real
# budget (campaign owns the chaos gauge; we overwrite any per-event drift).
pressure = min(1.0, (round_no / cr.time_limit) * (0.8 + 0.05 * cr.severity))
world_state.reset_chaos(wid)
world_state.add_chaos(wid, pressure)
if round_no >= cr.time_limit:
grade = "F"
dispatch = crisis_mod.narrate(cr, prog, round_no=round_no, failed=True)
return _finish_chapter(world, cr, grade, prog, dispatch, won=False)
dispatch = crisis_mod.narrate(cr, prog, round_no=round_no)
world_state.campaign_update(wid, last_dispatch=dispatch)
return {"outcome": "ongoing", "dispatch": dispatch, "progress": prog, "grade": None, **view(world)}
def _finish_chapter(world, cr, grade, prog, dispatch, won):
wid = world["id"]
state = world_state.get_campaign(wid)
camp = get_campaign_def(world)
chapter = current_chapter(world)
resilience = max(0, state["town_resilience"] - GRADE_COST.get(grade, 12))
grades = state["chapter_grades"] + [{
"chapter": chapter.id if chapter else cr.id,
"title": cr.title,
"grade": grade,
"rounds": state["crisis_round"],
"mvp": prog.mvp,
"won": won,
}]
clues = state["clues"] + ([chapter.clue] if chapter and chapter.clue else [])
world_state.campaign_update(
wid,
town_resilience=resilience,
chapter_grades=grades,
clues=clues,
last_dispatch=dispatch,
)
next_index = state["chapter_index"] + 1
outcome = "won" if won else "lost_crisis"
outro = (chapter.outro_win if won else chapter.outro_loss) if chapter else ""
if resilience <= 0:
world_state.campaign_update(wid, status="lost", crisis_id=None)
return {"outcome": "campaign_lost", "dispatch": dispatch, "outro": outro,
"grade": grade, "progress": prog, **view(world)}
if next_index >= len(camp.chapters):
# campaign cleared — the meta-mystery resolves
world_state.campaign_update(wid, status="finale", chapter_index=next_index, crisis_id=None)
return {"outcome": "finale", "dispatch": dispatch, "outro": outro, "grade": grade,
"reveal": camp.finale_reveal, "progress": prog, **view(world)}
world_state.campaign_update(wid, chapter_index=next_index)
_inject(world, camp.chapters[next_index])
return {"outcome": outcome, "dispatch": dispatch, "outro": outro, "grade": grade,
"progress": prog, **view(world)}
def _grade(round_no, chaos, won):
if not won:
return "F"
if round_no == 1:
base = "A"
elif round_no == 2:
base = "B"
elif round_no == 3:
base = "C"
else:
base = "D"
# a messy scene (high chaos) knocks the grade down one notch
if chaos >= 0.6 and base in ("A", "B", "C"):
base = {"A": "B", "B": "C", "C": "D"}[base]
return base
# --------------------------------------------------------------------------- snapshot
def view(world):
"""A serializable snapshot of campaign + active crisis for the UI/HUD."""
wid = world["id"]
camp = get_campaign_def(world)
state = world_state.get_campaign(wid)
cr = current_crisis(world)
chapter = current_chapter(world)
crisis_view = None
if cr:
crisis_view = {
"id": cr.id,
"title": cr.title,
"kind": cr.kind,
"affected_hotspot": cr.affected_hotspot,
"severity": cr.severity,
"time_limit": cr.time_limit,
"round": state["crisis_round"],
"focus": cr.focus,
"requirements": [
{"id": r.id, "label": r.label, "met": r.id in state["crisis_met"],
"optional": r.optional}
for r in cr.requirements
],
}
return {
"active": state["active"],
"status": state["status"],
"title": camp.title if camp else "",
"chapter_index": state["chapter_index"],
"chapter_count": len(camp.chapters) if camp else 0,
"chapter_intro": chapter.intro if chapter else "",
"town_resilience": state["town_resilience"],
"chaos": round(world_state.get_chaos(wid), 3),
"clues": state["clues"],
"chapter_grades": state["chapter_grades"],
"mvp_tally": state["mvp_tally"],
"last_dispatch": state["last_dispatch"],
"crisis": crisis_view,
}