Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,12 @@ pnpm dev:stack
| 10 Chunk 地形 | `pnpm verify:phase10` |
| 11 世界 lore | `pnpm verify:phase11` |
| 12 集体记忆 | `pnpm verify:phase12` |
| 13 视觉 / 铭牌 | `pnpm verify:phase13` · `pnpm uat:phase13:playwright` |
| 14 Living NPCs | `pnpm verify:phase14` |
| 15 Town loop | `pnpm verify:phase15` · `pnpm uat:phase15:playwright` |
| 16 Ambient NPCs | `pnpm verify:phase16` · `pnpm uat:phase16:playwright` |
| 17 Speak SLA | `pnpm agent:verify --e2e --base`(无独立 `verify:phase17`) |

Golden Flows 回归:`pnpm agent:verify --e2e`(见 [docs/E2E-POLICY.md §8](./docs/E2E-POLICY.md#8-golden-flowsagent-迭代回归预言机))。

环境变量详见根目录 [`.env.example`](./.env.example)。
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ AI 驱动的多人联机生活模拟 Web 游戏:与拥有记忆的 NPC 用自
- **自然语言指挥** — 通过 ai-gateway 解析意图,worker 异步执行 NPC 回合
- **持久记忆** — Postgres + pgvector,NPC 会记住互动并影响后续行为
- **Phaser 4 世界** — 2D 像素「地球Online」风:网格移动、程序化 chunk 地形与世界 lore
- **智能 Ambient NPC(v3)** — schedule/zone 漫游、异步 LLM intent 副行、Tiled 碰撞与 village-plaza 跨区(Phase 16)
- **Speak SLA(v3)** — worker-state / memory-context 缓存、stale fallback、并行 speak 多 Tab 不丢回复(Phase 17 / ISSUE-048)
- **集体态度** — NPC 对玩家/群体的态度随行为演化(Phase 12+)

## 技术栈
Expand Down Expand Up @@ -89,14 +91,16 @@ flowchart LR
pnpm turbo test
pnpm turbo build
pnpm verify # build + test + verify:cloud
pnpm agent:verify # diff → mapped unit tests (mock LLM OK)

# 跨层单测(可 mock LLM)
pnpm --filter @aetherlife/game-server test
cd workers/agent-worker && LLM_MOCK=1 uv run pytest -q
cd apps/ai-gateway && uv run pytest tests -q
```

Phase 集成验收(需 `pnpm dev:stack` + 真实 API Key):见 [CONTRIBUTING.md](./CONTRIBUTING.md#集成验收)。
Phase 集成验收(需 `pnpm dev:stack` + 真实 API Key):见 [CONTRIBUTING.md](./CONTRIBUTING.md#集成验收)。
v3 Speak SLA / Golden Flows:`pnpm agent:verify --e2e --base`(见 [docs/E2E-POLICY.md](./docs/E2E-POLICY.md))。

Action schema:[packages/game-actions/README.md](./packages/game-actions/README.md)

Expand All @@ -108,6 +112,8 @@ Action schema:[packages/game-actions/README.md](./packages/game-actions/README
| [docs/CONTRACTS.md](./docs/CONTRACTS.md) | game-server ↔ worker API 契约 |
| [docs/INVARIANTS-MULTIPLAYER.md](./docs/INVARIANTS-MULTIPLAYER.md) | 多人空间与 NL 不变量 |
| [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) | 阶段演进与跨层防债务 |

## 贡献

Expand Down
3 changes: 3 additions & 0 deletions apps/ai-gateway/app/guards/content.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import logging
import os
import re
from dataclasses import dataclass

Expand Down Expand Up @@ -53,6 +54,8 @@ async def check(self, text: str) -> GuardResult:
)
if res.status_code >= 400:
logger.warning("moderation API error %s", res.status_code)
if os.getenv("NODE_ENV") == "production":
return GuardResult(False, "moderation unavailable")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return GuardResult(True)
data = res.json()
flagged = data.get("results", [{}])[0].get("flagged", False)
Expand Down
4 changes: 4 additions & 0 deletions apps/ai-gateway/app/main.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import os

from fastapi import FastAPI
from fastapi.responses import JSONResponse

Expand All @@ -17,4 +19,6 @@ def health():

@app.exception_handler(Exception)
async def unhandled_exception_handler(_request, exc):
if os.getenv("NODE_ENV") == "production":
return JSONResponse(status_code=500, content={"ok": False, "error": "internal server error"})
return JSONResponse(status_code=500, content={"ok": False, "error": str(exc)})
33 changes: 31 additions & 2 deletions apps/ai-gateway/app/services/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import httpx

from app.config import get_settings
from app.models.actions import validate_nl_action

PARSE_SYSTEM = """You extract a single game action as JSON from the player message.
Return ONLY valid JSON with one object matching exactly one of:
Expand Down Expand Up @@ -75,6 +76,30 @@ def _heuristic_parse(message: str) -> dict[str, Any]:
return {"type": "wait", "durationMs": 1000}


def _parse_openrouter_content(body: object) -> dict[str, Any]:
if not isinstance(body, dict):
raise ValueError("OpenRouter response is not an object")
choices = body.get("choices")
if not isinstance(choices, list) or not choices:
raise ValueError("OpenRouter response missing choices")
first = choices[0]
if not isinstance(first, dict):
raise ValueError("OpenRouter choice is not an object")
message = first.get("message")
if not isinstance(message, dict):
raise ValueError("OpenRouter choice missing message")
content = message.get("content")
if not isinstance(content, str) or not content.strip():
raise ValueError("OpenRouter message content empty")
parsed = json.loads(content)
if not isinstance(parsed, dict):
raise ValueError("OpenRouter content is not a JSON object")
action, err = validate_nl_action(parsed)
if err or not action:
raise ValueError(err or "invalid NL action")
return action


async def parse_intent_json(message: str, *, golden_expected: dict | None = None) -> dict[str, Any]:
"""Return raw action dict before Pydantic validation."""
settings = get_settings()
Expand Down Expand Up @@ -114,13 +139,17 @@ async def parse_intent_json(message: str, *, golden_expected: dict | None = None
)
res.raise_for_status()
body = res.json()
content = body["choices"][0]["message"]["content"]
return json.loads(content)
return _parse_openrouter_content(body)
except httpx.HTTPStatusError as exc:
last_error = exc
if exc.response.status_code == 429 and key_idx + 1 < len(keys):
continue
raise
except (json.JSONDecodeError, ValueError) as exc:
last_error = exc
if key_idx + 1 < len(keys):
continue
break
if last_error:
raise last_error
return _heuristic_parse(message)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
27 changes: 27 additions & 0 deletions apps/ai-gateway/tests/test_openrouter_parse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import pytest

from app.services.llm import _parse_openrouter_content


def test_parse_openrouter_content_valid_move():
body = {
"choices": [
{"message": {"content": '{"type":"move","x":3,"y":4}'}}
]
}
assert _parse_openrouter_content(body) == {"type": "move", "x": 3.0, "y": 4.0}


def test_parse_openrouter_content_rejects_invalid_action():
body = {
"choices": [
{"message": {"content": '{"type":"speak","targetId":"","content":"hi"}'}}
]
}
with pytest.raises(ValueError, match="String should have at least 1 character"):
_parse_openrouter_content(body)


def test_parse_openrouter_content_rejects_malformed_envelope():
with pytest.raises(ValueError, match="missing choices"):
_parse_openrouter_content({"choices": []})
5 changes: 3 additions & 2 deletions apps/game-server/src/ambient/intent-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,9 @@ function applyIntentToLiveRoom(

const colyseus = getColyseusRoom(roomId);
if (!colyseus) return;
syncColyseusFromMap(colyseus.state, map);
bumpStateVersion(colyseus.state);
const state = colyseus.state as GameRoomState;
syncColyseusFromMap(state, map);
bumpStateVersion(state);
}

/** Test hook — wipe all cached intents. */
Expand Down
11 changes: 11 additions & 0 deletions apps/game-server/src/collective/action-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ export function clearActionTrackersForRoom(roomId: string): void {
}
}

/** Clear compete/collaborate windows for one initiator (multiplayer reset). */
export function clearActionTrackersForPlayer(roomId: string, playerId: string): void {
const prefix = `${roomId}:`;
for (const [key, entry] of recentByObject.entries()) {
if (key.startsWith(prefix) && entry.playerId === playerId) recentByObject.delete(key);
}
for (const [key, entry] of recentByNpc.entries()) {
if (key.startsWith(prefix) && entry.playerId === playerId) recentByNpc.delete(key);
}
}

/** @internal vitest */
export function clearAllActionTrackers(): void {
recentByObject.clear();
Expand Down
7 changes: 7 additions & 0 deletions apps/game-server/src/collective/move-intent-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ export class MoveIntentTracker {
}
}

clearForPlayer(roomId: string, playerId: string): void {
const suffix = `:${playerId}`;
for (const k of [...this.intents.keys()]) {
if (k.startsWith(`${roomId}:`) && k.endsWith(suffix)) this.intents.delete(k);
}
}

clearAll(): void {
this.intents.clear();
}
Expand Down
35 changes: 29 additions & 6 deletions apps/game-server/src/colyseus/GameRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,29 @@ export class GameRoom extends Room {
/** Per-session move pipeline — async chunk load must not interleave dx/dy handlers. */
private moveQueueTail = new Map<string, Promise<void>>();

private sendMoveAckForClient(client: Client, raw: ColyseusMovePayload): void {
const clientSeq =
raw && typeof raw === "object" && typeof raw.clientSeq === "number"
? raw.clientSeq
: undefined;
if (clientSeq === undefined) return;
const player = this.gameState.players.get(client.sessionId);
if (!player) return;
client.send(COLYSEUS_SERVER_MESSAGES.moveAck, {
clientSeq,
x: player.x,
y: player.y,
facing: player.facing as Facing,
});
}

private enqueuePlayerMove(client: Client, raw: ColyseusMovePayload): void {
const sid = client.sessionId;
const tail = this.moveQueueTail.get(sid) ?? Promise.resolve();
const run = tail.then(() => this.processPlayerMove(client, raw));
const settled = run.catch((err) => {
console.error("[GameRoom] move failed", err);
this.sendMoveAckForClient(client, raw);
});
this.moveQueueTail.set(sid, settled);
}
Expand Down Expand Up @@ -214,7 +231,8 @@ export class GameRoom extends Room {
this.onMessage(COLYSEUS_CLIENT_MESSAGES.speak, async (client, raw: ColyseusSpeakPayload) => {
const text = validateChatMessage(raw?.text);
const npcId = validateChatNpcId(this.mapRoomId, raw?.npcId);
const playerId = normalizePlayerId(raw?.playerId);
const player = this.gameState.players.get(client.sessionId);
const playerId = normalizePlayerId(player?.playerId);
if (!text || !npcId || !playerId) {
client.send(COLYSEUS_SERVER_MESSAGES.error, { message: "invalid speak payload" });
return;
Expand All @@ -228,10 +246,6 @@ export class GameRoom extends Room {
client.send(COLYSEUS_SERVER_MESSAGES.speakBusy, { reason: "npc_busy", npcId });
return;
}
const player = this.gameState.players.get(client.sessionId);
if (player) {
player.playerId = playerId;
}
const pendingToken = `pending:${client.sessionId}`;
this.npcSpeakJobs.set(npcId, pendingToken);
this.lastSpeakInitiatorByNpc.set(npcId, playerId);
Expand All @@ -244,7 +258,7 @@ export class GameRoom extends Room {
playerMessage: text,
});
// speakAck before Redis enqueue — fast-lane worker can finish before LPUSH returns otherwise.
client.send("speakAck", { jobId, npcId });
client.send(COLYSEUS_SERVER_MESSAGES.speakAck, { jobId, npcId });
const casualStub = previewCasualSpeakStub(text);
if (casualStub) {
emitJobEvent(jobId, "speakPartial", { text: casualStub, npcId });
Expand Down Expand Up @@ -421,6 +435,15 @@ export class GameRoom extends Room {
this.npcSpeakJobs.set(npcId, jobId);
}

/** Atomically claim speak mutex; returns false if NPC already busy. */
tryAcquireNpcSpeakJob(npcId: string, jobId: string): boolean {
if (this.npcSpeakJobs.has(npcId)) {
return false;
}
this.npcSpeakJobs.set(npcId, jobId);
return true;
}

/** Called after Map executor mutates NPC/objects */
refreshFromMap(): void {
const { state: mapState } = getOrCreate(this.mapRoomId);
Expand Down
19 changes: 19 additions & 0 deletions apps/game-server/src/colyseus/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
registerColyseusRoom,
} from "./room-registry.js";
import {
collectPlayerCells,
findPlayerCellByPlayerId,
resetColyseusFromMap,
roomStateForInitiator,
Expand Down Expand Up @@ -36,6 +37,24 @@ describe("initiator player view", () => {
clearColyseusRoomRegistry();
});

it("collectPlayerCells prefers Colyseus players over legacy map.player", () => {
const map = createDefaultRoom("default");
map.player = { x: 99, y: 99 };

const state = new GameRoomState();
const player = new PlayerSchema();
player.playerId = "player-alpha01";
player.x = 6;
player.y = 1;
state.players.set("sess-a", player);

registerColyseusRoom("default", { state } as never);

const cells = collectPlayerCells("default", map);
expect(cells).toEqual([{ x: 6, y: 1 }]);
expect(cells.some((c) => c.x === 99)).toBe(false);
});

it("findPlayerCellByPlayerId reads Colyseus players map", () => {
const state = new GameRoomState();
const player = new PlayerSchema();
Expand Down
48 changes: 46 additions & 2 deletions apps/game-server/src/colyseus/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import {
HOME_DEFAULT_PLAYER_SPAWN,
LEGACY_PLAYER_ID,
normalizePlayerId,
PLAYER_ID_HEADER,
type GridCell,
type RoomState,
} from "@aetherlife/shared";
import type { Request } from "express";
import { getOrCreate } from "../room/store.js";
import { getChunkLoader } from "../world/chunk-loader.js";
import { buildMoveGrid, findNearestWalkableCell } from "./move-handler.js";
Expand All @@ -16,7 +18,7 @@ function cellKey(x: number, y: number): string {
return `${x},${y}`;
}

/** Merge map snapshot player cell with all connected Colyseus player positions. */
/** Merge Colyseus player positions; legacy map.player only when no live multiplayer session. */
export function collectPlayerCells(roomId: string, map: RoomState): GridCell[] {
const seen = new Set<string>();
const cells: GridCell[] = [];
Expand All @@ -27,15 +29,57 @@ export function collectPlayerCells(roomId: string, map: RoomState): GridCell[] {
cells.push({ x, y });
};

add(map.player.x, map.player.y);
const colyseus = getColyseusRoom(roomId);
if (colyseus) {
const state = colyseus.state as GameRoomState;
state.players.forEach((player) => add(player.x, player.y));
if (state.players.size > 0) {
return cells;
}
}

add(map.player.x, map.player.y);
return cells;
}

/** HTTP routes: require X-Player-Id header match; when Colyseus live, player must be connected. */
export function assertScopedPlayerRequest(
req: Request,
playerId: string,
roomId: string,
): { ok: true } | { ok: false; status: number; error: string } {
const normalized = normalizePlayerId(playerId);
if (normalized === LEGACY_PLAYER_ID) {
return { ok: true };
}

const headerId = normalizePlayerId(req.get(PLAYER_ID_HEADER));
if (!headerId || headerId !== normalized) {
return {
ok: false,
status: 403,
error: "X-Player-Id required and must match request scope",
};
}

const colyseus = getColyseusRoom(roomId);
if (!colyseus) {
return { ok: true };
}

const state = colyseus.state as GameRoomState;
let connected = false;
state.players.forEach((player) => {
if (normalizePlayerId(player.playerId) === normalized) {
connected = true;
}
});
if (!connected) {
return { ok: false, status: 403, error: "player not connected to room" };
}
return { ok: true };
}

/** Live Colyseus position for the player who sent this turn (multiplayer). */
export function findPlayerCellByPlayerId(
roomId: string,
Expand Down
Loading
Loading