feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能

新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 09:10:26 +08:00
co-authored by Claude Sonnet 5
parent 127a59dafd
commit 1bea06e3cd
195 changed files with 17219 additions and 738 deletions
+657 -8
View File
@@ -38,16 +38,31 @@ export interface ChatResult {
context: ChatContext | null;
}
export async function sendChatMessage(
characterId: string,
userId: string,
sessionId: string,
text: string,
): Promise<ChatResult> {
const res = await fetch(`${API_BASE_URL}/chat/${characterId}`, {
// T38:既有 1:1 聊天併入 SOLO 房,取代舊版由呼叫端自行產生 sessionId 的 sendChatMessage。
// 伺服器端為同一使用者+角色配對沿用同一間進行中的 SOLO 房,契約與 apps/web/lib/api.ts 一致。
export interface SoloRoomSummary {
id: string;
mode: "SOLO";
status: "ACTIVE" | "CLOSED";
}
export async function getOrCreateSoloRoom(userId: string, characterId: string): Promise<SoloRoomSummary> {
const res = await fetch(`${API_BASE_URL}/room/solo`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, sessionId, text }),
body: JSON.stringify({ userId, characterId }),
});
if (!res.ok) {
throw new Error(`房間建立失敗(${res.status})`);
}
return res.json();
}
export async function sendSoloMessage(roomId: string, userId: string, text: string): Promise<ChatResult> {
const res = await fetch(`${API_BASE_URL}/room/${roomId}/message`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, text }),
});
if (!res.ok) {
throw new Error(`對話請求失敗(${res.status})`);
@@ -78,6 +93,25 @@ export async function fetchExpression(characterId: string, userId: string): Prom
return res.json();
}
export interface SynthesizedVoice {
audioRef: string; // TTS_PROVIDER=mock 時是 `mock://` 佔位字串,非真實音檔
prosodyMarkup: string;
durationEstimateMs: number;
}
// T20-P3:語音合成——與 apps/web 呼叫同一端點,播放邏輯見 app/room/[roomId].tsx。
export async function synthesizeVoice(characterId: string, userId: string, text: string): Promise<SynthesizedVoice> {
const res = await fetch(`${API_BASE_URL}/voice/${characterId}/${userId}/synthesize`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
if (!res.ok) {
throw new Error(`語音合成失敗(${res.status})`);
}
return res.json();
}
export async function registerPushToken(userId: string, token: string, platform: string): Promise<void> {
await fetch(`${API_BASE_URL}/push/register`, {
method: "POST",
@@ -85,3 +119,618 @@ export async function registerPushToken(userId: string, token: string, platform:
body: JSON.stringify({ userId, token, platform }),
});
}
// T20-P1:大廳頁最小可用(在場名單+進行中房間清單)與房間歷程分頁,契約與 apps/web/lib/api.ts 一致
// (見該檔 T19-P1 段落),此處為 React Native 端各自實作的對應封裝。
export interface LobbySnapshot {
lobbyRoomId: string;
roster: string[];
activeRooms: { id: string; mode: string; topicSeed: string | null; memberCharacterIds: string[] }[];
}
export async function getLobby(userId: string): Promise<LobbySnapshot> {
const res = await fetch(`${API_BASE_URL}/room/lobby?userId=${encodeURIComponent(userId)}`);
if (!res.ok) {
throw new Error(`大廳查詢失敗(${res.status})`);
}
return res.json();
}
export type LobbyPresenceState = "BUILDING" | "SLEEPING" | "BUSY" | "SEMI_BUSY" | "IDLE" | "IN_SOLO_ROOM" | "IN_GROUP_ROOM";
export interface LobbyMilestoneView {
characterId: string;
label: string;
storyOrder: number;
createdAt: string;
}
export interface LobbyWorkView {
id: string;
title: string;
inEpilogue: boolean;
milestones: LobbyMilestoneView[]; // T34:自走一段時間後自主成立的人生變化
}
export interface LobbyCharacterView {
id: string;
workId: string | null;
formalName: string;
buildStatus: string;
presenceState: LobbyPresenceState;
roomId: string | null;
}
export interface LobbyActiveRoomView {
id: string;
mode: string;
topicSeed: string | null;
memberCharacterIds: string[];
unreadCount: number;
}
export interface LobbyFullSnapshot {
works: LobbyWorkView[];
characters: LobbyCharacterView[];
activeRooms: LobbyActiveRoomView[];
}
// T4/T20-P2:正式大廳聚合端點,契約與 apps/web/lib/api.ts 一致(見該檔 T19-P2 段落)。
export async function getFullLobby(userId: string): Promise<LobbyFullSnapshot> {
const res = await fetch(`${API_BASE_URL}/lobby?userId=${encodeURIComponent(userId)}`);
if (!res.ok) {
throw new Error(`大廳查詢失敗(${res.status})`);
}
return res.json();
}
export interface LobbySayResult {
calledCharacterIds: string[];
accepted: string[];
declined: string[];
roomId: string | null;
mode: "SOLO" | "GROUP" | null;
message: string | null;
delegatedBy: string | null;
pendingPossessedCharacterIds: string[]; // T43:喊到但目前套用中,改記待決給套用者
}
// T5/T20-P2:大廳對話式呼叫,契約與 apps/web/lib/api.ts 一致。
export async function sayToLobby(userId: string, text: string): Promise<LobbySayResult> {
const res = await fetch(`${API_BASE_URL}/lobby/say`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, text }),
});
if (!res.ok) {
throw new Error(`大廳呼叫失敗(${res.status})`);
}
return res.json();
}
// T20-P4:匯入章節、待裁決、結局確認——契約與 apps/web/lib/api.ts 的 T19-P4 段落一致,mobile 補上
// 對等的封裝與畫面(`app/canon-import.tsx`/`app/canon-review.tsx`)。
export interface CharacterSummary {
id: string;
workId: string | null;
aliases: { formalName: string }[];
}
export async function listCharacters(): Promise<CharacterSummary[]> {
const res = await fetch(`${API_BASE_URL}/characters`);
if (!res.ok) {
throw new Error(`角色清單查詢失敗(${res.status})`);
}
return res.json();
}
export interface CanonWork {
id: string;
title: string;
timeFlowMode: "FAST" | "REALTIME" | "SLOW" | "FROZEN";
anchorStoryOrder: number;
inEpilogue: boolean;
}
export async function listCanonWorks(): Promise<CanonWork[]> {
const res = await fetch(`${API_BASE_URL}/canon/works`);
if (!res.ok) {
throw new Error(`作品清單查詢失敗(${res.status})`);
}
return res.json();
}
export interface CanonScene {
id: string;
workId: string;
storyOrder: number;
projected: boolean;
}
export async function listCanonScenes(workId: string): Promise<CanonScene[]> {
const res = await fetch(`${API_BASE_URL}/canon/works/${workId}/scenes`);
if (!res.ok) {
throw new Error(`場景清單查詢失敗(${res.status})`);
}
return res.json();
}
export type SceneLineStatus = "PENDING" | "CONFIRMED" | "QUARANTINED";
export type SceneLineType = "DIALOGUE" | "ACTION" | "MONOLOGUE" | "NARRATION";
export interface SceneLine {
id: string;
sceneId: string;
lineIndex: number;
lineType: SceneLineType;
rawText: string;
hasExplicitMarker: boolean;
markerCharacterId: string | null;
speakerCharacterId: string | null;
confidence: number;
status: SceneLineStatus;
}
export interface CanonSceneDetail extends CanonScene {
lines: SceneLine[];
}
export async function getCanonScene(sceneId: string): Promise<CanonSceneDetail> {
const res = await fetch(`${API_BASE_URL}/canon/scenes/${sceneId}`);
if (!res.ok) {
throw new Error(`場景詳情查詢失敗(${res.status})`);
}
return res.json();
}
export type ContradictionStatus = "PENDING_HUMAN_REVIEW" | "RESOLVED";
export interface SceneContradiction {
id: string;
workId: string;
eventLabel: string;
factKey: string;
description: string;
status: ContradictionStatus;
}
export async function listContradictions(workId: string): Promise<SceneContradiction[]> {
const res = await fetch(`${API_BASE_URL}/canon/works/${workId}/contradictions`);
if (!res.ok) {
throw new Error(`矛盾清單查詢失敗(${res.status})`);
}
return res.json();
}
export async function adjudicateLine(lineId: string, speakerCharacterId: string): Promise<SceneLine> {
const res = await fetch(`${API_BASE_URL}/canon/scene-lines/${lineId}/adjudicate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ speakerCharacterId }),
});
if (!res.ok) {
throw new Error(`裁決失敗(${res.status})`);
}
return res.json();
}
export async function correctLineAttribution(lineId: string, newSpeakerCharacterId: string): Promise<SceneLine> {
const res = await fetch(`${API_BASE_URL}/canon/scene-lines/${lineId}/correct`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ newSpeakerCharacterId }),
});
if (!res.ok) {
throw new Error(`歸屬更正失敗(${res.status})`);
}
return res.json();
}
export async function reattemptQuarantined(sceneId: string): Promise<{ released: number }> {
const res = await fetch(`${API_BASE_URL}/canon/scenes/${sceneId}/reattempt-quarantined`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
if (!res.ok) {
throw new Error(`重試失敗(${res.status})`);
}
return res.json();
}
export interface ImportSceneLineInput {
lineType: SceneLineType;
rawText: string;
hasExplicitMarker?: boolean;
markerCharacterId?: string;
}
export interface ImportSceneInput {
workId: string;
volume: number;
chapterLabel: string;
storyOrder: number;
summary: string;
rawText: string;
participantCharacterIds: string[];
lines: ImportSceneLineInput[];
}
export interface ImportedSceneResult extends CanonSceneDetail {
pendingAdjudication: boolean;
}
export async function importCanonScene(input: ImportSceneInput): Promise<ImportedSceneResult> {
const res = await fetch(`${API_BASE_URL}/canon/scenes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(typeof body?.message === "string" ? body.message : `章節匯入失敗(${res.status})`);
}
return res.json();
}
export async function listPendingAdjudication(workId: string): Promise<ImportedSceneResult[]> {
const res = await fetch(`${API_BASE_URL}/canon/works/${workId}/pending-adjudication`);
if (!res.ok) {
throw new Error(`待裁決清單查詢失敗(${res.status})`);
}
return res.json();
}
export type EndingDetectionResolution = "PENDING" | "CONFIRMED" | "REJECTED";
export interface EndingDetectionLog {
id: string;
sceneId: string;
evidence: string;
resolution: EndingDetectionResolution;
createdAt: string;
respondedAt: string | null;
}
export async function listEndingDetections(workId: string): Promise<EndingDetectionLog[]> {
const res = await fetch(`${API_BASE_URL}/canon/works/${workId}/ending-detections`);
if (!res.ok) {
throw new Error(`結局偵測清單查詢失敗(${res.status})`);
}
return res.json();
}
export async function confirmEndingDetection(logId: string): Promise<CanonWork> {
const res = await fetch(`${API_BASE_URL}/epilogue/ending-detections/${logId}/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(typeof body?.message === "string" ? body.message : `結局確認失敗(${res.status})`);
}
return res.json();
}
export async function rejectEndingDetection(logId: string): Promise<EndingDetectionLog> {
const res = await fetch(`${API_BASE_URL}/epilogue/ending-detections/${logId}/reject`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(typeof body?.message === "string" ? body.message : `結局拒絕失敗(${res.status})`);
}
return res.json();
}
export const ROOM_USER_SPEAKER_ID = "USER";
// T20-P3:TALK/ACTION/DELEGATE/LEAVE/SYSTEM,對應後端 RoomTurnKind(見 T6/T19-P3)。
export type RoomTurnKind = "TALK" | "ACTION" | "DELEGATE" | "LEAVE" | "SYSTEM";
export interface RoomTurn {
seq: number;
speakerId: string;
speakerLabel: string;
content: string;
kind: RoomTurnKind;
}
export interface RoomUnreadInfo {
lastReadSeq: number;
firstUnreadSeq: number | null;
unreadCount: number;
}
export interface RoomUsageSummary {
totalPromptTokens: number;
totalCompletionTokens: number;
generationCount: number;
}
export interface RoomDetail {
id: string;
mode: string;
status: string;
topicSeed: string | null;
participants: string[];
turnCount: number;
turns: RoomTurn[];
hasMore: boolean;
nextCursor: number | null;
unread: RoomUnreadInfo;
muted: boolean; // T31:靜音——不推播、不自動播放語音,自走仍照常進行
usage: RoomUsageSummary;
}
export async function getRoomUsage(roomId: string): Promise<RoomUsageSummary> {
const res = await fetch(`${API_BASE_URL}/room/${roomId}/usage`);
if (!res.ok) {
throw new Error(`用量查詢失敗(${res.status})`);
}
return res.json();
}
// T31:靜音只影響推播與自動語音播放,自走排程與生成照常進行。
export async function setRoomMuted(roomId: string, muted: boolean): Promise<{ id: string; muted: boolean }> {
const res = await fetch(`${API_BASE_URL}/room/${roomId}/mute`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ muted }),
});
if (!res.ok) {
throw new Error(`靜音設定失敗(${res.status})`);
}
return res.json();
}
export interface RoomTurnsPageOptions {
afterSeq?: number;
beforeSeq?: number;
limit?: number;
}
export async function getRoom(roomId: string, page: RoomTurnsPageOptions = {}): Promise<RoomDetail> {
const params = new URLSearchParams();
if (page.afterSeq !== undefined) params.set("afterSeq", String(page.afterSeq));
if (page.beforeSeq !== undefined) params.set("beforeSeq", String(page.beforeSeq));
if (page.limit !== undefined) params.set("limit", String(page.limit));
const query = params.toString();
const res = await fetch(`${API_BASE_URL}/room/${roomId}${query ? `?${query}` : ""}`);
if (!res.ok) {
throw new Error(`房間查詢失敗(${res.status})`);
}
return res.json();
}
export interface RoomMessageResult {
speakers: { characterId: string; text: string; score: number }[];
}
// T20-P2:通用房間發言——後端依 room.mode 內部分流 SOLO/GROUP,回應形狀不同,但本畫面一律透過
// SSE/串流收新訊息,不依賴這裡的回傳值本身,故用寬鬆型別接收即可。
export async function sendRoomMessage(roomId: string, userId: string, userLabel: string, text: string): Promise<unknown> {
const res = await fetch(`${API_BASE_URL}/room/${roomId}/message`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, userLabel, text }),
});
if (!res.ok) {
throw new Error(`發言失敗(${res.status})`);
}
return res.json();
}
export async function endRoom(roomId: string): Promise<{ ok: boolean }> {
const res = await fetch(`${API_BASE_URL}/room/${roomId}/end`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
if (!res.ok) {
throw new Error(`結束房間失敗(${res.status})`);
}
return res.json();
}
// T11/T20-P2:回報已讀游標,契約與 apps/web/lib/api.ts 一致。
export async function reportRoomRead(roomId: string, userId: string, lastReadSeq: number): Promise<RoomUnreadInfo> {
const res = await fetch(`${API_BASE_URL}/room/${roomId}/read`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, lastReadSeq }),
});
if (!res.ok) {
throw new Error(`已讀回報失敗(${res.status})`);
}
return res.json();
}
export type RoomStreamEvent =
| { type: "turn"; seq: number; data: { speakerId: string; speakerLabel: string; content: string; kind: RoomTurnKind } }
| { type: "member"; seq: number; data: { characterId: string; action: "joined" | "left" } }
| { type: "closed"; seq: number; data: { reason: string } };
// T10/T20-P2:房間即時推送——React Native 沒有跨平台一致的 EventSource(見 AGENTS.md
// 「Expo HAS CHANGED」提醒,已查證 Expo SDK 57 官方文件:`expo/fetch` 提供 WinterCG 相容的
// streaming response body,`EventSource` 則未在文件中記載跨平台可用),故改用 `expo/fetch` 的
// `resp.body.getReader()` 手動解析標準 SSE 分幀(event/id/data,空行斷幀),語意與 apps/web 用
// 瀏覽器原生 EventSource 消費的是同一個後端端點、同一套事件格式,只是消費端實作不同。
// 回傳一個 cancel 函式,呼叫者卸載時務必呼叫以中止底層串流讀取。
export function subscribeRoomStream(
roomId: string,
afterSeq: number,
onEvent: (event: RoomStreamEvent) => void,
onError?: (err: unknown) => void,
): () => void {
let cancelled = false;
const controller = new AbortController();
(async () => {
try {
const { fetch: expoFetch } = await import("expo/fetch");
const resp = await expoFetch(`${API_BASE_URL}/room/${roomId}/stream?afterSeq=${afterSeq}`, {
headers: { Accept: "text/event-stream" },
signal: controller.signal,
});
const reader = resp.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buffer = "";
while (!cancelled) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let frameBreak = buffer.indexOf("\n\n");
while (frameBreak !== -1) {
const frame = buffer.slice(0, frameBreak);
buffer = buffer.slice(frameBreak + 2);
let eventType = "message";
let id: string | null = null;
const dataLines: string[] = [];
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) eventType = line.slice(6).trim();
else if (line.startsWith("id:")) id = line.slice(3).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
}
if (eventType !== "ping" && dataLines.length > 0 && id !== null) {
try {
const data = JSON.parse(dataLines.join("\n"));
onEvent({ type: eventType as RoomStreamEvent["type"], seq: Number(id), data } as RoomStreamEvent);
} catch {
// 忽略解析失敗的單一分幀,不中斷整條串流。
}
}
frameBreak = buffer.indexOf("\n\n");
}
}
} catch (err) {
if (!cancelled) onError?.(err);
}
})();
return () => {
cancelled = true;
controller.abort();
};
}
// T20-P5 人格套用:契約與 apps/api/src/room/possession.controller.ts、apps/web/lib/api.ts 一致。
export interface PossessionStatus {
possessingCharacterId: string | null;
}
export async function getPossessionStatus(userId: string): Promise<PossessionStatus> {
const res = await fetch(`${API_BASE_URL}/possession/status?userId=${encodeURIComponent(userId)}`);
if (!res.ok) {
throw new Error(`套用狀態查詢失敗(${res.status})`);
}
return res.json();
}
export async function enterPossession(userId: string, characterId: string): Promise<PossessionStatus> {
const res = await fetch(`${API_BASE_URL}/possession/enter`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, characterId }),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(typeof body?.message === "string" ? body.message : `套用失敗(${res.status})`);
}
return res.json();
}
export async function exitPossession(userId: string): Promise<PossessionStatus> {
const res = await fetch(`${API_BASE_URL}/possession/exit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId }),
});
if (!res.ok) {
throw new Error(`退出套用失敗(${res.status})`);
}
return res.json();
}
export async function speakAsPossessed(
roomId: string,
userId: string,
text: string,
): Promise<{ speakers: { characterId: string; text: string; score: number }[] }> {
const res = await fetch(`${API_BASE_URL}/possession/${roomId}/message`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, text }),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(typeof body?.message === "string" ? body.message : `發話失敗(${res.status})`);
}
return res.json();
}
export type RoomInvitationStatus = "PENDING" | "ACCEPTED" | "DECLINED";
export interface PendingInvitation {
id: string;
roomId: string;
characterId: string;
invitedByCharacterId: string | null;
status: RoomInvitationStatus;
createdAt: string;
}
export async function listPendingInvitations(userId: string): Promise<PendingInvitation[]> {
const res = await fetch(`${API_BASE_URL}/possession/pending-invitations?userId=${encodeURIComponent(userId)}`);
if (!res.ok) {
throw new Error(`待決邀請查詢失敗(${res.status})`);
}
return res.json();
}
export async function decideInvitation(invitationId: string, accept: boolean): Promise<void> {
const res = await fetch(`${API_BASE_URL}/possession/invitations/${invitationId}/decide`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accept }),
});
if (!res.ok) {
throw new Error(`邀請決定失敗(${res.status})`);
}
}
export interface PendingLobbyCall {
id: string;
userId: string;
characterId: string;
text: string;
createdAt: string;
}
export async function listPendingLobbyCalls(userId: string): Promise<PendingLobbyCall[]> {
const res = await fetch(`${API_BASE_URL}/possession/pending-calls?userId=${encodeURIComponent(userId)}`);
if (!res.ok) {
throw new Error(`待決呼叫查詢失敗(${res.status})`);
}
return res.json();
}
export async function decideLobbyCall(pendingCallId: string, accept: boolean): Promise<{ roomId: string | null }> {
const res = await fetch(`${API_BASE_URL}/possession/lobby-calls/${pendingCallId}/decide`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accept }),
});
if (!res.ok) {
throw new Error(`呼叫決定失敗(${res.status})`);
}
return res.json();
}