新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
737 lines
24 KiB
TypeScript
737 lines
24 KiB
TypeScript
import type { EmotionTag, RelationshipStage } from "@kokorone/shared";
|
||
|
||
// Q-1:與 apps/web/lib/api.ts 共用同一套 @kokorone/shared 型別,API client 本身雖是各端各自實作
|
||
// (React Native 沒有 Next.js 的伺服器端環境變數機制),但契約(回應形狀)三端一致。
|
||
export const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? "http://localhost:3001";
|
||
|
||
export interface HealthResponse {
|
||
status: string;
|
||
}
|
||
|
||
export async function fetchHealth(): Promise<HealthResponse> {
|
||
const res = await fetch(`${API_BASE_URL}/health`);
|
||
if (!res.ok) {
|
||
throw new Error(`健康檢查失敗(${res.status})`);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
export interface ChatResponseStyle {
|
||
tone: string;
|
||
sentenceLength: "short" | "medium" | "long";
|
||
initiative: "low" | "medium" | "high";
|
||
}
|
||
|
||
export interface ChatContext {
|
||
emotion: { dominant: EmotionTag; style: ChatResponseStyle };
|
||
relationship: { intimacy: number; trust: number; stage: RelationshipStage };
|
||
}
|
||
|
||
export type ChatAvailability = "NORMAL" | "BRIEF" | "DELAYED" | "NO_REPLY";
|
||
|
||
export interface ChatResult {
|
||
text: string;
|
||
actions: string[];
|
||
isFastChannel: boolean;
|
||
availability: ChatAvailability;
|
||
exceptionType: string | null;
|
||
context: ChatContext | null;
|
||
}
|
||
|
||
// 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, 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})`);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
export interface TachieExpressionResponse {
|
||
emotionTag: EmotionTag;
|
||
rawEmotionTag: EmotionTag;
|
||
eyebrow: string;
|
||
eye: string;
|
||
mouth: string;
|
||
effect: string | null;
|
||
pose: string;
|
||
magnitude: number;
|
||
gaze: "UP" | "AWAY" | "DIRECT" | "NEUTRAL";
|
||
availableCostumes: string[];
|
||
lockedFallbackApplied: boolean;
|
||
usedCryingVariant: boolean;
|
||
}
|
||
|
||
export async function fetchExpression(characterId: string, userId: string): Promise<TachieExpressionResponse> {
|
||
const res = await fetch(`${API_BASE_URL}/tachie/${characterId}/${userId}/expression`);
|
||
if (!res.ok) {
|
||
throw new Error(`表情查詢失敗(${res.status})`);
|
||
}
|
||
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",
|
||
headers: { "Content-Type": "application/json" },
|
||
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();
|
||
}
|