director: never crash the fight on a 503 or a stale lane
Browse filesGuard the LLM call in _choose (backend failure -> deterministic rng pick) and re-resolve the target lane against current state in _apply with an IllegalMove backstop. Fixes two worker crashes seen on the Space: HTTPStatusError 503 from ZeroGPU under load, and IllegalMove when the player empties the targeted lane during the slow director await.
- scrypt/warden/director.py +60 -22
scrypt/warden/director.py
CHANGED
|
@@ -12,7 +12,7 @@ from __future__ import annotations
|
|
| 12 |
import random
|
| 13 |
from dataclasses import dataclass, field
|
| 14 |
|
| 15 |
-
from scrypt.engine.combat import LANES, CombatState
|
| 16 |
from scrypt.inference.backend import Backend
|
| 17 |
|
| 18 |
from .context import build_messages, combat_digest
|
|
@@ -129,34 +129,72 @@ class Director:
|
|
| 129 |
'by calling the tool. Pick what stings most.'
|
| 130 |
)
|
| 131 |
harness = Harness(self.backend, [tool], max_steps=2, max_tokens=120)
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
return picked[0] if picked else self.rng.choice(names)
|
| 134 |
|
| 135 |
# ------------------------------------------------------------ applying
|
| 136 |
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
if action == "throttle":
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
return Intervention(
|
| 143 |
"throttle", detail,
|
| 144 |
f"you just quietly weakened the player's {card}; gloat without "
|
| 145 |
"explaining exactly what you did",
|
| 146 |
)
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
| 12 |
import random
|
| 13 |
from dataclasses import dataclass, field
|
| 14 |
|
| 15 |
+
from scrypt.engine.combat import LANES, CombatState, IllegalMove
|
| 16 |
from scrypt.inference.backend import Backend
|
| 17 |
|
| 18 |
from .context import build_messages, combat_digest
|
|
|
|
| 129 |
'by calling the tool. Pick what stings most.'
|
| 130 |
)
|
| 131 |
harness = Harness(self.backend, [tool], max_steps=2, max_tokens=120)
|
| 132 |
+
try:
|
| 133 |
+
await harness.run(build_messages(frame, digest=combat_digest(state)))
|
| 134 |
+
except Exception:
|
| 135 |
+
# A dead/slow/rude backend (e.g. the Space's ZeroGPU returning 503)
|
| 136 |
+
# must never crash the fight: fall back to a deterministic pick,
|
| 137 |
+
# exactly as author.compose and the voice path do.
|
| 138 |
+
return self.rng.choice(names)
|
| 139 |
return picked[0] if picked else self.rng.choice(names)
|
| 140 |
|
| 141 |
# ------------------------------------------------------------ applying
|
| 142 |
|
| 143 |
+
_NOOP = Intervention("none", "no-op", "")
|
| 144 |
+
|
| 145 |
+
def _resolve_lane(self, state: CombatState, action: str) -> int | None:
|
| 146 |
+
"""Re-pick a legal target lane from CURRENT state. The lane chosen in
|
| 147 |
+
_legal_options was validated BEFORE the (slow) LLM await; the player
|
| 148 |
+
keeps acting during it, so by now that lane may be empty/occupied.
|
| 149 |
+
Re-deriving here keeps the intervention real instead of crashing on a
|
| 150 |
+
stale index. None = nothing legal remains for this action."""
|
| 151 |
if action == "throttle":
|
| 152 |
+
lanes = [
|
| 153 |
+
i for i in range(LANES)
|
| 154 |
+
if state.player_row[i] is not None and state.player_row[i].power > 0
|
| 155 |
+
]
|
| 156 |
+
return max(lanes, key=lambda i: state.player_row[i].power) if lanes else None
|
| 157 |
+
if action == "reinforce":
|
| 158 |
+
empty = [i for i in range(LANES) if state.foe_queue[i] is None]
|
| 159 |
+
return self.rng.choice(empty) if empty else None
|
| 160 |
+
if action == "withdraw":
|
| 161 |
+
queued = [
|
| 162 |
+
i for i in range(LANES)
|
| 163 |
+
if state.foe_queue[i] is not None and state.foe_queue[i].power >= 2
|
| 164 |
+
]
|
| 165 |
+
return max(queued, key=lambda i: state.foe_queue[i].power) if queued else None
|
| 166 |
+
return None
|
| 167 |
+
|
| 168 |
+
def _apply(self, state: CombatState, action: str, args: dict) -> Intervention:
|
| 169 |
+
lane = self._resolve_lane(state, action)
|
| 170 |
+
if lane is None:
|
| 171 |
+
return self._NOOP # the board moved under us; nothing legal left
|
| 172 |
+
try:
|
| 173 |
+
if action == "throttle":
|
| 174 |
+
if not self.budget.try_spend("tamper_player_deck"):
|
| 175 |
+
return self._NOOP
|
| 176 |
+
card = state.player_row[lane].spec.id
|
| 177 |
+
detail = state.throttle_player_card(lane)
|
| 178 |
return Intervention(
|
| 179 |
"throttle", detail,
|
| 180 |
f"you just quietly weakened the player's {card}; gloat without "
|
| 181 |
"explaining exactly what you did",
|
| 182 |
)
|
| 183 |
+
if action == "reinforce":
|
| 184 |
+
spec = self.content.card(args["card"])
|
| 185 |
+
detail = state.reinforce_queue(lane, spec)
|
| 186 |
+
return Intervention(
|
| 187 |
+
"reinforce", detail,
|
| 188 |
+
f"you just scheduled an extra {spec.id} against the player",
|
| 189 |
+
)
|
| 190 |
+
if action == "withdraw":
|
| 191 |
+
detail = state.withdraw_queue(lane)
|
| 192 |
+
return Intervention(
|
| 193 |
+
"withdraw", detail,
|
| 194 |
+
"you quietly removed a threat because the player is being crushed; "
|
| 195 |
+
"say something that sounds like boredom, not kindness",
|
| 196 |
+
)
|
| 197 |
+
except IllegalMove:
|
| 198 |
+
# Backstop: state changed between resolve and mutate. Never crash.
|
| 199 |
+
return self._NOOP
|
| 200 |
+
return self._NOOP
|