Files
Kokorone/apps/mobile/components/LobbyPreview.tsx
jiantw83andClaude Sonnet 5 1bea06e3cd feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:10:26 +08:00

519 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import { useRouter } from "expo-router";
import {
confirmEndingDetection,
decideInvitation,
decideLobbyCall,
enterPossession,
exitPossession,
getFullLobby,
getPossessionStatus,
listEndingDetections,
listPendingAdjudication,
listPendingInvitations,
listPendingLobbyCalls,
rejectEndingDetection,
sayToLobby,
type EndingDetectionLog,
type LobbyFullSnapshot,
type LobbyPresenceState,
type PendingInvitation,
type PendingLobbyCall,
type PossessionStatus,
} from "../lib/api";
import { useUnread } from "../lib/unread-context";
const USER_ID = "seed-user-primary";
const PRESENCE_LABELS: Record<LobbyPresenceState, string> = {
BUILDING: "建置中",
SLEEPING: "睡眠中",
BUSY: "忙碌中",
SEMI_BUSY: "略忙",
IDLE: "空閒",
IN_SOLO_ROOM: "單獨聊天中",
IN_GROUP_ROOM: "多人房中",
};
// T20-P2:大廳頁完整版,取代 T20-P1 最小快照(在場名單+房間清單),改用 T4 正式聚合端點,
// 與 apps/web 的 LobbyView 對應同一版面:進行中房間(含未讀)→ 作品分組角色卡片 → 大廳對話輸入。
export function LobbyPreview() {
const router = useRouter();
const { setRoomUnreadCounts } = useUnread();
const [snapshot, setSnapshot] = useState<LobbyFullSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const [text, setText] = useState("");
const [pending, setPending] = useState(false);
const [lobbyMessage, setLobbyMessage] = useState<string | null>(null);
const [pendingAdjudicationByWork, setPendingAdjudicationByWork] = useState<Record<string, number>>({});
const [endingDetectionsByWork, setEndingDetectionsByWork] = useState<Record<string, EndingDetectionLog[]>>({});
const [possession, setPossession] = useState<PossessionStatus | null>(null);
const [pendingInvitations, setPendingInvitations] = useState<PendingInvitation[]>([]);
const [pendingCalls, setPendingCalls] = useState<PendingLobbyCall[]>([]);
async function refresh() {
try {
const [lobby, possessionStatus] = await Promise.all([getFullLobby(USER_ID), getPossessionStatus(USER_ID)]);
setSnapshot(lobby);
setPossession(possessionStatus);
setError(null);
// T20-P2:把各房未讀數餵給 UnreadContext,聊天分頁籤的紅點才能反映「大廳裡有房間未讀」。
setRoomUnreadCounts(Object.fromEntries(lobby.activeRooms.map((room) => [room.id, room.unreadCount])));
} catch {
setError("大廳查詢失敗,請確認 api 服務是否已啟動");
}
}
useEffect(() => {
void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// T20-P5:套用中時查詢待決邀請與待決大廳呼叫,與 apps/web 的 LobbyView 對應同一批次查詢方式。
useEffect(() => {
if (!possession?.possessingCharacterId) {
setPendingInvitations([]);
setPendingCalls([]);
return;
}
let cancelled = false;
void Promise.all([listPendingInvitations(USER_ID), listPendingLobbyCalls(USER_ID)]).then(([invitations, calls]) => {
if (cancelled) return;
setPendingInvitations(invitations);
setPendingCalls(calls);
});
return () => {
cancelled = true;
};
}, [possession]);
async function handleEnterPossession(characterId: string) {
if (pending) return;
setPending(true);
setError(null);
try {
await enterPossession(USER_ID, characterId);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "套用失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
async function handleExitPossession() {
if (pending) return;
setPending(true);
setError(null);
try {
await exitPossession(USER_ID);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "退出套用失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
async function handleDecideInvitation(invitationId: string, accept: boolean) {
if (pending) return;
setPending(true);
setError(null);
try {
await decideInvitation(invitationId, accept);
setPendingInvitations(await listPendingInvitations(USER_ID));
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "邀請決定失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
async function handleDecideCall(pendingCallId: string, accept: boolean) {
if (pending) return;
setPending(true);
setError(null);
try {
const decision = await decideLobbyCall(pendingCallId, accept);
setPendingCalls(await listPendingLobbyCalls(USER_ID));
if (decision.roomId) {
router.push(`/room/${decision.roomId}`);
return;
}
} catch (err) {
setError(err instanceof Error ? err.message : "呼叫決定失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
// T20-P4:作品卡片標題列的「有待裁決項目」提示與結局偵測待確認清單,與 apps/web 的
// LobbyView 對應同一批次查詢方式,snapshot 每次刷新都會連動重新取得。
useEffect(() => {
if (!snapshot) return;
const workIds = snapshot.works.map((w) => w.id);
let cancelled = false;
void Promise.all(
workIds.map(async (workId) => {
const [adjudication, endings] = await Promise.all([listPendingAdjudication(workId), listEndingDetections(workId)]);
return { workId, pendingCount: adjudication.length, endings };
}),
).then((results) => {
if (cancelled) return;
setPendingAdjudicationByWork(Object.fromEntries(results.map((r) => [r.workId, r.pendingCount])));
setEndingDetectionsByWork(Object.fromEntries(results.map((r) => [r.workId, r.endings])));
});
return () => {
cancelled = true;
};
}, [snapshot]);
async function handleConfirmEnding(logId: string) {
if (pending) return;
setPending(true);
setError(null);
try {
await confirmEndingDetection(logId);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "結局確認失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
async function handleRejectEnding(logId: string) {
if (pending) return;
setPending(true);
setError(null);
try {
await rejectEndingDetection(logId);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "結局拒絕失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
async function handleSay() {
const trimmed = text.trim();
if (!trimmed || pending || possession?.possessingCharacterId) return;
setPending(true);
setError(null);
setLobbyMessage(null);
try {
const result = await sayToLobby(USER_ID, trimmed);
setText("");
if (result.roomId) {
router.push(`/room/${result.roomId}`);
return;
}
if (result.message) {
setLobbyMessage(result.message);
}
await refresh();
} catch {
setError("大廳呼叫失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
if (error) {
return <Text style={styles.error}>{error}</Text>;
}
if (!snapshot) {
return <Text style={styles.hint}>大廳載入中……</Text>;
}
const workGroups = snapshot.works.map((work) => ({
work,
characters: snapshot.characters.filter((c) => c.workId === work.id),
}));
const unassigned = snapshot.characters.filter((c) => c.workId === null);
const possessedCharacterId = possession?.possessingCharacterId ?? null;
const possessedCharacterName = possessedCharacterId
? snapshot.characters.find((c) => c.id === possessedCharacterId)?.formalName ?? possessedCharacterId
: null;
return (
<View style={styles.container}>
{possessedCharacterId && (
<View style={styles.possessionBanner}>
<View style={styles.possessionBannerRow}>
<Text style={styles.possessionBannerTitle}>你現在是「{possessedCharacterName}」</Text>
<Pressable onPress={() => void handleExitPossession()} disabled={pending}>
<Text style={[styles.exitPossessionLink, pending ? styles.sendButtonDisabled : null]}>退出套用</Text>
</Pressable>
</View>
<Text style={styles.possessionBannerHint}>她的自走已暫停;請到她所在的房間以她的身分發話,或在下方決定邀請與呼叫。</Text>
{pendingCalls.map((call) => (
<View key={call.id} style={styles.endingCard}>
<Text style={styles.endingCardTitle}>有人喊了你:「{call.text}」</Text>
<View style={styles.endingCardActions}>
<Pressable onPress={() => void handleDecideCall(call.id, true)} disabled={pending}>
<Text style={[styles.confirmButton, pending ? styles.sendButtonDisabled : null]}>接受</Text>
</Pressable>
<Pressable onPress={() => void handleDecideCall(call.id, false)} disabled={pending}>
<Text style={[styles.rejectButton, pending ? styles.sendButtonDisabled : null]}>婉拒</Text>
</Pressable>
</View>
</View>
))}
{pendingInvitations.map((invitation) => (
<View key={invitation.id} style={styles.endingCard}>
<Text style={styles.endingCardTitle}>
{(invitation.invitedByCharacterId &&
snapshot.characters.find((c) => c.id === invitation.invitedByCharacterId)?.formalName) ??
"有人"}
邀請你到房間裡聊聊
</Text>
<View style={styles.endingCardActions}>
<Pressable onPress={() => void handleDecideInvitation(invitation.id, true)} disabled={pending}>
<Text style={[styles.confirmButton, pending ? styles.sendButtonDisabled : null]}>接受</Text>
</Pressable>
<Pressable onPress={() => void handleDecideInvitation(invitation.id, false)} disabled={pending}>
<Text style={[styles.rejectButton, pending ? styles.sendButtonDisabled : null]}>婉拒</Text>
</Pressable>
</View>
</View>
))}
</View>
)}
<Text style={styles.sectionTitle}>進行中房間({snapshot.activeRooms.length})</Text>
{snapshot.activeRooms.length === 0 ? (
<Text style={styles.hint}>目前沒有進行中的房間。</Text>
) : (
snapshot.activeRooms.map((room) => (
<Pressable key={room.id} style={styles.roomCard} onPress={() => router.push(`/room/${room.id}`)}>
<View style={styles.roomCardRow}>
<Text style={styles.roomCardText}>
{room.memberCharacterIds
.map((id) => snapshot.characters.find((c) => c.id === id)?.formalName ?? id)
.join("、") || room.mode}
</Text>
{room.unreadCount > 0 && (
<View style={styles.unreadBadge}>
<Text style={styles.unreadBadgeText}>{room.unreadCount}</Text>
</View>
)}
</View>
</Pressable>
))
)}
{workGroups.map(({ work, characters }) => {
if (characters.length === 0) return null;
const pendingCount = pendingAdjudicationByWork[work.id] ?? 0;
const endings = endingDetectionsByWork[work.id] ?? [];
return (
<View key={work.id} style={styles.workSection}>
<View style={styles.workHeaderRow}>
<Text style={styles.sectionTitle}>
{work.title}
{work.inEpilogue ? "(後日談)" : ""}
</Text>
<View style={styles.workHeaderActions}>
{pendingCount > 0 && (
<Pressable style={styles.pendingBadge} onPress={() => router.push("/canon-review")}>
<Text style={styles.pendingBadgeText}>有 {pendingCount} 項待裁決</Text>
</Pressable>
)}
<Pressable style={styles.importChip} onPress={() => router.push(`/canon-import?workId=${work.id}`)}>
<Text style={styles.importChipText}>匯入章節</Text>
</Pressable>
</View>
</View>
<View style={styles.rosterRow}>
{characters.map((character) => (
<View key={character.id} style={styles.chip}>
<Text style={styles.chipText}>
{character.formalName}/{PRESENCE_LABELS[character.presenceState]}
</Text>
{character.id === possessedCharacterId ? (
<View style={styles.possessedBadge}>
<Text style={styles.possessedBadgeText}>套用中</Text>
</View>
) : (
!possessedCharacterId && (
<Pressable onPress={() => void handleEnterPossession(character.id)} disabled={pending} style={styles.possessButton}>
<Text style={styles.possessButtonText}>套用</Text>
</Pressable>
)
)}
</View>
))}
</View>
{endings.map((ending) => (
<View key={ending.id} style={styles.endingCard}>
<Text style={styles.endingCardTitle}>偵測到可能的結局章節,是否切入後日談?</Text>
<Text style={styles.endingCardEvidence}>判定理由:{ending.evidence}</Text>
<View style={styles.endingCardActions}>
<Pressable onPress={() => void handleConfirmEnding(ending.id)} disabled={pending}>
<Text style={[styles.confirmButton, pending ? styles.sendButtonDisabled : null]}>確認.切入後日談</Text>
</Pressable>
<Pressable onPress={() => void handleRejectEnding(ending.id)} disabled={pending}>
<Text style={[styles.rejectButton, pending ? styles.sendButtonDisabled : null]}>拒絕</Text>
</Pressable>
</View>
</View>
))}
{work.milestones.length > 0 && (
<View style={styles.milestoneList}>
{work.milestones.map((milestone, index) => (
<Text key={`${milestone.characterId}-${index}`} style={styles.milestoneText}>
.{characters.find((c) => c.id === milestone.characterId)?.formalName ?? milestone.characterId}:
{milestone.label}
</Text>
))}
</View>
)}
</View>
);
})}
{unassigned.length > 0 && (
<View style={styles.workSection}>
<Text style={styles.sectionTitle}>未分組角色</Text>
<View style={styles.rosterRow}>
{unassigned.map((character) => (
<View key={character.id} style={styles.chip}>
<Text style={styles.chipText}>
{character.formalName}/{PRESENCE_LABELS[character.presenceState]}
</Text>
{character.id === possessedCharacterId ? (
<View style={styles.possessedBadge}>
<Text style={styles.possessedBadgeText}>套用中</Text>
</View>
) : (
!possessedCharacterId && (
<Pressable onPress={() => void handleEnterPossession(character.id)} disabled={pending} style={styles.possessButton}>
<Text style={styles.possessButtonText}>套用</Text>
</Pressable>
)
)}
</View>
))}
</View>
</View>
)}
{lobbyMessage && (
<View style={styles.messageBox}>
<Text style={styles.messageBoxText}>{lobbyMessage}</Text>
</View>
)}
{possessedCharacterId ? (
<Text style={styles.hint}>套用中無法以自己的身分喊人,請先退出套用。</Text>
) : (
<View style={styles.sayRow}>
<TextInput
value={text}
onChangeText={setText}
placeholder="喊一下誰的名字……"
style={styles.sayInput}
editable={!pending}
onSubmitEditing={handleSay}
/>
<Pressable onPress={handleSay} disabled={pending || !text.trim()}>
<Text style={[styles.sendButton, pending || !text.trim() ? styles.sendButtonDisabled : null]}>送出</Text>
</Pressable>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { paddingHorizontal: 16, paddingTop: 12, paddingBottom: 12, gap: 6 },
sectionTitle: { fontSize: 12, fontWeight: "bold", color: "#8a6b6f", marginTop: 6 },
rosterRow: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
workSection: { gap: 6 },
workHeaderRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 6 },
workHeaderActions: { flexDirection: "row", alignItems: "center", gap: 6 },
pendingBadge: { borderRadius: 999, backgroundColor: "#FF7E9D", paddingHorizontal: 8, paddingVertical: 3 },
pendingBadgeText: { fontSize: 10, fontWeight: "bold", color: "#fff" },
importChip: { borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 8, paddingVertical: 3 },
importChipText: { fontSize: 10, color: "#8a6b6f" },
endingCard: { borderRadius: 12, borderWidth: 1, borderColor: "#E5D5D8", padding: 8, gap: 4 },
endingCardTitle: { fontSize: 11, color: "#222" },
endingCardEvidence: { fontSize: 11, color: "#8a6b6f" },
endingCardActions: { flexDirection: "row", gap: 12 },
confirmButton: { fontSize: 11, fontWeight: "bold", color: "#FF7E9D" },
rejectButton: { fontSize: 11, color: "#8a6b6f" },
milestoneList: { gap: 2 },
milestoneText: { fontSize: 11, color: "#8a6b6f" },
chip: {
flexDirection: "row",
alignItems: "center",
gap: 4,
borderRadius: 999,
borderWidth: 1,
borderColor: "#E5D5D8",
paddingHorizontal: 10,
paddingVertical: 4,
},
possessedBadge: { borderRadius: 999, backgroundColor: "#FF7E9D", paddingHorizontal: 6, paddingVertical: 1 },
possessedBadgeText: { fontSize: 9, fontWeight: "bold", color: "#fff" },
possessButton: { borderRadius: 999, borderWidth: 1, borderColor: "#E5D5D8", paddingHorizontal: 6, paddingVertical: 1 },
possessButtonText: { fontSize: 9, color: "#8a6b6f" },
possessionBanner: { borderRadius: 12, borderWidth: 2, borderColor: "#FF7E9D", backgroundColor: "#FFE1E9", padding: 10, gap: 6 },
possessionBannerRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
possessionBannerTitle: { fontSize: 13, fontWeight: "bold", color: "#FF7E9D" },
possessionBannerHint: { fontSize: 11, color: "#8a6b6f" },
exitPossessionLink: { fontSize: 12, fontWeight: "bold", color: "#FF7E9D" },
chipText: { fontSize: 12, color: "#222" },
roomCard: {
borderRadius: 12,
borderWidth: 1,
borderColor: "#E5D5D8",
paddingHorizontal: 12,
paddingVertical: 8,
},
roomCardRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
roomCardText: { fontSize: 12, color: "#222" },
unreadBadge: {
backgroundColor: "#FF7E9D",
borderRadius: 999,
paddingHorizontal: 7,
paddingVertical: 2,
minWidth: 18,
alignItems: "center",
},
unreadBadgeText: { fontSize: 10, fontWeight: "bold", color: "#fff" },
messageBox: {
borderRadius: 12,
borderWidth: 1,
borderColor: "#E5D5D8",
paddingHorizontal: 12,
paddingVertical: 8,
},
messageBoxText: { fontSize: 12, color: "#222" },
sayRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
marginTop: 8,
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 999,
borderWidth: 1,
borderColor: "#E5D5D8",
},
sayInput: { flex: 1, fontSize: 14 },
sendButton: { color: "#FF7E9D", fontWeight: "bold" },
sendButtonDisabled: { opacity: 0.5 },
hint: { fontSize: 12, color: "#8a6b6f", paddingHorizontal: 16 },
error: { fontSize: 12, color: "#FF7E9D", paddingHorizontal: 16 },
});