新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
229 lines
8.4 KiB
TypeScript
229 lines
8.4 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import {
|
||
fetchPersonalityDrift,
|
||
listCanonWorks,
|
||
listCharacters,
|
||
proposeEpilogueMilestone,
|
||
setTimeFlow,
|
||
type CanonWork,
|
||
type CharacterSummary,
|
||
type DriftedPersonality,
|
||
type MilestoneProposalResult,
|
||
type TimeFlowMode,
|
||
} from "../../lib/api";
|
||
|
||
const TIME_FLOW_LABELS: Record<TimeFlowMode, string> = {
|
||
FAST: "快轉",
|
||
REALTIME: "現實同步(1:1)",
|
||
SLOW: "放慢",
|
||
FROZEN: "凍結",
|
||
};
|
||
|
||
// N-6 後日談畫面:時間流速切換、人格漂移檢視、里程碑提案——三者都依「推演深淺」(storyOrder)運作,
|
||
// 預設值抓作品目前的 anchorStoryOrder,使用者可自行調整。
|
||
export function EpilogueView() {
|
||
const [works, setWorks] = useState<CanonWork[]>([]);
|
||
const [workId, setWorkId] = useState("");
|
||
const [work, setWork] = useState<CanonWork | null>(null);
|
||
const [characters, setCharacters] = useState<CharacterSummary[]>([]);
|
||
const [characterId, setCharacterId] = useState("");
|
||
const [storyOrder, setStoryOrder] = useState(0);
|
||
|
||
const [timeFlowError, setTimeFlowError] = useState<string | null>(null);
|
||
const [drift, setDrift] = useState<DriftedPersonality | null>(null);
|
||
const [milestoneResult, setMilestoneResult] = useState<MilestoneProposalResult | null>(null);
|
||
const [pending, setPending] = useState(false);
|
||
|
||
useEffect(() => {
|
||
void listCanonWorks().then(setWorks);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const selected = works.find((w) => w.id === workId) ?? null;
|
||
setWork(selected);
|
||
setStoryOrder(selected?.anchorStoryOrder ?? 0);
|
||
setDrift(null);
|
||
setMilestoneResult(null);
|
||
setTimeFlowError(null);
|
||
if (workId) {
|
||
void listCharacters().then((all) => setCharacters(all.filter((c) => c.workId === workId)));
|
||
} else {
|
||
setCharacters([]);
|
||
}
|
||
}, [workId, works]);
|
||
|
||
async function handleSetTimeFlow(mode: TimeFlowMode) {
|
||
if (!workId || pending) return;
|
||
setPending(true);
|
||
setTimeFlowError(null);
|
||
try {
|
||
const updated = await setTimeFlow(workId, mode);
|
||
setWork(updated);
|
||
setWorks((prev) => prev.map((w) => (w.id === updated.id ? updated : w)));
|
||
} catch (err) {
|
||
// N-6 驗收:受限情境要顯示被拒原因,這裡把 setTimeFlow() 丟出的後端 message 原樣呈現。
|
||
setTimeFlowError(err instanceof Error ? err.message : "時間流速設定失敗");
|
||
} finally {
|
||
setPending(false);
|
||
}
|
||
}
|
||
|
||
async function handleViewDrift() {
|
||
if (!characterId || pending) return;
|
||
setPending(true);
|
||
try {
|
||
setDrift(await fetchPersonalityDrift(characterId, storyOrder));
|
||
} catch {
|
||
setDrift(null);
|
||
} finally {
|
||
setPending(false);
|
||
}
|
||
}
|
||
|
||
async function handleProposeMilestone() {
|
||
if (!characterId || pending) return;
|
||
setPending(true);
|
||
try {
|
||
setMilestoneResult(await proposeEpilogueMilestone(characterId, storyOrder));
|
||
} catch {
|
||
setMilestoneResult(null);
|
||
} finally {
|
||
setPending(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<select
|
||
value={workId}
|
||
onChange={(event) => setWorkId(event.target.value)}
|
||
className="rounded-full border bg-transparent px-3 py-2 text-sm"
|
||
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
|
||
>
|
||
<option value="">選擇作品……</option>
|
||
{works.map((w) => (
|
||
<option key={w.id} value={w.id}>
|
||
{w.title}
|
||
</option>
|
||
))}
|
||
</select>
|
||
|
||
{work && (
|
||
<>
|
||
<section className="flex flex-col gap-2 rounded-2xl border p-4 text-xs" style={{ borderColor: "var(--line)" }}>
|
||
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
|
||
時間流速
|
||
</h2>
|
||
<p style={{ color: "var(--ink-soft)" }}>
|
||
目前:{TIME_FLOW_LABELS[work.timeFlowMode]}
|
||
{work.inEpilogue ? ".已進入後日談" : ".尚未進入後日談"}
|
||
</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{(Object.keys(TIME_FLOW_LABELS) as TimeFlowMode[]).map((mode) => (
|
||
<button
|
||
key={mode}
|
||
type="button"
|
||
onClick={() => void handleSetTimeFlow(mode)}
|
||
disabled={pending}
|
||
className="rounded-full border px-3 py-1 disabled:opacity-50"
|
||
style={{
|
||
borderColor: "var(--line)",
|
||
background: work.timeFlowMode === mode ? "var(--chip-bg)" : "transparent",
|
||
color: "var(--ink)",
|
||
}}
|
||
>
|
||
{TIME_FLOW_LABELS[mode]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{timeFlowError && <p style={{ color: "var(--accent)" }}>{timeFlowError}</p>}
|
||
</section>
|
||
|
||
<select
|
||
value={characterId}
|
||
onChange={(event) => {
|
||
setCharacterId(event.target.value);
|
||
setDrift(null);
|
||
setMilestoneResult(null);
|
||
}}
|
||
className="rounded-full border bg-transparent px-3 py-2 text-sm"
|
||
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
|
||
>
|
||
<option value="">選擇角色……</option>
|
||
{characters.map((character) => (
|
||
<option key={character.id} value={character.id}>
|
||
{character.aliases[0]?.formalName ?? character.id}
|
||
</option>
|
||
))}
|
||
</select>
|
||
|
||
{characterId && (
|
||
<>
|
||
<div className="flex items-center gap-2 text-xs">
|
||
<label style={{ color: "var(--ink-soft)" }}>推演深淺(storyOrder,預設抓作品目前的錨定點)</label>
|
||
<input
|
||
type="number"
|
||
value={storyOrder}
|
||
onChange={(event) => setStoryOrder(Number(event.target.value))}
|
||
className="w-24 rounded-full border bg-transparent px-3 py-1"
|
||
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
|
||
/>
|
||
</div>
|
||
|
||
<section className="flex flex-col gap-2 rounded-2xl border p-4 text-xs" style={{ borderColor: "var(--line)" }}>
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
|
||
人格漂移
|
||
</h2>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleViewDrift()}
|
||
disabled={pending}
|
||
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
|
||
style={{ background: "var(--accent-bright)" }}
|
||
>
|
||
查看
|
||
</button>
|
||
</div>
|
||
{drift && (
|
||
<p style={{ color: "var(--ink-soft)" }}>
|
||
性格核心:{drift.archetype}(不變).外顯度:{drift.expressiveness.toFixed(2)}(位移
|
||
{drift.driftAmount >= 0 ? "+" : ""}
|
||
{drift.driftAmount.toFixed(2)}).情緒衰減倍率:{drift.emotionHalfLifeMultiplier.toFixed(2)}
|
||
</p>
|
||
)}
|
||
</section>
|
||
|
||
<section className="flex flex-col gap-2 rounded-2xl border p-4 text-xs" style={{ borderColor: "var(--line)" }}>
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
|
||
人生里程碑
|
||
</h2>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleProposeMilestone()}
|
||
disabled={pending}
|
||
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
|
||
style={{ background: "var(--accent-bright)" }}
|
||
>
|
||
提案
|
||
</button>
|
||
</div>
|
||
{milestoneResult && (
|
||
<p style={{ color: milestoneResult.label ? "var(--accent)" : "var(--ink-soft)" }}>
|
||
{milestoneResult.label
|
||
? `提案成功:${milestoneResult.label}`
|
||
: "此時沒有里程碑可提案(可能離上次太近,或角色目標裡沒有對應的正史伏筆)"}
|
||
</p>
|
||
)}
|
||
</section>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|