@@ -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 ( ) ;
}