From 121adb21dab31120dc90b8547300d0ced9bfbabb Mon Sep 17 00:00:00 2001 From: moyun Date: Wed, 15 Jul 2026 12:40:51 +0800 Subject: [PATCH 01/14] feat(26.2-01): raise all 12 council maxRadius to 40 in both SSOTs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Unlock zone-scale wander (A3 / D-19–D-21); leash becomes escape-only - Keep dual SSOT in lockstep (D-02); coords/facing unchanged Co-authored-by: Cursor --- .../world/beginning-fields@v1/spawns.json | 24 +++++++++---------- packages/shared/src/worldRegion.ts | 24 +++++++++---------- 2 files changed, 24 insertions(+), 24 deletions(-) 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/packages/shared/src/worldRegion.ts b/packages/shared/src/worldRegion.ts index 4f98fcb..416e96a 100644 --- a/packages/shared/src/worldRegion.ts +++ b/packages/shared/src/worldRegion.ts @@ -417,18 +417,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 }, ], }, }, From 9aa8e46ee1d65581dce222a9ab8e6468dd01a289 Mon Sep 17 00:00:00 2001 From: moyun Date: Wed, 15 Jul 2026 12:42:24 +0800 Subject: [PATCH 02/14] test(26.2-01): add dual-SSOT council spawn radius sync test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pin disk spawns.json ≡ defaultBeginningFieldsBundle ≡ maxRadius 40 - Drift-sensitive so A3 unlock stays locked in CI (D-02) Co-authored-by: Cursor --- .../src/world/council-spawn-radius.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 apps/game-server/src/world/council-spawn-radius.test.ts 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); + } + }); +}); From 042f45214afc7a9633503858c62ff677e22cbefa Mon Sep 17 00:00:00 2001 From: moyun Date: Wed, 15 Jul 2026 12:42:41 +0800 Subject: [PATCH 03/14] feat(26.2-01): lower LINGER_PAUSE_PERCENT from 30 to 15 - D-09: less linger standstill so A3 zone roam feels livelier Co-authored-by: Cursor --- apps/game-server/src/ambient/zone-wander.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/game-server/src/ambient/zone-wander.ts b/apps/game-server/src/ambient/zone-wander.ts index 9214dd8..8937e61 100644 --- a/apps/game-server/src/ambient/zone-wander.ts +++ b/apps/game-server/src/ambient/zone-wander.ts @@ -19,7 +19,7 @@ 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; From e79792522d72ec28823d2f4686628957b2cd1cbf Mon Sep 17 00:00:00 2001 From: moyun Date: Wed, 15 Jul 2026 12:45:28 +0800 Subject: [PATCH 04/14] feat(26.2-02): export B2 stepPercentForMobility and shouldStepThisTick - Additive pure gates: wander 55%, linger (stationary/poi) 30% - Hash domain ambient-step:${npcId}:${gameMinute}; bucket loop untouched Co-authored-by: Cursor --- apps/game-server/src/ambient/tick.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/game-server/src/ambient/tick.ts b/apps/game-server/src/ambient/tick.ts index f85eab0..f8d2034 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,7 +13,12 @@ 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, + shouldSkipMovement, + type Mobility, + type ScheduleSegment, +} from "./schedule.js"; import { pickZoneTarget } from "./zone-wander.js"; export const MAIN_AMBIENT_NPC_IDS = COUNCIL_NPC_IDS; @@ -30,6 +41,16 @@ export function hashNpcBucket(npcId: string): number { return Math.abs(hash) % AMBIENT_BUCKET_COUNT; } +/** Per-tick step probability (0–100) by mobility — wander ~55%, linger (stationary/poi) ~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). Resting/idle never reach here. */ +export function shouldStepThisTick(npcId: string, gameMinute: number, mobility: Mobility): boolean { + return stableStringHash(`ambient-step:${npcId}:${gameMinute}`) % 100 < stepPercentForMobility(mobility); +} + export type AmbientTickContext = { roomId: string; gameState: GameRoomState; From 17bf5b374daa104f81d36fbd1187fe0f5a5e16ce Mon Sep 17 00:00:00 2001 From: moyun Date: Wed, 15 Jul 2026 12:46:00 +0800 Subject: [PATCH 05/14] test(26.2-02): add B2 gate distribution and jitter tests - Deterministic 1440-minute scans for ~55% wander / ~30% linger - Co-pass and per-NPC desync assertions (D-22/D-24); stepActiveMinute helper Co-authored-by: Cursor --- apps/game-server/src/ambient/tick.test.ts | 68 ++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/apps/game-server/src/ambient/tick.test.ts b/apps/game-server/src/ambient/tick.test.ts index 0162fc5..6b0ba98 100644 --- a/apps/game-server/src/ambient/tick.test.ts +++ b/apps/game-server/src/ambient/tick.test.ts @@ -10,10 +10,27 @@ 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 type { Mobility } from "./schedule.js"; +import { + applySoftLeashTarget, + hashNpcBucket, + MAIN_AMBIENT_NPC_IDS, + pickJoinVicinityTarget, + runAmbientTick, + shouldStepThisTick, + stepPercentForMobility, +} 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", @@ -363,3 +380,52 @@ describe("runAmbientTick", () => { expect(code).not.toMatch(/\brunBackgroundNpcTick\b/); }); }); + +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); + }); +}); From 095235718c8599d677399a99f2bc66eff4f628c5 Mon Sep 17 00:00:00 2001 From: moyun Date: Wed, 15 Jul 2026 12:48:52 +0800 Subject: [PATCH 06/14] feat(26.2-03): wire B2 step gate and delete exclusive bucket - Replace hashNpcBucket gate with shouldStepThisTick (joinVicinity bypass for D-11) - Rewrite multi-mover and maxRadius-0 pin tests; drop distinct-bucket test Co-authored-by: Cursor --- apps/game-server/src/ambient/tick.test.ts | 119 +++++++++++++++------- apps/game-server/src/ambient/tick.ts | 20 +--- 2 files changed, 84 insertions(+), 55 deletions(-) diff --git a/apps/game-server/src/ambient/tick.test.ts b/apps/game-server/src/ambient/tick.test.ts index 6b0ba98..fbc17f7 100644 --- a/apps/game-server/src/ambient/tick.test.ts +++ b/apps/game-server/src/ambient/tick.test.ts @@ -10,10 +10,13 @@ import { } from "@aetherlife/shared"; import { GameRoomState } from "../colyseus/schema.js"; import { clearAllIntentsForTests, setIntent } from "./intent-cache.js"; -import type { Mobility } from "./schedule.js"; +import { + resolveScheduleSegment, + shouldSkipMovement, + type Mobility, +} from "./schedule.js"; import { applySoftLeashTarget, - hashNpcBucket, MAIN_AMBIENT_NPC_IDS, pickJoinVicinityTarget, runAmbientTick, @@ -252,31 +255,69 @@ describe("runAmbientTick", () => { expect([...MAIN_AMBIENT_NPC_IDS].sort()).toEqual([...COUNCIL_NPC_IDS].sort()); }); - it("only NPCs in the current minute bucket may change position", async () => { + it("at least two NPCs move in the same runAmbientTick", async () => { clearAllIntentsForTests(); - const map = createDefaultRoom("tick-bucket"); + const map = createDefaultRoom("tick-multi-move"); 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, { - intent: parseAmbientIntent({ - target: { gx: targetGx, gy: targetGy }, - reasonZh: "去那边", - untilGameMinute: gameMinute + 60, - }), - trigger: "segment_change", - gameMinute, + + 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, @@ -285,34 +326,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 }; @@ -330,11 +382,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", diff --git a/apps/game-server/src/ambient/tick.ts b/apps/game-server/src/ambient/tick.ts index f8d2034..4a6b73a 100644 --- a/apps/game-server/src/ambient/tick.ts +++ b/apps/game-server/src/ambient/tick.ts @@ -23,24 +23,6 @@ import { pickZoneTarget } from "./zone-wander.js"; export const MAIN_AMBIENT_NPC_IDS = COUNCIL_NPC_IDS; -const AMBIENT_BUCKET_COUNT = 12; - -/** 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; -} - /** Per-tick step probability (0–100) by mobility — wander ~55%, linger (stationary/poi) ~30% (D-23). */ export function stepPercentForMobility(mobility: Mobility): number { return mobility === "wander" ? 55 : 30; @@ -270,7 +252,7 @@ export function runAmbientTick(ctx: AmbientTickContext): { continue; } - if (gameState.gameMinute % AMBIENT_BUCKET_COUNT !== hashNpcBucket(npc.id)) { + if (!npc.joinVicinityActive && !shouldStepThisTick(npc.id, gameState.gameMinute, segment.mobility)) { continue; } From fd26519d32d7de9e00ea2b9f8c5b1e97d613dab4 Mon Sep 17 00:00:00 2001 From: moyun Date: Wed, 15 Jul 2026 12:50:38 +0800 Subject: [PATCH 07/14] feat(26.2-03): bypass soft leash for join_vicinity targets - Add source discriminator on resolveMovementTarget; skip leash when source is join - Cover D-11 gate+leash bypass and A3 in-region radius-40 clamp neutrality Co-authored-by: Cursor --- apps/game-server/src/ambient/tick.test.ts | 77 +++++++++++++++++++++++ apps/game-server/src/ambient/tick.ts | 11 +++- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/apps/game-server/src/ambient/tick.test.ts b/apps/game-server/src/ambient/tick.test.ts index fbc17f7..942fbb8 100644 --- a/apps/game-server/src/ambient/tick.test.ts +++ b/apps/game-server/src/ambient/tick.test.ts @@ -416,6 +416,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*\(/); diff --git a/apps/game-server/src/ambient/tick.ts b/apps/game-server/src/ambient/tick.ts index 4a6b73a..9ed244e 100644 --- a/apps/game-server/src/ambient/tick.ts +++ b/apps/game-server/src/ambient/tick.ts @@ -158,13 +158,14 @@ function resolveMovementTarget( grid: ReturnType, playerCells: GridCell[], recent: GridCell[], -): { targetGx: number; targetGy: number; nextRecent: 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", }; } @@ -176,6 +177,7 @@ function resolveMovementTarget( targetGx: cached.intent.target.gx, targetGy: cached.intent.target.gy, nextRecent: recent, + source: "intent", }; } if (isZoneIntent(cached.intent)) { @@ -192,6 +194,7 @@ function resolveMovementTarget( targetGx: picked.targetGx, targetGy: picked.targetGy, nextRecent: picked.nextRecent, + source: "zone", }; } } @@ -208,6 +211,7 @@ function resolveMovementTarget( targetGx: picked.targetGx, targetGy: picked.targetGy, nextRecent: picked.nextRecent, + source: "zone", }; } @@ -271,7 +275,10 @@ export function runAmbientTick(ctx: AmbientTickContext): { ); recentNpcCells.set(npc.id, resolved.nextRecent); - const leashed = applySoftLeashTarget(npc, resolved.targetGx, resolved.targetGy); + const leashed = + resolved.source === "join" + ? { targetGx: resolved.targetGx, targetGy: resolved.targetGy } + : applySoftLeashTarget(npc, resolved.targetGx, resolved.targetGy); const step = stepNpcTowardTarget({ npcX: npc.x, From 4aa03315d2712ca4526d55e6c5519bb70cf3ea31 Mon Sep 17 00:00:00 2001 From: moyun Date: Wed, 15 Jul 2026 12:53:04 +0800 Subject: [PATCH 08/14] docs(26.2-04): rewrite BEGINNING-FIELDS + ambient README for A3/B2 - Uniform maxRadius 40 table; drop dead per-zone 3/4/5 soft anchors - Document B2 shouldStepThisTick (~55/30), LINGER_PAUSE_PERCENT 15, multi-mover Co-authored-by: Cursor --- apps/game-server/src/ambient/README.md | 14 +++++---- docs/BEGINNING-FIELDS.md | 42 ++++++++++++++------------ 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/apps/game-server/src/ambient/README.md b/apps/game-server/src/ambient/README.md index 204faa3..234ba5c 100644 --- a/apps/game-server/src/ambient/README.md +++ b/apps/game-server/src/ambient/README.md @@ -29,11 +29,13 @@ Phase 16 权威 ambient 逻辑:`schedule.ts`(日程)→ `tick.ts`(6s tic ### `mobility` 与移动行为 +走步资格(Phase **26.2 / B2**):`tick.ts` 导出 `shouldStepThisTick` / `stepPercentForMobility`——哈希键 `ambient-step:{npcId}:{gameMinute}`(deterministic,**非**加密随机,仅节奏抖动)。**同 tick 可多名 NPC 移动**;Phase 26 独占 12 分桶(每 tick 仅 1 人)已移除。`resting` / `idle` 仍经上游 `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` 内随机可走格;社交段见下方 bias | 每 tick 以 **~55%** 概率(`shouldStepThisTick`,hash `ambient-step:{npcId}:{gameMinute}`)尝试走步;多名 NPC 可同 tick 移动(仍受 speak 占用、碰撞、玩家格阻挡) | +| `stationary` | **Linger**:当前位置 Chebyshev **≤ `LINGER_RADIUS`** 格内微 wander | 两段独立合成(D-23 + D-09):B2 门 **~30%** × linger 不停 **`(100 - LINGER_PAUSE_PERCENT)%`** ⇒ 有效移动 ≈ **25.5%**/tick | +| `poi` | 优先走向区域内 **social POI**(如 well);若已在 POI 或未找到,则同 `stationary` 的 linger | 同 linger(B2 30% × linger pause) | ### 完全不移动的 activity @@ -55,7 +57,7 @@ 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_PAUSE_PERCENT` | 是 | `15` | 每个 tick、每名 NPC **原地不动** 的概率(%)。哈希键:`linger:{npcId}:{gameMinute}`,同一游戏分钟 deterministic,不同 NPC/分钟错开。与 B2 `ambient-step:` 门独立(两段 knobs)。调高 → 更常停住;调低 → 更碎步。 | | `MAX_RECENT` | 否 | `8` | 最近访问格 deque 长度,避免 linger/wander 在 2–3 格间来回抖。 | --- @@ -73,7 +75,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 +109,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/docs/BEGINNING-FIELDS.md b/docs/BEGINNING-FIELDS.md index ce0cda3..3c4149f 100644 --- a/docs/BEGINNING-FIELDS.md +++ b/docs/BEGINNING-FIELDS.md @@ -99,23 +99,25 @@ 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` 仍钉死)。 From 15ec4bcd1cf438e672d3911f724acb57be6f2785 Mon Sep 17 00:00:00 2001 From: moyun Date: Wed, 15 Jul 2026 12:53:35 +0800 Subject: [PATCH 09/14] docs(26.2-04): record C-06 B2 gate/join bypass + Guardrail #109 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tick 消费: shouldStepThisTick 55/30 + multi-mover supersedes exclusive bucket - Join: D-11 bypass of probability gate and soft leash; pin still wins - Guardrail #109: forbid reintroducing D-MAP-AMB-03 exclusive buckets Co-authored-by: Cursor --- docs/CONTRACTS.md | 6 +++--- docs/ISSUE-LOG.md | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 5ba1d37..d67fdc4 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);走步资格为 **per-NPC 概率门**(`shouldStepThisTick`:hash `ambient-step:{npcId}:{gameMinute}` % 100 < wander **55** / linger **30**;resting/idle 经 `shouldSkipMovement` 为 0);**同 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 **绕过概率门与 soft leash**(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..89a850f 100644 --- a/docs/ISSUE-LOG.md +++ b/docs/ISSUE-LOG.md @@ -192,6 +192,7 @@ 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 取代**:tick 走步资格必须走 `shouldStepThisTick` 概率门(wander **55** / linger **30**,hash `ambient-step:{npcId}:{gameMinute}`);**禁止**重新引入 exclusive bucket 或用 `Math.random()` 替换 hash 门;双 SSOT `maxRadius` 改动必须过 `council-spawn-radius.test.ts`。回归:`pnpm --filter @aetherlife/game-server test -- src/ambient/`。 ## 记录 From 45b64559ceef76bfcb0d742db36f4f9d027bfec8 Mon Sep 17 00:00:00 2001 From: moyun Date: Wed, 15 Jul 2026 17:14:52 +0800 Subject: [PATCH 10/14] =?UTF-8?q?fix(26.2):=20Animal=20Crossing=E2=80=93st?= =?UTF-8?q?yle=20ambient=20roam=20and=20anti-cluster?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close World Alive UAT gaps: walk/pause cadence, zone commute, never-stack targets, full-home wander schedules by persona, gridDebug zone overlays, and harden verify:phase8 dual NL adjacency. Co-authored-by: Cursor --- apps/game-server/data/schedules/README.md | 15 +- apps/game-server/data/schedules/npc-1.json | 10 +- apps/game-server/data/schedules/npc-10.json | 6 +- apps/game-server/data/schedules/npc-11.json | 17 +- apps/game-server/data/schedules/npc-12.json | 19 +-- apps/game-server/data/schedules/npc-2.json | 12 +- apps/game-server/data/schedules/npc-3.json | 17 +- apps/game-server/data/schedules/npc-4.json | 21 +-- apps/game-server/data/schedules/npc-5.json | 14 +- apps/game-server/data/schedules/npc-6.json | 21 ++- apps/game-server/data/schedules/npc-7.json | 10 +- apps/game-server/data/schedules/npc-8.json | 12 +- apps/game-server/data/schedules/npc-9.json | 14 +- .../data/world/beginning-fields@v1/zones.json | 5 + apps/game-server/src/ambient/README.md | 25 ++- apps/game-server/src/ambient/schedule.test.ts | 47 ++++-- apps/game-server/src/ambient/schedule.ts | 4 +- apps/game-server/src/ambient/tick.test.ts | 52 ++++++ apps/game-server/src/ambient/tick.ts | 157 ++++++++++++++++-- .../src/ambient/zone-wander.test.ts | 78 ++++++++- apps/game-server/src/ambient/zone-wander.ts | 129 ++++++++++++-- apps/game-server/src/colyseus/GameRoom.ts | 11 ++ apps/web/src/game/roomSceneInput.ts | 86 +++++++++- docs/BEGINNING-FIELDS.md | 17 ++ docs/ISSUE-LOG.md | 34 +++- packages/shared/src/worldRegion.ts | 1 + scripts/verify-phase8.mjs | 37 +++-- 27 files changed, 707 insertions(+), 164 deletions(-) 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/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 234ba5c..10c8fb6 100644 --- a/apps/game-server/src/ambient/README.md +++ b/apps/game-server/src/ambient/README.md @@ -24,18 +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**):`tick.ts` 导出 `shouldStepThisTick` / `stepPercentForMobility`——哈希键 `ambient-step:{npcId}:{gameMinute}`(deterministic,**非**加密随机,仅节奏抖动)。**同 tick 可多名 NPC 移动**;Phase 26 独占 12 分桶(每 tick 仅 1 人)已移除。`resting` / `idle` 仍经上游 `shouldSkipMovement`,不进本门。 +走步资格(Phase **26.2 gap / 动森式**):每人持有 **walking | pausing** 状态——到达目标后停 **2–8** tick,再抽新目标;走路期间每 tick 最多 1 格(不每分钟重抽目标)。`shouldStepThisTick` / `stepPercentForMobility` 仍导出供历史断言;主路径以 walk/pause 为准。**同 tick 可多名 NPC 移动**。仅 `resting` 经上游 `shouldSkipMovement` 完全跳过。 | 值 | 选目标策略 | 是否每 tick 都动 | |----|------------|------------------| -| `wander` | 在 `zoneId` 内随机可走格;社交段见下方 bias | 每 tick 以 **~55%** 概率(`shouldStepThisTick`,hash `ambient-step:{npcId}:{gameMinute}`)尝试走步;多名 NPC 可同 tick 移动(仍受 speak 占用、碰撞、玩家格阻挡) | -| `stationary` | **Linger**:当前位置 Chebyshev **≤ `LINGER_RADIUS`** 格内微 wander | 两段独立合成(D-23 + D-09):B2 门 **~30%** × linger 不停 **`(100 - LINGER_PAUSE_PERCENT)%`** ⇒ 有效移动 ≈ **25.5%**/tick | -| `poi` | 优先走向区域内 **social POI**(如 well);若已在 POI 或未找到,则同 `stationary` 的 linger | 同 linger(B2 30% × linger pause) | +| `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 @@ -44,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`,但**不再**因此跳过移动。 --- @@ -56,10 +55,18 @@ Phase 16 权威 ambient 逻辑:`schedule.ts`(日程)→ `tick.ts`(6s tic | 参数 | 导出 | 默认 | 含义 | |------|------|------|------| -| `LINGER_RADIUS` | 是 | `2` | Chebyshev 距离(格)。`stationary` / `poi` 模式下,目标格必须在 NPC 当前位置 **≤ 此半径** 内。越大越像「在区域里闲逛」,越小越像「原地小动作」。 | -| `LINGER_PAUSE_PERCENT` | 是 | `15` | 每个 tick、每名 NPC **原地不动** 的概率(%)。哈希键:`linger:{npcId}:{gameMinute}`,同一游戏分钟 deterministic,不同 NPC/分钟错开。与 B2 `ambient-step:` 门独立(两段 knobs)。调高 → 更常停住;调低 → 更碎步。 | +| `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 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 942fbb8..8dd27a0 100644 --- a/apps/game-server/src/ambient/tick.test.ts +++ b/apps/game-server/src/ambient/tick.test.ts @@ -250,6 +250,58 @@ describe("runAmbientTick", () => { ); }); + 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: 21, gy: 12 }, + reasonZh: "走一步", + untilGameMinute: 600, + }), + trigger: "segment_change", + 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()); diff --git a/apps/game-server/src/ambient/tick.ts b/apps/game-server/src/ambient/tick.ts index 9ed244e..6afdf9f 100644 --- a/apps/game-server/src/ambient/tick.ts +++ b/apps/game-server/src/ambient/tick.ts @@ -23,16 +23,34 @@ import { pickZoneTarget } from "./zone-wander.js"; export const MAIN_AMBIENT_NPC_IDS = COUNCIL_NPC_IDS; -/** Per-tick step probability (0–100) by mobility — wander ~55%, linger (stationary/poi) ~30% (D-23). */ +/** Fail-safe: abandon a stuck walk after this many ambient ticks. */ +export const WALK_TIMEOUT_TICKS = 48; + +/** 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 — retained for tests / docs (D-23). */ export function stepPercentForMobility(mobility: Mobility): number { return mobility === "wander" ? 55 : 30; } -/** Deterministic per-NPC-per-minute step gate (D-23/D-24). Resting/idle never reach here. */ +/** Deterministic per-NPC-per-minute step gate (D-23/D-24). Resting never reaches walk path. */ 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; +}; + export type AmbientTickContext = { roomId: string; gameState: GameRoomState; @@ -41,6 +59,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 { @@ -158,6 +178,8 @@ function resolveMovementTarget( grid: ReturnType, playerCells: GridCell[], recent: 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) { @@ -169,6 +191,11 @@ function resolveMovementTarget( }; } + const zoneOpts = { + occupiedCells, + reservedTargets, + }; + const cached = getIntent(roomId, npc.id); if (cached && !isIntentExpired(cached.intent, gameMinute, cached.gameMinute)) { npc.intentReasonZh = cached.intent.reasonZh?.trim() ?? ""; @@ -189,6 +216,7 @@ function resolveMovementTarget( playerCells, recentCells: recent, gameMinute, + ...zoneOpts, }); return { targetGx: picked.targetGx, @@ -206,6 +234,7 @@ function resolveMovementTarget( playerCells, recentCells: recent, gameMinute, + ...zoneOpts, }); return { targetGx: picked.targetGx, @@ -215,71 +244,161 @@ function resolveMovementTarget( }; } +function beginWalk( + motionMap: Map, + npcId: string, + targetGx: number, + targetGy: number, +): void { + motionMap.set(npcId, { + mode: "walking", + targetGx, + targetGy, + pauseTicksLeft: 0, + walkTicksLeft: WALK_TIMEOUT_TICKS, + }); +} + +function beginPause( + motionMap: Map, + npcId: string, + gameMinute: number, + x: number, + y: number, +): void { + motionMap.set(npcId, { + mode: "pausing", + targetGx: x, + targetGy: y, + pauseTicksLeft: ambientPauseTicks(npcId, gameMinute), + walkTicksLeft: 0, + }); +} + /** * 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[] = []; 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 (!npc.joinVicinityActive && !shouldStepThisTick(npc.id, gameState.gameMinute, segment.mobility)) { - continue; + let motion = ambientMotion.get(npc.id); + + // 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 grid = buildMoveGrid(map, gameState, "", loader, { excludeNpcId: npc.id }); 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); + const holdingWalk = + motion?.mode === "walking" && + motion.walkTicksLeft > 0 && + (motion.targetGx !== npc.x || motion.targetGy !== npc.y) && + !npc.joinVicinityActive; + + 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); + } + } const leashed = resolved.source === "join" ? { targetGx: resolved.targetGx, targetGy: resolved.targetGy } : 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, npcY: npc.y, @@ -294,6 +413,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); + } } 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..7c7bfdc 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++) { @@ -197,6 +197,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 8937e61..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, @@ -22,6 +23,8 @@ export const LINGER_RADIUS = 2; 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..939e39d 100644 --- a/apps/game-server/src/colyseus/GameRoom.ts +++ b/apps/game-server/src/colyseus/GameRoom.ts @@ -57,6 +57,16 @@ 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; + } + >(); private lastAckedSeq = new Map(); private lastChunksFingerprint = ""; /** Set when matchmaker spawned a duplicate shard for the same mapRoomId. */ @@ -405,6 +415,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/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/docs/BEGINNING-FIELDS.md b/docs/BEGINNING-FIELDS.md index 3c4149f..3f9b3c1 100644 --- a/docs/BEGINNING-FIELDS.md +++ b/docs/BEGINNING-FIELDS.md @@ -121,3 +121,20 @@ atlas 整图加载 → object `setTexture(key, frame)` 无效 → 重复 tile / | 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/ISSUE-LOG.md b/docs/ISSUE-LOG.md index 89a850f..00018ff 100644 --- a/docs/ISSUE-LOG.md +++ b/docs/ISSUE-LOG.md @@ -192,7 +192,8 @@ 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 取代**:tick 走步资格必须走 `shouldStepThisTick` 概率门(wander **55** / linger **30**,hash `ambient-step:{npcId}:{gameMinute}`);**禁止**重新引入 exclusive bucket 或用 `Math.random()` 替换 hash 门;双 SSOT `maxRadius` 改动必须过 `council-spawn-radius.test.ts`。回归:`pnpm --filter @aetherlife/game-server test -- src/ambient/`。 +109. **Phase 26 D-MAP-AMB-03 独占 12 分桶(每 tick 仅 1 NPC 移动)已被 26.2 B2 取代**:禁止重新引入 exclusive bucket。主路径为 **walk/pause**(到达后停 2–8 tick,再抽目标);`shouldStepThisTick` 仍导出作分布断言。双 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。 ## 记录 @@ -2666,4 +2667,35 @@ 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 + +--- + diff --git a/packages/shared/src/worldRegion.ts b/packages/shared/src/worldRegion.ts index 416e96a..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 } }, 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) { From d0c4f83cbe573a00c72b6af3ea7743441d6e27b5 Mon Sep 17 00:00:00 2001 From: moyun Date: Wed, 15 Jul 2026 17:40:53 +0800 Subject: [PATCH 11/14] refactor: extract speak fetch modules and shell drawer state Split worker speak/state helpers out of npc_loop, move ChatPage drawer auto-open into useShellDrawerState, and refresh lpc-npc-9 sprite. Co-authored-by: Cursor --- apps/web/public/assets/sprites/lpc-npc-9.png | Bin 95493 -> 91464 bytes apps/web/src/ChatPage.tsx | 71 +-- apps/web/src/hooks/useShellDrawerState.ts | 93 +++ .../src/graph/nodes/llm_social_turn.py | 37 +- workers/agent-worker/src/graph/npc_loop.py | 576 +----------------- workers/agent-worker/src/graph/prompt.py | 27 +- workers/agent-worker/src/graph/speak_fetch.py | 465 ++++++++++++++ .../src/graph/speak_system_context.py | 64 ++ .../src/graph/worker_state_fetch.py | 125 ++++ .../tests/test_fetch_state_and_memory.py | 91 ++- .../tests/test_load_memory_recall_fallback.py | 4 +- 11 files changed, 851 insertions(+), 702 deletions(-) create mode 100644 apps/web/src/hooks/useShellDrawerState.ts create mode 100644 workers/agent-worker/src/graph/speak_fetch.py create mode 100644 workers/agent-worker/src/graph/speak_system_context.py create mode 100644 workers/agent-worker/src/graph/worker_state_fetch.py diff --git a/apps/web/public/assets/sprites/lpc-npc-9.png b/apps/web/public/assets/sprites/lpc-npc-9.png index 40120bc1dd044e0a3b65ca2c7664ab8c5f477376..628a6423c9f842ec21fd19750bda75cccfa65ec5 100644 GIT binary patch literal 91464 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 CUb|iZ diff --git a/apps/web/src/ChatPage.tsx b/apps/web/src/ChatPage.tsx index 9dafb72..7e90f4a 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,21 @@ export function ChatPage() { useCollectiveAttitude(mapRoomId, activeNpcId, connected); onCollectiveUpdatedRef.current = refetchCollective; + const { + drawerOpen, + drawerTab, + openDrawer, + handleDrawerTabChange, + closeDrawer, + openChronicle, + } = useShellDrawerState({ + clearChronicleUnread, + mapRoomId, + activeNpcId, + playerId, + collectiveRecentEvents: collectiveSnapshot?.recentEvents, + }); + const latestCollectiveEvent = collectiveSnapshot?.recentEvents[0]; const collectiveFeedbackKind = latestCollectiveEvent && @@ -222,61 +234,16 @@ 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); + openChronicle(); void openMinutesForEntry(toast.resultEntryId); }, - [openDrawer, openMinutesForEntry], + [openDrawer, openChronicle, openMinutesForEntry], ); useEffect(() => { @@ -553,7 +520,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/hooks/useShellDrawerState.ts b/apps/web/src/hooks/useShellDrawerState.ts new file mode 100644 index 0000000..f4a0b31 --- /dev/null +++ b/apps/web/src/hooks/useShellDrawerState.ts @@ -0,0 +1,93 @@ +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); + }, []); + + const openChronicle = useCallback(() => { + setDrawerTab("chronicle"); + setDrawerOpen(true); + }, []); + + return { + drawerOpen, + drawerTab, + openDrawer, + handleDrawerTabChange, + closeDrawer, + openChronicle, + }; +} 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..fd9f2f7 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,46 @@ ) 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, + _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], 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..5a79136 --- /dev/null +++ b/workers/agent-worker/src/graph/speak_fetch.py @@ -0,0 +1,465 @@ +"""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: + 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, + } + + +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..fa4b273 --- /dev/null +++ b/workers/agent-worker/src/graph/worker_state_fetch.py @@ -0,0 +1,125 @@ +"""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) -> 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 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") 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..4762197 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,50 +215,42 @@ 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) + key = wsf._worker_state_stale_key("default", "p1") + 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) - 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 @@ -267,9 +259,10 @@ def test_fetch_state_hot_cache_skips_http(): 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 +285,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("default", "p1", old_room) ok_response = MagicMock() ok_response.status_code = 200 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..1c2b004 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) From 19ea4724dee3f6b26cb725f590199c8cccfc5dcf Mon Sep 17 00:00:00 2001 From: moyun Date: Wed, 15 Jul 2026 22:33:54 +0800 Subject: [PATCH 12/14] fix(26.2): stop NPC ambient idle-slide drift (ISSUE-104) Use NPC_GRID_STEP_MS=600 so each cell covers a full LPC walk cycle; enable npcWorldLive from Colyseus roomNpcs; harden ambient spacing, chronicle drawer, and worker-state cache keys from PR CR follow-ups. Co-authored-by: Cursor --- apps/game-server/src/ambient/tick.test.ts | 180 ++++++++++++++++++ apps/game-server/src/ambient/tick.ts | 31 ++- apps/game-server/src/colyseus/GameRoom.ts | 1 + apps/web/src/ChatPage.tsx | 39 ++-- apps/web/src/game/RoomScene.ts | 2 +- apps/web/src/game/entitySprites.ts | 7 +- .../src/game/gridMovement.npcCatchup.test.ts | 21 ++ apps/web/src/game/gridMovement.ts | 23 +++ apps/web/src/game/lpcNpc1Sheet.test.ts | 11 +- apps/web/src/game/roomSceneNpcMotion.ts | 132 +++++++++---- apps/web/src/game/roomSceneSync.ts | 13 +- apps/web/src/game/roomSceneTypes.ts | 3 + .../web/src/hooks/useShellDrawerState.test.ts | 30 +++ apps/web/src/hooks/useShellDrawerState.ts | 6 - docs/CONTRACTS.md | 4 +- docs/ISSUE-LOG.md | 37 ++++ scripts/uat-pr19-cr-screenshots.mjs | 157 +++++++++++++++ workers/agent-worker/src/graph/npc_loop.py | 3 +- workers/agent-worker/src/graph/speak_fetch.py | 3 +- .../src/graph/worker_state_fetch.py | 69 +++++-- .../tests/test_fetch_state_and_memory.py | 37 +++- .../tests/test_load_memory_recall_fallback.py | 31 +++ 22 files changed, 744 insertions(+), 96 deletions(-) create mode 100644 apps/web/src/game/gridMovement.npcCatchup.test.ts create mode 100644 apps/web/src/hooks/useShellDrawerState.test.ts create mode 100644 scripts/uat-pr19-cr-screenshots.mjs diff --git a/apps/game-server/src/ambient/tick.test.ts b/apps/game-server/src/ambient/tick.test.ts index 8dd27a0..ddd3807 100644 --- a/apps/game-server/src/ambient/tick.test.ts +++ b/apps/game-server/src/ambient/tick.test.ts @@ -12,16 +12,19 @@ import { GameRoomState } from "../colyseus/schema.js"; import { clearAllIntentsForTests, setIntent } from "./intent-cache.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"; @@ -250,6 +253,183 @@ describe("runAmbientTick", () => { ); }); + 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("held walk destination stays reserved so a later NPC does not claim it", async () => { + clearAllIntentsForTests(); + const map = createDefaultRoom("tick-reserve-hold"); + const gameState = new GameRoomState(); + // orchard stationary linger for npc-1 (picker) and npc-2 (walker holder) + gameState.gameMinute = 400; + + 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"), + ]; + + 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, 401)!; + 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?.mode).toBe("walking"); + expect(pickerMotion?.targetGx === reservedDest.x && pickerMotion?.targetGy === 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"); diff --git a/apps/game-server/src/ambient/tick.ts b/apps/game-server/src/ambient/tick.ts index 6afdf9f..9246a9f 100644 --- a/apps/game-server/src/ambient/tick.ts +++ b/apps/game-server/src/ambient/tick.ts @@ -15,6 +15,7 @@ import { getIntent, isIntentExpired } from "./intent-cache.js"; import { buildOtherNpcCells, stepNpcTowardTarget } from "./move.js"; import { resolveScheduleSegment, + segmentKey, shouldSkipMovement, type Mobility, type ScheduleSegment, @@ -49,6 +50,8 @@ export type AmbientMotion = { 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 = { @@ -244,11 +247,25 @@ function resolveMovementTarget( }; } +/** 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", @@ -256,6 +273,7 @@ function beginWalk( targetGy, pauseTicksLeft: 0, walkTicksLeft: WALK_TIMEOUT_TICKS, + segmentKey: holdSegmentKey, }); } @@ -265,6 +283,7 @@ function beginPause( gameMinute: number, x: number, y: number, + holdSegmentKey: string, ): void { motionMap.set(npcId, { mode: "pausing", @@ -272,6 +291,7 @@ function beginPause( targetGy: y, pauseTicksLeft: ambientPauseTicks(npcId, gameMinute), walkTicksLeft: 0, + segmentKey: holdSegmentKey, }); } @@ -290,7 +310,7 @@ export function runAmbientTick(ctx: AmbientTickContext): { const gameMinute = gameState.gameMinute; const playerCells = collectPlayerCells(roomId, map); - const reservedTargets: GridCell[] = []; + const reservedTargets: GridCell[] = collectWalkingReservedTargets(ambientMotion); for (const npc of map.npcs) { if (!isCouncilNpcId(npc.id)) { @@ -323,7 +343,12 @@ export function runAmbientTick(ctx: AmbientTickContext): { continue; } + 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) { @@ -380,7 +405,7 @@ export function runAmbientTick(ctx: AmbientTickContext): { 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); + beginWalk(ambientMotion, npc.id, resolved.targetGx, resolved.targetGy, activeSegmentKey); } } @@ -415,7 +440,7 @@ export function runAmbientTick(ctx: AmbientTickContext): { } if (resolved.source !== "join" && npc.x === leashed.targetGx && npc.y === leashed.targetGy) { - beginPause(ambientMotion, npc.id, gameMinute, npc.x, npc.y); + beginPause(ambientMotion, npc.id, gameMinute, npc.x, npc.y, activeSegmentKey); } } diff --git a/apps/game-server/src/colyseus/GameRoom.ts b/apps/game-server/src/colyseus/GameRoom.ts index 939e39d..af9bc27 100644 --- a/apps/game-server/src/colyseus/GameRoom.ts +++ b/apps/game-server/src/colyseus/GameRoom.ts @@ -65,6 +65,7 @@ export class GameRoom extends Room { targetGy: number; pauseTicksLeft: number; walkTicksLeft: number; + segmentKey: string; } >(); private lastAckedSeq = new Map(); diff --git a/apps/web/src/ChatPage.tsx b/apps/web/src/ChatPage.tsx index 7e90f4a..4571c08 100644 --- a/apps/web/src/ChatPage.tsx +++ b/apps/web/src/ChatPage.tsx @@ -217,7 +217,6 @@ export function ChatPage() { openDrawer, handleDrawerTabChange, closeDrawer, - openChronicle, } = useShellDrawerState({ clearChronicleUnread, mapRoomId, @@ -240,10 +239,10 @@ export function ChatPage() { openDrawer("council"); return; } - openChronicle(); + openDrawer("chronicle"); void openMinutesForEntry(toast.resultEntryId); }, - [openDrawer, openChronicle, openMinutesForEntry], + [openDrawer, openMinutesForEntry], ); useEffect(() => { @@ -388,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(() => { @@ -418,7 +419,7 @@ export function ChatPage() { }), ); return () => cancelAnimationFrame(id); - }, [roomState, npcWorldLive, colyseusNpcGrids]); + }, [roomState, roomNpcs.length, npcWorldLive, colyseusNpcGrids]); const performResetGame = useCallback(async () => { prevNpcPosRef.current.clear(); 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/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 index f4a0b31..cb5900d 100644 --- a/apps/web/src/hooks/useShellDrawerState.ts +++ b/apps/web/src/hooks/useShellDrawerState.ts @@ -77,17 +77,11 @@ export function useShellDrawerState({ setDrawerOpen(false); }, []); - const openChronicle = useCallback(() => { - setDrawerTab("chronicle"); - setDrawerOpen(true); - }, []); - return { drawerOpen, drawerTab, openDrawer, handleDrawerTabChange, closeDrawer, - openChronicle, }; } diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index d67fdc4..63eb58f 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -101,10 +101,10 @@ 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 保留);**位移**另受 `NpcState.maxRadius` 约束(`===0` 钉死;`>0` soft leash — Phase **26.2** / MAP-06);走步资格为 **per-NPC 概率门**(`shouldStepThisTick`:hash `ambient-step:{npcId}:{gameMinute}` % 100 < wander **55** / linger **30**;resting/idle 经 `shouldSkipMovement` 为 0);**同 tick 可多名 NPC 移动**(26.2 / B2,取代 Phase 26 独占分桶) | +| **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);走步资格为 **per-NPC walk/pause**(到达后停 2–8 tick,再抽新目标;walking 时每 tick 最多 1 格)。`shouldStepThisTick` / `stepPercentForMobility`(wander ~55 / linger ~30)仅作历史断言导出,**主路径不以之为门**。仅 `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 朝发起者邻近格移动;该窗口内该 NPC **绕过概率门与 soft leash**(D-11);`maxRadius===0` 钉死仍优先于 join;worker 每 NPC 每 game-day bucket(480 分钟)最多 2 次 | +| **Join** | `joinVicinity` → 8s 窗口内 NPC 朝发起者邻近格移动;该窗口内该 NPC **绕过 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` | diff --git a/docs/ISSUE-LOG.md b/docs/ISSUE-LOG.md index 00018ff..96d8b6c 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 内省。 @@ -2698,4 +2699,40 @@ Worker 主循环仅在 npc-turn 队列 **连续 5s 为空** 时才 `BLPOP` chunk --- +### 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 门禁) + +--- + 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/workers/agent-worker/src/graph/npc_loop.py b/workers/agent-worker/src/graph/npc_loop.py index fd9f2f7..ce9917b 100644 --- a/workers/agent-worker/src/graph/npc_loop.py +++ b/workers/agent-worker/src/graph/npc_loop.py @@ -77,6 +77,7 @@ _hot_worker_snapshot, _player_id, _remember_worker_snapshot, + _remember_worker_snapshot_all_projections, _stale_worker_snapshot, _stale_worker_snapshots, _worker_state_stale_key, @@ -302,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/speak_fetch.py b/workers/agent-worker/src/graph/speak_fetch.py index 5a79136..df3574c 100644 --- a/workers/agent-worker/src/graph/speak_fetch.py +++ b/workers/agent-worker/src/graph/speak_fetch.py @@ -310,11 +310,10 @@ def load_memory_context( 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}", + "matched=false", file=sys.stderr, ) except Exception as exc: diff --git a/workers/agent-worker/src/graph/worker_state_fetch.py b/workers/agent-worker/src/graph/worker_state_fetch.py index fa4b273..c870b2d 100644 --- a/workers/agent-worker/src/graph/worker_state_fetch.py +++ b/workers/agent-worker/src/graph/worker_state_fetch.py @@ -31,20 +31,51 @@ def _player_id(state: GraphState) -> str: return state.get("player_id") or "__legacy__" -def _worker_state_stale_key(room_id: str, player_id: str) -> str: - return f"{room_id}:{player_id}" +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]) -> None: +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)] = ( + _stale_worker_snapshots[ + _worker_state_stale_key(room_id, player_id, skip_nearby_lore=skip_nearby_lore) + ] = ( 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)) +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 @@ -54,9 +85,16 @@ def _stale_worker_snapshot(room_id: str, player_id: str) -> dict[str, Any] | Non return {**snap, "_stale": True, "_stale_age_ms": age_ms} -def _hot_worker_snapshot(room_id: str, player_id: str) -> dict[str, Any] | None: +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)) + 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 @@ -82,7 +120,7 @@ def fetch_state( 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) + 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) @@ -99,7 +137,12 @@ def fetch_state( nearby = body.get("nearbyLore") if nearby is not None: snapshot = {**snapshot, "nearbyLore": nearby} - _remember_worker_snapshot(room_id, player_id, snapshot) + _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 @@ -110,7 +153,11 @@ def fetch_state( if attempt + 1 < _FETCH_STATE_ATTEMPTS: time.sleep(0.5 + attempt) continue - stale = _stale_worker_snapshot(room_id, player_id) + 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( 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 4762197..a03c967 100644 --- a/workers/agent-worker/tests/test_fetch_state_and_memory.py +++ b/workers/agent-worker/tests/test_fetch_state_and_memory.py @@ -220,8 +220,8 @@ def test_fetch_state_uses_stale_snapshot_after_timeout(): 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}]} - wsf._remember_worker_snapshot("default", "p1", stale_body) - key = wsf._worker_state_stale_key("default", "p1") + 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) @@ -240,7 +240,7 @@ def test_fetch_state_hot_cache_skips_http(): 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}]} - wsf._remember_worker_snapshot("default", "p1", fresh_body) + wsf._remember_worker_snapshot("default", "p1", fresh_body, skip_nearby_lore=True) client = MagicMock() @@ -257,6 +257,32 @@ def test_fetch_state_hot_cache_skips_http(): 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 @@ -285,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"], } - wsf._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 @@ -299,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 1c2b004..3b16191 100644 --- a/workers/agent-worker/tests/test_load_memory_recall_fallback.py +++ b/workers/agent-worker/tests/test_load_memory_recall_fallback.py @@ -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 From 21284b5b8f2d2b0e52a337c3cd9caf09ac2ea4f3 Mon Sep 17 00:00:00 2001 From: moyun Date: Wed, 15 Jul 2026 22:44:27 +0800 Subject: [PATCH 13/14] test(phase-26.2): add Nyquist validation canaries Assert LINGER_PAUSE_PERCENT === 15; leave RED B2 wire canaries that prove shouldStepThisTick is not called from runAmbientTick (45b6455). Co-authored-by: Cursor --- apps/game-server/src/ambient/tick.test.ts | 73 +++++++++++++++++++ .../src/ambient/zone-wander.test.ts | 4 + 2 files changed, 77 insertions(+) diff --git a/apps/game-server/src/ambient/tick.test.ts b/apps/game-server/src/ambient/tick.test.ts index ddd3807..8a090e5 100644 --- a/apps/game-server/src/ambient/tick.test.ts +++ b/apps/game-server/src/ambient/tick.test.ts @@ -735,6 +735,79 @@ 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)", () => { diff --git a/apps/game-server/src/ambient/zone-wander.test.ts b/apps/game-server/src/ambient/zone-wander.test.ts index 7c7bfdc..bf94d7f 100644 --- a/apps/game-server/src/ambient/zone-wander.test.ts +++ b/apps/game-server/src/ambient/zone-wander.test.ts @@ -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; From 945667b4fef1b96d6b9275af933f289169bcc17f Mon Sep 17 00:00:00 2001 From: moyun Date: Wed, 15 Jul 2026 23:00:05 +0800 Subject: [PATCH 14/14] fix(26.2): restore B2 step gate alongside walk/pause (ISSUE-105) Re-wire shouldStepThisTick for new strolls; mid-walk and join bypass. Align C-06, ambient README, Guardrail #111; keep Nyquist canaries green. Co-authored-by: Cursor --- apps/game-server/src/ambient/README.md | 2 +- apps/game-server/src/ambient/tick.test.ts | 32 ++++++++++++++++----- apps/game-server/src/ambient/tick.ts | 25 ++++++++++++---- docs/CONTRACTS.md | 4 +-- docs/ISSUE-LOG.md | 35 +++++++++++++++++++++-- 5 files changed, 80 insertions(+), 18 deletions(-) diff --git a/apps/game-server/src/ambient/README.md b/apps/game-server/src/ambient/README.md index 10c8fb6..e12db85 100644 --- a/apps/game-server/src/ambient/README.md +++ b/apps/game-server/src/ambient/README.md @@ -29,7 +29,7 @@ Phase 16 权威 ambient 逻辑:`schedule.ts`(日程)→ `tick.ts`(6s tic ### `mobility` 与移动行为 -走步资格(Phase **26.2 gap / 动森式**):每人持有 **walking | pausing** 状态——到达目标后停 **2–8** tick,再抽新目标;走路期间每 tick 最多 1 格(不每分钟重抽目标)。`shouldStepThisTick` / `stepPercentForMobility` 仍导出供历史断言;主路径以 walk/pause 为准。**同 tick 可多名 NPC 移动**。仅 `resting` 经上游 `shouldSkipMovement` 完全跳过。 +走步资格(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 都动 | |----|------------|------------------| diff --git a/apps/game-server/src/ambient/tick.test.ts b/apps/game-server/src/ambient/tick.test.ts index 8a090e5..a52aacd 100644 --- a/apps/game-server/src/ambient/tick.test.ts +++ b/apps/game-server/src/ambient/tick.test.ts @@ -285,8 +285,6 @@ describe("runAmbientTick", () => { clearAllIntentsForTests(); const map = createDefaultRoom("tick-reserve-hold"); const gameState = new GameRoomState(); - // orchard stationary linger for npc-1 (picker) and npc-2 (walker holder) - gameState.gameMinute = 400; const picker = map.npcs.find((n) => n.id === "npc-1")!; const walker = map.npcs.find((n) => n.id === "npc-2")!; @@ -297,6 +295,20 @@ describe("runAmbientTick", () => { ...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; @@ -320,7 +332,7 @@ describe("runAmbientTick", () => { map.player.x = 1; map.player.y = 1; - const walkerSeg = resolveScheduleSegment(walker.id, 401)!; + const walkerSeg = resolveScheduleSegment(walker.id, chosenMinute)!; const motion = new Map([ [ walker.id, @@ -362,10 +374,16 @@ describe("runAmbientTick", () => { }); const pickerMotion = motion.get(picker.id); - expect(pickerMotion?.mode).toBe("walking"); - expect(pickerMotion?.targetGx === reservedDest.x && pickerMotion?.targetGy === reservedDest.y).toBe( - false, - ); + 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 () => { diff --git a/apps/game-server/src/ambient/tick.ts b/apps/game-server/src/ambient/tick.ts index 9246a9f..a5cbc6c 100644 --- a/apps/game-server/src/ambient/tick.ts +++ b/apps/game-server/src/ambient/tick.ts @@ -32,12 +32,16 @@ export function ambientPauseTicks(npcId: string, gameMinute: number): number { return 2 + (stableStringHash(`ambient-pause:${npcId}:${gameMinute}`) % 7); } -/** Per-tick step probability (0–100) by mobility — retained for tests / docs (D-23). */ +/** 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). Resting never reaches walk path. */ +/** + * 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); } @@ -364,16 +368,25 @@ export function runAmbientTick(ctx: AmbientTickContext): { motion = undefined; } - const grid = buildMoveGrid(map, gameState, "", loader, { excludeNpcId: npc.id }); - const otherNpcCells = buildOtherNpcCells(map, npc.id); - const recent = recentNpcCells.get(npc.id) ?? []; - 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; + } + + const grid = buildMoveGrid(map, gameState, "", loader, { excludeNpcId: npc.id }); + const otherNpcCells = buildOtherNpcCells(map, npc.id); + const recent = recentNpcCells.get(npc.id) ?? []; + let resolved: { targetGx: number; targetGy: number; diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 63eb58f..a7748b7 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -101,10 +101,10 @@ 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 保留);**位移**另受 `NpcState.maxRadius` 约束(`===0` 钉死;`>0` soft leash — Phase **26.2** / MAP-06);走步资格为 **per-NPC walk/pause**(到达后停 2–8 tick,再抽新目标;walking 时每 tick 最多 1 格)。`shouldStepThisTick` / `stepPercentForMobility`(wander ~55 / linger ~30)仅作历史断言导出,**主路径不以之为门**。仅 `resting` 经 `shouldSkipMovement` 完全跳过移动(`idle` 不跳过 — Guardrail #110);**同 tick 可多名 NPC 移动**(26.2 / B2,取代 Phase 26 独占分桶) | +| **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 朝发起者邻近格移动;该窗口内该 NPC **绕过 soft leash**,并打断 walk/pause hold(D-11);`maxRadius===0` 钉死仍优先于 join;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` | diff --git a/docs/ISSUE-LOG.md b/docs/ISSUE-LOG.md index 96d8b6c..d4b756d 100644 --- a/docs/ISSUE-LOG.md +++ b/docs/ISSUE-LOG.md @@ -193,9 +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。主路径为 **walk/pause**(到达后停 2–8 tick,再抽目标);`shouldStepThisTick` 仍导出作分布断言。双 SSOT `maxRadius` 改动必须过 `council-spawn-radius.test.ts`。回归:`pnpm --filter @aetherlife/game-server test -- src/ambient/`。 +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 冻结) @@ -2735,4 +2735,35 @@ Worker 主循环仅在 npc-turn 队列 **连续 5s 为空** 时才 `BLPOP` chunk --- +### 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 + +--- +