Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
8509a53
test(28-01): add Wave 0 skipped stubs for relationship advanced
moyunzero Jul 21, 2026
35e5273
chore(28-01): scaffold verify:phase28 and register package script
moyunzero Jul 21, 2026
5ec9c3f
test(28-03): add failing test for relationship band public types
moyunzero Jul 21, 2026
ec7fea9
feat(28-03): implement relationship band public types + ZH labels
moyunzero Jul 21, 2026
2517903
feat(28-03): add relationshipSync message and CONTRACTS C-09b
moyunzero Jul 21, 2026
d56f13d
test(28-04): add failing tests for idle relationship decay
moyunzero Jul 21, 2026
9925739
feat(28-04): implement idle decay without last_interact bump
moyunzero Jul 21, 2026
253147f
feat(28-04): wire monthly idle decay from GameRoom ambient tick
moyunzero Jul 21, 2026
7bc7b2f
test(28-02): add failing test for relationship embedding column
moyunzero Jul 21, 2026
d2cdb77
feat(28-02): add nullable embedding to npc_relationships
moyunzero Jul 21, 2026
7afed42
test(28-05): add failing tests for npc-mutual-chat selector
moyunzero Jul 21, 2026
72f1c00
feat(28-05): implement npc-mutual-chat queue and proximity selector
moyunzero Jul 21, 2026
c81e6d6
feat(28-05): wire GameRoom mutual-chat enqueue with speak defer
moyunzero Jul 21, 2026
be03d75
test(28-07): add failing tests for player GET npc-relationships
moyunzero Jul 21, 2026
4de0283
feat(28-07): add scoped player GET npc-relationships with band DTO
moyunzero Jul 21, 2026
c2dd6b2
feat(28-07): broadcast relationshipSync hint after apply-deltas
moyunzero Jul 21, 2026
75f8f45
test(28-06): add failing tests for npc mutual-chat thresholds
moyunzero Jul 21, 2026
94fe5f4
feat(28-06): implement npc mutual-chat worker drain and thresholds
moyunzero Jul 21, 2026
550da6a
feat(28-06): broadcast mutual-chat bubble and linkedEdges roster hint
moyunzero Jul 21, 2026
2bcf68d
test(28-08): add failing belief gate unit tests
moyunzero Jul 21, 2026
ea80e38
feat(28-08): implement belief gate with attitude cutoffs
moyunzero Jul 21, 2026
2b09d20
test(28-08): add failing interactive speak belief gate tests
moyunzero Jul 21, 2026
94dcae3
feat(28-08): wire belief gate before compose_reply
moyunzero Jul 21, 2026
ac65213
test(28-10): add failing tests for useNpcRelationships
moyunzero Jul 21, 2026
df2d361
feat(28-10): implement useNpcRelationships hybrid sync hook
moyunzero Jul 21, 2026
e23ae81
test(28-10): add failing RelationshipGraphPanel and drawer tab tests
moyunzero Jul 21, 2026
28c0a75
feat(28-10): add 关系网 drawer tab and band relationship graph
moyunzero Jul 21, 2026
9089e14
test(28-09): add failing relationship edge embed tests
moyunzero Jul 21, 2026
810bb5e
feat(28-11): add MutualChatBubble Phaser Text module
moyunzero Jul 21, 2026
57f341d
feat(28-09): async relationship edge embed write path
moyunzero Jul 21, 2026
2eee3bd
feat(28-09): relationship_rag speak retrieval and paraphrase
moyunzero Jul 21, 2026
79bc015
feat(28-11): wire mutualChatBubble into Phaser scene
moyunzero Jul 21, 2026
c1b55d5
feat(28-12): fill verify:phase28 asserts + Nyquist map
moyunzero Jul 21, 2026
3c19f86
docs(28-12): add Phase 28 Guardrail #116 and C-09b anchors
moyunzero Jul 21, 2026
cf0fe40
fix(28-12): engage dialogue before opening 关系网 in verify:phase28
moyunzero Jul 21, 2026
4ad3b3b
docs: note v5 Council Worldview shipped in AGENTS.md
moyunzero Jul 21, 2026
a5a321f
fix(28): stamp room clock and close PR #22 CR findings
moyunzero Jul 21, 2026
1defd3d
fix(28): close remaining PR #22 CR — serialize mutual-chat, hydrate d…
moyunzero Jul 29, 2026
2bb1137
fix(web): Campfire volume shares south-edge Y-sort so flames cover legs
moyunzero Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。

---

Expand Down
41 changes: 40 additions & 1 deletion apps/game-server/src/colyseus/GameRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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<void> {
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;
Expand Down
1 change: 1 addition & 0 deletions apps/game-server/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
4 changes: 4 additions & 0 deletions apps/game-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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());
Expand All @@ -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());
Expand Down
149 changes: 149 additions & 0 deletions apps/game-server/src/queue/npc-mutual-chat.ts
Original file line number Diff line number Diff line change
@@ -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<string, NpcMutualChatJobPayload>();
const localJobClaims = new Set<string>();

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<boolean> {
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<void> {
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<void> {
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<boolean> {
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<string | null> {
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();
}
90 changes: 90 additions & 0 deletions apps/game-server/src/routes/internal-npc-mutual-chat.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading