| """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 |
|
|
|
|
| |
| 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 = "" |
|
|
|
|
| |
| 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"]) |
|
|
|
|
| |
| 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) |
| |
| 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) |
|
|
|
|
| |
| 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) |
|
|
| |
| |
| 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): |
| |
| 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" |
| |
| if chaos >= 0.6 and base in ("A", "B", "C"): |
| base = {"A": "B", "B": "C", "C": "D"}[base] |
| return base |
|
|
|
|
| |
| 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, |
| } |
|
|