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 +1 -0
- dist/frontend/assets/index-DxRbocHC.js +0 -0
- reports/llm_io_election_debug_20260614T140845Z.md +0 -0
- reports/npc_prompt_refactor_20260614.md +104 -0
- scripts/live_sim.py +113 -0
- scripts/selfplay_sim.py +139 -65
- src/world_simulator/simulation/connectors/openai_compatible.py +373 -153
- tests/test_country_systems.py +14 -12
- tests/test_observability_perception.py +19 -28
- tests/test_survival_loop.py +11 -9
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
|
| 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 |
-
|
| 81 |
-
|
| 82 |
-
npc_id =
|
| 83 |
-
tick = int(
|
| 84 |
-
allowed = set(
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 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 |
-
|
| 99 |
-
|
| 100 |
-
|
| 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
|
|
|
|
| 115 |
return _tool_response("use", {"use_type": "craft", "params": {"recipe_id": "cannon"}})
|
| 116 |
-
if
|
| 117 |
return _tool_response(
|
| 118 |
"use",
|
| 119 |
{"use_type": "assign_cannon_operator", "params": {"npc_id": npc_id}},
|
| 120 |
)
|
| 121 |
-
if
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
"
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
},
|
| 132 |
-
)
|
| 133 |
|
| 134 |
# --- survival upkeep ---
|
| 135 |
-
if
|
| 136 |
return _tool_response("use", {"use_type": "eat", "resource_type": "food"})
|
| 137 |
-
if
|
| 138 |
return _tool_response("use", {"use_type": "heal", "resource_type": "herbs"})
|
| 139 |
|
| 140 |
# --- guards engage live threats ---
|
| 141 |
-
if threats and
|
| 142 |
return _tool_response("attack", {"target_entity_id": threats[0]["id"]})
|
| 143 |
|
| 144 |
-
|
| 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
|
| 151 |
-
if
|
| 152 |
-
|
| 153 |
-
dist = 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 |
-
"
|
| 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":
|
| 169 |
|
| 170 |
# --- periodically deposit surplus into the home treasury ---
|
| 171 |
-
if
|
| 172 |
for res in ("wood", "food", "herbs"):
|
| 173 |
if inv.get(res, 0) > 0:
|
| 174 |
return _tool_response(
|
| 175 |
"transfer",
|
| 176 |
-
{"
|
| 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":
|
| 185 |
-
"
|
| 186 |
)
|
| 187 |
|
| 188 |
# --- default: gather the nearest reachable resource, else wander ---
|
| 189 |
if resources:
|
| 190 |
-
|
| 191 |
-
return _tool_response("use", {"use_type": "gather", "resource_id": node["id"]})
|
| 192 |
|
| 193 |
-
|
| 194 |
-
|
| 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
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 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":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 809 |
},
|
| 810 |
]
|
| 811 |
|
| 812 |
|
| 813 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 814 |
if role == "guard":
|
| 815 |
-
return
|
| 816 |
-
"
|
| 817 |
-
"
|
| 818 |
-
|
| 819 |
-
"You can still use, speak, move or transfer when the situation calls for it.",
|
| 820 |
-
]
|
| 821 |
if role == "builder":
|
| 822 |
-
return
|
| 823 |
-
"
|
| 824 |
-
"
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 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[
|
| 188 |
|
| 189 |
def completer(request: dict[str, Any]) -> dict[str, Any]:
|
| 190 |
-
captured.append(
|
| 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 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
assert
|
| 210 |
-
assert
|
| 211 |
-
assert
|
| 212 |
-
|
|
|
|
|
|
|
| 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[
|
| 35 |
|
| 36 |
def fake_chat_completer(request: dict[str, Any]) -> dict[str, Any]:
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
return _tool_response("speak", {"
|
| 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 |
-
|
| 182 |
-
|
| 183 |
-
|
|
|
|
| 184 |
raise RuntimeError("scripted model outage")
|
| 185 |
-
return _tool_response("speak", {"
|
| 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 |
-
|
| 201 |
world: WorldState,
|
| 202 |
dead_ids: set[str],
|
| 203 |
) -> None:
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 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[
|
| 337 |
|
| 338 |
def fake_chat_completer(request: dict[str, Any]) -> dict[str, Any]:
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
|
|
|
| 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
|
| 371 |
-
# Every captured prompt is the
|
| 372 |
-
assert all("
|
| 373 |
-
assert all("
|
| 374 |
-
assert all("
|
|
|
|
| 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 |
|