diff --git a/apps/game-server/data/schedules/README.md b/apps/game-server/data/schedules/README.md index 1e5ecd1..0fd0a81 100644 --- a/apps/game-server/data/schedules/README.md +++ b/apps/game-server/data/schedules/README.md @@ -23,7 +23,7 @@ JSON Schema:[`schema.json`](./schema.json) | `fromMinute` | 0–1439 | 段开始(含)。`360` = 06:00 | | `toMinute` | 0–1439 | 段结束(**不含**)。`480` = 08:00 | | `activityKey` | enum | 活动 ID → 中文 HUD 由 `@aetherlife/shared` `npcActivity` 映射 | -| `zoneId` | string | `{regionId}:{localZoneId}`,如 `beginning-fields@v1:orchard` | +| `zoneId` | string | `{regionId}:{localZoneId}`,如 `beginning-fields@v1:home`(全图闲逛)或 `…:orchard` / `plaza` / `pond` | | `mobility` | `"wander"` \| `"stationary"` \| `"poi"` | 本段内如何选移动目标(见下) | ### 时间示例 @@ -46,7 +46,7 @@ JSON Schema:[`schema.json`](./schema.json) | Key | 典型场景 | |-----|----------| | `resting` | 睡觉 — **不移动**(`shouldSkipMovement`) | -| `idle` | 无日程 / 非法 key 降级 — **不移动** | +| `idle` | 无日程 / 非法 key 降级标签;**不再**跳过移动(日程发呆请用 `wandering` + `wander`) | | `reading` | 晨读、学习 | | `tending_crops` | 农田劳作 | | `watering` | 浇水 | @@ -55,7 +55,7 @@ JSON Schema:[`schema.json`](./schema.json) | `fishing` | 钓鱼 | | `patrol` | 区域巡逻(常配 `wander`) | | `socializing` | 社交(常配 `wander` 或 `poi`) | -| `wandering` | 背景村民默认活动 | +| `wandering` | 闲逛(原 idle 段并进此项) | | `unknown` | Schema 占位;运行时应被 `validateActivityKey` 转为 `idle` | --- @@ -79,9 +79,11 @@ JSON Schema:[`schema.json`](./schema.json) 格式:`{regionId}:{localId}` - `regionId`:如 `beginning-fields@v1`(含版本,便于换图) -- `localId`:registry 内 zone,如 `orchard`、`pond`、`plaza` +- `localId`:registry 内 zone,如 `home`(全图)、`orchard`、`pond`、`plaza` -zone 矩形来自 `apps/game-server/data/world/.../zones.json`(经 WorldRegistry 加载)。 +zone 矩形来自 `apps/game-server/data/world/.../zones.json`(经 WorldRegistry 加载;与 `packages/shared` `defaultBeginningFieldsBundle` 双 SSOT)。 + +动森风格:白天主段用 `mobility: wander` + `zoneId: …:home` 逛整张可走区;短时 `stationary`/`poi` 绑定人物专属场点(果园/广场/池塘)。 --- @@ -89,5 +91,6 @@ zone 矩形来自 `apps/game-server/data/world/.../zones.json`(经 WorldRegist 1. 改 JSON 后跑 `pnpm --filter @aetherlife/game-server test -- src/ambient/schedule.test.ts` 2. 段边界不要重叠(同一分钟只应命中一段) -3. 长 `stationary` 段现在会有 linger;若要 **真·不动**,用 `resting` 或 `idle`,不要用 `stationary` +3. `resting` 才完全不移动;发呆请用 `wandering` + `wander` 4. 新 activity 需同步 `@aetherlife/shared` `NPC_ACTIVITY_KEYS` 与 `schema.json` enum +5. 改 zones 须同步 `zones.json` 与 `defaultBeginningFieldsBundle` diff --git a/apps/game-server/data/schedules/npc-1.json b/apps/game-server/data/schedules/npc-1.json index 8d3923e..fe4972b 100644 --- a/apps/game-server/data/schedules/npc-1.json +++ b/apps/game-server/data/schedules/npc-1.json @@ -1,7 +1,7 @@ { "npcId": "npc-1", "persona": "order_keeper", - "_comment": "Order keeper — AM stationary orchard study, PM patrol orchard, evening plaza social, night orchard rest.", + "_comment": "Order keeper — morning orchard reading, day home patrol, evening plaza social, night rest.", "segments": [ { "fromMinute": 360, @@ -12,16 +12,16 @@ }, { "fromMinute": 480, - "toMinute": 720, + "toMinute": 900, "activityKey": "patrol", - "zoneId": "beginning-fields@v1:orchard", + "zoneId": "beginning-fields@v1:home", "mobility": "wander" }, { - "fromMinute": 720, + "fromMinute": 900, "toMinute": 1080, "activityKey": "socializing", - "zoneId": "beginning-fields@v1:plaza", + "zoneId": "beginning-fields@v1:home", "mobility": "wander" }, { diff --git a/apps/game-server/data/schedules/npc-10.json b/apps/game-server/data/schedules/npc-10.json index cb11524..86e1de7 100644 --- a/apps/game-server/data/schedules/npc-10.json +++ b/apps/game-server/data/schedules/npc-10.json @@ -1,20 +1,20 @@ { "npcId": "npc-10", "persona": "brawler", - "_comment": "Brawler — AM plaza patrol, PM plaza wander, evening orchard chopping.", + "_comment": "Brawler — home patrol/roam by day; evening wood chopping at orchard; night rest plaza.", "segments": [ { "fromMinute": 360, "toMinute": 720, "activityKey": "patrol", - "zoneId": "beginning-fields@v1:plaza", + "zoneId": "beginning-fields@v1:home", "mobility": "wander" }, { "fromMinute": 720, "toMinute": 1080, "activityKey": "wandering", - "zoneId": "beginning-fields@v1:plaza", + "zoneId": "beginning-fields@v1:home", "mobility": "wander" }, { diff --git a/apps/game-server/data/schedules/npc-11.json b/apps/game-server/data/schedules/npc-11.json index 29f86d2..21f34c8 100644 --- a/apps/game-server/data/schedules/npc-11.json +++ b/apps/game-server/data/schedules/npc-11.json @@ -1,24 +1,31 @@ { "npcId": "npc-11", "persona": "perfectionist", - "_comment": "Perfectionist — AM orchard tending stationary, PM orchard watering, evening orchard reading.", + "_comment": "Perfectionist — morning orchard chores, day careful home roam, evening reading, night rest.", "segments": [ { "fromMinute": 360, - "toMinute": 540, + "toMinute": 480, "activityKey": "tending_crops", "zoneId": "beginning-fields@v1:orchard", "mobility": "stationary" }, { - "fromMinute": 540, - "toMinute": 900, + "fromMinute": 480, + "toMinute": 720, "activityKey": "watering", "zoneId": "beginning-fields@v1:orchard", "mobility": "stationary" }, { - "fromMinute": 900, + "fromMinute": 720, + "toMinute": 1080, + "activityKey": "wandering", + "zoneId": "beginning-fields@v1:home", + "mobility": "wander" + }, + { + "fromMinute": 1080, "toMinute": 1200, "activityKey": "reading", "zoneId": "beginning-fields@v1:orchard", diff --git a/apps/game-server/data/schedules/npc-12.json b/apps/game-server/data/schedules/npc-12.json index 9eb4958..5931b82 100644 --- a/apps/game-server/data/schedules/npc-12.json +++ b/apps/game-server/data/schedules/npc-12.json @@ -1,31 +1,24 @@ { "npcId": "npc-12", "persona": "explorer", - "_comment": "Explorer — AM orchard wander, PM plaza patrol, evening pond fishing, night plaza wander.", + "_comment": "Explorer — maximal home roam; short fishing/social; night still wandering (light sleeper).", "segments": [ { "fromMinute": 360, - "toMinute": 540, - "activityKey": "wandering", - "zoneId": "beginning-fields@v1:orchard", - "mobility": "wander" - }, - { - "fromMinute": 540, "toMinute": 900, - "activityKey": "patrol", - "zoneId": "beginning-fields@v1:plaza", + "activityKey": "wandering", + "zoneId": "beginning-fields@v1:home", "mobility": "wander" }, { "fromMinute": 900, - "toMinute": 1080, + "toMinute": 1020, "activityKey": "fishing", "zoneId": "beginning-fields@v1:pond", "mobility": "stationary" }, { - "fromMinute": 1080, + "fromMinute": 1020, "toMinute": 1200, "activityKey": "socializing", "zoneId": "beginning-fields@v1:plaza", @@ -35,7 +28,7 @@ "fromMinute": 1200, "toMinute": 360, "activityKey": "wandering", - "zoneId": "beginning-fields@v1:plaza", + "zoneId": "beginning-fields@v1:home", "mobility": "wander" } ] diff --git a/apps/game-server/data/schedules/npc-2.json b/apps/game-server/data/schedules/npc-2.json index acd5094..8f525d7 100644 --- a/apps/game-server/data/schedules/npc-2.json +++ b/apps/game-server/data/schedules/npc-2.json @@ -1,20 +1,20 @@ { "npcId": "npc-2", "persona": "expansionist", - "_comment": "Expansionist — AM orchard crops, PM wander plaza outreach, evening orchard patrol, night pond rest.", + "_comment": "Expansionist — morning tend orchard, day roam whole home, evening plaza, night rest pond.", "segments": [ { "fromMinute": 360, - "toMinute": 540, + "toMinute": 480, "activityKey": "tending_crops", "zoneId": "beginning-fields@v1:orchard", "mobility": "stationary" }, { - "fromMinute": 540, + "fromMinute": 480, "toMinute": 900, - "activityKey": "patrol", - "zoneId": "beginning-fields@v1:plaza", + "activityKey": "wandering", + "zoneId": "beginning-fields@v1:home", "mobility": "wander" }, { @@ -29,7 +29,7 @@ "toMinute": 1200, "activityKey": "watering", "zoneId": "beginning-fields@v1:orchard", - "mobility": "wander" + "mobility": "stationary" }, { "fromMinute": 1200, diff --git a/apps/game-server/data/schedules/npc-3.json b/apps/game-server/data/schedules/npc-3.json index d2520d9..611288a 100644 --- a/apps/game-server/data/schedules/npc-3.json +++ b/apps/game-server/data/schedules/npc-3.json @@ -1,27 +1,34 @@ { "npcId": "npc-3", "persona": "logician", - "_comment": "Logician — AM pond reading, midday plaza debate poi, evening plaza patrol, night pond rest.", + "_comment": "Logician — morning pond reading, day home roam, evening plaza social, night rest pond.", "segments": [ { "fromMinute": 360, - "toMinute": 540, + "toMinute": 480, "activityKey": "reading", "zoneId": "beginning-fields@v1:pond", "mobility": "stationary" }, { - "fromMinute": 540, + "fromMinute": 480, "toMinute": 900, + "activityKey": "wandering", + "zoneId": "beginning-fields@v1:home", + "mobility": "wander" + }, + { + "fromMinute": 900, + "toMinute": 1080, "activityKey": "socializing", "zoneId": "beginning-fields@v1:plaza", "mobility": "poi" }, { - "fromMinute": 900, + "fromMinute": 1080, "toMinute": 1200, "activityKey": "patrol", - "zoneId": "beginning-fields@v1:plaza", + "zoneId": "beginning-fields@v1:home", "mobility": "wander" }, { diff --git a/apps/game-server/data/schedules/npc-4.json b/apps/game-server/data/schedules/npc-4.json index 5eee9a0..b6f119b 100644 --- a/apps/game-server/data/schedules/npc-4.json +++ b/apps/game-server/data/schedules/npc-4.json @@ -1,34 +1,27 @@ { "npcId": "npc-4", "persona": "chaos_agent", - "_comment": "Chaos agent — AM idle plaza, PM wander plaza, night wandering plaza.", + "_comment": "Chaos — nearly always roaming full home; short plaza social; rarely rests.", "segments": [ { "fromMinute": 360, - "toMinute": 720, - "activityKey": "idle", - "zoneId": "beginning-fields@v1:plaza", - "mobility": "wander" - }, - { - "fromMinute": 720, - "toMinute": 1080, + "toMinute": 900, "activityKey": "wandering", - "zoneId": "beginning-fields@v1:plaza", + "zoneId": "beginning-fields@v1:home", "mobility": "wander" }, { - "fromMinute": 1080, - "toMinute": 1200, + "fromMinute": 900, + "toMinute": 1080, "activityKey": "socializing", "zoneId": "beginning-fields@v1:plaza", "mobility": "poi" }, { - "fromMinute": 1200, + "fromMinute": 1080, "toMinute": 360, "activityKey": "wandering", - "zoneId": "beginning-fields@v1:plaza", + "zoneId": "beginning-fields@v1:home", "mobility": "wander" } ] diff --git a/apps/game-server/data/schedules/npc-5.json b/apps/game-server/data/schedules/npc-5.json index 3ce5c77..fef086f 100644 --- a/apps/game-server/data/schedules/npc-5.json +++ b/apps/game-server/data/schedules/npc-5.json @@ -1,26 +1,26 @@ { "npcId": "npc-5", "persona": "pacifist", - "_comment": "Pacifist — AM pond fishing, midday orchard tending, evening pond rest.", + "_comment": "Pacifist — morning/evening pond fishing linger; midday peaceful home roam.", "segments": [ { "fromMinute": 360, - "toMinute": 540, + "toMinute": 480, "activityKey": "fishing", "zoneId": "beginning-fields@v1:pond", "mobility": "stationary" }, { - "fromMinute": 540, + "fromMinute": 480, "toMinute": 900, - "activityKey": "tending_crops", - "zoneId": "beginning-fields@v1:orchard", - "mobility": "stationary" + "activityKey": "wandering", + "zoneId": "beginning-fields@v1:home", + "mobility": "wander" }, { "fromMinute": 900, "toMinute": 1080, - "activityKey": "watering", + "activityKey": "tending_crops", "zoneId": "beginning-fields@v1:orchard", "mobility": "stationary" }, diff --git a/apps/game-server/data/schedules/npc-6.json b/apps/game-server/data/schedules/npc-6.json index b3da8a1..adeba4d 100644 --- a/apps/game-server/data/schedules/npc-6.json +++ b/apps/game-server/data/schedules/npc-6.json @@ -1,27 +1,34 @@ { "npcId": "npc-6", "persona": "power_broker", - "_comment": "Power broker — AM orchard reading, PM plaza social poi, evening plaza patrol.", + "_comment": "Power broker — morning reading, day home roam + plaza networking, night rest orchard.", "segments": [ { "fromMinute": 360, - "toMinute": 540, + "toMinute": 480, "activityKey": "reading", "zoneId": "beginning-fields@v1:orchard", "mobility": "stationary" }, { - "fromMinute": 540, - "toMinute": 900, + "fromMinute": 480, + "toMinute": 720, + "activityKey": "wandering", + "zoneId": "beginning-fields@v1:home", + "mobility": "wander" + }, + { + "fromMinute": 720, + "toMinute": 1080, "activityKey": "socializing", "zoneId": "beginning-fields@v1:plaza", "mobility": "poi" }, { - "fromMinute": 900, + "fromMinute": 1080, "toMinute": 1200, - "activityKey": "patrol", - "zoneId": "beginning-fields@v1:plaza", + "activityKey": "socializing", + "zoneId": "beginning-fields@v1:home", "mobility": "wander" }, { diff --git a/apps/game-server/data/schedules/npc-7.json b/apps/game-server/data/schedules/npc-7.json index f9a0a40..f471b20 100644 --- a/apps/game-server/data/schedules/npc-7.json +++ b/apps/game-server/data/schedules/npc-7.json @@ -1,14 +1,14 @@ { "npcId": "npc-7", "persona": "mediator", - "_comment": "Mediator — AM pond idle, midday plaza social poi, evening orchard stroll.", + "_comment": "Mediator — morning home roam, midday plaza social, evening orchard stroll, night rest pond.", "segments": [ { "fromMinute": 360, "toMinute": 540, - "activityKey": "idle", - "zoneId": "beginning-fields@v1:pond", - "mobility": "stationary" + "activityKey": "wandering", + "zoneId": "beginning-fields@v1:home", + "mobility": "wander" }, { "fromMinute": 540, @@ -21,7 +21,7 @@ "fromMinute": 900, "toMinute": 1200, "activityKey": "patrol", - "zoneId": "beginning-fields@v1:orchard", + "zoneId": "beginning-fields@v1:home", "mobility": "wander" }, { diff --git a/apps/game-server/data/schedules/npc-8.json b/apps/game-server/data/schedules/npc-8.json index 5608034..cb39ab7 100644 --- a/apps/game-server/data/schedules/npc-8.json +++ b/apps/game-server/data/schedules/npc-8.json @@ -1,27 +1,27 @@ { "npcId": "npc-8", "persona": "guardian", - "_comment": "Guardian — AM orchard patrol, PM orchard stationary watch, evening plaza patrol.", + "_comment": "Guardian — patrol full home most of day; brief plaza post; night rest orchard.", "segments": [ { "fromMinute": 360, "toMinute": 720, "activityKey": "patrol", - "zoneId": "beginning-fields@v1:orchard", + "zoneId": "beginning-fields@v1:home", "mobility": "wander" }, { "fromMinute": 720, - "toMinute": 1080, + "toMinute": 900, "activityKey": "patrol", - "zoneId": "beginning-fields@v1:orchard", + "zoneId": "beginning-fields@v1:plaza", "mobility": "stationary" }, { - "fromMinute": 1080, + "fromMinute": 900, "toMinute": 1200, "activityKey": "patrol", - "zoneId": "beginning-fields@v1:plaza", + "zoneId": "beginning-fields@v1:home", "mobility": "wander" }, { diff --git a/apps/game-server/data/schedules/npc-9.json b/apps/game-server/data/schedules/npc-9.json index 16319e4..70997b7 100644 --- a/apps/game-server/data/schedules/npc-9.json +++ b/apps/game-server/data/schedules/npc-9.json @@ -1,27 +1,27 @@ { "npcId": "npc-9", "persona": "aesthete", - "_comment": "Aesthete — AM orchard reading, PM plaza social wander, evening pond fishing.", + "_comment": "Aesthete — morning orchard reading, day home roam for beauty, evening pond, night rest orchard.", "segments": [ { "fromMinute": 360, - "toMinute": 540, + "toMinute": 480, "activityKey": "reading", "zoneId": "beginning-fields@v1:orchard", "mobility": "stationary" }, { - "fromMinute": 540, + "fromMinute": 480, "toMinute": 900, - "activityKey": "socializing", - "zoneId": "beginning-fields@v1:plaza", + "activityKey": "wandering", + "zoneId": "beginning-fields@v1:home", "mobility": "wander" }, { "fromMinute": 900, "toMinute": 1080, - "activityKey": "wandering", - "zoneId": "beginning-fields@v1:orchard", + "activityKey": "socializing", + "zoneId": "beginning-fields@v1:home", "mobility": "wander" }, { diff --git a/apps/game-server/data/world/beginning-fields@v1/spawns.json b/apps/game-server/data/world/beginning-fields@v1/spawns.json index 19a3fb7..82afbb8 100644 --- a/apps/game-server/data/world/beginning-fields@v1/spawns.json +++ b/apps/game-server/data/world/beginning-fields@v1/spawns.json @@ -1,17 +1,17 @@ { "defaultPlayerSpawn": { "lx": 34, "ly": 13 }, "councilSpawns": [ - { "x": 9, "y": 21, "facing": "s", "maxRadius": 0 }, - { "x": 9, "y": 5, "facing": "s", "maxRadius": 0 }, - { "x": 23, "y": 11, "facing": "e", "maxRadius": 0 }, - { "x": 31, "y": 13, "facing": "w", "maxRadius": 0 }, - { "x": 17, "y": 13, "facing": "e", "maxRadius": 0 }, - { "x": 33, "y": 28, "facing": "n", "maxRadius": 0 }, - { "x": 20, "y": 26, "facing": "s", "maxRadius": 0 }, - { "x": 16, "y": 31, "facing": "n", "maxRadius": 0 }, - { "x": 27, "y": 27, "facing": "w", "maxRadius": 0 }, - { "x": 29, "y": 17, "facing": "s", "maxRadius": 0 }, - { "x": 5, "y": 9, "facing": "e", "maxRadius": 0 }, - { "x": 17, "y": 22, "facing": "s", "maxRadius": 0 } + { "x": 9, "y": 21, "facing": "s", "maxRadius": 40 }, + { "x": 9, "y": 5, "facing": "s", "maxRadius": 40 }, + { "x": 23, "y": 11, "facing": "e", "maxRadius": 40 }, + { "x": 31, "y": 13, "facing": "w", "maxRadius": 40 }, + { "x": 17, "y": 13, "facing": "e", "maxRadius": 40 }, + { "x": 33, "y": 28, "facing": "n", "maxRadius": 40 }, + { "x": 20, "y": 26, "facing": "s", "maxRadius": 40 }, + { "x": 16, "y": 31, "facing": "n", "maxRadius": 40 }, + { "x": 27, "y": 27, "facing": "w", "maxRadius": 40 }, + { "x": 29, "y": 17, "facing": "s", "maxRadius": 40 }, + { "x": 5, "y": 9, "facing": "e", "maxRadius": 40 }, + { "x": 17, "y": 22, "facing": "s", "maxRadius": 40 } ] } diff --git a/apps/game-server/data/world/beginning-fields@v1/zones.json b/apps/game-server/data/world/beginning-fields@v1/zones.json index b7bc83c..a429cb4 100644 --- a/apps/game-server/data/world/beginning-fields@v1/zones.json +++ b/apps/game-server/data/world/beginning-fields@v1/zones.json @@ -1,5 +1,10 @@ { "zones": [ + { + "id": "home", + "labelZh": "起始田野(全图)", + "rect": { "lx": 0, "ly": 0, "w": 40, "h": 40 } + }, { "id": "orchard", "labelZh": "果园", diff --git a/apps/game-server/src/ambient/README.md b/apps/game-server/src/ambient/README.md index 204faa3..e12db85 100644 --- a/apps/game-server/src/ambient/README.md +++ b/apps/game-server/src/ambient/README.md @@ -24,16 +24,18 @@ Phase 16 权威 ambient 逻辑:`schedule.ts`(日程)→ `tick.ts`(6s tic |------|------| | `fromMinute` / `toMinute` | 半开区间 `[from, to)`;`to < from` 表示跨午夜(如 22:00→06:00)。 | | `activityKey` | HUD / 铭牌活动文案(如 `reading`、`patrol`)。未知 key 加载时降为 `idle`。 | -| `zoneId` | 命名空间 zone,形如 `beginning-fields@v1:orchard`。 wander/linger 只在该矩形内选格。 | +| `zoneId` | 命名空间 zone,形如 `beginning-fields@v1:home`(全图)或 `…:orchard` / `plaza` / `pond`。 wander/linger 只在该矩形内选格(碰撞过滤)。 | | `mobility` | 移动模式,见下表。 | ### `mobility` 与移动行为 +走步资格(Phase **26.2 / 动森式**):**B2 `shouldStepThisTick`**(wander ~55% / linger ~30%;`ambient-step:{npcId}:{minute}`;`join_vicinity` 绕过)决定是否**新开一程**;通过后持有 **walking | pausing**——到达停 **2–8** tick 再抽目标;**mid-walk 不重掷 B2**,每 tick 最多 1 格。**同 tick 可多名 NPC 移动**。仅 `resting` 经上游 `shouldSkipMovement` 完全跳过。 + | 值 | 选目标策略 | 是否每 tick 都动 | |----|------------|------------------| -| `wander` | 在 `zoneId` 内随机可走格;社交段见下方 bias | 每 tick 尝试(仍受 speak 占用、碰撞、玩家格阻挡) | -| `stationary` | **Linger**:当前位置 Chebyshev **≤ `LINGER_RADIUS`** 格内微 wander | 约 **`100 - LINGER_PAUSE_PERCENT`%** tick 会选新目标;其余 tick 原地停 | -| `poi` | 优先走向区域内 **social POI**(如 well);若已在 POI 或未找到,则同 `stationary` 的 linger | 同 linger | +| `wander` | 在 `zoneId` 内选可走格(避开占用格,偏好个人空间 ≥2);社交段见下方 bias | walking 时每 tick 尝试步进;到达后 pausing 2–8 tick | +| `stationary` | **Linger**:当前位置 Chebyshev **≤ `LINGER_RADIUS`**;若在 zone 外则 **通勤到最近 zone 格** | 同上 + linger 本地 `LINGER_PAUSE_PERCENT` | +| `poi` | 优先走向区域内 **social POI**(若未占用);否则同 linger / zone pick | 同上 | ### 完全不移动的 activity @@ -42,9 +44,8 @@ Phase 16 权威 ambient 逻辑:`schedule.ts`(日程)→ `tick.ts`(6s tic | 条件 | 说明 | |------|------| | `activityKey === "resting"` | 睡觉段(如午夜–06:00) | -| `activityKey === "idle"` | 无有效日程或未知活动降级 | -**注意:** `mobility: "stationary"` **不再** 跳过移动;晨间 `reading` / `cooking` 等会 linger 微动。 +日程里的「发呆」请用 `wandering` + `wander`(26.2 gap:原 `idle` 段已并进 wander)。未知 `activityKey` 仍会 coerce 为标签 `idle`,但**不再**因此跳过移动。 --- @@ -54,10 +55,18 @@ Phase 16 权威 ambient 逻辑:`schedule.ts`(日程)→ `tick.ts`(6s tic | 参数 | 导出 | 默认 | 含义 | |------|------|------|------| -| `LINGER_RADIUS` | 是 | `2` | Chebyshev 距离(格)。`stationary` / `poi` 模式下,目标格必须在 NPC 当前位置 **≤ 此半径** 内。越大越像「在区域里闲逛」,越小越像「原地小动作」。 | -| `LINGER_PAUSE_PERCENT` | 是 | `30` | 每个 tick、每名 NPC **原地不动** 的概率(%)。哈希键:`linger:{npcId}:{gameMinute}`,同一游戏分钟 deterministic,不同 NPC/分钟错开。调高 → 更常停住;调低 → 更碎步。 | +| `LINGER_RADIUS` | 是 | `2` | Chebyshev 距离(格)。`stationary` / `poi` 在 zone 内时,目标格必须在 NPC 当前位置 **≤ 此半径** 内。 | +| `LINGER_PAUSE_PERCENT` | 是 | `15` | linger 选目标时额外原地概率(%)。哈希键:`linger:{npcId}:{gameMinute}`。 | +| `PERSONAL_SPACE` | 是 | `2` | 选目标时优先与其他 NPC 至少此距离;**永不叠格**(有空闲格时排除占用/本 tick 已预约格);擦肩路过允许。 | | `MAX_RECENT` | 否 | `8` | 最近访问格 deque 长度,避免 linger/wander 在 2–3 格间来回抖。 | +Walk/pause(`tick.ts`): + +| 参数 | 默认 | 含义 | +|------|------|------| +| `WALK_TIMEOUT_TICKS` | `48` | 走路超时强制重抽目标(防卡死) | +| `ambientPauseTicks` | 2–8 | 到达后停顿时长(hash `ambient-pause:{npcId}:{gameMinute}`) | + --- ## Wander 社交软 bias @@ -73,7 +82,7 @@ Phase 16 权威 ambient 逻辑:`schedule.ts`(日程)→ `tick.ts`(6s tic | 类型 | ID 模式 | 移动来源 | |------|---------|----------| -| 主 NPC | `npc-1`…`npc-3` | `data/schedules/npc-*.json` + 可选 worker **intent cache** | +| 主 NPC | `npc-1`…`npc-12`(议会 12 席) | `data/schedules/npc-*.json` + 可选 worker **intent cache** | | 背景 NPC | `bg-villager-*` | 合成段 `backgroundWanderSegment`:恒 `mobility: wander`,`activityKey: wandering` | 主 NPC 在 `npcSpeakJobs` 中有 job 时 **整 tick 跳过**(对话优先)。 @@ -107,5 +116,5 @@ Phase 16 权威 ambient 逻辑:`schedule.ts`(日程)→ `tick.ts`(6s tic ```bash pnpm --filter @aetherlife/game-server test -- src/ambient/ pnpm agent:verify -# 实机:pnpm dev:stack → 06:00 进房,观察 npc-1/2/3 在 reading/cooking 段内 2 格内微动 +# 实机:pnpm dev:stack → 06:00 进房,观察议会席在 reading/cooking 段内 linger 微动(B2 多 NPC 同 tick) ``` diff --git a/apps/game-server/src/ambient/schedule.test.ts b/apps/game-server/src/ambient/schedule.test.ts index efb3c9b..60b9db4 100644 --- a/apps/game-server/src/ambient/schedule.test.ts +++ b/apps/game-server/src/ambient/schedule.test.ts @@ -12,7 +12,7 @@ import { } from "./schedule.js"; describe("shouldSkipMovement / isLingerMobility", () => { - it("skips only idle and resting", () => { + it("skips only resting (idle schedules walk as wander)", () => { expect( shouldSkipMovement({ fromMinute: 0, @@ -22,6 +22,15 @@ describe("shouldSkipMovement / isLingerMobility", () => { mobility: "stationary", }), ).toBe(true); + expect( + shouldSkipMovement({ + fromMinute: 360, + toMinute: 720, + activityKey: "idle", + zoneId: "beginning-fields@v1:plaza", + mobility: "wander", + }), + ).toBe(false); expect( shouldSkipMovement({ fromMinute: 360, @@ -106,32 +115,44 @@ describe("validateNpcSchedulesAgainstRegistry (T-16-01)", () => { }); describe("hybrid persona schedules (D-zone-persona-hybrid)", () => { - it("npc-1 has AM stationary, PM wander, evening stationary", () => { + it("npc-1 has AM orchard linger then home wander patrol", () => { const schedule = getNpcSchedule("npc-1")!; - const am = schedule.segments.filter((s) => s.fromMinute >= 360 && s.toMinute <= 720); - expect(am.some((s) => s.mobility === "stationary")).toBe(true); - const pm = schedule.segments.filter((s) => s.fromMinute >= 720 && s.toMinute <= 1080); - expect(pm.some((s) => s.mobility === "wander")).toBe(true); - const evening = schedule.segments.filter((s) => s.fromMinute >= 1080 && s.toMinute <= 1200); - expect(evening.some((s) => s.mobility === "stationary")).toBe(true); + const morning = schedule.segments.find((s) => s.fromMinute === 360)!; + expect(morning.mobility).toBe("stationary"); + expect(morning.zoneId).toBe("beginning-fields@v1:orchard"); + const day = schedule.segments.find((s) => s.fromMinute === 480)!; + expect(day.mobility).toBe("wander"); + expect(day.zoneId).toBe("beginning-fields@v1:home"); }); - it("npc-2 expansionist has AM orchard stationary and PM plaza wander", () => { + it("npc-2 expansionist has AM orchard stationary and day home roam", () => { const schedule = getNpcSchedule("npc-2")!; expect(schedule.persona).toBe("expansionist"); const morning = schedule.segments.find((s) => s.fromMinute === 360)!; expect(morning.mobility).toBe("stationary"); expect(morning.zoneId).toBe("beginning-fields@v1:orchard"); - const afternoon = schedule.segments.find((s) => s.fromMinute === 540)!; - expect(afternoon.mobility).toBe("wander"); - expect(afternoon.zoneId).toBe("beginning-fields@v1:plaza"); + const day = schedule.segments.find((s) => s.fromMinute === 480)!; + expect(day.mobility).toBe("wander"); + expect(day.zoneId).toBe("beginning-fields@v1:home"); }); - it("npc-3 logician has evening socialize poi at plaza", () => { + it("npc-3 logician has midday plaza socialize poi", () => { const schedule = getNpcSchedule("npc-3")!; expect(schedule.persona).toBe("logician"); const socialize = schedule.segments.find((s) => s.activityKey === "socializing")!; expect(socialize.mobility).toBe("poi"); expect(socialize.zoneId).toBe("beginning-fields@v1:plaza"); }); + + it("every council seat has at least one beginning-fields@v1:home wander segment", () => { + for (const id of COUNCIL_NPC_IDS) { + const schedule = getNpcSchedule(id)!; + expect( + schedule.segments.some( + (s) => s.zoneId === "beginning-fields@v1:home" && s.mobility === "wander", + ), + `${id} should roam home`, + ).toBe(true); + } + }); }); diff --git a/apps/game-server/src/ambient/schedule.ts b/apps/game-server/src/ambient/schedule.ts index 974f63b..479f4ed 100644 --- a/apps/game-server/src/ambient/schedule.ts +++ b/apps/game-server/src/ambient/schedule.ts @@ -49,9 +49,9 @@ export function minuteInSegment(minute: number, fromMinute: number, toMinute: nu return m >= from || m < to; } -/** True sleep / no movement — resting & idle only. Stationary/poi use zone linger (see ambient/README.md). */ +/** True sleep / no movement — resting only. `idle` schedules merge into wander (26.2 gap); unknown keys still coerce to idle label but may move. */ export function shouldSkipMovement(segment: ScheduleSegment): boolean { - return segment.activityKey === "idle" || segment.activityKey === "resting"; + return segment.activityKey === "resting"; } /** stationary | poi → micro-wander within LINGER_RADIUS; wander → full zone pick. */ diff --git a/apps/game-server/src/ambient/tick.test.ts b/apps/game-server/src/ambient/tick.test.ts index 0162fc5..a52aacd 100644 --- a/apps/game-server/src/ambient/tick.test.ts +++ b/apps/game-server/src/ambient/tick.test.ts @@ -10,10 +10,33 @@ import { } from "@aetherlife/shared"; import { GameRoomState } from "../colyseus/schema.js"; import { clearAllIntentsForTests, setIntent } from "./intent-cache.js"; -import { hashNpcBucket, MAIN_AMBIENT_NPC_IDS, pickJoinVicinityTarget, runAmbientTick, applySoftLeashTarget } from "./tick.js"; +import { + resolveScheduleSegment, + segmentKey, + shouldSkipMovement, + type Mobility, +} from "./schedule.js"; +import { + applySoftLeashTarget, + collectWalkingReservedTargets, + MAIN_AMBIENT_NPC_IDS, + pickJoinVicinityTarget, + runAmbientTick, + shouldStepThisTick, + stepPercentForMobility, + WALK_TIMEOUT_TICKS, +} from "./tick.js"; import { clearChunkDeltaMemory } from "../world/chunk-repository.js"; import { ChunkLoader } from "../world/chunk-loader.js"; +/** First minute in 0..1439 where the B2 step gate passes (plan 03 integration helper). */ +function stepActiveMinute(npcId: string, mobility: Mobility): number { + for (let m = 0; m < 1440; m++) { + if (shouldStepThisTick(npcId, m, mobility)) return m; + } + throw new Error(`no passing minute for ${npcId}/${mobility} — hash key wrong?`); +} + const TICK_SOURCE = readFileSync( join(dirname(fileURLToPath(import.meta.url)), "tick.ts"), "utf8", @@ -230,36 +253,321 @@ describe("runAmbientTick", () => { ); }); - it("MAIN_AMBIENT_NPC_IDS covers all 12 council seats", () => { - expect(MAIN_AMBIENT_NPC_IDS.length).toBe(12); - expect([...MAIN_AMBIENT_NPC_IDS].sort()).toEqual([...COUNCIL_NPC_IDS].sort()); + it("collectWalkingReservedTargets seeds held walk destinations", () => { + const motion = new Map([ + [ + "npc-1", + { + mode: "walking" as const, + targetGx: 22, + targetGy: 12, + pauseTicksLeft: 0, + walkTicksLeft: 10, + segmentKey: "orchard|reading|stationary|360-480", + }, + ], + [ + "npc-2", + { + mode: "pausing" as const, + targetGx: 5, + targetGy: 5, + pauseTicksLeft: 3, + walkTicksLeft: 0, + segmentKey: "orchard|tending_crops|stationary|360-480", + }, + ], + ]); + expect(collectWalkingReservedTargets(motion)).toEqual([{ x: 22, y: 12 }]); }); - it("only NPCs in the current minute bucket may change position", async () => { + it("held walk destination stays reserved so a later NPC does not claim it", async () => { clearAllIntentsForTests(); - const map = createDefaultRoom("tick-bucket"); + const map = createDefaultRoom("tick-reserve-hold"); const gameState = new GameRoomState(); - const gameMinute = 478; - gameState.gameMinute = gameMinute; - const activeBucket = (gameMinute + 1) % 12; - const activeNpc = map.npcs.find((n) => hashNpcBucket(n.id) === activeBucket)!; - activeNpc.maxRadius = 4; - const targetGx = activeNpc.x + 1; - const targetGy = activeNpc.y; - setIntent("tick-bucket", activeNpc.id, { + + const picker = map.npcs.find((n) => n.id === "npc-1")!; + const walker = map.npcs.find((n) => n.id === "npc-2")!; + // Process picker before walker so only seeded reservations protect the held dest. + map.npcs = [ + picker, + walker, + ...map.npcs.filter((n) => n.id !== "npc-1" && n.id !== "npc-2"), + ]; + + // Minute where picker passes B2 (new stroll) while walker holds mid-walk (bypass). + let chosenMinute = -1; + for (let m = 0; m < 1440; m++) { + const pickerSeg = resolveScheduleSegment(picker.id, m); + const walkerSeg = resolveScheduleSegment(walker.id, m); + if (!pickerSeg || !walkerSeg) continue; + if (shouldSkipMovement(pickerSeg) || shouldSkipMovement(walkerSeg)) continue; + if (!shouldStepThisTick(picker.id, m, pickerSeg.mobility)) continue; + chosenMinute = m; + break; + } + expect(chosenMinute).toBeGreaterThanOrEqual(0); + gameState.gameMinute = chosenMinute === 0 ? 1439 : chosenMinute - 1; + + const reservedDest = { x: 12, y: 10 }; + picker.x = 10; + picker.y = 10; + picker.homeX = 10; + picker.homeY = 10; + picker.maxRadius = 40; + walker.x = 8; + walker.y = 10; + walker.homeX = 8; + walker.homeY = 10; + walker.maxRadius = 40; + + // Park the rest so they do not consume zone cells or reclaim reservedDest. + const speakJobs = new Map(); + for (const npc of map.npcs) { + if (npc.id === "npc-1" || npc.id === "npc-2") continue; + speakJobs.set(npc.id, "park"); + npc.x = 2; + npc.y = 2; + } + map.player.x = 1; + map.player.y = 1; + + const walkerSeg = resolveScheduleSegment(walker.id, chosenMinute)!; + const motion = new Map([ + [ + walker.id, + { + mode: "walking" as const, + targetGx: reservedDest.x, + targetGy: reservedDest.y, + pauseTicksLeft: 0, + walkTicksLeft: WALK_TIMEOUT_TICKS, + segmentKey: segmentKey(walkerSeg), + }, + ], + ]); + + // Linger pool around (10,10) ≤2 includes reservedDest (12,10). Fill recent so + // the non-reserved alternative peers are less sticky; reservation must still win. + const recentNpcCells = new Map(); + recentNpcCells.set(picker.id, [ + { x: 10, y: 10 }, + { x: 11, y: 10 }, + { x: 9, y: 10 }, + { x: 10, y: 11 }, + { x: 10, y: 9 }, + { x: 11, y: 11 }, + { x: 9, y: 9 }, + { x: 11, y: 9 }, + { x: 9, y: 11 }, + ]); + + const loader = await loaderForMap(map); + runAmbientTick({ + roomId: "tick-reserve-hold", + gameState, + map, + loader, + npcSpeakJobs: speakJobs, + recentNpcCells, + ambientMotion: motion, + }); + + const pickerMotion = motion.get(picker.id); + expect(pickerMotion).toBeDefined(); + // May finish a 1-step stroll into pause same tick — assert reserved dest was not claimed. + if (pickerMotion!.mode === "walking") { + expect( + pickerMotion!.targetGx === reservedDest.x && pickerMotion!.targetGy === reservedDest.y, + ).toBe(false); + } else { + expect(pickerMotion!.mode).toBe("pausing"); + expect(picker.x === reservedDest.x && picker.y === reservedDest.y).toBe(false); + } + }); + + it("invalidates held walk when the schedule segment changes", async () => { + clearAllIntentsForTests(); + const map = createDefaultRoom("tick-segment-invalidate"); + const gameState = new GameRoomState(); + // npc-1: orchard stationary ends at 480 → home patrol wander + gameState.gameMinute = 479; + + const npc = map.npcs.find((n) => n.id === "npc-1")!; + npc.x = 10; + npc.y = 10; + npc.homeX = 10; + npc.homeY = 10; + npc.maxRadius = 40; + + const speakJobs = new Map(); + for (const other of map.npcs) { + if (other.id === npc.id) continue; + speakJobs.set(other.id, "park"); + other.x = 2; + other.y = 2; + } + map.player.x = 1; + map.player.y = 1; + + const staleSeg = resolveScheduleSegment(npc.id, 400)!; + const staleTarget = { x: 14, y: 10 }; + const motion = new Map([ + [ + npc.id, + { + mode: "walking" as const, + targetGx: staleTarget.x, + targetGy: staleTarget.y, + pauseTicksLeft: 0, + walkTicksLeft: WALK_TIMEOUT_TICKS, + segmentKey: segmentKey(staleSeg), + }, + ], + ]); + + const loader = await loaderForMap(map); + runAmbientTick({ + roomId: "tick-segment-invalidate", + gameState, + map, + loader, + npcSpeakJobs: speakJobs, + recentNpcCells: new Map(), + ambientMotion: motion, + }); + + expect(gameState.gameMinute).toBe(480); + const active = resolveScheduleSegment(npc.id, 480)!; + expect(segmentKey(active)).not.toBe(segmentKey(staleSeg)); + const next = motion.get(npc.id); + expect(next?.segmentKey).toBe(segmentKey(active)); + // Continued hold would only decrement walkTicksLeft; invalidate + re-pick resets it. + if (next?.mode === "walking") { + expect(next.walkTicksLeft).toBe(WALK_TIMEOUT_TICKS); + } + }); + + it("arrives then pauses before picking a new walk", async () => { + clearAllIntentsForTests(); + const map = createDefaultRoom("tick-pause"); + const gameState = new GameRoomState(); + gameState.gameMinute = 479; + const npc = map.npcs.find((n) => n.id === "npc-12")!; + npc.x = 20; + npc.y = 12; + npc.homeX = 20; + npc.homeY = 12; + npc.maxRadius = 40; + setIntent("tick-pause", npc.id, { intent: parseAmbientIntent({ - target: { gx: targetGx, gy: targetGy }, - reasonZh: "去那边", - untilGameMinute: gameMinute + 60, + target: { gx: 21, gy: 12 }, + reasonZh: "走一步", + untilGameMinute: 600, }), trigger: "segment_change", - gameMinute, + gameMinute: 480, + }); + const loader = await loaderForMap(map); + const motion = new Map(); + runAmbientTick({ + roomId: "tick-pause", + gameState, + map, + loader, + npcSpeakJobs: new Map(), + recentNpcCells: new Map(), + ambientMotion: motion, + }); + expect(npc.x).toBe(21); + expect(npc.y).toBe(12); + expect(motion.get(npc.id)?.mode).toBe("pausing"); + expect(motion.get(npc.id)!.pauseTicksLeft).toBeGreaterThanOrEqual(2); + + const xDuringPause = npc.x; + const yDuringPause = npc.y; + runAmbientTick({ + roomId: "tick-pause", + gameState, + map, + loader, + npcSpeakJobs: new Map(), + recentNpcCells: new Map(), + ambientMotion: motion, }); + expect(npc.x).toBe(xDuringPause); + expect(npc.y).toBe(yDuringPause); + expect(motion.get(npc.id)?.mode).toBe("pausing"); + }); + + it("MAIN_AMBIENT_NPC_IDS covers all 12 council seats", () => { + expect(MAIN_AMBIENT_NPC_IDS.length).toBe(12); + expect([...MAIN_AMBIENT_NPC_IDS].sort()).toEqual([...COUNCIL_NPC_IDS].sort()); + }); + + it("at least two NPCs move in the same runAmbientTick", async () => { + clearAllIntentsForTests(); + const map = createDefaultRoom("tick-multi-move"); + const gameState = new GameRoomState(); + + let chosenMinute = -1; + let chosenIds: string[] = []; + for (let m = 0; m < 1440; m++) { + const eligible: string[] = []; + for (const id of COUNCIL_NPC_IDS) { + const segment = resolveScheduleSegment(id, m); + if (!segment || shouldSkipMovement(segment)) continue; + if (!shouldStepThisTick(id, m, segment.mobility)) continue; + eligible.push(id); + } + if (eligible.length >= 2) { + chosenMinute = m; + chosenIds = eligible.slice(0, 2); + break; + } + } + expect(chosenMinute).toBeGreaterThanOrEqual(0); + expect(chosenIds.length).toBe(2); + + gameState.gameMinute = chosenMinute === 0 ? 1439 : chosenMinute - 1; + // Park everyone else so they cannot occupy mover targets (MP-07). + map.player.x = 1; + map.player.y = 1; + map.npcs.forEach((npc, i) => { + if (!chosenIds.includes(npc.id)) { + npc.x = 2; + npc.y = 2 + i; + npc.maxRadius = 40; + } + }); + const clearSlots = [ + { x: 20, y: 12 }, + { x: 28, y: 12 }, + ]; + for (let i = 0; i < chosenIds.length; i += 1) { + const id = chosenIds[i]!; + const slot = clearSlots[i]!; + const npc = map.npcs.find((n) => n.id === id)!; + npc.x = slot.x; + npc.y = slot.y; + npc.homeX = slot.x; + npc.homeY = slot.y; + npc.maxRadius = 40; + setIntent("tick-multi-move", id, { + intent: parseAmbientIntent({ + target: { gx: slot.x + 1, gy: slot.y }, + reasonZh: "去那边", + untilGameMinute: chosenMinute + 60, + }), + trigger: "segment_change", + gameMinute: chosenMinute, + }); + } const loader = await loaderForMap(map); const before = new Map(map.npcs.map((n) => [n.id, { x: n.x, y: n.y }])); runAmbientTick({ - roomId: "tick-bucket", + roomId: "tick-multi-move", gameState, map, loader, @@ -268,34 +576,45 @@ describe("runAmbientTick", () => { }); let movedCount = 0; - for (const npc of map.npcs) { - const start = before.get(npc.id)!; + for (const id of chosenIds) { + const npc = map.npcs.find((n) => n.id === id)!; + const start = before.get(id)!; const moved = npc.x !== start.x || npc.y !== start.y; if (moved) { movedCount += 1; - expect(gameState.gameMinute % 12).toBe(hashNpcBucket(npc.id)); + const dx = Math.abs(npc.x - start.x); + const dy = Math.abs(npc.y - start.y); + expect(dx + dy).toBeLessThanOrEqual(1); } } - expect(movedCount).toBeGreaterThanOrEqual(1); + expect(movedCount).toBeGreaterThanOrEqual(2); }); it("maxRadius 0 council seats stay at embassy home during ambient tick", async () => { clearAllIntentsForTests(); const map = createDefaultRoom("tick-stationary"); const gameState = new GameRoomState(); - const gameMinute = 478; - gameState.gameMinute = gameMinute; - const activeBucket = (gameMinute + 1) % 12; - const stationaryNpc = map.npcs.find((n) => hashNpcBucket(n.id) === activeBucket)!; + const stationaryNpc = map.npcs[0]!; stationaryNpc.maxRadius = 0; + let passMinute = -1; + for (let m = 0; m < 1440; m++) { + const seg = resolveScheduleSegment(stationaryNpc.id, m); + if (!seg || shouldSkipMovement(seg)) continue; + if (shouldStepThisTick(stationaryNpc.id, m, seg.mobility)) { + passMinute = m; + break; + } + } + expect(passMinute).toBeGreaterThanOrEqual(0); + gameState.gameMinute = passMinute === 0 ? 1439 : passMinute - 1; setIntent("tick-stationary", stationaryNpc.id, { intent: parseAmbientIntent({ - target: { gx: stationaryNpc.x + 2, gy: stationaryNpc.y }, + target: { gx: stationaryNpc.x + 20, gy: stationaryNpc.y }, reasonZh: "想走远一点", - untilGameMinute: gameMinute + 60, + untilGameMinute: passMinute + 60, }), trigger: "segment_change", - gameMinute, + gameMinute: passMinute, }); const loader = await loaderForMap(map); const before = { x: stationaryNpc.x, y: stationaryNpc.y }; @@ -313,11 +632,6 @@ describe("runAmbientTick", () => { expect(stationaryNpc.y).toBe(before.y); }); - it("assigns each council seat a distinct hash bucket slot 0..11", () => { - const buckets = COUNCIL_NPC_IDS.map((id) => hashNpcBucket(id)); - expect(new Set(buckets).size).toBe(12); - }); - it("applySoftLeashTarget biases wander target back toward embassy home", () => { const npc = { id: "npc-7", @@ -352,6 +666,83 @@ describe("runAmbientTick", () => { expect(leashed.targetGx).not.toBe(50); }); + it("join_vicinity bypasses the step gate and the soft leash (D-11)", async () => { + clearAllIntentsForTests(); + const map = createDefaultRoom("tick-join-bypass"); + const gameState = new GameRoomState(); + const npc = map.npcs[0]!; + // Player ~4 cells west on walkable cells; home far so soft leash would pull home. + npc.x = 32; + npc.y = 12; + npc.homeX = 5; + npc.homeY = 9; + npc.maxRadius = 2; + map.player.x = 28; + map.player.y = 12; + npc.joinVicinityActive = true; + npc.joinVicinityStartedAt = Date.now(); + npc.joinVicinityUntil = Date.now() + 8000; + + let failMinute = -1; + for (let m = 0; m < 1440; m++) { + const seg = resolveScheduleSegment(npc.id, m); + if (!seg || shouldSkipMovement(seg)) continue; + if (!shouldStepThisTick(npc.id, m, seg.mobility)) { + failMinute = m; + break; + } + } + expect(failMinute).toBeGreaterThanOrEqual(0); + gameState.gameMinute = failMinute === 0 ? 1439 : failMinute - 1; + + // Park non-movers; pin diagonals so join target is uniquely (29,12) — first step west. + map.npcs.forEach((other, i) => { + if (other.id === npc.id) return; + if (i === 1) { + other.x = 29; + other.y = 11; + } else if (i === 2) { + other.x = 29; + other.y = 13; + } else { + other.x = 2; + other.y = 2 + i; + } + }); + + const distBefore = Math.max(Math.abs(npc.x - map.player.x), Math.abs(npc.y - map.player.y)); + expect(distBefore).toBeGreaterThan(2); + const loader = await loaderForMap(map); + + runAmbientTick({ + roomId: "tick-join-bypass", + gameState, + map, + loader, + npcSpeakJobs: new Map(), + recentNpcCells: new Map(), + }); + + const distAfter = Math.max(Math.abs(npc.x - map.player.x), Math.abs(npc.y - map.player.y)); + expect(distAfter).toBeLessThan(distBefore); + }); + + it("applySoftLeashTarget never clamps in-region zone targets at radius 40", () => { + const npc = { + id: "npc-11", + name: "test", + x: 5, + y: 9, + homeX: 5, + homeY: 9, + maxRadius: 40, + status: "idle", + inventory: [], + }; + const leashed = applySoftLeashTarget(npc, 39, 39); + expect(leashed).toEqual({ targetGx: 39, targetGy: 39 }); + }); + it("contains no fetch/axios/worker/llm imports (LIFE-03)", () => { const code = TICK_SOURCE.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); expect(code).not.toMatch(/\bfetch\s*\(/); @@ -362,4 +753,126 @@ describe("runAmbientTick", () => { expect(code).not.toMatch(/\bisBackgroundNpc\b/); expect(code).not.toMatch(/\brunBackgroundNpcTick\b/); }); + + /** + * Plan 03 acceptance: grep -c 'shouldStepThisTick(npc.id' tick.ts == 1. + * Regression: wired in 0952357, removed in 45b6455 (walk/pause rewrite). + * Adversarial — must FAIL while runAmbientTick does not call the B2 gate. + */ + it("runAmbientTick source wires shouldStepThisTick(npc.id) B2 gate (D-22/D-25)", () => { + const withoutComments = TICK_SOURCE.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); + const callSites = withoutComments.match(/shouldStepThisTick\(\s*npc\.id/g) ?? []; + expect(callSites.length).toBe(1); + }); + + /** + * Behavioral half of B2 wire: on a gate-FAIL minute without join_vicinity, + * NPC must not take a step. Fails if the loop skips shouldStepThisTick. + */ + it("runAmbientTick skips stepping when shouldStepThisTick fails (B2 gate live)", async () => { + clearAllIntentsForTests(); + const map = createDefaultRoom("tick-b2-gate-live"); + const gameState = new GameRoomState(); + const npc = map.npcs.find((n) => n.id === "npc-1")!; + npc.x = 20; + npc.y = 12; + npc.homeX = 20; + npc.homeY = 12; + npc.maxRadius = 40; + map.player.x = 1; + map.player.y = 1; + + let failMinute = -1; + for (let m = 0; m < 1440; m++) { + const seg = resolveScheduleSegment(npc.id, m); + if (!seg || shouldSkipMovement(seg)) continue; + if (!shouldStepThisTick(npc.id, m, seg.mobility)) { + failMinute = m; + break; + } + } + expect(failMinute).toBeGreaterThanOrEqual(0); + gameState.gameMinute = failMinute === 0 ? 1439 : failMinute - 1; + + setIntent("tick-b2-gate-live", npc.id, { + intent: parseAmbientIntent({ + target: { gx: npc.x + 1, gy: npc.y }, + reasonZh: "一步测试", + untilGameMinute: failMinute + 60, + }), + trigger: "segment_change", + gameMinute: failMinute, + }); + + const speakJobs = new Map(); + for (const other of map.npcs) { + if (other.id === npc.id) continue; + speakJobs.set(other.id, "park"); + other.x = 2; + other.y = 2; + } + + const before = { x: npc.x, y: npc.y }; + const loader = await loaderForMap(map); + runAmbientTick({ + roomId: "tick-b2-gate-live", + gameState, + map, + loader, + npcSpeakJobs: speakJobs, + recentNpcCells: new Map(), + }); + + expect(npc.x).toBe(before.x); + expect(npc.y).toBe(before.y); + }); +}); + +describe("shouldStepThisTick (B2 gate)", () => { + it("wander gate passes ~55% of minutes (deterministic count)", () => { + let pass = 0; + for (let m = 0; m < 1440; m++) { + if (shouldStepThisTick("npc-3", m, "wander")) pass++; + } + const ratio = pass / 1440; + expect(ratio).toBeGreaterThan(0.5); + expect(ratio).toBeLessThan(0.6); + expect(stepActiveMinute("npc-3", "wander")).toBeGreaterThanOrEqual(0); + }); + + it("linger gate passes ~30% of minutes (deterministic count)", () => { + expect(stepPercentForMobility("wander")).toBe(55); + expect(stepPercentForMobility("stationary")).toBe(30); + expect(stepPercentForMobility("poi")).toBe(30); + let pass = 0; + for (let m = 0; m < 1440; m++) { + if (shouldStepThisTick("npc-3", m, "stationary")) pass++; + } + const ratio = pass / 1440; + expect(ratio).toBeGreaterThan(0.25); + expect(ratio).toBeLessThan(0.35); + }); + + it("multiple NPCs can pass the gate on the same minute (B2, D-22/D-24)", () => { + let coPassMinutes = 0; + for (let m = 0; m < 1440; m++) { + const movers = COUNCIL_NPC_IDS.filter((id) => shouldStepThisTick(id, m, "wander")); + if (movers.length >= 2) coPassMinutes++; + } + expect(coPassMinutes).toBeGreaterThan(0); + }); + + it("per-NPC jitter desynchronizes gate minutes (D-24)", () => { + let npc1Only = false; + let npc2Only = false; + for (let m = 0; m < 1440; m++) { + const a = shouldStepThisTick("npc-1", m, "wander"); + const b = shouldStepThisTick("npc-2", m, "wander"); + if (a && !b) npc1Only = true; + if (b && !a) npc2Only = true; + if (npc1Only && npc2Only) break; + } + expect(npc1Only).toBe(true); + expect(npc2Only).toBe(true); + }); }); diff --git a/apps/game-server/src/ambient/tick.ts b/apps/game-server/src/ambient/tick.ts index f85eab0..a5cbc6c 100644 --- a/apps/game-server/src/ambient/tick.ts +++ b/apps/game-server/src/ambient/tick.ts @@ -1,5 +1,11 @@ import type { GridCell, NpcState, RoomState } from "@aetherlife/shared"; -import { COUNCIL_NPC_IDS, isCouncilNpcId, isTargetIntent, isZoneIntent } from "@aetherlife/shared"; +import { + COUNCIL_NPC_IDS, + isCouncilNpcId, + isTargetIntent, + isZoneIntent, + stableStringHash, +} from "@aetherlife/shared"; import { collectPlayerCells, findPlayerCellByPlayerId } from "../colyseus/bridge.js"; import { buildMoveGrid, findNearestWalkableCell } from "../colyseus/move-handler.js"; import type { GameRoomState } from "../colyseus/schema.js"; @@ -7,29 +13,51 @@ import { applyMapAndBumpVersion } from "../colyseus/version.js"; import type { ChunkLoader } from "../world/chunk-loader.js"; import { getIntent, isIntentExpired } from "./intent-cache.js"; import { buildOtherNpcCells, stepNpcTowardTarget } from "./move.js"; -import { resolveScheduleSegment, shouldSkipMovement, type ScheduleSegment } from "./schedule.js"; +import { + resolveScheduleSegment, + segmentKey, + shouldSkipMovement, + type Mobility, + type ScheduleSegment, +} from "./schedule.js"; import { pickZoneTarget } from "./zone-wander.js"; export const MAIN_AMBIENT_NPC_IDS = COUNCIL_NPC_IDS; -const AMBIENT_BUCKET_COUNT = 12; +/** Fail-safe: abandon a stuck walk after this many ambient ticks. */ +export const WALK_TIMEOUT_TICKS = 48; -/** Stable 0..11 bucket per council seat — one NPC moves per bucket per tick (D-MAP-AMB-03). */ -export function hashNpcBucket(npcId: string): number { - const match = /^npc-(\d+)$/.exec(npcId); - if (match) { - const seat = Number.parseInt(match[1]!, 10); - if (seat >= 1 && seat <= AMBIENT_BUCKET_COUNT) { - return (seat - 1) % AMBIENT_BUCKET_COUNT; - } - } - let hash = 0; - for (let i = 0; i < npcId.length; i += 1) { - hash = (hash * 31 + npcId.charCodeAt(i)) | 0; - } - return Math.abs(hash) % AMBIENT_BUCKET_COUNT; +/** Pause span 2–8 ticks after arriving (动森式走走停停). */ +export function ambientPauseTicks(npcId: string, gameMinute: number): number { + return 2 + (stableStringHash(`ambient-pause:${npcId}:${gameMinute}`) % 7); } +/** Per-tick step probability (0–100) by mobility — wander ~55%, linger ~30% (D-23). */ +export function stepPercentForMobility(mobility: Mobility): number { + return mobility === "wander" ? 55 : 30; +} + +/** + * Deterministic per-NPC-per-minute step gate (D-23/D-24). + * Gates starting a new stroll; mid-walk holds and join_vicinity bypass it. + * Resting never reaches this path (`shouldSkipMovement` upstream). + */ +export function shouldStepThisTick(npcId: string, gameMinute: number, mobility: Mobility): boolean { + return stableStringHash(`ambient-step:${npcId}:${gameMinute}`) % 100 < stepPercentForMobility(mobility); +} + +export type AmbientMotion = { + mode: "walking" | "pausing"; + targetGx: number; + targetGy: number; + /** Ticks remaining in pause (decremented each ambient tick). */ + pauseTicksLeft: number; + /** Ticks remaining before walk timeout forces re-pick. */ + walkTicksLeft: number; + /** Schedule segment that started this hold — invalidate when the active segment changes. */ + segmentKey: string; +}; + export type AmbientTickContext = { roomId: string; gameState: GameRoomState; @@ -38,6 +66,8 @@ export type AmbientTickContext = { npcSpeakJobs: ReadonlyMap; /** Per-NPC recent target cells (anti-repeat wander). */ recentNpcCells: Map; + /** Per-NPC walk/pause cadence (room-local, not Colyseus-synced). */ + ambientMotion?: Map; }; function chebyshev(ax: number, ay: number, bx: number, by: number): number { @@ -155,16 +185,24 @@ function resolveMovementTarget( grid: ReturnType, playerCells: GridCell[], recent: GridCell[], -): { targetGx: number; targetGy: number; nextRecent: GridCell[] } { + occupiedCells: readonly GridCell[], + reservedTargets: readonly GridCell[], +): { targetGx: number; targetGy: number; nextRecent: GridCell[]; source: "join" | "intent" | "zone" } { const joinTarget = pickJoinVicinityTarget(npc, roomId, playerCells, grid); if (joinTarget) { return { targetGx: joinTarget.x, targetGy: joinTarget.y, nextRecent: recent, + source: "join", }; } + const zoneOpts = { + occupiedCells, + reservedTargets, + }; + const cached = getIntent(roomId, npc.id); if (cached && !isIntentExpired(cached.intent, gameMinute, cached.gameMinute)) { npc.intentReasonZh = cached.intent.reasonZh?.trim() ?? ""; @@ -173,6 +211,7 @@ function resolveMovementTarget( targetGx: cached.intent.target.gx, targetGy: cached.intent.target.gy, nextRecent: recent, + source: "intent", }; } if (isZoneIntent(cached.intent)) { @@ -184,11 +223,13 @@ function resolveMovementTarget( playerCells, recentCells: recent, gameMinute, + ...zoneOpts, }); return { targetGx: picked.targetGx, targetGy: picked.targetGy, nextRecent: picked.nextRecent, + source: "zone", }; } } @@ -200,56 +241,145 @@ function resolveMovementTarget( playerCells, recentCells: recent, gameMinute, + ...zoneOpts, }); return { targetGx: picked.targetGx, targetGy: picked.targetGy, nextRecent: picked.nextRecent, + source: "zone", }; } +/** Destinations already claimed by held walks — seed before per-NPC target picks (never-stack). */ +export function collectWalkingReservedTargets( + ambientMotion: ReadonlyMap, +): GridCell[] { + const reserved: GridCell[] = []; + for (const motion of ambientMotion.values()) { + if (motion.mode === "walking") { + reserved.push({ x: motion.targetGx, y: motion.targetGy }); + } + } + return reserved; +} + +function beginWalk( + motionMap: Map, + npcId: string, + targetGx: number, + targetGy: number, + holdSegmentKey: string, +): void { + motionMap.set(npcId, { + mode: "walking", + targetGx, + targetGy, + pauseTicksLeft: 0, + walkTicksLeft: WALK_TIMEOUT_TICKS, + segmentKey: holdSegmentKey, + }); +} + +function beginPause( + motionMap: Map, + npcId: string, + gameMinute: number, + x: number, + y: number, + holdSegmentKey: string, +): void { + motionMap.set(npcId, { + mode: "pausing", + targetGx: x, + targetGy: y, + pauseTicksLeft: ambientPauseTicks(npcId, gameMinute), + walkTicksLeft: 0, + segmentKey: holdSegmentKey, + }); +} + /** * Authoritative ambient simulation tick: game clock, schedule activity, ≤1 grid step per NPC. - * No LLM or HTTP calls (LIFE-03). + * Walk/pause cadence (动森式); never stack when alternatives exist. No LLM/HTTP (LIFE-03). */ export function runAmbientTick(ctx: AmbientTickContext): { stateVersion: number; delta: ReturnType["delta"]; } { const { roomId, gameState, map, loader, npcSpeakJobs, recentNpcCells } = ctx; + const ambientMotion = ctx.ambientMotion ?? new Map(); gameState.gameMinute = (gameState.gameMinute + 1) % 1440; + const gameMinute = gameState.gameMinute; const playerCells = collectPlayerCells(roomId, map); + const reservedTargets: GridCell[] = collectWalkingReservedTargets(ambientMotion); for (const npc of map.npcs) { if (!isCouncilNpcId(npc.id)) { continue; } if (npcSpeakJobs.has(npc.id)) { + ambientMotion.delete(npc.id); continue; } clearJoinVicinityIfDone(npc, playerCells); - const segment = resolveScheduleSegment(npc.id, gameState.gameMinute); + const segment = resolveScheduleSegment(npc.id, gameMinute); if (!segment) { npc.activityKey = "idle"; + ambientMotion.delete(npc.id); continue; } npc.activityKey = segment.activityKey; - syncIntentReasonFromCache(npc, roomId, gameState.gameMinute); + syncIntentReasonFromCache(npc, roomId, gameMinute); if (shouldSkipMovement(segment)) { + ambientMotion.delete(npc.id); continue; } if (npc.maxRadius === 0) { + ambientMotion.delete(npc.id); continue; } - if (gameState.gameMinute % AMBIENT_BUCKET_COUNT !== hashNpcBucket(npc.id)) { + const activeSegmentKey = segmentKey(segment); + let motion = ambientMotion.get(npc.id); + if (motion && motion.segmentKey !== activeSegmentKey) { + ambientMotion.delete(npc.id); + motion = undefined; + } + + // join_vicinity interrupts pause/walk hold + if (npc.joinVicinityActive) { + ambientMotion.delete(npc.id); + motion = undefined; + } else if (motion?.mode === "pausing") { + motion.pauseTicksLeft -= 1; + if (motion.pauseTicksLeft > 0) { + ambientMotion.set(npc.id, motion); + continue; + } + ambientMotion.delete(npc.id); + motion = undefined; + } + + const holdingWalk = + motion?.mode === "walking" && + motion.walkTicksLeft > 0 && + (motion.targetGx !== npc.x || motion.targetGy !== npc.y) && + !npc.joinVicinityActive; + + // B2: gate new strolls only — mid-walk continuum + join_vicinity bypass (D-11 / D-22). + if ( + !holdingWalk && + !npc.joinVicinityActive && + !shouldStepThisTick(npc.id, gameMinute, segment.mobility) + ) { continue; } @@ -257,18 +387,55 @@ export function runAmbientTick(ctx: AmbientTickContext): { const otherNpcCells = buildOtherNpcCells(map, npc.id); const recent = recentNpcCells.get(npc.id) ?? []; - const resolved = resolveMovementTarget( - npc, - segment, - roomId, - gameState.gameMinute, - grid, - playerCells, - recent, - ); - recentNpcCells.set(npc.id, resolved.nextRecent); + let resolved: { + targetGx: number; + targetGy: number; + nextRecent: GridCell[]; + source: "join" | "intent" | "zone"; + }; + + if (holdingWalk && motion) { + motion.walkTicksLeft -= 1; + ambientMotion.set(npc.id, motion); + resolved = { + targetGx: motion.targetGx, + targetGy: motion.targetGy, + nextRecent: recent, + source: "zone", + }; + } else { + resolved = resolveMovementTarget( + npc, + segment, + roomId, + gameMinute, + grid, + playerCells, + recent, + otherNpcCells, + reservedTargets, + ); + recentNpcCells.set(npc.id, resolved.nextRecent); + if (resolved.source !== "join") { + reservedTargets.push({ x: resolved.targetGx, y: resolved.targetGy }); + beginWalk(ambientMotion, npc.id, resolved.targetGx, resolved.targetGy, activeSegmentKey); + } + } + + const leashed = + resolved.source === "join" + ? { targetGx: resolved.targetGx, targetGy: resolved.targetGy } + : applySoftLeashTarget(npc, resolved.targetGx, resolved.targetGy); - const leashed = applySoftLeashTarget(npc, resolved.targetGx, resolved.targetGy); + // If soft-leash retargets, keep motion in sync so we don't oscillate. + if (resolved.source !== "join") { + const cur = ambientMotion.get(npc.id); + if (cur?.mode === "walking" && (cur.targetGx !== leashed.targetGx || cur.targetGy !== leashed.targetGy)) { + cur.targetGx = leashed.targetGx; + cur.targetGy = leashed.targetGy; + ambientMotion.set(npc.id, cur); + } + } const step = stepNpcTowardTarget({ npcX: npc.x, @@ -284,6 +451,10 @@ export function runAmbientTick(ctx: AmbientTickContext): { npc.x = step.x; npc.y = step.y; } + + if (resolved.source !== "join" && npc.x === leashed.targetGx && npc.y === leashed.targetGy) { + beginPause(ambientMotion, npc.id, gameMinute, npc.x, npc.y, activeSegmentKey); + } } return applyMapAndBumpVersion(gameState, map); diff --git a/apps/game-server/src/ambient/zone-wander.test.ts b/apps/game-server/src/ambient/zone-wander.test.ts index 75988cb..bf94d7f 100644 --- a/apps/game-server/src/ambient/zone-wander.test.ts +++ b/apps/game-server/src/ambient/zone-wander.test.ts @@ -10,7 +10,7 @@ import { createDefaultRoom } from "@aetherlife/shared"; import collisionFixture from "../../data/world/beginning-fields@v1/collision.json"; import type { ScheduleSegment } from "./schedule.js"; import { bootBeginningFieldsCollision, regionWalkabilityAt } from "../world/region-walkability.js"; -import { pickZoneTarget, LINGER_RADIUS, LINGER_PAUSE_PERCENT, MAX_ZONE_SAMPLE_CELLS, shouldSampleZoneCell } from "./zone-wander.js"; +import { pickZoneTarget, LINGER_RADIUS, LINGER_PAUSE_PERCENT, MAX_ZONE_SAMPLE_CELLS, PERSONAL_SPACE, pickSpaciousCell, shouldSampleZoneCell } from "./zone-wander.js"; function lingerActiveMinute(npcId: string): number { for (let m = 0; m < 1440; m++) { @@ -52,6 +52,10 @@ describe("pickZoneTarget", () => { vi.restoreAllMocks(); }); + it("LINGER_PAUSE_PERCENT is 15 (D-09 MAP-06)", () => { + expect(LINGER_PAUSE_PERCENT).toBe(15); + }); + it("returns cell inside zone rect bounds", () => { const map = createDefaultRoom("zone-bounds"); const npc = map.npcs[0] as NpcState; @@ -197,6 +201,82 @@ describe("pickZoneTarget", () => { expect(targetGx).toBe(npc.x); expect(targetGy).toBe(npc.y); }); + + it("stationary outside zone commutes to nearest zone cell", () => { + const map = createDefaultRoom("zone-commute"); + const npc = map.npcs[0] as NpcState; + npc.x = 5; + npc.y = 5; + const segment: ScheduleSegment = { + fromMinute: 360, + toMinute: 480, + activityKey: "reading", + zoneId: "beginning-fields@v1:orchard", + mobility: "stationary", + }; + const gameMinute = lingerActiveMinute(npc.id); + const { targetGx, targetGy } = pickZoneTarget({ + npc, + segment, + grid: openGrid(), + playerCells: [], + recentCells: [], + gameMinute, + }); + expect(targetGx).toBeGreaterThanOrEqual(18); + expect(targetGx).toBeLessThan(30); + expect(targetGy).toBeGreaterThanOrEqual(6); + expect(targetGy).toBeLessThan(16); + expect(Math.max(Math.abs(targetGx - npc.x), Math.abs(targetGy - npc.y))).toBeGreaterThan( + LINGER_RADIUS, + ); + }); + + it("never picks an occupied cell when a free alternative exists", () => { + const map = createDefaultRoom("zone-nostack"); + const npc = map.npcs[0] as NpcState; + npc.x = 24; + npc.y = 10; + const segment: ScheduleSegment = { + fromMinute: 480, + toMinute: 720, + activityKey: "patrol", + zoneId: "beginning-fields@v1:orchard", + mobility: "wander", + }; + const occupied = [ + { x: 18, y: 6 }, + { x: 19, y: 6 }, + { x: 20, y: 6 }, + ]; + for (let i = 0; i < 15; i++) { + vi.spyOn(Math, "random").mockReturnValue((i * 0.11) % 1); + const { targetGx, targetGy } = pickZoneTarget({ + npc, + segment, + grid: openGrid(), + playerCells: [], + recentCells: [], + gameMinute: 500 + i, + occupiedCells: occupied, + }); + expect(occupied.some((o) => o.x === targetGx && o.y === targetGy)).toBe(false); + } + }); + + it("pickSpaciousCell prefers PERSONAL_SPACE clearance", () => { + const pool = [ + { x: 10, y: 10 }, + { x: 11, y: 10 }, + { x: 20, y: 20 }, + ]; + const occupied = [{ x: 10, y: 10 }]; + const chosen = pickSpaciousCell(pool, occupied, []); + expect(chosen).toEqual({ x: 20, y: 20 }); + expect(Math.max(Math.abs(chosen!.x - 10), Math.abs(chosen!.y - 10))).toBeGreaterThanOrEqual( + PERSONAL_SPACE, + ); + }); }); describe("shouldSampleZoneCell (T-16-02)", () => { diff --git a/apps/game-server/src/ambient/zone-wander.ts b/apps/game-server/src/ambient/zone-wander.ts index 9214dd8..5ea84a6 100644 --- a/apps/game-server/src/ambient/zone-wander.ts +++ b/apps/game-server/src/ambient/zone-wander.ts @@ -6,6 +6,7 @@ import { type GlobalMoveGrid, type GridCell, type NpcState, + type WorldRegion, type WorldRegistry, type Zone, type ZoneId, @@ -19,9 +20,11 @@ const SOCIAL_BIAS_ACTIVITIES = new Set(["socializing", "patrol"]); /** Chebyshev radius (cells) for stationary/poi linger — 动森/星露谷式「在工位附近晃」. See ambient/README.md */ export const LINGER_RADIUS = 2; /** Per-tick probability (0–100) to stand still during linger; hash-stable per npcId+gameMinute. */ -export const LINGER_PAUSE_PERCENT = 30; +export const LINGER_PAUSE_PERCENT = 15; /** Max walkable cells sampled per zone per tick — caps DoS from huge zone rects (T-16-02). */ export const MAX_ZONE_SAMPLE_CELLS = 256; +/** Prefer destinations at least this far from other NPCs (brush-by closer still allowed if no spacious cell). */ +export const PERSONAL_SPACE = 2; function shouldPauseLinger(npcId: string, gameMinute: number): boolean { return stableStringHash(`linger:${npcId}:${gameMinute}`) % 100 < LINGER_PAUSE_PERCENT; @@ -31,6 +34,18 @@ function chebyshev(ax: number, ay: number, bx: number, by: number): number { return Math.max(Math.abs(ax - bx), Math.abs(ay - by)); } +function cellTaken(cell: GridCell, blocked: readonly GridCell[]): boolean { + return blocked.some((b) => b.x === cell.x && b.y === cell.y); +} + +function minDistToOccupied(cell: GridCell, occupied: readonly GridCell[]): number { + let min = Infinity; + for (const o of occupied) { + min = Math.min(min, chebyshev(cell.x, cell.y, o.x, o.y)); + } + return min === Infinity ? 99 : min; +} + function findZone(registry: WorldRegistry, zoneId: string): Zone | undefined { let regionId: string; let localId: string; @@ -62,6 +77,78 @@ function pushRecent(recent: GridCell[], cell: GridCell): GridCell[] { return next; } +function collectZoneWalkable( + zone: Zone, + region: WorldRegion, + grid: GlobalMoveGrid, +): GridCell[] { + const candidates: GridCell[] = []; + const { rect } = zone; + for (let lx = rect.lx; lx < rect.lx + rect.w; lx++) { + for (let ly = rect.ly; ly < rect.ly + rect.h; ly++) { + if (!shouldSampleZoneCell(zone.zoneId, lx, ly, rect)) continue; + const { gx, gy } = toGlobal(region, lx, ly); + if (!grid.isBlocked(gx, gy)) { + candidates.push({ x: gx, y: gy }); + } + } + } + return candidates; +} + +/** Prefer free cells with personal space; never pick an occupied/reserved cell when alternatives exist. */ +export function pickSpaciousCell( + pool: readonly GridCell[], + occupied: readonly GridCell[], + reserved: readonly GridCell[], +): GridCell | null { + if (pool.length === 0) return null; + const blocked = [...occupied, ...reserved]; + const free = pool.filter((c) => !cellTaken(c, blocked)); + const usable = free.length > 0 ? free : pool; + const spacious = usable.filter((c) => minDistToOccupied(c, occupied) >= PERSONAL_SPACE); + const ranked = (spacious.length > 0 ? spacious : usable).slice(); + ranked.sort((a, b) => { + const da = minDistToOccupied(a, occupied); + const db = minDistToOccupied(b, occupied); + if (db !== da) return db - da; + return a.x - b.x || a.y - b.y; + }); + // Soft random among top-scored peers so destinations aren't identical every tick. + const bestScore = minDistToOccupied(ranked[0]!, occupied); + const top = ranked.filter((c) => minDistToOccupied(c, occupied) === bestScore); + return top[Math.floor(Math.random() * top.length)] ?? null; +} + +function nearestZoneCell( + from: GridCell, + candidates: readonly GridCell[], + occupied: readonly GridCell[], + reserved: readonly GridCell[], +): GridCell | null { + const blocked = [...occupied, ...reserved]; + let best: GridCell | null = null; + let bestD = Infinity; + for (const c of candidates) { + if (cellTaken(c, blocked)) continue; + const d = chebyshev(from.x, from.y, c.x, c.y); + if (d < bestD) { + bestD = d; + best = c; + } + } + if (best) return best; + // All free cells taken — still commute toward nearest (step layer prevents stacking). + for (const c of candidates) { + const d = chebyshev(from.x, from.y, c.x, c.y); + if (d < bestD) { + bestD = d; + best = c; + } + } + return best; +} + export type ZoneWanderInput = { npc: NpcState; segment: ScheduleSegment; @@ -70,6 +157,10 @@ export type ZoneWanderInput = { recentCells: GridCell[]; /** Game clock minute — stabilizes linger pause cadence per tick. */ gameMinute: number; + /** Other NPC standing cells (never stack when alternatives exist). */ + occupiedCells?: readonly GridCell[]; + /** Destinations already claimed this ambient tick. */ + reservedTargets?: readonly GridCell[]; }; export function pickZoneTarget(input: ZoneWanderInput): { @@ -77,6 +168,8 @@ export function pickZoneTarget(input: ZoneWanderInput): { targetGy: number; nextRecent: GridCell[]; } { + const occupied = input.occupiedCells ?? []; + const reserved = input.reservedTargets ?? []; const registry = getWorldRegistry(); const fallback = { targetGx: input.npc.x, @@ -97,28 +190,18 @@ export function pickZoneTarget(input: ZoneWanderInput): { pois.find((p) => p.kind === "social") ?? pois.find((p) => p.localId === "well"); if (socialPoi) { const { gx, gy } = toGlobal(region, socialPoi.lx, socialPoi.ly); - if (!input.grid.isBlocked(gx, gy)) { + const poiCell = { x: gx, y: gy }; + if (!input.grid.isBlocked(gx, gy) && !cellTaken(poiCell, [...occupied, ...reserved])) { return { targetGx: gx, targetGy: gy, - nextRecent: pushRecent(input.recentCells, { x: gx, y: gy }), + nextRecent: pushRecent(input.recentCells, poiCell), }; } } } - const candidates: GridCell[] = []; - const { rect } = zone; - for (let lx = rect.lx; lx < rect.lx + rect.w; lx++) { - for (let ly = rect.ly; ly < rect.ly + rect.h; ly++) { - if (!shouldSampleZoneCell(zone.zoneId, lx, ly, rect)) continue; - const { gx, gy } = toGlobal(region, lx, ly); - if (!input.grid.isBlocked(gx, gy)) { - candidates.push({ x: gx, y: gy }); - } - } - } - + const candidates = collectZoneWalkable(zone, region, input.grid); if (candidates.length === 0) return fallback; let pool = candidates; @@ -131,10 +214,23 @@ export function pickZoneTarget(input: ZoneWanderInput): { (c) => chebyshev(input.npc.x, input.npc.y, c.x, c.y) <= LINGER_RADIUS, ); if (nearby.length === 0) { - return fallback; + // Outside schedule zone — commute to nearest free zone cell (26.2 gap). + const commute = nearestZoneCell( + { x: input.npc.x, y: input.npc.y }, + candidates, + occupied, + reserved, + ); + if (!commute) return fallback; + return { + targetGx: commute.x, + targetGy: commute.y, + nextRecent: pushRecent(input.recentCells, commute), + }; } pool = nearby; } + const nearPlayer = input.playerCells.length > 0 && input.playerCells.some( @@ -161,7 +257,8 @@ export function pickZoneTarget(input: ZoneWanderInput): { (c) => !input.recentCells.some((r) => r.x === c.x && r.y === c.y), ); const pickFrom = filtered.length > 0 ? filtered : pool; - const chosen = pickFrom[Math.floor(Math.random() * pickFrom.length)]!; + const chosen = pickSpaciousCell(pickFrom, occupied, reserved); + if (!chosen) return fallback; return { targetGx: chosen.x, diff --git a/apps/game-server/src/colyseus/GameRoom.ts b/apps/game-server/src/colyseus/GameRoom.ts index f7db8a9..af9bc27 100644 --- a/apps/game-server/src/colyseus/GameRoom.ts +++ b/apps/game-server/src/colyseus/GameRoom.ts @@ -57,6 +57,17 @@ export class GameRoom extends Room { private lastSpeakInitiatorByNpc = new Map(); /** Per-NPC recent wander targets (room-local ambient state). */ private ambientRecentNpcCells = new Map(); + private ambientMotion = new Map< + string, + { + mode: "walking" | "pausing"; + targetGx: number; + targetGy: number; + pauseTicksLeft: number; + walkTicksLeft: number; + segmentKey: string; + } + >(); private lastAckedSeq = new Map(); private lastChunksFingerprint = ""; /** Set when matchmaker spawned a duplicate shard for the same mapRoomId. */ @@ -405,6 +416,7 @@ export class GameRoom extends Room { loader, npcSpeakJobs: this.npcSpeakJobs, recentNpcCells: this.ambientRecentNpcCells, + ambientMotion: this.ambientMotion, }); const newMinute = this.gameState.gameMinute; diff --git a/apps/game-server/src/world/council-spawn-radius.test.ts b/apps/game-server/src/world/council-spawn-radius.test.ts new file mode 100644 index 0000000..bd021bc --- /dev/null +++ b/apps/game-server/src/world/council-spawn-radius.test.ts @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + BEGINNING_FIELDS_ID, + defaultBeginningFieldsBundle, + type CouncilSpawnEntry, + type RegionSpawns, +} from "@aetherlife/shared"; +import { describe, expect, it } from "vitest"; + +const DATA_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../data/world"); + +describe("council spawn radius dual-SSOT", () => { + it("disk spawns.json ≡ defaultBeginningFieldsBundle ≡ 40 for all 12 council slots", () => { + const disk = JSON.parse( + readFileSync(join(DATA_DIR, BEGINNING_FIELDS_ID, "spawns.json"), "utf8"), + ) as RegionSpawns; + const diskSlots = disk.councilSpawns ?? []; + expect(diskSlots).toHaveLength(12); + + const bundleSpawns = defaultBeginningFieldsBundle().spawnsByRegionId[ + BEGINNING_FIELDS_ID + ] as RegionSpawns; + const bundleSlots = bundleSpawns.councilSpawns ?? []; + expect(bundleSlots).toHaveLength(12); + + for (let i = 0; i < 12; i++) { + const diskSlot = diskSlots[i] as CouncilSpawnEntry; + const bundleSlot = bundleSlots[i] as CouncilSpawnEntry; + expect(bundleSlot).toEqual(diskSlot); + expect(diskSlot.maxRadius).toBe(40); + expect(bundleSlot.maxRadius).toBe(40); + } + }); +}); diff --git a/apps/web/public/assets/sprites/lpc-npc-9.png b/apps/web/public/assets/sprites/lpc-npc-9.png index 40120bc..628a642 100644 Binary files a/apps/web/public/assets/sprites/lpc-npc-9.png and b/apps/web/public/assets/sprites/lpc-npc-9.png differ diff --git a/apps/web/src/ChatPage.tsx b/apps/web/src/ChatPage.tsx index 9dafb72..4571c08 100644 --- a/apps/web/src/ChatPage.tsx +++ b/apps/web/src/ChatPage.tsx @@ -11,10 +11,10 @@ import { CollectiveAttitudeOverlay } from "./components/CollectiveAttitudeOverla import { CollectiveDebugPanel } from "./components/CollectiveDebugPanel.js"; import { CornerMenu } from "./components/CornerMenu.js"; import { DialogueOverlay } from "./components/DialogueOverlay.js"; -import type { DrawerTab } from "./components/DialogueBar.js"; import { OnboardingCoach } from "./components/OnboardingCoach.js"; import { ShellDrawer } from "./components/ShellDrawer.js"; import { useCollectiveAttitude } from "./hooks/useCollectiveAttitude.js"; +import { useShellDrawerState } from "./hooks/useShellDrawerState.js"; import { CHRONICLE_TOAST_MESSAGE, useWorldHistory, @@ -31,7 +31,6 @@ import { import { getMapRoomId } from "./lib/mapRoomId.js"; import { stripNpcsForViewport } from "./lib/stripNpcsForViewport.js"; import { - resolveCollectiveInitiatorPlayerId, shouldShowCollectiveFeedbackBanner, } from "./lib/collectiveInitiator.js"; import { getOrCreatePlayerId } from "./lib/playerSession.js"; @@ -179,8 +178,6 @@ export function ChatPage() { [fetchWorldHistoryEntry], ); const [draft, setDraft] = useState(""); - const [drawerOpen, setDrawerOpen] = useState(false); - const [drawerTab, setDrawerTab] = useState("history"); const [npcMoveHint, setNpcMoveHint] = useState(null); /** After first roomState sync, NPC live moves may animate; load/reset always snap. */ const [npcWorldLive, setNpcWorldLive] = useState(false); @@ -214,6 +211,20 @@ export function ChatPage() { useCollectiveAttitude(mapRoomId, activeNpcId, connected); onCollectiveUpdatedRef.current = refetchCollective; + const { + drawerOpen, + drawerTab, + openDrawer, + handleDrawerTabChange, + closeDrawer, + } = useShellDrawerState({ + clearChronicleUnread, + mapRoomId, + activeNpcId, + playerId, + collectiveRecentEvents: collectiveSnapshot?.recentEvents, + }); + const latestCollectiveEvent = collectiveSnapshot?.recentEvents[0]; const collectiveFeedbackKind = latestCollectiveEvent && @@ -222,58 +233,13 @@ export function ChatPage() { ? latestCollectiveEvent.kind : null; - const pendingCollectiveAutoOpenRef = useRef(false); - - useEffect(() => { - const event = collectiveSnapshot?.recentEvents[0]; - if (!event || event.kind !== "rude") return; - if (resolveCollectiveInitiatorPlayerId(event) !== playerId) return; - const key = `collective-auto-open:${mapRoomId}:${activeNpcId}`; - if (sessionStorage.getItem(key)) return; - pendingCollectiveAutoOpenRef.current = true; - setDrawerTab("collective"); - setDrawerOpen(true); - }, [collectiveSnapshot?.recentEvents, mapRoomId, activeNpcId, playerId]); - - // Defer sessionStorage until drawer stays open — Strict Mode remount clears the timer - // before storage is set, so the second mount can still auto-open (dev + Playwright UAT). - useEffect(() => { - if (!pendingCollectiveAutoOpenRef.current) return; - if (!drawerOpen || drawerTab !== "collective") return; - const key = `collective-auto-open:${mapRoomId}:${activeNpcId}`; - const t = window.setTimeout(() => { - sessionStorage.setItem(key, "1"); - pendingCollectiveAutoOpenRef.current = false; - }, 100); - return () => window.clearTimeout(t); - }, [drawerOpen, drawerTab, mapRoomId, activeNpcId]); - - const openDrawer = useCallback((tab: DrawerTab) => { - setDrawerTab(tab); - setDrawerOpen(true); - if (tab === "chronicle") { - clearChronicleUnread(); - } - }, [clearChronicleUnread]); - - const handleDrawerTabChange = useCallback( - (tab: DrawerTab) => { - setDrawerTab(tab); - if (tab === "chronicle") { - clearChronicleUnread(); - } - }, - [clearChronicleUnread], - ); - const handleCouncilVoteToastClick = useCallback( (toast: CouncilVoteToastPayload) => { if (toast.kind === "deliberation_start") { openDrawer("council"); return; } - setDrawerTab("chronicle"); - setDrawerOpen(true); + openDrawer("chronicle"); void openMinutesForEntry(toast.resultEntryId); }, [openDrawer, openMinutesForEntry], @@ -421,28 +387,30 @@ export function ChatPage() { useEffect(() => subscribeTabPresence(() => setDuplicateTab(true)), []); useEffect(() => { - if (!roomState) return; - const moves: string[] = []; - if (npcWorldLive) { - for (const npc of roomState.npcs) { - const prev = prevNpcPosRef.current.get(npc.id); - if (prev && (prev.x !== npc.x || prev.y !== npc.y)) { - moves.push(`${npc.name} 移动到 (${npc.x}, ${npc.y})`); + // Prefer Colyseus live grids for hints/moveMap; HTTP roomState is secondary. + if (roomState) { + const moves: string[] = []; + if (npcWorldLive) { + for (const npc of roomState.npcs) { + const prev = prevNpcPosRef.current.get(npc.id); + if (prev && (prev.x !== npc.x || prev.y !== npc.y)) { + moves.push(`${npc.name} 移动到 (${npc.x}, ${npc.y})`); + } } } + for (const npc of roomState.npcs) { + prevNpcPosRef.current.set(npc.id, { x: npc.x, y: npc.y }); + } + if (moves.length > 0) setNpcMoveHint(moves.join(";")); + setMoveMap((prev) => mergeRoomStateIntoMoveMap(roomState, prev, colyseusNpcGrids)); } - for (const npc of roomState.npcs) { - prevNpcPosRef.current.set(npc.id, { x: npc.x, y: npc.y }); - } - if (moves.length > 0) setNpcMoveHint(moves.join(";")); - setMoveMap((prev) => { - if (!npcWorldLive) return roomState; - return mergeRoomStateIntoMoveMap(roomState, prev, colyseusNpcGrids); - }); if (npcWorldLive) return; if (awaitingResetRef.current) return; if (initialNpcLiveDoneRef.current) return; + // Enable walk tweens once Colyseus NPCs exist — do not wait for HTTP roomState + // (that left early ambient steps on snapNpcTo = no walk frames). + if (roomNpcs.length === 0 && !roomState) return; const id = requestAnimationFrame(() => requestAnimationFrame(() => { @@ -451,7 +419,7 @@ export function ChatPage() { }), ); return () => cancelAnimationFrame(id); - }, [roomState, npcWorldLive, colyseusNpcGrids]); + }, [roomState, roomNpcs.length, npcWorldLive, colyseusNpcGrids]); const performResetGame = useCallback(async () => { prevNpcPosRef.current.clear(); @@ -553,7 +521,7 @@ export function ChatPage() { open={drawerOpen} tab={drawerTab} onTabChange={handleDrawerTabChange} - onClose={() => setDrawerOpen(false)} + onClose={closeDrawer} messages={messages} thinkingNpcId={thinkingNpcId} activeNpcId={activeNpcId} diff --git a/apps/web/src/game/RoomScene.ts b/apps/web/src/game/RoomScene.ts index 65b2b43..a54e75c 100644 --- a/apps/web/src/game/RoomScene.ts +++ b/apps/web/src/game/RoomScene.ts @@ -968,7 +968,7 @@ export class RoomScene extends Phaser.Scene { snapNpcToImpl(this.npcMotionCtx(), ent, gx, gy); } - /** NPC moves step-by-step (~140ms/cell), matching player sendMoveTo animation. */ + /** NPC moves step-by-step (~NPC_GRID_STEP_MS/cell = one LPC gait). */ private tweenNpcTo(ent: EntitySprite, gx: number, gy: number, npcId: string): void { tweenNpcToImpl(this.npcMotionCtx(), ent, gx, gy, npcId); } diff --git a/apps/web/src/game/entitySprites.ts b/apps/web/src/game/entitySprites.ts index 6fc32b1..7f5e17b 100644 --- a/apps/web/src/game/entitySprites.ts +++ b/apps/web/src/game/entitySprites.ts @@ -366,11 +366,8 @@ function playLpcWalkAnim(ent: AnimatableEntity, facing: CardinalFacing): void { const key = lpcNpcAnimKey(profile, "walk", facing); ent.facingDir = facing; applyFacingFlip(ent.avatar, facing, profile); - const current = ent.avatar.anims.currentAnim; - if (current?.key === key && ent.avatar.anims.isPlaying) { - return; - } - ent.avatar.play(key, true); + // Always (re)start walk — ignoreIfPlaying=false so idle→walk always swaps frames. + ent.avatar.play(key, false); } export function playWalkAnim(ent: AnimatableEntity, facing: CardinalFacing): void { diff --git a/apps/web/src/game/gridMovement.npcCatchup.test.ts b/apps/web/src/game/gridMovement.npcCatchup.test.ts new file mode 100644 index 0000000..30c13a9 --- /dev/null +++ b/apps/web/src/game/gridMovement.npcCatchup.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { + NPC_ANIMATE_CATCHUP_MAX_CELLS, + shouldSnapNpcCatchup, +} from "./gridMovement.js"; + +describe("shouldSnapNpcCatchup", () => { + it("allows adjacent and short steps to animate", () => { + expect(shouldSnapNpcCatchup(0, 0, 1, 0)).toBe(false); + expect(shouldSnapNpcCatchup(0, 0, 0, 2)).toBe(false); + expect(shouldSnapNpcCatchup(5, 5, 6, 6)).toBe(false); + }); + + it("snaps multi-cell cold-start / batched catch-up", () => { + expect(shouldSnapNpcCatchup(0, 0, 3, 0)).toBe(true); + expect(shouldSnapNpcCatchup(10, 10, 12, 12)).toBe(true); + expect(shouldSnapNpcCatchup(0, 0, 0, NPC_ANIMATE_CATCHUP_MAX_CELLS + 1)).toBe( + true, + ); + }); +}); diff --git a/apps/web/src/game/gridMovement.ts b/apps/web/src/game/gridMovement.ts index 6e2084e..3e27ee7 100644 --- a/apps/web/src/game/gridMovement.ts +++ b/apps/web/src/game/gridMovement.ts @@ -1,5 +1,28 @@ /** Shared grid locomotion timing — Phaser tweens + keyboard repeat. */ export const GRID_STEP_MS = 200; +/** + * NPC ambient visual step (ms). Must cover a readable LPC gait. + * Player WASD uses GRID_STEP_MS (200); NPCs only move every ambient tick (~6s), so + * reusing 200ms only shows ~2–3 walk frames → frozen-pose slide (“漂移”). + * 8 frames × 75ms = 600ms = one full NPC walk loop. + */ +export const NPC_GRID_STEP_MS = 600; +/** + * Max Manhattan distance for animated NPC catch-up. Larger gaps snap + * (cold start / batched Colyseus packs) — avoids multi-cell Linear "drift". + */ +export const NPC_ANIMATE_CATCHUP_MAX_CELLS = 2; + +/** True when animated walk would look like multi-cell drift — snap instead. */ +export function shouldSnapNpcCatchup( + fromX: number, + fromY: number, + toX: number, + toY: number, + maxCells: number = NPC_ANIMATE_CATCHUP_MAX_CELLS, +): boolean { + return Math.abs(fromX - toX) + Math.abs(fromY - toY) > maxCells; +} export const STEP_OVERLAP = 0.72; export const MAX_PREDICT_AHEAD = 8; /** Visual-only steps while network pending is full (keeps sprite moving at chunk boundaries). */ diff --git a/apps/web/src/game/lpcNpc1Sheet.test.ts b/apps/web/src/game/lpcNpc1Sheet.test.ts index 9581730..2203214 100644 --- a/apps/web/src/game/lpcNpc1Sheet.test.ts +++ b/apps/web/src/game/lpcNpc1Sheet.test.ts @@ -68,11 +68,10 @@ describe("lpcNpc1Sheet", () => { expect(LPC_NPC1_IDLE_BASE_ROW).toBe(22); }); - it("builds walk and idle anim keys per profile", () => { - expect(lpcNpcAnimKey("lpc-player-1", "walk", "down")).toBe("lpcp1-walk-down"); - expect(lpcNpcAnimKey("lpc-npc-1", "walk", "down")).toBe("lpc1-walk-down"); - expect(lpcNpcAnimKey("lpc-npc-2", "walk", "up")).toBe("lpc2-walk-up"); - expect(lpcNpcAnimKey("lpc-npc-3", "idle", "up")).toBe("lpc3-idle-up"); - expect(lpcNpcAnimKey("lpc-npc-12", "walk", "left")).toBe("lpc12-walk-left"); + it("NPC step duration covers one LPC walk loop (not player WASD 200ms)", async () => { + const { NPC_GRID_STEP_MS, GRID_STEP_MS } = await import("./gridMovement.js"); + const { lpcNpc1WalkCycleMs } = await import("./lpcNpc1Sheet.js"); + expect(NPC_GRID_STEP_MS).toBe(lpcNpc1WalkCycleMs("lpc-npc-1")); + expect(NPC_GRID_STEP_MS).toBeGreaterThan(GRID_STEP_MS); }); }); diff --git a/apps/web/src/game/roomSceneInput.ts b/apps/web/src/game/roomSceneInput.ts index 63375e6..39661b7 100644 --- a/apps/web/src/game/roomSceneInput.ts +++ b/apps/web/src/game/roomSceneInput.ts @@ -1,9 +1,12 @@ import * as Phaser from "phaser"; import type { ChunkView, RoomState } from "@aetherlife/shared"; import { + BEGINNING_FIELDS_ID, getCouncilSpawnSlots, + getWorldRegistry, HOME_MAP_TILE_H, HOME_MAP_TILE_W, + toGlobal, } from "@aetherlife/shared"; import { clientFindPath } from "../lib/chunkWalkability.js"; import { isGlobalFloorBlocked } from "./floorBlocked.js"; @@ -48,6 +51,33 @@ function isGridDebugEnabled(): boolean { /** Above all Y-sorted map sprites + Tiled overhead (screen-fixed HUD). */ const GRID_DEBUG_HUD_DEPTH = YSORT_OVERHEAD_DEPTH + 2_000; +/** Theme colors for Beginning Fields ambient zones (gridDebug overlay). */ +const ZONE_DEBUG_STYLE: Record = { + home: { fill: 0x88aacc, stroke: 0xaaddff, alpha: 0.08 }, + orchard: { fill: 0x44aa55, stroke: 0x66ff88, alpha: 0.22 }, + plaza: { fill: 0xcc9944, stroke: 0xffcc66, alpha: 0.22 }, + pond: { fill: 0x3377bb, stroke: 0x66aaff, alpha: 0.22 }, +}; + +function zonesAtCell(gx: number, gy: number): string[] { + void getCouncilSpawnSlots(); + const registry = getWorldRegistry(); + if (!registry) return []; + const region = registry.regions.find((r) => r.id === BEGINNING_FIELDS_ID); + const zones = registry.zonesByRegion.get(BEGINNING_FIELDS_ID); + if (!region || !zones) return []; + const hit: string[] = []; + for (const zone of zones) { + const { gx: x0, gy: y0 } = toGlobal(region, zone.rect.lx, zone.rect.ly); + const x1 = x0 + zone.rect.w; + const y1 = y0 + zone.rect.h; + if (gx >= x0 && gx < x1 && gy >= y0 && gy < y1) { + hit.push(`${zone.localId}(${zone.labelZh})`); + } + } + return hit; +} + function gridDebugHudText( x: number, y: number, @@ -55,7 +85,9 @@ function gridDebugHudText( ): string { const walk = regionWalkabilityAt(x, y); const walkLabel = walk === true ? "可走" : walk === false ? "阻挡" : "区外"; - return `gridDebug · 格 (${x}, ${y}) · ${walkLabel}\n已选 ${pickCount}/12 · Shift+点击记录出生点`; + const zones = zonesAtCell(x, y); + const zoneLine = zones.length > 0 ? zones.join(" · ") : "(无 zone)"; + return `gridDebug · 格 (${x}, ${y}) · ${walkLabel}\nzone: ${zoneLine}\n已选 ${pickCount}/12 · Shift+点击记录出生点`; } /** Dev: ?gridDebug=1 — grid overlay, hover cell, Shift+click records spawn candidates. */ @@ -74,8 +106,59 @@ function setupGridDebugPicker(ctx: RoomSceneInputCtx): void { w.__aetherlife_gridPicks = w.__aetherlife_gridPicks ?? []; const overlayGfx = ctx.scene.add.graphics().setDepth(YSORT_OVERHEAD_DEPTH - 200); + const zoneGfx = ctx.scene.add.graphics().setDepth(YSORT_OVERHEAD_DEPTH - 220); const hoverGfx = ctx.scene.add.graphics().setDepth(YSORT_OVERHEAD_DEPTH - 150); const markerGfx = ctx.scene.add.graphics().setDepth(YSORT_OVERHEAD_DEPTH - 100); + const zoneLabels: Phaser.GameObjects.Text[] = []; + + const drawZoneOverlay = () => { + zoneGfx.clear(); + for (const label of zoneLabels) label.destroy(); + zoneLabels.length = 0; + try { + // Same boot path as getCouncilSpawnSlots — registry may be cold on first paint. + void getCouncilSpawnSlots(); + const registry = getWorldRegistry(); + const region = registry?.regions.find((r) => r.id === BEGINNING_FIELDS_ID); + const zones = registry?.zonesByRegion.get(BEGINNING_FIELDS_ID) ?? []; + if (!region) return; + // Draw home first (full map wash), then nested activity zones on top. + const ordered = [...zones].sort((a, b) => { + if (a.localId === "home") return -1; + if (b.localId === "home") return 1; + return a.localId.localeCompare(b.localId); + }); + for (const zone of ordered) { + const style = ZONE_DEBUG_STYLE[zone.localId] ?? { + fill: 0xffffff, + stroke: 0xffffff, + alpha: 0.15, + }; + const { gx, gy } = toGlobal(region, zone.rect.lx, zone.rect.ly); + const px = gx * CELL_PX; + const py = gy * CELL_PX; + const pw = zone.rect.w * CELL_PX; + const ph = zone.rect.h * CELL_PX; + zoneGfx.fillStyle(style.fill, style.alpha); + zoneGfx.fillRect(px, py, pw, ph); + zoneGfx.lineStyle(zone.localId === "home" ? 2 : 3, style.stroke, 0.9); + zoneGfx.strokeRect(px + 1, py + 1, pw - 2, ph - 2); + const label = ctx.scene.add + .text(px + 6, py + 4, `${zone.localId} · ${zone.labelZh}\n(${gx},${gy})–(${gx + zone.rect.w - 1},${gy + zone.rect.h - 1})`, { + fontSize: "14px", + fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", + color: "#ffffff", + backgroundColor: "#000000b3", + padding: { x: 6, y: 4 }, + }) + .setDepth(YSORT_OVERHEAD_DEPTH - 210) + .setOrigin(0, 0); + zoneLabels.push(label); + } + } catch { + // registry not ready + } + }; const drawGridOverlay = () => { overlayGfx.clear(); @@ -176,6 +259,7 @@ function setupGridDebugPicker(ctx: RoomSceneInputCtx): void { hud.setText(gridDebugHudText(x, y, picks.length)); }); + drawZoneOverlay(); drawGridOverlay(); drawPickMarkers(); updateHover(ctx.input.activePointer); diff --git a/apps/web/src/game/roomSceneNpcMotion.ts b/apps/web/src/game/roomSceneNpcMotion.ts index 10838cb..a9bae41 100644 --- a/apps/web/src/game/roomSceneNpcMotion.ts +++ b/apps/web/src/game/roomSceneNpcMotion.ts @@ -3,14 +3,21 @@ import type { GridCell, RoomState } from "@aetherlife/shared"; import { clientFindPath } from "../lib/chunkWalkability.js"; import { entityYSortDepth } from "./entityLayout.js"; import { gridToWorld } from "./gridLayout.js"; -import { GRID_STEP_MS } from "./gridMovement.js"; +import { + NPC_ANIMATE_CATCHUP_MAX_CELLS, + NPC_GRID_STEP_MS, + shouldSnapNpcCatchup, +} from "./gridMovement.js"; import { applyStepAnimation, applyStepEndAnimation, } from "./entitySprites.js"; +import { isLpcProfile, lpcNpc1WalkCycleMs } from "./lpcNpc1Sheet.js"; import type { EntitySprite, PlayerSnap } from "./roomSceneTypes.js"; -const STEP_MS = GRID_STEP_MS; +const STEP_MS = NPC_GRID_STEP_MS; +/** Match remote peers — Linear + frozen stand reads as 漂移. */ +const NPC_STEP_EASE = "Cubic.easeInOut"; export type RoomSceneNpcMotionCtx = { registry: Phaser.Data.DataManager; @@ -49,11 +56,52 @@ export function pathForNpcMove( export function snapNpcTo(ctx: RoomSceneNpcMotionCtx, ent: EntitySprite, gx: number, gy: number): void { ent.targetGridX = gx; ent.targetGridY = gy; + ent.pendingGridX = undefined; + ent.pendingGridY = undefined; ctx.stopEntityMotion(ent); ctx.snapEntityToGrid(ent, gx, gy); } -/** NPC moves step-by-step (~140ms/cell), matching player sendMoveTo animation. */ +function beginNpcStepTween( + ctx: RoomSceneNpcMotionCtx, + ent: EntitySprite, + gx: number, + gy: number, + onArrived: () => void, +): void { + const fromX = ent.gridX; + const fromY = ent.gridY; + const { wx, wy } = gridToWorld(gx, gy); + ent.container.setDepth(entityYSortDepth(gx, gy, ent.depthLayer)); + applyStepAnimation(ent, fromX, fromY, gx, gy); + // Pace LPC gait to one cycle per cell (Phaser AnimationState.timeScale). + if (ent.avatar?.anims) { + const profile = ent.spriteProfile; + const cycleMs = + profile && isLpcProfile(profile) ? lpcNpc1WalkCycleMs(profile) : STEP_MS; + ent.avatar.anims.timeScale = cycleMs / STEP_MS; + } + ent.moveTween = ctx.tweens.add({ + targets: ent.container, + x: wx, + y: wy, + duration: STEP_MS, + ease: NPC_STEP_EASE, + onComplete: () => { + ent.gridX = gx; + ent.gridY = gy; + ent.moveTween = undefined; + if (ent.avatar?.anims) ent.avatar.anims.timeScale = 1; + onArrived(); + }, + }); +} + +/** + * NPC moves step-by-step (~NPC_GRID_STEP_MS/cell = one LPC gait). + * Must not kill an in-flight step when a stale schema still reports the start cell + * (ISSUE-004-style interrupt) — that causes idle-pose Linear “漂移”. + */ export function tweenNpcTo( ctx: RoomSceneNpcMotionCtx, ent: EntitySprite, @@ -64,8 +112,10 @@ export function tweenNpcTo( if (ent.gridX === gx && ent.gridY === gy) { ent.targetGridX = gx; ent.targetGridY = gy; - ctx.stopEntityMotion(ent); - ctx.snapEntityToGrid(ent, gx, gy); + ent.pendingGridX = undefined; + ent.pendingGridY = undefined; + // Stale schema at start cell while still tweening toward dest — keep going. + if (ent.moveTween?.isPlaying()) return; return; } @@ -77,59 +127,73 @@ export function tweenNpcTo( return; } - const reduced = ctx.registry.get("reducedMotion") as boolean; - const path = pathForNpcMove(ctx, ent.gridX, ent.gridY, gx, gy, npcId); + if (ent.moveTween?.isPlaying()) { + // Queue at most one follow-up cell; finish current step first. + ent.pendingGridX = gx; + ent.pendingGridY = gy; + ent.targetGridX = gx; + ent.targetGridY = gy; + return; + } - ctx.stopEntityMotion(ent); + const reduced = ctx.registry.get("reducedMotion") as boolean; ent.targetGridX = gx; ent.targetGridY = gy; - ctx.snapEntityToGrid(ent, ent.gridX, ent.gridY); + ent.pendingGridX = undefined; + ent.pendingGridY = undefined; - if (reduced) { + if (reduced || shouldSnapNpcCatchup(ent.gridX, ent.gridY, gx, gy)) { + ctx.stopEntityMotion(ent); ctx.snapEntityToGrid(ent, gx, gy); + applyStepEndAnimation(ent, false); return; } + const path = pathForNpcMove(ctx, ent.gridX, ent.gridY, gx, gy, npcId); + const resumeFromPendingOrIdle = (): void => { + const px = ent.pendingGridX; + const py = ent.pendingGridY; + if (px != null && py != null && (px !== ent.gridX || py !== ent.gridY)) { + ent.pendingGridX = undefined; + ent.pendingGridY = undefined; + tweenNpcTo(ctx, ent, px, py, npcId); + return; + } + applyStepEndAnimation(ent, false); + }; + if (!path || path.length <= 1) { const dist = Math.abs(ent.gridX - gx) + Math.abs(ent.gridY - gy); if (dist === 1) { - ctx.tweenEntityOneStep(ent, gx, gy, STEP_MS); + beginNpcStepTween(ctx, ent, gx, gy, resumeFromPendingOrIdle); } else { ctx.snapEntityToGrid(ent, gx, gy); + applyStepEndAnimation(ent, false); } return; } + if (path.length - 1 > NPC_ANIMATE_CATCHUP_MAX_CELLS) { + ctx.snapEntityToGrid(ent, gx, gy); + applyStepEndAnimation(ent, false); + return; + } + let stepIndex = 1; const walkNext = (): void => { if (stepIndex >= path.length) { - ent.moveTween = undefined; + resumeFromPendingOrIdle(); return; } const cell = path[stepIndex]!; stepIndex += 1; - const fromX = ent.gridX; - const fromY = ent.gridY; - const { wx, wy } = gridToWorld(cell.x, cell.y); - ent.container.setDepth(entityYSortDepth(cell.x, cell.y, ent.depthLayer)); - applyStepAnimation(ent, fromX, fromY, cell.x, cell.y); - ent.moveTween = ctx.tweens.add({ - targets: ent.container, - x: wx, - y: wy, - duration: STEP_MS, - ease: "Linear", - onComplete: () => { - ent.gridX = cell.x; - ent.gridY = cell.y; - const continuing = stepIndex < path.length; - applyStepEndAnimation(ent, continuing); - if (continuing) { - walkNext(); - } else { - ent.moveTween = undefined; - } - }, + beginNpcStepTween(ctx, ent, cell.x, cell.y, () => { + const continuing = stepIndex < path.length; + if (continuing) { + walkNext(); + } else { + resumeFromPendingOrIdle(); + } }); }; walkNext(); diff --git a/apps/web/src/game/roomSceneSync.ts b/apps/web/src/game/roomSceneSync.ts index 5716278..fbac979 100644 --- a/apps/web/src/game/roomSceneSync.ts +++ b/apps/web/src/game/roomSceneSync.ts @@ -257,6 +257,10 @@ export function syncRoomEntities(host: RoomSceneSyncHost): void { const seenNpcs = new Set(); const animateNpcMoves = host.registry.get("npcAnimateMoves") === true; + // Cold start / post-reset: first frame after animate turns on → snap-align (no long tween catch-up). + const wasAnimateMoves = host.registry.get("_npcAnimateMovesLatch") === true; + host.registry.set("_npcAnimateMovesLatch", animateNpcMoves); + const snapLiveEdge = animateNpcMoves && !wasAnimateMoves; for (const npc of mapNpcs) { seenNpcs.add(npc.id); let ent = host.npcSprites.get(npc.id); @@ -264,8 +268,13 @@ export function syncRoomEntities(host: RoomSceneSyncHost): void { ent = host.createNpcEntity(npcDisplayName(npc.name), npc.x, npc.y, npc.id, 1); host.npcSprites.set(npc.id, ent); } - if (animateNpcMoves) { - host.tweenNpcTo(ent, npc.x, npc.y, npc.id); + if (animateNpcMoves && !snapLiveEdge) { + const atCell = ent.gridX === npc.x && ent.gridY === npc.y; + const idle = + atCell && !ent.moveTween?.isPlaying() && ent.pendingGridX == null; + if (!idle) { + host.tweenNpcTo(ent, npc.x, npc.y, npc.id); + } } else { host.snapNpcTo(ent, npc.x, npc.y); } diff --git a/apps/web/src/game/roomSceneTypes.ts b/apps/web/src/game/roomSceneTypes.ts index a4ee418..43e9bc9 100644 --- a/apps/web/src/game/roomSceneTypes.ts +++ b/apps/web/src/game/roomSceneTypes.ts @@ -48,6 +48,9 @@ export type EntitySprite = AnimatableEntity & { intentLabelTween?: Phaser.Tweens.Tween; intentLabelWantShow?: boolean; moveTween?: Phaser.Tweens.Tween; + /** While a step tween plays, queue the next ambient/schema dest (1 cell). */ + pendingGridX?: number; + pendingGridY?: number; npcId?: string; playerSessionId?: string; spriteMode?: boolean; diff --git a/apps/web/src/hooks/useShellDrawerState.test.ts b/apps/web/src/hooks/useShellDrawerState.test.ts new file mode 100644 index 0000000..abd0960 --- /dev/null +++ b/apps/web/src/hooks/useShellDrawerState.test.ts @@ -0,0 +1,30 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { useShellDrawerState } from "./useShellDrawerState.js"; + +const HOOK_SRC = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "useShellDrawerState.ts"), + "utf8", +); +const CHAT_SRC = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "../ChatPage.tsx"), + "utf8", +); + +describe("useShellDrawerState", () => { + it("clears chronicle unread via openDrawer — no openChronicle shortcut", () => { + expect(HOOK_SRC).not.toMatch(/\bopenChronicle\b/); + expect(HOOK_SRC).toMatch(/if \(tab === "chronicle"\) \{\s*clearChronicleUnread\(\);/s); + expect(CHAT_SRC).not.toMatch(/\bopenChronicle\b/); + expect(CHAT_SRC).toMatch(/openDrawer\("chronicle"\)/); + }); + + it("exports openDrawer and does not export openChronicle", () => { + expect(typeof useShellDrawerState).toBe("function"); + expect(HOOK_SRC).toMatch(/return \{\s*drawerOpen,/); + expect(HOOK_SRC).toMatch(/openDrawer,/); + expect(HOOK_SRC).not.toMatch(/openChronicle,/); + }); +}); diff --git a/apps/web/src/hooks/useShellDrawerState.ts b/apps/web/src/hooks/useShellDrawerState.ts new file mode 100644 index 0000000..cb5900d --- /dev/null +++ b/apps/web/src/hooks/useShellDrawerState.ts @@ -0,0 +1,87 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { DrawerTab } from "../components/DialogueBar.js"; +import type { CollectiveEventSummary } from "./useCollectiveAttitude.js"; +import { + resolveCollectiveInitiatorPlayerId, +} from "../lib/collectiveInitiator.js"; + +export type UseShellDrawerStateOptions = { + clearChronicleUnread: () => void; + mapRoomId: string; + activeNpcId: string; + playerId: string; + collectiveRecentEvents: CollectiveEventSummary[] | undefined; +}; + +/** + * Shell drawer open/tab state + collective auto-open (rude event for initiator). + * Biography stays a council sub-slot (CouncilBiographySlot) — no top-level tab here. + */ +export function useShellDrawerState({ + clearChronicleUnread, + mapRoomId, + activeNpcId, + playerId, + collectiveRecentEvents, +}: UseShellDrawerStateOptions) { + const [drawerOpen, setDrawerOpen] = useState(false); + const [drawerTab, setDrawerTab] = useState("history"); + const pendingCollectiveAutoOpenRef = useRef(false); + + useEffect(() => { + const event = collectiveRecentEvents?.[0]; + if (!event || event.kind !== "rude") return; + if (resolveCollectiveInitiatorPlayerId(event) !== playerId) return; + const key = `collective-auto-open:${mapRoomId}:${activeNpcId}`; + if (sessionStorage.getItem(key)) return; + pendingCollectiveAutoOpenRef.current = true; + setDrawerTab("collective"); + setDrawerOpen(true); + }, [collectiveRecentEvents, mapRoomId, activeNpcId, playerId]); + + // Defer sessionStorage until drawer stays open — Strict Mode remount clears the timer + // before storage is set, so the second mount can still auto-open (dev + Playwright UAT). + useEffect(() => { + if (!pendingCollectiveAutoOpenRef.current) return; + if (!drawerOpen || drawerTab !== "collective") return; + const key = `collective-auto-open:${mapRoomId}:${activeNpcId}`; + const t = window.setTimeout(() => { + sessionStorage.setItem(key, "1"); + pendingCollectiveAutoOpenRef.current = false; + }, 100); + return () => window.clearTimeout(t); + }, [drawerOpen, drawerTab, mapRoomId, activeNpcId]); + + const openDrawer = useCallback( + (tab: DrawerTab) => { + setDrawerTab(tab); + setDrawerOpen(true); + if (tab === "chronicle") { + clearChronicleUnread(); + } + }, + [clearChronicleUnread], + ); + + const handleDrawerTabChange = useCallback( + (tab: DrawerTab) => { + setDrawerTab(tab); + if (tab === "chronicle") { + clearChronicleUnread(); + } + }, + [clearChronicleUnread], + ); + + const closeDrawer = useCallback(() => { + setDrawerOpen(false); + }, []); + + return { + drawerOpen, + drawerTab, + openDrawer, + handleDrawerTabChange, + closeDrawer, + }; +} diff --git a/docs/BEGINNING-FIELDS.md b/docs/BEGINNING-FIELDS.md index ce0cda3..3f9b3c1 100644 --- a/docs/BEGINNING-FIELDS.md +++ b/docs/BEGINNING-FIELDS.md @@ -99,23 +99,42 @@ atlas 整图加载 → object `setTexture(key, frame)` 无效 → 重复 tile / ## 议会 12 席出生点(Phase 26 · 村内多点分散) -**决策(2026-06-30 v3):** 12 议员锚点分布于全图(西北 x=5 → 东南 x=33),避免同屏聚团与占格堵路。所有点须可走(collision=0),任意两点 Chebyshev ≥3,`maxRadius: 0` 锁定锚点防止 ambient 聚团。`shuffleCouncilSpawnAssignments(roomId)` 将 12 槽位随机映射到 npc-1…12。 - -**SSOT:** `apps/game-server/data/world/beginning-fields@v1/spawns.json` → `councilSpawns[]`(本地格 lx/ly,经 `getCouncilSpawnSlots` 转全局 gx/gy)。 - -| 槽序 | 锚点 (x,y) | facing | 区域 | -|------|------------|--------|------| -| 0 | 9, 21 | s | 西侧偏南 | -| 1 | 9, 5 | s | 西北林地 | -| 2 | 23, 11 | e | 中北 | -| 3 | 31, 13 | w | 东北 | -| 4 | 17, 13 | e | 中部 | -| 5 | 33, 28 | n | 东南岸 | -| 6 | 20, 26 | s | 中南 | -| 7 | 16, 31 | n | 西南岸 | -| 8 | 27, 27 | w | 东南路径 | -| 9 | 29, 17 | s | 东侧 | -| 10 | 5, 9 | e | 西北角 | -| 11 | 17, 22 | s | 中西 | - -**约束:** 全点 collision 可走;与玩家默认 spawn (34,13) Chebyshev ≥3;任意两点 ≥3;x 跨度 ≥20、y 跨度 ≥20(见 `region-walkability.test.ts`);`maxRadius: 0`。 +**决策(2026-06-30 v3):** 12 议员锚点分布于全图(西北 x=5 → 东南 x=33),避免同屏聚团与占格堵路。所有点须可走(collision=0),任意两点 Chebyshev ≥3。最初 `maxRadius: 0` 钉死锚点防 ambient 聚团。 + +**修订(Phase 26.2 · 2026-07-15 · A3):** 全 12 议会席位统一 `maxRadius: 40`(zone 漫游为主;soft leash 仅防卡死/出界,半径大于最远锚点→角点 Chebyshev≈34)。`maxRadius===0` 仍表示钉死。决策 SSOT:`.planning/phases/26.2-world-alive-wander/26.2-CONTEXT.md`(D-19…D-25)。`shuffleCouncilSpawnAssignments(roomId)` 将 12 槽位随机映射到 npc-1…12。 + +**SSOT:** `apps/game-server/data/world/beginning-fields@v1/spawns.json` → `councilSpawns[]`(本地格 lx/ly,经 `getCouncilSpawnSlots` 转全局 gx/gy);镜像 `packages/shared` `defaultBeginningFieldsBundle`(双 SSOT 须同步,见 `council-spawn-radius.test.ts`)。 + +| 槽序 | 锚点 (x,y) | facing | 区域标签 | maxRadius (26.2) | +|------|------------|--------|----------|------------------| +| 0 | 9, 21 | s | west-path | 40 | +| 1 | 9, 5 | s | woodland | 40 | +| 2 | 23, 11 | e | orchard | 40 | +| 3 | 31, 13 | w | plaza | 40 | +| 4 | 17, 13 | e | central | 40 | +| 5 | 33, 28 | n | pond | 40 | +| 6 | 20, 26 | s | pond | 40 | +| 7 | 16, 31 | n | shore | 40 | +| 8 | 27, 27 | w | pond | 40 | +| 9 | 29, 17 | s | plaza | 40 | +| 10 | 5, 9 | e | woodland | 40 | +| 11 | 17, 22 | s | west-central | 40 | + +**约束:** 全点 collision 可走;与玩家默认 spawn (34,13) Chebyshev ≥3;任意两点 ≥3;x 跨度 ≥20、y 跨度 ≥20(见 `region-walkability.test.ts`);`maxRadius` 全席 40(`===0` 仍钉死)。 + +--- + +## Ambient zones(NPC 日程选目标矩形) + +双 SSOT:`apps/game-server/data/world/beginning-fields@v1/zones.json` ↔ `packages/shared` `defaultBeginningFieldsBundle`。 + +| localId | 中文 | 全局格范围 (含起不含终) | 用途 | +|---------|------|-------------------------|------| +| `home` | 起始田野(全图) | (0,0)–(39,39) | 白天 `wander` 主段:可在整张 home **可走格**闲逛 | +| `orchard` | 果园 | (18,6)–(29,15) | 劳作 / 晨读等短时 linger | +| `plaza` | 村口广场 | (28,8)–(39,19) | 社交 / POI(井) | +| `pond` | 池塘 | (22,22)–(35,33) | 钓鱼 / 休整 | + +**调试可视化:** `http://localhost:5173/?gridDebug=1` 叠加着色 zone 框 + 标签;悬停格 HUD 显示所属 zone。 + +**日程约定:** 长漫游用 `…:home` + `wander`;人物定位用短 `stationary`/`poi` 绑 orchard/plaza/pond;睡觉用 `resting`。 diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 5ba1d37..a7748b7 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -101,14 +101,14 @@ TS game-server、Python worker、LLM Prompt、`@aetherlife/game-actions` 之间 | **Worker** | `ambient_intent.py`:`LLM_PROVIDER_REFLECT` / `LORE` 结构化 JSON;**禁止** `tool_calls_to_actions` | | **写回** | `POST /internal/rooms/:roomId/npc-intent`(`requireWorkerAuth`)→ in-memory `setIntent` + `clearPendingNpcIntentJob` | | **Schema** | `@aetherlife/shared` `AmbientIntentSchema`:`target {gx,gy}` **或** `zoneId`;`reasonZh` ≤32;`untilGameMinute`;可选 `joinVicinity` | -| **Tick 消费** | `ambient/tick.ts` 读 cache:target 格或 zone-bias wander;过期/缺失 → zone-wander **且不得清空**已有 `intentReasonZh`(segment fallback 保留) | +| **Tick 消费** | `ambient/tick.ts` 读 cache:target 格或 zone-bias wander;过期/缺失 → zone-wander **且不得清空**已有 `intentReasonZh`(segment fallback 保留);**位移**另受 `NpcState.maxRadius` 约束(`===0` 钉死;`>0` soft leash — Phase **26.2** / MAP-06);走步资格 = **B2 `shouldStepThisTick`**(wander ~55 / linger ~30;join 绕过)**+ walk/pause**(通过后抽目标并持 walk,到达停 2–8 tick;mid-walk 不再重掷 B2)。仅 `resting` 经 `shouldSkipMovement` 完全跳过移动(`idle` 不跳过 — Guardrail #110);**同 tick 可多名 NPC 移动**(26.2 / B2,取代 Phase 26 独占分桶) | | **reasonZh 语义** | **动机层**(情绪/社交/短期打算,12–18 字);禁止与 `activityDisplayZh` 同义复述;segment 开始时 **同步** rule fallback(`intent-fallback.ts`),LLM 异步 **静默替换** | | **Dedupe** | `@aetherlife/shared` `isReasonZhRedundantWithActivity`;server `applyIntentToLiveRoom` + client `effectiveIntentReasonZh` | -| **Join** | `joinVicinity` → 8s 窗口内 NPC 朝发起者邻近格移动;worker 每 NPC 每 game-day bucket(480 分钟)最多 2 次 | +| **Join** | `joinVicinity` → 8s 窗口内 NPC 朝发起者邻近格移动;该窗口内该 NPC **绕过 B2 门与 soft leash**,并打断 walk/pause hold(D-11);`maxRadius===0` 钉死仍优先于 join;worker 每 NPC 每 game-day bucket(480 分钟)最多 2 次 | | **Colyseus** | `npc{N}IntentReasonZh`、`JoinVicinityActive/Until/StartedAt` 同步至客户端 | | **UI** | 玩家可见 **永久两行**(名 + activity);**禁止**渲染 `intentReasonZh` 第三行;`updateIntentLabels` 恒隐藏;L2 `reasonZh` 仅 registry/debug/speak 引用;**禁止** spinner / thought bubble;frozen:`entityLabels.ts`、`useNpcChat.ts` | -**验证:** `pnpm --filter @aetherlife/game-server test -- intent-cache npc-ambient-intent ambient/tick`;`cd workers/agent-worker && LLM_MOCK=1 uv run pytest tests/test_ambient_intent.py -q`;`pnpm --filter @aetherlife/web test -- RoomScene.activity`;`pnpm dev:stack` → `pnpm verify:phase16`(真实 LLM,≤45s intent 断言)。 +**验证:** `pnpm --filter @aetherlife/game-server test -- intent-cache npc-ambient-intent ambient/tick src/world/council-spawn-radius.test.ts`;`cd workers/agent-worker && LLM_MOCK=1 uv run pytest tests/test_ambient_intent.py -q`;`pnpm --filter @aetherlife/web test -- RoomScene.activity`;`pnpm dev:stack` → `pnpm verify:phase16`(真实 LLM,≤45s intent 断言)。 **锚点文件:** `ambient/intent-cache.ts`, `queue/npc-ambient-intent.ts`, `routes/internal-ambient-intent.ts`, `ambient/tick.ts`, `workers/.../ambient_intent.py`, `apps/web/src/game/intentLabels.ts`. diff --git a/docs/ISSUE-LOG.md b/docs/ISSUE-LOG.md index aab8a9b..d4b756d 100644 --- a/docs/ISSUE-LOG.md +++ b/docs/ISSUE-LOG.md @@ -73,6 +73,7 @@ 9. **禁止** 在 `POST /reset` 完成前递增 `npcResetEpoch`:否则会用**旧** `mapNpcs` 重建 sprite,再在 `npcWorldLive=true` 时从远格 tween 回默认格(「走回」)。 10. 重置顺序:`flushSync` 关闭 animate → `await resetGame()` → `flushSync` 同步 `moveMap` + `npcResetEpoch` → 双 `rAF` 再开启 live。 +10b. **NPC 步进时长必须覆盖 LPC gait**:`NPC_GRID_STEP_MS`(600 = 8×75ms)≠ 玩家 `GRID_STEP_MS`(200)。200ms 格间 tween 只能露出 ~2–3 走帧 → 站立姿势滑动(「漂移」)。`npcAnimateMoves` false→true 首帧 snap;曼哈顿/路径 >2 snap;禁止同格 schema 打断进行中步;`moveMap` 始终合并 Colyseus;**`npcWorldLive` 可在 `roomNpcs` 就绪后开启**(勿只等 HTTP roomState)。回归:`lpcNpc1Sheet.test.ts` · `gridMovement.npcCatchup.test.ts`。 11. 回归:`pnpm uat:phase7:reset-snap`(需 `pnpm --filter @aetherlife/shared build` + dev web/gs)。 12. **`window.__aetherlife_npcDebug` 仅允许在 `import.meta.env.DEV` 下挂载**;生产构建不得暴露网格/tween 内省。 @@ -192,7 +193,9 @@ 106. **全员 LPC 角色皮**:本地/远端**所有玩家**与 **`npc-1`…`npc-12`** 使用烘焙 `sprites/lpc-player-1.png` + `sprites/lpc-npc-{1…12}.png`(`createPlayerSprite` → `createLpcNpcSprite`;`spriteProfileForNpc` 映射 npc-1…12);**禁止**恢复 `sprites/characters.png` 四色 palette 作玩家皮除非新开 phase 决策。`useSpriteEntities()` 门槛:`spritesLpcNpc1` + `spritesNpcs` 均须存在(全部 `lpc-npc-*` 随 `CORE_AREA_ASSETS` 加载)。烘焙:`pnpm assets:sync:lpc-npcs`(源 `npc-asset/player-1.png` + `npc-asset/npc-{1…12}.png`)。文档:[BEGINNING-FIELDS.md](./BEGINNING-FIELDS.md) §角色视觉。回归:`pnpm --filter @aetherlife/web test` + `pnpm verify:phase6:move-only`。 107. **显示格 CELL_PX=32**:`gridLayout.CELL_PX=32`(16px 源 ×2);角色显示高 `CHAR_DISPLAY_PX=64`(占 2 逻辑格)。改 `CELL_PX` 须同步 `entityLayout.LABEL_SCALE`、`GRID_STEP_MS`、地图注释与 [BEGINNING-FIELDS.md](./BEGINNING-FIELDS.md)。Phase 13.3 历史仍为 48px 校准记录,**当前运行时以 32px 为准**。 108. **Phase 26+ verify 禁止断言 bg-villager**:`verify:phase16` / UAT 脚本 **禁止** 要求房间存在 `bg-villager-*`(ISSUE-101);ambient/铭牌回归用 12 席 council + `verify:phase26`。 - +109. **Phase 26 D-MAP-AMB-03 独占 12 分桶(每 tick 仅 1 NPC 移动)已被 26.2 B2 取代**:禁止重新引入 exclusive bucket。走步资格 = **B2 `shouldStepThisTick`**(新开一程;join 绕过)**+ walk/pause**(mid-walk 不重掷 B2)。双 SSOT `maxRadius` 改动必须过 `council-spawn-radius.test.ts`。回归:`pnpm --filter @aetherlife/game-server test -- src/ambient/`。 +110. **Ambient 动森式闲逛(26.2 gap)**:`shouldSkipMovement` **仅** `resting`;日程发呆用 `wandering`+`wander`。`stationary` 在 zone 外须 **通勤到最近 zone 格**。选目标 **永不叠格**(占用 + 本 tick reserved);偏好 `PERSONAL_SPACE≥2`,擦肩可。白天主漫游 zone 为 `beginning-fields@v1:home`(全图);子 zone orchard/plaza/pond 仅短时人设 linger。改 zones 须双 SSOT(`zones.json` + `defaultBeginningFieldsBundle`)。回归:`src/ambient/` + 新 `roomId` + `?gridDebug=1` 看 zone。 +111. **禁止移除 `runAmbientTick` 内 B2 调用**:`shouldStepThisTick(npc.id, …)` 须在 `maxRadius===0` 钉死与 walk/pause 之后、resolve 之前保留(mid-walk / join 绕过)。`45b6455` 曾只留 walk/pause 导致门失效(ISSUE-105);改 `tick.ts` 须保留 canary `wires shouldStepThisTick(npc.id)` + `skips stepping when shouldStepThisTick fails`。 ## 记录 ### ISSUE-001 — thinking 中切换 NPC Tab 后无法移动(UI 冻结) @@ -2665,4 +2668,102 @@ Worker 主循环仅在 npc-turn 队列 **连续 5s 为空** 时才 `BLPOP` chunk --- +### ISSUE-103 — World Alive UAT:站桩 + 重叠扎堆(动森感不足) + +- **状态:** fixed +- **发现:** 2026-07-15(`/gsd-verify-work 26.2` 人工) +- **阶段/范围:** Phase 26.2 gap · `apps/game-server/src/ambient/**` · `data/schedules/npc-4|7.json` +- **严重性:** major + +**根因** + +- `shouldSkipMovement` 把日程 `idle` 当睡觉 → npc-4/7 早晨长时间冻住。 +- `stationary` 且 spawn 离 zone >`LINGER_RADIUS` 时 `nearby=[]` 原地 fallback → 全天 stationary 席位永久站桩。 +- `pickZoneTarget` 不看他人占位 → 多人可同去一格,视觉重叠扎堆。 +- 每 tick 重抽目标 + 概率门 → 不像「走到再停」的真人节奏。 + +**修复** + +- 仅 `resting` 跳过移动;npc-4/7 idle 段并进 `wandering`+`wander`。 +- zone 外 stationary → 通勤最近 zone 格;选格排除占用/本 tick reserved,偏好 `PERSONAL_SPACE=2`。 +- `AmbientMotion` walk/pause(到达停 2–8 tick,走路超时 48 tick 重抽)。 + +**验证** + +- `pnpm --filter @aetherlife/game-server test -- src/ambient/` +- 实机:`pnpm dev:stack` + **新 roomId**,看 2–3 min(无永久站桩、无叠格) + +**防复发** + +- Guardrail #110 + +--- + +### ISSUE-104 — Ambient NPC 冷启动/步进呈 idle「漂移」(无可读走帧) + +- **状态:** fixed +- **发现:** 2026-07-15(Phase 26.2 World Alive 实机) +- **阶段/范围:** Phase 26.2 · `apps/web/src/game/**` · `ChatPage` `npcWorldLive` +- **严重性:** major(世界「活着」体感) + +**复现** + +1. `pnpm dev:stack`,硬刷新进房(新 roomId 更易见)。 +2. 观察议会 NPC ambient 前几步:角色滑动到邻格,几乎不见 LPC walk 帧;稍后偶发可见走帧。 + +**根因** + +- NPC 格间 tween 误用玩家 WASD 的 `GRID_STEP_MS=200`;LPC walk 循环为 **8×75ms≈600ms** → 200ms 仅露 ~2–3 帧,观感 = 站立姿势滑动(「漂移」)。 +- `npcWorldLive` 曾只等 HTTP `roomState`:Colyseus 已有格时 early ambient 仍走 `snapNpcTo`(无 walk)。 +- 叠加:live 边沿未 snap、远距 catch-up tween、同格 schema 打断进行中步、`moveMap` 被滞后 HTTP 盖写。 + +**修复** + +- 新增 `NPC_GRID_STEP_MS=600`;步进时 `anims.timeScale = cycleMs / stepMs` 对齐 gait。 +- LPC `play(key, false)` 强制 idle→walk;同目标中途不打断;远距/多格 snap(`NPC_ANIMATE_CATCHUP_MAX_CELLS=2`)。 +- `npcWorldLive` 在 `roomNpcs` 就绪即可开;`moveMap` 始终合并 Colyseus;live 边沿首帧 snap。 + +**验证** + +- `pnpm --filter @aetherlife/web exec vitest run src/game/lpcNpc1Sheet.test.ts src/game/gridMovement.npcCatchup.test.ts` +- `pnpm --filter @aetherlife/web exec vitest run src/game/` +- 实机硬刷新:ambient 每步有清晰 walk 循环(非 idle 滑行) + +**防复发** + +- Guardrail **10b**(NPC 步进须覆盖 LPC gait / live 门禁) + +--- + +### ISSUE-105 — B2 `shouldStepThisTick` 从 `runAmbientTick` 被摘掉(仅留导出) + +- **状态:** fixed +- **发现:** 2026-07-15(`/gsd-validate-phase 26.2` Nyquist) +- **阶段/范围:** Phase 26.2 · `apps/game-server/src/ambient/tick.ts` +- **严重性:** major(D-22/D-25 / MAP-06 门失效;多人同分钟概率门与 join 绕过半段不可证) + +**复现** + +1. `grep -c 'shouldStepThisTick(npc.id' apps/game-server/src/ambient/tick.ts` → `0` +2. 在 gate-FAIL 分钟跑 `runAmbientTick`(无 walk hold)→ NPC 仍可步进 + +**根因** + +- `0952357` 接入 B2;`45b6455` 引入 walk/pause 时删除了循环内调用,docs 误改为「历史断言」。 + +**修复** + +- 恢复:非 mid-walk、非 `joinVicinityActive` 时 `!shouldStepThisTick(npc.id, …) → continue` +- mid-walk Continuum 与 join 仍绕过;C-06 / ambient README / Guardrail #109+#111 对齐 + +**验证** + +- `pnpm --filter @aetherlife/game-server test -- src/ambient/ src/world/council-spawn-radius.test.ts`(含 B2 wire canaries) + +**防复发** + +- Guardrail #111 + +--- + diff --git a/packages/shared/src/worldRegion.ts b/packages/shared/src/worldRegion.ts index 4f98fcb..e962f74 100644 --- a/packages/shared/src/worldRegion.ts +++ b/packages/shared/src/worldRegion.ts @@ -397,6 +397,7 @@ export function defaultBeginningFieldsBundle(): WorldRegistryBundle { zonesByRegionId: { [BEGINNING_FIELDS_ID]: { zones: [ + { id: "home", labelZh: "起始田野(全图)", rect: { lx: 0, ly: 0, w: 40, h: 40 } }, { id: "orchard", labelZh: "果园", rect: { lx: 18, ly: 6, w: 12, h: 10 } }, { id: "plaza", labelZh: "村口广场", rect: { lx: 28, ly: 8, w: 12, h: 12 } }, { id: "pond", labelZh: "池塘", rect: { lx: 22, ly: 22, w: 14, h: 12 } }, @@ -417,18 +418,18 @@ export function defaultBeginningFieldsBundle(): WorldRegistryBundle { [BEGINNING_FIELDS_ID]: { defaultPlayerSpawn: { lx: 34, ly: 13 }, councilSpawns: [ - { x: 9, y: 21, facing: "s", maxRadius: 0 }, - { x: 9, y: 5, facing: "s", maxRadius: 0 }, - { x: 23, y: 11, facing: "e", maxRadius: 0 }, - { x: 31, y: 13, facing: "w", maxRadius: 0 }, - { x: 17, y: 13, facing: "e", maxRadius: 0 }, - { x: 33, y: 28, facing: "n", maxRadius: 0 }, - { x: 20, y: 26, facing: "s", maxRadius: 0 }, - { x: 16, y: 31, facing: "n", maxRadius: 0 }, - { x: 27, y: 27, facing: "w", maxRadius: 0 }, - { x: 29, y: 17, facing: "s", maxRadius: 0 }, - { x: 5, y: 9, facing: "e", maxRadius: 0 }, - { x: 17, y: 22, facing: "s", maxRadius: 0 }, + { x: 9, y: 21, facing: "s", maxRadius: 40 }, + { x: 9, y: 5, facing: "s", maxRadius: 40 }, + { x: 23, y: 11, facing: "e", maxRadius: 40 }, + { x: 31, y: 13, facing: "w", maxRadius: 40 }, + { x: 17, y: 13, facing: "e", maxRadius: 40 }, + { x: 33, y: 28, facing: "n", maxRadius: 40 }, + { x: 20, y: 26, facing: "s", maxRadius: 40 }, + { x: 16, y: 31, facing: "n", maxRadius: 40 }, + { x: 27, y: 27, facing: "w", maxRadius: 40 }, + { x: 29, y: 17, facing: "s", maxRadius: 40 }, + { x: 5, y: 9, facing: "e", maxRadius: 40 }, + { x: 17, y: 22, facing: "s", maxRadius: 40 }, ], }, }, diff --git a/scripts/uat-pr19-cr-screenshots.mjs b/scripts/uat-pr19-cr-screenshots.mjs new file mode 100644 index 0000000..3042704 --- /dev/null +++ b/scripts/uat-pr19-cr-screenshots.mjs @@ -0,0 +1,157 @@ +/** + * PR19 CR acceptance — Playwright against current immersive UI + screenshots. + * Covers: ambient gridDebug, tutorial skip, Phaser boot, WASD, speak, chronicle drawer. + * Requires: pnpm dev:stack (real LLM). WEB_URL=http://localhost:5173 + */ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + assertE2eNoMock, + assertE2eRealLlm, + e2eSpeakTimeoutMs, +} from "./lib/e2e-policy.mjs"; +import { loadRootEnv } from "./lib/env.mjs"; +import { healthOk, loadPlaywright, sleep, webBase } from "./lib/speak-browser-stack.mjs"; +import { engageNpcDialogue } from "./lib/dialogue-engage.mjs"; +import { sendSpeakOverlay } from "./lib/e2e-memory-helpers.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +loadRootEnv(root); +assertE2eNoMock("uat:pr19-cr"); +assertE2eRealLlm("uat:pr19-cr"); + +const OUT = resolve(root, "tmp/pr19-cr-gf-screenshots"); +const roomId = `pr19-cr-accept-${Date.now()}`; +const speakTimeoutMs = Math.max(120_000, e2eSpeakTimeoutMs()); + +async function shot(page, name) { + const file = resolve(OUT, name); + await page.screenshot({ path: file, fullPage: true }); + console.log(`[pr19-accept] 📸 ${name}`); + return file; +} + +async function skipTutorial(page) { + const skip = page.locator('button:has-text("跳过")').first(); + if (await skip.isVisible().catch(() => false)) { + await skip.click(); + await sleep(400); + } +} + +async function main() { + await mkdir(OUT, { recursive: true }); + await healthOk(); + + const chromium = await loadPlaywright(); + const browser = await chromium.launch({ headless: true }); + const report = { + startedAt: new Date().toISOString(), + roomId, + tests: [], + pass: false, + }; + + const record = (id, name, pass, detail) => { + report.tests.push({ id, name, pass, detail }); + console.log(`[pr19-accept] ${pass ? "✓" : "✗"} ${id} ${name}${detail ? ` — ${detail}` : ""}`); + }; + + const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + const url = `${webBase}/?roomId=${encodeURIComponent(roomId)}&gridDebug=1`; + console.log(`[pr19-accept] open ${url}`); + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 }); + await page.waitForSelector('[data-testid="world-stage"]', { timeout: 30_000 }); + await page.waitForSelector('[data-testid="phaser-parent"], canvas', { timeout: 45_000 }); + await sleep(2500); + await skipTutorial(page); + await shot(page, "10-accept-boot-skip-tutorial.png"); + record(1, "cold boot + phaser + skip tutorial", true, "world-stage visible"); + + // Ambient roam window + await sleep(12_000); + await shot(page, "11-accept-ambient-roam.png"); + record(2, "ambient roam wait (~2 ticks)", true, "gridDebug still up"); + + // WASD nudge + await page.keyboard.press("KeyD"); + await sleep(800); + await page.keyboard.press("KeyW"); + await sleep(800); + await shot(page, "12-accept-after-wasd.png"); + record(3, "WASD move", true, "D+W"); + + // Speak path (npc-4 safer for non-hostile) + try { + await engageNpcDialogue(page, "npc-4", { timeoutMs: Math.min(90_000, speakTimeoutMs) }); + await shot(page, "13-accept-dialogue-engaged.png"); + await sendSpeakOverlay(page, "你好,用一句话简短回复"); + await page + .locator('[data-testid="dialogue-overlay"]') + .waitFor({ state: "visible", timeout: speakTimeoutMs }); + // wait for non-empty npc reply / streaming end + await page.waitForFunction( + () => { + const el = document.querySelector('[data-testid="dialogue-overlay"]'); + if (!el) return false; + const t = (el.textContent || "").trim(); + return t.length > 8 && !t.includes("思考中"); + }, + { timeout: speakTimeoutMs }, + ); + await shot(page, "14-accept-npc-reply.png"); + record(4, "speak → npc reply", true, `timeoutBudget=${speakTimeoutMs}ms`); + } catch (err) { + await shot(page, "14-accept-speak-FAILED.png"); + record(4, "speak → npc reply", false, String(err?.message || err)); + } + + // Chronicle via corner menu / drawer + try { + const menu = page.locator('[data-testid="corner-menu"]').first(); + if (await menu.count()) { + await menu.click({ force: true }).catch(() => {}); + await sleep(400); + } + // open drawer through dialogue bar chronicle/memory if engaged else corner + const chron = page + .locator( + '[data-testid="dialogue-drawer-memory"], button:has-text("编年"), button:has-text("史书"), [data-tab="chronicle"]', + ) + .first(); + if (await chron.count()) { + await chron.click({ force: true }).catch(() => {}); + await sleep(700); + } + await shot(page, "15-accept-chronicle-drawer.png"); + record(5, "chronicle / drawer open attempt", true, "screenshot captured"); + } catch (err) { + await shot(page, "15-accept-drawer-FAILED.png"); + record(5, "chronicle / drawer open attempt", false, String(err?.message || err)); + } + + // Peer tab smoke + const pageB = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + await pageB.goto(`${webBase}/?roomId=${encodeURIComponent(roomId)}`, { + waitUntil: "domcontentloaded", + timeout: 60_000, + }); + await pageB.waitForSelector('[data-testid="world-stage"]', { timeout: 30_000 }); + await skipTutorial(pageB); + await sleep(3000); + await shot(pageB, "16-accept-peer-tab.png"); + record(6, "second tab same room", true, "world-stage ok"); + + await browser.close(); + report.finishedAt = new Date().toISOString(); + report.pass = report.tests.every((t) => t.pass); + await writeFile(resolve(OUT, "pr19-accept-report.json"), JSON.stringify(report, null, 2)); + console.log(`[pr19-accept] report → tmp/pr19-cr-gf-screenshots/pr19-accept-report.json pass=${report.pass}`); + if (!report.pass) process.exit(1); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/verify-phase8.mjs b/scripts/verify-phase8.mjs index f3436fb..ae09630 100644 --- a/scripts/verify-phase8.mjs +++ b/scripts/verify-phase8.mjs @@ -538,29 +538,36 @@ async function main() { ), ]); console.log("verify:phase8: NL staging positions OK"); + // Assert adjacency immediately after each speak. Waiting until both finishes lets + // speak_end ambient walk the first NPC away during the second turn (GF-03 flake). + const nlAdjacentMs = Number.parseInt( + process.env.VERIFY_NL_ADJACENT_MS || "45000", + 10, + ); for (let nlAttempt = 0; nlAttempt < 2; nlAttempt++) { if (nlAttempt > 0) { console.warn(`verify:phase8: dual NL move retry=${nlAttempt}`); } - await runSpeakTurn( - roomA, - { text: "移动到我的下方", npcId: "npc-1", playerId: playerAId }, - "NL npc-1 player A", - speakTimeoutMs, - ); - await runSpeakTurn( - roomB, - { text: "移动到我的下方", npcId: "npc-2", playerId: playerBId }, - "NL npc-2 player B", - speakTimeoutMs, - ); try { + // Avoid personality-seed hostile seats (npc-1/6/8/11): D-20 gate blocks move. + await runSpeakTurn( + roomA, + { text: "移动到我的下方", npcId: "npc-3", playerId: playerAId }, + "NL npc-3 player A", + speakTimeoutMs, + ); await waitForNpcAdjacent( roomA, roomA.sessionId, playerAId, - "npc-1", - "player A → npc-1", + "npc-3", + "player A → npc-3", + nlAdjacentMs, + ); + await runSpeakTurn( + roomB, + { text: "移动到我的下方", npcId: "npc-2", playerId: playerBId }, + "NL npc-2 player B", speakTimeoutMs, ); await waitForNpcAdjacent( @@ -569,7 +576,7 @@ async function main() { playerBId, "npc-2", "player B → npc-2", - speakTimeoutMs, + nlAdjacentMs, ); break; } catch (err) { diff --git a/workers/agent-worker/src/graph/nodes/llm_social_turn.py b/workers/agent-worker/src/graph/nodes/llm_social_turn.py index bdd04a3..f9fa841 100644 --- a/workers/agent-worker/src/graph/nodes/llm_social_turn.py +++ b/workers/agent-worker/src/graph/nodes/llm_social_turn.py @@ -26,8 +26,10 @@ from src.graph.recall_merge import is_recall_question from src.graph.speak_intent import SpeakIntent, is_casual_greeting_only from src.graph.stable_string_hash import stable_string_hash -from src.graph.persona import build_persona_block -from src.graph.prompt import build_room_constraints, format_attitude_context +from src.graph.speak_system_context import ( + SOCIAL_MEMORY_RECALL_HINT, + build_speak_system_context, +) from src.graph.state import GraphState from src.graph.tools import load_tools_for_binding, parse_tool_calls, reply_from_turn from src.llm.call_budget import record_llm_call @@ -301,33 +303,12 @@ def _build_social_messages( *, system_append: str = "", ) -> list[SystemMessage | HumanMessage]: - room = state.get("room_snapshot") or {} - attitude = format_attitude_context( - band=state.get("attitude_band"), - effective_score=state.get("effective_score"), - summaries=state.get("collective_summaries"), - ) - npc_id = state.get("npc_id") or "npc-1" - persona_block = build_persona_block( - npc_id, - runtime_relationships=state.get("runtime_relationships"), + system_text = build_speak_system_context( + state, + base_prompt=SOCIAL_SYSTEM_PROMPT, + memory_suffix=SOCIAL_MEMORY_RECALL_HINT, + system_append=system_append, ) - base_prompt = SOCIAL_SYSTEM_PROMPT - if persona_block: - base_prompt = f"{base_prompt}\n\n{persona_block}" - system_text = f"{base_prompt}\n{build_room_constraints(room)}\n\n{attitude}" - memory = (state.get("memory_summary") or "").strip() - if memory: - system_text = ( - f"{system_text}\n\nMemory summary:\n{memory}\n" - "若玩家追问 Memory summary 中已有的事实,reply 须直接给出答案,勿拒绝或说「不记得」。" - ) - canon = (state.get("canon_context") or "").strip() - if canon: - system_text = f"{system_text}\n\n{canon}" - append = (system_append or "").strip() - if append: - system_text = f"{system_text}\n\n{append}" player_message = state.get("player_message") or "" human = ( f"Player message: {player_message}\n\n" diff --git a/workers/agent-worker/src/graph/npc_loop.py b/workers/agent-worker/src/graph/npc_loop.py index d577c09..ce9917b 100644 --- a/workers/agent-worker/src/graph/npc_loop.py +++ b/workers/agent-worker/src/graph/npc_loop.py @@ -1,7 +1,6 @@ import os import sys import time -from concurrent.futures import ThreadPoolExecutor from typing import Any import httpx @@ -20,16 +19,13 @@ resolve_npc_snap_anchor_cell, ) from src.graph.action_sanitize import tool_calls_to_actions -from src.graph.prompt import build_turn_messages, format_memory_summary +from src.graph.prompt import build_turn_messages from src.graph.reflect import run_reflect_llm, should_reflect from src.graph.state import GraphState from src.graph.summarize import maybe_bulk_summarize from src.graph.recall_merge import ( - augment_retrieved_with_dialogue_turns, - augment_retrieved_with_recent, is_recall_question, merge_recall_into_reply, - needs_recency_augment, pick_recall_memory, ) from src.graph.reply_sanitize import sanitize_npc_reply @@ -56,13 +52,6 @@ ) from src.graph.nodes.llm_social_turn import llm_social_turn from src.graph.job_context import get_partial_emit, record_phase_ms -from src.graph.speak_intent import ( - SpeakIntent, - classify_speak_intent, - message_needs_nearby_lore, - should_skip_memory_context, - should_skip_memory_embed, -) from src.llm.call_budget import record_llm_call from src.council.leaning_drift import ( apply_speak_leaning_drift, @@ -71,557 +60,47 @@ ) from src.council.memory_context import fetch_dual_rag_context from src.memory.client import ( - _MEMORY_CONTEXT_INTERACTIVE_TIMEOUT_S, - _MEMORY_CONTEXT_RECALL_ATTEMPTS, - _MEMORY_CONTEXT_RECALL_TIMEOUT_S, append_npc_memory, append_player_memory, - fetch_memory_context, fetch_recent_memories, - parse_collective_from_context, store_reflection, ) from src.memory.importance import DEFAULT_IMPORTANCE, score_importance, score_turn_importance from src.persistence.checkpointer import get_checkpointer +# Re-exports / shared symbols (patch-friendly aliases for legacy tests) +from src.graph.worker_state_fetch import ( # noqa: F401 + _FETCH_STATE_HOT_CACHE_TTL_S, + _FETCH_STATE_TIMEOUT_S, + _STALE_SNAPSHOT_TTL_S, + _game_headers, + _hot_worker_snapshot, + _player_id, + _remember_worker_snapshot, + _remember_worker_snapshot_all_projections, + _stale_worker_snapshot, + _stale_worker_snapshots, + _worker_state_stale_key, + fetch_state, +) +from src.graph.speak_fetch import ( # noqa: F401 + _MEMORY_MERGE_KEYS, + _attach_speak_enrichment, + _fetch_speak_enrichment, + _load_collective_gate_fields, + _neutral_memory_fields, + fetch_nearby_lore_into_snapshot, + fetch_runtime_relationship_edges, + fetch_state_and_memory, + load_memory_context, +) + def _mock_tool_calls() -> list[dict[str, Any]]: # Avoid door cell (3,3) — executor treats objects as blocked. return [{"name": "move", "args": {"type": "move", "x": 4, "y": 5}}] -def _game_headers(settings: Settings) -> dict[str, str]: - headers: dict[str, str] = {} - if settings.internal_worker_token: - headers["Authorization"] = f"Bearer {settings.internal_worker_token}" - return headers - - -_FETCH_STATE_TIMEOUT_S = 6.0 -_FETCH_STATE_ATTEMPTS = 2 -_FETCH_STATE_HOT_CACHE_TTL_S = 3.0 -_STALE_SNAPSHOT_TTL_S = 300.0 -_RUNTIME_REL_TIMEOUT_S = 6.0 -_stale_worker_snapshots: dict[str, tuple[dict[str, Any], float]] = {} - - -def _worker_state_stale_key(room_id: str, player_id: str) -> str: - return f"{room_id}:{player_id}" - - -def _remember_worker_snapshot(room_id: str, player_id: str, snapshot: dict[str, Any]) -> None: - clean = {k: v for k, v in snapshot.items() if not str(k).startswith("_")} - _stale_worker_snapshots[_worker_state_stale_key(room_id, player_id)] = ( - clean, - time.time(), - ) - - -def _stale_worker_snapshot(room_id: str, player_id: str) -> dict[str, Any] | None: - entry = _stale_worker_snapshots.get(_worker_state_stale_key(room_id, player_id)) - if not entry: - return None - snap, ts = entry - if time.time() - ts > _STALE_SNAPSHOT_TTL_S: - return None - age_ms = int((time.time() - ts) * 1000) - return {**snap, "_stale": True, "_stale_age_ms": age_ms} - - -def _hot_worker_snapshot(room_id: str, player_id: str) -> dict[str, Any] | None: - """Fresh worker-state snapshot within hot TTL — skip HTTP on back-to-back speaks.""" - entry = _stale_worker_snapshots.get(_worker_state_stale_key(room_id, player_id)) - if not entry: - return None - snap, ts = entry - age_s = time.time() - ts - if age_s > _FETCH_STATE_HOT_CACHE_TTL_S: - return None - age_ms = int(age_s * 1000) - return {**snap, "_cache_hit": True, "_cache_age_ms": age_ms} - - -def _neutral_memory_fields() -> dict[str, Any]: - band = "neutral" - return { - "memory_summary": "", - "memory_count": 0, - "retrieved_memories": [], - "latest_bulk": None, - "latest_reflection": None, - "gate_rejected": False, - "attitude_band": band, - "effective_score": None, - "allowed_tools": list(allowed_tools_for_band(band)), - "collective_summaries": [], - "runtime_relationships": [], - "canon_context": "", - } - - -def fetch_runtime_relationship_edges( - state: GraphState, - *, - settings: Settings, - client: httpx.Client, -) -> list[dict[str, Any]]: - room_id = state["room_id"] - npc_id = state.get("npc_id") or "npc-1" - url = f"{settings.game_server_url}/internal/rooms/{room_id}/npc-relationships" - try: - res = client.get( - url, - params={"npcId": npc_id, "limit": "5"}, - headers=_game_headers(settings), - timeout=_RUNTIME_REL_TIMEOUT_S, - ) - res.raise_for_status() - return list(safe_response_json(res).get("edges") or []) - except Exception as exc: - print( - f"npc-relationships fetch failed room={room_id} npc={npc_id}: {exc}", - file=sys.stderr, - ) - return [] - - -def _fetch_speak_enrichment( - state: GraphState, - *, - settings: Settings, - client: httpx.Client, - skip_dual_rag: bool, -) -> dict[str, Any]: - npc_id = state.get("npc_id") or "npc-1" - edges: list[dict[str, Any]] = [] - canon_context = "" - if not skip_dual_rag: - edges = fetch_runtime_relationship_edges(state, settings=settings, client=client) - speak_intent = state.get("speak_intent") - if speak_intent: - intent = SpeakIntent(speak_intent) - else: - intent = classify_speak_intent( - state.get("player_message") or "", - state.get("recent_turns"), - ) - dual = fetch_dual_rag_context( - client, - settings, - state["room_id"], - state.get("player_message") or "", - npc_id=npc_id, - skip_embed=should_skip_memory_embed(intent), - ) - canon_context = str(dual.get("canon_context") or "") - return { - "runtime_relationships": edges, - "canon_context": canon_context, - } - - -def _load_collective_gate_fields( - state: GraphState, - *, - settings: Settings, - client: httpx.Client, -) -> dict[str, Any]: - """Hostile gate needs band/allowed_tools even when full memory-context is skipped.""" - try: - ctx = fetch_memory_context( - client, - settings, - state["room_id"], - (state.get("player_message") or "").strip() or " ", - npc_id=state.get("npc_id") or "npc-1", - player_id=_player_id(state), - timeout=_MEMORY_CONTEXT_INTERACTIVE_TIMEOUT_S, - attempts=1, - skip_embed=True, - ) - except Exception as exc: - print( - f"collective gate load failed room={state['room_id']}: {exc}", - file=sys.stderr, - ) - return {} - parsed = parse_collective_from_context(ctx) - return { - key: parsed[key] - for key in ( - "attitude_band", - "effective_score", - "allowed_tools", - "collective_summaries", - ) - if key in parsed - } - - -def fetch_state( - state: GraphState, - *, - settings: Settings, - client: httpx.Client, - skip_nearby_lore: bool = False, -) -> GraphState: - room_id = state["room_id"] - headers = _game_headers(settings) - player_id = _player_id(state) - if player_id and player_id != "__legacy__": - headers["X-Player-Id"] = player_id - url = f"{settings.game_server_url}/internal/rooms/{room_id}/worker-state" - if skip_nearby_lore: - url = f"{url}?skipNearbyLore=1" - hot = _hot_worker_snapshot(room_id, player_id) - if hot is not None: - age_ms = int(hot.pop("_cache_age_ms", 0)) - hot.pop("_cache_hit", None) - record_phase_ms("t_fetch_state_ms", 0) - record_phase_ms("t_fetch_state_cache_age_ms", age_ms) - return {**state, "room_snapshot": hot} - last_exc: BaseException | None = None - for attempt in range(_FETCH_STATE_ATTEMPTS): - try: - res = client.get(url, headers=headers, timeout=_FETCH_STATE_TIMEOUT_S) - res.raise_for_status() - body = safe_response_json(res) - snapshot = body.get("state", {}) or {} - nearby = body.get("nearbyLore") - if nearby is not None: - snapshot = {**snapshot, "nearbyLore": nearby} - _remember_worker_snapshot(room_id, player_id, snapshot) - return {**state, "room_snapshot": snapshot} - except httpx.TimeoutException as exc: - last_exc = exc - print( - f"worker-state timeout room={room_id} attempt={attempt + 1}/{_FETCH_STATE_ATTEMPTS}", - file=sys.stderr, - ) - if attempt + 1 < _FETCH_STATE_ATTEMPTS: - time.sleep(0.5 + attempt) - continue - stale = _stale_worker_snapshot(room_id, player_id) - if stale is not None: - age_ms = int(stale.get("_stale_age_ms") or 0) - print( - f"worker-state stale-fallback room={room_id} age_ms={age_ms}", - file=sys.stderr, - ) - record_phase_ms("t_worker_state_stale_ms", age_ms) - return {**state, "room_snapshot": stale} - raise - if last_exc is not None: - raise last_exc - raise RuntimeError("fetch_state retry loop exited without response") - - -def fetch_nearby_lore_into_snapshot( - state: GraphState, - *, - settings: Settings, - client: httpx.Client, -) -> GraphState: - """Lazy lore: full worker-state without skipNearbyLore (NARRATIVE + lore markers only).""" - room_id = state["room_id"] - headers = _game_headers(settings) - player_id = _player_id(state) - if player_id and player_id != "__legacy__": - headers["X-Player-Id"] = player_id - url = f"{settings.game_server_url}/internal/rooms/{room_id}/worker-state" - try: - res = client.get(url, headers=headers, timeout=_FETCH_STATE_TIMEOUT_S) - res.raise_for_status() - nearby = safe_response_json(res).get("nearbyLore") or [] - snapshot = {**(state.get("room_snapshot") or {}), "nearbyLore": nearby} - return {**state, "room_snapshot": snapshot} - except Exception as exc: - print(f"lazy nearby-lore failed room={room_id}: {exc}", file=sys.stderr) - return state - - - -def _player_id(state: GraphState) -> str: - return state.get("player_id") or "__legacy__" - - -def load_memory_context( - state: GraphState, - *, - settings: Settings, - client: httpx.Client, - memory_timeout: float | None = None, - memory_attempts: int = 3, - skip_embed: bool = False, -) -> GraphState: - npc_id = state.get("npc_id") or "npc-1" - try: - ctx = fetch_memory_context( - client, - settings, - state["room_id"], - state.get("player_message") or "", - npc_id=npc_id, - player_id=_player_id(state), - timeout=memory_timeout, - attempts=memory_attempts, - skip_embed=skip_embed, - ) - except httpx.TimeoutException as exc: - print( - f"memory-context timeout room={state['room_id']} npc={npc_id}: {exc}", - file=sys.stderr, - ) - ctx = {} - except httpx.HTTPError as exc: - print( - f"memory-context http error room={state['room_id']} npc={npc_id}: {exc}", - file=sys.stderr, - ) - ctx = {} - player_msg = (state.get("player_message") or "").strip() - recall_recent_limit = 30 if ("密码" in player_msg and is_recall_question(player_msg)) else 20 - if needs_recency_augment(player_msg) and not skip_embed: - try: - recent = fetch_recent_memories( - client, - settings, - state["room_id"], - limit=recall_recent_limit, - npc_id=npc_id, - player_id=_player_id(state), - ) - augmented = augment_retrieved_with_recent( - ctx.get("retrieved"), - recent, - ) - augmented = augment_retrieved_with_dialogue_turns( - augmented, - state.get("recent_turns"), - ) - if augmented: - ctx = { - **ctx, - "retrieved": augmented, - "memoryCount": max( - int(ctx.get("memoryCount") or 0), - len(augmented), - ), - } - print( - f"memory-context recall recency-augment room={state['room_id']} " - f"npc={npc_id} rows={len(augmented)} recent={len(recent)}", - file=sys.stderr, - ) - except Exception as exc: - print( - f"memory-context recall recency-augment failed room={state['room_id']}: {exc}", - file=sys.stderr, - ) - - if is_recall_question(player_msg) and not pick_recall_memory( - player_msg, - ctx.get("retrieved"), - ): - try: - recent = fetch_recent_memories( - client, - settings, - state["room_id"], - limit=recall_recent_limit, - npc_id=npc_id, - player_id=_player_id(state), - ) - if recent: - fallback = augment_retrieved_with_recent([], recent) - fallback = augment_retrieved_with_dialogue_turns( - fallback, - state.get("recent_turns"), - ) - if pick_recall_memory(player_msg, fallback): - ctx = { - **ctx, - "retrieved": fallback, - "memoryCount": max( - int(ctx.get("memoryCount") or 0), - len(fallback), - ), - } - print( - f"memory-context recall recent-only fallback room={state['room_id']} " - f"npc={npc_id} player={_player_id(state)} rows={len(fallback)}", - file=sys.stderr, - ) - else: - preview = (recent[0].get("text") or "")[:80] if recent else "" - print( - f"memory-context recall recent-only miss room={state['room_id']} " - f"npc={npc_id} player={_player_id(state)} recent={len(recent)} " - f"preview={preview!r}", - file=sys.stderr, - ) - except Exception as exc: - print( - f"memory-context recall recent-only fallback failed " - f"room={state['room_id']}: {exc}", - file=sys.stderr, - ) - summary = format_memory_summary( - latest_bulk=ctx.get("latestBulkSummary"), - latest_reflection=ctx.get("latestReflection"), - retrieved=ctx.get("retrieved"), - ) - collective = parse_collective_from_context(ctx) - return { - **state, - "memory_summary": summary, - "memory_count": int(ctx.get("memoryCount") or 0), - "retrieved_memories": ctx.get("retrieved") or [], - "latest_bulk": ctx.get("latestBulkSummary"), - "latest_reflection": ctx.get("latestReflection"), - "gate_rejected": False, - **collective, - } - - -_MEMORY_MERGE_KEYS = ( - "memory_summary", - "memory_count", - "retrieved_memories", - "latest_bulk", - "latest_reflection", - "gate_rejected", - "attitude_band", - "effective_score", - "allowed_tools", - "collective_summaries", -) - - -def _attach_speak_enrichment( - state: GraphState, - *, - settings: Settings, - skip_dual_rag: bool, -) -> GraphState: - t0 = time.perf_counter() - with create_http_client() as thread_client: - enrichment = _fetch_speak_enrichment( - state, - settings=settings, - client=thread_client, - skip_dual_rag=skip_dual_rag, - ) - record_phase_ms("t_speak_enrichment_ms", int((time.perf_counter() - t0) * 1000)) - return {**state, **enrichment} - - -def fetch_state_and_memory( - state: GraphState, - *, - settings: Settings, - client: httpx.Client, -) -> GraphState: - """Parallel worker-state + memory-context to cut speak pre-LLM latency.""" - del client # each thread uses its own httpx.Client (not thread-safe) - player_message = state.get("player_message") or "" - recent_turns = state.get("recent_turns") - intent = classify_speak_intent(player_message, recent_turns) - state = {**state, "speak_intent": intent.value} - skip_memory = should_skip_memory_context(intent) - skip_embed = should_skip_memory_embed(intent) - - if skip_memory: - t0 = time.perf_counter() - with create_http_client() as thread_client: - state_with_room = fetch_state( - state, - settings=settings, - client=thread_client, - skip_nearby_lore=True, - ) - record_phase_ms("t_fetch_state_ms", int((time.perf_counter() - t0) * 1000)) - merged = {**state_with_room, **_neutral_memory_fields()} - if intent == SpeakIntent.PHYSICAL: - t_mem = time.perf_counter() - with create_http_client() as thread_client: - merged.update( - _load_collective_gate_fields( - merged, - settings=settings, - client=thread_client, - ), - ) - record_phase_ms("t_memory_ms", int((time.perf_counter() - t_mem) * 1000)) - else: - record_phase_ms("t_memory_ms", 0) - merged["speak_intent"] = intent.value - return _attach_speak_enrichment( - merged, - settings=settings, - skip_dual_rag=True, - ) - - def _fetch() -> GraphState: - t0 = time.perf_counter() - with create_http_client() as thread_client: - out = fetch_state( - state, - settings=settings, - client=thread_client, - skip_nearby_lore=True, - ) - record_phase_ms("t_fetch_state_ms", int((time.perf_counter() - t0) * 1000)) - return out - - def _memory() -> GraphState: - t0 = time.perf_counter() - recall = intent == SpeakIntent.RECALL - memory_timeout = ( - _MEMORY_CONTEXT_RECALL_TIMEOUT_S if recall else _MEMORY_CONTEXT_INTERACTIVE_TIMEOUT_S - ) - memory_attempts = _MEMORY_CONTEXT_RECALL_ATTEMPTS if recall else 1 - with create_http_client() as thread_client: - out = load_memory_context( - state, - settings=settings, - client=thread_client, - memory_timeout=memory_timeout, - memory_attempts=memory_attempts, - skip_embed=skip_embed, - ) - record_phase_ms("t_memory_ms", int((time.perf_counter() - t0) * 1000)) - return out - - with ThreadPoolExecutor(max_workers=2) as pool: - state_future = pool.submit(_fetch) - memory_future = pool.submit(_memory) - state_with_room = state_future.result() - state_with_memory = memory_future.result() - - merged = {**state_with_room} - for key in _MEMORY_MERGE_KEYS: - if key in state_with_memory: - merged[key] = state_with_memory[key] - merged["speak_intent"] = intent.value - - if intent == SpeakIntent.NARRATIVE and message_needs_nearby_lore(player_message): - t0 = time.perf_counter() - with create_http_client() as thread_client: - merged = fetch_nearby_lore_into_snapshot( - merged, - settings=settings, - client=thread_client, - ) - record_phase_ms("t_lazy_lore_ms", int((time.perf_counter() - t0) * 1000)) - - return _attach_speak_enrichment( - merged, - settings=settings, - skip_dual_rag=False, - ) - - def _invoke_llm_turn( llm: Any, messages: list[Any], @@ -824,7 +303,7 @@ def apply_tools(state: GraphState, *, settings: Settings, client: httpx.Client) updated_snapshot = body.get("state") if not isinstance(updated_snapshot, dict): updated_snapshot = state.get("room_snapshot") or {} - _remember_worker_snapshot(room_id, player_id, updated_snapshot) + _remember_worker_snapshot_all_projections(room_id, player_id, updated_snapshot) record_phase_ms("t_apply_ms", int((time.perf_counter() - t0) * 1000)) return { **state, diff --git a/workers/agent-worker/src/graph/prompt.py b/workers/agent-worker/src/graph/prompt.py index 8ceffb1..93918b3 100644 --- a/workers/agent-worker/src/graph/prompt.py +++ b/workers/agent-worker/src/graph/prompt.py @@ -4,7 +4,7 @@ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage from src.graph.action_intent import player_requests_physical_action -from src.graph.persona import build_persona_block +from src.graph.speak_system_context import build_speak_system_context from src.graph.state import GraphState from src.collective.constants import BAND_LABEL_ZH @@ -136,34 +136,17 @@ def format_attitude_context( def build_turn_messages(state: GraphState) -> list[SystemMessage | HumanMessage | AIMessage]: - memory = (state.get("memory_summary") or "").strip() room = state.get("room_snapshot") or {} room_json = json.dumps(room, ensure_ascii=False) if len(room_json) > 1500: room_json = room_json[:1500] + "…" - attitude = format_attitude_context( - band=state.get("attitude_band"), - effective_score=state.get("effective_score"), - summaries=state.get("collective_summaries"), - just_happened=state.get("just_happened_summary"), + system_text = build_speak_system_context( + state, + base_prompt=NPC_SYSTEM_PROMPT, + include_just_happened=True, ) - npc_id = state.get("npc_id") or "npc-1" - persona_block = build_persona_block( - npc_id, - runtime_relationships=state.get("runtime_relationships"), - ) - base_prompt = NPC_SYSTEM_PROMPT - if persona_block: - base_prompt = f"{base_prompt}\n\n{persona_block}" - system_text = f"{base_prompt}\n{build_room_constraints(room)}\n\n{attitude}" - if memory: - system_text = f"{system_text}\n\nMemory summary:\n{memory}" - canon = (state.get("canon_context") or "").strip() - if canon: - system_text = f"{system_text}\n\n{canon}" - messages: list[SystemMessage | HumanMessage | AIMessage] = [ SystemMessage(content=system_text) ] diff --git a/workers/agent-worker/src/graph/speak_fetch.py b/workers/agent-worker/src/graph/speak_fetch.py new file mode 100644 index 0000000..df3574c --- /dev/null +++ b/workers/agent-worker/src/graph/speak_fetch.py @@ -0,0 +1,464 @@ +"""Speak pre-LLM fetch: memory RAG, dual-RAG enrichment, parallel orchestration.""" + +from __future__ import annotations + +import sys +import time +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +import httpx + +from src.collective.scoring import allowed_tools_for_band +from src.config import Settings +from src.council.memory_context import fetch_dual_rag_context +from src.graph.job_context import record_phase_ms +from src.graph.prompt import format_memory_summary +from src.graph.recall_merge import ( + augment_retrieved_with_dialogue_turns, + augment_retrieved_with_recent, + is_recall_question, + needs_recency_augment, + pick_recall_memory, +) +from src.graph.speak_intent import ( + SpeakIntent, + classify_speak_intent, + message_needs_nearby_lore, + should_skip_memory_context, + should_skip_memory_embed, +) +from src.graph.state import GraphState +from src.graph.worker_state_fetch import ( + _FETCH_STATE_TIMEOUT_S, + _game_headers, + _player_id, + fetch_state, +) +from src.http_json import create_http_client, safe_response_json +from src.memory.client import ( + _MEMORY_CONTEXT_INTERACTIVE_TIMEOUT_S, + _MEMORY_CONTEXT_RECALL_ATTEMPTS, + _MEMORY_CONTEXT_RECALL_TIMEOUT_S, + fetch_memory_context, + fetch_recent_memories, + parse_collective_from_context, +) + +_RUNTIME_REL_TIMEOUT_S = 6.0 + +_MEMORY_MERGE_KEYS = ( + "memory_summary", + "memory_count", + "retrieved_memories", + "latest_bulk", + "latest_reflection", + "gate_rejected", + "attitude_band", + "effective_score", + "allowed_tools", + "collective_summaries", +) + + +def _neutral_memory_fields() -> dict[str, Any]: + band = "neutral" + return { + "memory_summary": "", + "memory_count": 0, + "retrieved_memories": [], + "latest_bulk": None, + "latest_reflection": None, + "gate_rejected": False, + "attitude_band": band, + "effective_score": None, + "allowed_tools": list(allowed_tools_for_band(band)), + "collective_summaries": [], + "runtime_relationships": [], + "canon_context": "", + } + + +def fetch_runtime_relationship_edges( + state: GraphState, + *, + settings: Settings, + client: httpx.Client, +) -> list[dict[str, Any]]: + room_id = state["room_id"] + npc_id = state.get("npc_id") or "npc-1" + url = f"{settings.game_server_url}/internal/rooms/{room_id}/npc-relationships" + try: + res = client.get( + url, + params={"npcId": npc_id, "limit": "5"}, + headers=_game_headers(settings), + timeout=_RUNTIME_REL_TIMEOUT_S, + ) + res.raise_for_status() + return list(safe_response_json(res).get("edges") or []) + except Exception as exc: + print( + f"npc-relationships fetch failed room={room_id} npc={npc_id}: {exc}", + file=sys.stderr, + ) + return [] + + +def _fetch_speak_enrichment( + state: GraphState, + *, + settings: Settings, + client: httpx.Client, + skip_dual_rag: bool, +) -> dict[str, Any]: + npc_id = state.get("npc_id") or "npc-1" + edges: list[dict[str, Any]] = [] + canon_context = "" + if not skip_dual_rag: + edges = fetch_runtime_relationship_edges(state, settings=settings, client=client) + speak_intent = state.get("speak_intent") + if speak_intent: + intent = SpeakIntent(speak_intent) + else: + intent = classify_speak_intent( + state.get("player_message") or "", + state.get("recent_turns"), + ) + dual = fetch_dual_rag_context( + client, + settings, + state["room_id"], + state.get("player_message") or "", + npc_id=npc_id, + skip_embed=should_skip_memory_embed(intent), + ) + canon_context = str(dual.get("canon_context") or "") + return { + "runtime_relationships": edges, + "canon_context": canon_context, + } + + +def _load_collective_gate_fields( + state: GraphState, + *, + settings: Settings, + client: httpx.Client, +) -> dict[str, Any]: + """Hostile gate needs band/allowed_tools even when full memory-context is skipped.""" + try: + ctx = fetch_memory_context( + client, + settings, + state["room_id"], + (state.get("player_message") or "").strip() or " ", + npc_id=state.get("npc_id") or "npc-1", + player_id=_player_id(state), + timeout=_MEMORY_CONTEXT_INTERACTIVE_TIMEOUT_S, + attempts=1, + skip_embed=True, + ) + except Exception as exc: + print( + f"collective gate load failed room={state['room_id']}: {exc}", + file=sys.stderr, + ) + return {} + parsed = parse_collective_from_context(ctx) + return { + key: parsed[key] + for key in ( + "attitude_band", + "effective_score", + "allowed_tools", + "collective_summaries", + ) + if key in parsed + } + + +def fetch_nearby_lore_into_snapshot( + state: GraphState, + *, + settings: Settings, + client: httpx.Client, +) -> GraphState: + """Lazy lore: full worker-state without skipNearbyLore (NARRATIVE + lore markers only).""" + room_id = state["room_id"] + headers = _game_headers(settings) + player_id = _player_id(state) + if player_id and player_id != "__legacy__": + headers["X-Player-Id"] = player_id + url = f"{settings.game_server_url}/internal/rooms/{room_id}/worker-state" + try: + res = client.get(url, headers=headers, timeout=_FETCH_STATE_TIMEOUT_S) + res.raise_for_status() + nearby = safe_response_json(res).get("nearbyLore") or [] + snapshot = {**(state.get("room_snapshot") or {}), "nearbyLore": nearby} + return {**state, "room_snapshot": snapshot} + except Exception as exc: + print(f"lazy nearby-lore failed room={room_id}: {exc}", file=sys.stderr) + return state + + +def load_memory_context( + state: GraphState, + *, + settings: Settings, + client: httpx.Client, + memory_timeout: float | None = None, + memory_attempts: int = 3, + skip_embed: bool = False, +) -> GraphState: + npc_id = state.get("npc_id") or "npc-1" + try: + ctx = fetch_memory_context( + client, + settings, + state["room_id"], + state.get("player_message") or "", + npc_id=npc_id, + player_id=_player_id(state), + timeout=memory_timeout, + attempts=memory_attempts, + skip_embed=skip_embed, + ) + except httpx.TimeoutException as exc: + print( + f"memory-context timeout room={state['room_id']} npc={npc_id}: {exc}", + file=sys.stderr, + ) + ctx = {} + except httpx.HTTPError as exc: + print( + f"memory-context http error room={state['room_id']} npc={npc_id}: {exc}", + file=sys.stderr, + ) + ctx = {} + player_msg = (state.get("player_message") or "").strip() + recall_recent_limit = 30 if ("密码" in player_msg and is_recall_question(player_msg)) else 20 + if needs_recency_augment(player_msg) and not skip_embed: + try: + recent = fetch_recent_memories( + client, + settings, + state["room_id"], + limit=recall_recent_limit, + npc_id=npc_id, + player_id=_player_id(state), + ) + augmented = augment_retrieved_with_recent( + ctx.get("retrieved"), + recent, + ) + augmented = augment_retrieved_with_dialogue_turns( + augmented, + state.get("recent_turns"), + ) + if augmented: + ctx = { + **ctx, + "retrieved": augmented, + "memoryCount": max( + int(ctx.get("memoryCount") or 0), + len(augmented), + ), + } + print( + f"memory-context recall recency-augment room={state['room_id']} " + f"npc={npc_id} rows={len(augmented)} recent={len(recent)}", + file=sys.stderr, + ) + except Exception as exc: + print( + f"memory-context recall recency-augment failed room={state['room_id']}: {exc}", + file=sys.stderr, + ) + + if is_recall_question(player_msg) and not pick_recall_memory( + player_msg, + ctx.get("retrieved"), + ): + try: + recent = fetch_recent_memories( + client, + settings, + state["room_id"], + limit=recall_recent_limit, + npc_id=npc_id, + player_id=_player_id(state), + ) + if recent: + fallback = augment_retrieved_with_recent([], recent) + fallback = augment_retrieved_with_dialogue_turns( + fallback, + state.get("recent_turns"), + ) + if pick_recall_memory(player_msg, fallback): + ctx = { + **ctx, + "retrieved": fallback, + "memoryCount": max( + int(ctx.get("memoryCount") or 0), + len(fallback), + ), + } + print( + f"memory-context recall recent-only fallback room={state['room_id']} " + f"npc={npc_id} player={_player_id(state)} rows={len(fallback)}", + file=sys.stderr, + ) + else: + print( + f"memory-context recall recent-only miss room={state['room_id']} " + f"npc={npc_id} player={_player_id(state)} recent={len(recent)} " + "matched=false", + file=sys.stderr, + ) + except Exception as exc: + print( + f"memory-context recall recent-only fallback failed " + f"room={state['room_id']}: {exc}", + file=sys.stderr, + ) + summary = format_memory_summary( + latest_bulk=ctx.get("latestBulkSummary"), + latest_reflection=ctx.get("latestReflection"), + retrieved=ctx.get("retrieved"), + ) + collective = parse_collective_from_context(ctx) + return { + **state, + "memory_summary": summary, + "memory_count": int(ctx.get("memoryCount") or 0), + "retrieved_memories": ctx.get("retrieved") or [], + "latest_bulk": ctx.get("latestBulkSummary"), + "latest_reflection": ctx.get("latestReflection"), + "gate_rejected": False, + **collective, + } + + +def _attach_speak_enrichment( + state: GraphState, + *, + settings: Settings, + skip_dual_rag: bool, +) -> GraphState: + t0 = time.perf_counter() + with create_http_client() as thread_client: + enrichment = _fetch_speak_enrichment( + state, + settings=settings, + client=thread_client, + skip_dual_rag=skip_dual_rag, + ) + record_phase_ms("t_speak_enrichment_ms", int((time.perf_counter() - t0) * 1000)) + return {**state, **enrichment} + + +def fetch_state_and_memory( + state: GraphState, + *, + settings: Settings, + client: httpx.Client, +) -> GraphState: + """Parallel worker-state + memory-context to cut speak pre-LLM latency.""" + del client # each thread uses its own httpx.Client (not thread-safe) + player_message = state.get("player_message") or "" + recent_turns = state.get("recent_turns") + intent = classify_speak_intent(player_message, recent_turns) + state = {**state, "speak_intent": intent.value} + skip_memory = should_skip_memory_context(intent) + skip_embed = should_skip_memory_embed(intent) + + if skip_memory: + t0 = time.perf_counter() + with create_http_client() as thread_client: + state_with_room = fetch_state( + state, + settings=settings, + client=thread_client, + skip_nearby_lore=True, + ) + record_phase_ms("t_fetch_state_ms", int((time.perf_counter() - t0) * 1000)) + merged = {**state_with_room, **_neutral_memory_fields()} + if intent == SpeakIntent.PHYSICAL: + t_mem = time.perf_counter() + with create_http_client() as thread_client: + merged.update( + _load_collective_gate_fields( + merged, + settings=settings, + client=thread_client, + ), + ) + record_phase_ms("t_memory_ms", int((time.perf_counter() - t_mem) * 1000)) + else: + record_phase_ms("t_memory_ms", 0) + merged["speak_intent"] = intent.value + return _attach_speak_enrichment( + merged, + settings=settings, + skip_dual_rag=True, + ) + + def _fetch() -> GraphState: + t0 = time.perf_counter() + with create_http_client() as thread_client: + out = fetch_state( + state, + settings=settings, + client=thread_client, + skip_nearby_lore=True, + ) + record_phase_ms("t_fetch_state_ms", int((time.perf_counter() - t0) * 1000)) + return out + + def _memory() -> GraphState: + t0 = time.perf_counter() + recall = intent == SpeakIntent.RECALL + memory_timeout = ( + _MEMORY_CONTEXT_RECALL_TIMEOUT_S if recall else _MEMORY_CONTEXT_INTERACTIVE_TIMEOUT_S + ) + memory_attempts = _MEMORY_CONTEXT_RECALL_ATTEMPTS if recall else 1 + with create_http_client() as thread_client: + out = load_memory_context( + state, + settings=settings, + client=thread_client, + memory_timeout=memory_timeout, + memory_attempts=memory_attempts, + skip_embed=skip_embed, + ) + record_phase_ms("t_memory_ms", int((time.perf_counter() - t0) * 1000)) + return out + + with ThreadPoolExecutor(max_workers=2) as pool: + state_future = pool.submit(_fetch) + memory_future = pool.submit(_memory) + state_with_room = state_future.result() + state_with_memory = memory_future.result() + + merged = {**state_with_room} + for key in _MEMORY_MERGE_KEYS: + if key in state_with_memory: + merged[key] = state_with_memory[key] + merged["speak_intent"] = intent.value + + if intent == SpeakIntent.NARRATIVE and message_needs_nearby_lore(player_message): + t0 = time.perf_counter() + with create_http_client() as thread_client: + merged = fetch_nearby_lore_into_snapshot( + merged, + settings=settings, + client=thread_client, + ) + record_phase_ms("t_lazy_lore_ms", int((time.perf_counter() - t0) * 1000)) + + return _attach_speak_enrichment( + merged, + settings=settings, + skip_dual_rag=False, + ) diff --git a/workers/agent-worker/src/graph/speak_system_context.py b/workers/agent-worker/src/graph/speak_system_context.py new file mode 100644 index 0000000..9a26cda --- /dev/null +++ b/workers/agent-worker/src/graph/speak_system_context.py @@ -0,0 +1,64 @@ +"""Unified speak system-prompt assembly (legacy tool path + social path).""" + +from __future__ import annotations + +from src.graph.persona import build_persona_block +from src.graph.state import GraphState + +# Injected after Memory summary for social path only (byte-stable when unchanged). +SOCIAL_MEMORY_RECALL_HINT = ( + "若玩家追问 Memory summary 中已有的事实,reply 须直接给出答案,勿拒绝或说「不记得」。" +) + + +def build_speak_system_context( + state: GraphState, + *, + base_prompt: str, + include_just_happened: bool = False, + memory_suffix: str = "", + system_append: str = "", + timeline_context: str = "", +) -> str: + """Assemble system text: persona → room → attitude → memory → canon → timeline → append. + + ``timeline_context`` defaults empty (Phase 27 injection seam); empty string is a no-op. + """ + # Lazy import avoids circular import with prompt.build_turn_messages. + from src.graph.prompt import build_room_constraints, format_attitude_context + + room = state.get("room_snapshot") or {} + attitude = format_attitude_context( + band=state.get("attitude_band"), + effective_score=state.get("effective_score"), + summaries=state.get("collective_summaries"), + just_happened=( + state.get("just_happened_summary") if include_just_happened else None + ), + ) + npc_id = state.get("npc_id") or "npc-1" + persona_block = build_persona_block( + npc_id, + runtime_relationships=state.get("runtime_relationships"), + ) + system_text = base_prompt + if persona_block: + system_text = f"{system_text}\n\n{persona_block}" + system_text = f"{system_text}\n{build_room_constraints(room)}\n\n{attitude}" + memory = (state.get("memory_summary") or "").strip() + if memory: + memory_block = f"Memory summary:\n{memory}" + suffix = (memory_suffix or "").strip() + if suffix: + memory_block = f"{memory_block}\n{suffix}" + system_text = f"{system_text}\n\n{memory_block}" + canon = (state.get("canon_context") or "").strip() + if canon: + system_text = f"{system_text}\n\n{canon}" + timeline = (timeline_context or "").strip() + if timeline: + system_text = f"{system_text}\n\n{timeline}" + append = (system_append or "").strip() + if append: + system_text = f"{system_text}\n\n{append}" + return system_text diff --git a/workers/agent-worker/src/graph/worker_state_fetch.py b/workers/agent-worker/src/graph/worker_state_fetch.py new file mode 100644 index 0000000..c870b2d --- /dev/null +++ b/workers/agent-worker/src/graph/worker_state_fetch.py @@ -0,0 +1,172 @@ +"""Worker-state HTTP fetch + hot/stale snapshot cache (extracted from npc_loop).""" + +from __future__ import annotations + +import sys +import time +from typing import Any + +import httpx + +from src.config import Settings +from src.graph.job_context import record_phase_ms +from src.graph.state import GraphState +from src.http_json import safe_response_json + +_FETCH_STATE_TIMEOUT_S = 6.0 +_FETCH_STATE_ATTEMPTS = 2 +_FETCH_STATE_HOT_CACHE_TTL_S = 3.0 +_STALE_SNAPSHOT_TTL_S = 300.0 +_stale_worker_snapshots: dict[str, tuple[dict[str, Any], float]] = {} + + +def _game_headers(settings: Settings) -> dict[str, str]: + headers: dict[str, str] = {} + if settings.internal_worker_token: + headers["Authorization"] = f"Bearer {settings.internal_worker_token}" + return headers + + +def _player_id(state: GraphState) -> str: + return state.get("player_id") or "__legacy__" + + +def _worker_state_stale_key( + room_id: str, + player_id: str, + *, + skip_nearby_lore: bool = False, +) -> str: + proj = "skipLore" if skip_nearby_lore else "full" + return f"{room_id}:{player_id}:{proj}" + + +def _remember_worker_snapshot( + room_id: str, + player_id: str, + snapshot: dict[str, Any], + *, + skip_nearby_lore: bool = False, +) -> None: + clean = {k: v for k, v in snapshot.items() if not str(k).startswith("_")} + _stale_worker_snapshots[ + _worker_state_stale_key(room_id, player_id, skip_nearby_lore=skip_nearby_lore) + ] = ( + clean, + time.time(), + ) + + +def _remember_worker_snapshot_all_projections( + room_id: str, + player_id: str, + snapshot: dict[str, Any], +) -> None: + """Action writes: refresh both full and skipNearbyLore projections.""" + _remember_worker_snapshot(room_id, player_id, snapshot, skip_nearby_lore=False) + _remember_worker_snapshot(room_id, player_id, snapshot, skip_nearby_lore=True) + + +def _stale_worker_snapshot( + room_id: str, + player_id: str, + *, + skip_nearby_lore: bool = False, +) -> dict[str, Any] | None: + entry = _stale_worker_snapshots.get( + _worker_state_stale_key(room_id, player_id, skip_nearby_lore=skip_nearby_lore) + ) + if not entry: + return None + snap, ts = entry + if time.time() - ts > _STALE_SNAPSHOT_TTL_S: + return None + age_ms = int((time.time() - ts) * 1000) + return {**snap, "_stale": True, "_stale_age_ms": age_ms} + + +def _hot_worker_snapshot( + room_id: str, + player_id: str, + *, + skip_nearby_lore: bool = False, +) -> dict[str, Any] | None: + """Fresh worker-state snapshot within hot TTL — skip HTTP on back-to-back speaks.""" + entry = _stale_worker_snapshots.get( + _worker_state_stale_key(room_id, player_id, skip_nearby_lore=skip_nearby_lore) + ) + if not entry: + return None + snap, ts = entry + age_s = time.time() - ts + if age_s > _FETCH_STATE_HOT_CACHE_TTL_S: + return None + age_ms = int(age_s * 1000) + return {**snap, "_cache_hit": True, "_cache_age_ms": age_ms} + + +def fetch_state( + state: GraphState, + *, + settings: Settings, + client: httpx.Client, + skip_nearby_lore: bool = False, +) -> GraphState: + room_id = state["room_id"] + headers = _game_headers(settings) + player_id = _player_id(state) + if player_id and player_id != "__legacy__": + headers["X-Player-Id"] = player_id + url = f"{settings.game_server_url}/internal/rooms/{room_id}/worker-state" + if skip_nearby_lore: + url = f"{url}?skipNearbyLore=1" + hot = _hot_worker_snapshot(room_id, player_id, skip_nearby_lore=skip_nearby_lore) + if hot is not None: + age_ms = int(hot.pop("_cache_age_ms", 0)) + hot.pop("_cache_hit", None) + record_phase_ms("t_fetch_state_ms", 0) + record_phase_ms("t_fetch_state_cache_age_ms", age_ms) + return {**state, "room_snapshot": hot} + last_exc: BaseException | None = None + for attempt in range(_FETCH_STATE_ATTEMPTS): + try: + res = client.get(url, headers=headers, timeout=_FETCH_STATE_TIMEOUT_S) + res.raise_for_status() + body = safe_response_json(res) + snapshot = body.get("state", {}) or {} + nearby = body.get("nearbyLore") + if nearby is not None: + snapshot = {**snapshot, "nearbyLore": nearby} + _remember_worker_snapshot( + room_id, + player_id, + snapshot, + skip_nearby_lore=skip_nearby_lore, + ) + return {**state, "room_snapshot": snapshot} + except httpx.TimeoutException as exc: + last_exc = exc + print( + f"worker-state timeout room={room_id} attempt={attempt + 1}/{_FETCH_STATE_ATTEMPTS}", + file=sys.stderr, + ) + if attempt + 1 < _FETCH_STATE_ATTEMPTS: + time.sleep(0.5 + attempt) + continue + stale = _stale_worker_snapshot( + room_id, + player_id, + skip_nearby_lore=skip_nearby_lore, + ) + if stale is not None: + age_ms = int(stale.get("_stale_age_ms") or 0) + print( + f"worker-state stale-fallback room={room_id} age_ms={age_ms}", + file=sys.stderr, + ) + record_phase_ms("t_worker_state_stale_ms", age_ms) + return {**state, "room_snapshot": stale} + raise + if last_exc is not None: + raise last_exc + raise RuntimeError("fetch_state retry loop exited without response") diff --git a/workers/agent-worker/tests/test_fetch_state_and_memory.py b/workers/agent-worker/tests/test_fetch_state_and_memory.py index 3d6fb04..a03c967 100644 --- a/workers/agent-worker/tests/test_fetch_state_and_memory.py +++ b/workers/agent-worker/tests/test_fetch_state_and_memory.py @@ -9,11 +9,11 @@ @pytest.fixture(autouse=True) def _clear_worker_snapshot_cache(): - from src.graph import npc_loop + from src.graph import worker_state_fetch as wsf - npc_loop._stale_worker_snapshots.clear() + wsf._stale_worker_snapshots.clear() yield - npc_loop._stale_worker_snapshots.clear() + wsf._stale_worker_snapshots.clear() def test_physical_action_skips_full_memory_but_loads_collective_gate(): @@ -38,16 +38,16 @@ def test_physical_action_skips_full_memory_but_loads_collective_gate(): }, } - with patch("src.graph.npc_loop.httpx.Client") as client_cls: + with patch("src.graph.speak_fetch.create_http_client") as client_cls: client = MagicMock() client.__enter__ = MagicMock(return_value=client) client.__exit__ = MagicMock(return_value=False) client.get.return_value = fake_response client_cls.return_value = client - with patch("src.graph.npc_loop.load_memory_context") as load_memory: + with patch("src.graph.speak_fetch.load_memory_context") as load_memory: with patch( - "src.graph.npc_loop.fetch_memory_context", + "src.graph.speak_fetch.fetch_memory_context", return_value={ "collective": { "band": "hostile", @@ -77,8 +77,8 @@ def test_casual_action_skips_memory_context(): } settings = Settings(game_server_url="http://127.0.0.1:2567") - with patch("src.graph.npc_loop.fetch_state") as fetch_state: - with patch("src.graph.npc_loop.load_memory_context") as load_memory: + with patch("src.graph.speak_fetch.fetch_state") as fetch_state: + with patch("src.graph.speak_fetch.load_memory_context") as load_memory: fetch_state.side_effect = lambda s, **_: { **s, "room_snapshot": {"npcs": []}, @@ -103,11 +103,11 @@ def test_narrative_action_loads_memory_with_skip_embed(): } settings = Settings(game_server_url="http://127.0.0.1:2567") - with patch("src.graph.npc_loop.fetch_state") as fetch_state: - with patch("src.graph.npc_loop.fetch_nearby_lore_into_snapshot") as lazy_lore: - with patch("src.graph.npc_loop.load_memory_context") as load_memory: + with patch("src.graph.speak_fetch.fetch_state") as fetch_state: + with patch("src.graph.speak_fetch.fetch_nearby_lore_into_snapshot") as lazy_lore: + with patch("src.graph.speak_fetch.load_memory_context") as load_memory: with patch( - "src.graph.npc_loop._fetch_speak_enrichment", + "src.graph.speak_fetch._fetch_speak_enrichment", return_value={}, ): fetch_state.return_value = {**state, "room_snapshot": {"npcs": []}} @@ -143,10 +143,10 @@ def test_recall_action_loads_memory_with_full_embed(): } settings = Settings(game_server_url="http://127.0.0.1:2567") - with patch("src.graph.npc_loop.fetch_state") as fetch_state: - with patch("src.graph.npc_loop.load_memory_context") as load_memory: + with patch("src.graph.speak_fetch.fetch_state") as fetch_state: + with patch("src.graph.speak_fetch.load_memory_context") as load_memory: with patch( - "src.graph.npc_loop._fetch_speak_enrichment", + "src.graph.speak_fetch._fetch_speak_enrichment", return_value={}, ): fetch_state.return_value = {**state, "room_snapshot": {"npcs": []}} @@ -160,7 +160,7 @@ def test_recall_action_loads_memory_with_full_embed(): def test_fetch_speak_enrichment_fetches_edges_for_npc12(): - from src.graph.npc_loop import _fetch_speak_enrichment + from src.graph.speak_fetch import _fetch_speak_enrichment state = { "room_id": "default", @@ -171,7 +171,7 @@ def test_fetch_speak_enrichment_fetches_edges_for_npc12(): settings = Settings(game_server_url="http://127.0.0.1:2567") client = MagicMock() - with patch("src.graph.npc_loop.fetch_runtime_relationship_edges") as edges: + with patch("src.graph.speak_fetch.fetch_runtime_relationship_edges") as edges: edges.return_value = [ { "npcAId": "npc-12", @@ -182,7 +182,7 @@ def test_fetch_speak_enrichment_fetches_edges_for_npc12(): "baseTag": "peer", }, ] - with patch("src.graph.npc_loop.fetch_dual_rag_context") as dual: + with patch("src.graph.speak_fetch.fetch_dual_rag_context") as dual: dual.return_value = {"canon_context": ""} out = _fetch_speak_enrichment(state, settings=settings, client=client, skip_dual_rag=False) @@ -191,7 +191,7 @@ def test_fetch_speak_enrichment_fetches_edges_for_npc12(): def test_casual_fast_lane_skips_relationship_edges_fetch(): - from src.graph.npc_loop import _fetch_speak_enrichment + from src.graph.speak_fetch import _fetch_speak_enrichment state = { "room_id": "default", @@ -203,8 +203,8 @@ def test_casual_fast_lane_skips_relationship_edges_fetch(): settings = Settings(game_server_url="http://127.0.0.1:2567") client = MagicMock() - with patch("src.graph.npc_loop.fetch_runtime_relationship_edges") as edges: - with patch("src.graph.npc_loop.fetch_dual_rag_context") as dual: + with patch("src.graph.speak_fetch.fetch_runtime_relationship_edges") as edges: + with patch("src.graph.speak_fetch.fetch_dual_rag_context") as dual: dual.return_value = {"canon_context": ""} out = _fetch_speak_enrichment(state, settings=settings, client=client, skip_dual_rag=True) @@ -215,61 +215,80 @@ def test_casual_fast_lane_skips_relationship_edges_fetch(): def test_fetch_state_uses_stale_snapshot_after_timeout(): - from src.graph import npc_loop + from src.graph import worker_state_fetch as wsf settings = Settings(game_server_url="http://127.0.0.1:2567") state = {"room_id": "default", "player_id": "p1", "room_snapshot": {}} stale_body = {"npcs": [{"id": "npc-1", "x": 1, "y": 2}]} - npc_loop._remember_worker_snapshot("default", "p1", stale_body) - key = npc_loop._worker_state_stale_key("default", "p1") - snap, ts = npc_loop._stale_worker_snapshots[key] - npc_loop._stale_worker_snapshots[key] = (snap, ts - npc_loop._FETCH_STATE_HOT_CACHE_TTL_S - 1.0) + wsf._remember_worker_snapshot("default", "p1", stale_body, skip_nearby_lore=True) + key = wsf._worker_state_stale_key("default", "p1", skip_nearby_lore=True) + snap, ts = wsf._stale_worker_snapshots[key] + wsf._stale_worker_snapshots[key] = (snap, ts - wsf._FETCH_STATE_HOT_CACHE_TTL_S - 1.0) - with patch("src.graph.npc_loop.httpx.Client") as client_cls: - client = MagicMock() - client.__enter__ = MagicMock(return_value=client) - client.__exit__ = MagicMock(return_value=False) - client.get.side_effect = httpx.TimeoutException("timeout") - client_cls.return_value = client + client = MagicMock() + client.get.side_effect = httpx.TimeoutException("timeout") - out = npc_loop.fetch_state(state, settings=settings, client=client, skip_nearby_lore=True) + out = wsf.fetch_state(state, settings=settings, client=client, skip_nearby_lore=True) assert out["room_snapshot"]["npcs"][0]["x"] == 1 assert out["room_snapshot"].get("_stale") is True def test_fetch_state_hot_cache_skips_http(): - from src.graph import npc_loop + from src.graph import worker_state_fetch as wsf settings = Settings(game_server_url="http://127.0.0.1:2567") state = {"room_id": "default", "player_id": "p1", "room_snapshot": {}} fresh_body = {"npcs": [{"id": "npc-1", "x": 1, "y": 2}]} - npc_loop._remember_worker_snapshot("default", "p1", fresh_body) + wsf._remember_worker_snapshot("default", "p1", fresh_body, skip_nearby_lore=True) - with patch("src.graph.npc_loop.httpx.Client") as client_cls: - client = MagicMock() - client.__enter__ = MagicMock(return_value=client) - client.__exit__ = MagicMock(return_value=False) - client_cls.return_value = client + client = MagicMock() - with patch("src.graph.npc_loop.record_phase_ms") as record_phase: - out = npc_loop.fetch_state( - state, - settings=settings, - client=client, - skip_nearby_lore=True, - ) + with patch("src.graph.worker_state_fetch.record_phase_ms") as record_phase: + out = wsf.fetch_state( + state, + settings=settings, + client=client, + skip_nearby_lore=True, + ) client.get.assert_not_called() assert out["room_snapshot"]["npcs"][0]["x"] == 1 record_phase.assert_any_call("t_fetch_state_ms", 0) +def test_skip_lore_cache_does_not_satisfy_full_fetch(): + from src.graph import worker_state_fetch as wsf + + wsf._stale_worker_snapshots.clear() + settings = Settings(game_server_url="http://127.0.0.1:2567") + state = {"room_id": "default", "player_id": "p1", "room_snapshot": {}} + skip_body = {"npcs": [{"id": "npc-1", "x": 1, "y": 2}]} + wsf._remember_worker_snapshot("default", "p1", skip_body, skip_nearby_lore=True) + + ok = MagicMock() + ok.status_code = 200 + ok.raise_for_status = MagicMock() + ok.json.return_value = { + "state": {"npcs": [{"id": "npc-1", "x": 1, "y": 2}]}, + "nearbyLore": [{"chunkKey": "0,0", "text": "lore"}], + } + client = MagicMock() + client.get.return_value = ok + + with patch("src.graph.worker_state_fetch.safe_response_json", side_effect=lambda r: r.json()): + out = wsf.fetch_state(state, settings=settings, client=client, skip_nearby_lore=False) + + client.get.assert_called_once() + assert out["room_snapshot"].get("nearbyLore") == [{"chunkKey": "0,0", "text": "lore"}] + + def test_apply_tools_refreshes_hot_snapshot_cache(): from src.graph import npc_loop + from src.graph import worker_state_fetch as wsf from src.graph.npc_loop import apply_tools - npc_loop._stale_worker_snapshots.clear() + wsf._stale_worker_snapshots.clear() settings = Settings(game_server_url="http://127.0.0.1:2567") old_room = { "width": 40, @@ -292,7 +311,7 @@ def test_apply_tools_refreshes_hot_snapshot_cache(): "tool_calls": [{"name": "move", "args": {"type": "move", "x": 10, "y": 20}}], "allowed_tools": ["move", "speak", "wait"], } - npc_loop._remember_worker_snapshot("default", "p1", old_room) + wsf._remember_worker_snapshot_all_projections("default", "p1", old_room) ok_response = MagicMock() ok_response.status_code = 200 @@ -306,3 +325,6 @@ def test_apply_tools_refreshes_hot_snapshot_cache(): assert hot is not None assert hot["npcs"][0]["x"] == 10 assert hot["npcs"][0]["y"] == 20 + hot_skip = npc_loop._hot_worker_snapshot("default", "p1", skip_nearby_lore=True) + assert hot_skip is not None + assert hot_skip["npcs"][0]["x"] == 10 diff --git a/workers/agent-worker/tests/test_load_memory_recall_fallback.py b/workers/agent-worker/tests/test_load_memory_recall_fallback.py index f66e0aa..3b16191 100644 --- a/workers/agent-worker/tests/test_load_memory_recall_fallback.py +++ b/workers/agent-worker/tests/test_load_memory_recall_fallback.py @@ -19,11 +19,11 @@ def test_load_memory_context_uses_recent_only_when_embed_misses_seed(): seed_row = {"text": "player: 请记住 FACT-P21-ABC 门禁密码是 7"} with patch( - "src.graph.npc_loop.fetch_memory_context", + "src.graph.speak_fetch.fetch_memory_context", return_value={"memoryCount": 0, "retrieved": []}, ): with patch( - "src.graph.npc_loop.fetch_recent_memories", + "src.graph.speak_fetch.fetch_recent_memories", return_value=[seed_row], ) as fetch_recent: client = MagicMock(spec=httpx.Client) @@ -33,3 +33,34 @@ def test_load_memory_context_uses_recent_only_when_embed_misses_seed(): picked = pick_recall_memory(state["player_message"], out["retrieved_memories"]) assert picked is not None assert "FACT-P21-ABC" in (picked.get("text") or "") + + +def test_recall_recent_only_miss_stderr_omits_memory_text(capsys): + """Miss path must log counts only — never dump recalled memory contents.""" + secret = "player: 门锁密码是 SECRET-PASS-42" + state = { + "room_id": "verify-mem-privacy", + "player_message": "我叫什么名字?", + "npc_id": "npc-1", + "player_id": "playerprivacy001", + "recent_turns": [], + } + settings = Settings(game_server_url="http://127.0.0.1:2567") + + with patch( + "src.graph.speak_fetch.fetch_memory_context", + return_value={"memoryCount": 0, "retrieved": []}, + ): + with patch( + "src.graph.speak_fetch.fetch_recent_memories", + return_value=[{"text": secret}], + ): + client = MagicMock(spec=httpx.Client) + load_memory_context(state, settings=settings, client=client) + + err = capsys.readouterr().err + assert "recent-only miss" in err + assert "matched=false" in err + assert "preview=" not in err + assert "SECRET-PASS-42" not in err + assert secret not in err