File size: 8,554 Bytes
f8657ee | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | """TinyWorld Crisis Mode β deterministic crisis resolver.
A *crisis* is a structured emergency injected into the town. Each crisis declares a
set of *requirements* β concrete responses (be at a place, take a kind of action,
maybe be the right role) that together constitute "handling it". After the five
residents react (via ``agents.react``), :func:`evaluate` checks their structured
output against the requirements. This is the deterministic source of truth β the
LLM only *speaks*; the engine decides whether the crisis is solved.
Design mirrors the rest of the codebase: pure logic, no ``gradio`` import, fully
testable offline with synthetic reaction dicts.
"""
from collections import defaultdict
from dataclasses import dataclass, field
import world_state
# --------------------------------------------------------------------------- model
@dataclass
class Requirement:
id: str # "containment" | "medical" | "coordination" ...
label: str # HUD text, e.g. "π₯ Contain the fire"
hotspots: list = field(default_factory=list) # any-of acceptable goto targets
action_keywords: list = field(default_factory=list) # any-of words in action/text
role: str = None # if set, only this character role can satisfy it
weight: float = 1.0
optional: bool = False # optional reqs add progress but aren't required to win
@dataclass
class Crisis:
id: str
title: str
narrative: str # event text injected to agents (round 1)
severity: int = 3 # 1-5, drives chaos accrual per unresolved round
focus: str = "" # teaching question (reused by the explainer)
requirements: list = field(default_factory=list)
time_limit: int = 4 # max rounds before failure
escalation: str = "" # appended to the event each unresolved round
success_text: str = "The town pulled together and handled it."
failure_text: str = "It got away from them this time."
affected_hotspot: str = None # where the crisis visually manifests on the map
kind: str = "fire" # visual effect hint for the canvas (fire/flood/storm/blackout)
@dataclass
class CrisisProgress:
crisis_id: str
met: set = field(default_factory=set) # requirement ids met cumulatively
newly_met: set = field(default_factory=set) # ids first satisfied this round
unmet: list = field(default_factory=list) # Requirement objects still open
contributions: dict = field(default_factory=dict) # name -> [req_id] satisfied this round
progress: float = 0.0
resolved: bool = False
mvp: str = None # resident who satisfied the most reqs
# --------------------------------------------------------------------------- loading
def load_crisis(data):
"""Build a :class:`Crisis` from a plain dict (world files stay data-only)."""
reqs = [Requirement(**r) for r in data.get("requirements", [])]
fields = {k: v for k, v in data.items() if k != "requirements"}
return Crisis(requirements=reqs, **fields)
def get_crisis(world, crisis_id):
for c in world.get("crises", []):
if c["id"] == crisis_id:
return load_crisis(c)
return None
# --------------------------------------------------------------------------- resolver
def _char_for(world, name):
return next((c for c in world.get("cast", []) if c["name"] == name), None)
def _norm(s):
"""Lowercase + de-accent so 'cafΓ©' matches the 'cafe' hotspot key."""
return (s or "").lower().replace("Γ©", "e").replace("Γ¨", "e").replace("Γͺ", "e")
def _mentions(hotspot, blob):
"""The resident named the place in their own words. Use the full phrase or the
place-type token (clinic/office/home/parkβ¦) β never the person-name token, so
saying 'Priya' doesn't count as being at priya_office."""
if hotspot.replace("_", " ") in blob:
return True
place = hotspot.split("_")[-1]
return len(place) > 3 and place in blob
def _satisfies(req, reaction, character):
"""True when one reaction fulfils one requirement.
Each declared condition (location / keywords / role) must hold; an empty
condition is a wildcard. The small dialogue model often picks the wrong
structured ``goto`` while clearly stating the right place in its action/text,
so a location counts if it's the chosen ``goto`` OR named in the words.
"""
if reaction.get("error"):
return False
blob = _norm(f"{reaction.get('action', '')} {reaction.get('text', '')}")
if req.hotspots:
location_ok = (reaction.get("moved_to") in req.hotspots
or any(_mentions(h, blob) for h in req.hotspots))
if not location_ok:
return False
if req.action_keywords:
if not any(kw.lower() in blob for kw in req.action_keywords):
return False
if req.role:
if character is None or world_state.character_role(character) != req.role:
return False
return True
def evaluate(crisis, reactions, world, prior_met=None):
"""Score a round of reactions against a crisis. Met requirements are sticky."""
prior_met = set(prior_met or set())
contributions = defaultdict(list)
newly_met = set()
for req in crisis.requirements:
if req.id in prior_met:
continue
for r in reactions:
char = _char_for(world, r.get("name"))
if _satisfies(req, r, char):
newly_met.add(req.id)
contributions[r["name"]].append(req.id)
break # first responder satisfies it
met = prior_met | newly_met
total_weight = sum(req.weight for req in crisis.requirements) or 1.0
met_weight = sum(req.weight for req in crisis.requirements if req.id in met)
progress = round(met_weight / total_weight, 4)
required = [req for req in crisis.requirements if not req.optional]
resolved = all(req.id in met for req in required)
unmet = [req for req in crisis.requirements if req.id not in met]
mvp = None
if contributions:
mvp = max(contributions, key=lambda n: (len(contributions[n]), n))
return CrisisProgress(
crisis_id=crisis.id,
met=met,
newly_met=newly_met,
unmet=unmet,
contributions=dict(contributions),
progress=progress,
resolved=resolved,
mvp=mvp,
)
# --------------------------------------------------------------------------- round event
def build_event_text(crisis, prior_progress=None):
"""The event string fed to agents for a round. Subsequent rounds carry feedback
about what is still unhandled, which is what drives multi-round coordination."""
if prior_progress is None:
return crisis.narrative
parts = [crisis.narrative]
if crisis.escalation:
parts.append(crisis.escalation)
if prior_progress.unmet:
labels = ", ".join(_plain(req.label) for req in prior_progress.unmet)
parts.append(f"Still unresolved: {labels}.")
return " ".join(parts)
# --------------------------------------------------------------------------- narration
def narrate(crisis, progress, round_no=1, failed=False):
"""A short 'Dispatch' verdict. Deterministic β used directly in MOCK mode and as
the offline fallback. The optional live-LLM flourish wraps this (see app)."""
if failed:
return f"π Dispatch β {crisis.title}: time ran out. {crisis.failure_text}"
if progress.resolved:
return f"π Dispatch β {crisis.title} contained. {crisis.success_text}"
if progress.newly_met:
done = ", ".join(_plain(_label(crisis, rid)) for rid in sorted(progress.newly_met))
open_ = ", ".join(_plain(req.label) for req in progress.unmet)
return f"π Dispatch β round {round_no}: {done} handled. Still open: {open_}."
open_ = ", ".join(_plain(req.label) for req in progress.unmet)
tail = f" {crisis.escalation}" if crisis.escalation else ""
return f"π Dispatch β round {round_no}: no progress. {open_} still unhandled.{tail}"
def _label(crisis, req_id):
return next((r.label for r in crisis.requirements if r.id == req_id), req_id)
def _plain(label):
"""Strip a leading emoji so labels read cleanly mid-sentence."""
parts = label.split(" ", 1)
if len(parts) == 2 and not parts[0].isascii():
return parts[1]
return label
|