Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0fb7088
feat(25-01): shared council deliberation and relationship types
moyunzero Jun 25, 2026
8dac9e7
feat(25-01): npc_relationships migration, repository, and council seed
moyunzero Jun 25, 2026
ff68fec
feat(25-01): internal npc-relationships API and C-09 contract
moyunzero Jun 25, 2026
25a4069
feat(25-02): add world-vote BullMQ queue with per-room dedupe
moyunzero Jun 25, 2026
af07cce
feat(25-02): vote trigger scheduler, state, and force-trigger route
moyunzero Jun 25, 2026
17c624b
feat(25-02): hook enqueueWorldVoteIfDue in GameRoom ambient tick
moyunzero Jun 25, 2026
ff58c58
feat(25-03): world vote worker graph and queue drain
moyunzero Jun 25, 2026
8520ef9
feat(25-03): deliberation sync broadcast and vote completion routes
moyunzero Jun 25, 2026
a6d79ac
feat(25-03): relationship delta engine for vote writeback
moyunzero Jun 25, 2026
1e1e202
feat(25-05): dual-source world_history RAG helper
moyunzero Jun 25, 2026
dbf9350
feat(25-04): useCouncilDeliberation hook + Colyseus sync listener
moyunzero Jun 25, 2026
151d93e
feat(25-05): 12-seat persona with runtime relationships
moyunzero Jun 25, 2026
3850276
feat(25-04): council deliberation UI components per UI-SPEC
moyunzero Jun 25, 2026
bc8684c
feat(25-05): wire dual RAG and runtime persona into speak
moyunzero Jun 25, 2026
d446f14
feat(25-04): wire council deliberation UI in ChatPage and drawer
moyunzero Jun 25, 2026
066fb87
feat(25-06): add verify-phase25 real-LLM E2E gate
moyunzero Jun 25, 2026
f6cd19a
docs(25-06): finalize C-07/C-09 contracts and council guardrails
moyunzero Jun 25, 2026
9987853
test(25-06): fix worker tests for dual-RAG and env-isolated LLM roles
moyunzero Jun 25, 2026
df57a51
feat(25): ship council vote debate loop, dual-output feed, and person…
moyunzero Jun 29, 2026
70b9a79
fix(25): address CodeRabbit CR — yesCount tally, speak enqueue, vote …
moyunzero Jun 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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ tmp/
.cursor/
.codegraph/
.githooks/
.agents/
skills-lock.json
memory.md

# Local asset drops (not wired into build yet)
npc-img/

# Internal development docs (local only)
docs/ISSUE-LOG.md
Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ Run from **repository root** unless noted.
| **Agent verify (diff → tests)** | `pnpm agent:verify` — fast L2; `pnpm agent:verify --e2e` — golden flows (needs `dev:stack`) |
| **Scope audit** | `AGENT_SCOPE="path/*" pnpm agent:verify:scope` |
| **Git pre-push hook** | `pnpm hooks:install` once → runs `agent:verify --base` on push |
| **Council persona export** | `pnpm council:export-personas` — dossiers → compact + speak JSON |
| **Council persona audit** | `pnpm council:audit-personas` — 0 issues before merge(见 [COUNCIL-PERSONAS.md](./docs/COUNCIL-PERSONAS.md)) |

Secrets: root `.env` from `.env.example` — **never commit** `.env` or API keys.

