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