Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
964cd40
test(29-01): add failing forgetting-curve score tests
moyunzero Jul 30, 2026
f93f4bd
feat(29-01): implement Ebbinghaus recency scoring helpers
moyunzero Jul 30, 2026
64cde5f
test(29-01): add SQL/TestMemoryBackend forgetting-curve parity tests
moyunzero Jul 30, 2026
e0154b8
feat(29-01): sync forgetting-curve score in SQL and TestMemoryBackend
moyunzero Jul 30, 2026
325eef6
test(29-02): add failing test for Redis dialogue session
moyunzero Jul 30, 2026
e571e29
feat(29-02): implement Redis+Map dialogue session layer
moyunzero Jul 30, 2026
eda7339
feat(29-02): wire getRecentTurnsAsync in npc-chat job path
moyunzero Jul 30, 2026
887e050
test(29-03): add failing verify:pgvector script entry
moyunzero Jul 30, 2026
662e6eb
feat(29-03): implement pgvector halfvec capability probe
moyunzero Jul 30, 2026
2b1fabd
feat(29-03): add halfvec HNSW and attitude semantic migrations
moyunzero Jul 30, 2026
ee23796
test(29-04): add failing tests for overfetch + forgetting-curve rerank
moyunzero Jul 30, 2026
bebec88
feat(29-04): distance overfetch then forgetting-curve rerank
moyunzero Jul 30, 2026
d346d89
test(29-05): add failing test for Chinese mood clamp
moyunzero Jul 30, 2026
8985f6a
feat(29-05): implement clampSemanticState Chinese 8-mood
moyunzero Jul 30, 2026
1b89081
test(29-05): add failing tests for attitude semantic upsert
moyunzero Jul 30, 2026
df95576
feat(29-05): upsert semantic attitudes without reputation wipe
moyunzero Jul 30, 2026
620607b
test(29-09): add failing test for relationshipPropagation
moyunzero Jul 30, 2026
eaa5764
feat(29-09): implement computeRelationshipPropagationDeltas
moyunzero Jul 30, 2026
ece70bf
test(29-09): add failing collective propagation service tests
moyunzero Jul 30, 2026
54d8c0a
feat(29-09): wire 2-hop relationship propagation in CollectiveService
moyunzero Jul 30, 2026
67fba72
test(29-06): add failing structured reflect tests
moyunzero Jul 30, 2026
befe9fa
feat(29-06): implement run_reflect_llm_structured
moyunzero Jul 30, 2026
7e6c2f1
test(29-06): add failing storeReflection semantic tests
moyunzero Jul 30, 2026
abb6526
feat(29-06): wire structured reflect to attitudes write
moyunzero Jul 30, 2026
6135493
feat(29-07): CollectiveContext carries attitude semantic
moyunzero Jul 30, 2026
a3390a8
test(29-07): add failing tests for attitude semantic injection
moyunzero Jul 30, 2026
62c36ef
feat(29-07): inject semantic into speak attitude context
moyunzero Jul 30, 2026
96b416c
docs(29-07): CONTRACTS C-05 public strip for semantic
moyunzero Jul 30, 2026
ed33d56
fix(29-08): align speak benchmark T_think with composer aria-busy
moyunzero Jul 30, 2026
78b7313
docs(29-08): record Phase 29 speak benchmark REGRESS
moyunzero Jul 30, 2026
7708698
fix(29): narrow reply audit and add Map-evict D-SESS UAT
moyunzero Jul 30, 2026
ba71e38
docs: credit Fantasy Tileset for Beginning Fields home map
moyunzero Jul 30, 2026
b82d1d2
test(29): mock relationship list in CollectiveService unit tests
moyunzero Jul 30, 2026
7c4bcac
fix(29): address CR — COALESCE parity, PROP overfetch, rehydrate race
moyunzero Jul 30, 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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ EMBED_BASE_URL=https://openrouter.ai/api/v1
REFLECT_EVERY_N=5
SUMMARIZE_THRESHOLD=100
SUMMARIZE_BATCH_SIZE=50
# Phase 29 — vector retrieval forgetting curve (Ebbinghaus; S = max(ε, S0 × importance/5))
MEMORY_RECENCY_HALFLIFE_HOURS=72
MEMORY_RECENCY_FLOOR=0.3
MEMORY_RECENCY_S_EPSILON=0.001

