Files
Kokorone/apps/web/app/lobby/LobbyView.tsx
T
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

566 lines
22 KiB
TypeScript
Raw 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.
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
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";
const USER_ID = "seed-user-primary";
const PRESENCE_LABELS: Record<LobbyPresenceState, string> = {
BUILDING: "建置中",
SLEEPING: "睡眠中",
BUSY: "忙碌中",
SEMI_BUSY: "略忙",
IDLE: "空閒",
IN_SOLO_ROOM: "單獨聊天中",
IN_GROUP_ROOM: "多人房中",
};
const UNAVAILABLE_STATES: LobbyPresenceState[] = ["BUILDING", "SLEEPING", "IN_SOLO_ROOM", "IN_GROUP_ROOM"];
// T19-P2:大廳頁完整版——取代 T19-P1 的最小快照(在場名單+房間清單),改用 T4 正式聚合端點
// 一次取得作品分組、角色狀態、各進行中房間未讀數,並提供大廳對話式呼叫(T5)輸入框:答應者開房
// 後直接導向該房,全部婉拒或找代答時把角色台詞就地顯示在輸入框上方(不跳轉)。
export function LobbyView() {
const router = useRouter();
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);
} catch {
setError("大廳查詢失敗,請確認 api 服務是否已啟動");
}
}
useEffect(() => {
void refresh();
}, []);
// T19-P5:套用中時查詢待決邀請與待決大廳呼叫——沿用她的身分決定接受/婉拒,不讓人格系統代答。
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);
const invitations = await listPendingInvitations(USER_ID);
setPendingInvitations(invitations);
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);
const calls = await listPendingLobbyCalls(USER_ID);
setPendingCalls(calls);
if (decision.roomId) {
router.push(`/room/${decision.roomId}`);
return;
}
} catch (err) {
setError(err instanceof Error ? err.message : "呼叫決定失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
// T19-P4:作品卡片標題列的「有待裁決項目」提示與結局偵測待確認清單——依目前大廳snapshot 內的
// 作品清單批次查詢,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 (
<p className="text-xs" style={{ color: "var(--accent)" }}>
{error}
</p>
);
}
if (!snapshot) {
return (
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
載入中……
</p>
);
}
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 (
<div className="flex flex-col gap-4">
{possessedCharacterId && (
<section
className="flex flex-col gap-2 rounded-2xl border-2 p-3"
style={{ borderColor: "var(--accent-bright)", background: "var(--chip-bg)" }}
>
<div className="flex items-center justify-between">
<p className="text-sm font-bold" style={{ color: "var(--accent)" }}>
你現在是「{possessedCharacterName}」
</p>
<button
type="button"
onClick={() => void handleExitPossession()}
disabled={pending}
className="rounded-full border px-3 py-1 text-xs font-bold disabled:opacity-50"
style={{ borderColor: "var(--accent)", color: "var(--accent)" }}
>
退出套用
</button>
</div>
<p className="text-[11px]" style={{ color: "var(--ink-soft)" }}>
她的自走已暫停;請到她所在的房間以她的身分發話,或在下方決定她收到的邀請與呼叫。
</p>
{pendingCalls.map((call) => (
<div key={call.id} className="flex flex-col gap-1 rounded-xl border p-2 text-[11px]" style={{ borderColor: "var(--line)" }}>
<p style={{ color: "var(--ink)" }}>有人喊了你:「{call.text}」</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => void handleDecideCall(call.id, true)}
disabled={pending}
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
接受
</button>
<button
type="button"
onClick={() => void handleDecideCall(call.id, false)}
disabled={pending}
className="rounded-full border px-3 py-1 disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
婉拒
</button>
</div>
</div>
))}
{pendingInvitations.map((invitation) => (
<div key={invitation.id} className="flex flex-col gap-1 rounded-xl border p-2 text-[11px]" style={{ borderColor: "var(--line)" }}>
<p style={{ color: "var(--ink)" }}>
{(invitation.invitedByCharacterId &&
snapshot.characters.find((c) => c.id === invitation.invitedByCharacterId)?.formalName) ??
"有人"}
邀請你到房間裡聊聊
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => void handleDecideInvitation(invitation.id, true)}
disabled={pending}
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
接受
</button>
<button
type="button"
onClick={() => void handleDecideInvitation(invitation.id, false)}
disabled={pending}
className="rounded-full border px-3 py-1 disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
婉拒
</button>
</div>
</div>
))}
</section>
)}
<section className="flex flex-col gap-2 rounded-2xl border p-3" style={{ borderColor: "var(--line)" }}>
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
進行中房間({snapshot.activeRooms.length})
</h2>
{snapshot.activeRooms.length === 0 ? (
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
目前沒有進行中的房間。
</p>
) : (
<ul className="flex flex-col gap-2">
{snapshot.activeRooms.map((room) => (
<li key={room.id}>
<button
type="button"
onClick={() => router.push(`/room/${room.id}`)}
className="flex w-full flex-col gap-1 rounded-xl border px-3 py-2 text-left text-xs"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
>
<span className="flex items-center justify-between">
<span style={{ color: "var(--ink-soft)" }}>{room.mode}</span>
{room.unreadCount > 0 && (
<span
className="rounded-full px-2 py-0.5 text-[10px] font-bold text-white"
style={{ background: "var(--accent-bright)" }}
>
{room.unreadCount}
</span>
)}
</span>
<span>
{room.memberCharacterIds
.map((id) => snapshot.characters.find((c) => c.id === id)?.formalName ?? id)
.join("、") || "(無人在場)"}
</span>
{room.topicSeed && <span style={{ color: "var(--ink-soft)" }}>話題:{room.topicSeed}</span>}
</button>
</li>
))}
</ul>
)}
</section>
{workGroups.map(({ work, characters }) => {
if (characters.length === 0) return null;
const pendingCount = pendingAdjudicationByWork[work.id] ?? 0;
const endings = endingDetectionsByWork[work.id] ?? [];
return (
<section key={work.id} className="flex flex-col gap-2 rounded-2xl border p-3" style={{ borderColor: "var(--line)" }}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="flex items-center gap-2 text-sm font-bold" style={{ color: "var(--ink)" }}>
{work.title}
{work.inEpilogue && (
<span className="rounded-full px-2 py-0.5 text-[10px]" style={{ background: "var(--chip-bg)", color: "var(--accent)" }}>
後日談
</span>
)}
</h2>
<div className="flex items-center gap-2 text-[10px]">
{pendingCount > 0 && (
<Link
href="/canon-review"
className="rounded-full px-2 py-0.5 font-bold text-white"
style={{ background: "var(--accent)" }}
>
有 {pendingCount} 項待裁決
</Link>
)}
<Link
href={`/canon-import?workId=${work.id}`}
className="rounded-full border px-2 py-0.5"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
匯入章節
</Link>
</div>
</div>
<div className="flex flex-wrap gap-2 text-xs">
{characters.map((character) => (
<span
key={character.id}
className="flex items-center gap-1 rounded-full border px-2 py-1"
style={{
borderColor: "var(--line)",
color: UNAVAILABLE_STATES.includes(character.presenceState) ? "var(--ink-soft)" : "var(--ink)",
}}
>
{character.formalName}
<span style={{ color: "var(--ink-soft)" }}>/{PRESENCE_LABELS[character.presenceState]}</span>
{character.id === possessedCharacterId ? (
<span className="rounded-full px-1.5 py-0.5 text-[9px] font-bold text-white" style={{ background: "var(--accent)" }}>
套用中
</span>
) : (
!possessedCharacterId && (
<button
type="button"
onClick={() => void handleEnterPossession(character.id)}
disabled={pending}
className="rounded-full border px-1.5 py-0.5 text-[9px] disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
套用
</button>
)
)}
</span>
))}
</div>
{endings.length > 0 && (
<div className="flex flex-col gap-2">
{endings.map((ending) => (
<div key={ending.id} className="flex flex-col gap-1 rounded-xl border p-2 text-[11px]" style={{ borderColor: "var(--line)" }}>
<p style={{ color: "var(--ink)" }}>偵測到可能的結局章節,是否切入後日談?</p>
<p style={{ color: "var(--ink-soft)" }}>判定理由:{ending.evidence}</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => void handleConfirmEnding(ending.id)}
disabled={pending}
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
確認.切入後日談
</button>
<button
type="button"
onClick={() => void handleRejectEnding(ending.id)}
disabled={pending}
className="rounded-full border px-3 py-1 disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
拒絕
</button>
</div>
</div>
))}
</div>
)}
{work.milestones.length > 0 && (
<div className="flex flex-col gap-1 text-[11px]" style={{ color: "var(--ink-soft)" }}>
{work.milestones.map((milestone, index) => (
<p key={`${milestone.characterId}-${index}`}>
.{characters.find((c) => c.id === milestone.characterId)?.formalName ?? milestone.characterId}
:{milestone.label}
</p>
))}
</div>
)}
</section>
);
})}
{unassigned.length > 0 && (
<section className="flex flex-col gap-2 rounded-2xl border p-3" style={{ borderColor: "var(--line)" }}>
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
未分組角色
</h2>
<div className="flex flex-wrap gap-2 text-xs">
{unassigned.map((character) => (
<span
key={character.id}
className="flex items-center gap-1 rounded-full border px-2 py-1"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
>
{character.formalName}
<span style={{ color: "var(--ink-soft)" }}>/{PRESENCE_LABELS[character.presenceState]}</span>
{character.id === possessedCharacterId ? (
<span className="rounded-full px-1.5 py-0.5 text-[9px] font-bold text-white" style={{ background: "var(--accent)" }}>
套用中
</span>
) : (
!possessedCharacterId && (
<button
type="button"
onClick={() => void handleEnterPossession(character.id)}
disabled={pending}
className="rounded-full border px-1.5 py-0.5 text-[9px] disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
套用
</button>
)
)}
</span>
))}
</div>
</section>
)}
{lobbyMessage && (
<p className="rounded-xl border px-3 py-2 text-xs" style={{ borderColor: "var(--line)", color: "var(--ink)" }}>
{lobbyMessage}
</p>
)}
{possessedCharacterId ? (
<p className="rounded-full border px-3 py-2 text-center text-xs" style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}>
套用中無法以自己的身分喊人,請先退出套用。
</p>
) : (
<div className="flex items-center gap-2">
<input
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSay();
}}
placeholder="喊一下誰的名字……"
className="flex-1 rounded-full border bg-transparent px-3 py-2 text-sm outline-none"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
disabled={pending}
/>
<button
type="button"
onClick={() => void handleSay()}
disabled={pending || !text.trim()}
className="rounded-full px-4 py-2 text-sm font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
送出
</button>
</div>
)}
</div>
);
}