diff --git a/AGENTS.md b/AGENTS.md index 82a3401..5f77a5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ Behavioral baseline (Cursor): [`.cursor/rules/Guidelines.mdc`](.cursor/rules/Gui Planned phase work: GSD commands in [CLAUDE.md](./CLAUDE.md) (`/gsd-quick`, `/gsd-debug`, `/gsd-execute-phase`). Do not bypass GSD unless the user explicitly asks. -**Phase 9 语音暂缓:** 勿实现 STT/TTS;恢复前读 [.planning/phases/09-voice-pipeline/09-STATUS.md](./.planning/phases/09-voice-pipeline/09-STATUS.md)。**当前活跃:** v5 **Phase 27**(Personal Life Timeline)— 见 [.planning/STATE.md](./.planning/STATE.md);勿默认接 Phase 10。 +**Phase 9 语音暂缓:** 勿实现 STT/TTS;恢复前读 [.planning/phases/09-voice-pipeline/09-STATUS.md](./.planning/phases/09-voice-pipeline/09-STATUS.md)。**v5 SHIPPED(2026-07-21):** 议会世界观 Phases 23–28 已归档 — 见 [.planning/STATE.md](./.planning/STATE.md) · `.planning/milestones/v5-*`;下一里程碑用 `/gsd-new-milestone`;勿默认接 Phase 10。 --- diff --git a/apps/game-server/src/colyseus/GameRoom.ts b/apps/game-server/src/colyseus/GameRoom.ts index 2bafd6f..0f96c1b 100644 --- a/apps/game-server/src/colyseus/GameRoom.ts +++ b/apps/game-server/src/colyseus/GameRoom.ts @@ -48,6 +48,8 @@ import { maybeEnqueuePersonalTimelineWeekly } from "../world/personal-timeline-w import { maybeEnqueueDyadFromAmbient, } from "../world/personal-timeline-dyad.js"; +import { maybeEnqueueNpcMutualChat } from "../world/npc-mutual-chat.js"; +import { maybeRunRelationshipDecay } from "../world/npc-relationship-decay.js"; import { setNpcSpeakPhase } from "./speak-schema.js"; export const AMBIENT_MS = 6000; @@ -438,7 +440,25 @@ export class GameRoom extends Room { } this.enqueueWorldVoteIfDue(); this.enqueuePersonalTimelineWeeklyIfDue(); - this.enqueuePersonalTimelineDyadAmbientIfDue(); + // Await mutual-chat claims before ambient dyad so same-tick supersession is real (D-MUTUAL / A2). + void this.enqueueNpcMutualChatIfDue() + .then(() => { + this.enqueuePersonalTimelineDyadAmbientIfDue(); + }) + .catch((err) => { + console.error("[GameRoom] mutual-chat→dyad sequence failed", err); + this.enqueuePersonalTimelineDyadAmbientIfDue(); + }); + // D-DECAY-04: silent idle decay — no speak-slot defer, no LLM, no relationshipSync. + this.runRelationshipDecayIfDue(); + } + + /** Protected path: minimal monthly idle-decay hook only (Phase 28 plan 04). */ + private runRelationshipDecayIfDue(): void { + const abs = getRoomVoteState(this.mapRoomId).absoluteGameMinute; + void maybeRunRelationshipDecay(this.mapRoomId, abs).catch((err) => { + console.error("[GameRoom] relationship decay failed", err); + }); } private enqueueWorldVoteIfDue(): void { @@ -464,6 +484,25 @@ export class GameRoom extends Room { }); } + /** D-MUTUAL-07: defer when any speak in-flight (same as world-vote). No LLM in tick. */ + private enqueueNpcMutualChatIfDue(): Promise { + if (this.npcSpeakJobs.size > 0) return Promise.resolve(); + const abs = getRoomVoteState(this.mapRoomId).absoluteGameMinute; + const { state: mapState } = getOrCreate(this.mapRoomId); + return maybeEnqueueNpcMutualChat({ + roomId: this.mapRoomId, + npcs: mapState.npcs.map((n) => ({ id: n.id, x: n.x, y: n.y })), + absoluteGameMinute: abs, + gameMinuteOfDay: this.gameState.gameMinute, + busyNpcIds: this.npcSpeakJobs, + npcSpeakInFlight: false, + }) + .then(() => undefined) + .catch((err) => { + console.error("[GameRoom] npc-mutual-chat enqueue failed", err); + }); + } + private enqueuePersonalTimelineDyadAmbientIfDue(): void { if (this.npcSpeakJobs.size > 0) return; const abs = getRoomVoteState(this.mapRoomId).absoluteGameMinute; diff --git a/apps/game-server/src/index.test.ts b/apps/game-server/src/index.test.ts index 36417b3..a7336c7 100644 --- a/apps/game-server/src/index.test.ts +++ b/apps/game-server/src/index.test.ts @@ -218,6 +218,7 @@ describe("game-server", () => { expect(Array.isArray(res.body.state.npcs)).toBe(true); expect(Array.isArray(res.body.nearbyLore)).toBe(true); expect(res.body.memoryCounts).toBeUndefined(); + expect(typeof res.body.state.absoluteGameMinute).toBe("number"); }); it("GET /internal/rooms/default/memory-context returns context shape", async () => { diff --git a/apps/game-server/src/index.ts b/apps/game-server/src/index.ts index 6763c11..b6fef8b 100644 --- a/apps/game-server/src/index.ts +++ b/apps/game-server/src/index.ts @@ -8,6 +8,7 @@ import { createCollectiveStateRouter } from "./routes/collective-state.js"; import { createWorldHistoryRouter } from "./routes/world-history.js"; import { createInternalWorldHistoryRouter } from "./routes/internal-world-history.js"; import { createPersonalTimelineRouter } from "./routes/personal-timeline.js"; +import { createNpcRelationshipsRouter } from "./routes/npc-relationships.js"; import { createInternalPersonalTimelineRouter } from "./routes/internal-personal-timeline.js"; import { createAuditRouter } from "./routes/audit.js"; import { createInternalJobsRouter } from "./routes/internal.js"; @@ -19,6 +20,7 @@ import { } from "./routes/internal-lore.js"; import { createInternalAmbientIntentRouter } from "./routes/internal-ambient-intent.js"; import { createInternalNpcRelationshipsRouter } from "./routes/internal-npc-relationships.js"; +import { createInternalNpcMutualChatRouter } from "./routes/internal-npc-mutual-chat.js"; import { createInternalWorldVoteTriggerRouter } from "./routes/internal-world-vote-trigger.js"; import { createInternalWorldVoteRouter } from "./routes/internal-world-vote.js"; import { attachColyseus } from "./colyseus/server.js"; @@ -53,6 +55,7 @@ export function createApp(): Express { app.use("/rooms", json, createCollectiveStateRouter()); app.use("/rooms", json, createWorldHistoryRouter()); app.use("/rooms", json, createPersonalTimelineRouter()); + app.use("/rooms", json, createNpcRelationshipsRouter()); app.use("/rooms", json, createChatRouter()); app.use("/internal/rooms", json, createInternalRoomsRouter()); app.use("/internal/rooms", json, createInternalMemoriesRouter()); @@ -61,6 +64,7 @@ export function createApp(): Express { app.use("/internal/rooms", json, createInternalPersonalTimelineRouter()); app.use("/internal/rooms", json, createInternalAmbientIntentRouter()); app.use("/internal/rooms", json, createInternalNpcRelationshipsRouter()); + app.use("/internal/rooms", json, createInternalNpcMutualChatRouter()); app.use("/internal/rooms", json, createInternalWorldVoteTriggerRouter()); app.use("/internal/rooms", json, createInternalWorldVoteRouter()); app.use("/internal/jobs", json, createInternalJobsRouter()); diff --git a/apps/game-server/src/queue/npc-mutual-chat.ts b/apps/game-server/src/queue/npc-mutual-chat.ts new file mode 100644 index 0000000..9bca4f1 --- /dev/null +++ b/apps/game-server/src/queue/npc-mutual-chat.ts @@ -0,0 +1,149 @@ +/** + * NPC mutual-chat job queue (D-MUTUAL-07). + * Tick enqueues only — worker drains LLM (plan 06). Mirror personal-timeline claim/LPUSH. + */ + +import { Redis } from "ioredis"; +import { normalizeEdgeIds } from "@aetherlife/shared"; + +export type NpcMutualChatJobPayload = { + roomId: string; + npcAId: string; + npcBId: string; + dayIndex: number; + absoluteGameMinute: number; + jobId: string; + enqueuedAt: string; +}; + +export const NPC_MUTUAL_CHAT_JOBS_KEY = "aetherlife:npc-mutual-chat:jobs"; + +export const NPC_MUTUAL_CHAT_JOB_CLAIM_PREFIX = + "aetherlife:npc-mutual-chat:job-claimed:"; + +const JOB_CLAIM_TTL_SECONDS = 60 * 60 * 24 * 14; + +const mockJobs = new Map(); +const localJobClaims = new Set(); + +function getRedisUrl(): string | undefined { + return process.env.REDIS_URL; +} + +function createRedis(url: string): Redis { + const client = new Redis(url, { maxRetriesPerRequest: null }); + client.on("error", (err) => { + console.error("[redis]", err.message); + }); + return client; +} + +export function npcMutualChatJobId( + roomId: string, + dayIndex: number, + npcAId: string, + npcBId: string, +): string { + const { npcAId: a, npcBId: b } = normalizeEdgeIds(npcAId, npcBId); + return `mc-${roomId}-${dayIndex}-${a}-${b}`; +} + +export async function claimNpcMutualChatJobId(jobId: string): Promise { + const url = getRedisUrl(); + if (!url) { + if (localJobClaims.has(jobId)) return false; + localJobClaims.add(jobId); + return true; + } + const client = createRedis(url); + try { + const key = `${NPC_MUTUAL_CHAT_JOB_CLAIM_PREFIX}${jobId}`; + const ok = await client.set(key, "1", "EX", JOB_CLAIM_TTL_SECONDS, "NX"); + return ok === "OK"; + } finally { + await client.quit(); + } +} + +export async function releaseNpcMutualChatJobId(jobId: string): Promise { + localJobClaims.delete(jobId); + const url = getRedisUrl(); + if (!url) return; + const client = createRedis(url); + try { + await client.del(`${NPC_MUTUAL_CHAT_JOB_CLAIM_PREFIX}${jobId}`); + } finally { + await client.quit(); + } +} + +async function lpushJob(payload: NpcMutualChatJobPayload): Promise { + const url = getRedisUrl(); + if (!url) return; + const client = createRedis(url); + try { + await client.lpush(NPC_MUTUAL_CHAT_JOBS_KEY, JSON.stringify(payload)); + } finally { + await client.quit(); + } +} + +async function claimAndLpushJob( + jobId: string, + payload: NpcMutualChatJobPayload, +): Promise { + const claimed = await claimNpcMutualChatJobId(jobId); + if (!claimed) return false; + try { + await lpushJob(payload); + if (!getRedisUrl()) { + // Local/mock inspection map only — never grow unbounded under Redis. + mockJobs.set(jobId, payload); + } + return true; + } catch (err) { + await releaseNpcMutualChatJobId(jobId); + throw err; + } +} + +/** + * Claim then LPUSH mutual-chat job. Returns jobId or null if already claimed / invalid. + */ +export async function enqueueNpcMutualChatJob(input: { + roomId: string; + npcAId: string; + npcBId: string; + dayIndex: number; + absoluteGameMinute: number; +}): Promise { + if (!input.roomId || input.npcAId === input.npcBId) return null; + const { npcAId, npcBId } = normalizeEdgeIds(input.npcAId, input.npcBId); + const jobId = npcMutualChatJobId(input.roomId, input.dayIndex, npcAId, npcBId); + const payload: NpcMutualChatJobPayload = { + roomId: input.roomId, + npcAId, + npcBId, + dayIndex: input.dayIndex, + absoluteGameMinute: input.absoluteGameMinute, + jobId, + enqueuedAt: new Date().toISOString(), + }; + const ok = await claimAndLpushJob(jobId, payload); + return ok ? jobId : null; +} + +export function getMockNpcMutualChatJob( + jobId: string, +): NpcMutualChatJobPayload | undefined { + return mockJobs.get(jobId); +} + +export function clearMockNpcMutualChatJobs(): void { + mockJobs.clear(); + localJobClaims.clear(); +} + +export function clearNpcMutualChatJobClaimsForTest(): void { + localJobClaims.clear(); +} diff --git a/apps/game-server/src/routes/internal-npc-mutual-chat.ts b/apps/game-server/src/routes/internal-npc-mutual-chat.ts new file mode 100644 index 0000000..5115823 --- /dev/null +++ b/apps/game-server/src/routes/internal-npc-mutual-chat.ts @@ -0,0 +1,90 @@ +import { Router, type Request, type Response } from "express"; +import { z } from "zod"; +import { linkedEdgeSchema } from "@aetherlife/shared"; +import { + broadcastLinkedEdgesHint, + broadcastRelationshipSync, + presentNpcMutualChat, +} from "../world/relationship-broadcast.js"; +import { requireWorkerAuth } from "./internal.js"; + +const presentBodySchema = z + .object({ + npcAId: z.string().min(1).max(64), + npcBId: z.string().min(1).max(64), + npcAReasonZh: z.string().min(1).max(40), + npcBReasonZh: z.string().min(1).max(40), + bubbleText: z.string().min(1).max(40), + }) + .strict(); + +const linkedHintBodySchema = z + .object({ + linkedEdges: z.array(linkedEdgeSchema).min(1).max(8), + }) + .strict(); + +export function createInternalNpcMutualChatRouter(): Router { + const router = Router(); + router.use(requireWorkerAuth); + + router.post("/:roomId/npc-mutual-chat/present", (req: Request, res: Response) => { + const roomId = req.params.roomId; + if (!roomId) { + res.status(400).json({ ok: false, error: "roomId required" }); + return; + } + + const parsed = presentBodySchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ ok: false, error: parsed.error.flatten() }); + return; + } + + const data = parsed.data; + if (data.npcAId === data.npcBId) { + res.status(400).json({ ok: false, error: "npcAId and npcBId must differ" }); + return; + } + + try { + const bubble = presentNpcMutualChat(roomId, data); + try { + broadcastRelationshipSync(roomId, { hasUpdate: true }); + } catch (err) { + console.error("[npc-mutual-chat] relationshipSync failed", err); + } + res.json({ ok: true, bubble }); + } catch (err) { + const message = err instanceof Error ? err.message : "present failed"; + res.status(500).json({ ok: false, error: message }); + } + }); + + router.post( + "/:roomId/npc-mutual-chat/linked-edges-hint", + (req: Request, res: Response) => { + const roomId = req.params.roomId; + if (!roomId) { + res.status(400).json({ ok: false, error: "roomId required" }); + return; + } + + const parsed = linkedHintBodySchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ ok: false, error: parsed.error.flatten() }); + return; + } + + try { + broadcastLinkedEdgesHint(roomId, { linkedEdges: parsed.data.linkedEdges }); + res.json({ ok: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "linked-edges-hint failed"; + res.status(500).json({ ok: false, error: message }); + } + }, + ); + + return router; +} diff --git a/apps/game-server/src/routes/internal-npc-relationships.ts b/apps/game-server/src/routes/internal-npc-relationships.ts index 5105bf1..0b1f73d 100644 --- a/apps/game-server/src/routes/internal-npc-relationships.ts +++ b/apps/game-server/src/routes/internal-npc-relationships.ts @@ -3,8 +3,13 @@ import { z } from "zod"; import { relationshipDeltaInputSchema } from "@aetherlife/shared"; import { applyRelationshipDeltas, + ensureRelationshipEdgeEmbedding, listRelationshipsForRoom, + searchSimilarEdges, } from "../world/npc-relationships-repository.js"; +import { embedText } from "../memory/embed.js"; +import { broadcastRelationshipSync } from "../world/relationship-broadcast.js"; +import { getRoomVoteState } from "../world/world-vote-state.js"; import { requireWorkerAuth } from "./internal.js"; const applyDeltasBodySchema = z @@ -14,6 +19,29 @@ const applyDeltasBodySchema = z }) .strict(); +const ensureEmbeddingBodySchema = z + .object({ + npcAId: z.string().min(1), + npcBId: z.string().min(1), + }) + .strict(); + +const searchSimilarBodySchema = z + .object({ + query: z.string().min(1), + activeNpcId: z.string().min(1).optional(), + k: z.number().int().min(1).max(10).optional(), + }) + .strict(); + +function safeBroadcastRelationshipSync(roomId: string): void { + try { + broadcastRelationshipSync(roomId, { hasUpdate: true }); + } catch (err) { + console.error("[npc-relationships] broadcastRelationshipSync failed", err); + } +} + export function createInternalNpcRelationshipsRouter(): Router { const router = Router(); router.use(requireWorkerAuth); @@ -57,11 +85,17 @@ export function createInternalNpcRelationshipsRouter(): Router { } try { + // Stamp last-interact with the room clock so monthly idle decay + // measures from the real interaction, not abs=0 (Codex CR PR#22). const result = await applyRelationshipDeltas({ roomId, deltas: parsed.data.deltas, voteEpoch: parsed.data.voteEpoch, + absoluteGameMinute: getRoomVoteState(roomId).absoluteGameMinute, }); + if (result.linkedEdges.length > 0) { + safeBroadcastRelationshipSync(roomId); + } res.json({ ok: true, linkedEdges: result.linkedEdges }); } catch (err) { const message = err instanceof Error ? err.message : "apply-deltas failed"; @@ -70,5 +104,61 @@ export function createInternalNpcRelationshipsRouter(): Router { }, ); + router.post( + "/:roomId/npc-relationships/ensure-embedding", + async (req: Request, res: Response) => { + const roomId = req.params.roomId; + if (!roomId) { + res.status(400).json({ ok: false, error: "roomId required" }); + return; + } + const parsed = ensureEmbeddingBodySchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ ok: false, error: parsed.error.flatten() }); + return; + } + try { + const embedded = await ensureRelationshipEdgeEmbedding( + roomId, + parsed.data.npcAId, + parsed.data.npcBId, + ); + res.json({ ok: true, embedded }); + } catch (err) { + const message = err instanceof Error ? err.message : "ensure-embedding failed"; + res.status(500).json({ ok: false, error: message }); + } + }, + ); + + router.post( + "/:roomId/npc-relationships/search-similar", + async (req: Request, res: Response) => { + const roomId = req.params.roomId; + if (!roomId) { + res.status(400).json({ ok: false, error: "roomId required" }); + return; + } + const parsed = searchSimilarBodySchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ ok: false, error: parsed.error.flatten() }); + return; + } + try { + const queryEmbedding = await embedText(parsed.data.query); + const edges = await searchSimilarEdges({ + roomId, + queryEmbedding, + activeNpcId: parsed.data.activeNpcId, + k: parsed.data.k ?? 5, + }); + res.json({ ok: true, edges }); + } catch (err) { + const message = err instanceof Error ? err.message : "search-similar failed"; + res.status(500).json({ ok: false, error: message }); + } + }, + ); + return router; } diff --git a/apps/game-server/src/routes/npc-relationships.test.ts b/apps/game-server/src/routes/npc-relationships.test.ts new file mode 100644 index 0000000..760f1ce --- /dev/null +++ b/apps/game-server/src/routes/npc-relationships.test.ts @@ -0,0 +1,216 @@ +/** + * Phase 28 player-scoped GET npc-relationships (D-API-01…03, D-GRAPH-02 / C-09b). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import request from "supertest"; +import { + COLYSEUS_SERVER_MESSAGES, + type ColyseusRelationshipSyncPayload, +} from "@aetherlife/shared"; +import { createApp } from "../index.js"; +import { + clearColyseusRoomRegistry, + registerColyseusRoom, +} from "../colyseus/room-registry.js"; +import { GameRoomState, PlayerSchema } from "../colyseus/schema.js"; +import { clearAllRooms } from "../room/store.js"; +import { + clearNpcRelationshipsMemory, + getLastInteractAbsMinute, + insertRelationshipEdge, +} from "../world/npc-relationships-repository.js"; +import { + broadcastRelationshipSync, +} from "../world/relationship-broadcast.js"; +import * as relationshipBroadcast from "../world/relationship-broadcast.js"; +import { + clearRoomVoteStateForTests, + getRoomVoteState, + tickRoomVoteClock, +} from "../world/world-vote-state.js"; + +const ROOM = "room-rel-http"; +const PLAYER = "player-alpha01"; + +describe("npc-relationships routes (C-09b)", () => { + const app = createApp(); + + beforeEach(() => { + delete process.env.DATABASE_URL; + delete process.env.INTERNAL_WORKER_TOKEN; + clearAllRooms(); + clearColyseusRoomRegistry(); + clearNpcRelationshipsMemory(); + clearRoomVoteStateForTests(); + }); + + it("D-API-01/03: GET /rooms/:roomId/npc-relationships requires joined-room / session scope", async () => { + const state = new GameRoomState(); + const player = new PlayerSchema(); + player.playerId = PLAYER; + state.players.set("sess-a", player); + registerColyseusRoom(ROOM, { state } as never); + + const res = await request(app) + .get(`/rooms/${ROOM}/npc-relationships`) + .set("X-Player-Id", "player-bravo001"); + + expect(res.status).toBe(403); + expect(res.body.ok).toBe(false); + expect(res.body.error).toMatch(/not connected/); + }); + + it("D-GRAPH-02 / D-API-01: response maps edges to band labels — no raw affection/trust integers", async () => { + const state = new GameRoomState(); + const player = new PlayerSchema(); + player.playerId = PLAYER; + state.players.set("sess-rel-list", player); + registerColyseusRoom(ROOM, { state } as never); + + await insertRelationshipEdge({ + roomId: ROOM, + npcAId: "npc-1", + npcBId: "npc-2", + baseTag: "ally", + affection: 40, + trust: 80, + }); + + const res = await request(app) + .get(`/rooms/${ROOM}/npc-relationships`) + .set("X-Player-Id", PLAYER); + + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(Array.isArray(res.body.edges)).toBe(true); + expect(res.body.edges.length).toBeGreaterThan(0); + + const edge = res.body.edges[0] as Record; + expect(edge).toMatchObject({ + npcAId: "npc-1", + npcBId: "npc-2", + baseTag: "ally", + band: "warm", + bandLabelZh: "亲近", + kindLabelZh: "同盟", + }); + expect(Array.isArray(edge.currentStatus)).toBe(true); + + const json = JSON.stringify(res.body); + expect(json).not.toMatch(/"affection"/); + expect(json).not.toMatch(/"trust"/); + expect(edge).not.toHaveProperty("affection"); + expect(edge).not.toHaveProperty("trust"); + }); + + it("worker internal list still returns full edges with affection/trust", async () => { + await insertRelationshipEdge({ + roomId: ROOM, + npcAId: "npc-3", + npcBId: "npc-4", + baseTag: "rival", + affection: -50, + trust: 20, + }); + + const res = await request(app).get(`/internal/rooms/${ROOM}/npc-relationships`); + + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(res.body.edges[0]).toMatchObject({ + affection: -50, + trust: 20, + }); + }); + + it("D-API-01: relationshipSync broadcast helper emits { hasUpdate } / seq hint only", () => { + const sent: Array<{ type: string; payload: unknown }> = []; + const fakeRoom = { + clients: [ + { + send(type: string, payload: unknown) { + sent.push({ type, payload }); + }, + }, + ], + }; + registerColyseusRoom(ROOM, fakeRoom as never); + + const payload: ColyseusRelationshipSyncPayload = { + hasUpdate: true, + latestSeq: 3, + }; + broadcastRelationshipSync(ROOM, payload); + + expect(COLYSEUS_SERVER_MESSAGES.relationshipSync).toBe("relationshipSync"); + expect(sent).toHaveLength(1); + expect(sent[0]!.type).toBe(COLYSEUS_SERVER_MESSAGES.relationshipSync); + expect(sent[0]!.payload).toEqual({ hasUpdate: true, latestSeq: 3 }); + const hintJson = JSON.stringify(sent[0]!.payload); + expect(hintJson).not.toMatch(/"edges"/); + expect(hintJson).not.toMatch(/"affection"/); + expect(hintJson).not.toMatch(/"trust"/); + + // No registered room — must not throw. + broadcastRelationshipSync("missing-room-rel-sync", { hasUpdate: true }); + }); + + it("apply-deltas success invokes broadcastRelationshipSync when edges change", async () => { + const spy = vi + .spyOn(relationshipBroadcast, "broadcastRelationshipSync") + .mockImplementation(() => undefined); + + await insertRelationshipEdge({ + roomId: ROOM, + npcAId: "npc-1", + npcBId: "npc-2", + baseTag: "ally", + affection: 10, + trust: 50, + }); + + const res = await request(app) + .post(`/internal/rooms/${ROOM}/npc-relationships/apply-deltas`) + .send({ + deltas: [ + { + npcAId: "npc-1", + npcBId: "npc-2", + affectionDelta: 5, + }, + ], + voteEpoch: "vote-rel-sync-1", + }); + + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(res.body.linkedEdges.length).toBeGreaterThan(0); + expect(spy).toHaveBeenCalledWith(ROOM, { hasUpdate: true }); + spy.mockRestore(); + }); + + it("apply-deltas stamps last-interact with room absoluteGameMinute (decay idle guard)", async () => { + await insertRelationshipEdge({ + roomId: ROOM, + npcAId: "npc-5", + npcBId: "npc-6", + baseTag: "ally", + affection: 10, + trust: 50, + }); + + // Advance room clock to a non-zero minute. + for (let i = 0; i < 2500; i++) tickRoomVoteClock(ROOM); + const expectedAbs = getRoomVoteState(ROOM).absoluteGameMinute; + expect(expectedAbs).toBeGreaterThan(0); + + const res = await request(app) + .post(`/internal/rooms/${ROOM}/npc-relationships/apply-deltas`) + .send({ + deltas: [{ npcAId: "npc-5", npcBId: "npc-6", affectionDelta: 2 }], + }); + + expect(res.status).toBe(200); + expect(getLastInteractAbsMinute(ROOM, "npc-5", "npc-6")).toBe(expectedAbs); + }); +}); diff --git a/apps/game-server/src/routes/npc-relationships.ts b/apps/game-server/src/routes/npc-relationships.ts new file mode 100644 index 0000000..ba3d804 --- /dev/null +++ b/apps/game-server/src/routes/npc-relationships.ts @@ -0,0 +1,44 @@ +import { Router } from "express"; +import { toRelationshipEdgeBandPublic } from "@aetherlife/shared"; +import { assertScopedPlayerRequest } from "../colyseus/bridge.js"; +import { playerIdFromRequest } from "../http/player-id.js"; +import { getOrCreate } from "../room/store.js"; +import { listRelationshipsForRoom } from "../world/npc-relationships-repository.js"; + +/** + * Player-facing C-09b GET — band-mapped edges only (D-API-01/03, D-GRAPH-02). + * Worker full-edge list stays on /internal/... + requireWorkerAuth. + */ +export function createNpcRelationshipsRouter(): Router { + const router = Router(); + + router.get("/:roomId/npc-relationships", async (req, res) => { + const { roomId } = req.params; + if (!roomId) { + res.status(400).json({ ok: false, error: "roomId required" }); + return; + } + + const playerId = playerIdFromRequest(req); + const scope = assertScopedPlayerRequest(req, playerId, roomId); + if (!scope.ok) { + res.status(scope.status).json({ ok: false, error: scope.error }); + return; + } + + try { + getOrCreate(roomId); + const edges = await listRelationshipsForRoom(roomId); + res.json({ + ok: true, + edges: edges.map(toRelationshipEdgeBandPublic), + }); + } catch (err) { + const message = + err instanceof Error ? err.message : "npc-relationships list failed"; + res.status(500).json({ ok: false, error: message }); + } + }); + + return router; +} diff --git a/apps/game-server/src/routes/rooms.ts b/apps/game-server/src/routes/rooms.ts index 6494c24..dd2cee2 100644 --- a/apps/game-server/src/routes/rooms.ts +++ b/apps/game-server/src/routes/rooms.ts @@ -35,6 +35,7 @@ import { getOrCreate, reset, setState } from "../room/store.js"; import { getChunkLoader } from "../world/chunk-loader.js"; import { getChunkLore } from "../world/lore-repository.js"; import { getChunkLoreCached } from "../world/lore-chunk-cache.js"; +import { getRoomVoteState } from "../world/world-vote-state.js"; import { logInternalLatency } from "../observability/internalLatency.js"; import { clearDialogueForPlayer } from "../npc/dialogue-session.js"; import { @@ -269,11 +270,16 @@ async function buildWorkerStatePayload( playerId: string, options?: { skipNearbyLore?: boolean }, ): Promise<{ - state: ReturnType; + state: ReturnType & { absoluteGameMinute: number }; nearbyLore: Array<{ cx: number; cy: number; nameZh: string; flavorOneLine: string }>; }> { const record = getOrCreate(roomId); - const viewState = roomStateForInitiator(record.state, roomId, playerId); + // Include the monotonic room clock so worker day-keyed caps (belief gate + // D-PLAYER-04/06) reset per real game-day instead of pinning to day-0. + const viewState = { + ...roomStateForInitiator(record.state, roomId, playerId), + absoluteGameMinute: getRoomVoteState(roomId).absoluteGameMinute, + }; if (options?.skipNearbyLore) { return { state: viewState, nearbyLore: [] }; } diff --git a/apps/game-server/src/world/npc-mutual-chat.test.ts b/apps/game-server/src/world/npc-mutual-chat.test.ts new file mode 100644 index 0000000..c399a66 --- /dev/null +++ b/apps/game-server/src/world/npc-mutual-chat.test.ts @@ -0,0 +1,235 @@ +/** + * Phase 28 NPC mutual chat — selector, stagger, speak defer, supersede ambient dyad. + * D-MUTUAL-01/03/05/07 · RESEARCH A2. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + clearMockNpcMutualChatJobs, + clearNpcMutualChatJobClaimsForTest, + getMockNpcMutualChatJob, +} from "../queue/npc-mutual-chat.js"; +import { + clearNpcMutualChatState, + isPairClaimedForMutualChat, + maybeEnqueueNpcMutualChat, + MUTUAL_CHAT_MAX_PER_DAY, + shouldDeferNpcMutualChatEnqueue, +} from "./npc-mutual-chat.js"; +import { + clearMockPersonalTimelineJobs, + clearPersonalTimelineJobClaimsForTest, +} from "../queue/personal-timeline.js"; +import { + clearPersonalTimelineDyadState, + maybeEnqueueDyadFromAmbient, +} from "./personal-timeline-dyad.js"; + +describe("npc-mutual-chat", () => { + beforeEach(() => { + delete process.env.REDIS_URL; + clearNpcMutualChatState(); + clearMockNpcMutualChatJobs(); + clearNpcMutualChatJobClaimsForTest(); + clearPersonalTimelineDyadState(); + clearMockPersonalTimelineJobs(); + clearPersonalTimelineJobClaimsForTest(); + }); + + afterEach(() => { + delete process.env.REDIS_URL; + }); + + it("D-MUTUAL-01/05: proximity + schedule filter selects eligible pairs (no hard veto by score)", async () => { + expect(MUTUAL_CHAT_MAX_PER_DAY).toBe(3); + + const roomId = "room-mc-select"; + // Cluster of three within Chebyshev≤2; npc-4 far away. + const npcs = [ + { id: "npc-1", x: 10, y: 10 }, + { id: "npc-2", x: 11, y: 10 }, + { id: "npc-3", x: 10, y: 11 }, + { id: "npc-4", x: 80, y: 80 }, + ]; + // Nemesis / low affection still eligible — score is weight only. + const relationshipScores = new Map([ + ["npc-1:npc-2", -80], + ["npc-1:npc-3", 40], + ["npc-2:npc-3", 10], + ]); + + const { enqueued } = await maybeEnqueueNpcMutualChat({ + roomId, + npcs, + absoluteGameMinute: 1440 * 5 + 600, // midday — schedules active + gameMinuteOfDay: 600, + relationshipScores, + forceSelectAllEligible: true, + villageBandOnly: false, + }); + + expect(enqueued.length).toBeGreaterThan(0); + expect(enqueued.length).toBeLessThanOrEqual(MUTUAL_CHAT_MAX_PER_DAY); + + const payloads = enqueued.map((id) => getMockNpcMutualChatJob(id)); + for (const job of payloads) { + expect(job).toBeTruthy(); + expect(job!.roomId).toBe(roomId); + expect(job!.dayIndex).toBe(5); + expect(["npc-1", "npc-2", "npc-3"]).toContain(job!.npcAId); + expect(["npc-1", "npc-2", "npc-3"]).toContain(job!.npcBId); + expect(job!.npcAId).not.toBe(job!.npcBId); + // Far npc-4 must not appear + expect(job!.npcAId).not.toBe("npc-4"); + expect(job!.npcBId).not.toBe("npc-4"); + } + + // Nemesis pair npc-1↔npc-2 remains eligible (may or may not win sort; assert not vetoed from candidate set) + const { candidates } = await maybeEnqueueNpcMutualChat({ + roomId: "room-mc-nemesis-check", + npcs: [ + { id: "npc-1", x: 10, y: 10 }, + { id: "npc-2", x: 11, y: 10 }, + ], + absoluteGameMinute: 1440 * 6 + 600, + gameMinuteOfDay: 600, + relationshipScores: new Map([["npc-1:npc-2", -99]]), + forceSelectAllEligible: true, + villageBandOnly: false, + dryRun: true, + }); + expect(candidates.some((c) => c.npcAId === "npc-1" && c.npcBId === "npc-2")).toBe( + true, + ); + }); + + it("D-MUTUAL-03: daily stagger caps 2–3 pair triggers per game day with 12-seat rotation", async () => { + const roomId = "room-mc-stagger"; + // Pack many council NPCs into one Chebyshev cluster so cap, not proximity, binds. + const npcs = Array.from({ length: 8 }, (_, i) => ({ + id: `npc-${i + 1}`, + x: 10 + (i % 3), + y: 10 + Math.floor(i / 3), + })); + + const first = await maybeEnqueueNpcMutualChat({ + roomId, + npcs, + absoluteGameMinute: 1440 * 10 + 600, + gameMinuteOfDay: 600, + forceSelectAllEligible: true, + villageBandOnly: false, + }); + expect(first.enqueued.length).toBeLessThanOrEqual(MUTUAL_CHAT_MAX_PER_DAY); + expect(first.enqueued.length).toBeGreaterThan(0); + + const again = await maybeEnqueueNpcMutualChat({ + roomId, + npcs, + absoluteGameMinute: 1440 * 10 + 700, + gameMinuteOfDay: 700, + forceSelectAllEligible: true, + villageBandOnly: false, + }); + expect(again.enqueued.length).toBe(0); + + // Different day → bucket rotation allows new enqueues (cap resets). + const nextDay = await maybeEnqueueNpcMutualChat({ + roomId, + npcs, + absoluteGameMinute: 1440 * 11 + 600, + gameMinuteOfDay: 600, + forceSelectAllEligible: true, + villageBandOnly: false, + }); + expect(nextDay.enqueued.length).toBeGreaterThan(0); + expect(nextDay.enqueued.length).toBeLessThanOrEqual(MUTUAL_CHAT_MAX_PER_DAY); + }); + + it("D-MUTUAL-07: defers enqueue when player speak is in-progress (same as world-vote)", async () => { + expect(shouldDeferNpcMutualChatEnqueue(1)).toBe(true); + expect(shouldDeferNpcMutualChatEnqueue(0)).toBe(false); + + const deferred = await maybeEnqueueNpcMutualChat({ + roomId: "room-mc-defer", + npcs: [ + { id: "npc-1", x: 10, y: 10 }, + { id: "npc-2", x: 11, y: 10 }, + ], + absoluteGameMinute: 1440 * 4 + 600, + gameMinuteOfDay: 600, + npcSpeakInFlight: true, + forceSelectAllEligible: true, + villageBandOnly: false, + }); + expect(deferred.enqueued).toEqual([]); + expect(deferred.deferred).toBe(true); + }); + + it("D-MUTUAL / A2: mutual-chat supersedes ambient dyad for same room/day/pair claim", async () => { + const roomId = "room-mc-supersede"; + const npcs = [ + { id: "npc-1", x: 10, y: 10 }, + { id: "npc-2", x: 11, y: 10 }, + { id: "npc-3", x: 10, y: 11 }, + ]; + const abs = 1440 * 7 + 600; + + const mc = await maybeEnqueueNpcMutualChat({ + roomId, + npcs, + absoluteGameMinute: abs, + gameMinuteOfDay: 600, + forceSelectAllEligible: true, + villageBandOnly: false, + }); + expect(mc.enqueued.length).toBeGreaterThan(0); + + const job = getMockNpcMutualChatJob(mc.enqueued[0]!); + expect(job).toBeTruthy(); + expect( + isPairClaimedForMutualChat(roomId, job!.dayIndex, job!.npcAId, job!.npcBId), + ).toBe(true); + + const dyadIds = await maybeEnqueueDyadFromAmbient({ + roomId, + npcs, + absoluteGameMinute: abs, + selectPct: 100, + }); + // Claimed mutual pairs must not get a silent ambient dyad event. + for (const id of dyadIds) { + // If anything enqueued, it must not be the mutual-claimed pair — covered by claim check below. + void id; + } + // Stronger: re-check that mutual pair stays claimed and ambient cannot claim same pair key. + expect( + isPairClaimedForMutualChat(roomId, job!.dayIndex, job!.npcAId, job!.npcBId), + ).toBe(true); + // With only 3 nearby NPCs and mutual taking up to 3 pairs, ambient should get nothing + // (all close pairs claimed) or only unclaimed pairs — never re-fire claimed ones. + expect(dyadIds.length).toBe(0); + }); + + it("queue claim NX + mock path blocks duplicate jobId without Redis", async () => { + const { + enqueueNpcMutualChatJob, + claimNpcMutualChatJobId, + } = await import("../queue/npc-mutual-chat.js"); + + const input = { + roomId: "room-mc-claim", + npcAId: "npc-1", + npcBId: "npc-2", + dayIndex: 3, + absoluteGameMinute: 1440 * 3 + 100, + }; + const first = await enqueueNpcMutualChatJob(input); + expect(first).toBeTruthy(); + expect(getMockNpcMutualChatJob(first!)?.npcAId).toBe("npc-1"); + + const again = await enqueueNpcMutualChatJob(input); + expect(again).toBeNull(); + + expect(await claimNpcMutualChatJobId(first!)).toBe(false); + }); +}); diff --git a/apps/game-server/src/world/npc-mutual-chat.ts b/apps/game-server/src/world/npc-mutual-chat.ts new file mode 100644 index 0000000..04a474d --- /dev/null +++ b/apps/game-server/src/world/npc-mutual-chat.ts @@ -0,0 +1,278 @@ +/** + * NPC mutual-chat proximity selector + daily stagger (D-MUTUAL-01/03/05/07). + * Enqueues only — no LLM in tick. Visible chat owned by mutual-chat budget. + */ + +import { + COUNCIL_NPC_IDS, + MINUTES_PER_DAY, + isCouncilNpcId, + normalizeEdgeIds, + stableStringHash, +} from "@aetherlife/shared"; +import { + resolveScheduleSegment, + shouldSkipMovement, +} from "../ambient/schedule.js"; +import { enqueueNpcMutualChatJob } from "../queue/npc-mutual-chat.js"; +import { getRoomVoteState } from "./world-vote-state.js"; + +/** Max mutual-chat pair triggers per room per SSOT game day (target 2–3). */ +export const MUTUAL_CHAT_MAX_PER_DAY = 3; +const MUTUAL_CHEBYSHEV_MAX = 2; +/** Default selection rate when not force-selecting (~12%). */ +const MUTUAL_SELECT_PCT = 12; + +export type MutualChatNpcPos = { + id: string; + x: number; + y: number; +}; + +export type MutualChatCandidate = { + npcAId: string; + npcBId: string; + weight: number; +}; + +const pairClaims = new Set(); +const countByRoomDay = new Map(); + +function dayIndexFromAbsoluteMinute(absoluteGameMinute: number): number { + return Math.floor(Math.max(0, absoluteGameMinute) / MINUTES_PER_DAY); +} + +function pairClaimKey(roomId: string, dayIndex: number, a: string, b: string): string { + const { npcAId, npcBId } = normalizeEdgeIds(a, b); + return `${roomId}:${dayIndex}:${npcAId}:${npcBId}`; +} + +function roomDayKey(roomId: string, dayIndex: number): string { + return `${roomId}:${dayIndex}`; +} + +function chebyshev(ax: number, ay: number, bx: number, by: number): number { + return Math.max(Math.abs(ax - bx), Math.abs(ay - by)); +} + +function seatIndex(npcId: string): number { + return (COUNCIL_NPC_IDS as readonly string[]).indexOf(npcId); +} + +/** + * 12-seat bucket rotation: pair is in today's focus when either seat's + * index % 4 matches dayIndex % 4 (spreads pair opportunities across days). + */ +export function pairInDailyBucket( + npcAId: string, + npcBId: string, + dayIndex: number, +): boolean { + const bucket = ((dayIndex % 4) + 4) % 4; + const ia = seatIndex(npcAId); + const ib = seatIndex(npcBId); + if (ia < 0 || ib < 0) return false; + return ia % 4 === bucket || ib % 4 === bucket; +} + +function scoreKey(a: string, b: string): string { + const { npcAId, npcBId } = normalizeEdgeIds(a, b); + return `${npcAId}:${npcBId}`; +} + +function zoneInVillageBand(zoneId: string | undefined): boolean { + if (!zoneId) return false; + return /village|plaza|square|market|beginning-fields/i.test(zoneId); +} + +/** Same defer rule as world-vote: any in-flight speak → skip enqueue. */ +export function shouldDeferNpcMutualChatEnqueue( + npcSpeakJobsSize: number, +): boolean { + return npcSpeakJobsSize > 0; +} + +export function clearNpcMutualChatState(): void { + pairClaims.clear(); + countByRoomDay.clear(); +} + +export function isPairClaimedForMutualChat( + roomId: string, + dayIndex: number, + a: string, + b: string, +): boolean { + return pairClaims.has(pairClaimKey(roomId, dayIndex, a, b)); +} + +function schedulesOverlapAndAvailable( + npcAId: string, + npcBId: string, + gameMinuteOfDay: number, + villageBandOnly: boolean, +): boolean { + const segA = resolveScheduleSegment(npcAId, gameMinuteOfDay); + const segB = resolveScheduleSegment(npcBId, gameMinuteOfDay); + if (!segA || !segB) return false; + if (shouldSkipMovement(segA) || shouldSkipMovement(segB)) return false; + if (villageBandOnly) { + if (!zoneInVillageBand(segA.zoneId) || !zoneInVillageBand(segB.zoneId)) { + return false; + } + } + return true; +} + +/** + * Select nearby council pairs and enqueue up to MUTUAL_CHAT_MAX_PER_DAY. + * Relationship scores weight sort only — never hard-veto (nemesis eligible). + */ +export async function maybeEnqueueNpcMutualChat(input: { + roomId: string; + npcs: readonly MutualChatNpcPos[]; + absoluteGameMinute?: number; + /** Minute-of-day for schedule filter (defaults to abs % MINUTES_PER_DAY). */ + gameMinuteOfDay?: number; + busyNpcIds?: ReadonlySet | ReadonlyMap; + npcSpeakInFlight?: boolean; + /** Optional affection/trust-style scores keyed `npcA:npcB` (normalized). Weight only. */ + relationshipScores?: ReadonlyMap; + /** Bypass hash selectPct — enqueue all eligible under daily cap (tests). */ + forceSelectAllEligible?: boolean; + /** Default true: both schedules must be in village-band zones. */ + villageBandOnly?: boolean; + /** Build candidates only; do not claim/enqueue. */ + dryRun?: boolean; +}): Promise<{ + enqueued: string[]; + deferred: boolean; + dayIndex: number; + candidates: MutualChatCandidate[]; +}> { + if (input.npcSpeakInFlight) { + return { enqueued: [], deferred: true, dayIndex: -1, candidates: [] }; + } + + const abs = + input.absoluteGameMinute ?? getRoomVoteState(input.roomId).absoluteGameMinute; + const dayIndex = dayIndexFromAbsoluteMinute(abs); + if (dayIndex <= 0) { + return { enqueued: [], deferred: false, dayIndex, candidates: [] }; + } + + const gameMinuteOfDay = + input.gameMinuteOfDay ?? Math.floor(Math.max(0, abs) % MINUTES_PER_DAY); + const villageBandOnly = input.villageBandOnly !== false; + const busy = input.busyNpcIds; + const isBusy = (id: string): boolean => Boolean(busy?.has(id)); + + const council = input.npcs.filter( + (n) => isCouncilNpcId(n.id) && !isBusy(n.id), + ); + + const rdKey = roomDayKey(input.roomId, dayIndex); + let remaining = MUTUAL_CHAT_MAX_PER_DAY - (countByRoomDay.get(rdKey) ?? 0); + if (remaining <= 0 && !input.dryRun) { + return { enqueued: [], deferred: false, dayIndex, candidates: [] }; + } + + type Pair = { + a: MutualChatNpcPos; + b: MutualChatNpcPos; + weight: number; + }; + const pairs: Pair[] = []; + + for (let i = 0; i < council.length; i++) { + for (let j = i + 1; j < council.length; j++) { + const a = council[i]!; + const b = council[j]!; + if (chebyshev(a.x, a.y, b.x, b.y) > MUTUAL_CHEBYSHEV_MAX) continue; + // forceSelectAllEligible skips bucket so tests can assert filter/weight without rotation. + if ( + !input.forceSelectAllEligible && + !pairInDailyBucket(a.id, b.id, dayIndex) + ) { + continue; + } + if ( + !schedulesOverlapAndAvailable( + a.id, + b.id, + gameMinuteOfDay, + villageBandOnly, + ) + ) { + continue; + } + const { npcAId, npcBId } = normalizeEdgeIds(a.id, b.id); + const claim = pairClaimKey(input.roomId, dayIndex, a.id, b.id); + if (pairClaims.has(claim)) continue; + + if (!input.forceSelectAllEligible) { + const roll = + stableStringHash( + `mutual-chat:${input.roomId}:${dayIndex}:${npcAId}:${npcBId}`, + ) % 100; + if (roll >= MUTUAL_SELECT_PCT) continue; + } + + const rel = + input.relationshipScores?.get(scoreKey(a.id, b.id)) ?? + input.relationshipScores?.get(`${a.id}:${b.id}`) ?? + 0; + // Higher affection → higher priority; negative (nemesis) still eligible, just lower weight. + const weight = rel + (stableStringHash(`${npcAId}:${npcBId}:${dayIndex}`) % 7); + pairs.push({ a, b, weight }); + } + } + + pairs.sort((x, y) => y.weight - x.weight); + + const candidates: MutualChatCandidate[] = pairs.map((p) => { + const { npcAId, npcBId } = normalizeEdgeIds(p.a.id, p.b.id); + return { npcAId, npcBId, weight: p.weight }; + }); + + if (input.dryRun) { + return { enqueued: [], deferred: false, dayIndex, candidates }; + } + + // Claim the whole batch synchronously before any await so a same-tick + // ambient dyad scan (even mid-enqueue) sees full supersession. + const batch: Array<{ a: MutualChatNpcPos; b: MutualChatNpcPos; claim: string }> = []; + for (const { a, b } of pairs) { + if (batch.length >= remaining) break; + const claim = pairClaimKey(input.roomId, dayIndex, a.id, b.id); + if (pairClaims.has(claim)) continue; + pairClaims.add(claim); + batch.push({ a, b, claim }); + } + + const enqueued: string[] = []; + for (const { a, b, claim } of batch) { + let jobId: string | null = null; + try { + jobId = await enqueueNpcMutualChatJob({ + roomId: input.roomId, + npcAId: a.id, + npcBId: b.id, + dayIndex, + absoluteGameMinute: abs, + }); + } catch (err) { + pairClaims.delete(claim); + throw err; + } + if (!jobId) { + pairClaims.delete(claim); + continue; + } + + enqueued.push(jobId); + countByRoomDay.set(rdKey, (countByRoomDay.get(rdKey) ?? 0) + 1); + } + + return { enqueued, deferred: false, dayIndex, candidates }; +} diff --git a/apps/game-server/src/world/npc-relationship-decay.test.ts b/apps/game-server/src/world/npc-relationship-decay.test.ts new file mode 100644 index 0000000..4c77277 --- /dev/null +++ b/apps/game-server/src/world/npc-relationship-decay.test.ts @@ -0,0 +1,236 @@ +/** + * Silent idle relationship decay (D-DECAY-01…04, D-VERIFY-02). + */ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + applyRelationshipDeltas, + clearNpcRelationshipsMemory, + getRelationshipEdge, + insertRelationshipEdge, +} from "./npc-relationships-repository.js"; +import { + GAME_MONTH_MINUTES, + clearRelationshipDecayState, + computeIdleDecayDelta, + maybeRunRelationshipDecay, + softBoundsForBaseTag, +} from "./npc-relationship-decay.js"; + +const ROOM = "room-decay-28-04"; + +describe("npc-relationship-decay", () => { + beforeEach(async () => { + delete process.env.DATABASE_URL; + clearNpcRelationshipsMemory(); + await clearRelationshipDecayState(); + }); + + it("D-DECAY-01: idle decay applies deltas with no UI hint / biography / toast", async () => { + await insertRelationshipEdge({ + roomId: ROOM, + npcAId: "npc-1", + npcBId: "npc-2", + baseTag: "ally", + affection: 55, + trust: 80, + historySummary: "seed", + }); + + const result = await maybeRunRelationshipDecay(ROOM, GAME_MONTH_MINUTES + 1); + expect(result.decayed).toBeGreaterThanOrEqual(1); + + const edge = await getRelationshipEdge(ROOM, "npc-1", "npc-2"); + expect(edge!.historySummary).toBe("seed"); + expect(edge!.affection).toBeLessThan(55); + expect(edge!.affection).toBeGreaterThanOrEqual(softBoundsForBaseTag("ally").floor); + // Silent: no relationshipSync / biography side effects from this module. + expect(result.broadcast).toBe(false); + expect(result.biographyEnqueued).toBe(false); + }); + + it("D-DECAY-02/03: monthly tick drifts idle edges toward 0 with |Δ| 1–3 and base_tag soft floor/ceiling", async () => { + await insertRelationshipEdge({ + roomId: ROOM, + npcAId: "npc-3", + npcBId: "npc-4", + baseTag: "ally", + affection: 55, + trust: 70, + }); + + const before = await getRelationshipEdge(ROOM, "npc-3", "npc-4"); + await maybeRunRelationshipDecay(ROOM, GAME_MONTH_MINUTES + 5); + const after = await getRelationshipEdge(ROOM, "npc-3", "npc-4"); + + const absDelta = Math.abs(after!.affection - before!.affection); + expect(absDelta).toBeGreaterThanOrEqual(1); + expect(absDelta).toBeLessThanOrEqual(3); + expect(after!.affection).toBeLessThan(before!.affection); + expect(after!.affection).toBeGreaterThanOrEqual(softBoundsForBaseTag("ally").floor); + + // Pure step helper also respects soft bounds + step magnitude. + const step = computeIdleDecayDelta(55, "ally", () => 0); // step size 1 + expect(Math.abs(step)).toBeGreaterThanOrEqual(1); + expect(Math.abs(step)).toBeLessThanOrEqual(3); + }); + + it("positive affection already below band floor keeps drifting toward 0 (never snaps up)", () => { + // ally floor=40; affection 30 must decay to 29, not jump to 40. + expect(computeIdleDecayDelta(30, "ally", () => 0)).toBe(-1); + // At/above floor the floor still holds: 41 with step 3 stops at 40. + expect(computeIdleDecayDelta(41, "ally", () => 0.99)).toBe(-1); + }); + + it("D-DECAY-03: decay does not bump last_interact_at (only real interact sources do)", async () => { + await insertRelationshipEdge({ + roomId: ROOM, + npcAId: "npc-5", + npcBId: "npc-6", + baseTag: "rival", + affection: -55, + trust: 10, + }); + // Real interact stamps last_interact_at + game-minute. + await applyRelationshipDeltas({ + roomId: ROOM, + absoluteGameMinute: 10, + deltas: [{ npcAId: "npc-5", npcBId: "npc-6", affectionDelta: -1 }], + }); + const afterInteract = await getRelationshipEdge(ROOM, "npc-5", "npc-6"); + const lastInteract = afterInteract!.lastInteractAt; + const interactionCount = afterInteract!.interactionCount; + expect(lastInteract).not.toBeNull(); + expect(interactionCount).toBe(1); + + // Idle after one game-month from that interact. + await maybeRunRelationshipDecay(ROOM, 10 + GAME_MONTH_MINUTES + 1); + const afterDecay = await getRelationshipEdge(ROOM, "npc-5", "npc-6"); + expect(afterDecay!.lastInteractAt).toBe(lastInteract); + expect(afterDecay!.interactionCount).toBe(interactionCount); + expect(afterDecay!.affection).toBeGreaterThan(afterInteract!.affection); // toward 0 + + // Second monthly pass still finds the edge idle (no timestamp refresh). + await maybeRunRelationshipDecay(ROOM, 10 + 2 * GAME_MONTH_MINUTES + 1); + const afterSecond = await getRelationshipEdge(ROOM, "npc-5", "npc-6"); + expect(afterSecond!.lastInteractAt).toBe(lastInteract); + expect(afterSecond!.interactionCount).toBe(interactionCount); + expect(afterSecond!.affection).toBeGreaterThan(afterDecay!.affection); + }); + + it("D-DECAY-04: decay runs independent of council in-flight; council counts as recent activity", async () => { + await insertRelationshipEdge({ + roomId: ROOM, + npcAId: "npc-7", + npcBId: "npc-8", + baseTag: "peer", + affection: 25, + trust: 60, + }); + await insertRelationshipEdge({ + roomId: ROOM, + npcAId: "npc-9", + npcBId: "npc-10", + baseTag: "nemesis", + affection: -55, + trust: 5, + }); + + // Recent council delta on first edge — should skip decay. + await applyRelationshipDeltas({ + roomId: ROOM, + absoluteGameMinute: GAME_MONTH_MINUTES + 100, + deltas: [{ npcAId: "npc-7", npcBId: "npc-8", affectionDelta: 2 }], + }); + const recentBefore = await getRelationshipEdge(ROOM, "npc-7", "npc-8"); + + // Idle second edge (never interacted since seed at 0). + const idleBefore = await getRelationshipEdge(ROOM, "npc-9", "npc-10"); + + const result = await maybeRunRelationshipDecay( + ROOM, + GAME_MONTH_MINUTES + 100, + { councilInFlight: true }, + ); + expect(result.skippedForCouncil).toBe(false); + + const recentAfter = await getRelationshipEdge(ROOM, "npc-7", "npc-8"); + expect(recentAfter!.affection).toBe(recentBefore!.affection); + + const idleAfter = await getRelationshipEdge(ROOM, "npc-9", "npc-10"); + expect(idleAfter!.affection).toBeGreaterThan(idleBefore!.affection); + }); + + it("decay path source does not call applyDeltaToRow", () => { + const here = dirname(fileURLToPath(import.meta.url)); + const src = readFileSync(join(here, "npc-relationship-decay.ts"), "utf8"); + const repo = readFileSync(join(here, "npc-relationships-repository.ts"), "utf8"); + expect(src).not.toMatch(/applyDeltaToRow/); + expect(src).toMatch(/applyIdleDecayDeltas/); + expect(repo).toMatch(/applyIdleDecayDeltas/); + // Idle path must not reuse the interact bump helper — bound the slice to the next export. + const idleStart = repo.indexOf("export async function applyIdleDecayDeltas"); + const idleEnd = repo.indexOf("export function clearNpcRelationshipsMemory", idleStart); + expect(idleStart).toBeGreaterThanOrEqual(0); + expect(idleEnd).toBeGreaterThan(idleStart); + const idleFn = repo.slice(idleStart, idleEnd); + expect(idleFn).not.toMatch(/applyDeltaToRow\(/); + }); + + it("GameRoom sequences mutual-chat before ambient dyad enqueue", () => { + const here = dirname(fileURLToPath(import.meta.url)); + const room = readFileSync(join(here, "../colyseus/GameRoom.ts"), "utf8"); + const tickStart = room.indexOf("private onAmbientTick"); + const tickEnd = room.indexOf("private runRelationshipDecayIfDue"); + const tick = room.slice(tickStart, tickEnd); + expect(tick).toMatch(/enqueueNpcMutualChatIfDue\(\)[\s\S]*\.then\(/); + expect(tick.indexOf("enqueueNpcMutualChatIfDue")).toBeLessThan( + tick.indexOf("enqueuePersonalTimelineDyadAmbientIfDue"), + ); + }); + + it("GameRoom ambient tick wires maybeRunRelationshipDecay without LLM", () => { + const here = dirname(fileURLToPath(import.meta.url)); + const room = readFileSync(join(here, "../colyseus/GameRoom.ts"), "utf8"); + expect(room).toMatch(/maybeRunRelationshipDecay/); + expect(room).toMatch(/runRelationshipDecayIfDue/); + // Decay call site must not gate on npcSpeakJobs (D-DECAY-04). + const method = room.slice(room.indexOf("private runRelationshipDecayIfDue")); + const body = method.slice(0, method.indexOf("private enqueueWorldVoteIfDue")); + expect(body).not.toMatch(/npcSpeakJobs/); + }); + + it("restart hydrate: SQL last_interact_at prevents idle misclassify at abs=0", async () => { + const { + hydrateInteractAbsFromEdges, + getLastInteractAbsMinute, + } = await import("./npc-relationships-repository.js"); + + const nowAbs = GAME_MONTH_MINUTES + 50; + const hydrated = hydrateInteractAbsFromEdges(ROOM, nowAbs, [ + { + npcAId: "npc-11", + npcBId: "npc-12", + lastInteractAt: new Date(), + }, + ]); + expect(hydrated).toBe(1); + expect(getLastInteractAbsMinute(ROOM, "npc-11", "npc-12")).toBe(nowAbs); + + await insertRelationshipEdge({ + roomId: ROOM, + npcAId: "npc-11", + npcBId: "npc-12", + baseTag: "ally", + affection: 55, + trust: 80, + }); + + await clearRelationshipDecayState(); + const result = await maybeRunRelationshipDecay(ROOM, nowAbs); + // Hydrated as "just interacted" at nowAbs → not idle → no decay this month. + expect(result.decayed).toBe(0); + }); +}); diff --git a/apps/game-server/src/world/npc-relationship-decay.ts b/apps/game-server/src/world/npc-relationship-decay.ts new file mode 100644 index 0000000..35b1da3 --- /dev/null +++ b/apps/game-server/src/world/npc-relationship-decay.ts @@ -0,0 +1,270 @@ +/** + * Silent idle-edge affection decay (D-DECAY-01…04). + * + * Soft floor/ceiling (RELATIONSHIP-DYNAMICS seed bands): decay drifts toward 0 + * but stops at the band edge so base_tag identity is not erased by idle alone. + * |Δ| per monthly pass is 1–3. Uses applyIdleDecayDeltas only (never the interact-bump path). + */ + +import { MINUTES_PER_DAY, DAYS_PER_MONTH, clampAffection } from "@aetherlife/shared"; +import { Redis } from "ioredis"; +import { + applyIdleDecayDeltas, + listRelationshipsForRoom, + getLastInteractAbsMinute, + getSeedAbsMinute, + hydrateInteractAbsFromEdges, + hydrateSeedAbsFromEdges, + type IdleDecayDelta, +} from "./npc-relationships-repository.js"; + +/** One game-month in absoluteGameMinute units (30 × 1440). */ +export const GAME_MONTH_MINUTES = DAYS_PER_MONTH * MINUTES_PER_DAY; + +/** Last monthIndex for which decay ran (per room). */ +const lastDecayMonthByRoom = new Map(); + +const DECAY_MONTH_KEY_PREFIX = "aetherlife:rel-decay-month:"; +let decayRedis: Redis | null | undefined; + +function decayMonthRedisKey(roomId: string): string { + return `${DECAY_MONTH_KEY_PREFIX}${roomId}`; +} + +function getDecayRedis(): Redis | null { + if (decayRedis !== undefined) return decayRedis; + const url = process.env.REDIS_URL; + if (!url) { + decayRedis = null; + return null; + } + decayRedis = new Redis(url, { maxRetriesPerRequest: null }); + decayRedis.on("error", (err) => { + console.error("[redis] relationship-decay", err.message); + }); + return decayRedis; +} + +async function loadLastDecayMonth(roomId: string): Promise { + if (lastDecayMonthByRoom.has(roomId)) { + return lastDecayMonthByRoom.get(roomId); + } + const redis = getDecayRedis(); + if (!redis) return undefined; + try { + const raw = await redis.get(decayMonthRedisKey(roomId)); + if (raw == null || raw === "") return undefined; + const n = Number.parseInt(raw, 10); + if (!Number.isFinite(n)) return undefined; + lastDecayMonthByRoom.set(roomId, n); + return n; + } catch (err) { + console.error("[relationship-decay] load last month failed", err); + return undefined; + } +} + +async function saveLastDecayMonth(roomId: string, monthIndex: number): Promise { + lastDecayMonthByRoom.set(roomId, monthIndex); + const redis = getDecayRedis(); + if (!redis) return; + try { + await redis.set(decayMonthRedisKey(roomId), String(monthIndex)); + } catch (err) { + console.error("[relationship-decay] save last month failed", err); + } +} + +export type SoftBounds = { floor: number; ceiling: number }; + +/** + * Soft bounds from RELATIONSHIP-DYNAMICS kind→seed bands. + * Positive bands: floor = seed min. Negative bands: ceiling = seed max (least cold). + */ +export function softBoundsForBaseTag(baseTag: string): SoftBounds { + const k = baseTag.toLowerCase(); + if (k === "nemesis" || k === "rival") return { floor: -100, ceiling: -40 }; + if ( + k === "ally" || + k === "close_ally" || + k === "strategic_ally" || + k === "chaos_ally" || + k === "chaotic_ally" || + k === "chaos_buddy" + ) { + return { floor: 40, ceiling: 100 }; + } + if (k.startsWith("respect") || k === "peer" || k === "appreciate" || k === "grateful") { + return { floor: 15, ceiling: 100 }; + } + if ( + k.startsWith("wary") || + k.startsWith("cautious") || + k === "suspicious" || + k === "avoid" || + k === "watch" || + k === "distant" + ) { + return { floor: -100, ceiling: 0 }; + } + if ( + k === "deal" || + k === "chess" || + k === "frenemy" || + k === "mixed" || + k === "trade" || + k === "opportunistic" + ) { + return { floor: -5, ceiling: 10 }; + } + if ( + k === "disdain" || + k === "opposes" || + k === "clash" || + k === "friction" || + k === "conflict_caution" || + k === "conflict_respect" + ) { + return { floor: -100, ceiling: -15 }; + } + if (k === "gentle_conflict" || k === "gentle_oppose") return { floor: -100, ceiling: 0 }; + if (k === "support" || k === "cooperate" || k === "partner") return { floor: 35, ceiling: 100 }; + return { floor: -100, ceiling: 100 }; +} + +export function monthIndexFromAbsoluteMinute(absoluteGameMinute: number): number { + return Math.floor(Math.max(0, absoluteGameMinute) / GAME_MONTH_MINUTES); +} + +/** Test helper */ +export async function clearRelationshipDecayState(): Promise { + lastDecayMonthByRoom.clear(); + const redis = getDecayRedis(); + if (!redis) return; + try { + const keys = await redis.keys(`${DECAY_MONTH_KEY_PREFIX}*`); + if (keys.length > 0) { + await redis.del(...keys); + } + } catch (err) { + console.error("[relationship-decay] clear redis month keys failed", err); + } +} + +/** + * Compute affection delta toward 0 for one idle monthly step. + * Magnitude 1–3; clamped by soft bounds. rng() → [0,1) selects step size. + */ +export function computeIdleDecayDelta( + affection: number, + baseTag: string, + rng: () => number = Math.random, +): number { + if (affection === 0) return 0; + const step = 1 + Math.floor(Math.min(0.999999, Math.max(0, rng())) * 3); + const { floor, ceiling } = softBoundsForBaseTag(baseTag); + + if (affection > 0) { + const candidate = affection - step; + let next = Math.max(0, candidate); + // Floor only holds when affection is still at/above the band floor; + // below-floor values keep drifting toward 0 (never snap upward). + if (affection >= floor) { + next = Math.max(floor, next); + } + return clampAffection(next) - affection; + } + + // affection < 0 — drift toward 0 + const candidate = affection + step; + let next = Math.min(0, candidate); + if (affection <= ceiling) { + next = Math.min(ceiling, next); + } + return clampAffection(next) - affection; +} + +export function isIdleEdge( + roomId: string, + npcAId: string, + npcBId: string, + absoluteGameMinute: number, +): boolean { + const last = + getLastInteractAbsMinute(roomId, npcAId, npcBId) ?? + getSeedAbsMinute(roomId, npcAId, npcBId) ?? + 0; + return absoluteGameMinute - last >= GAME_MONTH_MINUTES; +} + +export type MaybeRunRelationshipDecayOptions = { + /** Ignored — D-DECAY-04: decay is not blocked by council in-flight. */ + councilInFlight?: boolean; + rng?: () => number; +}; + +export type MaybeRunRelationshipDecayResult = { + decayed: number; + monthIndex: number; + broadcast: false; + biographyEnqueued: false; + skippedForCouncil: false; +}; + +/** + * On game-month rollover, apply silent idle decay for stale edges. + * No LLM, no relationshipSync, no biography. + */ +export async function maybeRunRelationshipDecay( + roomId: string, + absoluteGameMinute: number, + options?: MaybeRunRelationshipDecayOptions, +): Promise { + void options?.councilInFlight; // D-DECAY-04: intentionally unused + const monthIndex = monthIndexFromAbsoluteMinute(absoluteGameMinute); + const empty: MaybeRunRelationshipDecayResult = { + decayed: 0, + monthIndex, + broadcast: false, + biographyEnqueued: false, + skippedForCouncil: false, + }; + if (monthIndex <= 0) return empty; + + const prev = await loadLastDecayMonth(roomId); + if (prev === monthIndex) return empty; + + const edges = await listRelationshipsForRoom(roomId); + // Restart-safe: rehydrate process-local abs stamps from SQL last_interact_at / seed. + hydrateInteractAbsFromEdges(roomId, absoluteGameMinute, edges); + hydrateSeedAbsFromEdges(roomId, edges); + + await saveLastDecayMonth(roomId, monthIndex); + + const deltas: IdleDecayDelta[] = []; + const rng = options?.rng ?? Math.random; + + for (const edge of edges) { + if (!isIdleEdge(roomId, edge.npcAId, edge.npcBId, absoluteGameMinute)) continue; + const affectionDelta = computeIdleDecayDelta(edge.affection, edge.baseTag, rng); + if (affectionDelta === 0) continue; + deltas.push({ + npcAId: edge.npcAId, + npcBId: edge.npcBId, + affectionDelta, + }); + } + + if (deltas.length === 0) { + return empty; + } + + const { updated } = await applyIdleDecayDeltas({ roomId, deltas }); + return { + decayed: updated, + monthIndex, + broadcast: false, + biographyEnqueued: false, + skippedForCouncil: false, + }; +} diff --git a/apps/game-server/src/world/npc-relationships-embed.test.ts b/apps/game-server/src/world/npc-relationships-embed.test.ts new file mode 100644 index 0000000..7e14212 --- /dev/null +++ b/apps/game-server/src/world/npc-relationships-embed.test.ts @@ -0,0 +1,190 @@ +/** + * Phase 28 plan 09 — async relationship edge embeddings (D-EMBED-02/03). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EMBED_DIMENSIONS } from "@aetherlife/npc-memory"; +import { embedText } from "../memory/embed.js"; +import { + applyIdleDecayDeltas, + applyRelationshipDeltas, + buildRelationshipEmbedText, + clearNpcRelationshipsMemory, + getRelationshipEdgeEmbedding, + insertRelationshipEdge, + searchSimilarEdges, + updateEmbeddingForEdge, +} from "./npc-relationships-repository.js"; + +describe("relationship edge embed text (D-EMBED-02)", () => { + it("joins history_summary + current_status without affection/band tokens", () => { + const text = buildRelationshipEmbedText({ + historySummary: "昔日同盟,共守边境裂隙。", + currentStatus: ["疏远", "互不往来"], + affection: -40, + band: "hostile", + }); + expect(text).toContain("昔日同盟"); + expect(text).toContain("疏远"); + expect(text).toContain("互不往来"); + expect(text).not.toMatch(/-40/); + expect(text).not.toContain("hostile"); + expect(text).not.toMatch(/\baffection\b/i); + }); +}); + +describe("relationship edge embedding write (D-EMBED-03)", () => { + beforeEach(() => { + delete process.env.DATABASE_URL; + process.env.VITEST = "true"; + clearNpcRelationshipsMemory(); + }); + + afterEach(() => { + clearNpcRelationshipsMemory(); + }); + + it("updateEmbeddingForEdge stores mock embedText vector (2048 dims)", async () => { + await insertRelationshipEdge({ + roomId: "room-embed", + npcAId: "npc-1", + npcBId: "npc-2", + baseTag: "ally", + affection: 60, + trust: 70, + historySummary: "并肩作战多年。", + }); + + const text = buildRelationshipEmbedText({ + historySummary: "并肩作战多年。", + currentStatus: ["亲近"], + }); + const embedding = await embedText(text); + expect(embedding).toHaveLength(EMBED_DIMENSIONS); + + await updateEmbeddingForEdge("room-embed", "npc-1", "npc-2", embedding); + const stored = await getRelationshipEdgeEmbedding("room-embed", "npc-1", "npc-2"); + expect(stored).not.toBeNull(); + expect(stored!).toHaveLength(EMBED_DIMENSIONS); + expect(stored![0]).toBeCloseTo(embedding[0]!, 5); + }); + + it("applyRelationshipDeltas schedules async embed and updates vector (non-decay)", async () => { + await insertRelationshipEdge({ + roomId: "room-async", + npcAId: "npc-3", + npcBId: "npc-4", + baseTag: "rival", + affection: -20, + trust: 40, + historySummary: "旧怨未消。", + }); + + await applyRelationshipDeltas({ + roomId: "room-async", + deltas: [ + { + npcAId: "npc-3", + npcBId: "npc-4", + affectionDelta: -5, + historyAppend: "又起争执。", + statusTags: ["交恶"], + }, + ], + }); + + // Fire-and-forget: flush microtasks / short await + await vi.waitFor( + async () => { + const stored = await getRelationshipEdgeEmbedding("room-async", "npc-3", "npc-4"); + expect(stored).not.toBeNull(); + expect(stored!).toHaveLength(EMBED_DIMENSIONS); + }, + { timeout: 2000, interval: 20 }, + ); + }); + + it("applyIdleDecayDeltas does not embed", async () => { + await insertRelationshipEdge({ + roomId: "room-decay", + npcAId: "npc-5", + npcBId: "npc-6", + baseTag: "peer", + affection: 30, + trust: 50, + historySummary: "同窗旧友。", + }); + + await applyIdleDecayDeltas({ + roomId: "room-decay", + deltas: [{ npcAId: "npc-5", npcBId: "npc-6", affectionDelta: -2 }], + }); + + await new Promise((r) => setTimeout(r, 50)); + const stored = await getRelationshipEdgeEmbedding("room-decay", "npc-5", "npc-6"); + expect(stored).toBeNull(); + }); + + it("searchSimilarEdges ranks by cosine and filters activeNpcId", async () => { + await insertRelationshipEdge({ + roomId: "room-sim", + npcAId: "npc-1", + npcBId: "npc-2", + baseTag: "ally", + affection: 50, + trust: 60, + historySummary: "共守封印裂隙,边境防务同盟。", + }); + await insertRelationshipEdge({ + roomId: "room-sim", + npcAId: "npc-1", + npcBId: "npc-3", + baseTag: "peer", + affection: 10, + trust: 50, + historySummary: "偶尔闲聊天气。", + }); + await insertRelationshipEdge({ + roomId: "room-sim", + npcAId: "npc-4", + npcBId: "npc-5", + baseTag: "peer", + affection: 0, + trust: 50, + historySummary: "无关边。", + }); + + const targetText = "共守封印裂隙,边境防务同盟。"; + const targetVec = await embedText(targetText); + await updateEmbeddingForEdge( + "room-sim", + "npc-1", + "npc-2", + targetVec, + ); + await updateEmbeddingForEdge( + "room-sim", + "npc-1", + "npc-3", + await embedText("偶尔闲聊天气。"), + ); + await updateEmbeddingForEdge( + "room-sim", + "npc-4", + "npc-5", + await embedText("无关边。"), + ); + + const hits = await searchSimilarEdges({ + roomId: "room-sim", + queryEmbedding: targetVec, + activeNpcId: "npc-1", + k: 5, + }); + expect(hits).toHaveLength(2); + expect(hits[0]!.historySummary).toBe(targetText); + expect(hits[0]!.score).toBeGreaterThanOrEqual(hits[1]!.score); + for (const hit of hits) { + expect(hit.npcAId === "npc-1" || hit.npcBId === "npc-1").toBe(true); + } + }); +}); diff --git a/apps/game-server/src/world/npc-relationships-repository.ts b/apps/game-server/src/world/npc-relationships-repository.ts index b9b9cb2..cc6a6c1 100644 --- a/apps/game-server/src/world/npc-relationships-repository.ts +++ b/apps/game-server/src/world/npc-relationships-repository.ts @@ -10,8 +10,9 @@ import { type RelationshipDeltaInput, type RelationshipEdgePublic, } from "@aetherlife/shared"; -import { getSharedSql } from "@aetherlife/npc-memory"; +import { EMBED_DIMENSIONS, getSharedSql } from "@aetherlife/npc-memory"; import { randomUUID } from "node:crypto"; +import { embedText } from "../memory/embed.js"; export type ListRelationshipsOptions = { npcId?: string; @@ -23,6 +24,19 @@ export type ApplyRelationshipDeltasInput = { roomId: string; deltas: RelationshipDeltaInput[]; voteEpoch?: string; + /** Game clock stamp for idle-decay idle windows (D-DECAY-03). */ + absoluteGameMinute?: number; +}; + +export type IdleDecayDelta = { + npcAId: string; + npcBId: string; + affectionDelta: number; +}; + +export type ApplyIdleDecayDeltasInput = { + roomId: string; + deltas: IdleDecayDelta[]; }; export type ApplyRelationshipDeltasResult = { @@ -41,6 +55,7 @@ type RelationshipRow = { lastInteractAt: Date | null; currentStatus: string[]; historySummary: string; + embedding: number[] | null; updatedAt: Date; }; @@ -56,12 +71,160 @@ type DbRow = { last_interact_at: Date | string | null; current_status: unknown; history_summary: string; + embedding?: number[] | string | null; updated_at: Date | string; }; +export type SimilarRelationshipEdge = { + npcAId: string; + npcBId: string; + historySummary: string; + currentStatus: string[]; + score: number; +}; + +export type SearchSimilarEdgesInput = { + roomId: string; + queryEmbedding: number[]; + activeNpcId?: string; + k?: number; +}; + +/** D-EMBED-02: vector text = history + status; never affection ints / band id. */ +export function buildRelationshipEmbedText(input: { + historySummary: string; + currentStatus: string[]; + /** Ignored — accepted so callers cannot accidentally concatenate via spread. */ + affection?: number; + band?: string; +}): string { + const history = (input.historySummary ?? "").trim(); + const status = (input.currentStatus ?? []) + .filter((t): t is string => typeof t === "string" && t.trim().length > 0) + .map((t) => t.trim()) + .join(" "); + return [history, status].filter((part) => part.length > 0).join("\n"); +} + +function parseEmbedding(raw: unknown): number[] | null { + if (raw == null) return null; + if (Array.isArray(raw)) { + const nums = raw.filter((v): v is number => typeof v === "number"); + return nums.length === EMBED_DIMENSIONS ? nums : null; + } + if (typeof raw === "string") { + const trimmed = raw.trim().replace(/^\[/, "").replace(/\]$/, ""); + if (!trimmed) return null; + const nums = trimmed.split(",").map((p) => Number(p.trim())); + if (nums.length !== EMBED_DIMENSIONS || nums.some((n) => !Number.isFinite(n))) { + return null; + } + return nums; + } + return null; +} + +function cosineSimilarity(a: number[], b: number[]): number { + let dot = 0; + let na = 0; + let nb = 0; + const n = Math.min(a.length, b.length); + for (let i = 0; i < n; i += 1) { + const av = a[i] ?? 0; + const bv = b[i] ?? 0; + dot += av * bv; + na += av * av; + nb += bv * bv; + } + const denom = Math.sqrt(na) * Math.sqrt(nb); + return denom > 0 ? dot / denom : 0; +} + const memoryByRoom = new Map(); +/** Absolute game-minute of last real interact (not decay). Key: roomId:npcA:npcB */ +const lastInteractAbsByEdge = new Map(); +/** Absolute game-minute when edge was seeded (for never-interacted idle). */ +const seedAbsByEdge = new Map(); let sqlClient: ReturnType | null = null; +function edgeStampKey(roomId: string, npcAId: string, npcBId: string): string { + const n = normalizeEdgeIds(npcAId, npcBId); + return `${roomId}:${n.npcAId}:${n.npcBId}`; +} + +export function getLastInteractAbsMinute( + roomId: string, + npcAId: string, + npcBId: string, +): number | undefined { + return lastInteractAbsByEdge.get(edgeStampKey(roomId, npcAId, npcBId)); +} + +export function getSeedAbsMinute( + roomId: string, + npcAId: string, + npcBId: string, +): number | undefined { + return seedAbsByEdge.get(edgeStampKey(roomId, npcAId, npcBId)); +} + +function noteSeedAbs(roomId: string, npcAId: string, npcBId: string, abs: number): void { + const key = edgeStampKey(roomId, npcAId, npcBId); + if (!seedAbsByEdge.has(key)) seedAbsByEdge.set(key, abs); +} + +function noteInteractAbs(roomId: string, npcAId: string, npcBId: string, abs: number): void { + lastInteractAbsByEdge.set(edgeStampKey(roomId, npcAId, npcBId), abs); +} + +/** + * After process restart, Maps are empty while SQL still has last_interact_at. + * Stamp current abs so recently-interacted edges are not treated as idle (abs=0). + * Returns how many edges were hydrated. + */ +export function hydrateInteractAbsFromEdges( + roomId: string, + absoluteGameMinute: number, + edges: ReadonlyArray<{ + npcAId: string; + npcBId: string; + lastInteractAt: Date | null; + }>, +): number { + let hydrated = 0; + for (const edge of edges) { + if (!edge.lastInteractAt) continue; + if (getLastInteractAbsMinute(roomId, edge.npcAId, edge.npcBId) !== undefined) { + continue; + } + noteInteractAbs(roomId, edge.npcAId, edge.npcBId, absoluteGameMinute); + hydrated += 1; + } + return hydrated; +} + +/** + * Restore seed stamps for never-interacted edges after restart (seed abs=0). + * Without this, isIdleEdge falls back to 0 anyway — explicit stamp keeps parity with insert path. + */ +export function hydrateSeedAbsFromEdges( + roomId: string, + edges: ReadonlyArray<{ + npcAId: string; + npcBId: string; + lastInteractAt: Date | null; + }>, +): number { + let hydrated = 0; + for (const edge of edges) { + if (edge.lastInteractAt) continue; + if (getSeedAbsMinute(roomId, edge.npcAId, edge.npcBId) !== undefined) continue; + noteSeedAbs(roomId, edge.npcAId, edge.npcBId, 0); + hydrated += 1; + } + return hydrated; +} + function getSql(): ReturnType | null { const url = process.env.DATABASE_URL; if (!url) return null; @@ -93,6 +256,7 @@ function rowFromDb(raw: DbRow): RelationshipRow { : null, currentStatus: parseStatusTags(raw.current_status), historySummary: raw.history_summary, + embedding: parseEmbedding(raw.embedding), updatedAt: raw.updated_at instanceof Date ? raw.updated_at : new Date(raw.updated_at), }; @@ -202,11 +366,13 @@ async function insertMemoryEdge(input: InsertRelationshipEdgeInput): Promise 0) { + noteSeedAbs(input.roomId, normalized.npcAId, normalized.npcBId, 0); return rowFromDb(rows[0]!); } @@ -251,6 +418,7 @@ async function insertSqlEdge(input: InsertRelationshipEdgeInput): Promise 0) { + noteSeedAbs(input.roomId, normalized.npcAId, normalized.npcBId, 0); return rowFromDb(existingRows[0]!); } throw new Error("insertSqlEdge: conflict without existing row"); @@ -366,6 +534,7 @@ async function applyDeltasMemory( input: ApplyRelationshipDeltasInput, ): Promise { const linkedEdges: LinkedEdge[] = []; + const abs = input.absoluteGameMinute ?? 0; for (const delta of input.deltas) { const normalized = normalizeEdgeIds(delta.npcAId, delta.npcBId); @@ -378,6 +547,7 @@ async function applyDeltasMemory( npcBId: normalized.npcBId, }); if (changed) { + noteInteractAbs(input.roomId, normalized.npcAId, normalized.npcBId, abs); linkedEdges.push({ npcAId: normalized.npcAId, npcBId: normalized.npcBId }); } } @@ -389,6 +559,7 @@ async function applyDeltasSql( input: ApplyRelationshipDeltasInput, ): Promise { const linkedEdges: LinkedEdge[] = []; + const abs = input.absoluteGameMinute ?? 0; for (const delta of input.deltas) { const normalized = normalizeEdgeIds(delta.npcAId, delta.npcBId); @@ -413,6 +584,8 @@ async function applyDeltasSql( }); if (!changed) continue; + noteInteractAbs(input.roomId, normalized.npcAId, normalized.npcBId, abs); + await sql` UPDATE npc_relationships SET @@ -437,12 +610,262 @@ export async function applyRelationshipDeltas( input: ApplyRelationshipDeltasInput, ): Promise { const sql = getSql(); - return sql ? applyDeltasSql(input) : applyDeltasMemory(input); + const result = sql ? await applyDeltasSql(input) : await applyDeltasMemory(input); + for (const edge of result.linkedEdges) { + scheduleRelationshipEdgeEmbed(input.roomId, edge.npcAId, edge.npcBId); + } + return result; +} + +/** + * Fire-and-forget edge embed (D-EMBED-03). Never awaited from Colyseus hot path. + * Force refresh after delta so history/status changes re-embed. + */ +export function scheduleRelationshipEdgeEmbed( + roomId: string, + npcAId: string, + npcBId: string, +): void { + void ensureRelationshipEdgeEmbedding(roomId, npcAId, npcBId, { force: true }).catch( + (err) => { + console.error( + `[npc-relationships] async embed failed room=${roomId} ${npcAId}/${npcBId}`, + err, + ); + }, + ); +} + +/** Embed if missing or force refresh after delta (lazy speak miss + delta write). */ +export async function ensureRelationshipEdgeEmbedding( + roomId: string, + npcAId: string, + npcBId: string, + options?: { force?: boolean }, +): Promise { + const normalized = normalizeEdgeIds(npcAId, npcBId); + const existing = await getRelationshipEdgeEmbedding( + roomId, + normalized.npcAId, + normalized.npcBId, + ); + if (existing && !options?.force) { + return false; + } + + const edge = await getRelationshipEdge(roomId, normalized.npcAId, normalized.npcBId); + if (!edge) return false; + + const text = buildRelationshipEmbedText({ + historySummary: edge.historySummary, + currentStatus: edge.currentStatus, + }); + if (!text.trim()) return false; + + const embedding = await embedText(text); + await updateEmbeddingForEdge(roomId, normalized.npcAId, normalized.npcBId, embedding); + return true; +} + +export async function updateEmbeddingForEdge( + roomId: string, + npcAId: string, + npcBId: string, + embedding: number[], +): Promise { + if (embedding.length !== EMBED_DIMENSIONS) { + throw new Error(`unexpected embed dimensions: ${embedding.length}`); + } + const normalized = normalizeEdgeIds(npcAId, npcBId); + const sql = getSql(); + if (sql) { + const vectorLiteral = `[${embedding.join(",")}]`; + await sql` + UPDATE npc_relationships + SET embedding = ${vectorLiteral}::vector + WHERE room_id = ${roomId} + AND npc_a_id = ${normalized.npcAId} + AND npc_b_id = ${normalized.npcBId} + `; + return; + } + + const row = findMemoryEdge(roomId, normalized.npcAId, normalized.npcBId); + if (row) { + row.embedding = [...embedding]; + } +} + +export async function getRelationshipEdgeEmbedding( + roomId: string, + npcAId: string, + npcBId: string, +): Promise { + const normalized = normalizeEdgeIds(npcAId, npcBId); + const sql = getSql(); + if (sql) { + const rows = await sql<{ embedding: unknown }[]>` + SELECT embedding + FROM npc_relationships + WHERE room_id = ${roomId} + AND npc_a_id = ${normalized.npcAId} + AND npc_b_id = ${normalized.npcBId} + LIMIT 1 + `; + if (rows.length === 0) return null; + return parseEmbedding(rows[0]!.embedding); + } + return findMemoryEdge(roomId, normalized.npcAId, normalized.npcBId)?.embedding ?? null; +} + +export async function searchSimilarEdges( + input: SearchSimilarEdgesInput, +): Promise { + const k = input.k ?? 5; + const sql = getSql(); + if (sql) { + const vectorLiteral = `[${input.queryEmbedding.join(",")}]`; + const active = input.activeNpcId; + const rows = active + ? await sql< + { + npc_a_id: string; + npc_b_id: string; + history_summary: string; + current_status: unknown; + score: number; + }[] + >` + SELECT + npc_a_id, + npc_b_id, + history_summary, + current_status, + (1 - (embedding <=> ${vectorLiteral}::vector)) AS score + FROM npc_relationships + WHERE room_id = ${input.roomId} + AND embedding IS NOT NULL + AND (${active} = npc_a_id OR ${active} = npc_b_id) + ORDER BY embedding <=> ${vectorLiteral}::vector ASC + LIMIT ${k} + ` + : await sql< + { + npc_a_id: string; + npc_b_id: string; + history_summary: string; + current_status: unknown; + score: number; + }[] + >` + SELECT + npc_a_id, + npc_b_id, + history_summary, + current_status, + (1 - (embedding <=> ${vectorLiteral}::vector)) AS score + FROM npc_relationships + WHERE room_id = ${input.roomId} + AND embedding IS NOT NULL + ORDER BY embedding <=> ${vectorLiteral}::vector ASC + LIMIT ${k} + `; + return rows.map((row) => ({ + npcAId: row.npc_a_id, + npcBId: row.npc_b_id, + historySummary: row.history_summary, + currentStatus: parseStatusTags(row.current_status), + score: Number(row.score), + })); + } + + const rows = memoryRowsForRoom(input.roomId).filter((row) => { + if (!row.embedding || row.embedding.length !== EMBED_DIMENSIONS) return false; + if (!input.activeNpcId) return true; + return row.npcAId === input.activeNpcId || row.npcBId === input.activeNpcId; + }); + + return rows + .map((row) => ({ + npcAId: row.npcAId, + npcBId: row.npcBId, + historySummary: row.historySummary, + currentStatus: [...row.currentStatus], + score: cosineSimilarity(row.embedding!, input.queryEmbedding), + })) + .sort((a, b) => b.score - a.score) + .slice(0, k); +} + +/** + * Silent idle decay apply — updates affection only. + * Does NOT bump last_interact_at, interaction_count, history, or status (pitfall #1). + */ +export async function applyIdleDecayDeltas( + input: ApplyIdleDecayDeltasInput, +): Promise<{ updated: number }> { + const sql = getSql(); + return sql ? applyIdleDecaySql(input) : applyIdleDecayMemory(input); +} + +async function applyIdleDecayMemory( + input: ApplyIdleDecayDeltasInput, +): Promise<{ updated: number }> { + let updated = 0; + for (const delta of input.deltas) { + if (delta.affectionDelta === 0) continue; + const normalized = normalizeEdgeIds(delta.npcAId, delta.npcBId); + const row = findMemoryEdge(input.roomId, normalized.npcAId, normalized.npcBId); + if (!row) continue; + row.affection = clampAffection(row.affection + delta.affectionDelta); + row.updatedAt = new Date(); + updated += 1; + } + return { updated }; +} + +async function applyIdleDecaySql( + input: ApplyIdleDecayDeltasInput, +): Promise<{ updated: number }> { + const sql = getSql(); + if (!sql) throw new Error("sql client unavailable"); + let updated = 0; + + for (const delta of input.deltas) { + if (delta.affectionDelta === 0) continue; + const normalized = normalizeEdgeIds(delta.npcAId, delta.npcBId); + const rows = await sql` + SELECT * + FROM npc_relationships + WHERE room_id = ${input.roomId} + AND npc_a_id = ${normalized.npcAId} + AND npc_b_id = ${normalized.npcBId} + LIMIT 1 + `; + if (rows.length === 0) continue; + + const row = rowFromDb(rows[0]!); + const nextAffection = clampAffection(row.affection + delta.affectionDelta); + const updatedAt = new Date(); + await sql` + UPDATE npc_relationships + SET + affection = ${nextAffection}, + updated_at = ${updatedAt.toISOString()} + WHERE room_id = ${input.roomId} + AND npc_a_id = ${normalized.npcAId} + AND npc_b_id = ${normalized.npcBId} + `; + updated += 1; + } + return { updated }; } /** Test helper */ export function clearNpcRelationshipsMemory(): void { memoryByRoom.clear(); + lastInteractAbsByEdge.clear(); + seedAbsByEdge.clear(); } /** Expected undirected edge count for 12 council seats. */ diff --git a/apps/game-server/src/world/personal-timeline-dyad.ts b/apps/game-server/src/world/personal-timeline-dyad.ts index 55025f8..627521d 100644 --- a/apps/game-server/src/world/personal-timeline-dyad.ts +++ b/apps/game-server/src/world/personal-timeline-dyad.ts @@ -18,6 +18,7 @@ import { claimPersonalTimelineJobId, enqueuePersonalTimelineEventJob, } from "../queue/personal-timeline.js"; +import { isPairClaimedForMutualChat } from "./npc-mutual-chat.js"; import { getRoomVoteState } from "./world-vote-state.js"; const DYAD_CHEBYSHEV_MAX = 2; @@ -200,6 +201,8 @@ export async function maybeEnqueueDyadFromAmbient(input: { const { npcAId, npcBId } = normalizeEdgeIds(a.id, b.id); const claim = pairClaimKey(input.roomId, dayIndex, a.id, b.id); if (dyadDayClaims.has(claim)) continue; + // D-MUTUAL / A2: mutual-chat supersedes ambient dyad for same room/day/pair. + if (isPairClaimedForMutualChat(input.roomId, dayIndex, a.id, b.id)) continue; const score = stableStringHash(`dyad-ambient:${input.roomId}:${dayIndex}:${npcAId}:${npcBId}`) % 100; @@ -214,6 +217,7 @@ export async function maybeEnqueueDyadFromAmbient(input: { if (remaining <= 0) break; const claim = pairClaimKey(input.roomId, dayIndex, a.id, b.id); if (dyadDayClaims.has(claim)) continue; + if (isPairClaimedForMutualChat(input.roomId, dayIndex, a.id, b.id)) continue; const durablePair = durablePairClaimId(input.roomId, dayIndex, a.id, b.id); if (!(await claimPersonalTimelineJobId(durablePair))) continue; diff --git a/apps/game-server/src/world/relationship-broadcast.test.ts b/apps/game-server/src/world/relationship-broadcast.test.ts new file mode 100644 index 0000000..a0e5e5f --- /dev/null +++ b/apps/game-server/src/world/relationship-broadcast.test.ts @@ -0,0 +1,174 @@ +/** + * Phase 28 mutual-chat presentation + linkedEdges hint broadcast (D-MUTUAL-02/04). + */ +import { beforeEach, describe, expect, it } from "vitest"; +import request from "supertest"; +import { + COLYSEUS_SERVER_MESSAGES, + findNpc, +} from "@aetherlife/shared"; +import { createApp } from "../index.js"; +import { + clearColyseusRoomRegistry, + registerColyseusRoom, +} from "../colyseus/room-registry.js"; +import { GameRoomState } from "../colyseus/schema.js"; +import { clearAllRooms, getOrCreate } from "../room/store.js"; +import { + broadcastLinkedEdgesHint, + broadcastMutualChatBubble, + clampMutualBubbleText, + presentNpcMutualChat, +} from "../world/relationship-broadcast.js"; + +const ROOM = "room-mutual-chat"; + +describe("relationship-broadcast mutual chat", () => { + beforeEach(() => { + delete process.env.DATABASE_URL; + delete process.env.INTERNAL_WORKER_TOKEN; + clearAllRooms(); + clearColyseusRoomRegistry(); + }); + + it("clampMutualBubbleText truncates to ≤20 and strips control chars", () => { + expect(clampMutualBubbleText("短")).toBe("短"); + expect(clampMutualBubbleText("一二三四五六七八九十一二三四五六七八九十超")).toHaveLength(20); + expect(clampMutualBubbleText("a\nb\tc")).toBe("abc"); + }); + + it("broadcastMutualChatBubble sends ≤20 text and never puts edges on relationshipSync", () => { + const sends: Array<{ type: string; payload: unknown }> = []; + const room = { + clients: [ + { + send: (type: string, payload: unknown) => { + sends.push({ type, payload }); + }, + }, + ], + state: new GameRoomState(), + }; + registerColyseusRoom(ROOM, room as never); + + broadcastMutualChatBubble(ROOM, { + npcId: "npc-1", + peerNpcId: "npc-2", + text: "今日庭中风软正好叙话超过二十", + expiresAt: Date.now() + 4000, + }); + + expect(sends).toHaveLength(1); + expect(sends[0]!.type).toBe(COLYSEUS_SERVER_MESSAGES.mutualChatBubble); + const bubble = sends[0]!.payload as { text: string }; + expect(bubble.text.length).toBeLessThanOrEqual(20); + + broadcastLinkedEdgesHint(ROOM, { + linkedEdges: [{ npcAId: "npc-2", npcBId: "npc-1" }], + }); + const hint = sends.find((s) => s.type === COLYSEUS_SERVER_MESSAGES.relationshipLinkedHint); + expect(hint).toBeTruthy(); + expect(hint!.payload).toEqual({ + linkedEdges: [{ npcAId: "npc-1", npcBId: "npc-2" }], + }); + expect( + sends.some((s) => s.type === COLYSEUS_SERVER_MESSAGES.relationshipSync), + ).toBe(false); + }); + + it("presentNpcMutualChat sets dual intentReasonZh and broadcasts bubble", () => { + getOrCreate(ROOM); + const sends: Array<{ type: string; payload: unknown }> = []; + const room = { + clients: [ + { + send: (type: string, payload: unknown) => { + sends.push({ type, payload }); + }, + }, + ], + state: new GameRoomState(), + }; + registerColyseusRoom(ROOM, room as never); + + const bubble = presentNpcMutualChat(ROOM, { + npcAId: "npc-1", + npcBId: "npc-2", + npcAReasonZh: "与沈清晏交谈中", + npcBReasonZh: "与莫玄虚交谈中", + bubbleText: "今日风清", + }); + + expect(bubble?.text).toBe("今日风清"); + const { state: map } = getOrCreate(ROOM); + expect(findNpc(map, "npc-1")?.intentReasonZh).toContain("交谈中"); + expect(findNpc(map, "npc-2")?.intentReasonZh).toContain("交谈中"); + expect(sends.some((s) => s.type === COLYSEUS_SERVER_MESSAGES.mutualChatBubble)).toBe( + true, + ); + }); +}); + +describe("internal npc-mutual-chat routes", () => { + const app = createApp(); + + beforeEach(() => { + delete process.env.DATABASE_URL; + delete process.env.INTERNAL_WORKER_TOKEN; + clearAllRooms(); + clearColyseusRoomRegistry(); + }); + + it("POST present requires worker auth when token configured", async () => { + process.env.INTERNAL_WORKER_TOKEN = "secret-tok"; + const res = await request(app) + .post(`/internal/rooms/${ROOM}/npc-mutual-chat/present`) + .send({ + npcAId: "npc-1", + npcBId: "npc-2", + npcAReasonZh: "与乙交谈中", + npcBReasonZh: "与甲交谈中", + bubbleText: "你好", + }); + expect(res.status).toBe(401); + }); + + it("POST present + linked-edges-hint succeed with auth", async () => { + getOrCreate(ROOM); + const sends: Array<{ type: string; payload: unknown }> = []; + registerColyseusRoom(ROOM, { + clients: [ + { + send: (type: string, payload: unknown) => { + sends.push({ type, payload }); + }, + }, + ], + state: new GameRoomState(), + } as never); + + const present = await request(app) + .post(`/internal/rooms/${ROOM}/npc-mutual-chat/present`) + .send({ + npcAId: "npc-1", + npcBId: "npc-2", + npcAReasonZh: "与乙交谈中", + npcBReasonZh: "与甲交谈中", + bubbleText: "今日庭中风软正好叙话超过二十字", + }); + expect(present.status).toBe(200); + expect(present.body.ok).toBe(true); + expect(present.body.bubble.text.length).toBeLessThanOrEqual(20); + expect( + sends.some((s) => s.type === COLYSEUS_SERVER_MESSAGES.relationshipSync), + ).toBe(true); + + const hint = await request(app) + .post(`/internal/rooms/${ROOM}/npc-mutual-chat/linked-edges-hint`) + .send({ linkedEdges: [{ npcAId: "npc-1", npcBId: "npc-2" }] }); + expect(hint.status).toBe(200); + expect( + sends.some((s) => s.type === COLYSEUS_SERVER_MESSAGES.relationshipLinkedHint), + ).toBe(true); + }); +}); diff --git a/apps/game-server/src/world/relationship-broadcast.ts b/apps/game-server/src/world/relationship-broadcast.ts new file mode 100644 index 0000000..05b7287 --- /dev/null +++ b/apps/game-server/src/world/relationship-broadcast.ts @@ -0,0 +1,125 @@ +import { + COLYSEUS_SERVER_MESSAGES, + findNpc, + normalizeEdgeIds, + type ColyseusMutualChatBubblePayload, + type ColyseusRelationshipLinkedHintPayload, + type ColyseusRelationshipSyncPayload, +} from "@aetherlife/shared"; +import { syncColyseusFromMap } from "../colyseus/bridge.js"; +import { getColyseusRoom } from "../colyseus/room-registry.js"; +import type { GameRoomState } from "../colyseus/schema.js"; +import { bumpStateVersion } from "../colyseus/version.js"; +import { getOrCreate } from "../room/store.js"; + +const BUBBLE_MAX_CHARS = 20; +const BUBBLE_TTL_MS = 4000; +const CONTROL_CHARS = /[\x00-\x1f\x7f]/g; + +/** D-API-01 / D-GRAPH-04: hint-only — never edge bodies on WS. */ +export function broadcastRelationshipSync( + mapRoomId: string, + payload: ColyseusRelationshipSyncPayload, +): void { + const room = getColyseusRoom(mapRoomId); + if (!room) return; + + const hint: ColyseusRelationshipSyncPayload = { + hasUpdate: payload.hasUpdate, + }; + if (payload.latestSeq != null) { + hint.latestSeq = payload.latestSeq; + } + + for (const client of room.clients) { + client.send(COLYSEUS_SERVER_MESSAGES.relationshipSync, hint); + } +} + +export function clampMutualBubbleText(text: string): string { + const cleaned = String(text ?? "").replace(CONTROL_CHARS, "").trim(); + return cleaned.length <= BUBBLE_MAX_CHARS ? cleaned : cleaned.slice(0, BUBBLE_MAX_CHARS); +} + +/** D-MUTUAL-02: one-shot bubble — not a Colyseus schema field. */ +export function broadcastMutualChatBubble( + mapRoomId: string, + payload: ColyseusMutualChatBubblePayload, +): void { + const room = getColyseusRoom(mapRoomId); + if (!room) return; + + const text = clampMutualBubbleText(payload.text); + if (!text || !payload.npcId || !payload.peerNpcId) return; + + const msg: ColyseusMutualChatBubblePayload = { + npcId: payload.npcId, + peerNpcId: payload.peerNpcId, + text, + expiresAt: payload.expiresAt, + }; + + for (const client of room.clients) { + client.send(COLYSEUS_SERVER_MESSAGES.mutualChatBubble, msg); + } +} + +/** D-MUTUAL-04: LinkedEdge[] ids only — same client state CouncilRosterPanel consumes. */ +export function broadcastLinkedEdgesHint( + mapRoomId: string, + payload: ColyseusRelationshipLinkedHintPayload, +): void { + const room = getColyseusRoom(mapRoomId); + if (!room) return; + + const linkedEdges = (payload.linkedEdges ?? []) + .filter((e) => e?.npcAId && e?.npcBId && e.npcAId !== e.npcBId) + .map((e) => { + const { npcAId, npcBId } = normalizeEdgeIds(e.npcAId, e.npcBId); + return { npcAId, npcBId }; + }); + + const msg: ColyseusRelationshipLinkedHintPayload = { linkedEdges }; + for (const client of room.clients) { + client.send(COLYSEUS_SERVER_MESSAGES.relationshipLinkedHint, msg); + } +} + +/** + * Apply dual activity labels + broadcast one-shot bubble (D-MUTUAL-02). + * Does not add Colyseus schema fields. + */ +export function presentNpcMutualChat( + mapRoomId: string, + input: { + npcAId: string; + npcBId: string; + npcAReasonZh: string; + npcBReasonZh: string; + bubbleText: string; + }, +): ColyseusMutualChatBubblePayload | null { + if (!input.npcAId || !input.npcBId || input.npcAId === input.npcBId) return null; + + const { state: map } = getOrCreate(mapRoomId); + const npcA = findNpc(map, input.npcAId); + const npcB = findNpc(map, input.npcBId); + if (npcA) npcA.intentReasonZh = String(input.npcAReasonZh ?? "").trim().slice(0, 40); + if (npcB) npcB.intentReasonZh = String(input.npcBReasonZh ?? "").trim().slice(0, 40); + + const colyseus = getColyseusRoom(mapRoomId); + if (colyseus) { + const state = colyseus.state as GameRoomState; + syncColyseusFromMap(state, map); + bumpStateVersion(state); + } + + const bubble: ColyseusMutualChatBubblePayload = { + npcId: input.npcAId, + peerNpcId: input.npcBId, + text: clampMutualBubbleText(input.bubbleText), + expiresAt: Date.now() + BUBBLE_TTL_MS, + }; + broadcastMutualChatBubble(mapRoomId, bubble); + return bubble; +} diff --git a/apps/web/src/ChatPage.tsx b/apps/web/src/ChatPage.tsx index b4601b4..61bc0bc 100644 --- a/apps/web/src/ChatPage.tsx +++ b/apps/web/src/ChatPage.tsx @@ -1,14 +1,21 @@ import { bandLabelZh, createDefaultRoom, isBackgroundNpc, type RoomState, type WorldHistoryPublicEntry } from "@aetherlife/shared"; import type { ColyseusPersonalTimelineSyncPayload, + ColyseusRelationshipSyncPayload, ColyseusWorldHistorySyncPayload, } from "@aetherlife/shared"; +import { COLYSEUS_SERVER_MESSAGES } from "@aetherlife/shared"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { useColyseusRoom } from "./hooks/useColyseusRoom.js"; import { discoveredLoreRows } from "./hooks/useChunkLore.js"; import { useNpcChat } from "./hooks/useNpcChat.js"; import { usePersonalTimeline } from "./hooks/usePersonalTimeline.js"; +import { useNpcRelationships } from "./hooks/useNpcRelationships.js"; +import { + resolveDefaultCenterNpcId, + type RelationshipGraphMode, +} from "./components/RelationshipGraphPanel.js"; import { MovementPanel } from "./components/MovementPanel.js"; import { PhaserGame, probePhaserBoot, readReducedMotion } from "./components/PhaserGame.js"; import { CollectiveAttitudeOverlay } from "./components/CollectiveAttitudeOverlay.js"; @@ -72,6 +79,9 @@ export function ChatPage() { const mergePersonalTimelineSyncRef = useRef<(payload: ColyseusPersonalTimelineSyncPayload) => void>( () => {}, ); + const mergeRelationshipSyncRef = useRef<(payload: ColyseusRelationshipSyncPayload) => void>( + () => {}, + ); const markChronicleVoteEntryRef = useRef<() => void>(() => {}); const onWorldHistorySync = useCallback((payload: ColyseusWorldHistorySyncPayload) => { mergeWorldHistorySyncRef.current(payload); @@ -85,6 +95,9 @@ export function ChatPage() { const onPersonalTimelineSync = useCallback((payload: ColyseusPersonalTimelineSyncPayload) => { mergePersonalTimelineSyncRef.current(payload); }, []); + const onRelationshipSync = useCallback((payload: ColyseusRelationshipSyncPayload) => { + mergeRelationshipSyncRef.current(payload); + }, []); const { room, connected, @@ -109,6 +122,7 @@ export function ChatPage() { npcActivityById, npcAmbientById, roomNpcs, + mutualChatBubble, } = useColyseusRoom( mapRoomId, moveMap, @@ -184,12 +198,41 @@ export function ChatPage() { toastQueue: councilVoteToastQueue, chronicleUnread, mergeCouncilDeliberationSync, + mergeLinkedEdgesHint, markChronicleVoteEntry, clearChronicleUnread, consumeVoteToast, } = useCouncilDeliberation(speakQueueBusy); mergeCouncilDeliberationSyncRef.current = mergeCouncilDeliberationSync; markChronicleVoteEntryRef.current = markChronicleVoteEntry; + + useEffect(() => { + if (!room) return; + const off = room.onMessage( + COLYSEUS_SERVER_MESSAGES.relationshipLinkedHint, + (data: unknown) => { + mergeLinkedEdgesHint(data); + }, + ); + return () => { + off(); + }; + }, [room, mergeLinkedEdgesHint]); + + useEffect(() => { + if (!room) return; + const off = room.onMessage( + COLYSEUS_SERVER_MESSAGES.relationshipSync, + (data: unknown) => { + if (!data || typeof data !== "object") return; + const row = data as ColyseusRelationshipSyncPayload; + if (typeof row.hasUpdate !== "boolean") return; + mergeRelationshipSyncRef.current(row); + }, + ); + return () => off(); + }, [room]); + const councilVoteToast = councilVoteToastQueue[0] ?? null; const dismissCouncilVoteToast = useCallback(() => { consumeVoteToast(); @@ -250,6 +293,42 @@ export function ChatPage() { collectiveRecentEvents: collectiveSnapshot?.recentEvents, }); + const relationshipsTabFocused = drawerOpen && drawerTab === "relationships"; + const { + edges: relationshipEdges, + loading: relationshipLoading, + error: relationshipError, + hasUpdate: relationshipHasUpdate, + mergeRelationshipSync, + } = useNpcRelationships(mapRoomId, connected, relationshipsTabFocused); + mergeRelationshipSyncRef.current = mergeRelationshipSync; + + const [lastRosterNpcId, setLastRosterNpcId] = useState("npc-1"); + const [relationshipCenterNpcId, setRelationshipCenterNpcId] = useState("npc-1"); + const [relationshipGraphMode, setRelationshipGraphMode] = + useState("ego"); + + // Reset center/mode only on tab focus transition — never mid-session when + // activeNpcId changes, so the user's selected center survives. + const relationshipsTabWasFocusedRef = useRef(false); + useEffect(() => { + const wasFocused = relationshipsTabWasFocusedRef.current; + relationshipsTabWasFocusedRef.current = relationshipsTabFocused; + if (!relationshipsTabFocused || wasFocused) return; + setRelationshipCenterNpcId( + resolveDefaultCenterNpcId(activeNpcId, lastRosterNpcId), + ); + setRelationshipGraphMode("ego"); + }, [relationshipsTabFocused, activeNpcId, lastRosterNpcId]); + + const handleOpenPersonalBiography = useCallback( + (npcId: string) => { + setLastRosterNpcId(npcId); + void openPersonalBiography(npcId); + }, + [openPersonalBiography], + ); + const latestCollectiveEvent = collectiveSnapshot?.recentEvents[0]; const collectiveFeedbackKind = latestCollectiveEvent && @@ -579,7 +658,17 @@ export function ChatPage() { personalTimelineHasUpdate={personalTimelineHasUpdate} personalTimelineLoadingNpcId={personalTimelineLoadingNpcId} personalTimelineErrorByNpcId={personalTimelineErrorByNpcId} - onOpenPersonalBiography={openPersonalBiography} + onOpenPersonalBiography={handleOpenPersonalBiography} + relationshipEdges={relationshipEdges} + relationshipLoading={relationshipLoading} + relationshipError={relationshipError} + relationshipHasUpdate={relationshipHasUpdate} + relationshipCenterNpcId={relationshipCenterNpcId} + onRelationshipCenterChange={setRelationshipCenterNpcId} + relationshipGraphMode={relationshipGraphMode} + onRelationshipGraphModeChange={setRelationshipGraphMode} + lastRosterNpcId={lastRosterNpcId} + relationshipStaleHint={relationshipHasUpdate && relationshipsTabFocused} roomId={mapRoomId} roomConnected={connected} lastParsedIntent={lastParsedIntent} @@ -736,6 +825,7 @@ export function ChatPage() { npcActivityById={npcActivityById} npcAmbientById={npcAmbientById} speakBusyNpcId={speakBusyNpcId} + mutualChatBubble={mutualChatBubble} onBootFailed={() => { setPhaserOk(false); setBootOk(false); diff --git a/apps/web/src/components/DialogueBar.tsx b/apps/web/src/components/DialogueBar.tsx index d6c3675..02b47be 100644 --- a/apps/web/src/components/DialogueBar.tsx +++ b/apps/web/src/components/DialogueBar.tsx @@ -11,6 +11,7 @@ export type DrawerTab = | "history" | "collective" | "council" + | "relationships" | "chronicle" | "discoveries" | "memory"; @@ -114,6 +115,14 @@ export function DialogueBar({ > 议会 + + {staleHint ? ( +

关系有更新

+ ) : null} + + + {loading ? ( +

+ 载入中… +

+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + + {showEmpty ? ( +
+

尚无关系网

+

+ 加入房间并等待议员落位后,打开此页可查看档位关系。 +

+
+ ) : null} + + {!loading && !error && edges.length > 0 ? ( +
+ + {visibleEdges.map((edge) => { + const a = nodeById[edge.npcAId]; + const b = nodeById[edge.npcBId]; + if (!a || !b) return null; + const stroke = relationshipBandStroke(edge.band); + return ( + + ); + })} + {nodes.map((node) => { + const persona = getPersona(node.npcId); + const label = graphNodeLabel(persona.displayName); + const connectedEdge = + graphMode === "ego" && !node.isCenter + ? findEdge(edges, centerNpcId, node.npcId) + : undefined; + const bandLabel = connectedEdge?.bandLabelZh ?? ""; + return ( + + + + + + + ); + })} + + +
    + {visibleEdges.map((edge) => ( +
  • + + {getPersona(edge.npcAId).displayName} —{" "} + {getPersona(edge.npcBId).displayName} + + + {edge.bandLabelZh} + + + {edge.kindLabelZh} + + {edge.currentStatus.slice(0, 2).map((status) => ( + + {status} + + ))} +
  • + ))} +
+
+ ) : null} + + ); +} diff --git a/apps/web/src/components/ShellDrawer.test.ts b/apps/web/src/components/ShellDrawer.test.ts index 9b0a7b0..500ccef 100644 --- a/apps/web/src/components/ShellDrawer.test.ts +++ b/apps/web/src/components/ShellDrawer.test.ts @@ -37,8 +37,20 @@ describe("NpcAvatarStrip a11y", () => { }); }); +const shellDrawerRelationshipProps = { + relationshipEdges: [] as import("../hooks/useNpcRelationships.js").RelationshipRenderEdge[], + relationshipLoading: false, + relationshipError: null as string | null, + relationshipHasUpdate: false, + relationshipCenterNpcId: "npc-1", + onRelationshipCenterChange: () => {}, + relationshipGraphMode: "ego" as const, + onRelationshipGraphModeChange: () => {}, + lastRosterNpcId: "npc-1", +}; + describe("ShellDrawer a11y", () => { - it("places chronicle tab immediately after council tab", () => { + it("D-DRAWER-02: places 关系网 tab immediately after council tab", () => { const html = renderToStaticMarkup( createElement(ShellDrawer, { open: true, @@ -53,15 +65,20 @@ describe("ShellDrawer a11y", () => { collectiveLoading: false, discoveredLoreRows: [], ...shellDrawerWorldHistoryProps, + ...shellDrawerRelationshipProps, roomId: "room-1", roomConnected: true, }), ); - const tabIds = [...html.matchAll(/id="(shell-drawer-tab-[^"]+)"/g)].map((m) => m[1]); + const tabIds = [...html.matchAll(/\bid="(shell-drawer-tab-[^"]+)"/g)].map((m) => m[1]); const councilPos = tabIds.indexOf("shell-drawer-tab-council"); + const relationshipsPos = tabIds.indexOf("shell-drawer-tab-relationships"); const chroniclePos = tabIds.indexOf("shell-drawer-tab-chronicle"); expect(councilPos).toBeGreaterThanOrEqual(0); - expect(chroniclePos).toBe(councilPos + 1); + expect(relationshipsPos).toBe(councilPos + 1); + expect(chroniclePos).toBe(relationshipsPos + 1); + expect(html).toContain('data-testid="shell-drawer-tab-relationships"'); + expect(html).toContain("关系网"); }); it("wires drawer tabs to shell-drawer-panel ids", () => { @@ -79,6 +96,7 @@ describe("ShellDrawer a11y", () => { collectiveLoading: false, discoveredLoreRows: [], ...shellDrawerWorldHistoryProps, + ...shellDrawerRelationshipProps, roomId: "room-1", roomConnected: true, }), @@ -110,6 +128,7 @@ describe("ShellDrawer a11y", () => { collectiveLoading: false, discoveredLoreRows: [], ...shellDrawerWorldHistoryProps, + ...shellDrawerRelationshipProps, roomId: "room-1", roomConnected: true, }), @@ -157,6 +176,7 @@ describe("ShellDrawer a11y", () => { }, ], worldHistoryLoading: false, + ...shellDrawerRelationshipProps, roomId: "room-1", roomConnected: true, }), diff --git a/apps/web/src/components/ShellDrawer.tsx b/apps/web/src/components/ShellDrawer.tsx index 860776b..0dc6ba4 100644 --- a/apps/web/src/components/ShellDrawer.tsx +++ b/apps/web/src/components/ShellDrawer.tsx @@ -6,7 +6,9 @@ import { CouncilRosterPanel } from "./CouncilRosterPanel.js"; import { DiscoveredLorePanel } from "./DiscoveredLorePanel.js"; import { MessageList } from "./MessageList.js"; import { NpcMemoryPanel } from "./NpcMemoryPanel.js"; +import { RelationshipGraphPanel, type RelationshipGraphMode } from "./RelationshipGraphPanel.js"; import type { DrawerTab } from "./DialogueBar.js"; +import type { RelationshipRenderEdge } from "../hooks/useNpcRelationships.js"; import type { DiscoveredLoreRow } from "../hooks/useChunkLore.js"; import type { CouncilDeliberationFeedRow, @@ -63,6 +65,16 @@ type Props = { personalTimelineLoadingNpcId?: string | null; personalTimelineErrorByNpcId?: Record; onOpenPersonalBiography?: (npcId: string) => void; + relationshipEdges?: RelationshipRenderEdge[]; + relationshipLoading?: boolean; + relationshipError?: string | null; + relationshipHasUpdate?: boolean; + relationshipCenterNpcId?: string; + onRelationshipCenterChange?: (npcId: string) => void; + relationshipGraphMode?: RelationshipGraphMode; + onRelationshipGraphModeChange?: (mode: RelationshipGraphMode) => void; + lastRosterNpcId?: string; + relationshipStaleHint?: boolean; roomId: string; roomConnected: boolean; lastParsedIntent?: ParsedIntent; @@ -73,6 +85,7 @@ const TABS: { id: DrawerTab; label: string }[] = [ { id: "history", label: "对话历史" }, { id: "collective", label: "集体见闻" }, { id: "council", label: "星际议会" }, + { id: "relationships", label: "关系网" }, { id: "chronicle", label: "编年史" }, { id: "discoveries", label: "已发现" }, { id: "memory", label: "记忆" }, @@ -158,6 +171,16 @@ export function ShellDrawer({ personalTimelineLoadingNpcId = null, personalTimelineErrorByNpcId = {}, onOpenPersonalBiography, + relationshipEdges = [], + relationshipLoading = false, + relationshipError = null, + relationshipHasUpdate = false, + relationshipCenterNpcId = "npc-1", + onRelationshipCenterChange = () => {}, + relationshipGraphMode = "ego", + onRelationshipGraphModeChange = () => {}, + lastRosterNpcId = "npc-1", + relationshipStaleHint = false, roomId, roomConnected, lastParsedIntent = null, @@ -192,6 +215,7 @@ export function ShellDrawer({ aria-controls={drawerPanelId(item.id)} tabIndex={tab === item.id ? 0 : -1} className={`shell-drawer__tab${tab === item.id ? " shell-drawer__tab--active" : ""}`} + data-testid={drawerTabId(item.id)} onClick={() => onTabChange(item.id)} onKeyDown={(event) => handleDrawerTabKeyDown(event, index, onTabChange)} > @@ -203,6 +227,15 @@ export function ShellDrawer({ aria-label="编年史有新条目" /> ) : null} + {item.id === "relationships" && + relationshipHasUpdate && + tab !== "relationships" ? ( + + ) : null} ))} @@ -264,6 +297,21 @@ export function ShellDrawer({ ) : null} + {tab === "relationships" ? ( + + ) : null} + {tab === "chronicle" ? (