# Phase 11 — chunk lore LLM (T0/T1 tiering)
LLM_PROVIDER_LORE=agnes
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ AI-driven multiplayer life-simulation web game: guide memory-bearing NPCs throug
- [Testing](#testing)
- [Documentation](#documentation)
- [Contributing](#contributing)
- [Asset Credits](#asset-credits)
- [License](#license)

## About
Expand Down Expand Up @@ -230,6 +231,12 @@ Action schema: [packages/game-actions/README.md](./packages/game-actions/README.

Issues and PRs welcome. Read [CONTRIBUTING.md](./CONTRIBUTING.md) first. Never commit `.env` or API keys.

## Asset Credits

| Asset | Author | Use |
|-------|--------|-----|
| [The Fantasy Tileset](https://ventilatore.itch.io/the-fantasy-tileset) | [Ventilatore](https://ventilatore.itch.io) | Beginning Fields home map (16×16 tiles, Tiled) |

## License

[MIT](./LICENSE)
7 changes: 7 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ AI 驱动的多人联机生活模拟 Web 游戏:与拥有记忆的 NPC 用自
- [测试](#测试)
- [文档](#文档)
- [贡献](#贡献)
- [素材贡献](#素材贡献)
- [License](#license)

## 项目简介
Expand Down Expand Up @@ -231,6 +232,12 @@ Action schema:[packages/game-actions/README.md](./packages/game-actions/README

欢迎 Issue 与 PR。请先阅读 [CONTRIBUTING.md](./CONTRIBUTING.md)。请勿提交 `.env` 或 API 密钥。

## 素材贡献

| 素材 | 作者 | 用途 |
|------|------|------|
| [The Fantasy Tileset](https://ventilatore.itch.io/the-fantasy-tileset) | [Ventilatore](https://ventilatore.itch.io) | 始源区(Beginning Fields)家园地图(16×16 瓦片,Tiled) |

## License

[MIT](./LICENSE)
9 changes: 6 additions & 3 deletions apps/ai-gateway/app/guards/reply.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
import re
from typing import Any

FALLBACK_REPLY = "我还不能确定能否那样做——让我先试着与房间互动。"
FALLBACK_REPLY = "抱歉,我这边还做不到。"

STATE_CHANGE_PATTERNS = [
re.compile(r"\b(opened|closed|moved|picked up|picked|walked|went to|entered|left)\b", re.I),
re.compile(r"(打开了|关闭了|移动到|走向|拿起了|捡起了|去把门打开|就去打开|把门打开|我现在就去)", re.I),
re.compile(r"\b(opened|closed|picked up|walked|went to|entered|moved to)\b", re.I),
re.compile(
r"(打开了|关闭了|移动到|走向了|走向门|拿起了|捡起了|去把门打开|就去打开|把门打开|我现在就去(?:把门|打开|拿|捡|走|过去))",
re.I,
),
Comment on lines 10 to +15

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 | 🟠 Major | ⚡ Quick win

Constrain duplicated state-change patterns to affirmative physical actions.

Both guards can replace legitimate dialogue with the fallback because unanchored phrases match figurative, interrogative, and negated text.

  • apps/ai-gateway/app/guards/reply.py#L10-L15: refine the English/Chinese alternatives and add question/negation regression cases.
  • workers/agent-worker/src/guard/reply_audit.py#L8-L11: apply the identical refined patterns and tests.
📍 Affects 2 files
  • apps/ai-gateway/app/guards/reply.py#L10-L15 (this comment)
  • workers/agent-worker/src/guard/reply_audit.py#L8-L11
🤖 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/ai-gateway/app/guards/reply.py` around lines 10 - 15, Constrain the
duplicated state-change regex alternatives to affirmative physical actions,
preventing matches within figurative, interrogative, or negated dialogue while
preserving valid action detection. Apply the identical refined English and
Chinese patterns in apps/ai-gateway/app/guards/reply.py lines 10-15 and
workers/agent-worker/src/guard/reply_audit.py lines 8-11, and add regression
tests covering question and negation cases at both sites.

]

STATE_CHANGING_TOOLS = frozenset({"move", "interact", "transfer", "wait"})
Expand Down
9 changes: 9 additions & 0 deletions apps/ai-gateway/tests/test_check_reply.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,12 @@ def test_allows_with_interact_tool():
def test_allows_neutral():
text = "需要我帮你做什么吗?"
assert audit_reply(text, []) == text


def test_allows_emotional_moved_without_tools():
reply = "I'm moved that you asked, but I won't say my age."
assert audit_reply(reply, []) == reply


def test_fallback_is_chat_neutral():
assert "房间" not in FALLBACK_REPLY
167 changes: 165 additions & 2 deletions apps/game-server/src/collective/service.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,37 @@
import { describe, expect, it, beforeEach } from "vitest";
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { CollectiveRepository } from "@aetherlife/npc-memory";
import { KIND_FIXED_DELTA, personalitySeedForNpc } from "@aetherlife/shared";
import {
KIND_FIXED_DELTA,
PROPAGATION_MAX_FANOUT,
personalitySeedForNpc,
type RelationshipEdgePublic,
} from "@aetherlife/shared";
import { CollectiveService } from "./service.js";
import * as npcRelationships from "../world/npc-relationships-repository.js";

function propEdge(
partial: Pick<RelationshipEdgePublic, "npcAId" | "npcBId" | "baseTag" | "affection">,
): RelationshipEdgePublic {
return {
trust: 50,
interactionCount: 0,
lastInteractAt: null,
currentStatus: [],
historySummary: "",
updatedAt: new Date().toISOString(),
...partial,
};
}

describe("CollectiveService", () => {
beforeEach(() => {
CollectiveService.resetForTests(new CollectiveRepository(null));
// Avoid real Postgres when DATABASE_URL is set (propagation list can hang / flake).
vi.spyOn(npcRelationships, "listRelationshipsForRoom").mockResolvedValue([]);
});

afterEach(() => {
vi.restoreAllMocks();
});

it("records event when two players in window", async () => {
Expand Down Expand Up @@ -86,4 +112,141 @@ describe("CollectiveService", () => {
const rep = await repo.getAttitude("solo-worker", "npc-1", "p-a");
expect(rep).toBe(personalitySeedForNpc("npc-1") + (-8));
});

it("propagation applies friend reputation without extra collective events", async () => {
const listSpy = vi.spyOn(npcRelationships, "listRelationshipsForRoom").mockResolvedValue([
propEdge({
npcAId: "npc-1",
npcBId: "npc-friend",
baseTag: "ally",
affection: 50,
}),
]);

const svc = CollectiveService.getInstance();
const repo = svc.repoRef();
const result = await svc.recordRuleEvent({
roomId: "prop-room",
npcId: "npc-1",
kind: "rude",
summary: "玩家粗鲁",
playerIds: ["p-a"],
npcPositions: new Map([["npc-1", { x: 0, y: 0 }]]),
singlePlayerOk: true,
});

expect(listSpy).toHaveBeenCalledWith(
"prop-room",
expect.objectContaining({
npcId: "npc-1",
limit: PROPAGATION_MAX_FANOUT * 5,
}),
);
expect(result).toEqual(expect.objectContaining({ recorded: true }));
const events = await repo.listEventsInWindow("prop-room", "npc-1", 300_000);
expect(events).toHaveLength(1);

const friendRep = await repo.getAttitude("prop-room", "npc-friend", "p-a");
expect(friendRep).toBe(personalitySeedForNpc("npc-friend") + Math.round(-8 * 0.3));
});

it("recordWorkerEvent propagation keeps event count === 1", async () => {
vi.spyOn(npcRelationships, "listRelationshipsForRoom").mockResolvedValue([
propEdge({
npcAId: "npc-1",
npcBId: "npc-friend",
baseTag: "ally",
affection: 50,
}),
]);

const svc = CollectiveService.getInstance();
const repo = svc.repoRef();
await svc.recordWorkerEvent({
roomId: "prop-worker",
npcId: "npc-1",
kind: "rude",
summary: "worker rude",
playerIds: ["p-a"],
deltaScore: -8,
npcPositions: new Map([["npc-1", { x: 0, y: 0 }]]),
});

const events = await repo.listEventsInWindow("prop-worker", "npc-1", 300_000);
expect(events).toHaveLength(1);
const friendRep = await repo.getAttitude("prop-worker", "npc-friend", "p-a");
expect(friendRep).toBe(personalitySeedForNpc("npc-friend") - 2);
});

it("relationship list failure does not throw out of recordRuleEvent", async () => {
vi.spyOn(npcRelationships, "listRelationshipsForRoom").mockRejectedValueOnce(
new Error("db unavailable"),
);

const svc = CollectiveService.getInstance();
const repo = svc.repoRef();
const result = await svc.recordRuleEvent({
roomId: "prop-fail",
npcId: "npc-1",
kind: "help",
summary: "help",
playerIds: ["p-a", "p-b"],
npcPositions: new Map([["npc-1", { x: 0, y: 0 }]]),
});

expect(result).toEqual(expect.objectContaining({ recorded: true }));
const rep = await repo.getAttitude("prop-fail", "npc-1", "p-a");
expect(rep).toBe(personalitySeedForNpc("npc-1") + KIND_FIXED_DELTA.help);
});

it("getCollectiveContext carries semantic from attitude row (D-BELIEF-05)", async () => {
const svc = CollectiveService.getInstance();
const repo = svc.repoRef();
await repo.upsertSemanticState("r-sem-ctx", "npc-1", "p-a", {
mood: "恼火",
beliefs: ["我不信他的承诺"],
summary: "玩家曾失信",
});

const ctx = await svc.getCollectiveContext("r-sem-ctx", "npc-1", "p-a");
expect(ctx.currentMood).toBe("恼火");
expect(ctx.keyBeliefs).toEqual(["我不信他的承诺"]);
expect(ctx.summary).toBe("玩家曾失信");
expect(ctx.band).toBeTruthy();
expect(ctx.allowedTools.length).toBeGreaterThan(0);
expect(typeof ctx.playerReputation).toBe("number");
});

it("getCollectiveContext omits semantic when no attitude row", async () => {
const svc = CollectiveService.getInstance();
const ctx = await svc.getCollectiveContext("r-no-att", "npc-1", "p-missing");
expect(ctx.currentMood).toBeNull();
expect(ctx.keyBeliefs).toBeNull();
expect(ctx.summary).toBeNull();
expect(ctx.playerReputation).toBe(personalitySeedForNpc("npc-1"));
});

it("getCollectiveState public payload strips mood/beliefs/summary (D-BELIEF-11)", async () => {
const svc = CollectiveService.getInstance();
const repo = svc.repoRef();
await repo.upsertSemanticState("default", "npc-1", "p-pub", {
mood: "戏谑",
beliefs: ["他爱开玩笑"],
summary: "内部摘要勿泄露",
});

const ctx = await svc.getCollectiveContext("default", "npc-1", "p-pub");
expect(ctx.currentMood).toBe("戏谑");

const publicPayload = await svc.getCollectiveState("default", "p-pub", "npc-1");
const row = publicPayload.attitudes.find((a) => a.npcId === "npc-1");
expect(row).toBeDefined();
expect(row).not.toHaveProperty("currentMood");
expect(row).not.toHaveProperty("keyBeliefs");
expect(row).not.toHaveProperty("summary");
expect(row).not.toHaveProperty("mood");
expect(row).not.toHaveProperty("beliefs");
expect(JSON.stringify(publicPayload)).not.toContain("戏谑");
expect(JSON.stringify(publicPayload)).not.toContain("内部摘要勿泄露");
});
});
Loading
Loading