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
+922 -11
View File
@@ -25,16 +25,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 房,取代舊版 sendChatMessage/endChatSession(sessionId 由呼叫端自行
// 產生)。伺服器端會為同一使用者+角色配對沿用同一間進行中的 SOLO 房,呼叫端不需要自己管理 session。
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})`);
@@ -42,11 +57,11 @@ export async function sendChatMessage(
return res.json();
}
export async function endChatSession(sessionId: string, characterId: string): Promise<void> {
await fetch(`${API_BASE_URL}/session/${sessionId}/end`, {
export async function endSoloRoom(roomId: string): Promise<void> {
await fetch(`${API_BASE_URL}/room/${roomId}/end`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ characterId }),
body: "{}",
});
}
@@ -74,3 +89,899 @@ 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;
}
// P-6 語音播放:呼叫語音子系統取得合成結果,播放邏輯見 components/VoicePlaybackButton.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 interface RomanceEligibility {
romanceAxisEligible: boolean;
adultModeEligible: boolean;
isEpilogueAdult: boolean;
}
export async function fetchRomanceEligibility(characterId: string): Promise<RomanceEligibility> {
const res = await fetch(`${API_BASE_URL}/romance/${characterId}/eligibility`);
if (!res.ok) {
throw new Error(`戀愛資格查詢失敗(${res.status})`);
}
return res.json();
}
export interface AdultContentCheckResult {
allowed: boolean;
reason: string;
}
// L-8:每次呼叫都會在後端留下一筆嘗試紀錄(見 RomanceEligibilityService.checkAdultContentAllowed),
// 因此只在使用者實際切換 opt-in 開關、或載入設定頁當下呼叫,不要輪詢。
export async function checkAdultContent(characterId: string, userId: string): Promise<AdultContentCheckResult> {
const res = await fetch(`${API_BASE_URL}/romance/${characterId}/adult-content-check`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId }),
});
if (!res.ok) {
throw new Error(`成人內容資格檢查失敗(${res.status})`);
}
return res.json();
}
export interface AdultModeConsent {
userId: string;
verifiedAdult: boolean;
optedIn: boolean;
}
// L-8:「成年使用者驗證」機制計畫未指定(需人工確認),這裡只做 opt-in 開關與既有 API 串接——
// verifiedAdult 目前等同使用者自我宣告(與 optedIn 同一個開關),不是真正的身分驗證。
export async function setAdultModeConsent(userId: string, verifiedAdult: boolean, optedIn: boolean): Promise<AdultModeConsent> {
const res = await fetch(`${API_BASE_URL}/romance/adult-mode/consent`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, verifiedAdult, optedIn }),
});
if (!res.ok) {
throw new Error(`成人模式設定失敗(${res.status})`);
}
return res.json();
}
export type RomanceStage = "NONE" | "INTERESTED" | "AMBIGUOUS" | "PARTNER" | "DEEP_PARTNER" | "CRISIS";
export interface RomanceState {
intimacy: number;
trust: number;
stage: RelationshipStage;
romanceMeter: number;
romanceStage: RomanceStage;
}
// L-9:心動值與戀愛階段顯示,直接讀 Relationship 資料列(後端未另外包裝)。
export async function fetchRomanceState(characterId: string, userId: string): Promise<RomanceState> {
const res = await fetch(`${API_BASE_URL}/romance/${characterId}/${userId}/state`);
if (!res.ok) {
throw new Error(`戀愛狀態查詢失敗(${res.status})`);
}
return res.json();
}
export type ConfessionOutcome = "INELIGIBLE" | "REJECTED_TOO_SOON" | "REJECTED" | "ACCEPTED";
export interface ConfessionResult {
outcome: ConfessionOutcome;
romanceMeter: number;
consecutiveRejections: number;
}
// L-9:告白可能被拒絕——UI 必須如實呈現 outcome,不得把非 ACCEPTED 的結果畫成成功。
export async function confessRomance(characterId: string, userId: string): Promise<ConfessionResult> {
const res = await fetch(`${API_BASE_URL}/romance/${characterId}/confess`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId }),
});
if (!res.ok) {
throw new Error(`告白請求失敗(${res.status})`);
}
return res.json();
}
export type MilestoneType =
| "DISTANCE_SHORTENED"
| "OCCASIONAL_CONTACT"
| "LATE_NIGHT_TALK"
| "HAND_HOLDING"
| "HUG"
| "FOREHEAD_KISS"
| "CASUAL_INTIMACY";
export interface MilestoneResult {
allowed: boolean;
isFirstTime: boolean;
reactionLine?: string;
}
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 interface SceneLine {
id: string;
sceneId: string;
lineIndex: number;
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();
}
// T19-P4:匯入章節——場景層級(人類可讀摘要+原文)+逐句台詞/敘述/動作/內心獨白。
export type SceneLineType = "DIALOGUE" | "ACTION" | "MONOLOGUE" | "NARRATION";
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();
}
// T19-P4:待裁決作品卡片提示——沿用 M-11 待裁決畫面同一個判斷(場景自身有隔離台詞或所屬作品有
// 待確認矛盾),這裡只取數量供大廳卡片顯示提示用。
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;
}
// T13/T19-P4:每章結局偵測待確認清單——後端已經只回傳 resolution=PENDING 的紀錄。
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();
}
// T14/T19-P4:確認結局偵測——切入後日談;拒絕則維持原狀,且該場景不再重複提示。
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 type TimeFlowMode = "FAST" | "REALTIME" | "SLOW" | "FROZEN";
// N-6:時間流速上限(後日談推演成年角色不可快轉)在引擎層以 400 擋下,
// 這裡把後端回傳的 message 原樣往上丟,讓畫面能顯示真正的限制原因,而不是通用錯誤文字。
export async function setTimeFlow(workId: string, mode: TimeFlowMode): Promise<CanonWork> {
const res = await fetch(`${API_BASE_URL}/epilogue/works/${workId}/time-flow`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode }),
});
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 interface DriftedPersonality {
archetype: string;
expressiveness: number;
driftAmount: number;
emotionHalfLifeMultiplier: number;
}
export async function fetchPersonalityDrift(characterId: string, atStoryOrder: number): Promise<DriftedPersonality> {
const res = await fetch(`${API_BASE_URL}/epilogue/characters/${characterId}/drift?atStoryOrder=${atStoryOrder}`);
if (!res.ok) {
throw new Error(`人格漂移查詢失敗(${res.status})`);
}
return res.json();
}
export interface MilestoneProposalResult {
scene: { id: string } | null;
label: string | null;
}
export async function proposeEpilogueMilestone(
characterId: string,
storyOrder: number,
attendeeCharacterIds: string[] = [],
): Promise<MilestoneProposalResult> {
const res = await fetch(`${API_BASE_URL}/epilogue/characters/${characterId}/milestone`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ storyOrder, attendeeCharacterIds }),
});
if (!res.ok) {
throw new Error(`里程碑提案失敗(${res.status})`);
}
return res.json();
}
export const ROOM_USER_SPEAKER_ID = "USER";
export type RoomMode = "LOBBY" | "SOLO" | "GROUP" | "SELF_CHAT";
export type RoomStatus = "ACTIVE" | "ENDED";
export interface RoomSummary {
id: string;
mode: RoomMode;
status: RoomStatus;
topicSeed: string | null;
}
export async function createRoom(
userId: string,
characterIds: string[],
mode: RoomMode,
topicSeed?: string,
): Promise<RoomSummary> {
const res = await fetch(`${API_BASE_URL}/room`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, characterIds, mode, topicSeed }),
});
if (!res.ok) {
throw new Error(`房間建立失敗(${res.status})`);
}
return res.json();
}
// T19-P3:TALK/ACTION/DELEGATE/LEAVE/SYSTEM,對應後端 RoomTurnKind(見 T6)。
export type RoomTurnKind = "TALK" | "ACTION" | "DELEGATE" | "LEAVE" | "SYSTEM";
export interface RoomTurn {
seq: number;
speakerId: string; // 使用者固定為 ROOM_USER_SPEAKER_ID,其餘為 characterId
speakerLabel: string;
content: string;
kind: RoomTurnKind;
}
export interface RoomUsageSummary {
totalPromptTokens: number;
totalCompletionTokens: number;
generationCount: number;
}
export interface RoomUnreadInfo {
lastReadSeq: number;
firstUnreadSeq: number | null;
unreadCount: number;
}
export interface RoomDetail extends RoomSummary {
participants: string[]; // characterId 陣列,顯示名稱需另外查 listCharacters()
turnCount: number;
turns: RoomTurn[];
hasMore: boolean; // T12:還有更舊(或更新)的歷程可以翻頁
nextCursor: number | null; // T12:接續翻頁用的游標,配合 afterSeq/beforeSeq 使用
unread: RoomUnreadInfo; // T11:定位第一則未讀分隔線用
muted: boolean; // T31:靜音——不推播、不自動播放語音,自走仍照常進行
usage: RoomUsageSummary; // T9/T19-P3:本房累計用量
}
export interface RoomTurnsPageOptions {
afterSeq?: number; // 往後翻(取更新的一段)
beforeSeq?: number; // 往前翻(取更舊的一段)
limit?: number;
}
// T12:重新進入未關閉的房間預設看到最新一頁;帶 beforeSeq/afterSeq 可接續往前或往後翻頁。
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();
}
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 LobbySnapshot {
lobbyRoomId: string;
roster: string[]; // 目前在大廳在場的 characterId
activeRooms: { id: string; mode: string; topicSeed: string | null; memberCharacterIds: string[] }[];
}
// T19-P1/T20-P1:大廳頁最小可用所需的在場名單與進行中房間清單(正式聚合端點見 T4,P2)。
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/T19-P2:正式大廳聚合端點——單一請求取得作品分組、角色狀態與各進行中房間的未讀數,取代
// getLobby() 舊版最小快照作為大廳頁的主要資料來源。
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/T19-P2:大廳對話式呼叫——在大廳打字喊角色的名字,答應者會開一間 SOLO/GROUP 房。
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();
}
// T11/T19-P2:回報已讀游標(單調遞增、不可回退),回傳更新後的未讀狀態。
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();
}
// T10/T19-P2:房間 SSE 推送端點的完整 URL,供 EventSource 使用;afterSeq 帶初次載入時已看到的最新
// seq,避免重連時重複收到已顯示過的 turn(瀏覽器原生重連改走 Last-Event-ID,這裡只用於首次連線)。
export function roomStreamUrl(roomId: string, afterSeq?: number): string {
const params = afterSeq !== undefined ? `?afterSeq=${afterSeq}` : "";
return `${API_BASE_URL}/room/${roomId}/stream${params}`;
}
export interface RoomMessageResult {
speakers: { characterId: string; text: string; score: number }[];
}
export async function sendRoomMessage(roomId: string, userId: string, userLabel: string, text: string): Promise<RoomMessageResult> {
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();
}
// K-7:插話後即切換為群聊——這是後端 SelfChatService.interject() 的既定行為,前端不需要另外處理模式切換。
export async function interjectRoom(roomId: string, userId: string, userLabel: string, text: string): Promise<RoomMessageResult> {
const res = await fetch(`${API_BASE_URL}/room/${roomId}/interject`, {
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 interface SelfChatAdvanceResult {
ended: boolean;
turn?: { characterId: string; content: string };
reason?: "all-left"; // T6:唯一的收斂路徑——所有角色都已各自 LEAVE 離場
}
export async function advanceSelfChat(roomId: string): Promise<SelfChatAdvanceResult> {
const res = await fetch(`${API_BASE_URL}/room/${roomId}/self-chat/advance`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
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();
}
// 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 type TaskType = "ONE_TIME" | "RECURRING" | "CONDITIONAL" | "QUERY_ORGANIZE" | "TODO_TRACKING";
export type TaskStatus = "PENDING" | "TRIGGERED" | "COMPLETED" | "FAILED" | "CANCELLED";
export interface Task {
id: string;
characterId: string;
userId: string;
type: TaskType;
content: string;
rawText: string;
triggerAt: string | null;
cronExpression: string | null;
condition: string | null;
status: TaskStatus;
triggeredAt: string | null;
triggerMessage: string | null;
overdueNudgeCount: number;
completedAt: string | null;
createdAt: string;
}
export interface ParseTaskResult {
created: boolean;
task?: Task;
performanceLine?: string;
rapport?: boolean;
}
// J-8:自然語句解析——辨識成功時後端已直接建立委託(非純預覽),辨識失敗則回傳 created:false,
// 由畫面提供 createTask() 手動表單作為退路,兩個端點各自對應不同情境,不是「先預覽再重複建立」。
export async function parseTask(characterId: string, userId: string, text: string): Promise<ParseTaskResult> {
const res = await fetch(`${API_BASE_URL}/task/${characterId}/parse`, {
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 CreateTaskInput {
type: TaskType;
content: string;
rawText: string;
triggerAt?: string;
}
export async function createTask(characterId: string, userId: string, input: CreateTaskInput): Promise<ParseTaskResult> {
const res = await fetch(`${API_BASE_URL}/task/${characterId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, ...input }),
});
if (!res.ok) {
throw new Error(`委託建立失敗(${res.status})`);
}
return res.json();
}
export async function listTasks(characterId: string, userId: string): Promise<Task[]> {
const res = await fetch(`${API_BASE_URL}/task?characterId=${encodeURIComponent(characterId)}&userId=${encodeURIComponent(userId)}`);
if (!res.ok) {
throw new Error(`委託清單查詢失敗(${res.status})`);
}
return res.json();
}
// J-8:後端註記這是「測試用途:直接觸發,略過真實排程等待」,畫面上維持同樣的用途標示,
// 不假裝這是正式的「提早提醒」功能——單純是驗收流程要求的「觸發」這一步在畫面上走得完。
export async function fireTaskNow(taskId: string): Promise<{ message: string | null }> {
const res = await fetch(`${API_BASE_URL}/task/${taskId}/fire-now`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
if (!res.ok) {
throw new Error(`委託觸發失敗(${res.status})`);
}
return res.json();
}
export async function completeTask(taskId: string): Promise<Task> {
const res = await fetch(`${API_BASE_URL}/task/${taskId}/complete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
if (!res.ok) {
throw new Error(`委託標記完成失敗(${res.status})`);
}
return res.json();
}
export async function failTask(taskId: string): Promise<Task> {
const res = await fetch(`${API_BASE_URL}/task/${taskId}/fail`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
if (!res.ok) {
throw new Error(`委託標記失敗失敗(${res.status})`);
}
return res.json();
}
export async function attemptRomanceMilestone(
characterId: string,
userId: string,
milestoneType: MilestoneType,
): Promise<MilestoneResult> {
const res = await fetch(`${API_BASE_URL}/romance/${characterId}/milestone`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, milestoneType }),
});
if (!res.ok) {
throw new Error(`里程碑請求失敗(${res.status})`);
}
return res.json();
}
// T19-P5 人格套用:契約與 apps/api/src/room/possession.controller.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();
}
+13
View File
@@ -0,0 +1,13 @@
const STORAGE_KEY = "kokorone:voice-autoplay-enabled";
// T26:自走語音在場自動播放的開關,純前端偏好(不需要跨裝置同步),預設開啟。
export function isVoiceAutoplayEnabled(): boolean {
if (typeof window === "undefined") return true;
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored !== "false";
}
export function setVoiceAutoplayEnabled(enabled: boolean): void {
window.localStorage.setItem(STORAGE_KEY, String(enabled));
window.dispatchEvent(new StorageEvent("storage", { key: STORAGE_KEY }));
}