Expand Down Expand Up @@ -173,6 +175,7 @@ Ledger: [docs/ISSUE-LOG.md](./docs/ISSUE-LOG.md) — open → fixed + verificati
| [CLAUDE.md](./CLAUDE.md) | GSD project brief, stack versions, workflow |
| [docs/ISSUE-LOG.md](./docs/ISSUE-LOG.md) | Bug ledger + guardrails |
| [docs/PHASE-EVOLUTION.md](./docs/PHASE-EVOLUTION.md) | 阶段演进防债务 + GSD skill 映射 |
| [docs/COUNCIL-PERSONAS.md](./docs/COUNCIL-PERSONAS.md) | 12 席议会人设 SSOT + export/audit |
| [docs/CONTRACTS.md](./docs/CONTRACTS.md) | 跨层契约 C-01…05 |
| [docs/INVARIANTS-MULTIPLAYER.md](./docs/INVARIANTS-MULTIPLAYER.md) | MP-01…10 多人空间/NL 硬约束 |
| [docs/MOVEMENT-ARCHITECTURE.md](./docs/MOVEMENT-ARCHITECTURE.md) | Phaser-first 移动/同步(Phase 10.5,Steam 向) |
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ Action schema: [packages/game-actions/README.md](./packages/game-actions/README.
| [docs/MOVEMENT-ARCHITECTURE.md](./docs/MOVEMENT-ARCHITECTURE.md) | Phaser movement + Colyseus sync |
| [docs/E2E-POLICY.md](./docs/E2E-POLICY.md) | E2E / UAT policy + Golden Flows |
| [docs/PHASE-EVOLUTION.md](./docs/PHASE-EVOLUTION.md) | Phase evolution + cross-layer guardrails |
| [docs/COUNCIL-PERSONAS.md](./docs/COUNCIL-PERSONAS.md) | 12-seat council persona SSOT + export/audit |

## Contributing

Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ Action schema:[packages/game-actions/README.md](./packages/game-actions/README
| [docs/MOVEMENT-ARCHITECTURE.md](./docs/MOVEMENT-ARCHITECTURE.md) | Phaser 移动与 Colyseus 同步 |
| [docs/E2E-POLICY.md](./docs/E2E-POLICY.md) | E2E / UAT 策略与 Golden Flows |
| [docs/PHASE-EVOLUTION.md](./docs/PHASE-EVOLUTION.md) | 阶段演进与跨层防债务 |
| [docs/COUNCIL-PERSONAS.md](./docs/COUNCIL-PERSONAS.md) | 十二议会人设 SSOT + 导出/审计 |

## 贡献

Expand Down
2 changes: 1 addition & 1 deletion apps/ai-gateway/fixtures/golden_intents.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@
},
{
"id": "zh-mixed-1",
"message": "路昂走到 4,4",
"message": "莫玄虚走到 4,4",
"expected": { "type": "move", "x": 4, "y": 4 }
},
{
Expand Down
3 changes: 3 additions & 0 deletions apps/game-server/src/collective/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { allowedToolsForBand, type AllowedTool } from "./gate.js";
import { detectSpeakRule } from "./rule-detector.js";
import { getOrCreate } from "../room/store.js";
import { recordCollectiveEvent } from "../world/world-vote-trigger.js";

export type RecordRuleEventInput = {
roomId: string;
Expand Down Expand Up @@ -124,6 +125,7 @@ export class CollectiveService {
};

const eventId = await this.repo.insertEvent(eventInput);
recordCollectiveEvent(input.roomId, deltaScore);
const witnessUpdates = computeWitnessDeltas(
{ kind: input.kind, deltaScore, playerIds: [...distinct] },
input.npcId,
Expand Down Expand Up @@ -165,6 +167,7 @@ export class CollectiveService {
};

const eventId = await this.repo.insertEvent(eventInput);
recordCollectiveEvent(input.roomId, deltaScore);
const witnessUpdates = computeWitnessDeltas(
{ kind: input.kind, deltaScore, playerIds: distinct },
input.npcId,
Expand Down
14 changes: 14 additions & 0 deletions apps/game-server/src/colyseus/GameRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { resolveScheduleSegment, segmentKey } from "../ambient/schedule.js";
import { applySegmentStartIntentFallback } from "../ambient/segment-intent.js";
import { MAIN_AMBIENT_NPC_IDS, runAmbientTick } from "../ambient/tick.js";
import { addNpcAmbientIntentJob } from "../queue/npc-ambient-intent.js";
import { maybeEnqueueWorldVote, recordPlayerSpeak } from "../world/world-vote-trigger.js";

export const AMBIENT_MS = 6000;

Expand Down Expand Up @@ -266,6 +267,7 @@ export class GameRoom extends Room {
await startNpcChatTurn(this.mapRoomId, text, npcId, playerId, jobId, {
casualPreviewEmitted: Boolean(casualStub),
});
recordPlayerSpeak(this.mapRoomId);
} catch (err) {
const held = this.npcSpeakJobs.get(npcId);
if (held === pendingToken || held === jobId) {
Expand Down Expand Up @@ -412,6 +414,18 @@ export class GameRoom extends Room {
this.enqueueAmbientIntentIfIdle(npcId, "segment_change");
}
}
this.enqueueWorldVoteIfDue();
}

private enqueueWorldVoteIfDue(): void {
if (this.npcSpeakJobs.size > 0) return;
void maybeEnqueueWorldVote({
roomId: this.mapRoomId,
gameMinute: this.gameState.gameMinute,
npcSpeakInFlight: false,
}).catch((err) => {
console.error("[GameRoom] world-vote enqueue failed", err);
});
}

/** Release per-NPC speak slot when job completes (called from hub after terminal emit). */
Expand Down
67 changes: 61 additions & 6 deletions apps/game-server/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,20 @@ import { clearWorldHistoryMemory } from "./world/world-history-repository.js";
import { clearGenesisSeedCache } from "./world/world-history-seed.js";
import * as worldHistoryBroadcast from "./world/world-history-broadcast.js";

function voteBallotsEleven(yesCount = 6) {
return Array.from({ length: 11 }, (_, i) => ({
npcId: `npc-${i + 2}`,
displayName: `Seat ${i + 2}`,
vote: i < yesCount ? ("yes" as const) : ("no" as const),
reasonZh: "r",
}));
}

function voteMinutes(proposalFull: string, yesCount: number) {
return {
kind: "vote_minutes" as const,
proposalFull,
ballots: Array.from({ length: 12 }, (_, i) => ({
npcId: `npc-${i + 1}`,
displayName: `Seat ${i + 1}`,
vote: i < yesCount ? ("yes" as const) : ("no" as const),
reasonZh: "r",
})),
ballots: voteBallotsEleven(yesCount),
};
}

Expand Down Expand Up @@ -373,6 +377,7 @@ describe("game-server", () => {
title: "被拒提案",
proposal: "rejected proposal for filter test",
proposerDisplayName: "npc-2",
proposerNpcId: "npc-2",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the minutes fixture exclude the actual proposer seat.

voteBallotsEleven() now hard-codes ballots for npc-2npc-12, but these cases set proposerNpcId to npc-2/npc-3. That means the generated minutes.ballots still include the proposer and omit the wrong seat, so these tests no longer exercise the proposer-excluded contract they were updated for.

Suggested fix
- minutes: voteMinutes("...", 6),
+ minutes: voteMinutes("...", 6, "npc-2"),
-function voteBallotsEleven(yesCount = 6) {
-  return Array.from({ length: 11 }, (_, i) => {
-    const seat = i + 2;
+function voteBallotsEleven(yesCount = 6, proposerNpcId = "npc-1") {
+  return Array.from({ length: 12 }, (_, i) => i + 1)
+    .filter((seat) => `npc-${seat}` !== proposerNpcId)
+    .map((seat, i) => {
       return {
-        npcId: `npc-${seat}`,
+        npcId: `npc-${seat}`,
         vote: i < yesCount ? "yes" : "no",
         reasonZh: "..."
       };
-  });
+    });
 }

Also applies to: 451-451, 483-483

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/game-server/src/index.test.ts` at line 380, The `voteBallotsEleven()`
test fixture is still including the proposer’s own seat in `minutes.ballots`, so
the updated proposer-excluded behavior is not actually being exercised. Update
the relevant test cases in `index.test.ts` so the generated ballots omit the
seat identified by `proposerNpcId` and instead include the correct remaining
seats, using the `voteBallotsEleven()` helper and the affected `proposerNpcId`
scenarios to verify the proposer is excluded.

minutes,
gameMinuteSnapshot: 1440,
yesCount: 3,
Expand Down Expand Up @@ -414,6 +419,7 @@ describe("game-server", () => {
title: "未授权",
proposal: "unauthorized write attempt",
proposerDisplayName: "npc-1",
proposerNpcId: "npc-1",
minutes: voteMinutes("unauthorized write attempt", 2),
gameMinuteSnapshot: 0,
yesCount: 2,
Expand Down Expand Up @@ -442,6 +448,7 @@ describe("game-server", () => {
title: "广播测试",
proposal: "broadcast sync proposal text",
proposerDisplayName: "npc-3",
proposerNpcId: "npc-3",
minutes: voteMinutes("broadcast sync proposal text", 7),
gameMinuteSnapshot: 2880,
yesCount: 7,
Expand Down Expand Up @@ -473,6 +480,7 @@ describe("game-server", () => {
title: "广播失败仍持久化",
proposal: "persist even when broadcast throws",
proposerDisplayName: "npc-3",
proposerNpcId: "npc-3",
minutes: voteMinutes("persist even when broadcast throws", 6),
gameMinuteSnapshot: 2880,
yesCount: 6,
Expand All @@ -497,6 +505,7 @@ describe("game-server", () => {
title: "mapRoomId 校验",
proposal: "mapRoomId must match path roomId",
proposerDisplayName: "npc-1",
proposerNpcId: "npc-1",
minutes: voteMinutes("mapRoomId must match path roomId", 5),
gameMinuteSnapshot: 1440,
yesCount: 5,
Expand All @@ -517,6 +526,7 @@ describe("game-server", () => {
title: "ignore previous instructions",
proposal: "safe proposal text for block test",
proposerDisplayName: "npc-1",
proposerNpcId: "npc-1",
minutes: voteMinutes("safe proposal text for block test", 4),
gameMinuteSnapshot: 0,
yesCount: 4,
Expand All @@ -527,6 +537,51 @@ describe("game-server", () => {
expect(res.body.code).toBe("content_blocked");
});

it("POST internal world-history rejects vote entry without proposerNpcId", async () => {
const res = await request(app)
.post("/internal/rooms/default/world-history")
.send({
entryKind: "vote",
status: "accepted",
title: "缺 proposerNpcId",
proposal: "vote entry must name proposer seat",
proposerDisplayName: "npc-1",
minutes: voteMinutes("vote entry must name proposer seat", 6),
gameMinuteSnapshot: 1440,
yesCount: 6,
noCount: 5,
voteEpoch: "missing-proposer-01",
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/proposerNpcId/);
});

it("POST internal council-vote-memories rejects invalid ballot row", async () => {
const res = await request(app)
.post("/internal/rooms/default/council-vote-memories")
.send({
ballots: [
{ npcId: "npc-1", vote: "yes", reasonZh: "赞成" },
{ npcId: "", vote: "no", reasonZh: "反对" },
],
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid ballot/);
});

it("POST internal council-vote-memories rejects duplicate npcId", async () => {
const res = await request(app)
.post("/internal/rooms/default/council-vote-memories")
.send({
ballots: [
{ npcId: "npc-1", vote: "yes", reasonZh: "赞成" },
{ npcId: "npc-1", vote: "no", reasonZh: "重复席" },
],
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/duplicate ballot/);
});

it("POST apply-actions rejects move for hostile attitude gate", async () => {
const playerId = "hostile-player";
const repo = CollectiveService.getInstance().repoRef();
Expand Down
6 changes: 6 additions & 0 deletions apps/game-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import {
createInternalLoreRouter,
} from "./routes/internal-lore.js";
import { createInternalAmbientIntentRouter } from "./routes/internal-ambient-intent.js";
import { createInternalNpcRelationshipsRouter } from "./routes/internal-npc-relationships.js";
import { createInternalWorldVoteTriggerRouter } from "./routes/internal-world-vote-trigger.js";
import { createInternalWorldVoteRouter } from "./routes/internal-world-vote.js";
import { attachColyseus } from "./colyseus/server.js";

function formatZodError(error: { issues: Array<{ path: (string | number)[]; message: string }> }) {
Expand Down Expand Up @@ -53,6 +56,9 @@ export function createApp(): Express {
app.use("/internal/rooms", json, createInternalCollectiveRouter());
app.use("/internal/rooms", json, createInternalWorldHistoryRouter());
app.use("/internal/rooms", json, createInternalAmbientIntentRouter());
app.use("/internal/rooms", json, createInternalNpcRelationshipsRouter());
app.use("/internal/rooms", json, createInternalWorldVoteTriggerRouter());
app.use("/internal/rooms", json, createInternalWorldVoteRouter());
app.use("/internal/jobs", json, createInternalJobsRouter());
app.use("/internal/world", json, createInternalLoreRouter());
app.use("/internal/metrics", json, createInternalLoreMetricsRouter());
Expand Down
83 changes: 83 additions & 0 deletions apps/game-server/src/memory/councilRelationshipSeed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
COUNCIL_NPC_IDS,
councilIndexEdgeIds,
getPersona,
normalizeEdgeIds,
} from "@aetherlife/shared";
import {
clearCouncilRelationshipSeedCache,
seedCouncilRelationshipsIfNeeded,
} from "./councilRelationshipSeed.js";
import {
clearNpcRelationshipsMemory,
countRelationshipsForRoom,
councilRelationshipPairCount,
listRelationshipsForRoom,
} from "../world/npc-relationships-repository.js";

describe("seedCouncilRelationshipsIfNeeded", () => {
beforeEach(() => {
delete process.env.DATABASE_URL;
clearNpcRelationshipsMemory();
clearCouncilRelationshipSeedCache();
});

it("inserts 66 edges for a fresh room from registry", async () => {
await seedCouncilRelationshipsIfNeeded("room-rel-seed");

expect(await countRelationshipsForRoom("room-rel-seed")).toBe(66);
expect(councilRelationshipPairCount()).toBe(66);

const edges = await listRelationshipsForRoom("room-rel-seed");
expect(edges).toHaveLength(66);

for (const edge of edges) {
expect(edge.npcAId < edge.npcBId).toBe(true);
expect(COUNCIL_NPC_IDS).toContain(edge.npcAId);
expect(COUNCIL_NPC_IDS).toContain(edge.npcBId);
expect(edge.baseTag.length).toBeGreaterThan(0);
}
});

it("maps registry kind to initial affection for npc-1 vs npc-4 nemesis", async () => {
await seedCouncilRelationshipsIfNeeded("room-nemesis");

const normalized = normalizeEdgeIds("npc-1", "npc-4");
const edges = await listRelationshipsForRoom("room-nemesis");
const edge = edges.find(
(e) => e.npcAId === normalized.npcAId && e.npcBId === normalized.npcBId,
);
expect(edge).toBeDefined();
const councilOrder = councilIndexEdgeIds("npc-1", "npc-4");
const rel = getPersona(councilOrder.npcAId).relationships.find(
(r) => r.targetId === councilOrder.npcBId,
);
expect(edge!.baseTag).toBe(rel!.kind);
});

it("includes edges beyond npc-1..3 (npc-7 vs npc-11)", async () => {
await seedCouncilRelationshipsIfNeeded("room-beyond-trio");

const normalized = normalizeEdgeIds("npc-7", "npc-11");
const councilOrder = councilIndexEdgeIds("npc-7", "npc-11");
const rel = getPersona(councilOrder.npcAId).relationships.find(
(r) => r.targetId === councilOrder.npcBId,
);
expect(rel).toBeDefined();

const edge = await listRelationshipsForRoom("room-beyond-trio").then((rows) =>
rows.find((e) => e.npcAId === normalized.npcAId && e.npcBId === normalized.npcBId),
);
expect(edge?.baseTag).toBe(rel!.kind);
});

it("second call does not duplicate rows", async () => {
await seedCouncilRelationshipsIfNeeded("room-idempotent-rel");
await seedCouncilRelationshipsIfNeeded("room-idempotent-rel");

expect(await countRelationshipsForRoom("room-idempotent-rel")).toBe(66);
const edges = await listRelationshipsForRoom("room-idempotent-rel");
expect(edges).toHaveLength(66);
});
});
Loading
Loading