-
Notifications
You must be signed in to change notification settings - Fork 0
Gsd/phase 28 council relationship advanced #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 36 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 35e5273
chore(28-01): scaffold verify:phase28 and register package script
moyunzero 5ec9c3f
test(28-03): add failing test for relationship band public types
moyunzero ec7fea9
feat(28-03): implement relationship band public types + ZH labels
moyunzero 2517903
feat(28-03): add relationshipSync message and CONTRACTS C-09b
moyunzero d56f13d
test(28-04): add failing tests for idle relationship decay
moyunzero 9925739
feat(28-04): implement idle decay without last_interact bump
moyunzero 253147f
feat(28-04): wire monthly idle decay from GameRoom ambient tick
moyunzero 7bc7b2f
test(28-02): add failing test for relationship embedding column
moyunzero d2cdb77
feat(28-02): add nullable embedding to npc_relationships
moyunzero 7afed42
test(28-05): add failing tests for npc-mutual-chat selector
moyunzero 72f1c00
feat(28-05): implement npc-mutual-chat queue and proximity selector
moyunzero c81e6d6
feat(28-05): wire GameRoom mutual-chat enqueue with speak defer
moyunzero be03d75
test(28-07): add failing tests for player GET npc-relationships
moyunzero 4de0283
feat(28-07): add scoped player GET npc-relationships with band DTO
moyunzero c2dd6b2
feat(28-07): broadcast relationshipSync hint after apply-deltas
moyunzero 75f8f45
test(28-06): add failing tests for npc mutual-chat thresholds
moyunzero 94fe5f4
feat(28-06): implement npc mutual-chat worker drain and thresholds
moyunzero 550da6a
feat(28-06): broadcast mutual-chat bubble and linkedEdges roster hint
moyunzero 2bcf68d
test(28-08): add failing belief gate unit tests
moyunzero ea80e38
feat(28-08): implement belief gate with attitude cutoffs
moyunzero 2b09d20
test(28-08): add failing interactive speak belief gate tests
moyunzero 94dcae3
feat(28-08): wire belief gate before compose_reply
moyunzero ac65213
test(28-10): add failing tests for useNpcRelationships
moyunzero df2d361
feat(28-10): implement useNpcRelationships hybrid sync hook
moyunzero e23ae81
test(28-10): add failing RelationshipGraphPanel and drawer tab tests
moyunzero 28c0a75
feat(28-10): add 关系网 drawer tab and band relationship graph
moyunzero 9089e14
test(28-09): add failing relationship edge embed tests
moyunzero 810bb5e
feat(28-11): add MutualChatBubble Phaser Text module
moyunzero 57f341d
feat(28-09): async relationship edge embed write path
moyunzero 2eee3bd
feat(28-09): relationship_rag speak retrieval and paraphrase
moyunzero 79bc015
feat(28-11): wire mutualChatBubble into Phaser scene
moyunzero c1b55d5
feat(28-12): fill verify:phase28 asserts + Nyquist map
moyunzero 3c19f86
docs(28-12): add Phase 28 Guardrail #116 and C-09b anchors
moyunzero cf0fe40
fix(28-12): engage dialogue before opening 关系网 in verify:phase28
moyunzero 4ad3b3b
docs: note v5 Council Worldview shipped in AGENTS.md
moyunzero a5a321f
fix(28): stamp room clock and close PR #22 CR findings
moyunzero 1defd3d
fix(28): close remaining PR #22 CR — serialize mutual-chat, hydrate d…
moyunzero 2bb1137
fix(web): Campfire volume shares south-edge Y-sort so flames cover legs
moyunzero File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| /** | ||
| * 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); | ||
| 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(); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.