kikikita commited on
Commit
566718d
·
1 Parent(s): 412ec7f

Refactor survival prompt to provide a readable mission briefing

Browse files

- Introduced _parse_briefing function to extract structured facts from the briefing text.
- Updated make_fake_completer to utilize the new briefing format instead of raw JSON.
- Modified _build_survival_messages to generate a plain-language briefing for NPCs.
- Adjusted tests to validate the new briefing format, ensuring it includes necessary information while excluding raw JSON data.
- Enhanced clarity of survival directives and actions within the briefing.

dist/frontend/assets/index-C7Afv8j6.css ADDED
@@ -0,0 +1 @@
 
 
1
+ :root{color:#f5f7f0;font-synthesis:none;text-rendering:optimizelegibility;background:#0e1813;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}html,body,#root{width:100%;height:100%;margin:0;overflow:hidden}button{font:inherit}.shell{width:100vw;height:100svh;position:relative;overflow:hidden}.worldCanvas{z-index:0;inset:0;width:100%!important;height:100%!important;position:absolute!important}.scoreboard,.eventFeed,.controls,.agentPanel,.errorToast,.busyToast{-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);background:#121c17cc;border:1px solid #ffffff2e;box-shadow:0 18px 60px #00000038}.scoreboard{z-index:60;border-radius:10px;gap:8px;width:min(460px,100vw - 28px);padding:10px 14px 12px;display:grid;position:absolute;top:14px;left:50%;transform:translate(-50%)}.scoreDuel{grid-template-columns:1fr auto 1fr;align-items:center;gap:10px;display:grid}.scoreSide{align-items:baseline;gap:10px;display:flex}.scoreName{letter-spacing:.06em;font-size:13px;font-weight:800}.scoreValue{font-size:24px;font-weight:800;line-height:1}.scoreVs{color:#f5f7f08c;text-transform:uppercase;font-size:12px;font-weight:700}.scoreMeta{color:#f5f7f0c7;flex-wrap:wrap;justify-content:space-between;gap:10px;font-size:12px;display:flex}.scoreActivity{text-overflow:ellipsis;white-space:nowrap;color:#c7e6bd;overflow:hidden}.famineBadge{color:#ffeebc;letter-spacing:.04em;background:#f0c34e24;border:1px solid #f0c34e80;border-radius:5px;padding:2px 8px;font-size:11px;font-weight:800;animation:1.6s ease-in-out infinite faminePulse}.countryGrid{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;display:grid}.countryCard{background:#ffffff12;border:1px solid #ffffff38;border-radius:8px;gap:5px;min-width:0;padding:8px 9px;display:grid}.countryCardEmpty{color:#f5f7f099;grid-column:1/-1;font-size:12px}.countryHead{grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:6px;font-size:11px;display:grid}.countryHead strong{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.countryHead span:last-child{color:#f5f7f094;font-size:10px}.countryBadge,.npcCountryBadge{color:#101813;border-radius:5px;place-items:center;width:18px;height:18px;font-size:11px;font-weight:900;line-height:1;display:inline-grid}.countryPolicy{color:#f5f7f0d1;-webkit-line-clamp:2;-webkit-box-orient:vertical;min-height:2.5em;margin:0;font-size:11px;line-height:1.25;display:-webkit-box;overflow:hidden}.countryFacts{color:#f5f7f09e;grid-template-columns:repeat(2,minmax(0,1fr));gap:3px 8px;font-size:10px;display:grid}.countryFacts span{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}@keyframes faminePulse{0%,to{opacity:1}50%{opacity:.55}}.winProgress{gap:5px;display:grid}.winRow{grid-template-columns:158px 1fr;align-items:center;gap:8px;display:grid}.winLabel{color:#f5f7f0d1;white-space:nowrap;font-size:11px}.winBar{background:#ffffff1f;border-radius:5px;height:8px;overflow:hidden}.winFill{border-radius:5px;height:100%;transition:width .32s;display:block}.winFillPopulation{background:linear-gradient(90deg,#6fd96a,#b8f08a)}.winFillSurvival{background:linear-gradient(90deg,#5b96f0,#9fd1ff)}.modelPills{justify-content:center;gap:6px;display:flex}.modelPill{background:#ffffff17;border-radius:5px;padding:4px 8px;font-size:10px}.modelStatusReady{color:#d9f4cf}.modelStatusWarmup,.modelStatusUnknown{color:#fff3a1}.modelStatusError{color:#ffd3c8}.scoreboard .tooltipAnchor[data-tooltip]:before{border-top-color:#0000;border-bottom-color:#0a110ef5;top:calc(100% + 2px);bottom:auto}.scoreboard .tooltipAnchor[data-tooltip]:after{top:calc(100% + 14px);bottom:auto}.sidePanelHeader{border-bottom:1px solid #ffffff24;justify-content:space-between;align-items:center;gap:10px;padding:11px 12px 9px;display:flex}.sidePanelTitle{color:#fffce9;font-size:13px;font-weight:800}.sidePanelHint{color:#f5f7f08c;font-size:11px}.eventEmpty{color:#f5f7f08c;margin:0;padding:12px;font-size:12px}.eventFeed{z-index:30;border-radius:10px;flex-direction:column;width:min(316px,100vw - 28px);display:flex;position:absolute;top:14px;bottom:84px;right:14px;overflow:hidden}.eventList{scrollbar-width:thin;flex:1;align-content:start;gap:2px;min-height:0;padding:6px 8px 10px;display:grid;overflow-y:auto}.eventLine{border-left:3px solid #0000;border-radius:4px;grid-template-columns:34px 20px 1fr;align-items:baseline;gap:6px;padding:4px 6px;font-size:12px;line-height:1.3;display:grid}.eventTick{color:#f5f7f073;font-variant-numeric:tabular-nums;text-align:right;font-size:10px}.eventIcon{text-align:center}.eventText{color:#f5f7f0e0;overflow-wrap:anywhere}.severityGood{background:#6fd96a14;border-left-color:#6fd96a}.severityGood .eventText{color:#d6f5cf}.severityWarning{background:#f0c34e14;border-left-color:#f0c34e}.severityWarning .eventText{color:#ffeebc}.severityDanger{background:#f06a4e1a;border-left-color:#f06a4e}.severityDanger .eventText{color:#ffd3c8}.severityNeutral{border-left-color:#ffffff2e}.directiveTarget{color:#9fd1ff;flex-shrink:0;font-family:ui-monospace,monospace;font-size:10px;font-weight:700}.directiveText{color:#f5f7f0d1}.directiveBadge{color:#ffeebc;background:#f0c34e2e;border-radius:4px;flex-shrink:0;padding:1px 5px;font-size:9px;font-weight:700}.controls{z-index:30;border-radius:8px;gap:8px;padding:8px;display:flex;position:absolute;bottom:18px;right:14px}.controls button{color:#f7f5dd;cursor:pointer;background:#ffffff1f;border:0;border-radius:7px;place-items:center;width:42px;height:42px;display:grid}.controls button:disabled{cursor:wait;opacity:.88}.controls button:hover{background:#fff3}.controls button.isActive{color:#142018;background:#f8df6b}.controls button.isActive:hover{background:#ffe983}.controls button:focus-visible,.npcLabel button:focus-visible{outline-offset:2px;outline:2px solid #f8df6b}.agentPanel{z-index:30;border-radius:8px;width:min(316px,100vw - 108px);position:absolute;bottom:18px;left:14px;overflow:visible}.panelHeader{border-bottom:1px solid #ffffff24;justify-content:space-between;gap:12px;padding:13px 14px 10px;display:flex}.panelTitle{color:#fffce9;min-width:0;font-size:15px;font-weight:750}.panelRole{color:#c7e6bd;text-transform:uppercase;font-size:12px}.agentReadout{background:#ffffff1a;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;display:grid}.readoutCell{color:#f5f7f0d1;text-align:center;background:#121c17ad;min-width:0;padding:11px 8px;font-size:12px}.healthCell{color:#fff3a1;font-weight:750}.modelControl{background:#ffffff0d;border-top:1px solid #ffffff1a;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:8px;padding:8px 10px;display:grid}.modelControl span{color:#c7e6bd;letter-spacing:.07em;text-transform:uppercase;font-size:10px;font-weight:750}.modelControl select{color:#fffce9;background:#121c17e0;border:1px solid #ffffff2e;border-radius:6px;min-width:0;height:30px;font-size:11px}.modelControl select:disabled{opacity:.65}.directiveBanner{background:#f8df6b17;border-top:1px solid #ffffff1a;gap:4px;padding:10px 12px;display:grid}.directiveBanner span{color:#fff3a1;text-transform:uppercase;font-size:11px;font-weight:750;line-height:1.1}.directiveBanner p{color:#f5f7f0db;margin:0;font-size:12px;line-height:1.35}.relationshipStrip{flex-wrap:wrap;gap:6px;padding:9px 10px 0;display:flex}.relationshipPill{color:#f5f7f0d1;background:#ffffff17;border-radius:6px;max-width:96px;padding:6px 8px;font-size:11px;line-height:1}.houseResidents{gap:2px;padding:9px 0 0;display:grid}.houseResidents:last-child{padding-bottom:10px}.houseResidentsLabel{color:#c7e6bd;letter-spacing:.07em;text-transform:uppercase;padding:0 10px;font-size:10px;font-weight:750}.houseResidentStrip{padding-top:4px}button.houseResidentPill{font:inherit;cursor:pointer;border:none;font-size:11px}button.houseResidentPill:hover{color:#fffce9;background:#ffffff2e}.memoryList{max-height:148px;padding:9px 10px 10px;overflow-y:auto}.memorySummary,.memoryItem{border-bottom:1px solid #ffffff1a;gap:4px;padding:9px 0;display:grid}.memorySummary{padding-top:0}.memoryItem:last-child{border-bottom:0}.memoryTick{color:#c7e6bd;font-size:11px;line-height:1.1}.memorySummary p,.memoryItem p{color:#f5f7f0d6;margin:0;font-size:12px;line-height:1.35;position:relative}.memorySummary p{color:#f5f7f0b8}.tooltipAnchor{min-width:0;position:relative;overflow:visible}.tooltipAnchor:focus-visible{outline-offset:2px;outline:2px solid #f8df6b}.truncateText{text-overflow:ellipsis;white-space:nowrap;min-width:0;display:block;overflow:hidden}.tooltipMeasure{pointer-events:none;visibility:hidden;white-space:nowrap;width:max-content;height:0;position:absolute;overflow:hidden}.tooltipAnchor[data-tooltip]:before,.tooltipAnchor[data-tooltip]:after,.memoryItem p[data-tooltip]:before,.memoryItem p[data-tooltip]:after{z-index:80;opacity:0;pointer-events:none;visibility:hidden;transition:opacity .12s,transform .12s,visibility .12s;position:absolute;left:50%}.tooltipAnchor[data-tooltip]:before,.memoryItem p[data-tooltip]:before{content:"";border:6px solid #0000}.tooltipAnchor[data-tooltip]:after,.memoryItem p[data-tooltip]:after{content:attr(data-tooltip);color:#fffce9;overflow-wrap:anywhere;text-align:left;text-transform:none;white-space:normal;background:#0a110ef5;border:1px solid #ffffff2e;border-radius:6px;width:max-content;max-width:min(320px,100vw - 28px);padding:8px 10px;font-size:12px;font-weight:600;line-height:1.3;box-shadow:0 14px 42px #00000047}.tooltipAnchor[data-tooltip]:hover:before,.tooltipAnchor[data-tooltip]:hover:after,.tooltipAnchor[data-tooltip]:focus-visible:before,.tooltipAnchor[data-tooltip]:focus-visible:after,.memoryItem p[data-tooltip]:hover:before,.memoryItem p[data-tooltip]:hover:after{opacity:1;visibility:visible}.tooltipAnchor[data-tooltip]:before,.memoryItem p[data-tooltip]:before{border-top-color:#0a110ef5;bottom:calc(100% + 2px);transform:translate(-50%,4px)}.tooltipAnchor[data-tooltip]:after,.memoryItem p[data-tooltip]:after{bottom:calc(100% + 14px);transform:translate(-50%,4px)}.tooltipAnchor[data-tooltip]:hover:before,.tooltipAnchor[data-tooltip]:hover:after,.tooltipAnchor[data-tooltip]:focus-visible:before,.tooltipAnchor[data-tooltip]:focus-visible:after,.memoryItem p[data-tooltip]:hover:before,.memoryItem p[data-tooltip]:hover:after{transform:translate(-50%)}.errorToast{z-index:70;color:#ffd3c8;border-color:#ff785e8a;border-radius:8px;max-width:min(560px,100vw - 28px);padding:11px 14px;font-size:13px;position:absolute;top:142px;left:50%;transform:translate(-50%)}.busyToast{z-index:65;color:#fffce9;border-radius:8px;align-items:center;gap:8px;max-width:min(560px,100vw - 28px);padding:11px 14px;font-size:13px;display:inline-flex;position:absolute;top:142px;left:50%;transform:translate(-50%)}.spinIcon{animation:.9s linear infinite spin}@keyframes spin{to{transform:rotate(360deg)}}.npcLabel{pointer-events:auto}.npcCard{color:#fffce9;cursor:pointer;text-align:left;background:#0f1814d1;border:1.5px solid #ffffff38;border-radius:8px;gap:3px;min-width:96px;max-width:136px;padding:6px 8px 7px;display:grid;box-shadow:0 10px 28px #0000003d}.npcCard:hover{background:#121e18eb;box-shadow:0 12px 30px #00000047}.npcCardDead{opacity:.62;filter:grayscale(.7)}.npcCardName{align-items:center;gap:5px;min-width:0;display:flex}.npcCardName strong{text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:1.1;overflow:hidden}.npcCardIcon{font-size:11px;line-height:1}.npcCountryBadge{background:#f7d96c;width:16px;height:16px;font-size:10px}.npcCardRole{letter-spacing:.07em;text-transform:uppercase;font-size:9px;font-weight:700;line-height:1}.npcBar{background:#ffffff24;border-radius:3px;width:100%;height:5px;display:block;overflow:hidden}.npcBarFill{border-radius:3px;height:100%;transition:width .28s;display:block}.npcAgeBar{height:3px}.npcAgeFill{background:#d9b35a}.houseTag,.beastTag,.resourceTag{pointer-events:none;color:#fffce9;white-space:nowrap;background:#0f1814cc;border:1px solid #fff3;border-radius:7px;gap:3px;padding:5px 8px;display:grid}.houseTag{min-width:88px}button.houseTag{pointer-events:auto;font:inherit;text-align:left;cursor:pointer}button.houseTag:hover{background:#121e18eb;box-shadow:0 10px 26px #00000042}.houseTagBuilding{border-color:#ffd07a80}.houseTagDestroyed{opacity:.85;border-color:#f06a4e8c}.houseTagSelected{border-color:#9fd8ffd9;box-shadow:0 0 0 1px #9fd8ff73}.houseTagTitle,.beastTagTitle{font-size:11px;font-weight:700;line-height:1.1}.houseHpBar,.beastHpBar{height:4px}.beastTag{border-color:#f06a4e80;min-width:80px}.beastTagTitle{color:#ffd3c8}.resourceTag{padding:3px 7px;font-size:11px;font-weight:700}.treasuryTag,.cannonTag{text-align:center;background:#0f1814e0;border-color:#f4d36a73}.gameOverOverlay{z-index:100;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);background:#080d0ac7;place-items:center;padding:20px;animation:.4s overlayIn;display:grid;position:absolute;inset:0}@keyframes overlayIn{0%{opacity:0}to{opacity:1}}.gameOverCard{text-align:center;background:#121c17f5;border:1px solid #ffffff38;border-radius:14px;gap:14px;width:min(620px,100vw - 40px);max-height:calc(100svh - 60px);padding:26px 28px;display:grid;overflow-y:auto;box-shadow:0 30px 90px #00000080}.gameOver-win .gameOverCard{border-color:#6fd96a73}.gameOver-lose .gameOverCard{border-color:#f06a4e73}.gameOverCard h1{letter-spacing:.04em;margin:0;font-size:26px}.gameOver-win .gameOverCard h1{color:#d6f5cf}.gameOver-lose .gameOverCard h1{color:#ffd3c8}.gameOverSubtitle{color:#f5f7f0c7;margin:0;font-size:14px}.gameOverScore{justify-content:center;gap:12px;font-size:17px;font-weight:800;display:flex}.scoreDivider{color:#f5f7f066}.gameOverStats{grid-template-columns:repeat(3,1fr);gap:8px;display:grid}.gameOverStat{background:#ffffff12;border-radius:8px;gap:3px;padding:10px 6px;display:grid}.gameOverStatValue{font-size:17px;font-weight:800}.gameOverStatLabel{color:#f5f7f09e;text-transform:uppercase;letter-spacing:.05em;font-size:11px}.gameOverCauses{color:#f5f7f0ad;margin:0;font-size:12px}.gameOverStory{text-align:left;gap:7px;display:grid}.gameOverStory h2{color:#f5f7f0cc;text-transform:uppercase;letter-spacing:.06em;margin:0;font-size:13px}.gameOverStoryList{scrollbar-width:thin;background:#00000038;border-radius:8px;gap:2px;max-height:220px;padding:6px;display:grid;overflow-y:auto}.gameOverDismiss{color:#fffce9;cursor:pointer;background:#ffffff1a;border:1px solid #ffffff42;border-radius:8px;justify-self:center;padding:10px 22px;font-size:13px;font-weight:700}.gameOverDismiss:hover{background:#ffffff2e}@media (width<=1180px){.eventFeed{width:min(262px,100vw - 28px)}}@media (width<=860px){.scoreboard{width:min(400px,100vw - 16px);top:8px}.eventFeed{max-height:32svh;top:auto;bottom:200px}.agentPanel{display:none}.controls{bottom:200px;right:8px}}
dist/frontend/assets/index-DxRbocHC.js ADDED
The diff for this file is too large to render. See raw diff
 
reports/llm_io_election_debug_20260614T140845Z.md ADDED
The diff for this file is too large to render. See raw diff
 
reports/npc_prompt_refactor_20260614.md ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NPC Perception/Action Refactor — Final Report
2
+
3
+ **Date:** 2026-06-14
4
+ **Goal:** Make LLM-driven NPCs behave like reasoning humans, not random-action
5
+ generators — by replacing the unreadable JSON prompt with a spatially grounded
6
+ mission briefing, then iterating prompt → live sim → ledger audit until the
7
+ behaviour is meaningful.
8
+
9
+ ---
10
+
11
+ ## 1. Root cause
12
+
13
+ The survival prompt (`_build_survival_messages` in
14
+ [openai_compatible.py](../src/world_simulator/simulation/connectors/openai_compatible.py))
15
+ dumped a raw JSON payload to the model containing abstract junk
16
+ (`max_age`, `reproduction_cooldown`, `importance`) and nested coordinate dicts.
17
+ The model had no compass, no distances, and no explicit "the enemy treasury is
18
+ HERE" pointer, so movement looked aimless.
19
+
20
+ Verified engine fact (so prompt fixes would actually land): a `move` tool call
21
+ **with** `x`/`z` maps to `move_to_resource`, which honours the destination; a
22
+ `move` **without** coordinates maps to `move_to`, which is a random wander. The
23
+ prompt therefore had to force `x`/`z` on every move.
24
+
25
+ ## 2. What changed
26
+
27
+ `_build_survival_messages` now emits a **plain-language briefing** instead of
28
+ JSON:
29
+
30
+ - **Identity & needs** in human terms (`Health 100/100 (healthy)`,
31
+ `Hunger 0/100 (fine)`); abstract fields removed entirely.
32
+ - **Spatial awareness for every object**: absolute `X=/Z=`, an 8-point compass
33
+ bearing, and distance "in steps" — for allies, threats, resources, the home
34
+ treasury, and above all the **enemy treasury** (`LOOT TARGET ... at X=80 Z=6
35
+ - 40 steps EAST`), including copy-paste tool syntax to march and steal.
36
+ - **Action feedback**: `LAST TURN: <result>` and `YOU HEARD: "<ally speech>"`.
37
+ - **Explicit urgent survival lines** when starving/hurt while carrying food/herbs
38
+ (spelled out like the raid/gather syntax) — added after iteration 2 below.
39
+ - **System prompt** states the dual objective (survive + win the war) with
40
+ CAPS-LOCK rules: always send `x`/`z`; watch the enemy distance shrink; eat/heal
41
+ or die; coordinate with concrete coordinates; never invent ids.
42
+
43
+ Supporting changes: `scripts/live_sim.py` (real-LLM headless runner),
44
+ `scripts/selfplay_sim.py` completer reworked to parse the text briefing (also
45
+ proves the briefing is self-sufficient), and the three briefing-dependent tests
46
+ updated. **All 125 tests pass.**
47
+
48
+ ## 3. The loop (prompt → sim → audit → prompt)
49
+
50
+ | Iter | Change | Live-LLM ledger result |
51
+ |------|--------|------------------------|
52
+ | 1 | JSON → text briefing w/ coords, compass, raid pointer | 138/140 moves head to the enemy treasury coords; rich raid speech; **0 fallbacks** |
53
+ | 2 | Found starving NPCs walked to food nodes but ignored food in hand → added explicit `*** EAT NOW ***` line | Starving-with-food now eats **6/8** samples; hurt-with-herbs heals; otherwise marches to a node to gather |
54
+
55
+ **40-tick live run (Nemotron vs Qwen, real Modal endpoints):**
56
+ `move 367, speak 74, use 20, attack 10` — `accepted 464 / repaired 6`,
57
+ **fallback reasons: none**. Engine events: `gather 11, consume 3, npc_attack 10,
58
+ beast_attack 14, speech 33`.
59
+
60
+ ## 4. Definition-of-Done evidence
61
+
62
+ 1. **Coordinate-driven movement toward the enemy** — 138/140 moves target the
63
+ rival treasury; `npc-001` marched x=-80 → -16 over 23 ticks (a sustained
64
+ 16-step advance, not random jitter).
65
+ 2. **Meaningful raid coordination** — see dialogue below.
66
+ 3. **Gather/eat when in need** — NPCs head to food nodes when hungry and eat the
67
+ food in hand when starving (6/8); hurt NPCs heal.
68
+ 4. **No "walking into walls" / no broken output** — every move is engine-valid
69
+ and clamped to the map; zero fallback/unusable outputs across the run.
70
+
71
+ ## 5. Example of ideal dialogue + actions (verbatim from `ledger.jsonl`)
72
+
73
+ Raid planning with division of labour and rendezvous waypoints:
74
+
75
+ ```
76
+ npc-001: All, we march to X=80 Z=6 to raid treasury_qwen together. Follow my step-by-step route.
77
+ npc-001: We march to X=80 Z=6 to raid treasury_qwen. Meet me at X=-34 Z=4 in 3 turns.
78
+ npc-001: All, follow me to X=80 Z=6 to raid treasury_qwen and bring the coins and wood back to our treasury at X=-80 Z=6.
79
+ npc-004: Boris, gather the food at X=-72 Z=4, then meet me at X=-70 Z=0.
80
+ qwen-001: Let's split up: you and Niko head to enemy treasury at X=-80 Z=6 to raid it. I'll go with Oleg, Mira, and Pavel to gather resources near ours.
81
+ qwen-001: Lena, we must flee beast_1 at X=84 Z=0.3 immediately. Move to X=40 Z=0 to put distance between us and the beast.
82
+ ```
83
+
84
+ Matching actions in the same ticks (every Nemotron NPC executing the plan):
85
+
86
+ ```
87
+ npc-001 move -> x=80 z=6
88
+ npc-002 move -> x=80 z=6
89
+ npc-003 move -> x=80 z=6
90
+ npc-004 move -> x=80 z=6 (after directing Boris to the food node first)
91
+ npc-005 move -> x=80 z=6
92
+ npc-006 move -> x=80 z=6
93
+ ```
94
+
95
+ The NPCs now read a briefing like a human scout report, agree on a raid with
96
+ explicit coordinates, march purposefully across the map toward the enemy
97
+ treasury, and tend to their hunger/health along the way.
98
+
99
+ ---
100
+
101
+ *Artifacts:* refactor in
102
+ [openai_compatible.py](../src/world_simulator/simulation/connectors/openai_compatible.py);
103
+ real-LLM runner [scripts/live_sim.py](../scripts/live_sim.py); audited ledger
104
+ `logs/run_20260614T162911Z/ledger.jsonl`.
scripts/live_sim.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Headless live-LLM self-play.
2
+
3
+ Drives the *real* runtime pipeline against the configured Modal endpoints (no
4
+ fake completer) for N ticks, then prints the ledger path and a behaviour
5
+ summary. Used to audit how the real NPC model reacts to the prompt briefing.
6
+
7
+ Usage:
8
+ .venv\\Scripts\\python.exe scripts\\live_sim.py --ticks 60
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ from collections import Counter
16
+ from pathlib import Path
17
+ import sys
18
+
19
+ REPO_ROOT = Path(__file__).resolve().parents[1]
20
+ SRC_ROOT = REPO_ROOT / "src"
21
+ if str(SRC_ROOT) not in sys.path:
22
+ sys.path.insert(0, str(SRC_ROOT))
23
+
24
+
25
+ def _load_env() -> None:
26
+ env_path = REPO_ROOT / ".env"
27
+ if not env_path.is_file():
28
+ return
29
+ import os
30
+
31
+ for line in env_path.read_text(encoding="utf-8").splitlines():
32
+ line = line.strip()
33
+ if not line or line.startswith("#") or "=" not in line:
34
+ continue
35
+ key, value = line.split("=", 1)
36
+ os.environ.setdefault(key.strip(), value.strip())
37
+
38
+
39
+ def main() -> None:
40
+ _load_env()
41
+ from world_simulator.api.runtime import create_game_runtime
42
+ from world_simulator.config import load_game_config
43
+ from world_simulator.simulation.spawning import create_world
44
+
45
+ parser = argparse.ArgumentParser(description="Headless live-LLM self-play.")
46
+ parser.add_argument("--ticks", type=int, default=60)
47
+ parser.add_argument(
48
+ "--config", type=Path, default=REPO_ROOT / "config" / "game.modal.local.json"
49
+ )
50
+ args = parser.parse_args()
51
+
52
+ config = load_game_config(args.config)
53
+ world = create_world(config)
54
+ runtime = create_game_runtime(world=world, config=config)
55
+ print(
56
+ f"LIVE start config={args.config.name} ticks={args.ticks} "
57
+ f"simulator={runtime.simulator_name} npcs={len(world.npcs)}"
58
+ )
59
+
60
+ for i in range(args.ticks):
61
+ status, _payload = runtime.tick()
62
+ if int(status) != 200:
63
+ print(f"tick {i} failed: {status}")
64
+ break
65
+ if (i + 1) % 10 == 0:
66
+ print(f" ...tick {i + 1} done")
67
+
68
+ ledger_path = runtime._ledger.ledger_path # noqa: SLF001
69
+ print(f"LIVE done. ledger={ledger_path}")
70
+ _summarize(ledger_path)
71
+
72
+
73
+ def _summarize(ledger_path: Path) -> None:
74
+ actions: Counter[str] = Counter()
75
+ verdicts: Counter[str] = Counter()
76
+ fallbacks: Counter[str] = Counter()
77
+ event_types: Counter[str] = Counter()
78
+ sources: Counter[str] = Counter()
79
+
80
+ with ledger_path.open("r", encoding="utf-8") as handle:
81
+ for line in handle:
82
+ if not line.strip():
83
+ continue
84
+ rec = json.loads(line)
85
+ phase = rec.get("phase")
86
+ if phase == "npc_response":
87
+ verdict = rec.get("validator_verdict") or {}
88
+ verdicts[str(verdict.get("status"))] += 1
89
+ parsed = rec.get("parsed_action") or {}
90
+ if isinstance(parsed, dict) and parsed.get("action"):
91
+ actions[str(parsed["action"])] += 1
92
+ elif phase == "npc_fallback":
93
+ fallbacks[str(rec.get("reason"))] += 1
94
+ elif phase == "engine_events":
95
+ for event in rec.get("events") or []:
96
+ event_types[str(event.get("type"))] += 1
97
+
98
+ print("\n== parsed LLM actions ==")
99
+ for action, count in actions.most_common():
100
+ print(f" {action}: {count}")
101
+ print("\n== validator verdicts ==")
102
+ for status, count in verdicts.most_common():
103
+ print(f" {status}: {count}")
104
+ print("\n== fallback reasons ==")
105
+ for reason, count in fallbacks.most_common() or [("none", 0)]:
106
+ print(f" {reason}: {count}")
107
+ print("\n== engine event types ==")
108
+ for event_type, count in event_types.most_common():
109
+ print(f" {event_type}: {count}")
110
+
111
+
112
+ if __name__ == "__main__":
113
+ main()
scripts/selfplay_sim.py CHANGED
@@ -20,6 +20,7 @@ from __future__ import annotations
20
 
21
  import argparse
22
  import json
 
23
  from collections import Counter
24
  from pathlib import Path
25
  import sys
@@ -69,25 +70,117 @@ def _unusable_response() -> dict[str, object]:
69
  return {"choices": [{"message": {"role": "assistant", "content": ""}}]}
70
 
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  def make_fake_completer():
73
- """Return a thread-safe completer that decides one action from the prompt."""
74
 
75
  def complete(request: dict[str, object]) -> dict[str, object]:
76
  messages = request.get("messages") or []
77
  user = next((m for m in reversed(messages) if m.get("role") == "user"), None)
78
  if user is None:
79
  return _unusable_response()
80
- payload = json.loads(user["content"])
81
-
82
- npc_id = payload["npc_id"]
83
- tick = int(payload.get("tick", 0))
84
- allowed = set(payload.get("allowed_actions") or [])
85
- own = payload.get("own_state") or {}
86
- inv = own.get("inventory") or {}
87
- country = payload.get("country") or {}
88
- threats = payload.get("nearby_threats") or []
89
- allies = payload.get("nearby_allies") or []
90
- resources = payload.get("reachable_resources") or []
91
  seed = (hash(npc_id) ^ (tick * 2654435761)) & 0xFFFFFFFF
92
 
93
  # Rarely emit an unusable output so the fallback path is visibly logged.
@@ -95,69 +188,52 @@ def make_fake_completer():
95
  return _unusable_response()
96
 
97
  # --- elections: vote when the window is open and we have not voted ---
98
- election = country.get("election") if isinstance(country, dict) else None
99
- if "vote" in allowed and isinstance(election, dict) and not election.get("already_voted"):
100
- candidates = election.get("candidate_ids") or []
101
- if candidates:
102
- choice = candidates[seed % len(candidates)]
103
- return _tool_response("vote", {"candidate_id": choice})
104
-
105
- is_ruler = isinstance(country, dict) and country.get("ruler_id") == npc_id
106
- treasury = country.get("treasury") if isinstance(country, dict) else {}
107
- treasury = treasury if isinstance(treasury, dict) else {}
108
- cannon = country.get("cannon") if isinstance(country, dict) else None
109
- treasury_id = country.get("treasury_id") if isinstance(country, dict) else None
110
- treasury_pos = country.get("treasury_position") if isinstance(country, dict) else None
111
 
112
  # --- ruler drives the cannon programme ---
113
- if is_ruler:
114
- if cannon is None and treasury.get("coins", 0) >= 20 and treasury.get("wood", 0) >= 15:
 
115
  return _tool_response("use", {"use_type": "craft", "params": {"recipe_id": "cannon"}})
116
- if isinstance(cannon, dict) and not cannon.get("operator_id"):
117
  return _tool_response(
118
  "use",
119
  {"use_type": "assign_cannon_operator", "params": {"npc_id": npc_id}},
120
  )
121
- if isinstance(cannon, dict) and cannon.get("operator_id") == npc_id:
122
- if tick >= int(cannon.get("cooldown_until_tick", 0)):
123
- # Fire toward the rival country (mirror image across x=0).
124
- enemy_x = -float((treasury_pos or {}).get("x", 0.0) or 0.0)
125
- return _tool_response(
126
- "use",
127
- {
128
- "use_type": "fire",
129
- "target_entity_id": cannon.get("id"),
130
- "params": {"x": enemy_x, "z": 0.0},
131
- },
132
- )
133
 
134
  # --- survival upkeep ---
135
- if own.get("hunger", 0) >= 45 and inv.get("food", 0) > 0:
136
  return _tool_response("use", {"use_type": "eat", "resource_type": "food"})
137
- if own.get("health", 100) < 55 and inv.get("herbs", 0) > 0:
138
  return _tool_response("use", {"use_type": "heal", "resource_type": "herbs"})
139
 
140
  # --- guards engage live threats ---
141
- if threats and own.get("role") == "guard":
142
  return _tool_response("attack", {"target_entity_id": threats[0]["id"]})
143
 
144
- world_countries = payload.get("world_countries") or []
145
- rival = next(
146
- (c for c in world_countries if isinstance(c, dict) and not c.get("is_own")), None
147
- )
148
- own_pos = own.get("position") or {}
149
 
150
- # --- raid the rival treasury for coins when close enough ---
151
- if rival and rival.get("treasury_position") and (rival.get("treasury", {}).get("coins", 0) > 0):
152
- rpos = rival["treasury_position"]
153
- dist = abs(float(rpos.get("x", 0)) - float(own_pos.get("x", 0))) + abs(
154
- float(rpos.get("z", 0)) - float(own_pos.get("z", 0))
155
- )
156
  if dist <= 6 and seed % 3 == 0:
157
  return _tool_response(
158
  "transfer",
159
  {
160
- "target_entity_id": rival["treasury_id"],
161
  "resource_type": "coins",
162
  "amount": 2,
163
  "take": True,
@@ -165,15 +241,15 @@ def make_fake_completer():
165
  )
166
  if seed % 13 == 0:
167
  # March toward the rival treasury to attempt a raid later.
168
- return _tool_response("move", {"x": float(rpos.get("x", 0)), "z": float(rpos.get("z", 0))})
169
 
170
  # --- periodically deposit surplus into the home treasury ---
171
- if treasury_id and seed % 6 == 0:
172
  for res in ("wood", "food", "herbs"):
173
  if inv.get(res, 0) > 0:
174
  return _tool_response(
175
  "transfer",
176
- {"target_entity_id": treasury_id, "resource_type": res, "amount": 1},
177
  )
178
 
179
  # --- occasionally chat with a nearby ally ---
@@ -181,18 +257,16 @@ def make_fake_completer():
181
  partner = allies[0]
182
  return _tool_response(
183
  "speak",
184
- {"message": f"How is the {partner.get('role', 'work')} going?",
185
- "target_npc_id": partner["id"]},
186
  )
187
 
188
  # --- default: gather the nearest reachable resource, else wander ---
189
  if resources:
190
- node = resources[0]
191
- return _tool_response("use", {"use_type": "gather", "resource_id": node["id"]})
192
 
193
- pos = own.get("position") or {}
194
- x = float(pos.get("x", 0.0)) + ((seed % 7) - 3)
195
- z = float(pos.get("z", 0.0)) + ((seed % 5) - 2)
196
  return _tool_response("move", {"x": x, "z": z})
197
 
198
  return complete
 
20
 
21
  import argparse
22
  import json
23
+ import re
24
  from collections import Counter
25
  from pathlib import Path
26
  import sys
 
70
  return {"choices": [{"message": {"role": "assistant", "content": ""}}]}
71
 
72
 
73
+ def _parse_briefing(content: str) -> dict[str, object]:
74
+ """Extract the structured facts a scripted agent needs from the text briefing.
75
+
76
+ The briefing is plain language for the LLM; this parser doubles as a check
77
+ that every coordinate/id needed to act is actually present in it.
78
+ """
79
+ facts: dict[str, object] = {}
80
+
81
+ m = re.search(r"YOU are .*?\((\S+?)\), a (\w+)", content)
82
+ facts["npc_id"] = m.group(1) if m else "unknown"
83
+ facts["role"] = m.group(2) if m else "gatherer"
84
+
85
+ m = re.search(r"Turn (\d+)", content)
86
+ facts["tick"] = int(m.group(1)) if m else 0
87
+ m = re.search(r"Health (\d+)/100", content)
88
+ facts["health"] = int(m.group(1)) if m else 100
89
+ m = re.search(r"Hunger (\d+)/100", content)
90
+ facts["hunger"] = int(m.group(1)) if m else 0
91
+
92
+ inv: dict[str, int] = {}
93
+ m = re.search(r"Carrying: (.+?)\.", content)
94
+ if m:
95
+ for amount, name in re.findall(r"(\d+) (food|herbs|wood|weapon|coins)", m.group(1)):
96
+ inv[name] = int(amount)
97
+ facts["inventory"] = inv
98
+
99
+ m = re.search(r"YOUR POSITION: X=(-?\d+(?:\.\d+)?) Z=(-?\d+(?:\.\d+)?)", content)
100
+ facts["pos"] = (float(m.group(1)), float(m.group(2))) if m else (0.0, 0.0)
101
+
102
+ facts["is_ruler"] = "Ruler: you." in content
103
+
104
+ m = re.search(
105
+ r"enemy treasury \"(\S+?)\" at X=(-?\d+(?:\.\d+)?) Z=(-?\d+(?:\.\d+)?)", content
106
+ )
107
+ if m:
108
+ facts["enemy_treasury_id"] = m.group(1)
109
+ facts["enemy_treasury_pos"] = (float(m.group(2)), float(m.group(3)))
110
+ m = re.search(r"enemy treasury.*?Holds (\d+) coins", content, re.S)
111
+ facts["enemy_coins"] = int(m.group(1)) if m else 0
112
+
113
+ m = re.search(
114
+ r"Home treasury \"(\S+?)\" at X=(-?\d+(?:\.\d+)?) Z=(-?\d+(?:\.\d+)?)", content
115
+ )
116
+ if m:
117
+ facts["home_treasury_id"] = m.group(1)
118
+ facts["home_treasury_pos"] = (float(m.group(2)), float(m.group(3)))
119
+
120
+ m = re.search(r"Your cannon \"(\S+?)\".*?operator: (\S+?)\)", content)
121
+ if m:
122
+ facts["cannon_id"] = m.group(1)
123
+ facts["cannon_operator"] = None if m.group(2) == "none" else m.group(2)
124
+
125
+ facts["voting_open"] = "ELECTION IN PROGRESS" in content and "You have NOT voted" in content
126
+ m = re.search(r"Candidates: (.+?)\.", content)
127
+ facts["candidates"] = (
128
+ [c.strip() for c in m.group(1).split(",")] if m else []
129
+ )
130
+
131
+ facts["threats"] = _bullets_section(content, "THREATS NEAR YOU")
132
+ facts["allies"] = _bullets_section(content, "ALLIES NEAR YOU")
133
+ facts["resources"] = _bullets_section(content, "RESOURCES NEAR YOU")
134
+
135
+ m = re.search(r"ACTIONS YOU CAN TAKE: (.+?)\.", content)
136
+ facts["allowed"] = (
137
+ {a.strip() for a in m.group(1).split(",")} if m else {"move", "speak"}
138
+ )
139
+ return facts
140
+
141
+
142
+ def _bullets_section(content: str, header: str) -> list[dict[str, object]]:
143
+ """Parse the " - <id> ..." bullet lines under a section header."""
144
+ lines = content.splitlines()
145
+ items: list[dict[str, object]] = []
146
+ capturing = False
147
+ for line in lines:
148
+ if line.startswith(header):
149
+ capturing = True
150
+ continue
151
+ if capturing:
152
+ if line.strip().startswith("- "):
153
+ m = re.search(r"\"(\S+?)\"", line)
154
+ if m:
155
+ items.append(
156
+ {
157
+ "id": m.group(1),
158
+ "can_attack": "IN ATTACK RANGE" in line,
159
+ "gatherable": "close enough to gather" in line,
160
+ }
161
+ )
162
+ elif line.strip() == "" or not line.startswith(" "):
163
+ break
164
+ return items
165
+
166
+
167
  def make_fake_completer():
168
+ """Return a thread-safe completer that decides one action from the briefing."""
169
 
170
  def complete(request: dict[str, object]) -> dict[str, object]:
171
  messages = request.get("messages") or []
172
  user = next((m for m in reversed(messages) if m.get("role") == "user"), None)
173
  if user is None:
174
  return _unusable_response()
175
+ f = _parse_briefing(user["content"])
176
+
177
+ npc_id = str(f["npc_id"])
178
+ tick = int(f["tick"])
179
+ allowed = set(f["allowed"]) # type: ignore[arg-type]
180
+ inv = dict(f["inventory"]) # type: ignore[arg-type]
181
+ threats = list(f["threats"]) # type: ignore[arg-type]
182
+ allies = list(f["allies"]) # type: ignore[arg-type]
183
+ resources = list(f["resources"]) # type: ignore[arg-type]
 
 
184
  seed = (hash(npc_id) ^ (tick * 2654435761)) & 0xFFFFFFFF
185
 
186
  # Rarely emit an unusable output so the fallback path is visibly logged.
 
188
  return _unusable_response()
189
 
190
  # --- elections: vote when the window is open and we have not voted ---
191
+ if "vote" in allowed and f["voting_open"] and f["candidates"]:
192
+ candidates = list(f["candidates"]) # type: ignore[arg-type]
193
+ return _tool_response("vote", {"candidate_id": candidates[seed % len(candidates)]})
 
 
 
 
 
 
 
 
 
 
194
 
195
  # --- ruler drives the cannon programme ---
196
+ if f["is_ruler"]:
197
+ if "cannon_id" not in f and inv.get("coins", 0) >= 0:
198
+ # craft only makes sense from the treasury; the ruler tries it.
199
  return _tool_response("use", {"use_type": "craft", "params": {"recipe_id": "cannon"}})
200
+ if "cannon_id" in f and f.get("cannon_operator") is None:
201
  return _tool_response(
202
  "use",
203
  {"use_type": "assign_cannon_operator", "params": {"npc_id": npc_id}},
204
  )
205
+ if "cannon_id" in f and f.get("cannon_operator") == npc_id and "enemy_treasury_pos" in f:
206
+ ex, ez = f["enemy_treasury_pos"] # type: ignore[misc]
207
+ return _tool_response(
208
+ "use",
209
+ {
210
+ "use_type": "fire",
211
+ "target_entity_id": f["cannon_id"],
212
+ "params": {"x": ex, "z": ez},
213
+ },
214
+ )
 
 
215
 
216
  # --- survival upkeep ---
217
+ if int(f["hunger"]) >= 45 and inv.get("food", 0) > 0:
218
  return _tool_response("use", {"use_type": "eat", "resource_type": "food"})
219
+ if int(f["health"]) < 55 and inv.get("herbs", 0) > 0:
220
  return _tool_response("use", {"use_type": "heal", "resource_type": "herbs"})
221
 
222
  # --- guards engage live threats ---
223
+ if threats and f["role"] == "guard":
224
  return _tool_response("attack", {"target_entity_id": threats[0]["id"]})
225
 
226
+ own_x, own_z = f["pos"] # type: ignore[misc]
 
 
 
 
227
 
228
+ # --- raid the rival treasury for coins ---
229
+ if "enemy_treasury_pos" in f and int(f["enemy_coins"]) > 0:
230
+ ex, ez = f["enemy_treasury_pos"] # type: ignore[misc]
231
+ dist = abs(ex - own_x) + abs(ez - own_z)
 
 
232
  if dist <= 6 and seed % 3 == 0:
233
  return _tool_response(
234
  "transfer",
235
  {
236
+ "target_id": f["enemy_treasury_id"],
237
  "resource_type": "coins",
238
  "amount": 2,
239
  "take": True,
 
241
  )
242
  if seed % 13 == 0:
243
  # March toward the rival treasury to attempt a raid later.
244
+ return _tool_response("move", {"x": ex, "z": ez})
245
 
246
  # --- periodically deposit surplus into the home treasury ---
247
+ if "home_treasury_id" in f and seed % 6 == 0:
248
  for res in ("wood", "food", "herbs"):
249
  if inv.get(res, 0) > 0:
250
  return _tool_response(
251
  "transfer",
252
+ {"target_id": f["home_treasury_id"], "resource_type": res, "amount": 1},
253
  )
254
 
255
  # --- occasionally chat with a nearby ally ---
 
257
  partner = allies[0]
258
  return _tool_response(
259
  "speak",
260
+ {"message": "Let us raid the enemy treasury together!",
261
+ "target_id": partner["id"]},
262
  )
263
 
264
  # --- default: gather the nearest reachable resource, else wander ---
265
  if resources:
266
+ return _tool_response("use", {"use_type": "gather", "resource_id": resources[0]["id"]})
 
267
 
268
+ x = own_x + ((seed % 7) - 3)
269
+ z = own_z + ((seed % 5) - 2)
 
270
  return _tool_response("move", {"x": x, "z": z})
271
 
272
  return complete
src/world_simulator/simulation/connectors/openai_compatible.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import json
 
4
  import os
5
  import time
6
  from collections.abc import Callable, Sequence
@@ -13,7 +14,6 @@ from openai import OpenAI
13
  from world_simulator.api.modal_auth import modal_proxy_auth_headers_for
14
  from world_simulator.config import ConnectorConfig
15
  from world_simulator.domain import Npc, Vec3, WorldState
16
- from world_simulator.simulation.directives import active_directive_payload
17
  from world_simulator.simulation.actions import ActionHints, build_action_hints
18
  from world_simulator.simulation.autopilot import autopilot_directive
19
  from world_simulator.simulation.connectors.base import (
@@ -38,6 +38,7 @@ from world_simulator.simulation.memory import llm_compact_memory, recent_meaning
38
  from world_simulator.simulation.perception import CitizenPerception
39
  from world_simulator.simulation.roles import normalize_role
40
  from world_simulator.simulation.survival import (
 
41
  active_election_for_npc,
42
  is_survival_world,
43
  allowed_actions_for_npc,
@@ -754,83 +755,390 @@ def _build_survival_messages(
754
  *,
755
  allowed_actions: Sequence[str],
756
  ) -> list[dict[str, str]]:
757
- """Build a compact single-NPC survival prompt."""
758
- payload = {
759
- "npc_id": npc.id,
760
- "tick": next_tick,
761
- "own_state": {
762
- "name": npc.name,
763
- "role": npc.role,
764
- "position": {"x": npc.position.x, "z": npc.position.z},
765
- "health": round(npc.health),
766
- "hunger": round(npc.hunger),
767
- "fear": round(npc.fear),
768
- "safety": round(npc.safety),
769
- "age": npc.age,
770
- "max_age": npc.max_age,
771
- "reproduction_cooldown": npc.reproduction_cooldown,
772
- "importance": npc.importance,
773
- "goal": goal,
774
- "inventory": {
775
- "food": npc.inventory_food,
776
- "herbs": npc.inventory_herbs,
777
- "wood": npc.inventory_wood,
778
- "weapon": npc.inventory_weapon,
779
- "coins": npc.inventory_coins,
780
- },
781
- },
782
- "goal": goal,
783
- "country": _country_payload(world, npc),
784
- "world_countries": _world_countries_payload(world, npc),
785
- "active_directive": active_directive_payload(npc, tick=next_tick),
786
- "nearby_threats": _survival_nearby_threats(world, npc),
787
- "nearby_allies": _survival_nearby_allies(world, npc),
788
- "reachable_resources": _survival_reachable_resources(world, npc),
789
- "houses_with_free_capacity": _survival_houses_with_capacity(world),
790
- "top_memories": _top_memory_payload(npc, current_tick=next_tick),
791
- "allowed_actions": list(allowed_actions),
792
- "suggested_actions": list(suggested_actions),
793
- "behavior_rules": _role_rules_for_prompt(npc.role),
794
- }
795
 
796
  return [
797
  {
798
  "role": "system",
799
- "content": (
800
- f"You are {npc.name}, a {npc.role}. Call exactly one tool for the next tick. "
801
- "Use only allowed_actions and visible ids from the payload. Do not invent ids. "
802
- "Your role is context, not a tool restriction. "
803
- "Do not write prose. The engine validates and applies effects."
804
- ),
805
  },
806
  {
807
  "role": "user",
808
- "content": json.dumps(payload, ensure_ascii=False),
 
 
 
 
809
  },
810
  ]
811
 
812
 
813
- def _role_rules_for_prompt(role: str) -> list[str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
814
  if role == "guard":
815
- return [
816
- "Protect living villagers and houses from live threats.",
817
- "You tend to attack visible beasts or hostile live NPCs.",
818
- "When no threat is reachable, you tend to move through patrol routes.",
819
- "You can still use, speak, move or transfer when the situation calls for it.",
820
- ]
821
  if role == "builder":
822
- return [
823
- "You tend to build, repair, and gather wood when work is safe.",
824
- "You tend to seek shelter or flee from live threats.",
825
- "Share or ask for supplies only with live nearby allies.",
826
- "You can still use the same base tools as everyone else.",
827
- ]
828
- return [
829
- "You tend to gather reachable food, herbs, wood, or weapons when safe.",
830
- "Eat or heal before routine work when urgent.",
831
- "Flee or call for help from live threats.",
832
- "Transfer supplies only to live nearby allies.",
833
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
834
 
835
 
836
  def _survival_nearby_threats(world: WorldState, npc: Npc) -> list[dict[str, Any]]:
@@ -901,74 +1209,6 @@ def _survival_nearby_allies(world: WorldState, npc: Npc) -> list[dict[str, Any]]
901
  return sorted(allies, key=lambda item: (item["distance"], item["id"]))[:5]
902
 
903
 
904
- def _country_payload(world: WorldState, npc: Npc) -> dict[str, Any] | None:
905
- if npc.country_id is None:
906
- return None
907
- country = next((item for item in world.countries if item.id == npc.country_id), None)
908
- if country is None:
909
- return None
910
- election = active_election_for_npc(world, npc)
911
- return {
912
- "id": country.id,
913
- "name": country.name,
914
- "badge": country.badge,
915
- "ruler_id": country.ruler_id,
916
- "policy": country.policy,
917
- "treasury": dict(country.treasury.resources) if country.treasury else {},
918
- "treasury_id": country.treasury.id if country.treasury else None,
919
- "treasury_position": None
920
- if country.treasury is None
921
- else {"x": country.treasury.position.x, "z": country.treasury.position.z},
922
- "cannon": None
923
- if country.cannon is None
924
- else {
925
- "id": country.cannon.id,
926
- "position": {"x": country.cannon.position.x, "z": country.cannon.position.z},
927
- "operator_id": country.cannon.operator_id,
928
- "cooldown_until_tick": country.cannon.cooldown_until_tick,
929
- },
930
- "next_election_tick": country.next_election_tick,
931
- "election": None
932
- if election is None
933
- else {
934
- "start_tick": election.start_tick,
935
- "end_tick": election.end_tick,
936
- "candidate_ids": list(election.candidate_ids),
937
- "already_voted": npc.id in election.votes,
938
- },
939
- }
940
-
941
-
942
- def _world_countries_payload(world: WorldState, npc: Npc) -> list[dict[str, Any]]:
943
- """Public info on every country so an NPC knows the rival, its ruler/policy,
944
- and where the rival treasury/cannon is (needed to steal or strike)."""
945
- overview: list[dict[str, Any]] = []
946
- for country in world.countries:
947
- ruler = next((c for c in world.npcs if c.id == country.ruler_id), None)
948
- treasury_pos = (
949
- None
950
- if country.treasury is None
951
- else {"x": country.treasury.position.x, "z": country.treasury.position.z}
952
- )
953
- overview.append(
954
- {
955
- "id": country.id,
956
- "name": country.name,
957
- "is_own": country.id == npc.country_id,
958
- "ruler_id": country.ruler_id,
959
- "ruler_name": ruler.name if ruler else None,
960
- "policy": country.policy,
961
- "population": len(country.citizen_ids),
962
- "treasury_id": country.treasury.id if country.treasury else None,
963
- "treasury_position": treasury_pos,
964
- # The rival's stored resources are public knowledge worth stealing.
965
- "treasury": dict(country.treasury.resources) if country.treasury else {},
966
- "cannon_id": country.cannon.id if country.cannon else None,
967
- }
968
- )
969
- return overview
970
-
971
-
972
  def _survival_reachable_resources(world: WorldState, npc: Npc) -> list[dict[str, Any]]:
973
  resources: list[dict[str, Any]] = []
974
  for node in world.resource_nodes:
@@ -1008,26 +1248,6 @@ def _survival_houses_with_capacity(world: WorldState) -> list[dict[str, Any]]:
1008
  return houses[:5]
1009
 
1010
 
1011
- def _top_memory_payload(npc: Npc, *, current_tick: int) -> list[dict[str, Any]]:
1012
- episodes = sorted(
1013
- npc.structured_memory.episodes,
1014
- key=lambda episode: (episode.emotional_weight, episode.tick),
1015
- reverse=True,
1016
- )[:5]
1017
- return [
1018
- {
1019
- "age_ticks": max(0, current_tick - episode.tick),
1020
- "kind": episode.kind,
1021
- "actor_id": episode.actor_id,
1022
- "target_id": episode.target_id,
1023
- "summary": episode.summary,
1024
- "weight": episode.emotional_weight,
1025
- "tags": list(episode.tags),
1026
- }
1027
- for episode in sorted(episodes, key=lambda episode: episode.tick)
1028
- ]
1029
-
1030
-
1031
  def _npc_context(
1032
  world: WorldState,
1033
  npc: Npc,
 
1
  from __future__ import annotations
2
 
3
  import json
4
+ import math
5
  import os
6
  import time
7
  from collections.abc import Callable, Sequence
 
14
  from world_simulator.api.modal_auth import modal_proxy_auth_headers_for
15
  from world_simulator.config import ConnectorConfig
16
  from world_simulator.domain import Npc, Vec3, WorldState
 
17
  from world_simulator.simulation.actions import ActionHints, build_action_hints
18
  from world_simulator.simulation.autopilot import autopilot_directive
19
  from world_simulator.simulation.connectors.base import (
 
38
  from world_simulator.simulation.perception import CitizenPerception
39
  from world_simulator.simulation.roles import normalize_role
40
  from world_simulator.simulation.survival import (
41
+ GATHER_RADIUS,
42
  active_election_for_npc,
43
  is_survival_world,
44
  allowed_actions_for_npc,
 
755
  *,
756
  allowed_actions: Sequence[str],
757
  ) -> list[dict[str, str]]:
758
+ """Build a single-NPC survival prompt as a readable mission briefing.
759
+
760
+ The model reads a plain-language briefing (identity, needs, spatial bearings
761
+ to every object, the enemy treasury, allies, resources) instead of a raw
762
+ JSON dump. Coordinates and ids are still printed verbatim so they can be
763
+ copied straight into a tool call.
764
+ """
765
+ country = next((c for c in world.countries if c.id == npc.country_id), None)
766
+ country_name = country.name if country else "your nation"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
767
 
768
  return [
769
  {
770
  "role": "system",
771
+ "content": _survival_system_prompt(npc, country_name, allowed_actions),
 
 
 
 
 
772
  },
773
  {
774
  "role": "user",
775
+ "content": "\n".join(
776
+ _survival_briefing_lines(
777
+ world, next_tick, npc, goal, country, allowed_actions
778
+ )
779
+ ),
780
  },
781
  ]
782
 
783
 
784
+ def _survival_system_prompt(
785
+ npc: Npc, country_name: str, allowed_actions: Sequence[str]
786
+ ) -> str:
787
+ lines = [
788
+ f"You are {npc.name}, a {npc.role} of {country_name}. You are a living "
789
+ "person who wants to SURVIVE and help your nation WIN THE WAR against the "
790
+ "rival nation.",
791
+ "Read the BRIEFING below, decide your single best next action, then call "
792
+ "exactly one tool. NEVER write prose, JSON, or explanations.",
793
+ "CRITICAL RULES:",
794
+ "- To go ANYWHERE you MUST call move with BOTH x and z set to the "
795
+ "destination coordinates from the briefing. A move without x and z just "
796
+ "wanders in place and WASTES the turn.",
797
+ "- You travel only about 1 step per turn, so keep moving toward the SAME "
798
+ "coordinates for many turns to cross the map. Each turn the briefing shows "
799
+ "your distance to the enemy: if it is NOT shrinking, you are going the "
800
+ "wrong way.",
801
+ "- EAT when your hunger is high and HEAL when your health is low, or you "
802
+ "will DIE.",
803
+ "- To loot the enemy: MOVE to the enemy treasury coordinates over several "
804
+ "turns, and once you are next to it call transfer with take=true on that "
805
+ "treasury id.",
806
+ "- COORDINATE with allies: when you speak, give a concrete plan with exact "
807
+ "coordinates, e.g. \"March to X=80 Z=6 and raid the enemy treasury "
808
+ "together\".",
809
+ "- Use ONLY the ids and coordinates printed in the briefing. NEVER invent "
810
+ "an id.",
811
+ ]
812
+ return "\n".join(lines)
813
+
814
+
815
+ def _survival_briefing_lines(
816
+ world: WorldState,
817
+ next_tick: int,
818
+ npc: Npc,
819
+ goal: str,
820
+ country: Any,
821
+ allowed_actions: Sequence[str],
822
+ ) -> list[str]:
823
+ pos = npc.position
824
+ lines: list[str] = []
825
+
826
+ # --- identity + needs -------------------------------------------------- #
827
+ lines.append(
828
+ f"YOU are {npc.name} ({npc.id}), a {npc.role}"
829
+ + (f" of {country.name}" if country else "")
830
+ + f". Turn {next_tick}."
831
+ )
832
+ lines.append(
833
+ f"Health {round(npc.health)}/100 ({_health_word(npc.health)}). "
834
+ f"Hunger {round(npc.hunger)}/100 ({_hunger_word(npc.hunger)})."
835
+ )
836
+ carrying = _inventory_phrase(npc)
837
+ if carrying:
838
+ lines.append(f"Carrying: {carrying}.")
839
+ lines.append(f"YOUR POSITION: {_loc(pos.x, pos.z)}.")
840
+ lines.append(f"RIGHT NOW your instinct says: {_goal_word(goal)}.")
841
+
842
+ # --- urgent survival directives (spelled out like raid/gather) ---------- #
843
+ if npc.hunger >= 60 and npc.inventory_food > 0:
844
+ lines.append(
845
+ "*** YOU ARE STARVING and you CARRY food. EAT IT NOW: "
846
+ "use use_type=eat resource_type=food. ***"
847
+ )
848
+ if npc.health < 50 and npc.inventory_herbs > 0:
849
+ lines.append(
850
+ "*** YOU ARE HURT and you CARRY herbs. HEAL NOW: "
851
+ "use use_type=heal resource_type=herbs. ***"
852
+ )
853
+
854
+ # --- feedback on last turn -------------------------------------------- #
855
+ feedback = _last_action_feedback(world, npc.id)
856
+ if feedback:
857
+ lines.append(f"LAST TURN: {feedback}")
858
+ heard = _recent_speech_heard(npc)
859
+ if heard:
860
+ lines.append("YOU HEARD: " + " ".join(heard))
861
+
862
+ # --- enemy nation (raid target) --------------------------------------- #
863
+ rival = next(
864
+ (c for c in world.countries if c.id != npc.country_id), None
865
+ )
866
+ lines.append("")
867
+ if rival is not None:
868
+ lines.append(f"ENEMY NATION - {rival.name}:")
869
+ ruler = next((c for c in world.npcs if c.id == rival.ruler_id), None)
870
+ lines.append(
871
+ f" Ruler: {ruler.name if ruler else 'unknown'}. "
872
+ f"Citizens: {len(rival.citizen_ids)}."
873
+ )
874
+ if rival.treasury is not None:
875
+ tpos = rival.treasury.position
876
+ coins = rival.treasury.resources.get("coins", 0)
877
+ wood = rival.treasury.resources.get("wood", 0)
878
+ lines.append(
879
+ f" LOOT TARGET - enemy treasury \"{rival.treasury.id}\" at "
880
+ f"{_loc(tpos.x, tpos.z)} - {_bearing_to(pos, tpos.x, tpos.z)}. "
881
+ f"Holds {coins} coins, {wood} wood."
882
+ )
883
+ lines.append(
884
+ f" TO RAID IT: move x={_num(tpos.x)} z={_num(tpos.z)} every turn "
885
+ f"until you arrive, then transfer take=true "
886
+ f"target_id={rival.treasury.id} resource_type=coins."
887
+ )
888
+
889
+ # --- your nation ------------------------------------------------------- #
890
+ if country is not None:
891
+ lines.append("")
892
+ lines.append(f"YOUR NATION - {country.name}:")
893
+ ruler = next((c for c in world.npcs if c.id == country.ruler_id), None)
894
+ ruler_label = (
895
+ "you" if country.ruler_id == npc.id else (ruler.name if ruler else "unknown")
896
+ )
897
+ lines.append(f" Ruler: {ruler_label}. Policy: {country.policy}")
898
+ if country.treasury is not None:
899
+ tpos = country.treasury.position
900
+ res = country.treasury.resources
901
+ lines.append(
902
+ f" Home treasury \"{country.treasury.id}\" at {_loc(tpos.x, tpos.z)} "
903
+ f"({_bearing_to(pos, tpos.x, tpos.z)}). Holds "
904
+ f"{res.get('coins', 0)} coins, {res.get('wood', 0)} wood, "
905
+ f"{res.get('food', 0)} food. (Deposit with transfer "
906
+ f"target_id={country.treasury.id}.)"
907
+ )
908
+ if country.cannon is not None:
909
+ cpos = country.cannon.position
910
+ op = country.cannon.operator_id or "none"
911
+ lines.append(
912
+ f" Your cannon \"{country.cannon.id}\" at {_loc(cpos.x, cpos.z)} "
913
+ f"(operator: {op})."
914
+ )
915
+ election = active_election_for_npc(world, npc, tick=next_tick)
916
+ if election is not None and "vote" in allowed_actions:
917
+ voted = npc.id in election.votes
918
+ cands = ", ".join(election.candidate_ids)
919
+ lines.append(
920
+ f" ELECTION IN PROGRESS: vote for your next ruler. "
921
+ f"Candidates: {cands}. "
922
+ + ("You already voted." if voted else "You have NOT voted yet - vote now.")
923
+ )
924
+
925
+ # --- threats ----------------------------------------------------------- #
926
+ threats = _survival_nearby_threats(world, npc)
927
+ lines.append("")
928
+ if threats:
929
+ lines.append("THREATS NEAR YOU:")
930
+ for t in threats:
931
+ tp = t.get("position") or {}
932
+ where = (
933
+ _bearing_to(pos, tp["x"], tp["z"]) + f" ({_loc(tp['x'], tp['z'])})"
934
+ if "x" in tp
935
+ else f"{t['distance']:.0f} units away"
936
+ )
937
+ reach = (
938
+ "IN ATTACK RANGE - attack now"
939
+ if t.get("can_attack")
940
+ else "too far - move toward it to attack"
941
+ )
942
+ name = t.get("name") or t.get("kind", "threat")
943
+ lines.append(
944
+ f" - {t['kind'].upper()} \"{t['id']}\" ({name}) {where}. {reach}."
945
+ )
946
+ else:
947
+ lines.append("THREATS NEAR YOU: none.")
948
+
949
+ # --- allies ------------------------------------------------------------ #
950
+ allies = _survival_nearby_allies(world, npc)
951
+ lines.append("")
952
+ if allies:
953
+ lines.append("ALLIES NEAR YOU (talk to coordinate; <=12 units to speak):")
954
+ for a in allies:
955
+ ap = _npc_position(world, a["id"])
956
+ where = (
957
+ f"{_bearing_to(pos, ap.x, ap.z)} ({_loc(ap.x, ap.z)})"
958
+ if ap is not None
959
+ else f"{a['distance']:.0f} units away"
960
+ )
961
+ lines.append(
962
+ f" - {a['name']} \"{a['id']}\" ({a['role']}) {where}. "
963
+ f"HP {a['hp']}, hunger {a['hunger']}."
964
+ )
965
+ else:
966
+ lines.append("ALLIES NEAR YOU: none in sight.")
967
+
968
+ # --- resources --------------------------------------------------------- #
969
+ resources = _survival_reachable_resources(world, npc)
970
+ lines.append("")
971
+ if resources:
972
+ lines.append("RESOURCES NEAR YOU (gather with use use_type=gather resource_id=<id>):")
973
+ for r in resources:
974
+ rp = r.get("position") or {}
975
+ where = (
976
+ f"{_bearing_to(pos, rp['x'], rp['z'])} ({_loc(rp['x'], rp['z'])})"
977
+ if "x" in rp
978
+ else f"{r['distance']:.0f} units away"
979
+ )
980
+ close = " - close enough to gather" if r["distance"] <= GATHER_RADIUS else ""
981
+ lines.append(
982
+ f" - {r['type']} node \"{r['id']}\" {where}, {r['amount']} left{close}."
983
+ )
984
+ else:
985
+ lines.append("RESOURCES NEAR YOU: none in sight (move to find some).")
986
+
987
+ # --- shelter ----------------------------------------------------------- #
988
+ houses = _survival_houses_with_capacity(world)
989
+ if houses:
990
+ h = houses[0]
991
+ hp = h.get("position") or {}
992
+ if "x" in hp:
993
+ lines.append(
994
+ f"SHELTER: house \"{h['id']}\" at {_loc(hp['x'], hp['z'])} "
995
+ f"({_bearing_to(pos, hp['x'], hp['z'])}), {h['free_capacity']} free spots."
996
+ )
997
+
998
+ # --- action menu ------------------------------------------------------- #
999
+ lines.append("")
1000
+ lines.append("ACTIONS YOU CAN TAKE: " + ", ".join(allowed_actions) + ".")
1001
+ lines.append(_role_tip(npc.role))
1002
+ return lines
1003
+
1004
+
1005
+ def _role_tip(role: str) -> str:
1006
  if role == "guard":
1007
+ return (
1008
+ "As a guard you should hunt beasts and hostile enemies, defend allies, "
1009
+ "and lead raids on the enemy."
1010
+ )
 
 
1011
  if role == "builder":
1012
+ return (
1013
+ "As a builder you gather wood and raise shelter, but you still fight "
1014
+ "and raid when your nation needs it."
1015
+ )
1016
+ return (
1017
+ "As a gatherer you keep food and supplies flowing, but you still join "
1018
+ "raids and defend your nation when needed."
1019
+ )
1020
+
1021
+
1022
+ def _health_word(health: float) -> str:
1023
+ if health <= 30:
1024
+ return "CRITICAL - heal now"
1025
+ if health <= 65:
1026
+ return "hurt"
1027
+ return "healthy"
1028
+
1029
+
1030
+ def _hunger_word(hunger: float) -> str:
1031
+ if hunger >= 70:
1032
+ return "STARVING - eat now"
1033
+ if hunger >= 40:
1034
+ return "hungry"
1035
+ return "fine"
1036
+
1037
+
1038
+ def _goal_word(goal: str) -> str:
1039
+ return {
1040
+ "dead": "nothing - you are dead",
1041
+ "help_ally": "rush to help an ally under attack",
1042
+ "engage_threat": "fight the nearby threat",
1043
+ "survive_threat_flee": "flee from the nearby beast",
1044
+ "heal_self": "heal yourself with herbs",
1045
+ "eat_food": "eat to stop starving",
1046
+ "find_food": "find and gather food",
1047
+ "find_herbs": "find healing herbs",
1048
+ "obey_directive": "follow your orders",
1049
+ "settle": "you are safe - push the war effort",
1050
+ "work": "do useful work or advance the war",
1051
+ }.get(goal, goal.replace("_", " "))
1052
+
1053
+
1054
+ def _inventory_phrase(npc: Npc) -> str:
1055
+ parts = []
1056
+ for label, amount in (
1057
+ ("food", npc.inventory_food),
1058
+ ("herbs", npc.inventory_herbs),
1059
+ ("wood", npc.inventory_wood),
1060
+ ("weapon", npc.inventory_weapon),
1061
+ ("coins", npc.inventory_coins),
1062
+ ):
1063
+ if amount:
1064
+ parts.append(f"{amount} {label}")
1065
+ return ", ".join(parts)
1066
+
1067
+
1068
+ def _last_action_feedback(world: WorldState, npc_id: str) -> str | None:
1069
+ """Plain-language result of this NPC's previous turn, for self-correction."""
1070
+ for trace in reversed(world.last_action_debug):
1071
+ if trace.get("npc_id") != npc_id:
1072
+ continue
1073
+ summary = trace.get("summary")
1074
+ if isinstance(summary, str) and summary:
1075
+ requested = trace.get("requested_action")
1076
+ resolved = trace.get("action")
1077
+ if requested and resolved and requested != resolved:
1078
+ return f"you tried to {requested}; result: {summary}."
1079
+ return f"{summary}."
1080
+ resolution = trace.get("resolution")
1081
+ if isinstance(resolution, dict) and isinstance(resolution.get("summary"), str):
1082
+ return f"{resolution['summary']}."
1083
+ return None
1084
+
1085
+
1086
+ def _recent_speech_heard(npc: Npc) -> list[str]:
1087
+ """Recent things other NPCs said to/near this NPC, for conversational replies."""
1088
+ heard: list[str] = []
1089
+ for memory in recent_meaningful_memories(npc):
1090
+ text = memory.text.strip()
1091
+ lowered = text.lower()
1092
+ if "said:" in lowered or "told me:" in lowered or "asked you" in lowered:
1093
+ if not lowered.startswith("you "):
1094
+ heard.append(f"\"{text}\"")
1095
+ return heard[-3:]
1096
+
1097
+
1098
+ def _npc_position(world: WorldState, npc_id: str) -> Vec3 | None:
1099
+ for other in world.npcs:
1100
+ if other.id == npc_id:
1101
+ return other.position
1102
+ return None
1103
+
1104
+
1105
+ def _num(value: float) -> str:
1106
+ rounded = round(value)
1107
+ return str(int(rounded)) if abs(value - rounded) < 0.05 else f"{value:.1f}"
1108
+
1109
+
1110
+ def _loc(x: float, z: float) -> str:
1111
+ return f"X={_num(x)} Z={_num(z)}"
1112
+
1113
+
1114
+ def _steps_between(units: float) -> int:
1115
+ return max(0, round(units / BLOCK_SIZE))
1116
+
1117
+
1118
+ def _compass(self_pos: Vec3, x: float, z: float) -> str:
1119
+ """8-point compass from self toward (x, z). +X=East, +Z=South, -Z=North."""
1120
+ dx = x - self_pos.x
1121
+ dz = z - self_pos.z
1122
+ threshold = BLOCK_SIZE # ignore deltas smaller than one step on an axis
1123
+ ns = "NORTH" if dz < -threshold else ("SOUTH" if dz > threshold else "")
1124
+ ew = "EAST" if dx > threshold else ("WEST" if dx < -threshold else "")
1125
+ # When one axis dominates, drop the minor axis so a far-east target reads
1126
+ # "EAST", not "SOUTHEAST" for a 6-unit drift across 160 units.
1127
+ if ns and ew:
1128
+ if abs(dx) >= 2.5 * abs(dz):
1129
+ ns = ""
1130
+ elif abs(dz) >= 2.5 * abs(dx):
1131
+ ew = ""
1132
+ return (ns + ew) or "right here"
1133
+
1134
+
1135
+ def _bearing_to(self_pos: Vec3, x: float, z: float) -> str:
1136
+ dist = math.hypot(x - self_pos.x, z - self_pos.z)
1137
+ steps = _steps_between(dist)
1138
+ direction = _compass(self_pos, x, z)
1139
+ if direction == "right here":
1140
+ return "right next to you"
1141
+ return f"{steps} steps {direction}"
1142
 
1143
 
1144
  def _survival_nearby_threats(world: WorldState, npc: Npc) -> list[dict[str, Any]]:
 
1209
  return sorted(allies, key=lambda item: (item["distance"], item["id"]))[:5]
1210
 
1211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1212
  def _survival_reachable_resources(world: WorldState, npc: Npc) -> list[dict[str, Any]]:
1213
  resources: list[dict[str, Any]] = []
1214
  for node in world.resource_nodes:
 
1248
  return houses[:5]
1249
 
1250
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1251
  def _npc_context(
1252
  world: WorldState,
1253
  npc: Npc,
tests/test_country_systems.py CHANGED
@@ -184,10 +184,10 @@ def test_treasury_steal_via_transfer_take() -> None:
184
  # Perception exposes rival countries + own position
185
  # --------------------------------------------------------------------------- #
186
  def test_survival_prompt_exposes_rival_countries() -> None:
187
- captured: list[dict[str, Any]] = []
188
 
189
  def completer(request: dict[str, Any]) -> dict[str, Any]:
190
- captured.append(json.loads(request["messages"][1]["content"]))
191
  return _move_completer(request)
192
 
193
  world = create_world(_survival_config())
@@ -200,16 +200,18 @@ def test_survival_prompt_exposes_rival_countries() -> None:
200
  simulator.propose_tick(world, 1)
201
 
202
  assert captured
203
- payload = captured[0]
204
- assert "position" in payload["own_state"]
205
- overview = payload["world_countries"]
206
- assert {c["id"] for c in overview} == {"nemotron", "qwen"}
207
- own = next(c for c in overview if c["is_own"])
208
- rival = next(c for c in overview if not c["is_own"])
209
- assert own["id"] == "nemotron"
210
- assert rival["id"] == "qwen"
211
- assert rival["treasury_id"] == "treasury_qwen"
212
- assert "coins" in rival["treasury"]
 
 
213
 
214
 
215
  # --------------------------------------------------------------------------- #
 
184
  # Perception exposes rival countries + own position
185
  # --------------------------------------------------------------------------- #
186
  def test_survival_prompt_exposes_rival_countries() -> None:
187
+ captured: list[str] = []
188
 
189
  def completer(request: dict[str, Any]) -> dict[str, Any]:
190
+ captured.append(request["messages"][1]["content"])
191
  return _move_completer(request)
192
 
193
  world = create_world(_survival_config())
 
200
  simulator.propose_tick(world, 1)
201
 
202
  assert captured
203
+ # The briefing is a readable text mission brief, not a JSON dump. It must
204
+ # still name the rival nation, its treasury id, the loot coordinates, and
205
+ # the NPC's own position so a model can navigate and raid.
206
+ briefing = captured[0]
207
+ assert "ENEMY NATION" in briefing
208
+ assert "Qwen" in briefing
209
+ assert "treasury_qwen" in briefing
210
+ assert "YOUR POSITION:" in briefing
211
+ assert "X=" in briefing and "Z=" in briefing
212
+ # No raw abstract junk leaks into the prompt anymore.
213
+ assert "max_age" not in briefing
214
+ assert "reproduction_cooldown" not in briefing
215
 
216
 
217
  # --------------------------------------------------------------------------- #
tests/test_observability_perception.py CHANGED
@@ -31,13 +31,13 @@ from world_simulator.simulation.tick import advance_world
31
  def test_dead_npcs_never_appear_in_model_target_fields_over_scripted_deaths() -> None:
32
  world = _survival_world(npc_count=8)
33
  world.beasts.clear()
34
- captured_payloads: list[dict[str, Any]] = []
35
 
36
  def fake_chat_completer(request: dict[str, Any]) -> dict[str, Any]:
37
- payload = json.loads(request["messages"][1]["content"])
38
- captured_payloads.append(payload)
39
- npc_id = payload.get("npc_id") or payload.get("npc", {}).get("id")
40
- return _tool_response("speak", {"npc_id": npc_id, "message": "observing"})
41
 
42
  simulator = OpenAICompatibleWorldSimulator(
43
  ConnectorConfig(
@@ -178,11 +178,12 @@ def test_runtime_ledger_contains_all_required_phase_types_after_20_ticks() -> No
178
 
179
  def _ledger_test_simulator() -> OpenAICompatibleWorldSimulator:
180
  def fake_chat_completer(request: dict[str, Any]) -> dict[str, Any]:
181
- payload = json.loads(request["messages"][1]["content"])
182
- npc_id = payload.get("npc_id") or payload.get("npc", {}).get("id")
183
- if npc_id == "npc-001":
 
184
  raise RuntimeError("scripted model outage")
185
- return _tool_response("speak", {"npc_id": npc_id, "message": "observing"})
186
 
187
  return OpenAICompatibleWorldSimulator(
188
  ConnectorConfig(
@@ -197,28 +198,18 @@ def _ledger_test_simulator() -> OpenAICompatibleWorldSimulator:
197
 
198
 
199
  def _assert_survival_payload_has_no_dead_targets(
200
- payload: dict[str, Any],
201
  world: WorldState,
202
  dead_ids: set[str],
203
  ) -> None:
204
- payload_npc_id = payload.get("npc_id") or payload.get("npc", {}).get("id")
205
- assert payload_npc_id not in dead_ids
206
- dead_names = {
207
- npc.name.lower()
208
- for npc in world.npcs
209
- if npc.id in dead_ids
210
- }
211
- for field in ("nearby_threats", "nearby_allies"):
212
- for item in payload[field]:
213
- assert item.get("id") not in dead_ids
214
- assert item.get("npc_id") not in dead_ids
215
- for house in payload["houses_with_free_capacity"]:
216
- assert not (set(house.get("occupants", [])) & dead_ids)
217
- active_directive = payload.get("active_directive")
218
- if active_directive:
219
- text = str(active_directive.get("text", "")).lower()
220
- assert not any(dead_id.lower() in text for dead_id in dead_ids)
221
- assert not any(name in text for name in dead_names)
222
 
223
  def _assert_world_live_registries_have_no_dead_targets(
224
  world: WorldState,
 
31
  def test_dead_npcs_never_appear_in_model_target_fields_over_scripted_deaths() -> None:
32
  world = _survival_world(npc_count=8)
33
  world.beasts.clear()
34
+ captured_payloads: list[str] = []
35
 
36
  def fake_chat_completer(request: dict[str, Any]) -> dict[str, Any]:
37
+ # The survival prompt is now a readable text briefing; the connector
38
+ # injects the npc_id onto the tool call for us.
39
+ captured_payloads.append(request["messages"][1]["content"])
40
+ return _tool_response("speak", {"message": "observing"})
41
 
42
  simulator = OpenAICompatibleWorldSimulator(
43
  ConnectorConfig(
 
178
 
179
  def _ledger_test_simulator() -> OpenAICompatibleWorldSimulator:
180
  def fake_chat_completer(request: dict[str, Any]) -> dict[str, Any]:
181
+ # The briefing prints the NPC id in plain text. Force a model outage for
182
+ # npc-001 so the deterministic fallback path is exercised/logged.
183
+ content = request["messages"][1]["content"]
184
+ if "(npc-001)" in content:
185
  raise RuntimeError("scripted model outage")
186
+ return _tool_response("speak", {"message": "observing"})
187
 
188
  return OpenAICompatibleWorldSimulator(
189
  ConnectorConfig(
 
198
 
199
 
200
  def _assert_survival_payload_has_no_dead_targets(
201
+ briefing: str,
202
  world: WorldState,
203
  dead_ids: set[str],
204
  ) -> None:
205
+ # Allies, threats, resources and shelter are all rendered as " - ..." bullet
206
+ # lines. A dead NPC must never be offered as a target there.
207
+ bullet_lines = [
208
+ line for line in briefing.splitlines() if line.strip().startswith("- ")
209
+ ]
210
+ for dead_id in dead_ids:
211
+ for line in bullet_lines:
212
+ assert dead_id not in line, f"dead {dead_id} leaked into briefing: {line}"
 
 
 
 
 
 
 
 
 
 
213
 
214
  def _assert_world_live_registries_have_no_dead_targets(
215
  world: WorldState,
tests/test_survival_loop.py CHANGED
@@ -333,12 +333,13 @@ def test_transfer_shares_food_with_needier_neighbor() -> None:
333
  # Extra: the LLM connector gets a compact survival prompt and its chosen survival
334
  # action resolves through the engine without breaking the tick.
335
  def test_llm_connector_drives_survival_actions() -> None:
336
- captured: list[dict[str, Any]] = []
337
 
338
  def fake_chat_completer(request: dict[str, Any]) -> dict[str, Any]:
339
- payload = json.loads(request["messages"][1]["content"])
340
- captured.append(payload)
341
- action = payload["suggested_actions"][0] if payload["suggested_actions"] else "idle"
 
342
  return {
343
  "choices": [
344
  {
@@ -367,11 +368,12 @@ def test_llm_connector_drives_survival_actions() -> None:
367
  advance_world(world, simulator)
368
 
369
  assert world.tick == 1
370
- assert captured # the compact survival prompt was built and sent
371
- # Every captured prompt is the compact survival shape, not the social payload.
372
- assert all("suggested_actions" in payload and "goal" in payload for payload in captured)
373
- assert all("own_state" in payload and "inventory" in payload["own_state"] for payload in captured)
374
- assert all("allowed_actions" in payload and "behavior_rules" in payload for payload in captured)
 
375
  assert "openai_compatible" in world.last_tick_source
376
 
377
 
 
333
  # Extra: the LLM connector gets a compact survival prompt and its chosen survival
334
  # action resolves through the engine without breaking the tick.
335
  def test_llm_connector_drives_survival_actions() -> None:
336
+ captured: list[str] = []
337
 
338
  def fake_chat_completer(request: dict[str, Any]) -> dict[str, Any]:
339
+ # The survival prompt is a readable text briefing; pick the first tool the
340
+ # connector offered (always valid) instead of parsing a JSON payload.
341
+ captured.append(request["messages"][1]["content"])
342
+ action = request["tools"][0]["function"]["name"]
343
  return {
344
  "choices": [
345
  {
 
368
  advance_world(world, simulator)
369
 
370
  assert world.tick == 1
371
+ assert captured # the survival briefing was built and sent
372
+ # Every captured prompt is the readable survival briefing, not a JSON dump.
373
+ assert all("YOU are" in briefing for briefing in captured)
374
+ assert all("ENEMY NATION" in briefing for briefing in captured)
375
+ assert all("ACTIONS YOU CAN TAKE:" in briefing for briefing in captured)
376
+ assert all("max_age" not in briefing for briefing in captured)
377
  assert "openai_compatible" in world.last_tick_source
378
 
379