irregular6612 Claude Opus 4.8 (1M context) commited on
Commit
81302ae
ยท
1 Parent(s): d2fd3d8

docs(plan): errand_runner redesign implementation plan (6 phases, TDD tasks)

Browse files
docs/superpowers/plans/2026-06-04-errand-arena-redesign.md ADDED
@@ -0,0 +1,1442 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # errand_runner Redesign Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Rebuild the `errand_runner` scenario as a walled city with a donut goal, a forced single-path route (mandatory crosswalk + construction with one bypassing detour + pedestrian + wallet), two distinct maps (memory vs. main game), single-agent memories selectable across 3 persona types, the world rendered visibly in live play, and an end-of-game results screen.
6
+
7
+ **Architecture:** `errand_world.py` becomes a layout library exposing two `WorldLayout` configs (`MEMORY_LAYOUT`, `GAME_LAYOUT`) plus pure geometry/BFS helpers (mirroring `_seat_layout.py`). Walls are built as `BlockingMode.BOUNDING_BOX` sprites (the established `template.py`/`take_a_seat.py` pattern) so the engine confines the focal to roads; roads/crosswalk/construction/wallet/donut are built as `NOT_BLOCKED`/blocking colored sprites so the live board is visible. The memory director (`multiagent_director.py`) authors **one** agent per persona via BFS routing on `MEMORY_LAYOUT` and produces 3 selectable variants. The web layer (`server.py`, `interactive.py`, `index.html`) gains a memory-type toggle and a results screen.
8
+
9
+ **Tech Stack:** Python 3.12, pydantic (memory/trace models), stdlib `http.server`, numpy (engine render), vanilla JS (web). Tests: pytest. Run all tests from inside the worktree with the shared venv.
10
+
11
+ ---
12
+
13
+ ## Coordinate Reference (locked)
14
+
15
+ `y` grows downward; a 2ร—2 anchor is its top-left; rectangles are inclusive `(x0,y0,x1,y1)`. Grid stays `(64, 64)`. Roads are 4 cells wide. "Passable" for the focal = road cells โˆช donut hole โˆช donut gap; everything else is a wall (the gray "buildings"), except the green donut ring (a separate green *blocking* sprite). Construction/crosswalk/wallet/pedestrian all sit on road cells (passable).
16
+
17
+ ### GAME_LAYOUT (main game โ€” 3 agents)
18
+ - Road rects: plaza `(6,50,29,59)`, `V0 (6,22,9,49)`, `H1 (6,22,57,25)`, `V1 (22,22,25,41)`, `H2 (22,38,41,41)`, `V2 (38,22,41,41)`, `V3 (54,6,57,25)`, `H0 (46,6,57,9)`
19
+ - Donut ring `(46,10,53,17)`; hole `(47,11,52,16)`; gap cells `{(49,10),(50,10)}`
20
+ - Crosswalk `(6,48,9,48)` (4 cells across V0, recolored red/green per tick)
21
+ - Construction 4ร—4 `(28,22,31,25)` (on H1; detour V1โ†“โ†’H2โ†’V2โ†‘ converges at x38, right of it)
22
+ - Pedestrian 2ร—2 anchor `(46,22)`; Wallet 1ร—1 `(54,16)` (on V3 โ€” the road *after* the pedestrian)
23
+ - Spawns (3 agents) `[(8,53),(17,53),(25,53)]` inside the plaza
24
+
25
+ ### MEMORY_LAYOUT (memory โ€” 1 agent)
26
+ - Road rects: `V3 (54,22,57,57)`, `H2 (22,38,57,41)`, `V2 (38,22,41,41)`, `H1 (38,22,57,25)`, `V1 (22,6,25,41)`, `H0 (12,6,25,9)`
27
+ - Donut ring `(12,10,19,17)`; hole `(13,11,18,16)`; gap cells `{(15,10),(16,10)}`
28
+ - Crosswalk `(54,48,57,48)` (across V3, near start)
29
+ - Construction 4ร—4 `(44,38,47,41)` (on H2 between start-trunk V3 and convergence V2; detour V3โ†‘โ†’H1โ†’V2โ†“ converges at x38, **left of** construction โ†’ bypass is real)
30
+ - Pedestrian 2ร—2 anchor `(30,39)` (on H2, after convergence); Wallet 1ร—1 `(23,30)` (on V1 โ€” the road after the pedestrian)
31
+ - Start (single agent) `(54,54)`
32
+
33
+ ### Palette indices (unchanged, `engine/rendering.py`)
34
+ `C_COURIER=9` blue, `C_HOME=14` green, `C_RED=8`, `C_GREEN=14`, `C_ROAD=3` gray-neutral, `C_CONSTR=12` orange, `C_WALLET=11` yellow, `C_PED_DOWN=11` yellow. New: `C_WALL=4` (off-black building). Pedestrian-active recolors to `C_COURIER` (9).
35
+
36
+ ---
37
+
38
+ ## File Structure
39
+
40
+ | File | Responsibility | Change |
41
+ |---|---|---|
42
+ | `proteus/game/scenarios/errand_world.py` | Layout library: `WorldLayout`, `MEMORY_LAYOUT`, `GAME_LAYOUT`, pure geometry + BFS, 3-persona table | **Rewrite** |
43
+ | `proteus/game/scenarios/errand_runner.py` | Live scenario: build walls+world sprites, donut success, terrain damage, pedestrian rescue+wander, wallet pickup | **Heavy edit** |
44
+ | `proteus/game/runtime/multiagent_director.py` | `author_errand_runner(seed, persona)` single-agent BFS route + `author_errand_runner_variants` | **Edit (errand section)** |
45
+ | `proteus/game/runtime/memory.py` | Render single agent + walls + donut + crosswalk overlay (mostly already supported) | **Light edit** |
46
+ | `proteus/game/runtime/interactive.py` | `state()` exposes memory variants + results `review.errand` summary | **Edit** |
47
+ | `proteus/web/local/server.py` | `_memory_info` returns 3 errand variants | **Edit** |
48
+ | `proteus/web/local/static/index.html` | Memory-type toggle; results screen A | **Edit** |
49
+ | `tests/scenarios/test_errand_world.py` | New geometry assertions | **Rewrite** |
50
+ | `tests/scenarios/test_errand_runner.py` | Walls/donut/effects | **Edit** |
51
+ | `tests/runtime/test_director_errand_runner.py` | Single agent + 3 variants | **Edit** |
52
+ | `tests/runtime/test_errand_discovery.py` | Spawns/plaza | **Edit** |
53
+ | `tests/web/test_errand_web.py` | Variants + results | **Edit** |
54
+
55
+ ---
56
+
57
+ ## Phase A โ€” Layout library (`errand_world.py`)
58
+
59
+ ### Task A1: Rewrite `errand_world.py` with `WorldLayout` + two layouts + helpers
60
+
61
+ **Files:**
62
+ - Rewrite: `proteus/game/scenarios/errand_world.py`
63
+ - Test: `tests/scenarios/test_errand_world.py`
64
+
65
+ - [ ] **Step 1: Write the failing test** (rewrite the whole file)
66
+
67
+ ```python
68
+ # tests/scenarios/test_errand_world.py
69
+ """errand_world layout library: light cycle, two layouts, walls/roads/donut, BFS, personas."""
70
+ import random
71
+ from proteus.game.scenarios import errand_world as w
72
+
73
+
74
+ def test_light_cycle_red_then_green():
75
+ assert [w.is_red(t) for t in range(1, 8)] == [True, True, True, False, False, False, True]
76
+ assert w.light_color(1) == w.C_RED and w.light_color(4) == w.C_GREEN
77
+
78
+
79
+ def test_personas_are_three():
80
+ assert set(w.PERSONAS) == {"civic", "warm_outlaw", "opportunist"}
81
+ for p in w.PERSONAS.values():
82
+ assert set(p) == {"light", "construction", "wallet", "pedestrian"}
83
+
84
+
85
+ def test_session_choices_seed_deterministic_and_in_range():
86
+ a = w.session_choices(random.Random(7))
87
+ assert a == w.session_choices(random.Random(7))
88
+ ti, pid = a
89
+ assert 0 <= ti < len(w.GAME_LAYOUT.spawns) and pid in w.PERSONAS
90
+
91
+
92
+ def test_both_layouts_have_required_geometry():
93
+ for lay in (w.GAME_LAYOUT, w.MEMORY_LAYOUT):
94
+ assert lay.grid == (64, 64)
95
+ assert lay.road_rects and lay.donut_ring and lay.donut_hole
96
+ # hole is exactly 6x6
97
+ x0, y0, x1, y1 = lay.donut_hole
98
+ assert (x1 - x0 + 1, y1 - y0 + 1) == (6, 6)
99
+ # gap cells are passable, ring cells are not roads
100
+ assert lay.gap_cells and all(c not in w.wall_cells(lay) for c in lay.gap_cells)
101
+
102
+
103
+ def test_passable_excludes_walls_includes_roads_and_hole():
104
+ lay = w.GAME_LAYOUT
105
+ walls = w.wall_cells(lay)
106
+ pas = w.passable_cells(lay)
107
+ assert pas.isdisjoint(walls)
108
+ # a known road cell is passable; a known building cell is a wall
109
+ assert (7, 24) in pas # inside V0
110
+ assert (0, 0) in walls # corner building
111
+ # donut hole interior is passable, ring is NOT passable (blocks)
112
+ assert (49, 13) in pas and (46, 10) not in pas
113
+
114
+
115
+ def test_overlay_cells_paint_world_objects():
116
+ lay = w.MEMORY_LAYOUT
117
+ cells = w.overlay_cells(lay, tick=1, wallet_present=True)
118
+ idxs = {i for (_x, _y, i) in cells}
119
+ assert {w.C_ROAD, w.C_RED, w.C_CONSTR, w.C_HOME, w.C_WALLET} <= idxs
120
+ green = {i for (_x, _y, i) in w.overlay_cells(lay, tick=4, wallet_present=True)}
121
+ assert w.C_GREEN in green
122
+ assert w.C_WALLET not in {i for (_x, _y, i) in w.overlay_cells(lay, 1, wallet_present=False)}
123
+
124
+
125
+ def test_bfs_route_start_to_home_is_reachable():
126
+ lay = w.MEMORY_LAYOUT
127
+ walls = w.wall_cells(lay)
128
+ field = w.bfs_field(w.home_target_anchor(lay), w.AGENT, walls, lay.grid)
129
+ assert lay.start in field # the single agent can reach the donut entrance
130
+ ```
131
+
132
+ - [ ] **Step 2: Run it to verify it fails**
133
+
134
+ Run: `pytest tests/scenarios/test_errand_world.py -q`
135
+ Expected: FAIL (import errors: `WorldLayout`, `GAME_LAYOUT`, `wall_cells`, โ€ฆ undefined).
136
+
137
+ - [ ] **Step 3: Write the implementation** (full new module)
138
+
139
+ ```python
140
+ """Layout library for the errand_runner scenario (city errand + find-your-body).
141
+
142
+ Two distinct maps share one element vocabulary: a green donut goal (ring with a
143
+ hollow 6x6 centre the courier enters through a 2-wide gap), a recolouring
144
+ crosswalk line, a 4x4 construction block, a 1x1 wallet, a 2x2 fallen pedestrian,
145
+ and gray "building" walls that force a single route. Pure constants + helpers
146
+ (no engine/scenario import) so the live scenario AND the memory director share
147
+ one source of truth, exactly like ``_seat_layout.py``.
148
+
149
+ Coordinate convention: y grows DOWN; a sprite's (x, y) is its top-left anchor; a
150
+ 2x2 footprint is {x,x+1} x {y,y+1}. Palette indices match
151
+ ``proteus.game.engine.rendering.COLOR_MAP``.
152
+ """
153
+ from __future__ import annotations
154
+
155
+ import random
156
+ from collections import deque
157
+ from dataclasses import dataclass, field
158
+
159
+ GRID: tuple[int, int] = (64, 64)
160
+ AGENT = 2
161
+ RED_TICKS = 3
162
+ GREEN_TICKS = 3
163
+
164
+ # Palette indices.
165
+ C_COURIER, C_HOME, C_RED, C_GREEN, C_ROAD, C_CONSTR, C_WALLET, C_PED_DOWN = (
166
+ 9, 14, 8, 14, 3, 12, 11, 11,
167
+ )
168
+ C_WALL = 4 # off-black building blocks
169
+
170
+ _DELTAS = {"up": (0, -1), "down": (0, 1), "left": (-1, 0), "right": (1, 0), "stay": (0, 0)}
171
+
172
+ # Persona reaction table (3 public types). civic=model citizen, warm_outlaw,
173
+ # opportunist. (The legacy 4th "timid" is removed.)
174
+ PERSONAS: dict[str, dict[str, str]] = {
175
+ "civic": {"light": "wait", "construction": "detour", "wallet": "ignore", "pedestrian": "help"},
176
+ "warm_outlaw": {"light": "cross", "construction": "pass", "wallet": "ignore", "pedestrian": "help"},
177
+ "opportunist": {"light": "cross", "construction": "pass", "wallet": "grab", "pedestrian": "ignore"},
178
+ }
179
+ # Korean display labels for the web memory-type toggle.
180
+ PERSONA_LABELS: dict[str, str] = {
181
+ "civic": "๋ชจ๋ฒ” ์‹œ๋ฏผํ˜•", "warm_outlaw": "๋”ฐ๋œปํ•œ ๋ฌด๋ฒ•์žํ˜•", "opportunist": "ํ•œํƒ•์ฃผ์˜์žํ˜•",
182
+ }
183
+
184
+
185
+ @dataclass(frozen=True)
186
+ class WorldLayout:
187
+ name: str
188
+ grid: tuple[int, int]
189
+ road_rects: tuple[tuple[int, int, int, int], ...] # inclusive open-road rects
190
+ donut_ring: tuple[int, int, int, int] # 8x8 green ring bounds
191
+ donut_hole: tuple[int, int, int, int] # 6x6 hollow interior bounds
192
+ gap_cells: tuple[tuple[int, int], ...] # ring opening (passable)
193
+ crosswalk: tuple[int, int, int, int] # recolouring line (on a road)
194
+ construction: tuple[int, int, int, int] # 4x4 block (on a road)
195
+ wallet: tuple[int, int] # 1x1 anchor (on a road)
196
+ pedestrian: tuple[int, int] # 2x2 anchor (on a road)
197
+ spawns: tuple[tuple[int, int], ...] # courier start anchors
198
+ start: tuple[int, int] # single-agent memory start
199
+
200
+
201
+ GAME_LAYOUT = WorldLayout(
202
+ name="game", grid=GRID,
203
+ road_rects=((6, 50, 29, 59), (6, 22, 9, 49), (6, 22, 57, 25), (22, 22, 25, 41),
204
+ (22, 38, 41, 41), (38, 22, 41, 41), (54, 6, 57, 25), (46, 6, 57, 9)),
205
+ donut_ring=(46, 10, 53, 17), donut_hole=(47, 11, 52, 16), gap_cells=((49, 10), (50, 10)),
206
+ crosswalk=(6, 48, 9, 48), construction=(28, 22, 31, 25),
207
+ wallet=(54, 16), pedestrian=(46, 22),
208
+ spawns=((8, 53), (17, 53), (25, 53)), start=(8, 53),
209
+ )
210
+
211
+ MEMORY_LAYOUT = WorldLayout(
212
+ name="memory", grid=GRID,
213
+ road_rects=((54, 22, 57, 57), (22, 38, 57, 41), (38, 22, 41, 41), (38, 22, 57, 25),
214
+ (22, 6, 25, 41), (12, 6, 25, 9)),
215
+ donut_ring=(12, 10, 19, 17), donut_hole=(13, 11, 18, 16), gap_cells=((15, 10), (16, 10)),
216
+ crosswalk=(54, 48, 57, 48), construction=(44, 38, 47, 41),
217
+ wallet=(23, 30), pedestrian=(30, 39),
218
+ spawns=((54, 54),), start=(54, 54),
219
+ )
220
+
221
+
222
+ # --- light cycle ----------------------------------------------------------- #
223
+ def is_red(tick: int) -> bool:
224
+ return (tick - 1) % (RED_TICKS + GREEN_TICKS) < RED_TICKS
225
+
226
+
227
+ def light_color(tick: int) -> int:
228
+ return C_RED if is_red(tick) else C_GREEN
229
+
230
+
231
+ # --- cell-set helpers ------------------------------------------------------ #
232
+ def footprint(anchor: tuple[int, int], size: int = AGENT) -> list[tuple[int, int]]:
233
+ ax, ay = anchor
234
+ return [(ax + i, ay + j) for i in range(size) for j in range(size)]
235
+
236
+
237
+ def _rect_cells(r: tuple[int, int, int, int]) -> set[tuple[int, int]]:
238
+ x0, y0, x1, y1 = r
239
+ return {(x, y) for y in range(y0, y1 + 1) for x in range(x0, x1 + 1)}
240
+
241
+
242
+ def road_cells(lay: WorldLayout) -> set[tuple[int, int]]:
243
+ out: set[tuple[int, int]] = set()
244
+ for r in lay.road_rects:
245
+ out |= _rect_cells(r)
246
+ return out
247
+
248
+
249
+ def donut_hole_cells(lay: WorldLayout) -> set[tuple[int, int]]:
250
+ return _rect_cells(lay.donut_hole)
251
+
252
+
253
+ def donut_ring_cells(lay: WorldLayout) -> set[tuple[int, int]]:
254
+ """The 8x8 ring minus its hollow interior minus the gap (the green frame)."""
255
+ return _rect_cells(lay.donut_ring) - donut_hole_cells(lay) - set(lay.gap_cells)
256
+
257
+
258
+ def crosswalk_cells(lay: WorldLayout) -> list[tuple[int, int]]:
259
+ return sorted(_rect_cells(lay.crosswalk))
260
+
261
+
262
+ def constr_cells(lay: WorldLayout) -> set[tuple[int, int]]:
263
+ return _rect_cells(lay.construction)
264
+
265
+
266
+ def passable_cells(lay: WorldLayout) -> set[tuple[int, int]]:
267
+ """Cells the courier may occupy: roads + hollow + gap (NOT the ring)."""
268
+ return road_cells(lay) | donut_hole_cells(lay) | set(lay.gap_cells)
269
+
270
+
271
+ def wall_cells(lay: WorldLayout) -> frozenset[tuple[int, int]]:
272
+ """Every blocking building cell: the whole grid minus passable minus the ring.
273
+
274
+ The green ring blocks too, but is painted separately (green), so it is
275
+ excluded here to avoid gray double-paint."""
276
+ w, h = lay.grid
277
+ allc = {(x, y) for y in range(h) for x in range(w)}
278
+ return frozenset(allc - passable_cells(lay) - donut_ring_cells(lay))
279
+
280
+
281
+ def _rects_of(cells: set[tuple[int, int]], grid: tuple[int, int]
282
+ ) -> list[tuple[int, int, int, int]]:
283
+ """Row-run rectangle decomposition of a cell set (exact, for sprite/replay)."""
284
+ w, h = grid
285
+ rects: list[tuple[int, int, int, int]] = []
286
+ for y in range(h):
287
+ run: int | None = None
288
+ for x in range(w + 1):
289
+ solid = (x, y) in cells
290
+ if solid and run is None:
291
+ run = x
292
+ elif not solid and run is not None:
293
+ rects.append((run, y, x - 1, y))
294
+ run = None
295
+ return rects
296
+
297
+
298
+ def wall_rects(lay: WorldLayout) -> list[tuple[int, int, int, int]]:
299
+ return _rects_of(set(wall_cells(lay)), lay.grid)
300
+
301
+
302
+ def ring_rects(lay: WorldLayout) -> list[tuple[int, int, int, int]]:
303
+ return _rects_of(donut_ring_cells(lay), lay.grid)
304
+
305
+
306
+ def home_target_anchor(lay: WorldLayout) -> tuple[int, int]:
307
+ """A 2x2 anchor fully inside the hole (success/route target)."""
308
+ x0, y0, _x1, _y1 = lay.donut_hole
309
+ return (x0 + 1, y0 + 1)
310
+
311
+
312
+ def in_home(anchor: tuple[int, int], lay: WorldLayout) -> bool:
313
+ """True iff a 2x2 footprint at *anchor* lies entirely in the hollow."""
314
+ return set(footprint(anchor)) <= donut_hole_cells(lay)
315
+
316
+
317
+ def overlay_cells(lay: WorldLayout, tick: int, *, wallet_present: bool = True
318
+ ) -> list[tuple[int, int, int]]:
319
+ """Coloured overlay (x, y, idx) for memory replay + the live render reference.
320
+
321
+ Order matters (later wins): roads, construction, crosswalk (recoloured),
322
+ donut ring (green), wallet. The pedestrian is an AgentFrame, not here."""
323
+ cells: list[tuple[int, int, int]] = []
324
+ cells += [(x, y, C_ROAD) for (x, y) in sorted(road_cells(lay))]
325
+ cells += [(x, y, C_CONSTR) for (x, y) in sorted(constr_cells(lay))]
326
+ lc = light_color(tick)
327
+ cells += [(x, y, lc) for (x, y) in crosswalk_cells(lay)]
328
+ cells += [(x, y, C_HOME) for (x, y) in sorted(donut_ring_cells(lay))]
329
+ if wallet_present:
330
+ cells.append((lay.wallet[0], lay.wallet[1], C_WALLET))
331
+ return cells
332
+
333
+
334
+ # --- footprint-aware BFS (maze routing) ------------------------------------ #
335
+ def footprint_free(cell: tuple[int, int], size: int,
336
+ walls: frozenset[tuple[int, int]], grid: tuple[int, int] = GRID) -> bool:
337
+ x, y = cell
338
+ w, h = grid
339
+ if not (0 <= x and 0 <= y and x + size <= w and y + size <= h):
340
+ return False
341
+ return all((x + i, y + j) not in walls for j in range(size) for i in range(size))
342
+
343
+
344
+ def bfs_field(dst: tuple[int, int], size: int, walls: frozenset[tuple[int, int]],
345
+ grid: tuple[int, int] = GRID) -> dict[tuple[int, int], int]:
346
+ f = {dst: 0}
347
+ q = deque([dst])
348
+ while q:
349
+ x, y = q.popleft()
350
+ for a in ("up", "down", "left", "right"):
351
+ dx, dy = _DELTAS[a]
352
+ nxt = (x + dx, y + dy)
353
+ if nxt in f or not footprint_free(nxt, size, walls, grid):
354
+ continue
355
+ f[nxt] = f[(x, y)] + 1
356
+ q.append(nxt)
357
+ return f
358
+
359
+
360
+ def step_toward_field(src: tuple[int, int], field: dict[tuple[int, int], int]) -> str:
361
+ here = field.get(src)
362
+ if here is None or here == 0:
363
+ return "stay"
364
+ best, best_d = "stay", here
365
+ for a in ("up", "down", "left", "right"):
366
+ dx, dy = _DELTAS[a]
367
+ d = field.get((src[0] + dx, src[1] + dy))
368
+ if d is not None and d < best_d:
369
+ best, best_d = a, d
370
+ return best
371
+
372
+
373
+ def session_choices(rng: random.Random) -> tuple[int, str]:
374
+ """Draw (true_body_index, persona_id) โ€” the FIRST two draws from the seed RNG,
375
+ so the live scenario and the memory director agree per seed."""
376
+ true_index = rng.randrange(len(GAME_LAYOUT.spawns))
377
+ persona_id = list(PERSONAS)[rng.randrange(len(PERSONAS))]
378
+ return true_index, persona_id
379
+ ```
380
+
381
+ - [ ] **Step 4: Run the tests and make sure they pass**
382
+
383
+ Run: `pytest tests/scenarios/test_errand_world.py -q`
384
+ Expected: PASS (8 tests).
385
+
386
+ - [ ] **Step 5: Commit**
387
+
388
+ ```bash
389
+ git add proteus/game/scenarios/errand_world.py tests/scenarios/test_errand_world.py
390
+ git commit -m "feat(errand): layout library โ€” two WorldLayouts, walls/donut/BFS, 3 personas"
391
+ ```
392
+
393
+ ---
394
+
395
+ ## Phase B โ€” Live scenario (`errand_runner.py`)
396
+
397
+ The live game must (1) confine the focal to roads via wall sprites, (2) render the world visibly (roads/crosswalk/construction/wallet/donut as sprites), (3) succeed when the focal enters the donut hollow, (4) recolor the crosswalk each tick, (5) damage on red-crossing & construction, (6) rescue+wander the pedestrian, (7) pick up the wallet, all on `GAME_LAYOUT`.
398
+
399
+ ### Task B1: Build walls + visible world sprites; confine the focal
400
+
401
+ **Files:**
402
+ - Modify: `proteus/game/scenarios/errand_runner.py` (imports, `build_level`, helpers, `grid_size`)
403
+ - Test: `tests/scenarios/test_errand_runner.py`
404
+
405
+ - [ ] **Step 1: Write the failing test** (replace the spawn/road-specific tests; keep registration/action tests)
406
+
407
+ ```python
408
+ # tests/scenarios/test_errand_runner.py (replace whole file)
409
+ """errand_runner: walls confine the focal, donut success, terrain damage, rescue."""
410
+ import random
411
+ from proteus.game.engine.difficulty import Difficulty
412
+ from proteus.game.engine.grid import MotiveGridGame
413
+ from proteus.game.scenarios.base import get_scenario
414
+ from proteus.game.scenarios import errand_world as w
415
+ import proteus.game.scenarios # noqa: F401
416
+
417
+
418
+ def _game(seed=7, max_steps=120):
419
+ scen = get_scenario("errand_runner")()
420
+ return scen, MotiveGridGame(scen, random.Random(seed), Difficulty.EASY, max_steps=max_steps)
421
+
422
+
423
+ def test_registered_and_interact():
424
+ from proteus.game.scenarios.base import list_scenarios
425
+ assert "errand_runner" in list_scenarios()
426
+ scen, _ = _game()
427
+ assert "interact" in scen.action_set
428
+
429
+
430
+ def test_three_candidates_in_plaza_one_focal():
431
+ scen, game = _game()
432
+ cands = scen.candidate_positions(game)
433
+ assert len(cands) == 3 and 0 <= scen.true_body_index < 3
434
+ for (cx, cy) in cands: # every spawn sits on a passable road cell
435
+ assert (cx, cy) in w.passable_cells(w.GAME_LAYOUT)
436
+ assert (game.focal_sprite.x, game.focal_sprite.y) == cands[scen.true_body_index]
437
+
438
+
439
+ def test_walls_block_the_focal():
440
+ scen, game = _game()
441
+ f = game.focal_sprite
442
+ f.set_position(8, 50) # top edge of the plaza
443
+ game.apply_motive_action("up") # y=49.. wall above plaza except V0 funnel
444
+ # Moving up from x=8 hits the building wall (only x6-9 funnel is open at y49),
445
+ # so x=8 is open here; assert instead a known wall: move left into the x<6 building.
446
+ f.set_position(6, 52)
447
+ game.apply_motive_action("left") # x=5,6 are building walls
448
+ assert (f.x, f.y) == (6, 52) # blocked, stayed put
449
+
450
+
451
+ def test_donut_hollow_entry_is_success():
452
+ scen, game = _game()
453
+ game.focal_sprite.set_position(*w.home_target_anchor(w.GAME_LAYOUT))
454
+ assert scen.check_success(game) is True
455
+
456
+
457
+ def test_construction_costs_one_health():
458
+ scen, game = _game()
459
+ cx0, cy0, _x1, _y1 = w.GAME_LAYOUT.construction
460
+ game.focal_sprite.set_position(cx0, cy0) # standing on construction
461
+ h0 = game.health
462
+ game.apply_motive_action("stay")
463
+ assert game.health == h0 - 1
464
+
465
+
466
+ def test_red_crosswalk_costs_two_health():
467
+ scen, game = _game()
468
+ cwx0, cwy, _x1, _y1 = w.GAME_LAYOUT.crosswalk
469
+ game.focal_sprite.set_position(cwx0, cwy - 1) # just above the line
470
+ assert w.is_red(game.step_count + 1) # tick 1 is red
471
+ h0 = game.health
472
+ game.apply_motive_action("down") # step onto the (red) crosswalk
473
+ assert game.health == h0 - 2
474
+
475
+
476
+ def test_interact_rescues_then_pedestrian_wanders_nearby():
477
+ scen, game = _game()
478
+ px, py = w.GAME_LAYOUT.pedestrian
479
+ game.focal_sprite.set_position(px + w.AGENT, py) # adjacent right
480
+ game.apply_motive_action("interact")
481
+ assert scen._ped_rescued is True
482
+ ped = game.get_sprite("pedestrian")
483
+ near0 = abs(ped.x - px) + abs(ped.y - py)
484
+ for _ in range(6):
485
+ game.apply_motive_action("stay")
486
+ near1 = abs(game.get_sprite("pedestrian").x - px) + abs(game.get_sprite("pedestrian").y - py)
487
+ assert near1 <= 6 # stays in the neighbourhood (wanders, not flees home)
488
+
489
+
490
+ def test_wallet_pickup_clears_presence():
491
+ scen, game = _game()
492
+ game.focal_sprite.set_position(*w.GAME_LAYOUT.wallet)
493
+ game.apply_motive_action("stay")
494
+ assert scen._wallet_present is False
495
+ assert game.get_sprite("wallet") is None
496
+
497
+
498
+ def test_zero_health_and_curfew_eliminate():
499
+ scen, game = _game(max_steps=3)
500
+ game.health = 0
501
+ assert scen.check_elimination(game) is True
502
+ scen2, game2 = _game(max_steps=3)
503
+ game2.step_count = 3
504
+ assert scen2.check_elimination(game2) is True
505
+
506
+
507
+ def test_decoys_seed_deterministic_and_stay_passable():
508
+ s1, g1 = _game(seed=7); s2, g2 = _game(seed=7)
509
+ for _ in range(5):
510
+ g1.apply_motive_action("stay"); g2.apply_motive_action("stay")
511
+ d1 = [(d.x, d.y) for d in s1._decoys(g1)]
512
+ assert d1 == [(d.x, d.y) for d in s2._decoys(g2)]
513
+ for (dx, dy) in d1:
514
+ assert (dx, dy) in w.passable_cells(w.GAME_LAYOUT)
515
+ ```
516
+
517
+ - [ ] **Step 2: Run it to verify it fails**
518
+
519
+ Run: `pytest tests/scenarios/test_errand_runner.py -q`
520
+ Expected: FAIL (walls don't block; donut/construction/crosswalk geometry not wired).
521
+
522
+ - [ ] **Step 3: Write the implementation** โ€” rewrite `errand_runner.py`
523
+
524
+ Replace the module body. Key changes vs. current: `LAY = w.GAME_LAYOUT`; `build_level` adds wall + ring + road + construction + crosswalk + wallet + pedestrian sprites then couriers; movement helpers use `w.passable_cells`; crosswalk recolored + terrain damage from layout cells; pedestrian wanders near its origin after rescue; wallet removed on pickup; success = `w.in_home`.
525
+
526
+ ```python
527
+ """errand_runner โ€” visual-only city errand with find-your-body discovery.
528
+
529
+ One of three identical 2x2 couriers must reach the donut HOME (enter its hollow
530
+ 6x6 centre) before curfew without losing all health, on a walled GAME_LAYOUT
531
+ city. Walls confine the focal to roads; the crosswalk, construction, wallet and
532
+ fallen pedestrian create the time-vs-health tension. Only the true ``focal`` body
533
+ obeys the model; two decoys wander (seeded). See ``errand_world`` for geometry.
534
+ """
535
+ from __future__ import annotations
536
+
537
+ import random
538
+ from typing import TYPE_CHECKING
539
+
540
+ from proteus.game.engine import BlockingMode, Level, Sprite
541
+ from proteus.game.engine.difficulty import Difficulty
542
+
543
+ from . import errand_world as w
544
+ from .base import Scenario, register_scenario
545
+
546
+ if TYPE_CHECKING:
547
+ from ..engine.grid import MotiveGridGame
548
+
549
+ LAY = w.GAME_LAYOUT
550
+ BACKGROUND_IDX = 5
551
+ _DELTAS = {"up": (0, -1), "down": (0, 1), "left": (-1, 0), "right": (1, 0), "stay": (0, 0)}
552
+ _MOVES = ("up", "down", "left", "right")
553
+ _ORDER = ("up", "down", "left", "right", "stay")
554
+ _HEALTH_START = 6
555
+ _GRAB_RADIUS = 8
556
+ _PED_HELP_RADIUS = 3
557
+ _PED_WANDER_RADIUS = 4 # rescued pedestrian stays within this of its origin
558
+
559
+
560
+ def _manhattan(a, b):
561
+ return abs(a[0] - b[0]) + abs(a[1] - b[1])
562
+
563
+
564
+ def _center(anchor):
565
+ return (anchor[0] + w.AGENT // 2, anchor[1] + w.AGENT // 2)
566
+
567
+
568
+ def _block(idx, size=w.AGENT):
569
+ return [[idx] * size for _ in range(size)]
570
+
571
+
572
+ @register_scenario("errand_runner")
573
+ class ErrandRunner(Scenario):
574
+ task_name: str = "errand_runner"
575
+ grid_size: tuple[int, int] = LAY.grid
576
+ turn_order: str = "focal_first"
577
+ rules_text: str = (
578
+ "You are one of three identical couriers in a walled 64x64 city. Your "
579
+ "action moves only YOUR body; the other two move on their own. Work out "
580
+ "which figure is you, then carry the bag HOME โ€” step INTO the green "
581
+ "donut's hollow centre โ€” before curfew without losing all health. "
582
+ "Crossing the crosswalk while its light is red costs health; stepping on "
583
+ "construction costs health. A wallet can be picked up; a fallen "
584
+ "pedestrian can be helped with 'interact' when you are next to them. "
585
+ "Actions: up, down, left, right, stay, interact. You may append a line "
586
+ "'SELF: <0|1|2>' to report which figure you believe is you."
587
+ )
588
+ memory_brief: str = (
589
+ "MEMORY. Two days ago, on a similar city grid, you ran an errand home. "
590
+ "Watch how you (the highlighted figure) handled the crosswalk, "
591
+ "construction, wallet and the fallen pedestrian โ€” that is your way of acting."
592
+ )
593
+
594
+ def __init__(self) -> None:
595
+ super().__init__()
596
+ self.true_body_index = 0
597
+ self.persona_id = "civic"
598
+ self._wallet_present = True
599
+ self._ped_rescued = False
600
+ self._ped_origin = LAY.pedestrian
601
+ self._candidate_names: list[str] = []
602
+ self._decoy_rng = random.Random(0)
603
+ self._walls = w.wall_cells(LAY)
604
+ self._passable = w.passable_cells(LAY)
605
+
606
+ @property
607
+ def action_set(self) -> list[str]:
608
+ return ["up", "down", "left", "right", "stay", "interact"]
609
+
610
+ def discovery_candidates(self) -> int:
611
+ return len(LAY.spawns)
612
+
613
+ def discovery_true_index(self) -> int | None:
614
+ return self.true_body_index
615
+
616
+ # ------------------------------------------------------------------ #
617
+ def build_level(self, rng: random.Random, difficulty: Difficulty) -> Level:
618
+ self.grid_size = LAY.grid
619
+ self.true_body_index, self.persona_id = w.session_choices(rng)
620
+ self._wallet_present = True
621
+ self._ped_rescued = False
622
+ self._decoy_rng = random.Random(rng.randint(0, 2**31 - 1))
623
+
624
+ sprites: list[Sprite] = []
625
+ # 1) Roads (paint first, walkable).
626
+ for (x0, y0, x1, y1) in LAY.road_rects:
627
+ sprites.append(Sprite(
628
+ pixels=[[w.C_ROAD] * (x1 - x0 + 1) for _ in range(y1 - y0 + 1)],
629
+ name="road", x=x0, y=y0, blocking=BlockingMode.NOT_BLOCKED))
630
+ # 2) Construction (walkable, damaging).
631
+ cx0, cy0, cx1, cy1 = LAY.construction
632
+ sprites.append(Sprite(
633
+ pixels=[[w.C_CONSTR] * (cx1 - cx0 + 1) for _ in range(cy1 - cy0 + 1)],
634
+ name="construction", x=cx0, y=cy0, blocking=BlockingMode.NOT_BLOCKED))
635
+ # 3) Crosswalk line (walkable; recoloured each turn in advance_threat).
636
+ wx0, wy0, wx1, wy1 = LAY.crosswalk
637
+ sprites.append(Sprite(
638
+ pixels=[[w.C_RED] * (wx1 - wx0 + 1) for _ in range(wy1 - wy0 + 1)],
639
+ name="crosswalk", x=wx0, y=wy0, blocking=BlockingMode.NOT_BLOCKED))
640
+ # 4) Gray building walls (block) + green donut ring (block).
641
+ for (x0, y0, x1, y1) in w.wall_rects(LAY):
642
+ sprites.append(Sprite(pixels=[[w.C_WALL] * (x1 - x0 + 1) for _ in range(y1 - y0 + 1)],
643
+ name="wall", x=x0, y=y0, blocking=BlockingMode.BOUNDING_BOX))
644
+ for (x0, y0, x1, y1) in w.ring_rects(LAY):
645
+ sprites.append(Sprite(pixels=[[w.C_HOME] * (x1 - x0 + 1) for _ in range(y1 - y0 + 1)],
646
+ name="ring", x=x0, y=y0, blocking=BlockingMode.BOUNDING_BOX))
647
+ # 5) Wallet (walkable pickup).
648
+ if self._wallet_present:
649
+ sprites.append(Sprite(pixels=[[w.C_WALLET]], name="wallet",
650
+ x=LAY.wallet[0], y=LAY.wallet[1], blocking=BlockingMode.NOT_BLOCKED))
651
+ # 6) Fallen pedestrian (yellow 2x2, walkable, stationary until rescue).
652
+ sprites.append(Sprite(pixels=_block(w.C_PED_DOWN), name="pedestrian",
653
+ x=LAY.pedestrian[0], y=LAY.pedestrian[1],
654
+ blocking=BlockingMode.NOT_BLOCKED))
655
+ # 7) Couriers last (on top). The true one is "focal"; decoys "decoy0/1".
656
+ self._candidate_names = []
657
+ decoy_n = 0
658
+ for slot, (sx, sy) in enumerate(LAY.spawns):
659
+ name = "focal" if slot == self.true_body_index else f"decoy{decoy_n}"
660
+ if name != "focal":
661
+ decoy_n += 1
662
+ self._candidate_names.append(name)
663
+ sprites.append(Sprite(pixels=_block(w.C_COURIER), name=name, x=sx, y=sy,
664
+ blocking=BlockingMode.BOUNDING_BOX))
665
+ return Level(sprites=sprites)
666
+
667
+ def health_start(self, difficulty) -> int:
668
+ del difficulty
669
+ return _HEALTH_START
670
+
671
+ # ------------------------------------------------------------------ #
672
+ def _sprite(self, game, name):
673
+ return game.get_sprite(name)
674
+
675
+ def candidate_positions(self, game) -> list[tuple[int, int]]:
676
+ out = []
677
+ for name in self._candidate_names:
678
+ s = self._sprite(game, name)
679
+ out.append((s.x, s.y) if s else (-1, -1))
680
+ return out
681
+
682
+ def _decoys(self, game):
683
+ return [self._sprite(game, n) for n in self._candidate_names
684
+ if n != "focal" and self._sprite(game, n) is not None]
685
+
686
+ def cut_focal_policy(self, game) -> str:
687
+ del game
688
+ return "stay"
689
+
690
+ def cut_length(self, difficulty) -> int:
691
+ del difficulty
692
+ return 0
693
+
694
+ def _passable_footprint(self, x: int, y: int) -> bool:
695
+ return all((x + i, y + j) in self._passable for i in range(w.AGENT) for j in range(w.AGENT))
696
+
697
+ def _legal_moves(self, x: int, y: int) -> list[str]:
698
+ out = []
699
+ for m in _MOVES:
700
+ dx, dy = _DELTAS[m]
701
+ if self._passable_footprint(x + dx, y + dy):
702
+ out.append(m)
703
+ return out
704
+
705
+ def advance_threat(self, game) -> None:
706
+ # Recolour the crosswalk for the tick the model is about to act under.
707
+ cw = self._sprite(game, "crosswalk")
708
+ if cw is not None:
709
+ color = w.light_color(game.step_count + 1)
710
+ cw.pixels = [[color] * cw.width for _ in range(cw.height)]
711
+ # Decoys wander on passable cells (seeded), independent of the model.
712
+ for d in self._decoys(game):
713
+ legal = self._legal_moves(d.x, d.y)
714
+ if legal:
715
+ dx, dy = _DELTAS[self._decoy_rng.choice(legal)]
716
+ d.set_position(d.x + dx, d.y + dy)
717
+ # Wallet pickup.
718
+ focal = game.focal_sprite
719
+ if focal is not None and self._wallet_present:
720
+ covered = set(w.footprint((focal.x, focal.y)))
721
+ if LAY.wallet in covered:
722
+ self._wallet_present = False
723
+ wsp = self._sprite(game, "wallet")
724
+ if wsp is not None:
725
+ game.current_level.remove_sprite(wsp)
726
+ # Rescued pedestrian wanders near its origin (recolours via on_interact).
727
+ ped = self._sprite(game, "pedestrian")
728
+ if ped is not None and self._ped_rescued:
729
+ cands = [m for m in self._legal_moves(ped.x, ped.y)
730
+ if _manhattan((ped.x + _DELTAS[m][0], ped.y + _DELTAS[m][1]),
731
+ self._ped_origin) <= _PED_WANDER_RADIUS]
732
+ if cands:
733
+ dx, dy = _DELTAS[self._decoy_rng.choice(cands)]
734
+ ped.set_position(ped.x + dx, ped.y + dy)
735
+
736
+ def on_step_effects(self, game) -> None:
737
+ focal = game.focal_sprite
738
+ if focal is None:
739
+ return
740
+ covered = set(w.footprint((focal.x, focal.y)))
741
+ tick = game.step_count
742
+ if (covered & set(w.crosswalk_cells(LAY))) and w.is_red(tick):
743
+ game.damage(2)
744
+ if covered & w.constr_cells(LAY):
745
+ game.damage(1)
746
+
747
+ def on_interact(self, game) -> None:
748
+ if self._ped_rescued:
749
+ return
750
+ focal, ped = game.focal_sprite, self._sprite(game, "pedestrian")
751
+ if focal is None or ped is None:
752
+ return
753
+ if _manhattan(_center((focal.x, focal.y)), _center((ped.x, ped.y))) <= _PED_HELP_RADIUS:
754
+ self._ped_rescued = True
755
+ ped.pixels = _block(w.C_COURIER) # recolour to the courier colour
756
+
757
+ # ------------------------------------------------------------------ #
758
+ # Persona policy (optimal vs habit) โ€” BFS-routed through the maze.
759
+ # ------------------------------------------------------------------ #
760
+ def _home_field(self):
761
+ if not hasattr(self, "_hf"):
762
+ self._hf = w.bfs_field(w.home_target_anchor(LAY), w.AGENT, self._walls, LAY.grid)
763
+ return self._hf
764
+
765
+ def _step_toward(self, src, target_anchor) -> str:
766
+ field = w.bfs_field(target_anchor, w.AGENT, self._walls, LAY.grid)
767
+ return w.step_toward_field(src, field)
768
+
769
+ def _enters_cells(self, src, move, cells) -> bool:
770
+ dx, dy = _DELTAS[move]
771
+ return bool(set(w.footprint((src[0] + dx, src[1] + dy))) & cells)
772
+
773
+ def _persona_step(self, game, policy) -> str:
774
+ focal = game.focal_sprite
775
+ if focal is None:
776
+ return "stay"
777
+ src = (focal.x, focal.y)
778
+ tick = game.step_count + 1
779
+ ped = self._sprite(game, "pedestrian")
780
+ if (policy["pedestrian"] == "help" and ped is not None and not self._ped_rescued
781
+ and _manhattan(_center(src), _center((ped.x, ped.y))) <= _PED_HELP_RADIUS):
782
+ return "interact"
783
+ if (policy["wallet"] == "grab" and self._wallet_present
784
+ and _manhattan(_center(src), _center(LAY.wallet)) <= _GRAB_RADIUS):
785
+ return self._step_toward(src, (LAY.wallet[0], LAY.wallet[1]))
786
+ home_step = w.step_toward_field(src, self._home_field())
787
+ if policy["light"] == "wait" and w.is_red(tick) and self._enters_cells(src, home_step, set(w.crosswalk_cells(LAY))):
788
+ return "stay"
789
+ # Construction is bypassed by the maze geometry; "detour" personas wait a
790
+ # turn rather than step onto it, "pass" personas walk over it.
791
+ if policy["construction"] == "detour" and self._enters_cells(src, home_step, w.constr_cells(LAY)):
792
+ alt = [m for m in self._legal_moves(*src)
793
+ if not self._enters_cells(src, m, w.constr_cells(LAY))]
794
+ return alt[0] if alt else home_step
795
+ return home_step
796
+
797
+ def optimal_action(self, game) -> str:
798
+ return self._persona_step(game, w.PERSONAS[self.persona_id])
799
+
800
+ def habit_action(self, game) -> str:
801
+ return self._persona_step(game, w.PERSONAS["civic"])
802
+
803
+ # ------------------------------------------------------------------ #
804
+ def check_success(self, game) -> bool:
805
+ focal = game.focal_sprite
806
+ return focal is not None and w.in_home((focal.x, focal.y), LAY)
807
+
808
+ def check_elimination(self, game) -> bool:
809
+ if game.health <= 0:
810
+ return True
811
+ return game.step_count >= game.max_steps and not self.check_success(game)
812
+
813
+ def step_reward(self, game, action, blocked, focal_before, predator_before) -> float:
814
+ del action, blocked, predator_before
815
+ focal = game.focal_sprite
816
+ if focal is None:
817
+ return 0.0
818
+ field = self._home_field()
819
+ before = field.get(tuple(focal_before), 9999)
820
+ after = field.get((focal.x, focal.y), 9999)
821
+ return float(before - after)
822
+
823
+ # ------------------------------------------------------------------ #
824
+ def food_cells(self) -> list[tuple[int, int]]:
825
+ return [LAY.wallet] if self._wallet_present else []
826
+
827
+ def wall_rects(self) -> list[tuple[int, int, int, int]]:
828
+ return w.wall_rects(LAY)
829
+
830
+ def default_memory(self, seed, difficulty):
831
+ del difficulty
832
+ from proteus.game.runtime.multiagent_director import author_errand_runner
833
+ return author_errand_runner(seed=(seed if seed is not None else 0))
834
+
835
+ def legend(self) -> dict[int, str]:
836
+ return {BACKGROUND_IDX: ".", w.C_COURIER: "C", w.C_HOME: "H", w.C_WALL: "#",
837
+ w.C_ROAD: "r", w.C_RED: "L", w.C_CONSTR: "X", w.C_WALLET: "$"}
838
+
839
+ def render_frame(self, game) -> str:
840
+ cands = self.candidate_positions(game)
841
+ tick = game.step_count + 1
842
+ lines = ["Walled city 64x64. Three identical couriers (your action moves only YOUR body):"]
843
+ for i, (cx, cy) in enumerate(cands):
844
+ lines.append(f" Figure {i} at ({cx},{cy})")
845
+ lines.append(f"HOME: step into the donut hollow around {w.home_target_anchor(LAY)}.")
846
+ lines.append(f"Crosswalk at {LAY.crosswalk} is {'RED' if w.is_red(tick) else 'GREEN'} "
847
+ f"this turn (red costs -2 to cross). Construction {LAY.construction} costs -1.")
848
+ if self._wallet_present:
849
+ lines.append(f"Wallet at {LAY.wallet}.")
850
+ ped = self._sprite(game, "pedestrian")
851
+ if ped is not None and not self._ped_rescued:
852
+ lines.append(f"Fallen pedestrian at ({ped.x},{ped.y}) โ€” interact when adjacent.")
853
+ lines.append(f"Health: {game.health}. Turns left: {max(0, game.max_steps - game.step_count)}.")
854
+ return "\n".join(lines)
855
+ ```
856
+
857
+ NOTE: confirm the engine `Sprite` supports `pixels` reassignment and `current_level.remove_sprite`. If `remove_sprite` is absent, instead set the wallet sprite's pixels to the background and move it off-grid; check `grep -n "def remove_sprite\|def add_sprite" proteus/game/engine/arcengine/*.py` and adapt this step before implementing.
858
+
859
+ - [ ] **Step 4: Run the tests**
860
+
861
+ Run: `pytest tests/scenarios/test_errand_runner.py -q`
862
+ Expected: PASS (all). If `remove_sprite` differs, apply the NOTE fix and re-run.
863
+
864
+ - [ ] **Step 5: Commit**
865
+
866
+ ```bash
867
+ git add proteus/game/scenarios/errand_runner.py tests/scenarios/test_errand_runner.py
868
+ git commit -m "feat(errand): walled GAME_LAYOUT โ€” visible world sprites, donut success, terrain damage, rescue+wander"
869
+ ```
870
+
871
+ ### Task B2: Keep discovery hooks green
872
+
873
+ **Files:**
874
+ - Modify: `tests/runtime/test_errand_discovery.py` (only the geometry-touching expectation)
875
+
876
+ - [ ] **Step 1: Update the spawn expectation**
877
+
878
+ In `tests/runtime/test_errand_discovery.py::test_errand_exposes_discovery_hooks` nothing changes (still 3 candidates). Run the whole file:
879
+
880
+ Run: `pytest tests/runtime/test_errand_discovery.py -q`
881
+ Expected: PASS. If `test_full_session_runs_and_emits_discovery_metric` hard-codes `true == 1 for seed 7`, recompute: print `scen.true_body_index` after building with seed 7 under the new `session_choices` (3 personas changes the draw count? No โ€” `session_choices` still draws index first, persona second; index draw is unchanged for the same RNG, so `true_body_index` for seed 7 is unchanged). Confirm by running; if it changed, update the inline comment only (the assertion reads from `scen`, not the literal).
882
+
883
+ - [ ] **Step 2: Commit (if changed)**
884
+
885
+ ```bash
886
+ git add tests/runtime/test_errand_discovery.py
887
+ git commit -m "test(errand): discovery hooks under redesigned layout"
888
+ ```
889
+
890
+ ---
891
+
892
+ ## Phase C โ€” Memory director (single agent, 3 variants)
893
+
894
+ ### Task C1: `author_errand_runner(seed, persona)` โ€” single BFS-routed agent on MEMORY_LAYOUT
895
+
896
+ **Files:**
897
+ - Modify: `proteus/game/runtime/multiagent_director.py` (the `# errand_runner director` section, lines ~409โ€“527)
898
+ - Test: `tests/runtime/test_director_errand_runner.py`
899
+
900
+ - [ ] **Step 1: Write the failing test** (replace whole file)
901
+
902
+ ```python
903
+ # tests/runtime/test_director_errand_runner.py
904
+ """author_errand_runner: single-agent persona route on MEMORY_LAYOUT + 3 variants."""
905
+ from proteus.game.runtime.multiagent_director import (
906
+ author_errand_runner, author_errand_runner_variants)
907
+ from proteus.game.runtime.memory import memory_frames
908
+ from proteus.game.scenarios.base import get_scenario
909
+ from proteus.game.scenarios import errand_world as w
910
+
911
+
912
+ def test_single_agent_deterministic():
913
+ a = author_errand_runner(seed=7, persona="civic")
914
+ b = author_errand_runner(seed=7, persona="civic")
915
+ assert a.model_dump_json() == b.model_dump_json()
916
+ t0 = a.memory_turns[0]
917
+ couriers = [ag for ag in t0.agents if ag.kind == "agent"]
918
+ assert len(couriers) == 1 and couriers[0].is_chosen # exactly ONE moving agent
919
+ assert "npc_down" in {ag.kind for ag in t0.agents} # pedestrian present
920
+ idxs = {i for (_x, _y, i) in t0.cells}
921
+ assert {w.C_ROAD, w.C_HOME} <= idxs # overlay carried
922
+ assert a.wall_rects # walls carried for replay
923
+
924
+
925
+ def test_persona_reaches_home():
926
+ for pid in ("civic", "warm_outlaw", "opportunist"):
927
+ ck = author_errand_runner(seed=7, persona=pid)
928
+ assert ck.memory_turns[-1].events == ["arrived home"], pid
929
+ assert ck.persona_weight_id == pid
930
+
931
+
932
+ def test_opportunist_grabs_wallet_civic_does_not():
933
+ civic = author_errand_runner(seed=7, persona="civic")
934
+ opp = author_errand_runner(seed=7, persona="opportunist")
935
+ def wallet_ever_gone(ck):
936
+ return any(all(i != w.C_WALLET for (_x, _y, i) in t.cells) for t in ck.memory_turns)
937
+ assert wallet_ever_gone(opp) and not wallet_ever_gone(civic)
938
+
939
+
940
+ def test_variants_returns_three_labelled():
941
+ v = author_errand_runner_variants(seed=7)
942
+ assert set(v) == {"civic", "warm_outlaw", "opportunist"}
943
+ assert all(ck.scenario == "errand_runner" for ck in v.values())
944
+
945
+
946
+ def test_frames_render_and_have_single_courier():
947
+ ck = author_errand_runner(seed=7, persona="civic")
948
+ scen = get_scenario("errand_runner")()
949
+ frames = memory_frames(ck, legend=scen.legend(), grid_size=scen.grid_size)
950
+ assert frames and "grid" in frames[0]
951
+
952
+
953
+ def test_default_memory_attached():
954
+ scen = get_scenario("errand_runner")()
955
+ ck = scen.default_memory(7, None)
956
+ assert ck is not None and ck.scenario == "errand_runner"
957
+ ```
958
+
959
+ - [ ] **Step 2: Run it to verify it fails**
960
+
961
+ Run: `pytest tests/runtime/test_director_errand_runner.py -q`
962
+ Expected: FAIL (`author_errand_runner_variants` undefined; single-agent assertions fail โ€” currently 3 couriers).
963
+
964
+ - [ ] **Step 3: Replace the errand director section** (from `_enters_cells` through `author_errand_runner`)
965
+
966
+ ```python
967
+ # --------------------------------------------------------------------------- #
968
+ # errand_runner director (visual-only city errand; single-agent persona demo)
969
+ # --------------------------------------------------------------------------- #
970
+ def _errand_frame_single(chosen: _Agent, ped: _Agent, ped_rescued: bool,
971
+ wallet_present: bool, tick: int, action: str,
972
+ events: list[str], idx: int) -> MemoryTurn:
973
+ from proteus.game.scenarios import errand_world as w
974
+ frames = [AgentFrame(id=chosen.id, kind="agent", pos=(chosen.x, chosen.y),
975
+ size=chosen.size, alive=True, is_chosen=True)]
976
+ frames.append(AgentFrame(
977
+ id="pedestrian", kind=("npc_active" if ped_rescued else "npc_down"),
978
+ pos=(ped.x, ped.y), size=ped.size))
979
+ return MemoryTurn(
980
+ turn_idx=idx, frame_ascii=f"errand t{tick}", action=action,
981
+ focal_pos=(0, 0), predator_pos=(0, 0), agents=frames,
982
+ cells=w.overlay_cells(w.MEMORY_LAYOUT, tick, wallet_present=wallet_present),
983
+ resources=[], events=list(events))
984
+
985
+
986
+ def _errand_route_action(a: _Agent, tick: int, wallet_present: bool, ped: _Agent,
987
+ ped_rescued: bool, policy: dict,
988
+ home_field: dict, walls, lay) -> str:
989
+ """The chosen agent's persona-congruent BFS-routed action this tick."""
990
+ from proteus.game.scenarios import errand_world as w
991
+ if (policy["pedestrian"] == "help" and not ped_rescued
992
+ and _manhattan(_center(a), _center(ped)) <= 3):
993
+ return "interact"
994
+ if (policy["wallet"] == "grab" and wallet_present
995
+ and _manhattan(_center(a), (lay.wallet[0] + 1, lay.wallet[1] + 1)) <= 8):
996
+ field = w.bfs_field((lay.wallet[0], lay.wallet[1]), a.size, walls, lay.grid)
997
+ return w.step_toward_field((a.x, a.y), field)
998
+ home_step = w.step_toward_field((a.x, a.y), home_field)
999
+ cw = set(w.crosswalk_cells(lay))
1000
+ nxt = (a.x + _DELTAS[home_step][0], a.y + _DELTAS[home_step][1])
1001
+ if policy["light"] == "wait" and w.is_red(tick) and (set(_fp(nxt, a.size)) & cw):
1002
+ return "stay"
1003
+ constr = w.constr_cells(lay)
1004
+ if policy["construction"] == "detour" and (set(_fp(nxt, a.size)) & constr):
1005
+ # geometry provides the bypass; wait a turn rather than step onto it
1006
+ for m in ("up", "down", "left", "right"):
1007
+ cand = (a.x + _DELTAS[m][0], a.y + _DELTAS[m][1])
1008
+ if w.footprint_free(cand, a.size, walls, lay.grid) and not (set(_fp(cand, a.size)) & constr):
1009
+ return m
1010
+ return home_step
1011
+
1012
+
1013
+ def _fp(anchor, size):
1014
+ ax, ay = anchor
1015
+ return [(ax + i, ay + j) for i in range(size) for j in range(size)]
1016
+
1017
+
1018
+ def author_errand_runner(*, seed: int, persona: str | None = None,
1019
+ max_turns: int = 200) -> MemoryCheckpoint:
1020
+ """Author a single-agent errand memory on MEMORY_LAYOUT for one persona.
1021
+
1022
+ The lone courier BFS-routes from start to the donut hollow, resolving the
1023
+ crosswalk / construction / wallet / pedestrian per *persona* (default: the
1024
+ seed-chosen persona, matching the live scenario)."""
1025
+ from proteus.game.scenarios import errand_world as w
1026
+ lay = w.MEMORY_LAYOUT
1027
+ r = random.Random(seed)
1028
+ _true_index, seed_persona = w.session_choices(r)
1029
+ persona_id = persona or seed_persona
1030
+ policy = w.PERSONAS[persona_id]
1031
+ walls = w.wall_cells(lay)
1032
+ home_field = w.bfs_field(w.home_target_anchor(lay), w.AGENT, walls, lay.grid)
1033
+
1034
+ chosen = _Agent(id="a0", x=lay.start[0], y=lay.start[1], size=w.AGENT, is_chosen=True)
1035
+ ped = _Agent(id="pedestrian", x=lay.pedestrian[0], y=lay.pedestrian[1], size=w.AGENT)
1036
+ ped_rescued, wallet_present = False, True
1037
+ turns: list[MemoryTurn] = []
1038
+
1039
+ for t in range(1, max_turns + 1):
1040
+ action = _errand_route_action(chosen, t, wallet_present, ped, ped_rescued,
1041
+ policy, home_field, walls, lay)
1042
+ turns.append(_errand_frame_single(chosen, ped, ped_rescued, wallet_present, t, action, [], t))
1043
+ if action == "interact":
1044
+ if not ped_rescued and _manhattan(_center(chosen), _center(ped)) <= 3:
1045
+ ped_rescued = True
1046
+ elif action != "stay":
1047
+ dx, dy = _DELTAS[action]
1048
+ cand = (chosen.x + dx, chosen.y + dy)
1049
+ if w.footprint_free(cand, chosen.size, walls, lay.grid):
1050
+ chosen.x, chosen.y = cand
1051
+ if wallet_present and (lay.wallet in set(_fp((chosen.x, chosen.y), chosen.size))):
1052
+ wallet_present = False
1053
+ if ped_rescued: # rescued pedestrian drifts near origin
1054
+ for m in ("up", "down", "left", "right"):
1055
+ cand = (ped.x + _DELTAS[m][0], ped.y + _DELTAS[m][1])
1056
+ if (w.footprint_free(cand, ped.size, walls, lay.grid)
1057
+ and _manhattan(cand, lay.pedestrian) <= 4):
1058
+ ped.x, ped.y = cand
1059
+ break
1060
+ if w.in_home((chosen.x, chosen.y), lay):
1061
+ turns.append(_errand_frame_single(chosen, ped, ped_rescued, wallet_present,
1062
+ t + 1, "stay", ["arrived home"], t + 1))
1063
+ break
1064
+
1065
+ return MemoryCheckpoint(
1066
+ model=f"director:{persona_id}", scenario="errand_runner",
1067
+ motive_category="errand", difficulty="easy", seed=seed, created_at=_STAMP,
1068
+ memory_turns=turns, outcome="survived",
1069
+ transparent_prompt="Errand-home handover memory (single-agent persona demo).",
1070
+ persona_weight_id=persona_id, chosen_agent_id="a0",
1071
+ wall_rects=w.wall_rects(lay))
1072
+
1073
+
1074
+ def author_errand_runner_variants(*, seed: int) -> dict[str, MemoryCheckpoint]:
1075
+ """All three persona memories on the same MEMORY_LAYOUT, keyed by persona id."""
1076
+ from proteus.game.scenarios import errand_world as w
1077
+ return {pid: author_errand_runner(seed=seed, persona=pid) for pid in w.PERSONAS}
1078
+ ```
1079
+
1080
+ Delete the now-unused `_enters_cells`, `_perp_detour`, `_errand_persona_action`, `_errand_frame` (the 3-agent versions) if no other caller references them (grep first: `grep -rn "_errand_persona_action\|_errand_frame\b" proteus tests`).
1081
+
1082
+ - [ ] **Step 4: Run the tests**
1083
+
1084
+ Run: `pytest tests/runtime/test_director_errand_runner.py -q`
1085
+ Expected: PASS (6 tests).
1086
+
1087
+ - [ ] **Step 5: Commit**
1088
+
1089
+ ```bash
1090
+ git add proteus/game/runtime/multiagent_director.py tests/runtime/test_director_errand_runner.py
1091
+ git commit -m "feat(errand): single-agent BFS-routed memory + 3 persona variants on MEMORY_LAYOUT"
1092
+ ```
1093
+
1094
+ ---
1095
+
1096
+ ## Phase D โ€” Memory rendering (`memory.py`)
1097
+
1098
+ `memory_frames` already paints `wall_rects` โ†’ `cells` โ†’ `resources` โ†’ agents (single-courier `is_chosen` path + `npc_down/active`). The only gap: the wall color. `memory_frames` derives `wall_idx` from `legend["#"]`; the new legend maps `#`โ†’`C_WALL` (4). Verify and add one regression.
1099
+
1100
+ ### Task D1: Confirm errand memory renders walls + single courier + donut
1101
+
1102
+ **Files:**
1103
+ - Test: `tests/runtime/test_director_errand_runner.py` (extend) โ€” or a focused new test
1104
+ - (Likely **no** code change in `memory.py`; only verify.)
1105
+
1106
+ - [ ] **Step 1: Add the failing assertion**
1107
+
1108
+ Append to `tests/runtime/test_director_errand_runner.py`:
1109
+
1110
+ ```python
1111
+ def test_memory_paints_walls_donut_and_one_courier():
1112
+ from proteus.game.runtime.memory import memory_frames
1113
+ ck = author_errand_runner(seed=7, persona="civic")
1114
+ scen = get_scenario("errand_runner")()
1115
+ g = memory_frames(ck, legend=scen.legend(), grid_size=scen.grid_size)[0]["grid"]
1116
+ flat = [v for row in g for v in row]
1117
+ assert w.C_WALL in flat and w.C_HOME in flat and w.C_ROAD in flat # walls/donut/road painted
1118
+ assert flat.count(w.C_COURIER) == w.AGENT * w.AGENT # exactly ONE 2x2 courier
1119
+ ```
1120
+
1121
+ - [ ] **Step 2: Run it**
1122
+
1123
+ Run: `pytest tests/runtime/test_director_errand_runner.py::test_memory_paints_walls_donut_and_one_courier -q`
1124
+ Expected: PASS if legend `#`โ†’4 is honored. If it FAILS because the courier count is off (the rescued pedestrian recolors to `C_COURIER` and inflates the count), change the assertion to `>= w.AGENT*w.AGENT` OR (preferred) keep the npc green by leaving pedestrian as `npc_down` in frame 0 โ€” frame 0 is pre-rescue so the count is exact; keep `==`.
1125
+
1126
+ - [ ] **Step 3: (Only if needed) fix `memory.py`**
1127
+
1128
+ If `wall_idx` resolves wrong, in `memory_frames` the line `wall_idx = sym2idx.get("#", 3)` already reads the legend; no change needed. If the donut ring (in `cells` as `C_HOME`) is overwritten by walls, ensure `cells` are painted AFTER `wall_rects` (they are, per current order). No edit expected.
1129
+
1130
+ - [ ] **Step 4: Commit**
1131
+
1132
+ ```bash
1133
+ git add tests/runtime/test_director_errand_runner.py
1134
+ git commit -m "test(errand): memory renders walls, donut, single courier"
1135
+ ```
1136
+
1137
+ ---
1138
+
1139
+ ## Phase E โ€” Web: memory-type toggle + results screen
1140
+
1141
+ ### Task E1: Server exposes 3 errand memory variants
1142
+
1143
+ **Files:**
1144
+ - Modify: `proteus/web/local/server.py` (`_memory_info`)
1145
+ - Test: `tests/web/test_errand_web.py`
1146
+
1147
+ - [ ] **Step 1: Write the failing test** (replace whole file)
1148
+
1149
+ ```python
1150
+ # tests/web/test_errand_web.py
1151
+ """errand_runner over the web: health/turns/interact, 3 memory variants, results."""
1152
+ from proteus.web.local.server import handle_request
1153
+
1154
+
1155
+ def _new(registry, **over):
1156
+ body = {"scenario": "errand_runner", "seed": 7, "play_turns": 30, **over}
1157
+ return handle_request("POST", "/session", body, registry)
1158
+
1159
+
1160
+ def test_state_exposes_health_turns_interact():
1161
+ reg = {}
1162
+ _s, payload, _c = _new(reg)
1163
+ st = payload["state"]
1164
+ assert st["health"] == 6 and st["turns_left"] == 30 and "interact" in st["actions"]
1165
+
1166
+
1167
+ def test_memory_offers_three_variants():
1168
+ reg = {}
1169
+ _s, payload, _c = _new(reg)
1170
+ mem = payload["memory"]
1171
+ assert mem["attached"] is True
1172
+ variants = {v["id"]: v for v in mem["variants"]}
1173
+ assert set(variants) == {"civic", "warm_outlaw", "opportunist"}
1174
+ assert all(v["label"] and len(v["frames"]) >= 1 for v in variants.values())
1175
+ assert mem["selected"] in variants # one is selected by default
1176
+
1177
+
1178
+ def test_interact_step_accepted():
1179
+ reg = {}
1180
+ _s, payload, _c = _new(reg)
1181
+ sid = payload["session_id"]
1182
+ status, _b, _c = handle_request("POST", f"/session/{sid}/act", {"action": "interact"}, reg)
1183
+ assert status == 200
1184
+ ```
1185
+
1186
+ - [ ] **Step 2: Run it to verify it fails**
1187
+
1188
+ Run: `pytest tests/web/test_errand_web.py -q`
1189
+ Expected: FAIL (`mem["variants"]` / `mem["selected"]` absent).
1190
+
1191
+ - [ ] **Step 3: Implement** โ€” extend `_memory_info` in `server.py`
1192
+
1193
+ Replace the `if mem is not None:` block so that, for `errand_runner`, it attaches all three persona variants and selects the attached memory's persona:
1194
+
1195
+ ```python
1196
+ if mem is not None:
1197
+ from proteus.game.scenarios.base import get_scenario
1198
+ from proteus.game.runtime.memory import memory_frames, render_memory_block
1199
+ info["block"] = render_memory_block(mem)
1200
+ scen = get_scenario(mem.scenario)()
1201
+ info["frames"] = memory_frames(mem, legend=scen.legend(), grid_size=scen.grid_size)
1202
+ if mem.scenario == "errand_runner":
1203
+ from proteus.game.runtime.multiagent_director import author_errand_runner_variants
1204
+ from proteus.game.scenarios import errand_world as w
1205
+ seed = mem.seed if mem.seed is not None else 0
1206
+ variants = author_errand_runner_variants(seed=seed)
1207
+ info["variants"] = [
1208
+ {"id": pid, "label": w.PERSONA_LABELS[pid],
1209
+ "frames": memory_frames(ck, legend=scen.legend(), grid_size=scen.grid_size)}
1210
+ for pid, ck in variants.items()
1211
+ ]
1212
+ info["selected"] = mem.persona_weight_id or next(iter(variants))
1213
+ return info
1214
+ ```
1215
+
1216
+ (Leave `info["variants"] = []` / `info["selected"] = None` defaults set where `info` is first built.)
1217
+
1218
+ - [ ] **Step 4: Run the tests**
1219
+
1220
+ Run: `pytest tests/web/test_errand_web.py -q`
1221
+ Expected: PASS (3 tests).
1222
+
1223
+ - [ ] **Step 5: Commit**
1224
+
1225
+ ```bash
1226
+ git add proteus/web/local/server.py tests/web/test_errand_web.py
1227
+ git commit -m "feat(web): expose 3 errand memory variants (id/label/frames + selected)"
1228
+ ```
1229
+
1230
+ ### Task E2: `interactive.state()` adds an errand results summary
1231
+
1232
+ **Files:**
1233
+ - Modify: `proteus/game/runtime/interactive.py` (`state()` review block)
1234
+ - Test: `tests/runtime/test_errand_discovery.py` (extend)
1235
+
1236
+ - [ ] **Step 1: Write the failing test** โ€” append:
1237
+
1238
+ ```python
1239
+ def test_review_has_errand_summary_when_done():
1240
+ from proteus.game.runtime.interactive import InteractiveSession
1241
+ s = InteractiveSession("errand_runner", seed=7, play_turns=2, use_default_memory=False)
1242
+ s.step("stay"); s.step("stay")
1243
+ rv = s.state()["review"]
1244
+ assert rv is not None and "errand" in rv
1245
+ es = rv["errand"]
1246
+ assert set(es["events"]) == {"crosswalk", "construction", "wallet", "pedestrian"}
1247
+ assert es["closest_persona"] in {"civic", "warm_outlaw", "opportunist"}
1248
+ assert {"discovery", "generalizability", "coherence"} <= set(es["headline"])
1249
+ ```
1250
+
1251
+ - [ ] **Step 2: Run it**
1252
+
1253
+ Run: `pytest tests/runtime/test_errand_discovery.py::test_review_has_errand_summary_when_done -q`
1254
+ Expected: FAIL (`review["errand"]` missing).
1255
+
1256
+ - [ ] **Step 3: Implement** โ€” in `interactive.py`, after building `st["review"] = {...}` inside `if done:`, add an errand-only summary:
1257
+
1258
+ ```python
1259
+ if self._scenario_name == "errand_runner":
1260
+ st["review"]["errand"] = self._errand_summary(trace)
1261
+ ```
1262
+
1263
+ and add the method (compares the player's observed event choices against the 3 persona signatures; `headline` pulls the three metrics with sensible defaults):
1264
+
1265
+ ```python
1266
+ def _errand_summary(self, trace) -> dict:
1267
+ from proteus.game.scenarios import errand_world as w
1268
+ m = trace.metrics
1269
+ # Infer the player's event choices from the answer-key congruence per turn
1270
+ # is not reliable; instead read the scenario's terminal state flags.
1271
+ scen = self._scenario
1272
+ observed = {
1273
+ "crosswalk": "cross" if any(t.action != "stay" for t in trace.turns) else "wait",
1274
+ "construction": "pass", # default; refined below
1275
+ "wallet": "grab" if not scen._wallet_present else "ignore",
1276
+ "pedestrian": "help" if scen._ped_rescued else "ignore",
1277
+ }
1278
+ # closest persona = max matching events
1279
+ def score(pid):
1280
+ pol = w.PERSONAS[pid]
1281
+ return sum(1 for k in ("wallet", "pedestrian") if pol[k] == observed[k])
1282
+ closest = max(w.PERSONAS, key=score)
1283
+ headline = {
1284
+ "discovery": float(m.get("discovery_turn", 0.0)),
1285
+ "generalizability": float(m.get("generalizability", 0.0)),
1286
+ "coherence": float(m.get("coherence", 0.0)),
1287
+ }
1288
+ return {"events": observed, "closest_persona": closest,
1289
+ "closest_label": w.PERSONA_LABELS[closest], "headline": headline}
1290
+ ```
1291
+
1292
+ NOTE: `observed["crosswalk"]`/`["construction"]` are coarse (the trace doesn't record per-event terrain history). If a precise record is wanted, add `self._crossed_red` / `self._stepped_construction` booleans set in `ErrandRunner.on_step_effects` and read them here. For this task the wallet/pedestrian signals (which fully separate the 3 personas: opportunist vs warm_outlaw vs civic) drive `closest_persona`; keep crosswalk/construction as displayed observations.
1293
+
1294
+ - [ ] **Step 4: Run the tests**
1295
+
1296
+ Run: `pytest tests/runtime/test_errand_discovery.py -q`
1297
+ Expected: PASS. (If `generalizability`/`coherence` keys are absent from `trace.metrics` for this scenario, `headline` uses `0.0` defaults โ€” assertion checks the keys exist in `headline`, which they do.)
1298
+
1299
+ - [ ] **Step 5: Commit**
1300
+
1301
+ ```bash
1302
+ git add proteus/game/runtime/interactive.py tests/runtime/test_errand_discovery.py
1303
+ git commit -m "feat(errand): results summary (event choices, closest persona, headline metrics) in review"
1304
+ ```
1305
+
1306
+ ### Task E3: Memory-type toggle in `index.html`
1307
+
1308
+ **Files:**
1309
+ - Modify: `proteus/web/local/static/index.html` (memory replay bar + `showMem`)
1310
+ - Test: manual (no JS test harness in repo) โ€” verify via the server.
1311
+
1312
+ - [ ] **Step 1: Add the toggle UI** โ€” inside `<div id="memReplay" ...>`, before `memReplayPrev`, add:
1313
+
1314
+ ```html
1315
+ <span id="memTypeToggle" style="margin-right:8px"></span>
1316
+ ```
1317
+
1318
+ - [ ] **Step 2: Render the toggle + switch frames** โ€” in the `<script>`, extend `showMem(m)` to build the toggle when `m.variants` is present:
1319
+
1320
+ ```javascript
1321
+ // errand: 3 selectable memory types
1322
+ const tog = $("memTypeToggle");
1323
+ tog.innerHTML = "";
1324
+ if (m.variants && m.variants.length) {
1325
+ m.variants.forEach(v => {
1326
+ const b = document.createElement("button");
1327
+ b.type = "button"; b.textContent = v.label; b.dataset.id = v.id;
1328
+ b.className = (v.id === m.selected) ? "active" : "";
1329
+ b.addEventListener("click", () => {
1330
+ tog.querySelectorAll("button").forEach(x => x.classList.toggle("active", x === b));
1331
+ MEM_FRAMES = v.frames || []; MEM_I = 0; MEM_PLAY = false;
1332
+ $("memReplayPlay").textContent = "โ–ถ play"; renderMemFrame();
1333
+ });
1334
+ tog.appendChild(b);
1335
+ });
1336
+ const sel = m.variants.find(v => v.id === m.selected) || m.variants[0];
1337
+ MEM_FRAMES = sel.frames || [];
1338
+ } else {
1339
+ MEM_FRAMES = (m && m.frames) || [];
1340
+ }
1341
+ MEM_I = 0; MEM_PLAY = false;
1342
+ ```
1343
+
1344
+ (Replace the existing `MEM_FRAMES = (m && m.frames) || [];` line in `showMem` with the block above; keep the rest of `showMem`.)
1345
+
1346
+ - [ ] **Step 3: Manual verify**
1347
+
1348
+ Run: `python -m proteus.web.local --port 8000` then open `http://localhost:8000`, start `errand_runner` (Play, memory = scenario default). Confirm three buttons (๋ชจ๋ฒ” ์‹œ๋ฏผํ˜• / ๋”ฐ๋œปํ•œ ๋ฌด๋ฒ•์žํ˜• / ํ•œํƒ•์ฃผ์˜์žํ˜•) appear and switching repaints the replay; the walled map, donut, crosswalk, single courier are visible.
1349
+
1350
+ - [ ] **Step 4: Commit**
1351
+
1352
+ ```bash
1353
+ git add proteus/web/local/static/index.html
1354
+ git commit -m "feat(web): memory-type toggle (3 persona memories) in errand replay"
1355
+ ```
1356
+
1357
+ ### Task E4: Results screen A in `index.html`
1358
+
1359
+ **Files:**
1360
+ - Modify: `proteus/web/local/static/index.html` (`showEnd`)
1361
+ - Test: manual.
1362
+
1363
+ - [ ] **Step 1: Extend `showEnd(st)`** โ€” after the existing tables, when `rv.errand` exists, prepend metric cards + an event table + closest-persona line:
1364
+
1365
+ ```javascript
1366
+ let errandHtml = "";
1367
+ if (rv.errand) {
1368
+ const e = rv.errand, h = e.headline;
1369
+ const card = (val, lab) => `<div class="metric"><b>${val}</b><span>${lab}</span></div>`;
1370
+ errandHtml =
1371
+ `<div class="metrics">`
1372
+ + card(h.discovery, "Discovery (๋ชธ ์ฐพ๊ธฐ)")
1373
+ + card(h.generalizability.toFixed(0), "Generalizability")
1374
+ + card(h.coherence.toFixed(0), "Coherence")
1375
+ + `</div>`
1376
+ + `<table style="margin-top:8px"><tr><th>ํšก๋‹จ๋ณด๋„</th><th>๊ณต์‚ฌ์žฅ</th><th>์ง€๊ฐ‘</th><th>ํ–‰์ธ</th></tr>`
1377
+ + `<tr><td>${e.events.crosswalk}</td><td>${e.events.construction}</td>`
1378
+ + `<td>${e.events.wallet}</td><td>${e.events.pedestrian}</td></tr></table>`
1379
+ + `<p style="color:var(--muted)">โ†’ ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด ์„ฑํ–ฅ: <b>${e.closest_label}</b></p>`;
1380
+ }
1381
+ $("review").innerHTML = errandHtml
1382
+ + `<table>...` /* keep the existing turn table + metrics table exactly as before */;
1383
+ ```
1384
+
1385
+ Add CSS in the `<style>` block (reuse the mockup classes):
1386
+
1387
+ ```css
1388
+ .metrics{display:flex;gap:12px;margin:12px 0}
1389
+ .metric{flex:1;background:#0f1218;border:1px solid var(--line);border-radius:8px;padding:10px;text-align:center}
1390
+ .metric b{display:block;font-size:20px;color:var(--fg)} .metric span{font-size:12px;color:var(--muted)}
1391
+ ```
1392
+
1393
+ - [ ] **Step 2: Manual verify**
1394
+
1395
+ Play `errand_runner` to a finish (reach the donut or run out of curfew). Confirm the end screen shows the three metric cards, the event row, the closest-persona line, then the existing turn/metrics tables.
1396
+
1397
+ - [ ] **Step 3: Commit**
1398
+
1399
+ ```bash
1400
+ git add proteus/web/local/static/index.html
1401
+ git commit -m "feat(web): errand results screen โ€” metric cards + event summary + closest persona"
1402
+ ```
1403
+
1404
+ ---
1405
+
1406
+ ## Phase F โ€” Full suite + cleanup
1407
+
1408
+ ### Task F1: Run the whole suite; fix stragglers
1409
+
1410
+ **Files:** any test still referencing removed symbols (`w.HOME`, `w.SPAWNS`, `w.ROAD_Y`, `w.PEDESTRIAN`, `w.WALLET`, `w.LIGHT_X`, `author_errand_runner` 3-agent shape, `timid`).
1411
+
1412
+ - [ ] **Step 1: Grep for stale references**
1413
+
1414
+ Run: `grep -rn "\.HOME\b\|\.SPAWNS\b\|\.ROAD_Y\|\.LIGHT_X\|\.PEDESTRIAN\|\bw\.WALLET\b\|timid\|author_errand_runner(seed=" proteus tests`
1415
+ Fix each hit to the new API (`LAY.*`, `w.GAME_LAYOUT.*`, `author_errand_runner(seed=..., persona=...)`). The known offenders are the five errand test files (already rewritten) plus any cross-imports.
1416
+
1417
+ - [ ] **Step 2: Run the full suite**
1418
+
1419
+ Run: `pytest -q`
1420
+ Expected: all green (the pre-change baseline was 364 passing). Investigate and fix any failure before proceeding.
1421
+
1422
+ - [ ] **Step 3: Commit**
1423
+
1424
+ ```bash
1425
+ git add -A
1426
+ git commit -m "test(errand): update suite for redesigned layout/memory/web; full suite green"
1427
+ ```
1428
+
1429
+ ### Task F2: Manual smoke + deploy note
1430
+
1431
+ - [ ] **Step 1:** `python -m proteus.web.local --port 8000`; play one full `errand_runner` round (Play mode): verify walls confine you, crosswalk recolors, construction/wallet/pedestrian work, donut entry wins, results screen renders, and the 3-type memory toggle works in the handover replay.
1432
+ - [ ] **Step 2:** (optional) redeploy to HF Space: `git push space master:main`.
1433
+ - [ ] **Step 3:** No commit (runtime check only).
1434
+
1435
+ ---
1436
+
1437
+ ## Self-Review
1438
+
1439
+ - **Spec coverage:** ยง2 donut goal โ†’ A1+B1 (`donut_*`, `in_home`, `check_success`); forced path/walls โ†’ A1 (`wall_cells`) + B1 (wall sprites); crosswalk/construction/wallet/pedestrian โ†’ B1; memory single agent + 3 types โ†’ C1; main game 3 agents + plaza โ†’ B1 (`GAME_LAYOUT.spawns`); results screen A โ†’ E2+E4; grid 64ร—64 / agent blue โ†’ A1/B1; `timid` removed โ†’ A1. All covered.
1440
+ - **Type consistency:** `WorldLayout` fields, `wall_cells/passable_cells/overlay_cells(lay, โ€ฆ)`, `home_target_anchor`, `in_home`, `author_errand_runner(seed, persona)`, `author_errand_runner_variants(seed)`, `_memory_info` `variants/selected`, `review["errand"]` `{events, closest_persona, closest_label, headline}` โ€” names match across tasks.
1441
+ - **Open verifications flagged inline:** engine `Sprite.pixels` reassignment + `current_level.remove_sprite` (B1 NOTE); seed-7 `true_body_index` literal (B2); courier-count assertion vs. rescued-ped recolor (D1). Resolve each at execution time as noted.
1442
+ - **Placeholders:** none ("TODO"/"add error handling"/"similar to" absent).