Spaces:
Runtime error
Runtime error
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).
|