Spaces:
Sleeping
Sleeping
Commit ·
2e262ce
1
Parent(s): 41e09e0
docs: implementation plan for multi-agent memory + predator visuals (+ spec resource_race turn-order fix)
Browse files
docs/superpowers/plans/2026-06-03-multiagent-memory-and-predator-visuals.md
ADDED
|
@@ -0,0 +1,1618 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Multi-Agent Memory + Predator/Agent Visual & Turn-Order Refresh — 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:** Give the predator game a 2×2 agent / 3×3 open-mouth (ㄷ) predator look with predator-first turns, and add two scripted multi-agent "memory" scenarios (pack-flee survivor, resource-race winner) that feed the scored single-focal benchmark.
|
| 6 |
+
|
| 7 |
+
**Architecture:** Multi-agent complexity is confined to memory generation + memory rendering; the live `MotiveGridGame` and scored query stay single-focal. Turn order is a per-scenario `turn_order` flag that swaps two operations inside `grid.step()`. The chosen survivor/winner is authored to embody a persona (`risk_averse` / `greedy`); the existing persona machinery scores the query.
|
| 8 |
+
|
| 9 |
+
**Tech Stack:** Python 3, pydantic (memory models), numpy (engine only — `memory.py` stays numpy-free), pytest. Spec: `docs/superpowers/specs/2026-06-03-multiagent-memory-and-predator-visuals-design.md`.
|
| 10 |
+
|
| 11 |
+
**Conventions for every task:** run tests with `uv run pytest`. `(dx,dy)`: y grows DOWN. Sprite `(x,y)` is the top-left anchor; a transparent pixel is `-1`.
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## Phase 1 — Turn order + visuals
|
| 16 |
+
|
| 17 |
+
### Task 1: `Scenario.turn_order` hook + predator-first swap in `step()`
|
| 18 |
+
|
| 19 |
+
**Files:**
|
| 20 |
+
- Modify: `proteus/game/scenarios/base.py` (add class attribute)
|
| 21 |
+
- Modify: `proteus/game/engine/grid.py:147-177` (`step()`)
|
| 22 |
+
- Test: `tests/engine/test_turn_order.py` (create)
|
| 23 |
+
|
| 24 |
+
- [ ] **Step 1: Write the failing test**
|
| 25 |
+
|
| 26 |
+
```python
|
| 27 |
+
# tests/engine/test_turn_order.py
|
| 28 |
+
"""predator-first reorders the turn: advance_threat sees the focal's PRE-move cell."""
|
| 29 |
+
import random
|
| 30 |
+
|
| 31 |
+
from proteus.game.engine.difficulty import Difficulty
|
| 32 |
+
from proteus.game.engine.grid import MotiveGridGame
|
| 33 |
+
from proteus.game.scenarios.base import get_scenario
|
| 34 |
+
import proteus.game.scenarios # noqa: F401 (register scenarios)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _focal_cell_seen_by_threat(turn_order: str, action: str = "up"):
|
| 38 |
+
scen = get_scenario("predator_evade")()
|
| 39 |
+
scen.turn_order = turn_order # instance override
|
| 40 |
+
game = MotiveGridGame(scen, random.Random(0), Difficulty.EASY, max_steps=10)
|
| 41 |
+
seen = {}
|
| 42 |
+
real_advance = scen.advance_threat
|
| 43 |
+
|
| 44 |
+
def spy(g):
|
| 45 |
+
f = g.focal_sprite
|
| 46 |
+
seen["focal"] = (f.x, f.y)
|
| 47 |
+
real_advance(g)
|
| 48 |
+
|
| 49 |
+
scen.advance_threat = spy # shadow the bound method on this instance
|
| 50 |
+
before = (game.focal_sprite.x, game.focal_sprite.y)
|
| 51 |
+
game.apply_motive_action(action)
|
| 52 |
+
return before, seen["focal"]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_predator_first_threat_sees_premove_focal():
|
| 56 |
+
before, seen = _focal_cell_seen_by_threat("predator_first", "up")
|
| 57 |
+
assert seen == before # predator advanced BEFORE the focal moved
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_focal_first_threat_sees_postmove_focal():
|
| 61 |
+
before, seen = _focal_cell_seen_by_threat("focal_first", "up")
|
| 62 |
+
assert seen != before # legacy: focal moved first, predator chased the new cell
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
- [ ] **Step 2: Run test to verify it fails**
|
| 66 |
+
|
| 67 |
+
Run: `uv run pytest tests/engine/test_turn_order.py -v`
|
| 68 |
+
Expected: FAIL — `test_predator_first_...` fails (default is focal-first, so `seen != before`).
|
| 69 |
+
|
| 70 |
+
- [ ] **Step 3a: Add the `turn_order` attribute**
|
| 71 |
+
|
| 72 |
+
In `proteus/game/scenarios/base.py`, inside `class Scenario(ABC)`, right after `memory_brief: str = ""` (line ~68) add:
|
| 73 |
+
|
| 74 |
+
```python
|
| 75 |
+
# Turn resolution order. "focal_first" (default): focal moves, then the
|
| 76 |
+
# threat advances (chasing the focal's NEW cell). "predator_first": the
|
| 77 |
+
# threat advances first (chasing the focal's CURRENT cell), then the focal
|
| 78 |
+
# moves. Scenarios override this; predator_evade keeps the default so its
|
| 79 |
+
# dead-end diagnostic is preserved.
|
| 80 |
+
turn_order: str = "focal_first"
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
- [ ] **Step 3b: Swap the order in `step()`**
|
| 84 |
+
|
| 85 |
+
In `proteus/game/engine/grid.py`, replace the body of `step()` (lines 147-177) with:
|
| 86 |
+
|
| 87 |
+
```python
|
| 88 |
+
def step(self) -> None:
|
| 89 |
+
"""Resolve a single turn, honouring ``scenario.turn_order``.
|
| 90 |
+
|
| 91 |
+
focal_first (default): move focal, then advance threat (chasing the new
|
| 92 |
+
focal cell). predator_first: advance threat first (chasing the current
|
| 93 |
+
focal cell), then move focal. Capture/survival are checked once, after
|
| 94 |
+
both have moved. Always completes the action so the engine loop ends.
|
| 95 |
+
"""
|
| 96 |
+
action = _GAMEACTION_TO_ACTION.get(self._action.id, "stay")
|
| 97 |
+
dx, dy = _DIRECTION_DELTAS[action]
|
| 98 |
+
focal = self.focal_sprite
|
| 99 |
+
predator_first = (
|
| 100 |
+
getattr(self.scenario, "turn_order", "focal_first") == "predator_first"
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
if predator_first:
|
| 104 |
+
self.scenario.advance_threat(self)
|
| 105 |
+
|
| 106 |
+
if (
|
| 107 |
+
focal is not None
|
| 108 |
+
and (dx != 0 or dy != 0)
|
| 109 |
+
and self._footprint_in_bounds(focal, dx, dy)
|
| 110 |
+
):
|
| 111 |
+
self.try_move_sprite(focal, dx, dy)
|
| 112 |
+
|
| 113 |
+
if not predator_first:
|
| 114 |
+
self.scenario.advance_threat(self)
|
| 115 |
+
|
| 116 |
+
self.step_count += 1
|
| 117 |
+
|
| 118 |
+
if self.scenario.check_elimination(self):
|
| 119 |
+
self.lose()
|
| 120 |
+
elif self.step_count >= self.max_steps:
|
| 121 |
+
self.win()
|
| 122 |
+
|
| 123 |
+
self.complete_action()
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
- [ ] **Step 4: Run tests to verify they pass**
|
| 127 |
+
|
| 128 |
+
Run: `uv run pytest tests/engine/test_turn_order.py -v`
|
| 129 |
+
Expected: PASS (both).
|
| 130 |
+
|
| 131 |
+
- [ ] **Step 5: Guard against regressions in the existing suite**
|
| 132 |
+
|
| 133 |
+
Run: `uv run pytest tests/runtime tests/agents -q`
|
| 134 |
+
Expected: PASS (predator_evade is still focal_first; no behaviour change).
|
| 135 |
+
|
| 136 |
+
- [ ] **Step 6: Commit**
|
| 137 |
+
|
| 138 |
+
```bash
|
| 139 |
+
git add proteus/game/scenarios/base.py proteus/game/engine/grid.py tests/engine/test_turn_order.py
|
| 140 |
+
git commit -m "feat(engine): per-scenario turn_order with predator-first step swap"
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
---
|
| 144 |
+
|
| 145 |
+
### Task 2: Resize pack_evade — 2×2 focal, 3×3 ㄷ predator (mouth faces movement), gap=2, predator-first
|
| 146 |
+
|
| 147 |
+
**Files:**
|
| 148 |
+
- Modify: `proteus/game/scenarios/pack_evade.py` (constants + `build_level` + `advance_threat` + center math + `turn_order`)
|
| 149 |
+
- Test: `tests/scenarios/test_pack_evade_resize.py` (create)
|
| 150 |
+
- Test (existing): re-run pack_evade tests
|
| 151 |
+
|
| 152 |
+
This task replaces the hard-coded 3×3/5×5 geometry. Apply each edit exactly.
|
| 153 |
+
|
| 154 |
+
- [ ] **Step 1: Write the failing test**
|
| 155 |
+
|
| 156 |
+
```python
|
| 157 |
+
# tests/scenarios/test_pack_evade_resize.py
|
| 158 |
+
"""pack_evade after resize: 2x2 focal, 3x3 open-mouth predator, gap-2 channels."""
|
| 159 |
+
import random
|
| 160 |
+
|
| 161 |
+
from proteus.game.engine.difficulty import Difficulty
|
| 162 |
+
from proteus.game.engine.grid import MotiveGridGame
|
| 163 |
+
from proteus.game.scenarios.base import get_scenario
|
| 164 |
+
import proteus.game.scenarios # noqa: F401
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _game(diff=Difficulty.EASY, seed=42):
|
| 168 |
+
scen = get_scenario("pack_evade")()
|
| 169 |
+
return scen, MotiveGridGame(scen, random.Random(seed), diff, max_steps=50)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def test_sprite_sizes():
|
| 173 |
+
scen, game = _game()
|
| 174 |
+
assert (game.focal_sprite.width, game.focal_sprite.height) == (2, 2)
|
| 175 |
+
assert (game.predator_sprite.width, game.predator_sprite.height) == (3, 3)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def test_predator_has_open_mouth():
|
| 179 |
+
scen, game = _game()
|
| 180 |
+
# ã„· shape: 9-cell bounding box, exactly 7 solid (2 transparent mouth cells).
|
| 181 |
+
pix = game.predator_sprite.render()
|
| 182 |
+
assert (pix != -1).sum() == 7
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def test_turn_order_is_predator_first():
|
| 186 |
+
scen, _ = _game()
|
| 187 |
+
assert scen.turn_order == "predator_first"
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def test_mouth_faces_movement_direction():
|
| 191 |
+
scen, game = _game()
|
| 192 |
+
# Force the predator to take a known step and assert its rotation updates.
|
| 193 |
+
pred = game.predator_sprite
|
| 194 |
+
pred.set_position(30, 30)
|
| 195 |
+
game.focal_sprite.set_position(30, 10) # straight up from predator
|
| 196 |
+
scen.advance_threat(game)
|
| 197 |
+
# Moving up => rotation 270 (see _FACING in pack_evade).
|
| 198 |
+
assert game.predator_sprite.rotation == 270
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def test_narrow_gap_blocks_predator_admits_focal():
|
| 202 |
+
scen, game = _game()
|
| 203 |
+
scen.build_level(random.Random(0), Difficulty.EASY)
|
| 204 |
+
# A width-2 free corridor: 2-wide focal footprint fits, 3-wide predator does not.
|
| 205 |
+
# Use the scenario primitive on a synthetic 2-wide gap between two walls.
|
| 206 |
+
walls = {(10, y) for y in range(0, 20)} | {(13, y) for y in range(0, 20)}
|
| 207 |
+
scen._wall_cells = frozenset(walls)
|
| 208 |
+
# focal anchor (11,5): footprint x in {11,12} — clear of both wall columns.
|
| 209 |
+
assert scen._footprint_free(game, game.focal_sprite, 11, 5) is True
|
| 210 |
+
# predator anchor (11,5): footprint x in {11,12,13} — hits wall column 13.
|
| 211 |
+
assert scen._footprint_free(game, game.predator_sprite, 11, 5) is False
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
- [ ] **Step 2: Run test to verify it fails**
|
| 215 |
+
|
| 216 |
+
Run: `uv run pytest tests/scenarios/test_pack_evade_resize.py -v`
|
| 217 |
+
Expected: FAIL (`test_sprite_sizes` — focal is currently 3×3).
|
| 218 |
+
|
| 219 |
+
- [ ] **Step 3a: Update module constants**
|
| 220 |
+
|
| 221 |
+
In `proteus/game/scenarios/pack_evade.py`, replace lines 33-36 and 47-63 region. Concretely:
|
| 222 |
+
|
| 223 |
+
Replace
|
| 224 |
+
```python
|
| 225 |
+
_GRID = (64, 64)
|
| 226 |
+
_FOCAL_START = (5, 30) # 3x3 -> footprint x[5,8) y[30,33), center (6,31)
|
| 227 |
+
_PREDATOR_START = (54, 29) # 5x5 -> footprint x[54,59) y[29,34), center (56,31)
|
| 228 |
+
```
|
| 229 |
+
with
|
| 230 |
+
```python
|
| 231 |
+
_GRID = (64, 64)
|
| 232 |
+
_FOCAL_SIZE = 2
|
| 233 |
+
_PREDATOR_SIZE = 3
|
| 234 |
+
_FOCAL_START = (5, 30) # 2x2 -> footprint x[5,7) y[30,32), center (6,31)
|
| 235 |
+
_PREDATOR_START = (54, 30) # 3x3 -> footprint x[54,57) y[30,33), center (55,31)
|
| 236 |
+
# ã„· (open-mouth) predator pixels; mouth opens EAST in the base orientation.
|
| 237 |
+
# Transparent (-1) cells render as background and never collide.
|
| 238 |
+
_PREDATOR_PIXELS = [
|
| 239 |
+
[PREDATOR_IDX, PREDATOR_IDX, PREDATOR_IDX],
|
| 240 |
+
[PREDATOR_IDX, -1, -1],
|
| 241 |
+
[PREDATOR_IDX, PREDATOR_IDX, PREDATOR_IDX],
|
| 242 |
+
]
|
| 243 |
+
# action -> clockwise rotation so the mouth faces the movement direction.
|
| 244 |
+
_FACING = {"right": 0, "down": 90, "left": 180, "up": 270}
|
| 245 |
+
```
|
| 246 |
+
|
| 247 |
+
Replace
|
| 248 |
+
```python
|
| 249 |
+
_NARROW_GAP = 3 # focal (3-wide) fits; predator (5-wide) cannot
|
| 250 |
+
_CLEARANCE = 6 # min free corridor around blocks/spawns (predator-wide)
|
| 251 |
+
```
|
| 252 |
+
with
|
| 253 |
+
```python
|
| 254 |
+
_NARROW_GAP = 2 # focal (2-wide) fits; predator (3-wide) cannot
|
| 255 |
+
_CLEARANCE = 4 # min free corridor around blocks/spawns (> predator-wide)
|
| 256 |
+
```
|
| 257 |
+
|
| 258 |
+
Replace
|
| 259 |
+
```python
|
| 260 |
+
_SPAWN_RECTS = (
|
| 261 |
+
(_FOCAL_START[0], _FOCAL_START[1], _FOCAL_START[0] + 2, _FOCAL_START[1] + 2),
|
| 262 |
+
(_PREDATOR_START[0], _PREDATOR_START[1], _PREDATOR_START[0] + 4, _PREDATOR_START[1] + 4),
|
| 263 |
+
)
|
| 264 |
+
```
|
| 265 |
+
with
|
| 266 |
+
```python
|
| 267 |
+
_SPAWN_RECTS = (
|
| 268 |
+
(_FOCAL_START[0], _FOCAL_START[1],
|
| 269 |
+
_FOCAL_START[0] + _FOCAL_SIZE - 1, _FOCAL_START[1] + _FOCAL_SIZE - 1),
|
| 270 |
+
(_PREDATOR_START[0], _PREDATOR_START[1],
|
| 271 |
+
_PREDATOR_START[0] + _PREDATOR_SIZE - 1, _PREDATOR_START[1] + _PREDATOR_SIZE - 1),
|
| 272 |
+
)
|
| 273 |
+
```
|
| 274 |
+
|
| 275 |
+
Replace
|
| 276 |
+
```python
|
| 277 |
+
_FOCAL_CENTER = (_FOCAL_START[0] + 1, _FOCAL_START[1] + 1) # 3x3 center
|
| 278 |
+
_PREDATOR_CENTER = (_PREDATOR_START[0] + 2, _PREDATOR_START[1] + 2) # 5x5 center
|
| 279 |
+
```
|
| 280 |
+
with
|
| 281 |
+
```python
|
| 282 |
+
_FOCAL_CENTER = (_FOCAL_START[0] + _FOCAL_SIZE // 2, _FOCAL_START[1] + _FOCAL_SIZE // 2)
|
| 283 |
+
_PREDATOR_CENTER = (
|
| 284 |
+
_PREDATOR_START[0] + _PREDATOR_SIZE // 2,
|
| 285 |
+
_PREDATOR_START[1] + _PREDATOR_SIZE // 2,
|
| 286 |
+
)
|
| 287 |
+
```
|
| 288 |
+
|
| 289 |
+
- [ ] **Step 3b: Set the class `turn_order` + update `build_level` sprites**
|
| 290 |
+
|
| 291 |
+
In `class PackEvade`, add a class attribute next to `grid_size` (line ~115):
|
| 292 |
+
```python
|
| 293 |
+
turn_order: str = "predator_first"
|
| 294 |
+
```
|
| 295 |
+
|
| 296 |
+
In `build_level` replace the focal + predator sprite construction (lines 146-155) with:
|
| 297 |
+
```python
|
| 298 |
+
focal = Sprite(
|
| 299 |
+
pixels=[[FOCAL_IDX] * _FOCAL_SIZE for _ in range(_FOCAL_SIZE)],
|
| 300 |
+
name="focal", x=_FOCAL_START[0], y=_FOCAL_START[1],
|
| 301 |
+
blocking=BlockingMode.BOUNDING_BOX,
|
| 302 |
+
)
|
| 303 |
+
predator = Sprite(
|
| 304 |
+
pixels=[row[:] for row in _PREDATOR_PIXELS],
|
| 305 |
+
name="predator", x=_PREDATOR_START[0], y=_PREDATOR_START[1],
|
| 306 |
+
blocking=BlockingMode.NOT_BLOCKED,
|
| 307 |
+
)
|
| 308 |
+
```
|
| 309 |
+
|
| 310 |
+
- [ ] **Step 3c: Set rotation in `advance_threat`**
|
| 311 |
+
|
| 312 |
+
In `advance_threat` (lines 320-336) replace the final two lines:
|
| 313 |
+
```python
|
| 314 |
+
dx, dy = _DELTAS[best_action]
|
| 315 |
+
predator.move(dx, dy)
|
| 316 |
+
```
|
| 317 |
+
with
|
| 318 |
+
```python
|
| 319 |
+
dx, dy = _DELTAS[best_action]
|
| 320 |
+
predator.move(dx, dy)
|
| 321 |
+
if best_action in _FACING:
|
| 322 |
+
predator.set_rotation(_FACING[best_action])
|
| 323 |
+
```
|
| 324 |
+
|
| 325 |
+
- [ ] **Step 3d: Fix hard-coded predator-size center math**
|
| 326 |
+
|
| 327 |
+
In `step_reward` (line ~371) and `agent_distance_delta` (line ~395) replace both occurrences of:
|
| 328 |
+
```python
|
| 329 |
+
pred_center_before = (predator_before[0] + 5 // 2, predator_before[1] + 5 // 2)
|
| 330 |
+
```
|
| 331 |
+
with:
|
| 332 |
+
```python
|
| 333 |
+
pred_center_before = (
|
| 334 |
+
predator_before[0] + _PREDATOR_SIZE // 2,
|
| 335 |
+
predator_before[1] + _PREDATOR_SIZE // 2,
|
| 336 |
+
)
|
| 337 |
+
```
|
| 338 |
+
|
| 339 |
+
In `_generate_food`, the "behind the predator" comment mentions 5x5; no code change needed there (it uses `_PREDATOR_CENTER`). In `render_frame` the prose strings say "(3x3)"/"(5x5)" — update to `(2x2)`/`(3x3)` for accuracy (text only).
|
| 340 |
+
|
| 341 |
+
- [ ] **Step 4: Run the new test + the existing pack_evade suite**
|
| 342 |
+
|
| 343 |
+
Run: `uv run pytest tests/scenarios/test_pack_evade_resize.py tests/runtime/test_pack_evade_memory.py -v`
|
| 344 |
+
Expected: PASS. If a pre-existing pack_evade test pins the old 3-wide gap invariant, re-point its expected free-width from 3 to 2 (search for `_NARROW_GAP`/"3-wide" in tests).
|
| 345 |
+
|
| 346 |
+
- [ ] **Step 5: Commit**
|
| 347 |
+
|
| 348 |
+
```bash
|
| 349 |
+
git add proteus/game/scenarios/pack_evade.py tests/scenarios/test_pack_evade_resize.py
|
| 350 |
+
git commit -m "feat(pack_evade): 2x2 focal, 3x3 open-mouth predator, gap-2, predator-first"
|
| 351 |
+
```
|
| 352 |
+
|
| 353 |
+
---
|
| 354 |
+
|
| 355 |
+
## Phase 2 — Persona resource feature
|
| 356 |
+
|
| 357 |
+
### Task 3: Add `resource_reward` to personas + a `greedy` persona + resource-only reference policy
|
| 358 |
+
|
| 359 |
+
**Files:**
|
| 360 |
+
- Modify: `proteus/game/scenarios/base.py` (add `nearest_resource_distance` hook)
|
| 361 |
+
- Modify: `proteus/game/metrics/persona.py` (`PersonaWeights`, `reward_rw`, `reference_actions`, `BUILTIN_PERSONAS`)
|
| 362 |
+
- Test: `tests/metrics/test_persona_resource.py` (create)
|
| 363 |
+
|
| 364 |
+
- [ ] **Step 1: Write the failing test**
|
| 365 |
+
|
| 366 |
+
```python
|
| 367 |
+
# tests/metrics/test_persona_resource.py
|
| 368 |
+
"""Greedy persona seeks resources; reference works with no predator."""
|
| 369 |
+
from proteus.game.metrics.persona import (
|
| 370 |
+
PersonaWeights, get_persona, reference_actions, reward_rw,
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
class _FakeSprite:
|
| 375 |
+
def __init__(self, x, y):
|
| 376 |
+
self.x, self.y = x, y
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
class _ResourceWorld:
|
| 380 |
+
"""Minimal scenario+game stand-in: open 10x10, one resource at (9,5), no predator."""
|
| 381 |
+
grid_size = (10, 10)
|
| 382 |
+
_resource = (9, 5)
|
| 383 |
+
|
| 384 |
+
# --- scenario surface persona.py uses ---
|
| 385 |
+
def _is_free(self, game, cell):
|
| 386 |
+
x, y = cell
|
| 387 |
+
return 0 <= x < 10 and 0 <= y < 10
|
| 388 |
+
|
| 389 |
+
def nearest_resource_distance(self, game, cell):
|
| 390 |
+
return abs(cell[0] - self._resource[0]) + abs(cell[1] - self._resource[1])
|
| 391 |
+
|
| 392 |
+
# --- game surface persona.py uses ---
|
| 393 |
+
@property
|
| 394 |
+
def focal_sprite(self):
|
| 395 |
+
return _FakeSprite(5, 5)
|
| 396 |
+
|
| 397 |
+
@property
|
| 398 |
+
def predator_sprite(self):
|
| 399 |
+
return None
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
def test_greedy_reference_steps_toward_resource():
|
| 403 |
+
world = _ResourceWorld()
|
| 404 |
+
greedy = get_persona("greedy")
|
| 405 |
+
# focal at (5,5), resource at (9,5) -> "right" reduces distance most.
|
| 406 |
+
assert "right" in reference_actions(greedy, world, world)
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def test_reward_rw_no_predator_uses_resource_only():
|
| 410 |
+
world = _ResourceWorld()
|
| 411 |
+
greedy = get_persona("greedy")
|
| 412 |
+
r_right = reward_rw(greedy, world, world, (5, 5), (-1, -1), "right")
|
| 413 |
+
r_left = reward_rw(greedy, world, world, (5, 5), (-1, -1), "left")
|
| 414 |
+
assert r_right > r_left # closer to the resource scores higher
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def test_resource_reward_defaults_zero_for_existing_personas():
|
| 418 |
+
assert get_persona("risk_averse").resource_reward == 0.0
|
| 419 |
+
```
|
| 420 |
+
|
| 421 |
+
- [ ] **Step 2: Run test to verify it fails**
|
| 422 |
+
|
| 423 |
+
Run: `uv run pytest tests/metrics/test_persona_resource.py -v`
|
| 424 |
+
Expected: FAIL (`get_persona("greedy")` raises KeyError).
|
| 425 |
+
|
| 426 |
+
- [ ] **Step 3a: Add the scenario hook**
|
| 427 |
+
|
| 428 |
+
In `proteus/game/scenarios/base.py`, after `agent_distance_delta` (before the registry section, ~line 284) add:
|
| 429 |
+
|
| 430 |
+
```python
|
| 431 |
+
def nearest_resource_distance(self, game, cell) -> int | None:
|
| 432 |
+
"""Distance from *cell* (focal anchor) to the nearest collectible resource.
|
| 433 |
+
|
| 434 |
+
``None`` by default (no resources). Resource scenarios override this so
|
| 435 |
+
the persona reward's resource term can pull the reference toward the
|
| 436 |
+
nearest resource. Distance metric is the scenario's choice (BFS or
|
| 437 |
+
Manhattan); smaller = closer.
|
| 438 |
+
"""
|
| 439 |
+
del game, cell
|
| 440 |
+
return None
|
| 441 |
+
```
|
| 442 |
+
|
| 443 |
+
- [ ] **Step 3b: Extend `PersonaWeights`**
|
| 444 |
+
|
| 445 |
+
In `proteus/game/metrics/persona.py`, replace the dataclass fields (lines 46-49) with:
|
| 446 |
+
|
| 447 |
+
```python
|
| 448 |
+
persona_weight_id: str
|
| 449 |
+
risk_cost: float = 0.0
|
| 450 |
+
capture_penalty: float = 50.0
|
| 451 |
+
resource_reward: float = 0.0 # weight on proximity to the nearest resource
|
| 452 |
+
```
|
| 453 |
+
|
| 454 |
+
- [ ] **Step 3c: Rewrite `reward_rw` to make the predator optional + add the resource term**
|
| 455 |
+
|
| 456 |
+
Replace the body of `reward_rw` (lines 82-88) with:
|
| 457 |
+
|
| 458 |
+
```python
|
| 459 |
+
post = _post_focal(scenario, game, focal_before, action, blocked)
|
| 460 |
+
r = 0.0
|
| 461 |
+
if game.predator_sprite is not None:
|
| 462 |
+
d = scenario._bfs_distance(game, post, predator_before)
|
| 463 |
+
risk_exposure = 1.0 if d is None else 1.0 / (1.0 + d)
|
| 464 |
+
r -= weights.risk_cost * risk_exposure
|
| 465 |
+
if captured:
|
| 466 |
+
r -= weights.capture_penalty
|
| 467 |
+
if weights.resource_reward:
|
| 468 |
+
rd = scenario.nearest_resource_distance(game, post)
|
| 469 |
+
if rd is not None:
|
| 470 |
+
r += weights.resource_reward * (1.0 / (1.0 + rd))
|
| 471 |
+
return r
|
| 472 |
+
```
|
| 473 |
+
|
| 474 |
+
- [ ] **Step 3d: Make `reference_actions` tolerate a missing predator**
|
| 475 |
+
|
| 476 |
+
Replace lines 98-104 (the early-return + position read) with:
|
| 477 |
+
|
| 478 |
+
```python
|
| 479 |
+
focal = game.focal_sprite
|
| 480 |
+
if focal is None:
|
| 481 |
+
return ["stay"]
|
| 482 |
+
predator = game.predator_sprite
|
| 483 |
+
focal_before = (focal.x, focal.y)
|
| 484 |
+
predator_before = (predator.x, predator.y) if predator is not None else (-1, -1)
|
| 485 |
+
```
|
| 486 |
+
|
| 487 |
+
- [ ] **Step 3e: Register the `greedy` persona**
|
| 488 |
+
|
| 489 |
+
In `BUILTIN_PERSONAS` (lines 135-141) add after `survival_optimal`:
|
| 490 |
+
|
| 491 |
+
```python
|
| 492 |
+
"greedy": PersonaWeights(
|
| 493 |
+
persona_weight_id="greedy", risk_cost=1.0, resource_reward=6.0
|
| 494 |
+
),
|
| 495 |
+
```
|
| 496 |
+
|
| 497 |
+
- [ ] **Step 4: Run tests to verify they pass**
|
| 498 |
+
|
| 499 |
+
Run: `uv run pytest tests/metrics/test_persona_resource.py tests/runtime -q`
|
| 500 |
+
Expected: PASS (existing risk-only persona tests unaffected — predator branch unchanged when present).
|
| 501 |
+
|
| 502 |
+
- [ ] **Step 5: Commit**
|
| 503 |
+
|
| 504 |
+
```bash
|
| 505 |
+
git add proteus/game/scenarios/base.py proteus/game/metrics/persona.py tests/metrics/test_persona_resource.py
|
| 506 |
+
git commit -m "feat(persona): resource_reward feature + greedy persona + predator-optional reference"
|
| 507 |
+
```
|
| 508 |
+
|
| 509 |
+
---
|
| 510 |
+
|
| 511 |
+
## Phase 3 — Multi-agent memory data model + rendering
|
| 512 |
+
|
| 513 |
+
### Task 4: `AgentFrame` model + multi-agent fields on `MemoryTurn`/`MemoryCheckpoint`
|
| 514 |
+
|
| 515 |
+
**Files:**
|
| 516 |
+
- Modify: `proteus/game/runtime/memory.py` (models only in this task)
|
| 517 |
+
- Test: `tests/runtime/test_memory_multiagent_model.py` (create)
|
| 518 |
+
|
| 519 |
+
- [ ] **Step 1: Write the failing test**
|
| 520 |
+
|
| 521 |
+
```python
|
| 522 |
+
# tests/runtime/test_memory_multiagent_model.py
|
| 523 |
+
"""Multi-agent memory fields round-trip and stay backward compatible."""
|
| 524 |
+
from proteus.game.runtime.memory import (
|
| 525 |
+
AgentFrame, MemoryCheckpoint, MemoryTurn,
|
| 526 |
+
)
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
def test_agentframe_defaults():
|
| 530 |
+
a = AgentFrame(id="a0", kind="agent", pos=(3, 4), size=2)
|
| 531 |
+
assert a.alive is True and a.is_chosen is False and a.facing == "right"
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
def test_turn_multiagent_roundtrip():
|
| 535 |
+
t = MemoryTurn(
|
| 536 |
+
turn_idx=1, frame_ascii="", action="up",
|
| 537 |
+
focal_pos=(0, 0), predator_pos=(0, 0),
|
| 538 |
+
agents=[AgentFrame(id="a0", kind="agent", pos=(3, 4), size=2, is_chosen=True),
|
| 539 |
+
AgentFrame(id="predator", kind="predator", pos=(9, 9), size=3, facing="left")],
|
| 540 |
+
resources=[(5, 5)], events=["a1 eaten"],
|
| 541 |
+
)
|
| 542 |
+
back = MemoryTurn.model_validate_json(t.model_dump_json())
|
| 543 |
+
assert back.agents[0].is_chosen is True
|
| 544 |
+
assert back.agents[1].facing == "left"
|
| 545 |
+
assert back.resources == [(5, 5)] and back.events == ["a1 eaten"]
|
| 546 |
+
|
| 547 |
+
|
| 548 |
+
def test_checkpoint_chosen_id_and_backcompat():
|
| 549 |
+
ck = MemoryCheckpoint(
|
| 550 |
+
model="m", scenario="s", difficulty="easy", created_at="x",
|
| 551 |
+
outcome="survived", transparent_prompt="p", chosen_agent_id="a0",
|
| 552 |
+
)
|
| 553 |
+
back = MemoryCheckpoint.model_validate_json(ck.model_dump_json())
|
| 554 |
+
assert back.chosen_agent_id == "a0"
|
| 555 |
+
# Legacy single-agent turns still parse with empty multi-agent fields.
|
| 556 |
+
legacy = MemoryTurn(turn_idx=1, frame_ascii="x", action="up",
|
| 557 |
+
focal_pos=(1, 1), predator_pos=(2, 2))
|
| 558 |
+
assert legacy.agents == [] and legacy.resources == [] and legacy.events == []
|
| 559 |
+
```
|
| 560 |
+
|
| 561 |
+
- [ ] **Step 2: Run test to verify it fails**
|
| 562 |
+
|
| 563 |
+
Run: `uv run pytest tests/runtime/test_memory_multiagent_model.py -v`
|
| 564 |
+
Expected: FAIL (`ImportError: cannot import name 'AgentFrame'`).
|
| 565 |
+
|
| 566 |
+
- [ ] **Step 3a: Add the `AgentFrame` model**
|
| 567 |
+
|
| 568 |
+
In `proteus/game/runtime/memory.py`, after the imports (line 16) and before `class MemoryTurn` add:
|
| 569 |
+
|
| 570 |
+
```python
|
| 571 |
+
class AgentFrame(BaseModel):
|
| 572 |
+
"""One sprite's render state in a multi-agent memory turn.
|
| 573 |
+
|
| 574 |
+
Attributes:
|
| 575 |
+
id: Stable identifier (``"a0".."a3"`` or ``"predator"``).
|
| 576 |
+
kind: ``"agent"`` or ``"predator"`` (drives shape + colour).
|
| 577 |
+
pos: Top-left anchor ``(x, y)`` at the start of this turn.
|
| 578 |
+
size: Footprint side length (agent=2, predator=3).
|
| 579 |
+
alive: Painted only while alive (eaten agents disappear).
|
| 580 |
+
is_chosen: The agent the player continues (painted in the focal colour).
|
| 581 |
+
facing: Predator mouth direction for the ã„· shape (render only).
|
| 582 |
+
"""
|
| 583 |
+
|
| 584 |
+
id: str
|
| 585 |
+
kind: str
|
| 586 |
+
pos: tuple[int, int]
|
| 587 |
+
size: int
|
| 588 |
+
alive: bool = True
|
| 589 |
+
is_chosen: bool = False
|
| 590 |
+
facing: str = "right"
|
| 591 |
+
```
|
| 592 |
+
|
| 593 |
+
- [ ] **Step 3b: Add fields to `MemoryTurn`**
|
| 594 |
+
|
| 595 |
+
In `class MemoryTurn`, after `predator_pos: tuple[int, int]` (line 35) add:
|
| 596 |
+
|
| 597 |
+
```python
|
| 598 |
+
agents: list[AgentFrame] = Field(default_factory=list)
|
| 599 |
+
"""Per-sprite render states; non-empty ⇒ the multi-agent render path."""
|
| 600 |
+
resources: list[tuple[int, int]] = Field(default_factory=list)
|
| 601 |
+
"""Collectible resource cells still present this turn."""
|
| 602 |
+
events: list[str] = Field(default_factory=list)
|
| 603 |
+
"""Narration for this turn, e.g. ``"a1 eaten"`` / ``"a0 got resource"``."""
|
| 604 |
+
```
|
| 605 |
+
|
| 606 |
+
- [ ] **Step 3c: Add `chosen_agent_id` to `MemoryCheckpoint`**
|
| 607 |
+
|
| 608 |
+
In `class MemoryCheckpoint`, after `persona_weight_id: str | None = None` (line 66) add:
|
| 609 |
+
|
| 610 |
+
```python
|
| 611 |
+
chosen_agent_id: str | None = None
|
| 612 |
+
"""Id of the survivor / resource winner the player continues (multi-agent only)."""
|
| 613 |
+
```
|
| 614 |
+
|
| 615 |
+
- [ ] **Step 4: Run tests to verify they pass**
|
| 616 |
+
|
| 617 |
+
Run: `uv run pytest tests/runtime/test_memory_multiagent_model.py -v`
|
| 618 |
+
Expected: PASS.
|
| 619 |
+
|
| 620 |
+
- [ ] **Step 5: Commit**
|
| 621 |
+
|
| 622 |
+
```bash
|
| 623 |
+
git add proteus/game/runtime/memory.py tests/runtime/test_memory_multiagent_model.py
|
| 624 |
+
git commit -m "feat(memory): AgentFrame model + multi-agent turn/checkpoint fields"
|
| 625 |
+
```
|
| 626 |
+
|
| 627 |
+
---
|
| 628 |
+
|
| 629 |
+
### Task 5: Multi-agent `memory_frames` rendering (2×2 agents, 3×3 ㄷ predator, resources)
|
| 630 |
+
|
| 631 |
+
**Files:**
|
| 632 |
+
- Modify: `proteus/game/runtime/memory.py` (`memory_frames` + module-level shape helper)
|
| 633 |
+
- Test: `tests/runtime/test_memory_frames_multiagent.py` (create)
|
| 634 |
+
|
| 635 |
+
`memory.py` must stay numpy-free (pure lists). The predator mouth is described by the SOLID-cell offsets per facing.
|
| 636 |
+
|
| 637 |
+
- [ ] **Step 1: Write the failing test**
|
| 638 |
+
|
| 639 |
+
```python
|
| 640 |
+
# tests/runtime/test_memory_frames_multiagent.py
|
| 641 |
+
"""Multi-agent memory frames paint shapes + colours; single-agent path unchanged."""
|
| 642 |
+
from proteus.game.runtime.memory import (
|
| 643 |
+
AgentFrame, MemoryCheckpoint, MemoryTurn, memory_frames,
|
| 644 |
+
)
|
| 645 |
+
|
| 646 |
+
_LEGEND = {5: ".", 1: "A", 2: "B", 3: "#", 14: "F"}
|
| 647 |
+
_GRID = (12, 12)
|
| 648 |
+
_CHOSEN, _DISTRACT, _PRED, _FOOD, _BG = 1, 9, 2, 14, 5
|
| 649 |
+
|
| 650 |
+
|
| 651 |
+
def _ck(turn):
|
| 652 |
+
return MemoryCheckpoint(
|
| 653 |
+
model="m", scenario="s", difficulty="easy", created_at="x",
|
| 654 |
+
outcome="survived", transparent_prompt="p", memory_turns=[turn],
|
| 655 |
+
)
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
def test_multiagent_paints_agents_predator_resources():
|
| 659 |
+
turn = MemoryTurn(
|
| 660 |
+
turn_idx=1, frame_ascii="", action="up", focal_pos=(0, 0), predator_pos=(0, 0),
|
| 661 |
+
agents=[
|
| 662 |
+
AgentFrame(id="a0", kind="agent", pos=(2, 2), size=2, is_chosen=True),
|
| 663 |
+
AgentFrame(id="a1", kind="agent", pos=(6, 6), size=2),
|
| 664 |
+
AgentFrame(id="a2", kind="agent", pos=(1, 8), size=2, alive=False),
|
| 665 |
+
AgentFrame(id="predator", kind="predator", pos=(8, 1), size=3, facing="right"),
|
| 666 |
+
],
|
| 667 |
+
resources=[(5, 5)], events=["a2 eaten"],
|
| 668 |
+
)
|
| 669 |
+
grid = memory_frames(_ck(turn), legend=_LEGEND, grid_size=_GRID)[0]["grid"]
|
| 670 |
+
# chosen 2x2 at (2,2)
|
| 671 |
+
assert grid[2][2] == _CHOSEN and grid[3][3] == _CHOSEN
|
| 672 |
+
# distractor 2x2 at (6,6) in blue
|
| 673 |
+
assert grid[6][6] == _DISTRACT
|
| 674 |
+
# eaten agent NOT painted
|
| 675 |
+
assert grid[8][1] == _BG
|
| 676 |
+
# resource
|
| 677 |
+
assert grid[5][5] == _FOOD
|
| 678 |
+
# predator ã„· at (8,1), mouth EAST => the two right-middle cells are background
|
| 679 |
+
assert grid[1][8] == _PRED and grid[3][10] == _PRED # corners solid
|
| 680 |
+
assert grid[2][9] == _BG and grid[2][10] == _BG # mouth cells transparent
|
| 681 |
+
|
| 682 |
+
|
| 683 |
+
def test_multiagent_frame_carries_events():
|
| 684 |
+
turn = MemoryTurn(turn_idx=1, frame_ascii="", action="up", focal_pos=(0, 0),
|
| 685 |
+
predator_pos=(0, 0),
|
| 686 |
+
agents=[AgentFrame(id="a0", kind="agent", pos=(0, 0), size=2)],
|
| 687 |
+
events=["hello"])
|
| 688 |
+
out = memory_frames(_ck(turn), legend=_LEGEND, grid_size=_GRID)[0]
|
| 689 |
+
assert out["events"] == ["hello"]
|
| 690 |
+
|
| 691 |
+
|
| 692 |
+
def test_single_agent_path_unchanged():
|
| 693 |
+
# No agents -> legacy reconstruction (prose frame), paints predator 5-block etc.
|
| 694 |
+
turn = MemoryTurn(turn_idx=1, frame_ascii="prose", action="up",
|
| 695 |
+
focal_pos=(2, 2), predator_pos=(7, 7))
|
| 696 |
+
out = memory_frames(_ck(turn), legend=_LEGEND, grid_size=_GRID)[0]
|
| 697 |
+
assert "grid" in out and out["events"] == []
|
| 698 |
+
```
|
| 699 |
+
|
| 700 |
+
- [ ] **Step 2: Run test to verify it fails**
|
| 701 |
+
|
| 702 |
+
Run: `uv run pytest tests/runtime/test_memory_frames_multiagent.py -v`
|
| 703 |
+
Expected: FAIL (`grid[2][2]` not chosen colour — multi-agent branch missing).
|
| 704 |
+
|
| 705 |
+
- [ ] **Step 3a: Add the predator mouth-shape helper**
|
| 706 |
+
|
| 707 |
+
In `proteus/game/runtime/memory.py`, after `_ascii_to_grid` (line ~151) add:
|
| 708 |
+
|
| 709 |
+
```python
|
| 710 |
+
# Transparent (mouth) cells of the 3x3 ã„· predator, per facing, as (col, row).
|
| 711 |
+
# The mouth = the centre cell + the edge-centre cell on the facing side.
|
| 712 |
+
_PRED_MOUTH: dict[str, set[tuple[int, int]]] = {
|
| 713 |
+
"right": {(1, 1), (2, 1)},
|
| 714 |
+
"left": {(1, 1), (0, 1)},
|
| 715 |
+
"down": {(1, 1), (1, 2)},
|
| 716 |
+
"up": {(1, 1), (1, 0)},
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
|
| 720 |
+
def _predator_solid_offsets(facing: str) -> list[tuple[int, int]]:
|
| 721 |
+
"""The (col, row) offsets PAINTED for a 3x3 ã„· predator facing *facing*."""
|
| 722 |
+
mouth = _PRED_MOUTH.get(facing, _PRED_MOUTH["right"])
|
| 723 |
+
return [(c, r) for r in range(3) for c in range(3) if (c, r) not in mouth]
|
| 724 |
+
```
|
| 725 |
+
|
| 726 |
+
- [ ] **Step 3b: Add the multi-agent branch in `memory_frames`**
|
| 727 |
+
|
| 728 |
+
In `memory_frames`, the loop currently does `for mt in checkpoint.memory_turns:` then reconstructs. Add the multi-agent branch as the FIRST thing inside the loop (before `grid = _ascii_to_grid(...)`):
|
| 729 |
+
|
| 730 |
+
```python
|
| 731 |
+
distractor_idx = 9 # COLOR_MAP blue
|
| 732 |
+
for mt in checkpoint.memory_turns:
|
| 733 |
+
if mt.agents:
|
| 734 |
+
grid = [[bg] * w for _ in range(h)]
|
| 735 |
+
for (rx0, ry0, rx1, ry1) in checkpoint.wall_rects:
|
| 736 |
+
for y in range(max(0, ry0), min(h, ry1 + 1)):
|
| 737 |
+
for x in range(max(0, rx0), min(w, rx1 + 1)):
|
| 738 |
+
grid[y][x] = wall_idx
|
| 739 |
+
for (fx, fy) in mt.resources:
|
| 740 |
+
if 0 <= fx < w and 0 <= fy < h:
|
| 741 |
+
grid[fy][fx] = food_idx
|
| 742 |
+
for ag in mt.agents:
|
| 743 |
+
if not ag.alive:
|
| 744 |
+
continue
|
| 745 |
+
if ag.kind == "predator":
|
| 746 |
+
for (c, r) in _predator_solid_offsets(ag.facing):
|
| 747 |
+
x, y = ag.pos[0] + c, ag.pos[1] + r
|
| 748 |
+
if 0 <= x < w and 0 <= y < h:
|
| 749 |
+
grid[y][x] = predator_idx
|
| 750 |
+
else:
|
| 751 |
+
color = focal_idx if ag.is_chosen else distractor_idx
|
| 752 |
+
for r in range(ag.size):
|
| 753 |
+
for c in range(ag.size):
|
| 754 |
+
x, y = ag.pos[0] + c, ag.pos[1] + r
|
| 755 |
+
if 0 <= x < w and 0 <= y < h:
|
| 756 |
+
grid[y][x] = color
|
| 757 |
+
out.append({"turn_idx": mt.turn_idx, "action": mt.action,
|
| 758 |
+
"grid": grid, "events": list(mt.events)})
|
| 759 |
+
continue
|
| 760 |
+
grid = _ascii_to_grid(mt.frame_ascii, sym2idx)
|
| 761 |
+
... # (existing reconstruction unchanged)
|
| 762 |
+
```
|
| 763 |
+
|
| 764 |
+
NOTE: the existing loop body that follows must be preserved. Also change the existing single-agent append (the last line of the loop) from
|
| 765 |
+
```python
|
| 766 |
+
out.append({"turn_idx": mt.turn_idx, "action": mt.action, "grid": grid})
|
| 767 |
+
```
|
| 768 |
+
to
|
| 769 |
+
```python
|
| 770 |
+
out.append({"turn_idx": mt.turn_idx, "action": mt.action,
|
| 771 |
+
"grid": grid, "events": list(mt.events)})
|
| 772 |
+
```
|
| 773 |
+
so every frame carries `events` (legacy = `[]`). Delete the now-duplicate `for mt in checkpoint.memory_turns:` line that was already there (the branch above reuses the same loop header — keep exactly one loop header).
|
| 774 |
+
|
| 775 |
+
- [ ] **Step 4: Run tests to verify they pass**
|
| 776 |
+
|
| 777 |
+
Run: `uv run pytest tests/runtime/test_memory_frames_multiagent.py -v`
|
| 778 |
+
Expected: PASS.
|
| 779 |
+
|
| 780 |
+
- [ ] **Step 5: Regression-check existing frame tests**
|
| 781 |
+
|
| 782 |
+
Run: `uv run pytest tests/runtime/test_memory_frames.py -q`
|
| 783 |
+
Expected: PASS (they should tolerate the extra `events` key; if one asserts exact dict equality, update it to include `"events": []`).
|
| 784 |
+
|
| 785 |
+
- [ ] **Step 6: Commit**
|
| 786 |
+
|
| 787 |
+
```bash
|
| 788 |
+
git add proteus/game/runtime/memory.py tests/runtime/test_memory_frames_multiagent.py
|
| 789 |
+
git commit -m "feat(memory): multi-agent memory_frames (2x2 agents, 3x3 mouth predator, resources)"
|
| 790 |
+
```
|
| 791 |
+
|
| 792 |
+
---
|
| 793 |
+
|
| 794 |
+
## Phase 4 — Director + new scenarios
|
| 795 |
+
|
| 796 |
+
### Task 6: Scripted director — scenario ①`author_pack_flee`
|
| 797 |
+
|
| 798 |
+
**Files:**
|
| 799 |
+
- Create: `proteus/game/runtime/multiagent_director.py`
|
| 800 |
+
- Test: `tests/runtime/test_director_pack_flee.py` (create)
|
| 801 |
+
|
| 802 |
+
Open-field (no walls), equal speed. The chosen agent (`a0`) is **kill-immune** (authored survivor) and flees; distractors panic-wander and are caught one at a time after a free phase; a `max_turns` safeguard force-resolves any stragglers so the memory always ends with exactly one survivor.
|
| 803 |
+
|
| 804 |
+
- [ ] **Step 1: Write the failing test**
|
| 805 |
+
|
| 806 |
+
```python
|
| 807 |
+
# tests/runtime/test_director_pack_flee.py
|
| 808 |
+
from proteus.game.runtime.multiagent_director import author_pack_flee
|
| 809 |
+
|
| 810 |
+
|
| 811 |
+
def _run():
|
| 812 |
+
return author_pack_flee(
|
| 813 |
+
seed=7,
|
| 814 |
+
agent_starts=[(10, 30), (14, 38), (18, 24), (12, 46)],
|
| 815 |
+
predator_start=(54, 31),
|
| 816 |
+
)
|
| 817 |
+
|
| 818 |
+
|
| 819 |
+
def test_deterministic():
|
| 820 |
+
a, b = _run(), _run()
|
| 821 |
+
assert a.model_dump_json() == b.model_dump_json()
|
| 822 |
+
|
| 823 |
+
|
| 824 |
+
def test_ends_with_single_chosen_survivor():
|
| 825 |
+
ck = _run()
|
| 826 |
+
last = ck.memory_turns[-1]
|
| 827 |
+
alive = [a for a in last.agents if a.kind == "agent" and a.alive]
|
| 828 |
+
assert len(alive) == 1 and alive[0].is_chosen and alive[0].id == "a0"
|
| 829 |
+
assert ck.chosen_agent_id == "a0"
|
| 830 |
+
|
| 831 |
+
|
| 832 |
+
def test_chosen_alive_every_turn_and_kills_recorded():
|
| 833 |
+
ck = _run()
|
| 834 |
+
for t in ck.memory_turns:
|
| 835 |
+
chosen = next(a for a in t.agents if a.id == "a0")
|
| 836 |
+
assert chosen.alive
|
| 837 |
+
all_events = [e for t in ck.memory_turns for e in t.events]
|
| 838 |
+
assert sum("eaten" in e for e in all_events) == 3 # 3 distractors caught
|
| 839 |
+
|
| 840 |
+
|
| 841 |
+
def test_persona_id_recorded():
|
| 842 |
+
assert _run().persona_weight_id == "risk_averse"
|
| 843 |
+
```
|
| 844 |
+
|
| 845 |
+
- [ ] **Step 2: Run test to verify it fails**
|
| 846 |
+
|
| 847 |
+
Run: `uv run pytest tests/runtime/test_director_pack_flee.py -v`
|
| 848 |
+
Expected: FAIL (module does not exist).
|
| 849 |
+
|
| 850 |
+
- [ ] **Step 3: Create the director with scenario â‘ **
|
| 851 |
+
|
| 852 |
+
```python
|
| 853 |
+
# proteus/game/runtime/multiagent_director.py
|
| 854 |
+
"""Deterministic scripted directors that author multi-agent handover memories.
|
| 855 |
+
|
| 856 |
+
These do NOT run the live engine (which is single-focal). They own the
|
| 857 |
+
multi-agent truth on an open field and emit a ``MemoryCheckpoint`` whose
|
| 858 |
+
``memory_turns`` carry per-sprite ``AgentFrame``s, resources, and events. The
|
| 859 |
+
chosen agent (``a0``) is authored to embody a persona: it flees optimally
|
| 860 |
+
(``risk_averse``, scenario â‘ ) or beelines to the resource (``greedy``,
|
| 861 |
+
scenario â‘¡). Distractors wander. Geometry is open-field Manhattan.
|
| 862 |
+
"""
|
| 863 |
+
from __future__ import annotations
|
| 864 |
+
|
| 865 |
+
import random
|
| 866 |
+
from dataclasses import dataclass, field
|
| 867 |
+
|
| 868 |
+
from proteus.game.runtime.memory import AgentFrame, MemoryCheckpoint, MemoryTurn
|
| 869 |
+
|
| 870 |
+
_GRID = (64, 64)
|
| 871 |
+
_DELTAS = {"up": (0, -1), "down": (0, 1), "left": (-1, 0), "right": (1, 0), "stay": (0, 0)}
|
| 872 |
+
_MOVES = ("up", "down", "left", "right")
|
| 873 |
+
_FACING_FROM = {"right": "right", "left": "left", "up": "up", "down": "down"}
|
| 874 |
+
_STAMP = "0000-00-00T00-00-00"
|
| 875 |
+
|
| 876 |
+
|
| 877 |
+
@dataclass
|
| 878 |
+
class _Agent:
|
| 879 |
+
id: str
|
| 880 |
+
x: int
|
| 881 |
+
y: int
|
| 882 |
+
size: int
|
| 883 |
+
alive: bool = True
|
| 884 |
+
is_chosen: bool = False
|
| 885 |
+
|
| 886 |
+
|
| 887 |
+
def _in_bounds(x: int, y: int, size: int, grid=_GRID) -> bool:
|
| 888 |
+
return 0 <= x and 0 <= y and x + size <= grid[0] and y + size <= grid[1]
|
| 889 |
+
|
| 890 |
+
|
| 891 |
+
def _center(a: _Agent) -> tuple[int, int]:
|
| 892 |
+
return (a.x + a.size // 2, a.y + a.size // 2)
|
| 893 |
+
|
| 894 |
+
|
| 895 |
+
def _manhattan(a: tuple[int, int], b: tuple[int, int]) -> int:
|
| 896 |
+
return abs(a[0] - b[0]) + abs(a[1] - b[1])
|
| 897 |
+
|
| 898 |
+
|
| 899 |
+
def _overlap(a: _Agent, b: _Agent) -> bool:
|
| 900 |
+
return (a.x < b.x + b.size and b.x < a.x + a.size
|
| 901 |
+
and a.y < b.y + b.size and b.y < a.y + a.size)
|
| 902 |
+
|
| 903 |
+
|
| 904 |
+
def _legal_moves(a: _Agent, grid=_GRID) -> list[str]:
|
| 905 |
+
out = []
|
| 906 |
+
for m in _MOVES:
|
| 907 |
+
dx, dy = _DELTAS[m]
|
| 908 |
+
if _in_bounds(a.x + dx, a.y + dy, a.size, grid):
|
| 909 |
+
out.append(m)
|
| 910 |
+
return out
|
| 911 |
+
|
| 912 |
+
|
| 913 |
+
def _wander(a: _Agent, rng: random.Random) -> str:
|
| 914 |
+
legal = _legal_moves(a)
|
| 915 |
+
return rng.choice(legal) if legal else "stay"
|
| 916 |
+
|
| 917 |
+
|
| 918 |
+
def _flee(a: _Agent, threat: tuple[int, int]) -> str:
|
| 919 |
+
"""Pick the legal move that MAXIMISES Manhattan distance from *threat*."""
|
| 920 |
+
best, best_d = "stay", -1
|
| 921 |
+
for m in _legal_moves(a):
|
| 922 |
+
dx, dy = _DELTAS[m]
|
| 923 |
+
cc = (a.x + dx + a.size // 2, a.y + dy + a.size // 2)
|
| 924 |
+
d = _manhattan(cc, threat)
|
| 925 |
+
if d > best_d:
|
| 926 |
+
best, best_d = m, d
|
| 927 |
+
return best
|
| 928 |
+
|
| 929 |
+
|
| 930 |
+
def _step_toward(a: _Agent, target: tuple[int, int]) -> str:
|
| 931 |
+
"""One greedy footprint-safe step reducing Manhattan distance to *target*."""
|
| 932 |
+
best, best_d = "stay", _manhattan(_center(a), target)
|
| 933 |
+
for m in _legal_moves(a):
|
| 934 |
+
dx, dy = _DELTAS[m]
|
| 935 |
+
cc = (a.x + dx + a.size // 2, a.y + dy + a.size // 2)
|
| 936 |
+
d = _manhattan(cc, target)
|
| 937 |
+
if d < best_d:
|
| 938 |
+
best, best_d = m, d
|
| 939 |
+
return best
|
| 940 |
+
|
| 941 |
+
|
| 942 |
+
def _apply(a: _Agent, action: str) -> None:
|
| 943 |
+
dx, dy = _DELTAS[action]
|
| 944 |
+
if _in_bounds(a.x + dx, a.y + dy, a.size):
|
| 945 |
+
a.x, a.y = a.x + dx, a.y + dy
|
| 946 |
+
|
| 947 |
+
|
| 948 |
+
def _frame(agents: list[_Agent], pred: _Agent, facing: str,
|
| 949 |
+
resources, events, action: str, idx: int) -> MemoryTurn:
|
| 950 |
+
frames = [AgentFrame(id=a.id, kind="agent", pos=(a.x, a.y), size=a.size,
|
| 951 |
+
alive=a.alive, is_chosen=a.is_chosen) for a in agents]
|
| 952 |
+
if pred is not None:
|
| 953 |
+
frames.append(AgentFrame(id="predator", kind="predator",
|
| 954 |
+
pos=(pred.x, pred.y), size=pred.size, facing=facing))
|
| 955 |
+
alive_n = sum(1 for a in agents if a.alive)
|
| 956 |
+
return MemoryTurn(
|
| 957 |
+
turn_idx=idx, frame_ascii=f"{alive_n} agents alive; predator at {(pred.x, pred.y) if pred else None}",
|
| 958 |
+
action=action, focal_pos=(0, 0), predator_pos=(0, 0),
|
| 959 |
+
agents=frames, resources=list(resources), events=list(events),
|
| 960 |
+
)
|
| 961 |
+
|
| 962 |
+
|
| 963 |
+
def author_pack_flee(
|
| 964 |
+
*, seed: int, agent_starts: list[tuple[int, int]], predator_start: tuple[int, int],
|
| 965 |
+
agent_size: int = 2, predator_size: int = 3, free_turns: int = 8, max_turns: int = 80,
|
| 966 |
+
persona_id: str = "risk_averse",
|
| 967 |
+
) -> MemoryCheckpoint:
|
| 968 |
+
"""Author scenario â‘ : pack flees a predator; one survivor (a0) remains."""
|
| 969 |
+
rng = random.Random(seed)
|
| 970 |
+
agents = [_Agent(id=f"a{i}", x=p[0], y=p[1], size=agent_size, is_chosen=(i == 0))
|
| 971 |
+
for i, p in enumerate(agent_starts)]
|
| 972 |
+
pred = _Agent(id="predator", x=predator_start[0], y=predator_start[1], size=predator_size)
|
| 973 |
+
facing = "left"
|
| 974 |
+
turns: list[MemoryTurn] = []
|
| 975 |
+
catalyst_done = False
|
| 976 |
+
|
| 977 |
+
for t in range(1, max_turns + 1):
|
| 978 |
+
events: list[str] = []
|
| 979 |
+
chosen = next(a for a in agents if a.is_chosen)
|
| 980 |
+
# Record the PRE-move frame; the stored action is the chosen agent's.
|
| 981 |
+
chosen_action = (_flee(chosen, _center(pred)) if (t > free_turns or catalyst_done)
|
| 982 |
+
else _wander(chosen, rng))
|
| 983 |
+
turns.append(_frame(agents, pred, facing, [], events, chosen_action, t))
|
| 984 |
+
|
| 985 |
+
# --- predator-first resolution ---
|
| 986 |
+
targets = [a for a in agents if a.alive and not a.is_chosen]
|
| 987 |
+
if targets:
|
| 988 |
+
nearest = min(targets, key=lambda a: _manhattan(_center(pred), _center(a)))
|
| 989 |
+
move = _step_toward(pred, _center(nearest))
|
| 990 |
+
if move != "stay":
|
| 991 |
+
facing = _FACING_FROM[move]
|
| 992 |
+
_apply(pred, move)
|
| 993 |
+
# chosen + distractors move
|
| 994 |
+
_apply(chosen, chosen_action)
|
| 995 |
+
for a in agents:
|
| 996 |
+
if a.alive and not a.is_chosen:
|
| 997 |
+
_apply(a, _wander(a, rng))
|
| 998 |
+
# kills (distractors only; chosen is the authored survivor)
|
| 999 |
+
for a in agents:
|
| 1000 |
+
if a.alive and not a.is_chosen and _overlap(pred, a):
|
| 1001 |
+
a.alive = False
|
| 1002 |
+
catalyst_done = True
|
| 1003 |
+
events.append(f"{a.id} eaten")
|
| 1004 |
+
# patch events onto the frame we just appended
|
| 1005 |
+
turns[-1].events = list(events)
|
| 1006 |
+
|
| 1007 |
+
if sum(1 for a in agents if a.alive and not a.is_chosen) == 0:
|
| 1008 |
+
break
|
| 1009 |
+
|
| 1010 |
+
# Safeguard: force-resolve any stragglers so exactly the chosen survives.
|
| 1011 |
+
for a in agents:
|
| 1012 |
+
if a.alive and not a.is_chosen:
|
| 1013 |
+
a.alive = False
|
| 1014 |
+
if turns:
|
| 1015 |
+
# Append a final settled frame (predator removed for clarity).
|
| 1016 |
+
turns.append(_frame(agents, pred, facing, [], ["only a0 survives"], "stay",
|
| 1017 |
+
len(turns) + 1))
|
| 1018 |
+
|
| 1019 |
+
return MemoryCheckpoint(
|
| 1020 |
+
model=f"director:{persona_id}", scenario="pack_flee", motive_category="survival",
|
| 1021 |
+
difficulty="easy", seed=seed, created_at=_STAMP, memory_turns=turns,
|
| 1022 |
+
outcome="survived", transparent_prompt="Pack-flee handover memory.",
|
| 1023 |
+
persona_weight_id=persona_id, chosen_agent_id="a0",
|
| 1024 |
+
)
|
| 1025 |
+
```
|
| 1026 |
+
|
| 1027 |
+
- [ ] **Step 4: Run tests to verify they pass**
|
| 1028 |
+
|
| 1029 |
+
Run: `uv run pytest tests/runtime/test_director_pack_flee.py -v`
|
| 1030 |
+
Expected: PASS. (If `test_chosen_alive_every_turn_and_kills_recorded` finds ≠3 "eaten" events because the safeguard fired, increase `max_turns` to 120 — the open-field random-walk distractors must be caught by the directed predator within the budget; 80 is tuned for these starts.)
|
| 1031 |
+
|
| 1032 |
+
- [ ] **Step 5: Commit**
|
| 1033 |
+
|
| 1034 |
+
```bash
|
| 1035 |
+
git add proteus/game/runtime/multiagent_director.py tests/runtime/test_director_pack_flee.py
|
| 1036 |
+
git commit -m "feat(director): author_pack_flee multi-agent survivor memory"
|
| 1037 |
+
```
|
| 1038 |
+
|
| 1039 |
+
---
|
| 1040 |
+
|
| 1041 |
+
### Task 7: Scripted director — scenario ② `author_resource_race`
|
| 1042 |
+
|
| 1043 |
+
**Files:**
|
| 1044 |
+
- Modify: `proteus/game/runtime/multiagent_director.py` (add function)
|
| 1045 |
+
- Test: `tests/runtime/test_director_resource_race.py` (create)
|
| 1046 |
+
|
| 1047 |
+
- [ ] **Step 1: Write the failing test**
|
| 1048 |
+
|
| 1049 |
+
```python
|
| 1050 |
+
# tests/runtime/test_director_resource_race.py
|
| 1051 |
+
from proteus.game.runtime.multiagent_director import author_resource_race
|
| 1052 |
+
|
| 1053 |
+
|
| 1054 |
+
def _run():
|
| 1055 |
+
return author_resource_race(
|
| 1056 |
+
seed=3,
|
| 1057 |
+
agent_starts=[(8, 52), (20, 10), (40, 50), (55, 20), (30, 30)],
|
| 1058 |
+
resource=(54, 12),
|
| 1059 |
+
)
|
| 1060 |
+
|
| 1061 |
+
|
| 1062 |
+
def test_deterministic():
|
| 1063 |
+
assert _run().model_dump_json() == _run().model_dump_json()
|
| 1064 |
+
|
| 1065 |
+
|
| 1066 |
+
def test_ends_when_chosen_collects_resource():
|
| 1067 |
+
ck = _run()
|
| 1068 |
+
last = ck.memory_turns[-1]
|
| 1069 |
+
assert any("got resource" in e for e in last.events)
|
| 1070 |
+
chosen = next(a for a in last.agents if a.id == "a0")
|
| 1071 |
+
rx, ry = (54, 12)
|
| 1072 |
+
# chosen footprint covers the resource cell
|
| 1073 |
+
assert chosen.pos[0] <= rx < chosen.pos[0] + chosen.size
|
| 1074 |
+
assert chosen.pos[1] <= ry < chosen.pos[1] + chosen.size
|
| 1075 |
+
assert ck.chosen_agent_id == "a0" and ck.persona_weight_id == "greedy"
|
| 1076 |
+
|
| 1077 |
+
|
| 1078 |
+
def test_resource_present_until_collected():
|
| 1079 |
+
ck = _run()
|
| 1080 |
+
# Every turn except the last carries the resource; the last records pickup.
|
| 1081 |
+
for t in ck.memory_turns[:-1]:
|
| 1082 |
+
assert t.resources == [(54, 12)]
|
| 1083 |
+
```
|
| 1084 |
+
|
| 1085 |
+
- [ ] **Step 2: Run test to verify it fails**
|
| 1086 |
+
|
| 1087 |
+
Run: `uv run pytest tests/runtime/test_director_resource_race.py -v`
|
| 1088 |
+
Expected: FAIL (`author_resource_race` undefined).
|
| 1089 |
+
|
| 1090 |
+
- [ ] **Step 3: Add `author_resource_race` to the director**
|
| 1091 |
+
|
| 1092 |
+
Append to `proteus/game/runtime/multiagent_director.py`:
|
| 1093 |
+
|
| 1094 |
+
```python
|
| 1095 |
+
def _covers(a: _Agent, cell: tuple[int, int]) -> bool:
|
| 1096 |
+
return (a.x <= cell[0] < a.x + a.size and a.y <= cell[1] < a.y + a.size)
|
| 1097 |
+
|
| 1098 |
+
|
| 1099 |
+
def author_resource_race(
|
| 1100 |
+
*, seed: int, agent_starts: list[tuple[int, int]], resource: tuple[int, int],
|
| 1101 |
+
agent_size: int = 2, max_turns: int = 120, persona_id: str = "greedy",
|
| 1102 |
+
) -> MemoryCheckpoint:
|
| 1103 |
+
"""Author scenario â‘¡: a0 beelines to the lone resource; others wander."""
|
| 1104 |
+
rng = random.Random(seed)
|
| 1105 |
+
agents = [_Agent(id=f"a{i}", x=p[0], y=p[1], size=agent_size, is_chosen=(i == 0))
|
| 1106 |
+
for i, p in enumerate(agent_starts)]
|
| 1107 |
+
turns: list[MemoryTurn] = []
|
| 1108 |
+
|
| 1109 |
+
for t in range(1, max_turns + 1):
|
| 1110 |
+
chosen = next(a for a in agents if a.is_chosen)
|
| 1111 |
+
if _covers(chosen, resource):
|
| 1112 |
+
turns.append(_frame(agents, None, "right", [resource],
|
| 1113 |
+
["a0 got resource"], "stay", t))
|
| 1114 |
+
break
|
| 1115 |
+
action = _step_toward(chosen, resource)
|
| 1116 |
+
turns.append(_frame(agents, None, "right", [resource], [], action, t))
|
| 1117 |
+
_apply(chosen, action)
|
| 1118 |
+
for a in agents:
|
| 1119 |
+
if not a.is_chosen:
|
| 1120 |
+
_apply(a, _wander(a, rng))
|
| 1121 |
+
|
| 1122 |
+
return MemoryCheckpoint(
|
| 1123 |
+
model=f"director:{persona_id}", scenario="resource_race",
|
| 1124 |
+
motive_category="resource", difficulty="easy", seed=seed, created_at=_STAMP,
|
| 1125 |
+
memory_turns=turns, outcome="survived",
|
| 1126 |
+
transparent_prompt="Resource-race handover memory.",
|
| 1127 |
+
persona_weight_id=persona_id, chosen_agent_id="a0",
|
| 1128 |
+
)
|
| 1129 |
+
```
|
| 1130 |
+
|
| 1131 |
+
- [ ] **Step 4: Run tests to verify they pass**
|
| 1132 |
+
|
| 1133 |
+
Run: `uv run pytest tests/runtime/test_director_resource_race.py -v`
|
| 1134 |
+
Expected: PASS.
|
| 1135 |
+
|
| 1136 |
+
- [ ] **Step 5: Commit**
|
| 1137 |
+
|
| 1138 |
+
```bash
|
| 1139 |
+
git add proteus/game/runtime/multiagent_director.py tests/runtime/test_director_resource_race.py
|
| 1140 |
+
git commit -m "feat(director): author_resource_race multi-agent winner memory"
|
| 1141 |
+
```
|
| 1142 |
+
|
| 1143 |
+
---
|
| 1144 |
+
|
| 1145 |
+
### Task 8: `pack_flee` scenario (single-focal query, reuses pack_evade live mechanics)
|
| 1146 |
+
|
| 1147 |
+
**Files:**
|
| 1148 |
+
- Create: `proteus/game/scenarios/pack_flee.py`
|
| 1149 |
+
- Modify: `proteus/game/scenarios/__init__.py`
|
| 1150 |
+
- Test: `tests/scenarios/test_pack_flee.py` (create)
|
| 1151 |
+
|
| 1152 |
+
- [ ] **Step 1: Write the failing test**
|
| 1153 |
+
|
| 1154 |
+
```python
|
| 1155 |
+
# tests/scenarios/test_pack_flee.py
|
| 1156 |
+
import random
|
| 1157 |
+
|
| 1158 |
+
from proteus.game.engine.difficulty import Difficulty
|
| 1159 |
+
from proteus.game.scenarios.base import get_scenario, list_scenarios
|
| 1160 |
+
import proteus.game.scenarios # noqa: F401
|
| 1161 |
+
|
| 1162 |
+
|
| 1163 |
+
def test_registered():
|
| 1164 |
+
assert "pack_flee" in list_scenarios()
|
| 1165 |
+
|
| 1166 |
+
|
| 1167 |
+
def test_predator_first_and_sizes():
|
| 1168 |
+
scen = get_scenario("pack_flee")()
|
| 1169 |
+
assert scen.turn_order == "predator_first"
|
| 1170 |
+
|
| 1171 |
+
|
| 1172 |
+
def test_default_memory_is_multiagent_survivor():
|
| 1173 |
+
scen = get_scenario("pack_flee")()
|
| 1174 |
+
ck = scen.default_memory(seed=7, difficulty=Difficulty.EASY)
|
| 1175 |
+
assert ck.chosen_agent_id == "a0" and ck.persona_weight_id == "risk_averse"
|
| 1176 |
+
last = ck.memory_turns[-1]
|
| 1177 |
+
alive = [a for a in last.agents if a.kind == "agent" and a.alive]
|
| 1178 |
+
assert len(alive) == 1
|
| 1179 |
+
```
|
| 1180 |
+
|
| 1181 |
+
- [ ] **Step 2: Run test to verify it fails**
|
| 1182 |
+
|
| 1183 |
+
Run: `uv run pytest tests/scenarios/test_pack_flee.py -v`
|
| 1184 |
+
Expected: FAIL (`pack_flee` not registered).
|
| 1185 |
+
|
| 1186 |
+
- [ ] **Step 3a: Create the scenario**
|
| 1187 |
+
|
| 1188 |
+
```python
|
| 1189 |
+
# proteus/game/scenarios/pack_flee.py
|
| 1190 |
+
"""pack_flee — scenario ①: a single-focal predator-evasion QUERY whose handover
|
| 1191 |
+
memory is a hand-authored multi-agent pack flee (see
|
| 1192 |
+
``proteus.game.runtime.multiagent_director.author_pack_flee``). The lone survivor
|
| 1193 |
+
of that memory (``a0``, a risk_averse evader) is the focal the player continues.
|
| 1194 |
+
|
| 1195 |
+
Live mechanics are identical to the resized ``pack_evade`` (2x2 focal, 3x3
|
| 1196 |
+
open-mouth predator, predator-first), so this subclasses it and only swaps the
|
| 1197 |
+
handover memory + briefs.
|
| 1198 |
+
"""
|
| 1199 |
+
from __future__ import annotations
|
| 1200 |
+
|
| 1201 |
+
from proteus.game.scenarios.base import register_scenario
|
| 1202 |
+
from proteus.game.scenarios.pack_evade import PackEvade
|
| 1203 |
+
|
| 1204 |
+
|
| 1205 |
+
@register_scenario("pack_flee")
|
| 1206 |
+
class PackFlee(PackEvade):
|
| 1207 |
+
task_name: str = "pack_flee"
|
| 1208 |
+
rules_text: str = (
|
| 1209 |
+
"You are the lone survivor of a pack. A 3x3 predator hunts you (2x2). "
|
| 1210 |
+
"It steps toward you every turn; you are eaten on contact. Stay as far "
|
| 1211 |
+
"from it as you can. Actions: up, down, left, right, stay."
|
| 1212 |
+
)
|
| 1213 |
+
memory_brief: str = (
|
| 1214 |
+
"MEMORY. Your pack scattered as a predator closed in; the others were "
|
| 1215 |
+
"caught one by one and you alone escaped by keeping maximum distance."
|
| 1216 |
+
)
|
| 1217 |
+
|
| 1218 |
+
def default_memory(self, seed, difficulty):
|
| 1219 |
+
from proteus.game.runtime.multiagent_director import author_pack_flee
|
| 1220 |
+
# Deterministic spread of 4 agents + a distant predator on the 64x64 field.
|
| 1221 |
+
return author_pack_flee(
|
| 1222 |
+
seed=(seed if seed is not None else 0),
|
| 1223 |
+
agent_starts=[(10, 30), (14, 38), (18, 24), (12, 46)],
|
| 1224 |
+
predator_start=(54, 31),
|
| 1225 |
+
persona_id="risk_averse",
|
| 1226 |
+
)
|
| 1227 |
+
```
|
| 1228 |
+
|
| 1229 |
+
- [ ] **Step 3b: Register it**
|
| 1230 |
+
|
| 1231 |
+
In `proteus/game/scenarios/__init__.py` add after the `pack_evade` import:
|
| 1232 |
+
```python
|
| 1233 |
+
from proteus.game.scenarios import pack_flee # noqa: F401 — side-effect: register
|
| 1234 |
+
```
|
| 1235 |
+
and add `"pack_flee"` to `__all__`.
|
| 1236 |
+
|
| 1237 |
+
- [ ] **Step 4: Run tests to verify they pass**
|
| 1238 |
+
|
| 1239 |
+
Run: `uv run pytest tests/scenarios/test_pack_flee.py -v`
|
| 1240 |
+
Expected: PASS.
|
| 1241 |
+
|
| 1242 |
+
- [ ] **Step 5: Commit**
|
| 1243 |
+
|
| 1244 |
+
```bash
|
| 1245 |
+
git add proteus/game/scenarios/pack_flee.py proteus/game/scenarios/__init__.py tests/scenarios/test_pack_flee.py
|
| 1246 |
+
git commit -m "feat(scenario): pack_flee single-focal query + multi-agent survivor memory"
|
| 1247 |
+
```
|
| 1248 |
+
|
| 1249 |
+
---
|
| 1250 |
+
|
| 1251 |
+
### Task 9: `resource_race` scenario (resources-only single-focal query, greedy persona)
|
| 1252 |
+
|
| 1253 |
+
**Files:**
|
| 1254 |
+
- Create: `proteus/game/scenarios/resource_race.py`
|
| 1255 |
+
- Modify: `proteus/game/scenarios/__init__.py`
|
| 1256 |
+
- Test: `tests/scenarios/test_resource_race.py` (create)
|
| 1257 |
+
|
| 1258 |
+
No predator. `turn_order` stays `focal_first` (irrelevant without a threat; resource collection runs in `advance_threat` after the focal move). Ends at horizon (no early win).
|
| 1259 |
+
|
| 1260 |
+
- [ ] **Step 1: Write the failing test**
|
| 1261 |
+
|
| 1262 |
+
```python
|
| 1263 |
+
# tests/scenarios/test_resource_race.py
|
| 1264 |
+
import random
|
| 1265 |
+
|
| 1266 |
+
from proteus.game.engine.difficulty import Difficulty
|
| 1267 |
+
from proteus.game.engine.grid import MotiveGridGame
|
| 1268 |
+
from proteus.game.scenarios.base import get_scenario, list_scenarios
|
| 1269 |
+
import proteus.game.scenarios # noqa: F401
|
| 1270 |
+
|
| 1271 |
+
|
| 1272 |
+
def _game(seed=1):
|
| 1273 |
+
scen = get_scenario("resource_race")()
|
| 1274 |
+
return scen, MotiveGridGame(scen, random.Random(seed), Difficulty.EASY, max_steps=40)
|
| 1275 |
+
|
| 1276 |
+
|
| 1277 |
+
def test_registered():
|
| 1278 |
+
assert "resource_race" in list_scenarios()
|
| 1279 |
+
|
| 1280 |
+
|
| 1281 |
+
def test_no_predator_never_eliminates():
|
| 1282 |
+
scen, game = _game()
|
| 1283 |
+
assert game.predator_sprite is None
|
| 1284 |
+
assert scen.check_elimination(game) is False
|
| 1285 |
+
|
| 1286 |
+
|
| 1287 |
+
def test_optimal_action_heads_to_nearest_resource():
|
| 1288 |
+
scen, game = _game()
|
| 1289 |
+
a = scen.optimal_action(game)
|
| 1290 |
+
assert a in ("up", "down", "left", "right", "stay")
|
| 1291 |
+
assert scen.nearest_resource_distance(game, (game.focal_sprite.x, game.focal_sprite.y)) is not None
|
| 1292 |
+
|
| 1293 |
+
|
| 1294 |
+
def test_collecting_a_resource_removes_it():
|
| 1295 |
+
scen, game = _game()
|
| 1296 |
+
res = scen.food_cells()
|
| 1297 |
+
assert res, "expected resources on the field"
|
| 1298 |
+
target = res[0]
|
| 1299 |
+
# Teleport focal onto the resource and advance one turn (collection runs there).
|
| 1300 |
+
game.focal_sprite.set_position(target[0], target[1])
|
| 1301 |
+
scen.advance_threat(game)
|
| 1302 |
+
assert target not in scen.food_cells()
|
| 1303 |
+
|
| 1304 |
+
|
| 1305 |
+
def test_default_memory_greedy_winner():
|
| 1306 |
+
scen, _ = _game()
|
| 1307 |
+
ck = scen.default_memory(seed=3, difficulty=Difficulty.EASY)
|
| 1308 |
+
assert ck.persona_weight_id == "greedy" and ck.chosen_agent_id == "a0"
|
| 1309 |
+
```
|
| 1310 |
+
|
| 1311 |
+
- [ ] **Step 2: Run test to verify it fails**
|
| 1312 |
+
|
| 1313 |
+
Run: `uv run pytest tests/scenarios/test_resource_race.py -v`
|
| 1314 |
+
Expected: FAIL (`resource_race` not registered).
|
| 1315 |
+
|
| 1316 |
+
- [ ] **Step 3a: Create the scenario**
|
| 1317 |
+
|
| 1318 |
+
```python
|
| 1319 |
+
# proteus/game/scenarios/resource_race.py
|
| 1320 |
+
"""resource_race — scenario ②: a resources-only, predator-free single-focal QUERY.
|
| 1321 |
+
|
| 1322 |
+
The handover memory is a hand-authored multi-agent race for one resource where
|
| 1323 |
+
``a0`` (a greedy resource-seeker) wins (see
|
| 1324 |
+
``proteus.game.runtime.multiagent_director.author_resource_race``). The query
|
| 1325 |
+
field has several resources; the greedy reference policy heads for the nearest
|
| 1326 |
+
one each turn, and the existing persona metrics score how well the player
|
| 1327 |
+
maintains that resource-seeking persona.
|
| 1328 |
+
|
| 1329 |
+
No predator: ``check_elimination`` is always False, distance/pressure metrics are
|
| 1330 |
+
skipped (None), and the episode ends at the horizon. ``turn_order`` stays
|
| 1331 |
+
focal_first so resource collection (run in ``advance_threat``) sees the focal's
|
| 1332 |
+
post-move cell.
|
| 1333 |
+
"""
|
| 1334 |
+
from __future__ import annotations
|
| 1335 |
+
|
| 1336 |
+
import random
|
| 1337 |
+
from typing import TYPE_CHECKING
|
| 1338 |
+
|
| 1339 |
+
from proteus.game.engine import BlockingMode, Level, Sprite
|
| 1340 |
+
from proteus.game.engine.difficulty import Difficulty
|
| 1341 |
+
|
| 1342 |
+
from .base import Scenario, register_scenario
|
| 1343 |
+
|
| 1344 |
+
if TYPE_CHECKING:
|
| 1345 |
+
from ..engine.grid import MotiveGridGame
|
| 1346 |
+
|
| 1347 |
+
BACKGROUND_IDX = 5
|
| 1348 |
+
FOCAL_IDX = 1
|
| 1349 |
+
FOOD_IDX = 14
|
| 1350 |
+
_GRID = (64, 64)
|
| 1351 |
+
_FOCAL_SIZE = 2
|
| 1352 |
+
_FOCAL_START = (8, 52)
|
| 1353 |
+
_DELTAS = {"up": (0, -1), "down": (0, 1), "left": (-1, 0), "right": (1, 0), "stay": (0, 0)}
|
| 1354 |
+
_ORDER = ("up", "down", "left", "right", "stay")
|
| 1355 |
+
_N_RESOURCES = {Difficulty.EASY: 6, Difficulty.MEDIUM: 6, Difficulty.HARD: 8, Difficulty.EXPERT: 8}
|
| 1356 |
+
_MARGIN = 2
|
| 1357 |
+
|
| 1358 |
+
|
| 1359 |
+
def _manhattan(a, b):
|
| 1360 |
+
return abs(a[0] - b[0]) + abs(a[1] - b[1])
|
| 1361 |
+
|
| 1362 |
+
|
| 1363 |
+
@register_scenario("resource_race")
|
| 1364 |
+
class ResourceRace(Scenario):
|
| 1365 |
+
task_name: str = "resource_race"
|
| 1366 |
+
grid_size: tuple[int, int] = _GRID
|
| 1367 |
+
turn_order: str = "focal_first"
|
| 1368 |
+
rules_text: str = (
|
| 1369 |
+
"You are a 2x2 agent on a 64x64 field scattered with resources (green "
|
| 1370 |
+
"cells). Move onto a resource to collect it. Gather as many as you can. "
|
| 1371 |
+
"Actions: up, down, left, right, stay."
|
| 1372 |
+
)
|
| 1373 |
+
memory_brief: str = (
|
| 1374 |
+
"MEMORY. Among five agents you alone made straight for the resource and "
|
| 1375 |
+
"took it while the others wandered."
|
| 1376 |
+
)
|
| 1377 |
+
|
| 1378 |
+
_resources: list[tuple[int, int]] = []
|
| 1379 |
+
|
| 1380 |
+
def build_level(self, rng: random.Random, difficulty: Difficulty) -> Level:
|
| 1381 |
+
self.grid_size = _GRID
|
| 1382 |
+
w, h = _GRID
|
| 1383 |
+
spawn = {(_FOCAL_START[0] + i, _FOCAL_START[1] + j)
|
| 1384 |
+
for i in range(_FOCAL_SIZE) for j in range(_FOCAL_SIZE)}
|
| 1385 |
+
cells: list[tuple[int, int]] = []
|
| 1386 |
+
while len(cells) < _N_RESOURCES.get(difficulty, 6):
|
| 1387 |
+
c = (rng.randint(_MARGIN, w - 1 - _MARGIN), rng.randint(_MARGIN, h - 1 - _MARGIN))
|
| 1388 |
+
if c not in spawn and c not in cells:
|
| 1389 |
+
cells.append(c)
|
| 1390 |
+
self._resources = cells
|
| 1391 |
+
focal = Sprite(
|
| 1392 |
+
pixels=[[FOCAL_IDX] * _FOCAL_SIZE for _ in range(_FOCAL_SIZE)],
|
| 1393 |
+
name="focal", x=_FOCAL_START[0], y=_FOCAL_START[1],
|
| 1394 |
+
blocking=BlockingMode.BOUNDING_BOX,
|
| 1395 |
+
)
|
| 1396 |
+
food = [Sprite(pixels=[[FOOD_IDX]], name="food", x=fx, y=fy,
|
| 1397 |
+
blocking=BlockingMode.NOT_BLOCKED) for (fx, fy) in cells]
|
| 1398 |
+
return Level(sprites=[*food, focal])
|
| 1399 |
+
|
| 1400 |
+
# --- no-predator stubs -------------------------------------------------- #
|
| 1401 |
+
def cut_focal_policy(self, game: MotiveGridGame) -> str:
|
| 1402 |
+
del game
|
| 1403 |
+
return "stay"
|
| 1404 |
+
|
| 1405 |
+
def cut_length(self, difficulty) -> int:
|
| 1406 |
+
del difficulty
|
| 1407 |
+
return 0
|
| 1408 |
+
|
| 1409 |
+
def advance_threat(self, game: MotiveGridGame) -> None:
|
| 1410 |
+
"""No predator; collect any resource the focal now overlaps."""
|
| 1411 |
+
focal = game.focal_sprite
|
| 1412 |
+
if focal is None:
|
| 1413 |
+
return
|
| 1414 |
+
covered = {(focal.x + i, focal.y + j)
|
| 1415 |
+
for i in range(focal.width) for j in range(focal.height)}
|
| 1416 |
+
self._resources = [c for c in self._resources if c not in covered]
|
| 1417 |
+
|
| 1418 |
+
def check_elimination(self, game: MotiveGridGame) -> bool:
|
| 1419 |
+
del game
|
| 1420 |
+
return False
|
| 1421 |
+
|
| 1422 |
+
# --- geometry the persona/metrics use ----------------------------------- #
|
| 1423 |
+
def _is_free(self, game: MotiveGridGame, cell: tuple[int, int]) -> bool:
|
| 1424 |
+
x, y = cell
|
| 1425 |
+
w, h = self.grid_size
|
| 1426 |
+
return 0 <= x and 0 <= y and x + _FOCAL_SIZE <= w and y + _FOCAL_SIZE <= h
|
| 1427 |
+
|
| 1428 |
+
def nearest_resource_distance(self, game, cell) -> int | None:
|
| 1429 |
+
if not self._resources:
|
| 1430 |
+
return None
|
| 1431 |
+
center = (cell[0] + _FOCAL_SIZE // 2, cell[1] + _FOCAL_SIZE // 2)
|
| 1432 |
+
return min(_manhattan(center, r) for r in self._resources)
|
| 1433 |
+
|
| 1434 |
+
def optimal_action(self, game: MotiveGridGame) -> str:
|
| 1435 |
+
focal = game.focal_sprite
|
| 1436 |
+
if focal is None or not self._resources:
|
| 1437 |
+
return "stay"
|
| 1438 |
+
src = (focal.x, focal.y)
|
| 1439 |
+
best, best_d = "stay", self.nearest_resource_distance(game, src)
|
| 1440 |
+
for a in _ORDER:
|
| 1441 |
+
dx, dy = _DELTAS[a]
|
| 1442 |
+
cand = (src[0] + dx, src[1] + dy)
|
| 1443 |
+
if a != "stay" and not self._is_free(game, cand):
|
| 1444 |
+
continue
|
| 1445 |
+
d = self.nearest_resource_distance(game, cand)
|
| 1446 |
+
if d is not None and (best_d is None or d < best_d):
|
| 1447 |
+
best, best_d = a, d
|
| 1448 |
+
return best
|
| 1449 |
+
|
| 1450 |
+
def habit_action(self, game: MotiveGridGame) -> str:
|
| 1451 |
+
return self.optimal_action(game) # no diagnostic
|
| 1452 |
+
|
| 1453 |
+
def step_reward(self, game, action, blocked, focal_before, predator_before) -> float:
|
| 1454 |
+
del action, blocked, predator_before
|
| 1455 |
+
focal = game.focal_sprite
|
| 1456 |
+
if focal is None:
|
| 1457 |
+
return 0.0
|
| 1458 |
+
before = self.nearest_resource_distance(game, focal_before)
|
| 1459 |
+
after = self.nearest_resource_distance(game, (focal.x, focal.y))
|
| 1460 |
+
if before is None or after is None:
|
| 1461 |
+
return 0.0
|
| 1462 |
+
return float(before - after) # positive = moved toward a resource
|
| 1463 |
+
|
| 1464 |
+
def legend(self) -> dict[int, str]:
|
| 1465 |
+
return {BACKGROUND_IDX: ".", FOCAL_IDX: "A", FOOD_IDX: "F"}
|
| 1466 |
+
|
| 1467 |
+
def food_cells(self) -> list[tuple[int, int]]:
|
| 1468 |
+
return list(self._resources)
|
| 1469 |
+
|
| 1470 |
+
def render_frame(self, game: MotiveGridGame) -> str:
|
| 1471 |
+
focal = game.focal_sprite
|
| 1472 |
+
if focal is None:
|
| 1473 |
+
return "field state unavailable"
|
| 1474 |
+
fc = (focal.x + _FOCAL_SIZE // 2, focal.y + _FOCAL_SIZE // 2)
|
| 1475 |
+
cells = "; ".join(f"({x},{y})" for (x, y) in self._resources) or "none"
|
| 1476 |
+
return (f"Open field {_GRID[0]}x{_GRID[1]}. You are A (2x2) centered at {fc}. "
|
| 1477 |
+
f"Resources remaining: {cells}.")
|
| 1478 |
+
|
| 1479 |
+
def default_memory(self, seed, difficulty):
|
| 1480 |
+
from proteus.game.runtime.multiagent_director import author_resource_race
|
| 1481 |
+
return author_resource_race(
|
| 1482 |
+
seed=(seed if seed is not None else 0),
|
| 1483 |
+
agent_starts=[(8, 52), (20, 10), (40, 50), (55, 20), (30, 30)],
|
| 1484 |
+
resource=(54, 12), persona_id="greedy",
|
| 1485 |
+
)
|
| 1486 |
+
```
|
| 1487 |
+
|
| 1488 |
+
- [ ] **Step 3b: Register it**
|
| 1489 |
+
|
| 1490 |
+
In `proteus/game/scenarios/__init__.py` add:
|
| 1491 |
+
```python
|
| 1492 |
+
from proteus.game.scenarios import resource_race # noqa: F401 — side-effect: register
|
| 1493 |
+
```
|
| 1494 |
+
and add `"resource_race"` to `__all__`.
|
| 1495 |
+
|
| 1496 |
+
- [ ] **Step 4: Run tests + a full-loop smoke**
|
| 1497 |
+
|
| 1498 |
+
Run: `uv run pytest tests/scenarios/test_resource_race.py -v`
|
| 1499 |
+
Expected: PASS.
|
| 1500 |
+
|
| 1501 |
+
Then verify a full interactive episode runs end-to-end (no predator, ends at horizon):
|
| 1502 |
+
Run:
|
| 1503 |
+
```bash
|
| 1504 |
+
uv run python -c "
|
| 1505 |
+
from proteus.game.runtime.interactive import InteractiveSession
|
| 1506 |
+
import proteus.game.scenarios # noqa
|
| 1507 |
+
s = InteractiveSession('resource_race', seed=1, play_turns=5)
|
| 1508 |
+
for _ in range(5):
|
| 1509 |
+
st = s.step('right')
|
| 1510 |
+
print('outcome', st['outcome'])
|
| 1511 |
+
"
|
| 1512 |
+
```
|
| 1513 |
+
Expected: prints `outcome survived`.
|
| 1514 |
+
|
| 1515 |
+
- [ ] **Step 5: Commit**
|
| 1516 |
+
|
| 1517 |
+
```bash
|
| 1518 |
+
git add proteus/game/scenarios/resource_race.py proteus/game/scenarios/__init__.py tests/scenarios/test_resource_race.py
|
| 1519 |
+
git commit -m "feat(scenario): resource_race resources-only query + greedy multi-agent memory"
|
| 1520 |
+
```
|
| 1521 |
+
|
| 1522 |
+
---
|
| 1523 |
+
|
| 1524 |
+
## Phase 5 — Web wiring
|
| 1525 |
+
|
| 1526 |
+
### Task 10: Surface multi-agent memory in the web replay (events caption + legend)
|
| 1527 |
+
|
| 1528 |
+
**Files:**
|
| 1529 |
+
- Modify: `proteus/web/local/static/index.html` (caption + legend)
|
| 1530 |
+
- Test: `tests/web/test_multiagent_memory_route.py` (create)
|
| 1531 |
+
|
| 1532 |
+
`server._memory_info` already returns `memory_frames(...)`, which now includes
|
| 1533 |
+
`agents`-painted grids + `events`. No server change is needed for data; this task
|
| 1534 |
+
adds the client caption/legend and a route smoke test.
|
| 1535 |
+
|
| 1536 |
+
- [ ] **Step 1: Write the failing test**
|
| 1537 |
+
|
| 1538 |
+
```python
|
| 1539 |
+
# tests/web/test_multiagent_memory_route.py
|
| 1540 |
+
"""Creating a pack_flee session returns multi-agent memory frames over HTTP."""
|
| 1541 |
+
from proteus.web.local.server import handle_request
|
| 1542 |
+
import proteus.game.scenarios # noqa: F401
|
| 1543 |
+
|
| 1544 |
+
|
| 1545 |
+
def test_pack_flee_session_returns_painted_multiagent_memory():
|
| 1546 |
+
registry: dict = {}
|
| 1547 |
+
body = {"scenario": "pack_flee", "difficulty": "easy", "seed": 7,
|
| 1548 |
+
"play_turns": 5, "memory": "default"}
|
| 1549 |
+
status, payload, _ = handle_request("POST", "/session", body, registry)
|
| 1550 |
+
assert status == 200, payload
|
| 1551 |
+
frames = payload["memory"]["frames"]
|
| 1552 |
+
assert frames, "expected memory frames"
|
| 1553 |
+
# Distractor blue (9) appears somewhere in an early frame's grid.
|
| 1554 |
+
flat0 = [v for row in frames[0]["grid"] for v in row]
|
| 1555 |
+
assert 9 in flat0
|
| 1556 |
+
# events key is present on frames.
|
| 1557 |
+
assert "events" in frames[0]
|
| 1558 |
+
```
|
| 1559 |
+
|
| 1560 |
+
- [ ] **Step 2: Run test to verify it fails (or passes data-only) **
|
| 1561 |
+
|
| 1562 |
+
Run: `uv run pytest tests/web/test_multiagent_memory_route.py -v`
|
| 1563 |
+
Expected: PASS for the data assertions (server already forwards frames). If it
|
| 1564 |
+
fails on `9 in flat0`, the memory_frames branch (Task 5) regressed — fix there.
|
| 1565 |
+
This test pins the contract before the JS change.
|
| 1566 |
+
|
| 1567 |
+
- [ ] **Step 3: Add the events caption + legend in `index.html`**
|
| 1568 |
+
|
| 1569 |
+
In `proteus/web/local/static/index.html`, in `renderMemFrame()` (lines ~261-267) replace:
|
| 1570 |
+
```javascript
|
| 1571 |
+
$("memReplayCap").textContent =
|
| 1572 |
+
`Memory ${f.turn_idx} / ${MEM_FRAMES.length} — you chose: ${f.action}`;
|
| 1573 |
+
```
|
| 1574 |
+
with:
|
| 1575 |
+
```javascript
|
| 1576 |
+
const ev = (f.events && f.events.length) ? " · " + f.events.join(", ") : "";
|
| 1577 |
+
$("memReplayCap").textContent =
|
| 1578 |
+
`Memory ${f.turn_idx} / ${MEM_FRAMES.length} — you chose: ${f.action}${ev}`;
|
| 1579 |
+
```
|
| 1580 |
+
|
| 1581 |
+
In `showMem(m)` (after setting `$("memInfo").textContent`, ~line 295) append a static legend so the viewer can read the colours:
|
| 1582 |
+
```javascript
|
| 1583 |
+
$("memInfo").textContent +=
|
| 1584 |
+
" · blue = other agents, light = you, mouth-block = predator";
|
| 1585 |
+
```
|
| 1586 |
+
|
| 1587 |
+
- [ ] **Step 4: Re-run the web test + full web suite**
|
| 1588 |
+
|
| 1589 |
+
Run: `uv run pytest tests/web -q`
|
| 1590 |
+
Expected: PASS.
|
| 1591 |
+
|
| 1592 |
+
- [ ] **Step 5: Manual smoke (optional)**
|
| 1593 |
+
|
| 1594 |
+
Run: `uv run python -m proteus.web.local` then open the printed URL, pick
|
| 1595 |
+
scenario `pack_flee`, memory `scenario default`, start, and step through the
|
| 1596 |
+
memory replay — distractors render blue, the survivor light, the predator as an
|
| 1597 |
+
open-mouth block; the caption shows "aN eaten" on kill turns.
|
| 1598 |
+
|
| 1599 |
+
- [ ] **Step 6: Commit**
|
| 1600 |
+
|
| 1601 |
+
```bash
|
| 1602 |
+
git add proteus/web/local/static/index.html tests/web/test_multiagent_memory_route.py
|
| 1603 |
+
git commit -m "feat(web): multi-agent memory replay caption + colour legend"
|
| 1604 |
+
```
|
| 1605 |
+
|
| 1606 |
+
---
|
| 1607 |
+
|
| 1608 |
+
## Final verification
|
| 1609 |
+
|
| 1610 |
+
- [ ] **Run the whole suite**
|
| 1611 |
+
|
| 1612 |
+
Run: `uv run pytest -q`
|
| 1613 |
+
Expected: PASS (all phases green).
|
| 1614 |
+
|
| 1615 |
+
- [ ] **Confirm the four scenarios are registered**
|
| 1616 |
+
|
| 1617 |
+
Run: `uv run python -c "import proteus.game.scenarios as s; from proteus.game.scenarios.base import list_scenarios; print(list_scenarios())"`
|
| 1618 |
+
Expected: includes `pack_evade`, `pack_flee`, `predator_evade`, `resource_race`.
|
docs/superpowers/specs/2026-06-03-multiagent-memory-and-predator-visuals-design.md
CHANGED
|
@@ -119,8 +119,11 @@ width 2.
|
|
| 119 |
### 2.3 Turn order = predator-first
|
| 120 |
|
| 121 |
- New `Scenario.turn_order` hook returns `"focal_first"` by default;
|
| 122 |
-
`pack_evade`
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
| 124 |
- **Implementation = swap two operations inside `grid.step()`, guarded by
|
| 125 |
`turn_order`.** No session-loop changes are needed.
|
| 126 |
- `focal_first` (current): move focal → `advance_threat` → check terminal.
|
|
|
|
| 119 |
### 2.3 Turn order = predator-first
|
| 120 |
|
| 121 |
- New `Scenario.turn_order` hook returns `"focal_first"` by default;
|
| 122 |
+
`pack_evade` and `pack_flee` return `"predator_first"`. `predator_evade` keeps
|
| 123 |
+
the default (preserves its diagnostic). `resource_race` also keeps
|
| 124 |
+
`"focal_first"`: it has no predator, and its resource collection runs in
|
| 125 |
+
`advance_threat`, which must fire *after* the focal moves — so focal-first is
|
| 126 |
+
required there.
|
| 127 |
- **Implementation = swap two operations inside `grid.step()`, guarded by
|
| 128 |
`turn_order`.** No session-loop changes are needed.
|
| 129 |
- `focal_first` (current): move focal → `advance_threat` → check terminal.
|