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
+264
View File
@@ -0,0 +1,264 @@
"use client";
import { useEffect, useState } from "react";
import {
completeTask,
createTask,
failTask,
fireTaskNow,
listTasks,
parseTask,
type ParseTaskResult,
type Task,
type TaskType,
} from "../../lib/api";
const CHARACTER_ID = "seed-character-genki";
const USER_ID = "seed-user-primary";
const TASK_TYPE_LABELS: Record<TaskType, string> = {
ONE_TIME: "單次提醒",
RECURRING: "週期提醒",
CONDITIONAL: "條件提醒",
QUERY_ORGANIZE: "查資料/整理",
TODO_TRACKING: "代辦追蹤",
};
const STATUS_LABELS: Record<Task["status"], string> = {
PENDING: "尚未觸發",
TRIGGERED: "已觸發,等候回應",
COMPLETED: "已完成",
FAILED: "失敗",
CANCELLED: "已取消",
};
function isOverdue(task: Task): boolean {
return task.status === "TRIGGERED" && task.overdueNudgeCount > 0;
}
// J-8 委託介面:自然語言輸入→(辨識成功即直接建立/辨識失敗則手動表單建立)→列表→觸發→完成或失敗,
// 對應計畫§委託子系統「角色不只陪聊,還能接受委託」。
export function TasksView() {
const [text, setText] = useState("");
const [tasks, setTasks] = useState<Task[]>([]);
const [parseResult, setParseResult] = useState<ParseTaskResult | null>(null);
const [manualDraft, setManualDraft] = useState<{ content: string; triggerAt: string } | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function refresh() {
try {
setTasks(await listTasks(CHARACTER_ID, USER_ID));
} catch {
setError("無法連線到 api 服務,委託清單暫時無法顯示");
}
}
useEffect(() => {
void refresh();
}, []);
async function handleParse() {
const trimmed = text.trim();
if (!trimmed || pending) {
return;
}
setPending(true);
setError(null);
setParseResult(null);
setManualDraft(null);
try {
const result = await parseTask(CHARACTER_ID, USER_ID, trimmed);
setParseResult(result);
if (result.created) {
setText("");
await refresh();
} else {
// 辨識失敗:退回手動表單,讓使用者自己補齊內容後透過 createTask() 建立。
setManualDraft({ content: trimmed, triggerAt: "" });
}
} catch {
setError("委託解析失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
async function handleManualCreate() {
if (!manualDraft || !manualDraft.content.trim() || pending) {
return;
}
setPending(true);
setError(null);
try {
await createTask(CHARACTER_ID, USER_ID, {
type: "TODO_TRACKING",
content: manualDraft.content.trim(),
rawText: text.trim() || manualDraft.content.trim(),
triggerAt: manualDraft.triggerAt ? new Date(manualDraft.triggerAt).toISOString() : undefined,
});
setText("");
setManualDraft(null);
setParseResult(null);
await refresh();
} catch {
setError("委託建立失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
async function handleAction(action: (taskId: string) => Promise<Task>, taskId: string) {
setPending(true);
setError(null);
try {
await action(taskId);
await refresh();
} catch {
setError("操作失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2 rounded-2xl border p-4" style={{ borderColor: "var(--line)" }}>
<label className="text-xs" style={{ color: "var(--ink-soft)" }}>
跟她說一句話,讓她幫你記著(例如「三點提醒我開會」)
</label>
<div className="flex gap-2">
<input
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
void handleParse();
}
}}
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 handleParse()}
disabled={pending}
className="rounded-full px-4 py-2 text-sm font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
送出
</button>
</div>
{parseResult?.created && (
<p className="text-xs" style={{ color: "var(--accent)" }}>
已建立委託:{parseResult.task?.content}
{parseResult.performanceLine ? `——${parseResult.performanceLine}` : ""}
</p>
)}
{manualDraft && (
<div className="flex flex-col gap-2 rounded-xl p-3 text-xs" style={{ background: "var(--bg-soft)" }}>
<p style={{ color: "var(--ink-soft)" }}>沒辨識出明確時間,改用手動表單建立(分類為「代辦追蹤」):</p>
<input
value={manualDraft.content}
onChange={(event) => setManualDraft({ ...manualDraft, content: event.target.value })}
placeholder="委託內容"
className="rounded-full border bg-transparent px-3 py-1.5"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
/>
<input
type="datetime-local"
value={manualDraft.triggerAt}
onChange={(event) => setManualDraft({ ...manualDraft, triggerAt: event.target.value })}
className="rounded-full border bg-transparent px-3 py-1.5"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
/>
<button
type="button"
onClick={() => void handleManualCreate()}
disabled={pending}
className="self-start rounded-full px-4 py-1.5 font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
確認建立
</button>
</div>
)}
{error && (
<p className="text-xs" style={{ color: "var(--accent)" }}>
{error}
</p>
)}
</div>
<div className="flex flex-col gap-2">
{tasks.length === 0 && (
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
目前沒有委託。
</p>
)}
{tasks.map((task) => (
<div key={task.id} className="flex flex-col gap-1.5 rounded-2xl border p-3 text-xs" style={{ borderColor: "var(--line)" }}>
<div className="flex items-center justify-between">
<span className="font-bold" style={{ color: "var(--ink)" }}>
{task.content}
</span>
<span
className="rounded-full px-2 py-0.5"
style={{
background: isOverdue(task) ? "var(--accent-bright)" : "var(--chip-bg)",
color: isOverdue(task) ? "#fff" : "var(--accent)",
}}
>
{isOverdue(task) ? "逾期未回應" : STATUS_LABELS[task.status]}
</span>
</div>
<span style={{ color: "var(--ink-soft)" }}>
{TASK_TYPE_LABELS[task.type]}
{task.triggerAt ? `.${new Date(task.triggerAt).toLocaleString("zh-TW")}` : ""}
</span>
{task.triggerMessage && <span style={{ color: "var(--ink-soft)" }}>她說:{task.triggerMessage}</span>}
{(task.status === "PENDING" || task.status === "TRIGGERED") && (
<div className="flex gap-2 pt-1">
{task.status === "PENDING" && (
<button
type="button"
onClick={() => void handleAction((id) => fireTaskNow(id).then(() => listTasks(CHARACTER_ID, USER_ID).then((all) => all.find((t) => t.id === id)!)), task.id)}
disabled={pending}
className="rounded-full border px-3 py-1 disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
立即觸發(測試用)
</button>
)}
<button
type="button"
onClick={() => void handleAction(completeTask, task.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 handleAction(failTask, task.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>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import Link from "next/link";
import { TasksView } from "./TasksView";
export default function TasksPage() {
return (
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-lg font-bold" style={{ color: "var(--accent)" }}>
委託
</h1>
<Link href="/room/solo" className="text-xs" style={{ color: "var(--ink-soft)" }}>
回到聊天
</Link>
</div>
<TasksView />
</main>
);
}