From ac9ad1e9bde93fadd60adf5b8d5e23cbcae42499 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Thu, 3 Sep 2026 01:54:02 +0000
Subject: [PATCH 01/41] fix(history): accept place ids when restoring a session
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Opening a case with investigable places threw "the response is not the shape we
expected" before the player could ask anything. The interrogation screen reads
the history the moment it mounts, and the history's characterId only allowed a
uuid or the literal victim — places carry an author-written local id, so every
case holding one failed to parse and took the screen down with it.
That is 17 of the 43 cases now in production.
The comment above that union already warned about exactly this, having been
written when the body was added as a second kind of subject. Adding a third did
not reach it.
Found on 十七回忌の客: two sessions, no messages, no model calls — it never got
past the first read.
Co-Authored-By: Claude
---
__tests__/client/restore.test.ts | 32 ++++++++++++++++++++++++++++++++
src/client/lib/schemas.ts | 13 +++++++++----
2 files changed, 41 insertions(+), 4 deletions(-)
diff --git a/__tests__/client/restore.test.ts b/__tests__/client/restore.test.ts
index 00d31fb..4fb10a7 100644
--- a/__tests__/client/restore.test.ts
+++ b/__tests__/client/restore.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test'
import { restoreConversations } from '@/client/lib/restore'
import type { SessionHistory } from '@/client/lib/schemas'
+import { sessionHistorySchema } from '@/client/lib/schemas'
const history = (histories: SessionHistory['histories']): SessionHistory => ({
sessionId: '8571c162-a7d4-4be9-a14c-2d4ea2780d4f',
@@ -145,3 +146,34 @@ describe('restoreConversations', () => {
expect(Object.keys(result)).toEqual(['a'])
})
})
+
+/*
+ * 相手のIDは三種類ある。uuid の人物、決め打ちの `victim`、そして作者が書いた場所の
+ * ローカルID。履歴は聞き込みの画面へ入った瞬間に読むので、ここで受けそこねると
+ * 一手も打たないうちに画面ごと落ちる——実際、場所を足したときに `victim` までしか
+ * 許しておらず、場所を持つ事件が開いた時点で必ず落ちた。
+ */
+describe('sessionHistorySchema の相手ID', () => {
+ const body = (characterId: string) => ({
+ sessionId: '8571c162-a7d4-4be9-a14c-2d4ea2780d4f',
+ histories: [{ characterId, exchanges: [] }],
+ })
+
+ test('人物の uuid を受ける', () => {
+ expect(
+ sessionHistorySchema.safeParse(body('7f97837b-ef8f-46ff-a199-377926e8fb75')).success,
+ ).toBe(true)
+ })
+
+ test('遺体の victim を受ける', () => {
+ expect(sessionHistorySchema.safeParse(body('victim')).success).toBe(true)
+ })
+
+ test('場所のローカルIDを受ける', () => {
+ expect(sessionHistorySchema.safeParse(body('choba')).success).toBe(true)
+ })
+
+ test('三者のどれでもない文字列は弾く', () => {
+ expect(sessionHistorySchema.safeParse(body('帳場')).success).toBe(false)
+ })
+})
diff --git a/src/client/lib/schemas.ts b/src/client/lib/schemas.ts
index 87bf28f..b125521 100644
--- a/src/client/lib/schemas.ts
+++ b/src/client/lib/schemas.ts
@@ -4,7 +4,7 @@ import { floorPlanSchema } from '~/db/floor-plan'
import { gameModeSchema, hintSchema } from '~/db/game-mode'
import { llmProviderSchema, settableLlmRoleSchema } from '~/db/llm-catalog'
import { investigablePlaceSchema } from '~/db/place'
-import { VICTIM_ID } from '~/db/scenario-definition'
+import { placeIdSchema, VICTIM_ID } from '~/db/scenario-definition'
/**
* サーバのレスポンスは fetch の時点では unknown。
@@ -303,10 +303,15 @@ export const historyExchangeSchema = z.object({
/**
* 話しかけた相手のID。
*
- * 登場人物は uuid だが、被害者だけは決め打ちの `victim`(採番する先が一人しか無い)。
- * ここを uuid で縛ると、遺体を調べたセッションが復元できずに画面ごと落ちる。
+ * 登場人物は uuid、被害者は決め打ちの `victim`、場所は作者が書いたローカルID。
+ * 三者は形で見分けられるので、どれを指しているかは常に決まる(`placeIdSchema`)。
+ *
+ * ここを狭く縛ると、その相手を含むセッションが復元できずに**画面ごと落ちる**。
+ * 履歴は聞き込みの画面へ入った瞬間に読むので、一手も打たないうちに落ちる。
+ * 実際、場所を足したとき `victim` までしか許しておらず、場所を持つ事件が
+ * 開いた時点で必ず落ちた。相手を増やしたらここも必ず増やすこと。
*/
-const subjectIdSchema = z.union([z.uuid(), z.literal(VICTIM_ID)])
+const subjectIdSchema = z.union([z.uuid(), z.literal(VICTIM_ID), placeIdSchema])
export const sessionHistorySchema = z.object({
sessionId: z.uuid(),
From 392516f19e0c132d5c53459a5ca265b577d9fd53 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Mon, 7 Sep 2026 07:23:01 +0000
Subject: [PATCH 02/41] fix(scenario): keep death-time evidence consistent with
victim findings
- tsukimisou: give the victim a postmortem body-temperature/rigor finding so the
death-estimate evidence's reveal condition matches an actual detective response
- add migration 0025 to backfill existing scenario_truths/evidences rows
- judge rubric (v2 -> v3): only reveal evidence when a response confirms the
required fact, not merely when the player asks about it
- mocks(desktop): add hover affordance (inset line) marking clickable rows in
cast, roster, and scenario-select lists
Co-Authored-By: Claude
---
.../db/scenario-current-authoring.test.ts | 16 +++++++++++
__tests__/llm/judge.test.ts | 28 +++++++++++++++++++
..._tsukimisou-death-estimate-consistency.sql | 27 ++++++++++++++++++
db/scenarios/tsukimisou.yaml | 6 ++--
mocks/desktop/_desk.css | 23 ++++++++++++++-
mocks/desktop/accusation.html | 5 ++++
mocks/desktop/detective.html | 5 ++++
mocks/desktop/scenario-select.html | 7 ++++-
src/server/cache/scenario.ts | 4 ++-
9 files changed, 116 insertions(+), 5 deletions(-)
create mode 100644 db/migrations/0025_tsukimisou-death-estimate-consistency.sql
diff --git a/__tests__/db/scenario-current-authoring.test.ts b/__tests__/db/scenario-current-authoring.test.ts
index 1d25a59..d68d03c 100644
--- a/__tests__/db/scenario-current-authoring.test.ts
+++ b/__tests__/db/scenario-current-authoring.test.ts
@@ -329,6 +329,22 @@ describe('scenario current authoring guide', () => {
expect(violations).toEqual([])
})
+ test('月見荘で遺体から死亡推定を開くなら、体温と硬直の所見を遺体側にも持つ', async () => {
+ const tsukimisou = (await scenarios()).find(({ file }) => file === 'tsukimisou.yaml')?.scenario
+
+ expect(tsukimisou).toBeDefined()
+ if (tsukimisou === undefined) return
+
+ const postmortem = tsukimisou.evidences.find((evidence) => evidence.id === 'postmortem-signs')
+ const findings =
+ tsukimisou.victim?.findings.map((finding) => finding.statement).join('\n') ?? ''
+
+ expect(postmortem?.revealsDeathTime).toBe(true)
+ expect(postmortem?.sources.some((source) => source.type === 'victim')).toBe(true)
+ expect(findings).toMatch(/温か|体温/)
+ expect(findings).toContain('硬直')
+ })
+
test('精読で場所調査が有効と判断した事件には、空振りしない調査場所を置く', async () => {
const violations: string[] = []
diff --git a/__tests__/llm/judge.test.ts b/__tests__/llm/judge.test.ts
index 8dc737b..a4145fc 100644
--- a/__tests__/llm/judge.test.ts
+++ b/__tests__/llm/judge.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, test } from 'bun:test'
+import { loadJudgeRubric } from '@/server/cache/scenario'
import { judgementSchema } from '@/server/llm/judge'
const validJudgement = {
@@ -9,6 +10,33 @@ const validJudgement = {
suggestedQuestions: [],
}
+describe('loadJudgeRubric', () => {
+ test('質問しただけでは証拠を開かず、返答で事実が確認できたときだけ開示するよう審判に要求する', async () => {
+ const kv = {
+ get: async () => null,
+ put: async () => undefined,
+ } as unknown as KVNamespace
+ const db = {
+ select: () => ({
+ from: () => ({
+ where: async () => [
+ {
+ id: 'postmortem-signs',
+ revealCondition: '死後硬直と体温を確認できたら開示する。',
+ },
+ ],
+ }),
+ }),
+ } as never
+
+ const rubric = await loadJudgeRubric(kv, db, 'scenario-id')
+
+ expect(rubric).toContain('質問しただけ')
+ expect(rubric).toMatch(/返答.*確認/)
+ expect(rubric).toMatch(/分からない|確認できない/)
+ })
+})
+
describe('judgementSchema', () => {
test('Revelation解禁IDを構造化出力として受け取れる', () => {
const parsed = judgementSchema.parse({
diff --git a/db/migrations/0025_tsukimisou-death-estimate-consistency.sql b/db/migrations/0025_tsukimisou-death-estimate-consistency.sql
new file mode 100644
index 0000000..a309fec
--- /dev/null
+++ b/db/migrations/0025_tsukimisou-death-estimate-consistency.sql
@@ -0,0 +1,27 @@
+-- 月見荘: 遺体検分の返答と死亡推定の開示条件を一致させる。
+-- 既存の victim_findings(前提 Evidence UUID を含む)は保持し、所見だけを追記する。
+UPDATE scenario_truths
+SET victim_findings = CASE
+ WHEN EXISTS (
+ SELECT 1
+ FROM json_each(scenario_truths.victim_findings)
+ WHERE json_extract(value, '$.id') = 'postmortem-state'
+ ) THEN victim_findings
+ ELSE json_insert(
+ victim_findings,
+ '$[#]',
+ json('{"id":"postmortem-state","statement":"発見時、体にはまだ温かさが残っており、死後硬直も始まりかけた段階にとどまっている。","requires":{"revelations":[],"evidences":[]}}')
+ )
+END
+WHERE scenario_id = (
+ SELECT id FROM scenarios WHERE victim_name = '高瀬涼子' LIMIT 1
+);
+
+UPDATE evidences
+SET
+ description = '唇のまわりと指先のしびれの跡に加え、発見時には体にまだ温かさが残り、死後硬直も始まりかけた段階にとどまっている。発見時刻と合わせると、事切れたのは20時15分ごろと推定できる。',
+ reveal_condition = 'プレイヤーが遺体を調べ、検分の返答で「体にまだ温かさが残り、死後硬直も始まりかけている」という所見を実際に確認し、その所見を根拠に死亡時刻を検討したら開示する。'
+WHERE scenario_id = (
+ SELECT id FROM scenarios WHERE victim_name = '高瀬涼子' LIMIT 1
+)
+AND label = '遺体に残る中毒の徴候と、その進み具合';
diff --git a/db/scenarios/tsukimisou.yaml b/db/scenarios/tsukimisou.yaml
index 362f3b2..7a2ad5f 100644
--- a/db/scenarios/tsukimisou.yaml
+++ b/db/scenarios/tsukimisou.yaml
@@ -41,6 +41,8 @@ victim:
statement: 争った跡が無い。着衣も髪も乱れておらず、文机の上も片付いたままになっている。
- id: numbness-signs
statement: 唇のまわりと指先に、しびれが出たときの跡が残っている。
+ - id: postmortem-state
+ statement: 発見時、体にはまだ温かさが残っており、死後硬直も始まりかけた段階にとどまっている。
- id: single-glass
statement: 文机に、飲みかけのグラスが一つだけ置かれている。誰かと酌み交わした跡は無い。
# 後継者指定を知ってから読むと意味が変わる草案。順序を作るために前提を置く。
@@ -635,9 +637,9 @@ evidences:
# 探偵自身の検死。遺体を調べる一手を使った人だけが、刻限を自分の目で確かめられる。
- id: postmortem-signs
label: 遺体に残る中毒の徴候と、その進み具合
- description: 唇のまわりと指先のしびれの跡、体の冷え方と硬直の出方。口にしてから絶命までの時間と合わせると、事切れたのは20時15分ごろになる。
+ description: 唇のまわりと指先のしびれの跡に加え、発見時には体にまだ温かさが残り、死後硬直も始まりかけた段階にとどまっている。発見時刻と合わせると、事切れたのは20時15分ごろと推定できる。
reveal:
- condition: プレイヤーが遺体を調べ、探偵が唇や指先の跡、体の冷え方や硬直といった死後の変化に触れたら開示する。
+ condition: プレイヤーが遺体を調べ、検分の返答で「体にまだ温かさが残り、死後硬直も始まりかけている」という所見を実際に確認し、その所見を根拠に死亡時刻を検討したら開示する。
sources:
- type: victim
id: victim
diff --git a/mocks/desktop/_desk.css b/mocks/desktop/_desk.css
index 94d6a7a..9fdef43 100644
--- a/mocks/desktop/_desk.css
+++ b/mocks/desktop/_desk.css
@@ -709,14 +709,35 @@ select {
font-size: 14px;
}
+/* ---- 押せる行の左端に立つ一本 ---- */
+/*
+ * 机の上では指が触れないので、押せる行かどうかを静止画から読み取れない。
+ * 触れているあいだだけ左端に一本立てて、そこが押せることを言う。
+ *
+ * 選ばれた行の線と同じ位置・同じ太さで引き、明るさだけを変える——
+ * 迷っているあいだの線と、決めたあとの線が別物に見えると、
+ * 触れて動いた一本が「選べた」のか「選んだ」のか分からなくなる。
+ *
+ * 枠ではなく内側の影で引く。枠だと線が出た瞬間に行の字が右へずれる。
+ * 行の側に左の余白を用意しておくのも同じ理由で、線と字を重ねないため。
+ * 顔料を持つ行(人物・場所)はその人の色で、持たない行は鼠で引く。
+ *
+ * 指の台では hover が押したあと張り付くので、ポインタのある台だけに出す。
+ */
+
/* ---- 名簿(登場人物) ---- */
.cast .r {
display: flex;
align-items: center;
gap: 12px;
- padding: 10px 0;
+ padding: 10px 0 10px 12px;
border-bottom: 1px solid var(--keisen);
}
+@media (hover: hover) {
+ .cast .r:not([disabled]):hover {
+ box-shadow: inset 2px 0 0 currentColor;
+ }
+}
.cast .r:first-child {
border-top: 1px solid var(--keisen);
}
diff --git a/mocks/desktop/accusation.html b/mocks/desktop/accusation.html
index d65166d..7eb781c 100644
--- a/mocks/desktop/accusation.html
+++ b/mocks/desktop/accusation.html
@@ -32,6 +32,11 @@
.suspects button + button { border-left: 1px solid var(--keisen); }
/* 選ばれた一人だけ、下辺を朱に替える。塗り足しも枠も足さない。 */
.suspects button.on { border-bottom-color: var(--shu); }
+ /* 触れているあいだは左端にその人の顔料で一本(語彙は _desk.css)。
+ 指名の印は下辺なので、軸が違って喧嘩しない。 */
+ @media (hover: hover) {
+ .suspects button:hover { box-shadow: inset 2px 0 0 currentColor; }
+ }
.suspects .face {
width: 30px; height: 30px; border-radius: 50%; flex: none;
display: grid; place-items: center; background: var(--sumi-2);
diff --git a/mocks/desktop/detective.html b/mocks/desktop/detective.html
index 3058063..e01e603 100644
--- a/mocks/desktop/detective.html
+++ b/mocks/desktop/detective.html
@@ -60,9 +60,14 @@
/*
選択は塗らずに左端の一本で示す。行の頭は全行そろえたいので、枠ではなく
内側の影で引く——枠だと選ばれた行だけ字が右へずれる。
+ 触れているあいだも同じ位置に一本立てるが、そちらは鼠。探偵は顔料を
+ 持たないので、明るさだけで「選べる」と「選んだ」を分ける。
*/
.roster .r { align-items: flex-start; padding-left: 14px; }
.roster .r.on { box-shadow: inset 2px 0 0 var(--kinari); }
+ @media (hover: hover) {
+ .roster .r:not(.on):hover { box-shadow: inset 2px 0 0 var(--nezumi); }
+ }
.roster .pick {
display: flex; flex-direction: column; min-width: 0;
background: none; border: 0; padding: 0; text-align: left;
diff --git a/mocks/desktop/scenario-select.html b/mocks/desktop/scenario-select.html
index 5ccf392..c459c95 100644
--- a/mocks/desktop/scenario-select.html
+++ b/mocks/desktop/scenario-select.html
@@ -45,10 +45,15 @@
}
.case {
display: flex; flex-direction: column; gap: 2px; align-items: flex-start;
- padding: 13px 0; text-align: left;
+ padding: 13px 0 13px 12px; text-align: left;
background: none; border: 0; border-bottom: 1px solid var(--keisen);
color: inherit; font: inherit; text-decoration: none;
}
+ /* 触れている一件だけ左端に線を立てる(語彙は _desk.css の「押せる行の左端に立つ一本」)。
+ 事件は顔料を持たないので鼠で引く。 */
+ @media (hover: hover) {
+ .case:hover { box-shadow: inset 2px 0 0 var(--nezumi); }
+ }
.case .cat { font-size: 10px; color: var(--nezumi-dim); letter-spacing: 0.16em; line-height: 1.5; }
.case .ttl { font-family: var(--mincho); font-weight: 500; font-size: 15px; line-height: 1.5; letter-spacing: 0.03em; }
.case .meta { font-size: 11px; color: var(--nezumi-dim); line-height: 1.5; }
diff --git a/src/server/cache/scenario.ts b/src/server/cache/scenario.ts
index 6881406..8b51e71 100644
--- a/src/server/cache/scenario.ts
+++ b/src/server/cache/scenario.ts
@@ -29,7 +29,7 @@ const characterKey = (characterId: string) => `character:v2:${characterId}`
* 版を付けてある。ルーブリックは1時間キャッシュされるので、版が無いと
* 指示を直しても最大1時間は古い文面のまま判定が走る(デプロイ直後が一番危ない)。
*/
-const judgeRubricKey = (scenarioId: string) => `judge-rubric:v2:${scenarioId}`
+const judgeRubricKey = (scenarioId: string) => `judge-rubric:v3:${scenarioId}`
const judgeRevelationsKey = (scenarioId: string) => `judge-revelations:${scenarioId}`
// 版を付けてある。数える相手の並びが変わっても、1時間の TTL を待たずに切り替わるように。
const hintSubjectsKey = (scenarioId: string) => `hint-subjects:v2:${scenarioId}`
@@ -190,6 +190,8 @@ export const loadJudgeRubric = async (
const rubric = `あなたはマーダーミステリーの進行審判である。プレイヤーが指定した話題と、それを受けて探偵がNPCと交わしたやり取りを読み、以下を判定する。やり取りは同じ話題について複数の往復にわたることがあり、その全体をまとめて1回として判定する。
- revealedEvidenceIds: 今回のやり取りで開示条件を満たした証拠のIDを列挙する。満たしていなければ空配列。
+ **プレイヤーが条件に関係する言葉を質問しただけでは、開示条件を満たしたことにならない。** NPCの返答または検分の返答で、条件に必要な事実が実際に確認されていること。
+ 返答が「分からない」「確認できない」「その所見はない」など、必要な事実を否定または不明としている場合は、質問側に同じ言葉が含まれていても絶対に開示しない。
- revealedRevelationIds: ユーザーメッセージ末尾の「今回判定可能なRevelation」に列挙された候補のうち、今回の会話で条件を満たしたIDだけを列挙する。候補外のIDを推測してはいけない。満たしていなければ空配列。
- contradictionPointedOut: 探偵が過去の発言との矛盾を指摘できていたら true。
- npcLied: NPCの返答が、その場しのぎの嘘や誤誘導を含んでいたら true。
From 7b8f7317720226620a117df2bbc91bf39c4d5b8f Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Mon, 7 Sep 2026 07:44:56 +0000
Subject: [PATCH 03/41] ui(mocks): add hover feedback to desktop mocks and fix
playwright setup
Add background/color hover states to buttons, rows, and text-only actions
across desktop mocks so pressable elements react on touch. Also install
Playwright's chromium browser via postinstall and wire install-deps into
the devcontainer so the mock-shot screenshot tool works out of the box.
Co-Authored-By: Claude
---
.devcontainer/postCreateCommand.sh | 11 +++++++++
mocks/desktop/_desk.css | 39 ++++++++++++++++++++++++++++++
mocks/desktop/accusation.html | 2 +-
mocks/desktop/detective.html | 6 ++++-
mocks/desktop/scenario-select.html | 3 ++-
mocks/desktop/settings.html | 3 +++
package.json | 1 +
7 files changed, 62 insertions(+), 3 deletions(-)
diff --git a/.devcontainer/postCreateCommand.sh b/.devcontainer/postCreateCommand.sh
index aad0236..9776966 100755
--- a/.devcontainer/postCreateCommand.sh
+++ b/.devcontainer/postCreateCommand.sh
@@ -29,6 +29,17 @@ if [ -f package.json ]; then
fi
fi
+# Playwright's browser and the shared libraries it links against.
+# The screenshot tool (.claude/skills/mock-shot) needs both; without them it dies
+# with "libglib-2.0.so.0: cannot open shared object file".
+# The installs above pass --ignore-scripts, so package.json's postinstall does not
+# fire here — call it explicitly.
+if [ -x node_modules/.bin/playwright ]; then
+ sudo env "PATH=$PATH" node_modules/.bin/playwright install-deps chromium \
+ || echo "[postCreate] playwright install-deps failed — run it manually"
+ bun run postinstall || echo "[postCreate] browser download failed — run 'bun run postinstall'"
+fi
+
# Apply migrations and load the scenarios into the local D1 database.
# Both run against .wrangler/state, so no network and no database container.
if [ -n "$(ls -A db/migrations 2>/dev/null)" ]; then
diff --git a/mocks/desktop/_desk.css b/mocks/desktop/_desk.css
index 9fdef43..6ec8382 100644
--- a/mocks/desktop/_desk.css
+++ b/mocks/desktop/_desk.css
@@ -704,6 +704,39 @@ select {
grid-template-columns: repeat(2, 1fr);
gap: 18px;
}
+
+/* ---- 押せるものが触れられたとき ---- */
+/*
+ * 行と同じことをボタンにも言う。ただし枠を持っているので、左に一本足すと
+ * 枠の内側にもう一本増えて濁る。ボタンでは枠と字のほうを起こす。
+ * 地を上げる幅は行と揃えて --sumi-2 まで。
+ *
+ * 朱の一手だけ色を替えない。取り消せない一手が、触れただけで他の操作と
+ * 同じ顔になると、押す前の身構えがひとつ抜ける。
+ *
+ * 字だけの操作(戻る・やり直す)には起こすものが字しかないので、字だけ上げる。
+ */
+@media (hover: hover) {
+ .go:not(.off):hover {
+ background: var(--sumi-2);
+ border-color: var(--kinari);
+ }
+ .go.final:hover {
+ border-color: var(--shu);
+ }
+ .desk > .top .act:hover,
+ .askbar .btn:hover,
+ .res .foot span:hover {
+ background: var(--sumi-2);
+ border-color: var(--nezumi);
+ color: var(--kinari);
+ }
+ .desk > .top .back:hover,
+ .foot .again:hover,
+ .brief .skip:hover {
+ color: var(--kinari);
+ }
+}
.foot .pair .go {
letter-spacing: 0.12em;
font-size: 14px;
@@ -722,6 +755,11 @@ select {
* 行の側に左の余白を用意しておくのも同じ理由で、線と字を重ねないため。
* 顔料を持つ行(人物・場所)はその人の色で、持たない行は鼠で引く。
*
+ * 地も一段だけ起こす。線だけだと行のどこまでが一枚なのかが分からず、
+ * 二列に畳んだ名簿では隣の列の行を触っているように見える。値は聞き込み中の列
+ * (.chart .col.now)と同じ --sumi-2 で、これ以上は上げない——濃くすると
+ * 触れた行が箱になり、罫線で区切ってきた画面のなかで一行だけ札になる。
+ *
* 指の台では hover が押したあと張り付くので、ポインタのある台だけに出す。
*/
@@ -736,6 +774,7 @@ select {
@media (hover: hover) {
.cast .r:not([disabled]):hover {
box-shadow: inset 2px 0 0 currentColor;
+ background: var(--sumi-2);
}
}
.cast .r:first-child {
diff --git a/mocks/desktop/accusation.html b/mocks/desktop/accusation.html
index 7eb781c..0d053b7 100644
--- a/mocks/desktop/accusation.html
+++ b/mocks/desktop/accusation.html
@@ -35,7 +35,7 @@
/* 触れているあいだは左端にその人の顔料で一本(語彙は _desk.css)。
指名の印は下辺なので、軸が違って喧嘩しない。 */
@media (hover: hover) {
- .suspects button:hover { box-shadow: inset 2px 0 0 currentColor; }
+ .suspects button:hover { box-shadow: inset 2px 0 0 currentColor; background: var(--sumi-2); }
}
.suspects .face {
width: 30px; height: 30px; border-radius: 50%; flex: none;
diff --git a/mocks/desktop/detective.html b/mocks/desktop/detective.html
index e01e603..2a89141 100644
--- a/mocks/desktop/detective.html
+++ b/mocks/desktop/detective.html
@@ -66,7 +66,11 @@
.roster .r { align-items: flex-start; padding-left: 14px; }
.roster .r.on { box-shadow: inset 2px 0 0 var(--kinari); }
@media (hover: hover) {
- .roster .r:not(.on):hover { box-shadow: inset 2px 0 0 var(--nezumi); }
+ .roster .r:not(.on):hover { box-shadow: inset 2px 0 0 var(--nezumi); background: var(--sumi-2); }
+ /* 行の中の小さな操作と、字だけの操作。起こすものが字しかない。 */
+ .roster .acts span:hover, .add:hover, .mirror .edit:hover, .det > .lt > .back:hover {
+ color: var(--kinari);
+ }
}
.roster .pick {
display: flex; flex-direction: column; min-width: 0;
diff --git a/mocks/desktop/scenario-select.html b/mocks/desktop/scenario-select.html
index c459c95..7095b17 100644
--- a/mocks/desktop/scenario-select.html
+++ b/mocks/desktop/scenario-select.html
@@ -52,7 +52,8 @@
/* 触れている一件だけ左端に線を立てる(語彙は _desk.css の「押せる行の左端に立つ一本」)。
事件は顔料を持たないので鼠で引く。 */
@media (hover: hover) {
- .case:hover { box-shadow: inset 2px 0 0 var(--nezumi); }
+ .case:hover { box-shadow: inset 2px 0 0 var(--nezumi); background: var(--sumi-2); }
+ .masthead .rgt .set:hover { color: var(--kinari); }
}
.case .cat { font-size: 10px; color: var(--nezumi-dim); letter-spacing: 0.16em; line-height: 1.5; }
.case .ttl { font-family: var(--mincho); font-weight: 500; font-size: 15px; line-height: 1.5; letter-spacing: 0.03em; }
diff --git a/mocks/desktop/settings.html b/mocks/desktop/settings.html
index 94a95b3..d2df634 100644
--- a/mocks/desktop/settings.html
+++ b/mocks/desktop/settings.html
@@ -16,6 +16,9 @@
/* 数は打ち替えられる。枠と高さは _desk.css の .num のまま借りる。 */
.set .num { background: none; width: 100%; }
.set .back { text-decoration: none; }
+ @media (hover: hover) {
+ .set .back:hover { color: var(--kinari); }
+ }
diff --git a/package.json b/package.json
index 42f05be..7b6db67 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,7 @@
"private": true,
"type": "module",
"scripts": {
+ "postinstall": "playwright install --only-shell chromium",
"dev": "vite --host",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
From ff114687522bfc430c45c911b6a5e6f618e6dd77 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Mon, 7 Sep 2026 07:48:45 +0000
Subject: [PATCH 04/41] build(devcontainer): install playwright chromium deps
via apt-packages feature
Move the shared library list into the apt-packages feature so it stays in
step with the base image, and drop the redundant playwright install-deps
call from postCreateCommand.sh.
Co-Authored-By: Claude
---
.devcontainer/devcontainer.json | 6 +++++-
.devcontainer/postCreateCommand.sh | 9 ++++-----
2 files changed, 9 insertions(+), 6 deletions(-)
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index f181811..24506be 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -48,7 +48,11 @@
"ghcr.io/dhoeric/features/act:1": {},
"ghcr.io/devcontainers-community/features/direnv:1": {},
"ghcr.io/rocker-org/devcontainer-features/apt-packages:1": {
- "packages": "git-filter-repo"
+ // The libs after git-filter-repo are what Playwright's chromium links against
+ // (the ubuntu24.04 chromium list from playwright-core's nativeDeps); the
+ // screenshot tool in .claude/skills/mock-shot cannot launch without them.
+ // Keep this in step with the base image: the t64 names are noble-specific.
+ "packages": "git-filter-repo libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 libcairo2 libcups2t64 libdbus-1-3 libdrm2 libgbm1 libglib2.0-0t64 libnspr4 libnss3 libpango-1.0-0 libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2"
}
},
"postAttachCommand": "/bin/sh .devcontainer/postAttachCommand.sh",
diff --git a/.devcontainer/postCreateCommand.sh b/.devcontainer/postCreateCommand.sh
index 9776966..6dc4b28 100755
--- a/.devcontainer/postCreateCommand.sh
+++ b/.devcontainer/postCreateCommand.sh
@@ -29,14 +29,13 @@ if [ -f package.json ]; then
fi
fi
-# Playwright's browser and the shared libraries it links against.
-# The screenshot tool (.claude/skills/mock-shot) needs both; without them it dies
-# with "libglib-2.0.so.0: cannot open shared object file".
+# Playwright's browser for the screenshot tool (.claude/skills/mock-shot).
+# The shared libraries it links against are declared in devcontainer.json's
+# apt-packages feature — without them the browser dies at launch with
+# "libglib-2.0.so.0: cannot open shared object file".
# The installs above pass --ignore-scripts, so package.json's postinstall does not
# fire here — call it explicitly.
if [ -x node_modules/.bin/playwright ]; then
- sudo env "PATH=$PATH" node_modules/.bin/playwright install-deps chromium \
- || echo "[postCreate] playwright install-deps failed — run it manually"
bun run postinstall || echo "[postCreate] browser download failed — run 'bun run postinstall'"
fi
From 02d8f5499a070f9c0b52a6a5b30accfa7dd7351e Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Mon, 7 Sep 2026 07:50:23 +0000
Subject: [PATCH 05/41] chore(scripts): move mock-tooling scripts out of repo
root
Rename .tmp-*.mjs and .crop.mjs helper scripts into scripts/ with descriptive names instead of leaving throwaway files at the root.
Co-Authored-By: Claude
---
.tmp-crop.mjs => scripts/compare-mock-impl.mjs | 0
.crop.mjs => scripts/crop-image.mjs | 0
.tmp-crop-int.mjs => scripts/crop-interrogation-columns.mjs | 0
.tmp-crop-int2.mjs => scripts/crop-interrogation-last.mjs | 0
.tmp-crop3.mjs => scripts/crop-zoom-onpin.mjs | 0
.tmp-crop4.mjs => scripts/crop-zoom2-onpin.mjs | 0
.tmp-measure.mjs => scripts/measure-gutters.mjs | 0
.tmp-font.mjs => scripts/probe-fonts.mjs | 0
.tmp-mockshot.mjs => scripts/shoot-mock.mjs | 0
9 files changed, 0 insertions(+), 0 deletions(-)
rename .tmp-crop.mjs => scripts/compare-mock-impl.mjs (100%)
rename .crop.mjs => scripts/crop-image.mjs (100%)
rename .tmp-crop-int.mjs => scripts/crop-interrogation-columns.mjs (100%)
rename .tmp-crop-int2.mjs => scripts/crop-interrogation-last.mjs (100%)
rename .tmp-crop3.mjs => scripts/crop-zoom-onpin.mjs (100%)
rename .tmp-crop4.mjs => scripts/crop-zoom2-onpin.mjs (100%)
rename .tmp-measure.mjs => scripts/measure-gutters.mjs (100%)
rename .tmp-font.mjs => scripts/probe-fonts.mjs (100%)
rename .tmp-mockshot.mjs => scripts/shoot-mock.mjs (100%)
diff --git a/.tmp-crop.mjs b/scripts/compare-mock-impl.mjs
similarity index 100%
rename from .tmp-crop.mjs
rename to scripts/compare-mock-impl.mjs
diff --git a/.crop.mjs b/scripts/crop-image.mjs
similarity index 100%
rename from .crop.mjs
rename to scripts/crop-image.mjs
diff --git a/.tmp-crop-int.mjs b/scripts/crop-interrogation-columns.mjs
similarity index 100%
rename from .tmp-crop-int.mjs
rename to scripts/crop-interrogation-columns.mjs
diff --git a/.tmp-crop-int2.mjs b/scripts/crop-interrogation-last.mjs
similarity index 100%
rename from .tmp-crop-int2.mjs
rename to scripts/crop-interrogation-last.mjs
diff --git a/.tmp-crop3.mjs b/scripts/crop-zoom-onpin.mjs
similarity index 100%
rename from .tmp-crop3.mjs
rename to scripts/crop-zoom-onpin.mjs
diff --git a/.tmp-crop4.mjs b/scripts/crop-zoom2-onpin.mjs
similarity index 100%
rename from .tmp-crop4.mjs
rename to scripts/crop-zoom2-onpin.mjs
diff --git a/.tmp-measure.mjs b/scripts/measure-gutters.mjs
similarity index 100%
rename from .tmp-measure.mjs
rename to scripts/measure-gutters.mjs
diff --git a/.tmp-font.mjs b/scripts/probe-fonts.mjs
similarity index 100%
rename from .tmp-font.mjs
rename to scripts/probe-fonts.mjs
diff --git a/.tmp-mockshot.mjs b/scripts/shoot-mock.mjs
similarity index 100%
rename from .tmp-mockshot.mjs
rename to scripts/shoot-mock.mjs
From d0f696a29e29d8b27f8b9da44da22442d1fd582e Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Mon, 7 Sep 2026 14:20:58 +0000
Subject: [PATCH 06/41] feat(db): add analytics_sessions and analytics_turns
tables
Persist per-session solve outcomes and per-turn Q&A history for
analytics, independent of play_sessions retention so this data
survives deletion. Wires analytics writes into the sessions routes
and documents the new tables.
Co-Authored-By: Claude
---
db/migrations/0023_analytics-tables.sql | 54 +
db/migrations/meta/0023_snapshot.json | 1522 +++++++++++++++++++++++
db/migrations/meta/_journal.json | 7 +
db/schema.ts | 123 ++
docs/architecture/data.md | 7 +
src/server/db/analytics.ts | 151 +++
src/server/db/retention.ts | 7 +-
src/server/routes/sessions.ts | 90 +-
8 files changed, 1956 insertions(+), 5 deletions(-)
create mode 100644 db/migrations/0023_analytics-tables.sql
create mode 100644 db/migrations/meta/0023_snapshot.json
create mode 100644 src/server/db/analytics.ts
diff --git a/db/migrations/0023_analytics-tables.sql b/db/migrations/0023_analytics-tables.sql
new file mode 100644
index 0000000..31229d5
--- /dev/null
+++ b/db/migrations/0023_analytics-tables.sql
@@ -0,0 +1,54 @@
+-- 分析用の控え。play_sessions への外部キーを張らないのは意図で、
+-- 保持期間の削除に巻き込まれないことがこの2表の存在理由そのもの(db/schema.ts 参照)。
+-- 連番は drizzle が journal から採るので 0023 だが、手書きの 0025 より後に書いたもの。
+-- 互いに独立した変更なので、どちらの順で適用しても結果は変わらない。
+CREATE TABLE `analytics_sessions` (
+ `session_id` text PRIMARY KEY NOT NULL,
+ `scenario_id` text NOT NULL,
+ `mode` text NOT NULL,
+ `detective` text,
+ `max_turns` integer NOT NULL,
+ `questions_per_turn` integer NOT NULL,
+ `exchanges_per_topic` integer NOT NULL,
+ `started_at` integer DEFAULT (unixepoch()) NOT NULL,
+ `finished_at` integer,
+ `culprit_character_id` text,
+ `culprit_correct` integer,
+ `method_correct` integer,
+ `motive_correct` integer,
+ `reasoning` text,
+ `method` text,
+ `motive` text,
+ `method_comment` text,
+ `motive_comment` text,
+ `solved_seconds` integer,
+ `question_count` integer,
+ `evidence_found` integer,
+ `evidence_total` integer,
+ `contradiction_count` integer,
+ `accuracy_percent` integer
+);
+--> statement-breakpoint
+CREATE INDEX `analytics_sessions_scenario_id_idx` ON `analytics_sessions` (`scenario_id`);--> statement-breakpoint
+CREATE INDEX `analytics_sessions_started_at_idx` ON `analytics_sessions` (`started_at`);--> statement-breakpoint
+CREATE TABLE `analytics_turns` (
+ `id` text PRIMARY KEY NOT NULL,
+ `session_id` text NOT NULL,
+ `scenario_id` text NOT NULL,
+ `mode` text NOT NULL,
+ `subject_kind` text NOT NULL,
+ `subject_id` text NOT NULL,
+ `turn_index` integer,
+ `question_count` integer,
+ `topic` text NOT NULL,
+ `exchanges` text NOT NULL,
+ `revealed_evidence_ids` text,
+ `revealed_revelation_ids` text,
+ `contradiction_pointed_out` integer,
+ `npc_lied` integer,
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL
+);
+--> statement-breakpoint
+CREATE INDEX `analytics_turns_session_id_idx` ON `analytics_turns` (`session_id`);--> statement-breakpoint
+CREATE INDEX `analytics_turns_scenario_id_idx` ON `analytics_turns` (`scenario_id`);--> statement-breakpoint
+CREATE INDEX `analytics_turns_created_at_idx` ON `analytics_turns` (`created_at`);
\ No newline at end of file
diff --git a/db/migrations/meta/0023_snapshot.json b/db/migrations/meta/0023_snapshot.json
new file mode 100644
index 0000000..064add5
--- /dev/null
+++ b/db/migrations/meta/0023_snapshot.json
@@ -0,0 +1,1522 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "1f5ead39-3cf1-403e-9a67-e4382532478a",
+ "prevId": "1a711748-a372-470e-957e-00db96cda2e8",
+ "tables": {
+ "analytics_sessions": {
+ "name": "analytics_sessions",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "detective": {
+ "name": "detective",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "max_turns": {
+ "name": "max_turns",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "questions_per_turn": {
+ "name": "questions_per_turn",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exchanges_per_topic": {
+ "name": "exchanges_per_topic",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "culprit_character_id": {
+ "name": "culprit_character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "culprit_correct": {
+ "name": "culprit_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method_correct": {
+ "name": "method_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive_correct": {
+ "name": "motive_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reasoning": {
+ "name": "reasoning",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive": {
+ "name": "motive",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method_comment": {
+ "name": "method_comment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive_comment": {
+ "name": "motive_comment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "solved_seconds": {
+ "name": "solved_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "question_count": {
+ "name": "question_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "evidence_found": {
+ "name": "evidence_found",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "evidence_total": {
+ "name": "evidence_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contradiction_count": {
+ "name": "contradiction_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "accuracy_percent": {
+ "name": "accuracy_percent",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "analytics_sessions_scenario_id_idx": {
+ "name": "analytics_sessions_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ },
+ "analytics_sessions_started_at_idx": {
+ "name": "analytics_sessions_started_at_idx",
+ "columns": [
+ "started_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "analytics_turns": {
+ "name": "analytics_turns",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_kind": {
+ "name": "subject_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "turn_index": {
+ "name": "turn_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "question_count": {
+ "name": "question_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "topic": {
+ "name": "topic",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exchanges": {
+ "name": "exchanges",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revealed_evidence_ids": {
+ "name": "revealed_evidence_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revealed_revelation_ids": {
+ "name": "revealed_revelation_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contradiction_pointed_out": {
+ "name": "contradiction_pointed_out",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "npc_lied": {
+ "name": "npc_lied",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "analytics_turns_session_id_idx": {
+ "name": "analytics_turns_session_id_idx",
+ "columns": [
+ "session_id"
+ ],
+ "isUnique": false
+ },
+ "analytics_turns_scenario_id_idx": {
+ "name": "analytics_turns_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ },
+ "analytics_turns_created_at_idx": {
+ "name": "analytics_turns_created_at_idx",
+ "columns": [
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "characters": {
+ "name": "characters",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "public_introduction": {
+ "name": "public_introduction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "personality": {
+ "name": "personality",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "knowledge": {
+ "name": "knowledge",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "secrets": {
+ "name": "secrets",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "goals": {
+ "name": "goals",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lies": {
+ "name": "lies",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lie_refs": {
+ "name": "lie_refs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "memories": {
+ "name": "memories",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "characters_scenario_id_idx": {
+ "name": "characters_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "characters_scenario_id_scenarios_id_fk": {
+ "name": "characters_scenario_id_scenarios_id_fk",
+ "tableFrom": "characters",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "discoveries": {
+ "name": "discoveries",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "evidence_id": {
+ "name": "evidence_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "discovered_at": {
+ "name": "discovered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "discoveries_evidence_id_idx": {
+ "name": "discoveries_evidence_id_idx",
+ "columns": [
+ "evidence_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "discoveries_session_id_play_sessions_id_fk": {
+ "name": "discoveries_session_id_play_sessions_id_fk",
+ "tableFrom": "discoveries",
+ "tableTo": "play_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "discoveries_evidence_id_evidences_id_fk": {
+ "name": "discoveries_evidence_id_evidences_id_fk",
+ "tableFrom": "discoveries",
+ "tableTo": "evidences",
+ "columnsFrom": [
+ "evidence_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "discoveries_session_id_evidence_id_pk": {
+ "columns": [
+ "session_id",
+ "evidence_id"
+ ],
+ "name": "discoveries_session_id_evidence_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "evidences": {
+ "name": "evidences",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reveal_condition": {
+ "name": "reveal_condition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "supports": {
+ "name": "supports",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "contradicts": {
+ "name": "contradicts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "reveals_death_time": {
+ "name": "reveals_death_time",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "evidences_scenario_id_idx": {
+ "name": "evidences_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "evidences_scenario_id_scenarios_id_fk": {
+ "name": "evidences_scenario_id_scenarios_id_fk",
+ "tableFrom": "evidences",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "llm_usages": {
+ "name": "llm_usages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "input_tokens": {
+ "name": "input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "output_tokens": {
+ "name": "output_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "cached_input_tokens": {
+ "name": "cached_input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "cache_creation_input_tokens": {
+ "name": "cache_creation_input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "reasoning_tokens": {
+ "name": "reasoning_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "llm_usages_created_at_idx": {
+ "name": "llm_usages_created_at_idx",
+ "columns": [
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "llm_usages_session_id_idx": {
+ "name": "llm_usages_session_id_idx",
+ "columns": [
+ "session_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "messages": {
+ "name": "messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "character_id": {
+ "name": "character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "messages_session_id_idx": {
+ "name": "messages_session_id_idx",
+ "columns": [
+ "session_id"
+ ],
+ "isUnique": false
+ },
+ "messages_character_id_idx": {
+ "name": "messages_character_id_idx",
+ "columns": [
+ "character_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "messages_session_id_play_sessions_id_fk": {
+ "name": "messages_session_id_play_sessions_id_fk",
+ "tableFrom": "messages",
+ "tableTo": "play_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "messages_character_id_characters_id_fk": {
+ "name": "messages_character_id_characters_id_fk",
+ "tableFrom": "messages",
+ "tableTo": "characters",
+ "columnsFrom": [
+ "character_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "play_sessions": {
+ "name": "play_sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "detective": {
+ "name": "detective",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "play_sessions_scenario_id_idx": {
+ "name": "play_sessions_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ },
+ "play_sessions_started_at_idx": {
+ "name": "play_sessions_started_at_idx",
+ "columns": [
+ "started_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "play_sessions_scenario_id_scenarios_id_fk": {
+ "name": "play_sessions_scenario_id_scenarios_id_fk",
+ "tableFrom": "play_sessions",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "reports": {
+ "name": "reports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reported_at": {
+ "name": "reported_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "reports_scenario_id_idx": {
+ "name": "reports_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "reports_scenario_id_scenarios_id_fk": {
+ "name": "reports_scenario_id_scenarios_id_fk",
+ "tableFrom": "reports",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "results": {
+ "name": "results",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "solved_seconds": {
+ "name": "solved_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "question_count": {
+ "name": "question_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "evidence_found": {
+ "name": "evidence_found",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contradiction_count": {
+ "name": "contradiction_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accuracy_percent": {
+ "name": "accuracy_percent",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "method_correct": {
+ "name": "method_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive_correct": {
+ "name": "motive_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deduction": {
+ "name": "deduction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "results_session_id_play_sessions_id_fk": {
+ "name": "results_session_id_play_sessions_id_fk",
+ "tableFrom": "results",
+ "tableTo": "play_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "revelation_discoveries": {
+ "name": "revelation_discoveries",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revelation_id": {
+ "name": "revelation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "discovered_at": {
+ "name": "discovered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "revelation_discoveries_revelation_id_idx": {
+ "name": "revelation_discoveries_revelation_id_idx",
+ "columns": [
+ "revelation_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "revelation_discoveries_session_id_play_sessions_id_fk": {
+ "name": "revelation_discoveries_session_id_play_sessions_id_fk",
+ "tableFrom": "revelation_discoveries",
+ "tableTo": "play_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "revelation_discoveries_revelation_id_revelations_id_fk": {
+ "name": "revelation_discoveries_revelation_id_revelations_id_fk",
+ "tableFrom": "revelation_discoveries",
+ "tableTo": "revelations",
+ "columnsFrom": [
+ "revelation_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "revelation_discoveries_session_id_revelation_id_pk": {
+ "columns": [
+ "session_id",
+ "revelation_id"
+ ],
+ "name": "revelation_discoveries_session_id_revelation_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "revelations": {
+ "name": "revelations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_type": {
+ "name": "subject_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "related_facts": {
+ "name": "related_facts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "revelations_scenario_id_idx": {
+ "name": "revelations_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "revelations_scenario_id_scenarios_id_fk": {
+ "name": "revelations_scenario_id_scenarios_id_fk",
+ "tableFrom": "revelations",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "scenario_truths": {
+ "name": "scenario_truths",
+ "columns": {
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "culprit_character_id": {
+ "name": "culprit_character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "truth": {
+ "name": "truth",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive": {
+ "name": "motive",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "timeline": {
+ "name": "timeline",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "timeline_events": {
+ "name": "timeline_events",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "victim_cause_of_death": {
+ "name": "victim_cause_of_death",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_findings": {
+ "name": "victim_findings",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "place_findings": {
+ "name": "place_findings",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "secret_keywords": {
+ "name": "secret_keywords",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "scenario_truths_scenario_id_scenarios_id_fk": {
+ "name": "scenario_truths_scenario_id_scenarios_id_fk",
+ "tableFrom": "scenario_truths",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "scenarios": {
+ "name": "scenarios",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "synopsis": {
+ "name": "synopsis",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "briefing": {
+ "name": "briefing",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "floor_plan": {
+ "name": "floor_plan",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "time_start": {
+ "name": "time_start",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "time_end": {
+ "name": "time_end",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_name": {
+ "name": "victim_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_introduction": {
+ "name": "victim_introduction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_found_at": {
+ "name": "victim_found_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_found_in": {
+ "name": "victim_found_in",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_investigable": {
+ "name": "victim_investigable",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "victim_estimated_death_at": {
+ "name": "victim_estimated_death_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "places": {
+ "name": "places",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_published": {
+ "name": "is_published",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "difficulty": {
+ "name": "difficulty",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 3
+ },
+ "estimated_minutes": {
+ "name": "estimated_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 10
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json
index 482e363..5effa51 100644
--- a/db/migrations/meta/_journal.json
+++ b/db/migrations/meta/_journal.json
@@ -162,6 +162,13 @@
"when": 1788379031994,
"tag": "0022_scenario-place-public-copy-safety",
"breakpoints": true
+ },
+ {
+ "idx": 23,
+ "version": "6",
+ "when": 1788790609383,
+ "tag": "0023_analytics-tables",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/db/schema.ts b/db/schema.ts
index 0311d2f..47f131d 100644
--- a/db/schema.ts
+++ b/db/schema.ts
@@ -481,3 +481,126 @@ export const llmUsages = sqliteTable(
index('llm_usages_session_id_idx').on(table.sessionId),
],
)
+
+/**
+ * 分析用の控え(analytics_sessions / analytics_turns)。
+ *
+ * この2表は llm_usages と同じ理由で play_sessions への外部キーを張らない。
+ * 会話ログは保持期間で消す方針のままにしつつ、プロンプトと難易度を後から
+ * 調整するための材料——プレイヤーが何を打ち、NPCが何を返し、その回に何が
+ * 出たか——だけを残すのがこの2表の存在理由なので、cascade で一緒に消えては
+ * 目的がそのまま失われる。したがって session_id / scenario_id は参照の切れた
+ * 履歴上の値であり、play_sessions と JOIN できることを前提にしてはいけない
+ * (この2表どうしの JOIN は、両方とも消えないので成立する)。
+ *
+ * 集計に使う値は列に開き、読み返すだけの本文は JSON 列に置く。
+ * 「モード別に何ターンで解けたか」を数えるのは列、「そのとき何を打ったか」を
+ * 読むのは JSON、という住み分け。
+ */
+
+/** analytics_turns.exchanges の1往復。探偵の質問とNPCの返答。 */
+export type LoggedExchange = {
+ question: string
+ answer: string
+}
+
+/**
+ * 1プレイセッション1行。開始時に入れ、告発時に結果列を埋める。
+ *
+ * 告発まで至らなかった行は結果列が NULL のまま残る。これは欠損ではなく、
+ * 「どこで諦めたか」がこの表の一番知りたいことの一つなので、
+ * 完走した行だけを入れる作りにはしない。
+ */
+export const analyticsSessions = sqliteTable(
+ 'analytics_sessions',
+ {
+ /** play_sessions.id と同じ値。ただし外部キーではない(上のコメント参照)。 */
+ sessionId: text('session_id').primaryKey(),
+ scenarioId: text('scenario_id').notNull(),
+ /** 難易度モード。db/game-mode.ts の列挙。 */
+ mode: text('mode').notNull(),
+ /** 名乗らずに始められるので nullable。名前と容姿は自由記述で、そのまま入る。 */
+ detective: text('detective', { mode: 'json' }).$type(),
+ /**
+ * 進行の上限。難易度そのものを動かす値なので、モードとは別に持つ。
+ * 同じ nohope でも上限が違えば別の難しさになり、混ぜると読み違える。
+ */
+ maxTurns: integer('max_turns').notNull(),
+ questionsPerTurn: integer('questions_per_turn').notNull(),
+ exchangesPerTopic: integer('exchanges_per_topic').notNull(),
+ startedAt: createdTimestamp('started_at'),
+ /** ここから下は告発時に埋まる。埋まっていない=最後まで行っていない。 */
+ finishedAt: integer('finished_at', { mode: 'timestamp' }),
+ culpritCharacterId: text('culprit_character_id'),
+ culpritCorrect: integer('culprit_correct', { mode: 'boolean' }),
+ methodCorrect: integer('method_correct', { mode: 'boolean' }),
+ motiveCorrect: integer('motive_correct', { mode: 'boolean' }),
+ /** プレイヤーが書いた推理の本文。 */
+ reasoning: text('reasoning'),
+ method: text('method'),
+ motive: text('motive'),
+ /** 採点者の短評。プレイヤーの記述をどう読んだかが分かる。 */
+ methodComment: text('method_comment'),
+ motiveComment: text('motive_comment'),
+ solvedSeconds: integer('solved_seconds'),
+ questionCount: integer('question_count'),
+ evidenceFound: integer('evidence_found'),
+ /** 発見数だけでは難易度を読めないので、母数も一緒に残す。 */
+ evidenceTotal: integer('evidence_total'),
+ contradictionCount: integer('contradiction_count'),
+ accuracyPercent: integer('accuracy_percent'),
+ },
+ (table) => [
+ index('analytics_sessions_scenario_id_idx').on(table.scenarioId),
+ index('analytics_sessions_started_at_idx').on(table.startedAt),
+ ],
+)
+
+/**
+ * ask 1回(=プレイヤーが話題を1つ投げた回)1行。
+ *
+ * 聞き込みと検分を同じ形で受ける。messages が検分を落としているのは
+ * character_id が characters への外部キーで、場所も遺体も入らないため。
+ * こちらは外部キーを持たないので、subject_kind で区別して両方入る。
+ */
+export const analyticsTurns = sqliteTable(
+ 'analytics_turns',
+ {
+ id: uuidPrimaryKey('id'),
+ sessionId: text('session_id').notNull(),
+ scenarioId: text('scenario_id').notNull(),
+ /**
+ * モードは analytics_sessions から JOIN で引けるが、ここにも置く。
+ * 開始時の行の書き込みが落ちた回でも、ターン単体で難易度が読めるようにするため。
+ */
+ mode: text('mode').notNull(),
+ /** character / victim / place。sessions.ts の sourceTypeOf と同じ区別。 */
+ subjectKind: text('subject_kind').notNull(),
+ /** 人物ID、VICTIM_ID、場所ID のいずれか。 */
+ subjectId: text('subject_id').notNull(),
+ /**
+ * 何ターン目か(DO の round)と、その時点の累計質問数。
+ * DO への記録が落ちた回は取れないので nullable。
+ */
+ turnIndex: integer('turn_index'),
+ questionCount: integer('question_count'),
+ /** プレイヤーが打った文そのもの。この列がこの表の主目的。 */
+ topic: text('topic').notNull(),
+ /** 探偵の質問とNPCの返答。プロンプト調整はここを読む。 */
+ exchanges: text('exchanges', { mode: 'json' }).$type().notNull(),
+ /**
+ * その回の判定。Judge が落ちた回は判定そのものが無いので nullable。
+ * 落ちた回でもプレイヤーの入力は残したいので、行ごと捨てることはしない。
+ */
+ revealedEvidenceIds: text('revealed_evidence_ids', { mode: 'json' }).$type(),
+ revealedRevelationIds: text('revealed_revelation_ids', { mode: 'json' }).$type(),
+ contradictionPointedOut: integer('contradiction_pointed_out', { mode: 'boolean' }),
+ npcLied: integer('npc_lied', { mode: 'boolean' }),
+ createdAt: createdTimestamp('created_at'),
+ },
+ (table) => [
+ index('analytics_turns_session_id_idx').on(table.sessionId),
+ index('analytics_turns_scenario_id_idx').on(table.scenarioId),
+ index('analytics_turns_created_at_idx').on(table.createdAt),
+ ],
+)
diff --git a/docs/architecture/data.md b/docs/architecture/data.md
index 9649eca..c58f6aa 100644
--- a/docs/architecture/data.md
+++ b/docs/architecture/data.md
@@ -55,6 +55,9 @@ messages 会話ログ(NPC別、トークン使用量・プロバイ
discoveries 発見済み証拠(session_id + evidence_id の複合主キー)
results 結果(解決時間、質問回数、正解率)
reports UGC通報
+llm_usages LLM呼び出しごとのトークン消費(保持期間の削除対象外)
+analytics_sessions 分析用の控え・1セッション1行(保持期間の削除対象外)
+analytics_turns 分析用の控え・ask 1回1行(保持期間の削除対象外)
```
設計上の要点が3つあります。
@@ -69,6 +72,10 @@ reports UGC通報
**`scenarios.briefing` と `scenarios.floor_plan` は一覧に載せない。** 前者はゲームマスターが読み上げる事件の記録(空行区切りの段落)、後者は UI が SVG で描くための論理座標です。どちらも `GET /api/scenarios/:id` でだけ返します。選ぶ画面に長文と図が並ぶと、遊び始める前に読み疲れるためです。一覧が返すのはタイトル・カテゴリ・登場人物数・難易度・所要時間だけです。
+**`analytics_*` は `play_sessions` への外部キーを張らない。** `llm_usages` と同じ理由です。会話ログ(`messages`)と結果(`results`)は保持期間を過ぎたら消しますが、プロンプトと難易度を後から調整するための材料——プレイヤーが何を打ち、NPC が何を返し、その回に何が出たか——は残す必要があります。外部キーを張ると cascade で一緒に消え、この2表の存在理由がそのまま失われます。したがって `session_id` / `scenario_id` は参照の切れた履歴上の値で、`play_sessions` と JOIN できることを前提にしてはいけません(`analytics_sessions` と `analytics_turns` どうしの JOIN は、両方とも消えないので成立します)。
+
+**検分の記録は `analytics_turns` にしかない。** `messages.character_id` は `characters` への外部キーなので、場所も遺体も入りません(`src/server/routes/sessions.ts` の ask で検分だけ `messages` への insert を飛ばしています)。`analytics_turns` は外部キーを持たず `subject_kind` で区別するため、聞き込みと検分が同じ形で入ります。
+
**`play_sessions.detective` は開始時に決めたら変えない。** プレイヤーが演じる探偵(名前・年ごろ・性別・容姿)で、Actor のプロンプトに入ります。年ごろと性別は `db/detective.ts` の列挙が正典で、自由記述ではありません。NPC の呼びかけ(老人が十代の少女に「お嬢さん」と話しかける類)をこの2つから引くため、「28」「三十路」と書き方が割れると引けなくなります。会話の途中で変わるとキャッシュのプレフィックスが崩れるうえ、NPC から見て相手が別人になります。名乗らずに始めることもできるので nullable です。
## Durable Objects
diff --git a/src/server/db/analytics.ts b/src/server/db/analytics.ts
new file mode 100644
index 0000000..313643e
--- /dev/null
+++ b/src/server/db/analytics.ts
@@ -0,0 +1,151 @@
+import { sql } from 'drizzle-orm'
+import type { Db } from '@/server/db/client'
+import type { Score } from '@/server/game/scoring'
+import type { SessionLimits } from '@/shared/turns'
+import type { Detective } from '~/db/detective'
+import { analyticsSessions, analyticsTurns, type LoggedExchange } from '~/db/schema'
+
+/**
+ * 分析用の控えへの書き込み。
+ *
+ * 会話ログ(messages)と結果(results)は保持期間で消えるが、この2表は消えない。
+ * プロンプトと難易度を後から調整するための材料を残すのが目的で、表を分けた理由は
+ * db/schema.ts のコメントに書いてある。
+ *
+ * ここが返す Promise は、呼び出し側が必ず try/catch で受ける。記録の取りこぼしで
+ * プレイを止めないため——分析はプレイの副産物であって、プレイの条件ではない。
+ */
+
+/**
+ * セッション開始時の1行。
+ *
+ * 二重送信で 409 にせず onConflictDoNothing で流すのは、既に入っている行のほうが
+ * 開始時刻として正しいため。
+ */
+export const recordSessionStart = (
+ db: Db,
+ input: {
+ sessionId: string
+ scenarioId: string
+ mode: string
+ detective: Detective | undefined
+ limits: SessionLimits
+ },
+): Promise =>
+ db
+ .insert(analyticsSessions)
+ .values({
+ sessionId: input.sessionId,
+ scenarioId: input.scenarioId,
+ mode: input.mode,
+ detective: input.detective,
+ maxTurns: input.limits.maxTurns,
+ questionsPerTurn: input.limits.questionsPerTurn,
+ exchangesPerTopic: input.limits.exchangesPerTopic,
+ })
+ .onConflictDoNothing()
+
+/**
+ * 告発時に結果列を埋める。
+ *
+ * update ではなく upsert なのは、開始時の書き込みが落ちていても結果を拾うため。
+ * その場合に開始時刻は告発の時刻になってしまうが、行ごと失うよりはいい
+ * (所要時間は solvedSeconds のほうが正典で、そちらは DO の計時から来る)。
+ */
+export const recordSessionOutcome = (
+ db: Db,
+ input: {
+ sessionId: string
+ scenarioId: string
+ mode: string
+ detective: Detective | undefined
+ limits: SessionLimits
+ culpritCharacterId: string
+ culpritCorrect: boolean
+ reasoning: string
+ method: string
+ motive: string
+ methodComment: string
+ motiveComment: string
+ evidenceTotal: number
+ score: Score
+ },
+): Promise => {
+ const outcome = {
+ finishedAt: sql`(unixepoch())`,
+ culpritCharacterId: input.culpritCharacterId,
+ culpritCorrect: input.culpritCorrect,
+ methodCorrect: input.score.methodCorrect,
+ motiveCorrect: input.score.motiveCorrect,
+ reasoning: input.reasoning,
+ method: input.method,
+ motive: input.motive,
+ methodComment: input.methodComment,
+ motiveComment: input.motiveComment,
+ solvedSeconds: input.score.solvedSeconds,
+ questionCount: input.score.questionCount,
+ evidenceFound: input.score.evidenceFound,
+ evidenceTotal: input.evidenceTotal,
+ contradictionCount: input.score.contradictionCount,
+ accuracyPercent: input.score.accuracyPercent,
+ }
+
+ return db
+ .insert(analyticsSessions)
+ .values({
+ sessionId: input.sessionId,
+ scenarioId: input.scenarioId,
+ mode: input.mode,
+ detective: input.detective,
+ maxTurns: input.limits.maxTurns,
+ questionsPerTurn: input.limits.questionsPerTurn,
+ exchangesPerTopic: input.limits.exchangesPerTopic,
+ ...outcome,
+ })
+ .onConflictDoUpdate({ target: analyticsSessions.sessionId, set: outcome })
+}
+
+/** その回の判定。Judge が落ちた回は丸ごと存在しない。 */
+export type TurnJudgementRecord = {
+ revealedEvidenceIds: string[]
+ revealedRevelationIds: string[]
+ contradictionPointedOut: boolean
+ npcLied: boolean
+}
+
+/**
+ * ask 1回ぶんの1行。聞き込みも検分も同じ口から入る。
+ *
+ * judgement を任意にしてあるのは、Judge が落ちた回でもプレイヤーが打った文と
+ * NPC の返答は残したいため。判定だけが欠けた行になる。
+ */
+export const recordTurn = (
+ db: Db,
+ input: {
+ sessionId: string
+ scenarioId: string
+ mode: string
+ subjectKind: string
+ subjectId: string
+ turnIndex: number | undefined
+ questionCount: number | undefined
+ topic: string
+ exchanges: LoggedExchange[]
+ judgement: TurnJudgementRecord | undefined
+ },
+): Promise =>
+ db.insert(analyticsTurns).values({
+ sessionId: input.sessionId,
+ scenarioId: input.scenarioId,
+ mode: input.mode,
+ subjectKind: input.subjectKind,
+ subjectId: input.subjectId,
+ turnIndex: input.turnIndex,
+ questionCount: input.questionCount,
+ topic: input.topic,
+ exchanges: input.exchanges,
+ revealedEvidenceIds: input.judgement?.revealedEvidenceIds,
+ revealedRevelationIds: input.judgement?.revealedRevelationIds,
+ contradictionPointedOut: input.judgement?.contradictionPointedOut,
+ npcLied: input.judgement?.npcLied,
+ })
diff --git a/src/server/db/retention.ts b/src/server/db/retention.ts
index 430b9bc..2df35b0 100644
--- a/src/server/db/retention.ts
+++ b/src/server/db/retention.ts
@@ -7,9 +7,10 @@ import { playSessions } from '~/db/schema'
*
* messages / discoveries / results は外部キーの cascade で付いてくるので、
* ここで消すのは play_sessions だけでよい。
- * llm_usages は意図的に外部キーを張っていないため残る。「会話ログは捨てても
- * いくら使ったかの記録は残す」ことが、あのテーブルを分けた理由そのものなので、
- * ここに llm_usages への delete を足さないこと。
+ * llm_usages / analytics_sessions / analytics_turns は意図的に外部キーを張って
+ * いないため残る。「会話ログは捨てても、いくら使ったかの記録と、プロンプトや
+ * 難易度を調整するための材料は残す」ことが、あれらのテーブルを分けた理由その
+ * ものなので、ここにそれらへの delete を足さないこと。
*/
/**
diff --git a/src/server/routes/sessions.ts b/src/server/routes/sessions.ts
index c969ad7..323e992 100644
--- a/src/server/routes/sessions.ts
+++ b/src/server/routes/sessions.ts
@@ -10,6 +10,12 @@ import {
loadHintSubjects,
loadJudgeRubric,
} from '@/server/cache/scenario'
+import {
+ recordSessionOutcome,
+ recordSessionStart,
+ recordTurn,
+ type TurnJudgementRecord,
+} from '@/server/db/analytics'
import type { Db } from '@/server/db/client'
import { createDb } from '@/server/db/client'
import type { Bindings, Env } from '@/server/env'
@@ -531,10 +537,27 @@ sessionRoutes.post('/api/sessions', validateCreateSession, withEnv, async (c) =>
// 上限もここで固定する。送られてこなければ env の値がそのまま入るので、
// 設定画面を知らないクライアントでも今までと同じ進行になる。
- await session.setLimits(
- clampLimits(createInput.limits === undefined ? {} : createInput.limits, envLimits(env)),
+ const limits = clampLimits(
+ createInput.limits === undefined ? {} : createInput.limits,
+ envLimits(env),
)
+ await session.setLimits(limits)
+
+ // 分析用の控えを立てる。ここで入れておくと、告発まで至らなかったセッションが
+ // 結果列の空いた行として残り、どこで諦めたかが読める。
+ try {
+ await recordSessionStart(db, {
+ sessionId: row.id,
+ scenarioId: createInput.scenarioId,
+ mode: createInput.mode,
+ detective,
+ limits,
+ })
+ } catch (error) {
+ console.error('[sessions] failed to persist analytics session', error)
+ }
+
// 探偵の有無に関わらず必ず呼ぶ。ここで meta() が初期化されて計時が始まるので、
// 省くと「最初の質問を投げた瞬間」が開始時刻になり、考えていた時間がタイムから消える。
await session.snapshot()
@@ -1107,6 +1130,13 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
round: undefined,
}
+ /*
+ 判定を try の外へ持ち出すための入れ物。分析用の控えは Judge が落ちた回も
+ 書きたい(プレイヤーが何を打ったかは残す)ので、判定の成否と行の有無を
+ 切り離しておく必要がある。すぐ上の persisted と同じ手。
+ */
+ const judgementRecord: { value: TurnJudgementRecord | undefined } = { value: undefined }
+
try {
const appended = await session.appendTopic(
askInput.characterId,
@@ -1200,6 +1230,13 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
npcLied: judgement.npcLied,
})
+ judgementRecord.value = {
+ revealedEvidenceIds: judgement.revealedEvidenceIds,
+ revealedRevelationIds,
+ contradictionPointedOut: judgement.contradictionPointedOut,
+ npcLied: judgement.npcLied,
+ }
+
// 実りのあった話題に印を付ける。会話ログを遡ったときに、どこが効いたのかが
// 分かるようにするためのもの。往復番号が要るので、記録そのものが落ちていた
// 回は印も付けない。
@@ -1316,6 +1353,32 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
console.error('[ask] judge failed', error)
}
+ /*
+ 分析用の控え。judge の try/catch を抜けた後に書くのは、判定が落ちた回でも
+ プレイヤーが打った文と NPC の返答を残すため。messages と違って外部キーを
+ 持たないので、検分(場所・遺体)もここには普通に入る——保持期間を越えて
+ 残る入力の記録は、今のところこの表だけ。
+ */
+ try {
+ await recordTurn(db, {
+ sessionId,
+ scenarioId,
+ mode: meta.mode,
+ subjectKind: subject.kind,
+ subjectId: askInput.characterId,
+ turnIndex: persisted.round,
+ questionCount: persisted.questionCount,
+ topic: askInput.topic,
+ exchanges: collected.exchanges.map((exchange) => ({
+ question: exchange.question,
+ answer: exchange.answer,
+ })),
+ judgement: judgementRecord.value,
+ })
+ } catch (error) {
+ console.error('[ask] failed to persist analytics turn', error)
+ }
+
await stream.writeSSE({ event: 'done', data: '' })
})
})
@@ -1490,6 +1553,29 @@ sessionRoutes.post('/api/sessions/:id/accuse', validateAccuse, withEnv, async (c
console.error('[accuse] failed to persist result', error)
}
+ // 分析用の控えにも結果を書く。results と同じ値だが、あちらは保持期間で消える。
+ // 開始時の行が落ちていても拾えるよう、update ではなく upsert にしてある。
+ try {
+ await recordSessionOutcome(db, {
+ sessionId,
+ scenarioId,
+ mode: meta.mode,
+ detective: meta.detective === null ? undefined : meta.detective,
+ limits: limitsOf(finalSnapshot.limits, env),
+ culpritCharacterId: accuseInput.culpritCharacterId,
+ culpritCorrect: correct,
+ reasoning: accuseInput.reasoning,
+ method: accuseInput.method,
+ motive: accuseInput.motive,
+ methodComment: graded.grade.methodComment,
+ motiveComment: graded.grade.motiveComment,
+ evidenceTotal: evidenceCountRows.length,
+ score,
+ })
+ } catch (error) {
+ console.error('[accuse] failed to persist analytics outcome', error)
+ }
+
// 採点は既に届いているので、集計の失敗を採点の失敗として扱わない。
try {
await db.insert(llmUsages).values(
From 4ee91e1f81d30fd2ec5d39e653cbb2f23064a173 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Mon, 7 Sep 2026 15:21:30 +0000
Subject: [PATCH 07/41] ui(scenario): add hover feedback to scenario select
rows
Add a left accent line and background tint on hover for desktop rows,
plus a subtle text color shift on the count link.
Co-Authored-By: Claude
---
src/client/screens/ScenarioSelectScreen.tsx | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/client/screens/ScenarioSelectScreen.tsx b/src/client/screens/ScenarioSelectScreen.tsx
index 4649a52..0f647ed 100644
--- a/src/client/screens/ScenarioSelectScreen.tsx
+++ b/src/client/screens/ScenarioSelectScreen.tsx
@@ -114,7 +114,7 @@ export const ScenarioSelectScreen = ({
@@ -132,6 +132,9 @@ export const ScenarioSelectScreen = ({
一覧は読み物ではないので、行ごとに行間を締める。
机の上では三列。行の高さは中身に任せ、セルの下辺の罫線だけで分ける。
+
+ 机の上(lg以上)だけ、触れている行の左端に線を立てる。事件は顔料を持たないので
+ 鼠で引く。狭い幅では鼠が無いので付けない——押した瞬間に消える線は迷いのもとになる。
*/}
{scenarios.map((scenario, index) => {
@@ -151,7 +154,7 @@ export const ScenarioSelectScreen = ({
type="button"
onClick={() => setPending(scenario)}
disabled={loadingId !== undefined}
- className="flex w-full flex-col gap-[3px] py-[9px] text-left disabled:opacity-40 lg:gap-0.5 lg:py-[13px]"
+ className="flex w-full flex-col gap-[3px] py-[9px] text-left disabled:opacity-40 lg:gap-0.5 lg:py-[13px] lg:pl-3 lg:hover:bg-sumi-2 lg:hover:shadow-[inset_2px_0_0_var(--color-nezumi)]"
>
{/*
同じ分類が続くあいだは繰り返さない。3行続けて「殺人」と書いても
From 5a41c06536d39cb0dc714b788f3f2b2466a09179 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Mon, 7 Sep 2026 15:26:14 +0000
Subject: [PATCH 08/41] ui(screens): add mobile deadline rail and rework result
deadline logic
Adds a horizontal deadline rail to AccusationScreen's mobile timeline showing
discovery and estimated-death markers, changes ResultScreen to only show a
fixed deadline for solved cases (unsolved cases keep the uncertain window),
and renames story case titles for ScenarioSelectScreen.
Co-Authored-By: Claude
---
src/client/screens/AccusationScreen.tsx | 64 ++++++++++++--
src/client/screens/ResultScreen.stories.tsx | 2 +-
src/client/screens/ResultScreen.tsx | 17 ++--
.../screens/ScenarioSelectScreen.stories.tsx | 86 +++++++++----------
4 files changed, 113 insertions(+), 56 deletions(-)
diff --git a/src/client/screens/AccusationScreen.tsx b/src/client/screens/AccusationScreen.tsx
index 8e44810..90938a7 100644
--- a/src/client/screens/AccusationScreen.tsx
+++ b/src/client/screens/AccusationScreen.tsx
@@ -1,4 +1,4 @@
-import { useId, useState } from 'react'
+import { type ReactNode, useId, useState } from 'react'
import { AlibiChart, type AlibiPerson, type AlibiSegment } from '@/client/components/AlibiChart'
import { CharacterAvatar, inkOf, surfaceOf } from '@/client/components/CharacterAvatar'
import { Button } from '@/client/components/ui/button'
@@ -118,17 +118,69 @@ export const AccusationScreen = ({
]),
]
- /** 帯のなかでの位置。端末側は幅が端末に依るので、px ではなく % で置く。 */
- const ratio = (at: string): string => {
+ /** 帯のなかでの位置(%)。端末側は幅が端末に依るので、px ではなく % で置く。 */
+ const ratioNum = (at: string): number => {
if (timeWindow === null) {
- return '0%'
+ return 0
}
const from = toMinutes(timeWindow.start)
const length = toMinutes(timeWindow.end) - from
- return `${(((toMinutes(at) - from) / length) * 100).toFixed(1)}%`
+ return ((toMinutes(at) - from) / length) * 100
}
+ const ratio = (at: string): string => `${ratioNum(at).toFixed(1)}%`
+ const leftOf = (pct: number): string => `${pct.toFixed(1)}%`
+ /** 端に寄った札は文字が画面の外へ出ないよう内側へ折り返す(机の DeadlineLabel と同じ判断)。 */
+ const railLabelAlign = (pct: number): string =>
+ pct < 24 ? '' : pct >= 72 ? '-translate-x-full text-right' : '-translate-x-1/2'
+
+ /*
+ * 端末の帯に置く「遺体発見・死亡推定」の印。机の AlibiChart(DeadlineMarks)と同じ規則を
+ * 横向きに言い換えたもの——遺体発見は常に実線、死亡推定は手に入れた確度で描き分ける。
+ * この画面だけの帯なので、机の縦向きの実装をそのまま流用できない。
+ */
+ const deathInfo = deadlineOf(scenario.victim, interrogation.estimatedDeathAt)
+
+ /** 一点を指す印。裏の取れていない見立てだけ点線にする(実線=盤面が保証した情報)。 */
+ const RailTick = ({ pct, dotted }: { pct: number; dotted: boolean }) => (
+
+ )
+
+ /** 幅を指す窓。両端に返しを付け、面は塗らない(帯の色と競わせないため)。 */
+ const RailWindow = ({
+ fromPct,
+ toPct,
+ dotted,
+ }: {
+ fromPct: number
+ toPct: number
+ dotted: boolean
+ }) => (
+
+ )
+
+ /** 印の傍らに置く一行の札。等幅は時刻にだけ使う。 */
+ const RailLabel = ({ pct, children }: { pct: number; children: ReactNode }) => (
+
+ {children}
+
+ )
const canSubmit =
culpritCharacterId !== undefined &&
@@ -259,7 +311,7 @@ export const AccusationScreen = ({
区間は薄く——机の実線/破線と同じ区別を、線の太さではなく濃さで言い換える。
*/}
{timeWindow === null ? null : (
-
+
{scenario.characters.map((character, index) => (
diff --git a/src/client/screens/ScenarioSelectScreen.stories.tsx b/src/client/screens/ScenarioSelectScreen.stories.tsx
index 163ea4b..96c720b 100644
--- a/src/client/screens/ScenarioSelectScreen.stories.tsx
+++ b/src/client/screens/ScenarioSelectScreen.stories.tsx
@@ -24,49 +24,49 @@ type Story = StoryObj
* 作り物では確かめられない。mocks/_case.js と同じ43件を、同じ並びで持つ。
*/
const CASES: readonly (readonly [string, string, number, number, number])[] = [
- ['SFクローズドサークル', '世代船、三つの夜明け', 3, 5, 15],
- ['SFクローズドサークル', '火星基地、遅れて届いた返事', 3, 5, 15],
- ['クローズドサークル', '台風のデータセンター、無人の保守窓', 4, 5, 15],
- ['クローズドサークル', '台風圏の洋上基地、自律飛行の十八分', 4, 5, 18],
- ['クローズドサークル', '吹雪のロッジ、青席のアリバイ', 4, 4, 15],
- ['クローズドサークル', '崖上ホテル、磨りガラスの人影', 3, 5, 15],
- ['クローズドサークル', '崩落の時計博物館、十一分早い八時半', 4, 5, 18],
- ['クローズドサークル', '崩落洞窟、歩き続ける位置タグ', 4, 5, 18],
- ['クローズドサークル', '嵐の天象館、ガラスの向こうの目撃者', 3, 5, 15],
- ['クローズドサークル', '嵐の山宿、二階を歩く杖', 3, 5, 15],
- ['クローズドサークル', '嵐の研修島、九時四十二分の送信者', 4, 5, 18],
- ['クローズドサークル', '嵐の研究会、増殖した目撃証言', 3, 5, 18],
- ['クローズドサークル', '暴風の水族館、午前零時の給餌灯', 4, 5, 15],
- ['クローズドサークル', '暴風の灯台、濡れた外套と当直板', 3, 5, 15],
- ['クローズドサークル', '暴風海域、消えた作業艇', 3, 4, 15],
- ['クローズドサークル', '極夜基地、四つの時計が示した同じ嘘', 3, 5, 15],
- ['クローズドサークル', '水没寸前の裁判所、記録されなかった扉', 3, 5, 15],
- ['クローズドサークル', '深海居住区、十二分間の署名', 4, 5, 18],
- ['クローズドサークル', '隔離診療所、借りられた橙色の名札', 3, 5, 15],
- ['クローズドサークル', '雪の天文台、最後の連続写真', 3, 3, 10],
- ['クローズドサークル', '雪の彫刻館、消されたはずのない足跡', 3, 4, 15],
- ['クローズドサークル', '雪封じの劇場、無人のキューランプ', 4, 4, 15],
- ['クローズドサークル', '雪崩の修道院、九時二十分の鐘', 3, 5, 15],
- ['クローズドサークル', '雪崩の山荘、存在しない二一四号室', 3, 5, 15],
- ['クローズドサークル', '雪籠りの録音所、四十七秒の無音', 4, 5, 18],
- ['クローズドサークル', '雪籠り農園、朝支度の痕跡', 3, 5, 15],
- ['クローズドサークル', '雷雲の山頂駅、誰もいない一人乗り', 4, 4, 15],
- ['クローズドサークル', '高潮の文書館、内側に残った鍵', 4, 5, 18],
- ['不可能犯罪', '豪雨の発電所、水位線の密室', 3, 5, 18],
- ['不可能犯罪', '雪の画廊、密室ではない現場', 3, 5, 18],
- ['出版社ミステリ', '二十時四十七分の契約書', 3, 3, 10],
- ['放送局ミステリ', '午前零時、声だけが生きていた', 3, 3, 10],
- ['日常系本格', '雨の古書店、十九時八分のレシート', 3, 2, 10],
- ['未解決事件再調査', '1979年、雪山荘の借り物の記憶', 3, 5, 20],
- ['植物園ミステリ', '温室の朝、水の流れなかった二十分', 3, 2, 10],
- ['歴史クローズドサークル', '上海埠頭、複写伝票の夜', 3, 5, 15],
- ['歴史クローズドサークル', '検疫島、日没の鐘', 3, 5, 15],
- ['歴史クローズドサークル', '蒸気地下鉄、最後の電信', 3, 5, 15],
- ['祭りの夜', '消えた提灯、八分間の暗闇', 3, 3, 10],
- ['美術館ミステリ', '青い額縁は修復室にない', 3, 3, 10],
- ['船上ミステリ', '霧のフェリー、四十二人目の乗客', 3, 3, 10],
- ['館もの', '月見荘、十七回忌の夜', 3, 2, 10],
- ['駅ミステリ', '終電後、遅延証明書はまだ白紙だった', 3, 3, 10],
+ ['SFクローズドサークル', '2312年、世代船アステリア', 3, 5, 15],
+ ['SFクローズドサークル', '火星、エリュシオン観測基地', 3, 5, 15],
+ ['クローズドサークル', '冠水する湾岸データセンター', 4, 5, 15],
+ ['クローズドサークル', '台風圏の洋上風力基地', 4, 5, 18],
+ ['クローズドサークル', '四人だけの研修ロッジ', 4, 4, 15],
+ ['クローズドサークル', '崖の上から帰れない', 3, 5, 15],
+ ['クローズドサークル', '山道が崩れた時計博物館', 4, 5, 18],
+ ['クローズドサークル', '救助隊が来るまで', 4, 5, 18],
+ ['クローズドサークル', '星見ヶ丘、ロープウェイ停止', 3, 5, 15],
+ ['クローズドサークル', '梢庵から出られない', 3, 5, 15],
+ ['クローズドサークル', '船の来ない青凪荘', 4, 5, 18],
+ ['クローズドサークル', '道路封鎖、山中研究会館', 3, 5, 18],
+ ['クローズドサークル', '閉館後の海浜水族館', 4, 5, 15],
+ ['クローズドサークル', '補給船の来ない夕凪灯台', 3, 5, 15],
+ ['クローズドサークル', '退避航行中の「みなも」', 3, 4, 15],
+ ['クローズドサークル', '白夜第六観測基地', 3, 5, 15],
+ ['クローズドサークル', '河川氾濫、旧南央裁判所', 3, 5, 15],
+ ['クローズドサークル', '海底居住区アビス3', 4, 5, 18],
+ ['クローズドサークル', '封鎖された白嶺診療所', 3, 5, 15],
+ ['クローズドサークル', '北岳観測所、吹雪の午後十時', 3, 3, 10],
+ ['クローズドサークル', '雪は白庭彫刻館を閉ざした', 3, 4, 15],
+ ['クローズドサークル', '白燕座、雪の終演後', 4, 4, 15],
+ ['クローズドサークル', '山上の修道院から誰も帰れない', 3, 5, 15],
+ ['クローズドサークル', '白樺峰に雪崩が落ちた夜', 3, 5, 15],
+ ['クローズドサークル', 'ノース・レイクの深夜録音', 4, 5, 18],
+ ['クローズドサークル', '朝七時、高原農園', 3, 5, 15],
+ ['クローズドサークル', '落雷停止、霧岳山頂駅', 4, 4, 15],
+ ['クローズドサークル', '高潮警報、文書館閉鎖', 4, 5, 18],
+ ['不可能犯罪', '増水する山中発電所', 3, 5, 18],
+ ['不可能犯罪', '白環館、雪の作品保存庫', 3, 5, 18],
+ ['出版社ミステリ', '締切後の青燈社', 3, 3, 10],
+ ['放送局ミステリ', '午前零時十二分、第二収録ブース', 3, 3, 10],
+ ['日常系本格', '青雨堂、閉店後の商談', 3, 2, 10],
+ ['未解決事件再調査', '四十七年目の白樺館', 3, 5, 20],
+ ['植物園ミステリ', '開園前、標本庫にて', 3, 2, 10],
+ ['歴史クローズドサークル', '1928年、上海河岸', 3, 5, 15],
+ ['歴史クローズドサークル', '1796年、検疫島ラッザレット', 3, 5, 15],
+ ['歴史クローズドサークル', '1863年、地下鉄工事区画', 3, 5, 15],
+ ['祭りの夜', '祭りが暗くなった八分間', 3, 3, 10],
+ ['美術館ミステリ', '内覧会は終わっていた', 3, 3, 10],
+ ['船上ミステリ', '霧の中を進む「しおかぜ」', 3, 3, 10],
+ ['館もの', '十七回忌、月見荘にて', 3, 2, 10],
+ ['駅ミステリ', '終電が八分遅れた夜', 3, 3, 10],
]
/**
From d65eae51a92127925f316106500c9381da130bc9 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Mon, 7 Sep 2026 15:27:07 +0000
Subject: [PATCH 09/41] ui(screens): add death-info rail to accusation and
split turn label in result
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds a death-discovery/estimate tick rail to AccusationScreen's deadline track (fixed, range, claimed, and unknown cases), and widens ResultScreen's Row label to accept a ReactNode so the turn-count label reads "使ったターン" on mobile and "質問回数" on desktop.
Co-Authored-By: Claude
---
src/client/screens/AccusationScreen.tsx | 76 ++++++++++++++++++++++++-
src/client/screens/ResultScreen.tsx | 25 +++++++-
2 files changed, 97 insertions(+), 4 deletions(-)
diff --git a/src/client/screens/AccusationScreen.tsx b/src/client/screens/AccusationScreen.tsx
index 90938a7..9a67b72 100644
--- a/src/client/screens/AccusationScreen.tsx
+++ b/src/client/screens/AccusationScreen.tsx
@@ -343,7 +343,7 @@ export const AccusationScreen = ({
{/* 死亡推定は掴んで動かす目盛りと同じ描き方。ここは動かないが、朱で立てて唯一の刻限だと示す。 */}
{deadline === undefined ? null : (
)}
+ {/*
+ 帯の下、軸の上に置く刻限の一段。遺体発見は常に実線、死亡推定は確度で描き分ける
+ (机の AlibiChart と同じ規則)。指名の目盛りと重なると読めなくなるので、
+ 段の下(top:4〜88px)だけを使い、上の帯へは踏み込まない。
+ */}
+ {deathInfo === undefined ? null : (
+ <>
+ {deathInfo.foundAt === undefined ? null : (
+ <>
+
+
+ {'遺体発見 '}
+ {deathInfo.foundAt}
+
+ >
+ )}
+
+ {deathInfo.death === undefined ? null : deathInfo.death.kind === 'fixed' ? (
+ <>
+
+
+ {deathInfo.label}
+ {deathInfo.death.at}
+
+ >
+ ) : deathInfo.death.kind === 'range' ? (
+ <>
+
+
+ {deathInfo.label}
+ {`${deathInfo.death.from}–${deathInfo.death.to}`}
+
+ >
+ ) : deathInfo.death.kind === 'claimed' ? (
+ <>
+
+
+ {deathInfo.label}
+ {`? ${deathInfo.death.at}`}
+ {/* 誰の見立てかは札の尾に続ける。机には線の下の一段があるが、端末にその段が無いため。 */}
+ {`${deathInfo.death.by.name}の見立て`}
+
+ >
+ ) : (
+ // 不明。どこか一点を指せないので、分かっている幅ぜんぶを点線の窓で囲う。
+ <>
+
+
+ {deathInfo.label} ?
+
+ >
+ )}
+ >
+ )}
+
{timeWindow.start}
- {deadline === undefined ? null : {deadline.at}}
{timeWindow.end}
diff --git a/src/client/screens/ResultScreen.tsx b/src/client/screens/ResultScreen.tsx
index 1e3c6db..ef4b6ec 100644
--- a/src/client/screens/ResultScreen.tsx
+++ b/src/client/screens/ResultScreen.tsx
@@ -160,7 +160,15 @@ const ROW_STAGGER_MS = 200
* `at` は上から何番目か。渡さなければ他と一緒に出る——順に読ませたいのは
* 判定の三行だけで、記録は表として一度に見えたほうが早い。
*/
-const Row = ({ label, at = 0, children }: { label: string; at?: number; children: ReactNode }) => (
+const Row = ({
+ label,
+ at = 0,
+ children,
+}: {
+ label: ReactNode
+ at?: number
+ children: ReactNode
+}) => (
{elapsed}
- {result.questionCount}回
+ {/*
+ 訊くだけでなく遺体も現場も調べる。全部まとめて「手」なので、
+ 端末では質問と呼ばない——机は横幅があるぶん「質問回数」で言い切れる。
+ */}
+
+ 使ったターン
+ 質問回数
+ >
+ }
+ >
+ {result.questionCount}回
+
{result.evidenceFound}個
From d5dfdb40ad5577d83c2d91a1a3b94d5cd7465f86 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Tue, 8 Sep 2026 01:29:01 +0000
Subject: [PATCH 10/41] feat(db): add character short-name field and
interrogation rail UI
Add a short_name column to characters (migrated from existing name)
for compact display in the interrogation rail, and split the
interrogation screen's log/rail into dedicated components with
supporting fixtures and stories.
Co-Authored-By: Claude
---
db/compile-scenario.ts | 2 +
db/migrations/0024_character-short-name.sql | 5 +
db/migrations/meta/0024_snapshot.json | 1530 +++++++++++++++++
db/migrations/meta/_journal.json | 7 +
db/scenario-definition.ts | 8 +
db/scenarios/rainy-bookstore-receipt.yaml | 3 +
db/schema.ts | 7 +
src/client/components/Motions.stories.tsx | 12 +-
src/client/components/NewFactBand.tsx | 14 +-
src/client/index.css | 13 +-
src/client/lib/format.ts | 10 +-
src/client/lib/schemas.ts | 7 +
src/client/screens/AccusationScreen.tsx | 21 +-
src/client/screens/CaseOverviewScreen.tsx | 10 +-
src/client/screens/InterrogationRail.tsx | 202 +++
.../screens/InterrogationScreen.stories.tsx | 557 ++++--
src/client/screens/InterrogationScreen.tsx | 327 +---
.../screens/ScenarioSelectScreen.stories.tsx | 30 +-
src/client/screens/interrogation-log.ts | 239 +++
src/client/stories/fixtures.ts | 31 +-
src/server/read/scenarios.ts | 17 +-
21 files changed, 2568 insertions(+), 484 deletions(-)
create mode 100644 db/migrations/0024_character-short-name.sql
create mode 100644 db/migrations/meta/0024_snapshot.json
create mode 100644 src/client/screens/InterrogationRail.tsx
create mode 100644 src/client/screens/interrogation-log.ts
diff --git a/db/compile-scenario.ts b/db/compile-scenario.ts
index 8b94ea3..f8412e1 100644
--- a/db/compile-scenario.ts
+++ b/db/compile-scenario.ts
@@ -152,6 +152,8 @@ const compileDefinition = (
id: characterUuid(character.id),
scenarioId,
name: character.name,
+ // 書かれていなければフルネームをそのまま。帯が窮屈になるだけで、表示は壊れない。
+ shortName: character.shortName === undefined ? character.name : character.shortName,
publicIntroduction: character.publicIntroduction,
/*
関係は「事実」ではなく「相手への態度」なので、知っていることに混ぜると
diff --git a/db/migrations/0024_character-short-name.sql b/db/migrations/0024_character-short-name.sql
new file mode 100644
index 0000000..e8381c3
--- /dev/null
+++ b/db/migrations/0024_character-short-name.sql
@@ -0,0 +1,5 @@
+ALTER TABLE `characters` ADD `short_name` text DEFAULT '' NOT NULL;--> statement-breakpoint
+-- 既存の行にはまだ短い名前が無い。空のままにすると帯から名前が消えるので、
+-- フルネームを写して埋める。窮屈なのは直っていないが、表示は壊れない。
+-- 短い名前そのものは、あとから yaml へ書いて焼き直す。
+UPDATE `characters` SET `short_name` = `name` WHERE trim(`short_name`) = '';
diff --git a/db/migrations/meta/0024_snapshot.json b/db/migrations/meta/0024_snapshot.json
new file mode 100644
index 0000000..aa08c8d
--- /dev/null
+++ b/db/migrations/meta/0024_snapshot.json
@@ -0,0 +1,1530 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "d34b9fdc-9abd-47a8-991f-786fb35f50c1",
+ "prevId": "1f5ead39-3cf1-403e-9a67-e4382532478a",
+ "tables": {
+ "analytics_sessions": {
+ "name": "analytics_sessions",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "detective": {
+ "name": "detective",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "max_turns": {
+ "name": "max_turns",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "questions_per_turn": {
+ "name": "questions_per_turn",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exchanges_per_topic": {
+ "name": "exchanges_per_topic",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "culprit_character_id": {
+ "name": "culprit_character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "culprit_correct": {
+ "name": "culprit_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method_correct": {
+ "name": "method_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive_correct": {
+ "name": "motive_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reasoning": {
+ "name": "reasoning",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive": {
+ "name": "motive",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method_comment": {
+ "name": "method_comment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive_comment": {
+ "name": "motive_comment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "solved_seconds": {
+ "name": "solved_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "question_count": {
+ "name": "question_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "evidence_found": {
+ "name": "evidence_found",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "evidence_total": {
+ "name": "evidence_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contradiction_count": {
+ "name": "contradiction_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "accuracy_percent": {
+ "name": "accuracy_percent",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "analytics_sessions_scenario_id_idx": {
+ "name": "analytics_sessions_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ },
+ "analytics_sessions_started_at_idx": {
+ "name": "analytics_sessions_started_at_idx",
+ "columns": [
+ "started_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "analytics_turns": {
+ "name": "analytics_turns",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_kind": {
+ "name": "subject_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "turn_index": {
+ "name": "turn_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "question_count": {
+ "name": "question_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "topic": {
+ "name": "topic",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exchanges": {
+ "name": "exchanges",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revealed_evidence_ids": {
+ "name": "revealed_evidence_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revealed_revelation_ids": {
+ "name": "revealed_revelation_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contradiction_pointed_out": {
+ "name": "contradiction_pointed_out",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "npc_lied": {
+ "name": "npc_lied",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "analytics_turns_session_id_idx": {
+ "name": "analytics_turns_session_id_idx",
+ "columns": [
+ "session_id"
+ ],
+ "isUnique": false
+ },
+ "analytics_turns_scenario_id_idx": {
+ "name": "analytics_turns_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ },
+ "analytics_turns_created_at_idx": {
+ "name": "analytics_turns_created_at_idx",
+ "columns": [
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "characters": {
+ "name": "characters",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "short_name": {
+ "name": "short_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "public_introduction": {
+ "name": "public_introduction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "personality": {
+ "name": "personality",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "knowledge": {
+ "name": "knowledge",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "secrets": {
+ "name": "secrets",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "goals": {
+ "name": "goals",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lies": {
+ "name": "lies",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lie_refs": {
+ "name": "lie_refs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "memories": {
+ "name": "memories",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "characters_scenario_id_idx": {
+ "name": "characters_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "characters_scenario_id_scenarios_id_fk": {
+ "name": "characters_scenario_id_scenarios_id_fk",
+ "tableFrom": "characters",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "discoveries": {
+ "name": "discoveries",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "evidence_id": {
+ "name": "evidence_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "discovered_at": {
+ "name": "discovered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "discoveries_evidence_id_idx": {
+ "name": "discoveries_evidence_id_idx",
+ "columns": [
+ "evidence_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "discoveries_session_id_play_sessions_id_fk": {
+ "name": "discoveries_session_id_play_sessions_id_fk",
+ "tableFrom": "discoveries",
+ "tableTo": "play_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "discoveries_evidence_id_evidences_id_fk": {
+ "name": "discoveries_evidence_id_evidences_id_fk",
+ "tableFrom": "discoveries",
+ "tableTo": "evidences",
+ "columnsFrom": [
+ "evidence_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "discoveries_session_id_evidence_id_pk": {
+ "columns": [
+ "session_id",
+ "evidence_id"
+ ],
+ "name": "discoveries_session_id_evidence_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "evidences": {
+ "name": "evidences",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reveal_condition": {
+ "name": "reveal_condition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "supports": {
+ "name": "supports",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "contradicts": {
+ "name": "contradicts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "reveals_death_time": {
+ "name": "reveals_death_time",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "evidences_scenario_id_idx": {
+ "name": "evidences_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "evidences_scenario_id_scenarios_id_fk": {
+ "name": "evidences_scenario_id_scenarios_id_fk",
+ "tableFrom": "evidences",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "llm_usages": {
+ "name": "llm_usages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "input_tokens": {
+ "name": "input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "output_tokens": {
+ "name": "output_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "cached_input_tokens": {
+ "name": "cached_input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "cache_creation_input_tokens": {
+ "name": "cache_creation_input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "reasoning_tokens": {
+ "name": "reasoning_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "llm_usages_created_at_idx": {
+ "name": "llm_usages_created_at_idx",
+ "columns": [
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "llm_usages_session_id_idx": {
+ "name": "llm_usages_session_id_idx",
+ "columns": [
+ "session_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "messages": {
+ "name": "messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "character_id": {
+ "name": "character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "messages_session_id_idx": {
+ "name": "messages_session_id_idx",
+ "columns": [
+ "session_id"
+ ],
+ "isUnique": false
+ },
+ "messages_character_id_idx": {
+ "name": "messages_character_id_idx",
+ "columns": [
+ "character_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "messages_session_id_play_sessions_id_fk": {
+ "name": "messages_session_id_play_sessions_id_fk",
+ "tableFrom": "messages",
+ "tableTo": "play_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "messages_character_id_characters_id_fk": {
+ "name": "messages_character_id_characters_id_fk",
+ "tableFrom": "messages",
+ "tableTo": "characters",
+ "columnsFrom": [
+ "character_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "play_sessions": {
+ "name": "play_sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "detective": {
+ "name": "detective",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "play_sessions_scenario_id_idx": {
+ "name": "play_sessions_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ },
+ "play_sessions_started_at_idx": {
+ "name": "play_sessions_started_at_idx",
+ "columns": [
+ "started_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "play_sessions_scenario_id_scenarios_id_fk": {
+ "name": "play_sessions_scenario_id_scenarios_id_fk",
+ "tableFrom": "play_sessions",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "reports": {
+ "name": "reports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reported_at": {
+ "name": "reported_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "reports_scenario_id_idx": {
+ "name": "reports_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "reports_scenario_id_scenarios_id_fk": {
+ "name": "reports_scenario_id_scenarios_id_fk",
+ "tableFrom": "reports",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "results": {
+ "name": "results",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "solved_seconds": {
+ "name": "solved_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "question_count": {
+ "name": "question_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "evidence_found": {
+ "name": "evidence_found",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contradiction_count": {
+ "name": "contradiction_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accuracy_percent": {
+ "name": "accuracy_percent",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "method_correct": {
+ "name": "method_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive_correct": {
+ "name": "motive_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deduction": {
+ "name": "deduction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "results_session_id_play_sessions_id_fk": {
+ "name": "results_session_id_play_sessions_id_fk",
+ "tableFrom": "results",
+ "tableTo": "play_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "revelation_discoveries": {
+ "name": "revelation_discoveries",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revelation_id": {
+ "name": "revelation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "discovered_at": {
+ "name": "discovered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "revelation_discoveries_revelation_id_idx": {
+ "name": "revelation_discoveries_revelation_id_idx",
+ "columns": [
+ "revelation_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "revelation_discoveries_session_id_play_sessions_id_fk": {
+ "name": "revelation_discoveries_session_id_play_sessions_id_fk",
+ "tableFrom": "revelation_discoveries",
+ "tableTo": "play_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "revelation_discoveries_revelation_id_revelations_id_fk": {
+ "name": "revelation_discoveries_revelation_id_revelations_id_fk",
+ "tableFrom": "revelation_discoveries",
+ "tableTo": "revelations",
+ "columnsFrom": [
+ "revelation_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "revelation_discoveries_session_id_revelation_id_pk": {
+ "columns": [
+ "session_id",
+ "revelation_id"
+ ],
+ "name": "revelation_discoveries_session_id_revelation_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "revelations": {
+ "name": "revelations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_type": {
+ "name": "subject_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "related_facts": {
+ "name": "related_facts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "revelations_scenario_id_idx": {
+ "name": "revelations_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "revelations_scenario_id_scenarios_id_fk": {
+ "name": "revelations_scenario_id_scenarios_id_fk",
+ "tableFrom": "revelations",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "scenario_truths": {
+ "name": "scenario_truths",
+ "columns": {
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "culprit_character_id": {
+ "name": "culprit_character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "truth": {
+ "name": "truth",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive": {
+ "name": "motive",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "timeline": {
+ "name": "timeline",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "timeline_events": {
+ "name": "timeline_events",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "victim_cause_of_death": {
+ "name": "victim_cause_of_death",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_findings": {
+ "name": "victim_findings",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "place_findings": {
+ "name": "place_findings",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "secret_keywords": {
+ "name": "secret_keywords",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "scenario_truths_scenario_id_scenarios_id_fk": {
+ "name": "scenario_truths_scenario_id_scenarios_id_fk",
+ "tableFrom": "scenario_truths",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "scenarios": {
+ "name": "scenarios",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "synopsis": {
+ "name": "synopsis",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "briefing": {
+ "name": "briefing",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "floor_plan": {
+ "name": "floor_plan",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "time_start": {
+ "name": "time_start",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "time_end": {
+ "name": "time_end",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_name": {
+ "name": "victim_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_introduction": {
+ "name": "victim_introduction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_found_at": {
+ "name": "victim_found_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_found_in": {
+ "name": "victim_found_in",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_investigable": {
+ "name": "victim_investigable",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "victim_estimated_death_at": {
+ "name": "victim_estimated_death_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "places": {
+ "name": "places",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_published": {
+ "name": "is_published",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "difficulty": {
+ "name": "difficulty",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 3
+ },
+ "estimated_minutes": {
+ "name": "estimated_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 10
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json
index 5effa51..a8678de 100644
--- a/db/migrations/meta/_journal.json
+++ b/db/migrations/meta/_journal.json
@@ -169,6 +169,13 @@
"when": 1788790609383,
"tag": "0023_analytics-tables",
"breakpoints": true
+ },
+ {
+ "idx": 24,
+ "version": "6",
+ "when": 1788830884765,
+ "tag": "0024_character-short-name",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/db/scenario-definition.ts b/db/scenario-definition.ts
index 0b8e003..4aad83f 100644
--- a/db/scenario-definition.ts
+++ b/db/scenario-definition.ts
@@ -194,6 +194,14 @@ export const scenarioRelationshipSchema = z.object({
export const scenarioCharacterSchema = z.object({
id: localIdSchema,
name: nonemptyTextSchema.max(100),
+ /**
+ * 帯や名札のように幅の狭いところへ出す短い名前。姓だけ、渾名だけ。
+ *
+ * 場所の `shortName` と同じ役目だが、こちらは省略できる。書かなければ `name` が
+ * そのまま入る(db/compile-scenario.ts)。姓を機械的に切り出す案は採らない
+ * ——一文字姓や外国名では切る位置が決まらないので、書く人が決める。
+ */
+ shortName: nonemptyTextSchema.max(8).optional(),
publicIntroduction: nonemptyTextSchema.max(300),
personality: nonemptyTextSchema,
goals: z.array(nonemptyTextSchema),
diff --git a/db/scenarios/rainy-bookstore-receipt.yaml b/db/scenarios/rainy-bookstore-receipt.yaml
index fb8952d..175b9bf 100644
--- a/db/scenarios/rainy-bookstore-receipt.yaml
+++ b/db/scenarios/rainy-bookstore-receipt.yaml
@@ -183,6 +183,7 @@ timeline:
characters:
- id: makino
name: 牧野千尋
+ shortName: 牧野
publicIntroduction: "丁寧で几帳面な古書店員。"
personality: 丁寧で几帳面な古書店員。書誌や発送手順には強いが、金銭の話を向けられると防御的になる。水野には仕事を教わった恩がある一方、給料の低さには不満を抱えていた。
goals:
@@ -214,6 +215,7 @@ characters:
attitude: 値切り方が強引で苦手
- id: kuroda
name: 黒田征司
+ shortName: 黒田
publicIntroduction: "希少本の知識を誇る収集家。"
personality: 希少本の知識を誇る収集家。金で解決できると思いがちで、体面を傷つけられることを嫌う。事件そのものより、裏取引の提案が会社へ知られることを恐れている。
goals:
@@ -244,6 +246,7 @@ characters:
attitude: 本を見る目は信用している
- id: sena
name: 瀬名真琴
+ shortName: 瀬名
publicIntroduction: "気さくな喫茶店主で、商店街の人間関係には詳しい。"
personality: 気さくな喫茶店主で、商店街の人間関係には詳しい。借金のことだけは強い引け目があり、水野との金銭関係を聞かれると曖昧になる。
goals:
diff --git a/db/schema.ts b/db/schema.ts
index 47f131d..54839c9 100644
--- a/db/schema.ts
+++ b/db/schema.ts
@@ -209,6 +209,13 @@ export const characters = sqliteTable(
.notNull()
.references(() => scenarios.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
+ /**
+ * 幅の狭いところへ出す短い名前。アリバイ表の帯がこれを読む。
+ *
+ * 既定が空なのは、この列より前に焼かれた行があるため。移行では `name` を写して埋め、
+ * コンパイラも書かれていなければ `name` を入れるので、空のまま残る行は本来無い。
+ */
+ shortName: text('short_name').notNull().default(''),
/** プレイヤーへ最初から見せてよい、完全公開の人物紹介。 */
publicIntroduction: text('public_introduction').notNull().default(''),
personality: text('personality').notNull(),
diff --git a/src/client/components/Motions.stories.tsx b/src/client/components/Motions.stories.tsx
index 01d9d5c..1bb1055 100644
--- a/src/client/components/Motions.stories.tsx
+++ b/src/client/components/Motions.stories.tsx
@@ -200,16 +200,18 @@ const MotionCatalogue = () => (
窓口の受付は午後七時八分でした。レシートも残っています。
-
- 新事実
-
- 牧野は午後六時三十五分に店を出たと述べた
+
+
+ 新事実
+
+ 牧野は午後六時三十五分に店を出たと述べた
+
diff --git a/src/client/components/NewFactBand.tsx b/src/client/components/NewFactBand.tsx
index 14bb67f..0e8b978 100644
--- a/src/client/components/NewFactBand.tsx
+++ b/src/client/components/NewFactBand.tsx
@@ -12,9 +12,15 @@ const VISIBLE_MS = 2600
* 新しく分かったことを、会話の上に帯で被せて知らせる。
*
* 事件の記録は聞き込み中に画面上に無いので、増えたことは被せて伝えるしかない。
- * 箱にはせず、二本の罫線と薄い覆いだけ。操作は塞がない(pointer-events-none)
+ * 箱にはせず、二本の罫線だけ。操作は塞がない(pointer-events-none)
* ——テンポよく次を訊きたい人の指を、演出で止める理由がない。
*
+ * 地は透かさない。端末は幅が狭く帯が会話の行を丸ごと覆うので、わずかでも透けると
+ * 後ろの字と帯の字が重なって、どちらも読めなくなる。
+ *
+ * 机(lg)では被せず、名札の下・会話の上に流れの中で置く。机には縦の余りがあるので、
+ * 読んでいる行を隠してまで割り込む理由がない。
+ *
* 祝わない。増えたのは事実であって、手柄ではないので。
*
* 出し直しは呼び出し側が key を差し替えて行う(TurnAnnounce と同じ約束)。
@@ -35,11 +41,11 @@ export const NewFactBand = ({ text }: Props) => {
return (
-
)
diff --git a/src/client/index.css b/src/client/index.css
index 6ddacf9..3f96265 100644
--- a/src/client/index.css
+++ b/src/client/index.css
@@ -297,22 +297,27 @@ body {
* 箱にはせず、二本の罫線と薄い覆いだけ。操作は塞がず 2.6 秒で引く。
* 祝わない——増えたのは事実であって、手柄ではないので。
*/
+/*
+ * 縦の中央寄せはここに書かない。器の側(-translate-y-1/2)が持つ。
+ * 動きを切ってある人には下の @media でこの animation ごと none になるので、
+ * ここが中央寄せを兼ねていると、その人にだけ帯が半分ぶん下へずれる。
+ */
@keyframes band {
0% {
opacity: 0;
- transform: translateY(-50%) scaleY(0.86);
+ transform: scaleY(0.86);
}
11% {
opacity: 1;
- transform: translateY(-50%) scaleY(1);
+ transform: scaleY(1);
}
84% {
opacity: 1;
- transform: translateY(-50%) scaleY(1);
+ transform: scaleY(1);
}
100% {
opacity: 0;
- transform: translateY(-50%) scaleY(1);
+ transform: scaleY(1);
}
}
diff --git a/src/client/lib/format.ts b/src/client/lib/format.ts
index c7d86ec..0848100 100644
--- a/src/client/lib/format.ts
+++ b/src/client/lib/format.ts
@@ -1,12 +1,16 @@
import dayjs from 'dayjs'
-/** 経過秒数を `分:秒` に整形する。タイマー表示とリザルトの解決タイムの両方で使う。 */
+/**
+ * 経過秒数を `分:秒` に整形する。タイマー表示とリザルトの解決タイムの両方で使う。
+ *
+ * 分も0詰めする。この数字は等幅で計器として出るので、桁が増減すると
+ * 隣の字が横へ動く。`3:21` と `03:21` が入れ替わるだけで目盛りが揺れて見える。
+ */
export const formatSeconds = (totalSeconds: number): string => {
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
- const paddedSeconds = seconds < 10 ? `0${seconds}` : `${seconds}`
- return `${minutes}:${paddedSeconds}`
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
/**
diff --git a/src/client/lib/schemas.ts b/src/client/lib/schemas.ts
index b125521..af9ae86 100644
--- a/src/client/lib/schemas.ts
+++ b/src/client/lib/schemas.ts
@@ -31,6 +31,13 @@ export const scenarioListSchema = z.array(scenarioSummarySchema)
export const characterSchema = z.object({
id: z.uuid(),
name: z.string().nonempty(),
+ /**
+ * 幅の狭いところへ出す短い名前。アリバイ表の帯がこれを読む。
+ *
+ * サーバは書かれていない行にも `name` を写して返すので、常に値がある。
+ * 画面側で姓を切り出さないこと——一文字姓や外国名で切る位置が決まらない。
+ */
+ shortName: z.string().nonempty(),
publicIntroduction: z.string().nonempty(),
})
diff --git a/src/client/screens/AccusationScreen.tsx b/src/client/screens/AccusationScreen.tsx
index 9a67b72..8d9f9d9 100644
--- a/src/client/screens/AccusationScreen.tsx
+++ b/src/client/screens/AccusationScreen.tsx
@@ -368,7 +368,7 @@ export const AccusationScreen = ({
{'遺体発見 '}
- {deathInfo.foundAt}
+ {deathInfo.foundAt}
>
)}
@@ -377,8 +377,8 @@ export const AccusationScreen = ({
<>
- {deathInfo.label}
- {deathInfo.death.at}
+ {`${deathInfo.label} `}
+ {deathInfo.death.at}
>
) : deathInfo.death.kind === 'range' ? (
@@ -391,18 +391,18 @@ export const AccusationScreen = ({
- {deathInfo.label}
- {`${deathInfo.death.from}–${deathInfo.death.to}`}
+ {`${deathInfo.label} `}
+
+ {`${deathInfo.death.from}–${deathInfo.death.to}`}
+
>
) : deathInfo.death.kind === 'claimed' ? (
<>
- {deathInfo.label}
- {`? ${deathInfo.death.at}`}
+ {`${deathInfo.label} `}
+ {`? ${deathInfo.death.at}`}
{/* 誰の見立てかは札の尾に続ける。机には線の下の一段があるが、端末にその段が無いため。 */}
- {deathInfo.label} ?
+ {`${deathInfo.label} `}
+ ?
>
)}
diff --git a/src/client/screens/CaseOverviewScreen.tsx b/src/client/screens/CaseOverviewScreen.tsx
index d575fb3..7eb6c44 100644
--- a/src/client/screens/CaseOverviewScreen.tsx
+++ b/src/client/screens/CaseOverviewScreen.tsx
@@ -333,8 +333,12 @@ export const CaseOverviewScreen = ({
- {/* 事件の幅が長いと表は画面より背が高くなる。縮めずにここで送る(聞き込みと同じ)。 */}
-
+ {/*
+ 事件の幅が長いと表は画面より背が高くなる。縮めずにここで送る(聞き込みと同じ)。
+ 伸びはしない(flex-1 を持たない)——余りを飲ませると、短い事件で凡例が
+ 表から離れて画面の下端まで落ちる。溢れたときだけ縮んで、中を送る。
+ */}
+
+
実線
diff --git a/src/client/screens/InterrogationRail.tsx b/src/client/screens/InterrogationRail.tsx
new file mode 100644
index 0000000..e17c9d0
--- /dev/null
+++ b/src/client/screens/InterrogationRail.tsx
@@ -0,0 +1,202 @@
+import type { AlibiSegment, Deadline } from '@/client/components/AlibiChart'
+import { surfaceOf } from '@/client/components/CharacterAvatar'
+import { toMinutes } from '@/client/screens/interrogation-log'
+
+/**
+ * 端末の時刻軸。机のアリバイ表と同じものを、幅の無い画面で言い換えている。
+ *
+ * 深さは三段ぶん要る。上から、両端の時刻/印の道/刻限の一段。一段しか無いと、
+ * 18:23 のように端寄りの時刻が確定した途端、印が「18:20」の字を貫く。
+ * この画面からしか呼ばれない。
+ */
+
+/** 帯は左右 10px の余白の内側に引かれている。%だけで置くと線からずれる。 */
+const INSET = 10
+
+const at = (ratio: number): string =>
+ `calc(${INSET}px + (100% - ${INSET * 2}px) * ${ratio.toFixed(4)})`
+
+const wide = (ratio: number): string => `calc((100% - ${INSET * 2}px) * ${ratio.toFixed(4)})`
+
+type Span = { from: number; length: number }
+
+const ratioOf = (hhmm: string, span: Span): number => (toMinutes(hhmm) - span.from) / span.length
+
+/**
+ * 刻限の札。
+ *
+ * 揃え方で、指しているのが一点か幅かを言い分ける。一点の印は札の右端を印に合わせ、
+ * 窓の札は幅の真ん中へ置く。端末は 390px しかないので、印が端に寄れば札は零れる
+ * ——零れる側では折り返す(遺体発見は軸の 92% に立つ)。
+ */
+const RailLabel = ({
+ ratio,
+ label,
+ time,
+ centered = false,
+}: {
+ ratio: number
+ label: string
+ time: string
+ centered?: boolean
+}) => {
+ const shift =
+ ratio < 0.24 ? '' : centered && ratio < 0.72 ? '-translate-x-1/2' : '-translate-x-full'
+
+ return (
+
+ {`${label} `}
+ {time}
+
+ )
+}
+
+/** 一点を指す印。帯を縦に貫く。裏の取れていない見立てだけ点線にする。 */
+const RailTick = ({ ratio, dotted = false }: { ratio: number; dotted?: boolean }) => (
+
+)
+
+/**
+ * 幅を持つ印。両端に返しの付いた線を、印の道の下へ渡す。
+ * 塗らない——面を足すと容疑者の目盛りと競う。
+ */
+const RailWindow = ({ from, to, dotted }: { from: number; to: number; dotted: boolean }) => (
+
+)
+
+/**
+ * 刻限の印(docs/design/deadline-window.md)。
+ *
+ * 机は時刻が上から下へ流れるので横一本の線になるが、端末は左から右なので縦の目盛りになる。
+ * 遺体発見は事件の記録が語っている公開情報なので、どの状態でも実線で出す。
+ */
+const DeadlineMarks = ({ deadline, span }: { deadline: Deadline; span: Span }) => {
+ const { death, foundAt, label } = deadline
+ const found = foundAt === undefined ? undefined : ratioOf(foundAt, span)
+
+ return (
+ <>
+ {found === undefined || foundAt === undefined ? null : (
+ <>
+
+
+ >
+ )}
+
+ {death === undefined ? null : death.kind === 'fixed' ? (
+ <>
+
+
+ >
+ ) : death.kind === 'range' ? (
+ <>
+
+
+ >
+ ) : death.kind === 'claimed' ? (
+ <>
+
+ {/*
+ 誰の見立てかは、机では線の下の一行。端末にはその一段が無いので札の尾に続ける
+ ——顔料はその人のものなので、続けても言い分の出どころは残る。
+ */}
+
+ >
+ ) : (
+ // 不明。どこか一点を指せないので、分かっている幅ぜんぶを点線の窓で囲う。
+ <>
+
+
+ >
+ )}
+ >
+ )
+}
+
+type Props = {
+ span: { start: string; end: string }
+ /** 供述から立った線。目盛りはこの数だけ立つ。 */
+ segments: AlibiSegment[]
+ /** 目盛りの顔料を決める並び。アリバイ表の列と同じ順でなければ、色が表とずれる。 */
+ keys: string[]
+ deadline?: Deadline
+}
+
+export const InterrogationRail = ({ span, segments, keys, deadline }: Props) => {
+ const window: Span = {
+ from: toMinutes(span.start),
+ length: toMinutes(span.end) - toMinutes(span.start),
+ }
+ const pins = segments.map((segment, index) => {
+ // 「19:08 受付」のように端が記録で留まっているなら、そちらが立つ時刻。
+ const fixed = segment.fix === undefined ? undefined : segment.fix.split(' ')[0]
+
+ return {
+ id: `${segment.who}-${segment.from}-${index}`,
+ left: at(ratioOf(fixed === undefined ? segment.from : fixed, window)),
+ surface: surfaceOf(keys.indexOf(segment.who)),
+ solid: segment.kind === 'solid',
+ }
+ })
+ // 最後に裏付けが取れた一本。いま会話が指している場所として、白が立つ。
+ const lastSolid = pins.filter((pin) => pin.solid).at(-1)
+
+ return (
+
+
+ {span.start}
+
+
+ {span.end}
+
+ {/* 線も明示的に置く。器の中央任せだと、帯を深くした途端に印とずれる。 */}
+
+ {pins.map((pin) => (
+
+ ))}
+ {lastSolid === undefined ? null : (
+
+ )}
+ {deadline === undefined ? null : }
+
+ )
+}
diff --git a/src/client/screens/InterrogationScreen.stories.tsx b/src/client/screens/InterrogationScreen.stories.tsx
index 9c1a434..fb44c9d 100644
--- a/src/client/screens/InterrogationScreen.stories.tsx
+++ b/src/client/screens/InterrogationScreen.stories.tsx
@@ -8,11 +8,7 @@ import {
} from '@/client/hooks/useInterrogation'
import type { InvestigablePlace } from '@/client/lib/schemas'
import { InterrogationScreen } from '@/client/screens/InterrogationScreen'
-import {
- INTERROGATION_SEED,
- INTERROGATION_SEED_LAST_TURN,
- SCENARIO,
-} from '@/client/stories/fixtures'
+import { INTERROGATION_SEED, SCENARIO } from '@/client/stories/fixtures'
import { VICTIM_ID } from '~/db/scenario-definition'
/**
@@ -54,187 +50,408 @@ const PLACES: InvestigablePlace[] = [
]
const CHOBA = PLACES[0]
+const OKU = PLACES[1]
-if (CHOBA === undefined) {
- throw new Error('場所の story は帳場から始める前提で組んである。')
+if (CHOBA === undefined || OKU === undefined) {
+ throw new Error('場所の story は帳場と奥の間の二つで組んである。')
}
/**
- * アリバイ表に立つ線。
+ * 聞き込みの台本。
*
- * いまのAPIは時刻付きの在所を返さないので、story が持つ。
- * 中身は mocks/_case.js の台本と同じ——別の事件を並べると、差の出所が
- * 意匠なのかデータなのか分からなくなる。
+ * mocks/_case.js の script をそのまま写したもの。別の受け答えを並べると、
+ * 突き合わせたときの差が意匠から来ているのかデータから来ているのか分からなくなる。
*
- * 刻限は遺体発見だけが出ていて、死亡推定はまだ「不明」。この台本では誰も
- * 遺体を検分していないので、盤面もそこを知らない(#death=unknown と同じ状態)。
+ * 答えの改行は書き手が置いた呼吸で、そのまま段落の切れ目になる(モックの lines と同じ)。
*/
-const DEADLINE: Deadline = { foundAt: '19:10', label: '死亡推定', death: { kind: 'unknown' } }
+type Beat = {
+ /** 探偵が投げる問い。モックと同じく句点は置かない。 */
+ q: string
+ a: string
+ reveals: AlibiSegment[]
+}
-/** 4ターン目まで訊いたところで開いている線。 */
-const SEGMENTS_MID: AlibiSegment[] = [
- { who: MAKINO.id, from: '18:20', to: '18:36', kind: 'solid', place: '店内' },
- { who: MAKINO.id, from: '18:36', to: '19:08', kind: 'claim', place: '郵便局へ、雨のなかを' },
- {
- who: MAKINO.id,
- from: '19:08',
- to: '19:14',
- kind: 'solid',
- place: '郵便窓口',
- fix: '19:08 受付',
- },
- { who: KURODA.id, from: '18:23', to: '18:41', kind: 'solid', place: '店内', fix: '18:23 来店' },
-]
+const SCRIPT: Record = {
+ [MAKINO.id]: [
+ {
+ q: '閉店したあと、店に残っていたのは誰ですか',
+ a: 'わたしと、店長と、黒田さんです。黒田さんは初版本の話で六時二十三分ごろに見えました。\nわたしは奥の帳場にいましたから、そのあたりはよく覚えています。',
+ reveals: [{ who: MAKINO.id, from: '18:20', to: '18:36', kind: 'solid', place: '店内' }],
+ },
+ {
+ q: '店を出たあと、まっすぐ郵便局へ向かったんですね',
+ a: 'はい。発送があったので、午後六時三十六分には店を出ています。\n……三十分以上かかる道のりでしたけど。雨でしたから。',
+ reveals: [
+ {
+ who: MAKINO.id,
+ from: '18:36',
+ to: '19:08',
+ kind: 'claim',
+ place: '郵便局へ、雨のなかを',
+ },
+ ],
+ },
+ {
+ q: 'レシートを見せてもらえますか',
+ a: 'ええ、鞄に。……これです。窓口の受付は午後七時八分。\n小包の控えも一緒に残っています。日付も時刻も、機械が打ったものです。',
+ reveals: [
+ {
+ who: MAKINO.id,
+ from: '19:08',
+ to: '19:14',
+ kind: 'solid',
+ place: '郵便窓口',
+ fix: '19:08 受付',
+ },
+ ],
+ },
+ {
+ q: '黒田さんとは、店で話しましたか',
+ a: '少しだけ。黒田さんは初版本を見に来ていて、店長と奥で長く話していました。\nわたしが出るときは、まだ店内にいらしたはずです。',
+ reveals: [
+ {
+ who: KURODA.id,
+ from: '18:23',
+ to: '18:41',
+ kind: 'solid',
+ place: '店内',
+ fix: '18:23 来店',
+ },
+ ],
+ },
+ {
+ q: '瀬名さんは、あなたが出ていくのを見ていないそうです',
+ a: '……向かいのお店から、うちの戸口が全部見えるわけではありませんから。\nちょうど雨脚が強い時分でしたし。傘を差せば、顔なんて見えないでしょう。',
+ reveals: [],
+ },
+ ],
+ [KURODA.id]: [
+ {
+ q: '何時ごろ、店へ来られましたか',
+ a: '六時二十三分です。約束の時間より少し早く着きました。\n初版本は水野さんが奥から出してくださる手筈でしたので、待っていました。',
+ reveals: [
+ {
+ who: KURODA.id,
+ from: '18:23',
+ to: '18:41',
+ kind: 'solid',
+ place: '店内',
+ fix: '18:23 来店',
+ },
+ ],
+ },
+ {
+ q: '閉店後、店の奥へは入っていない',
+ a: '入っていません。商談は帳場の前で済みましたから。\nそのあとは、まっすぐ帰りました。',
+ reveals: [
+ { who: KURODA.id, from: '18:48', to: '19:20', kind: 'claim', place: '帰宅したと申告' },
+ ],
+ },
+ {
+ q: '傘は、どうされました',
+ a: '……ああ。忘れて出てしまって、裏の路地から戻ったんです。六時四十一分ごろ。\n軒下に立てかけたままで。濡れて帰るのは厭でしたから。',
+ reveals: [
+ {
+ who: KURODA.id,
+ from: '18:41',
+ to: '18:48',
+ kind: 'solid',
+ place: '裏の路地',
+ fix: '18:41 忘れ傘',
+ },
+ ],
+ },
+ {
+ q: '店主とは、何を話しましたか',
+ a: '値段の話です。もっとも、あの人はずっと店の奥にいて、出てきたのは一度きりでした。\n帳場に戻ってからも、奥の物音は続いていましたよ。',
+ reveals: [{ who: VICTIM_ID, from: '18:20', to: '18:50', kind: 'solid', place: '店の奥' }],
+ },
+ ],
+ [SENA.id]: [
+ {
+ q: 'その時間、あなたはどちらに',
+ a: '向かいの店に。うちは八時まで開けていますから、ずっと中にいました。\n雨の日はお客も来ませんし、窓の外ばかり見ていました。',
+ reveals: [
+ { who: SENA.id, from: '18:20', to: '18:39', kind: 'claim', place: '向かいの喫茶店' },
+ ],
+ },
+ {
+ q: '青雨堂の軒先で、雨宿りをされていたと聞きました',
+ a: 'ええ、六時三十九分ごろでしょうか。ゴミを出しに出たら、急に降りが強くなって。\n十分ばかり、青雨堂さんの軒を借りていました。',
+ reveals: [
+ {
+ who: SENA.id,
+ from: '18:39',
+ to: '18:48',
+ kind: 'solid',
+ place: '青雨堂の軒先',
+ fix: '18:39 雨宿り',
+ },
+ ],
+ },
+ {
+ q: '通報されたのは、あなたですね',
+ a: 'はい。灯りが点いたままなのが気になって、七時過ぎに戸を叩きました。\n返事がないので中へ入って……七時十二分に電話をしました。',
+ reveals: [
+ { who: SENA.id, from: '18:48', to: '19:12', kind: 'claim', place: '喫茶店に戻る' },
+ {
+ who: SENA.id,
+ from: '19:12',
+ to: '19:20',
+ kind: 'solid',
+ place: '青雨堂',
+ fix: '19:12 通報',
+ },
+ ],
+ },
+ {
+ q: '牧野さんが店を出ていくのは、見ましたか',
+ a: '見ていません。軒先にいた十分のあいだ、あの戸は一度も開きませんでした。\n……開いていたら、音で分かります。あすこの引戸は、建て付けが悪いので。',
+ reveals: [],
+ },
+ ],
+ /*
+ * 遺体の検分と場所調べ。喋らないので、探偵の一手は問いかけではなく独り言になる。
+ * 返ってくるのは所見——見て取ったことだけが並ぶ。
+ */
+ [VICTIM_ID]: [
+ {
+ q: '水野さんの死因はなんだろうか、確かめてみよう',
+ a: '争った跡は無い。着衣も髪も乱れていない。\n後頭部に、固いものが当たったような打撲がひとつ。倒れた先は帳場の奥だ。',
+ reveals: [{ who: VICTIM_ID, from: '18:20', to: '18:50', kind: 'solid', place: '店の奥' }],
+ },
+ ],
+ [CHOBA.id]: [
+ {
+ q: '帳場の帳面を見てみよう',
+ a: '閉店の締めが途中で止まっている。合計の欄が空のままだ。\n最後の記帳は六時四十四分。牧野の字で書かれている。',
+ reveals: [
+ {
+ who: MAKINO.id,
+ from: '18:40',
+ to: '18:44',
+ kind: 'solid',
+ place: '帳場',
+ fix: '18:44 最後の記帳',
+ },
+ ],
+ },
+ ],
+ [OKU.id]: [
+ {
+ q: '書架のあいだを見てみよう',
+ a: '棚の一段だけ、埃の跡が新しい。本が一冊、抜かれたまま戻っていない。\n空いた場所の札に、初版本の整理番号が残っている。',
+ reveals: [{ who: KURODA.id, from: '18:41', to: '18:48', kind: 'solid', place: '奥の間' }],
+ },
+ ],
+}
/**
- * 打ち止めまで訊いたところ。三人ぶんが出そろい、食い違いが一本立つ。
- *
- * 並びは聞き出した順。端末の帯は「最後に裏付けが取れた線」に白を立てるので、
- * 人ごとにまとめて並べ替えると、その一本が別の時刻へ移る。
+ * 聞き込みの順番。mocks/_mock.js の PLAY と同じ運びで、9手目に食い違いが立ち、
+ * 10手目でそれを牧野に当てる。遺体の検分と場所調べも同じ財布から一手を使う。
*/
-const SEGMENTS_LAST: AlibiSegment[] = [
- ...SEGMENTS_MID,
- {
- who: KURODA.id,
- from: '18:41',
- to: '18:48',
- kind: 'solid',
- place: '裏の路地',
- fix: '18:41 忘れ傘',
- },
- { who: KURODA.id, from: '18:48', to: '19:20', kind: 'claim', place: '帰宅したと申告' },
- { who: SENA.id, from: '18:20', to: '18:39', kind: 'claim', place: '向かいの喫茶店' },
- {
- who: SENA.id,
- from: '18:39',
- to: '18:48',
- kind: 'solid',
- place: '青雨堂の軒先',
- fix: '18:39 雨宿り',
- },
- { who: 'victim', from: '18:20', to: '18:50', kind: 'solid', place: '店の奥' },
- { who: SENA.id, from: '18:48', to: '19:12', kind: 'claim', place: '喫茶店に戻る' },
- { who: SENA.id, from: '19:12', to: '19:20', kind: 'solid', place: '青雨堂', fix: '19:12 通報' },
+const PLAY: { who: string; i: number }[] = [
+ { who: MAKINO.id, i: 0 },
+ { who: MAKINO.id, i: 1 },
+ { who: MAKINO.id, i: 2 },
+ { who: KURODA.id, i: 0 },
+ { who: KURODA.id, i: 2 },
+ { who: KURODA.id, i: 1 },
+ { who: SENA.id, i: 0 },
+ { who: SENA.id, i: 1 },
+ { who: SENA.id, i: 3 },
+ { who: MAKINO.id, i: 4 },
+ { who: KURODA.id, i: 3 },
+ { who: SENA.id, i: 2 },
+ { who: VICTIM_ID, i: 0 },
+ { who: CHOBA.id, i: 0 },
+ { who: OKU.id, i: 0 },
]
-/**
- * 帳場を調べ終えたところ。人の証言では出てこなかった一本が、所見から立つ。
- *
- * 立つ列は牧野。部屋そのものの列は無いので、場所を調べて分かったことは
- * 「誰がどこにいたか」として人の列に入る。
- */
-const SEGMENTS_PLACE: AlibiSegment[] = [
- ...SEGMENTS_LAST,
- {
- who: MAKINO.id,
- from: '18:40',
- to: '18:44',
- kind: 'solid',
- place: '帳場',
- fix: '18:44 最後の記帳',
- },
-]
+const MAX_TURNS = PLAY.length
const ASKED_AT = 1_756_000_000_000
+const beatOf = (who: string, index: number): Beat => {
+ const beat = SCRIPT[who]?.[index]
+
+ if (beat === undefined) {
+ throw new Error(`台本に無い一手を指している: ${who} / ${index}`)
+ }
+
+ return beat
+}
+
/** 一往復ぶん。話題・探偵の質問・返答は同じ時刻を共有して、一本の時系列に塊のまま並ぶ。 */
-const exchange = (n: number, topic: string, question: string, answer: string): ChatTurn[] => [
- { id: `t${n}`, role: 'topic', text: topic, askedAt: ASKED_AT + n * 60_000 },
- { id: `u${n}`, role: 'user', text: question, askedAt: ASKED_AT + n * 60_000 },
- { id: `a${n}`, role: 'assistant', text: answer, askedAt: ASKED_AT + n * 60_000 },
+const exchange = (n: number, beat: Beat): ChatTurn[] => [
+ { id: `t${n}`, role: 'topic', text: beat.q, askedAt: ASKED_AT + n * 60_000 },
+ { id: `u${n}`, role: 'user', text: beat.q, askedAt: ASKED_AT + n * 60_000 },
+ { id: `a${n}`, role: 'assistant', text: beat.a, askedAt: ASKED_AT + n * 60_000 },
]
-const merge = (base: ChatTurn[] | undefined, added: ChatTurn[]): ChatTurn[] =>
- base === undefined ? added : [...base, ...added]
+const keyOf = (segment: AlibiSegment): string =>
+ `${segment.who}/${segment.from}/${segment.to}/${segment.kind}`
+
+/** n 手目まで進めたところ。会話・線・次に訊けそうなことが、同じ台本から一度に出る。 */
+const playUpTo = (n: number) => {
+ const beats = PLAY.slice(0, n)
+ // 相手ごとに分けて持つ。積むだけなので、袋を作り直さず手元のものへ足す。
+ const conversations: Record = {}
+
+ for (const [index, play] of beats.entries()) {
+ const held = conversations[play.who]
+
+ conversations[play.who] = [
+ ...(held === undefined ? [] : held),
+ ...exchange(index, beatOf(play.who, play.i)),
+ ]
+ }
+
+ // 同じ線を二度開くことがある(黒田の来店は牧野からも黒田からも出る)。重ねても見え方は変わらない。
+ const segments = beats
+ .flatMap((play) => beatOf(play.who, play.i).reveals)
+ .filter((segment, index, all) => all.findIndex((s) => keyOf(s) === keyOf(segment)) === index)
+
+ return {
+ turn: n,
+ current: PLAY[n - 1]?.who,
+ conversations,
+ segments,
+ // 次に訊けそうなこと。台本の先読みなので、残りが無ければ空。
+ hints: PLAY.slice(n, n + 2).map((play) => beatOf(play.who, play.i).q),
+ }
+}
/**
- * 打ち止めの回。終盤の三往復を継ぎ足して、画面の下端に映るところを揃える。
- * 最後に喋ったのが瀬名なので、開いた画面は瀬名を向いている。
+ * 刻限。遺体発見だけが出ていて、死亡推定はまだ「不明」。この台本では誰も
+ * 遺体を検分していないので、盤面もそこを知らない(#death=unknown と同じ状態)。
*/
-const LAST_TURN_SEED: InterrogationSeed = {
- ...INTERROGATION_SEED_LAST_TURN,
- conversations: {
- ...INTERROGATION_SEED_LAST_TURN.conversations,
- [MAKINO.id]: merge(
- INTERROGATION_SEED_LAST_TURN.conversations[MAKINO.id],
- exchange(
- 10,
- '瀬名の証言との食い違い',
- '瀬名さんは、あなたが出ていくのを見ていないそうです。',
- '……向かいのお店から、うちの戸口が全部見えるわけではありませんから。ちょうど雨脚が強い時分でしたし。傘を差せば、顔なんて見えないでしょう。',
- ),
- ),
- [KURODA.id]: merge(
- INTERROGATION_SEED_LAST_TURN.conversations[KURODA.id],
- exchange(
- 11,
- '店主と話した内容',
- '店主とは、何を話しましたか。',
- '値段の話です。もっとも、あの人はずっと店の奥にいて、出てきたのは一度きりでした。帳場に戻ってからも、奥の物音は続いていましたよ。',
- ),
- ),
- [SENA.id]: merge(
- INTERROGATION_SEED_LAST_TURN.conversations[SENA.id],
- exchange(
- 12,
- '通報したときのこと',
- '通報されたのは、あなたですね。',
- 'はい。灯りが点いたままなのが気になって、七時過ぎに戸を叩きました。返事がないので中へ入って……七時十二分に電話をしました。',
- ),
- ),
- },
+const DEADLINE: Deadline = {
+ // 発見時刻は事件の記録が語っている公開情報。モックの CASE.found と同じ 19:15。
+ foundAt: SCENARIO.victim?.foundAt === null ? undefined : SCENARIO.victim?.foundAt,
+ label: '死亡推定',
+ death: { kind: 'unknown' },
+}
+
+/** 食い違い。牧野の申告と瀬名の証言が噛み合わない一点。九手目で立つ。 */
+const CLASH = { at: '18:36', label: '食い違い', between: [MAKINO.id, SENA.id] } satisfies {
+ at: string
+ label: string
+ between: [string, string]
}
/**
- * 十四手目。遺体を検分したあと、帳場へ回ったところ。
- *
- * 喋らない相手は二人続く。ログの名前はどちらも「所見」で、縦罫は遺体が芥子、
- * 帳場が灰——色の付いた相手は答え、灰のままの相手は答えない。
+ * 経過時間。モックの Mock.clock と同じ見せかけで、計っているわけではない。
+ * 十五手で十分弱に収まる速さ——一覧が「約10分」と言っている以上、
+ * 画面の時計だけ二十六分を指していては辻褄が合わない。
*/
-const PLACE_SEED: InterrogationSeed = {
- ...LAST_TURN_SEED,
- conversations: {
- ...LAST_TURN_SEED.conversations,
- [VICTIM_ID]: exchange(
- 13,
- '死因',
- '水野さんの死因はなんだろうか、確かめてみよう。',
- '争った跡は無い。着衣も髪も乱れていない。後頭部に、固いものが当たったような打撲がひとつ。倒れた先は帳場の奥だ。',
- ),
- [CHOBA.id]: exchange(
- 14,
- '帳場の帳面',
- '帳場の帳面を見てみよう。',
- '閉店の締めが途中で止まっている。合計の欄が空のままだ。最後の記帳は六時四十四分。牧野の字で書かれている。',
- ),
- },
- questionCount: 13,
- /*
- * 場所を調べるのも一手を使う。人に訊くか現場を見るかは同じ財布から出るので、
- * 台本の十五手ぶんをそのまま持たせる。
- */
+const elapsedOf = (turn: number): number => turn * 47 + 13
+
+const seedOf = (turn: number): InterrogationSeed => ({
+ ...INTERROGATION_SEED,
+ conversations: playUpTo(turn).conversations,
+ questionCount: turn - 1,
turn: {
- turn: 14,
- maxTurns: 15,
+ turn,
+ maxTurns: MAX_TURNS,
askedInTurn: 0,
questionsPerTurn: 1,
remainingInTurn: 1,
exhausted: false,
},
+})
+
+/**
+ * サーバの返事。経過時間はここからしか出ないので、静止画のために返しておく
+ * ——画面が計器を出すかどうかは通信の成否で決まるべきではない。
+ */
+const serveSession = (turn: number, seed: InterrogationSeed) => {
+ const original = globalThis.fetch
+ const served = (input: RequestInfo | URL, init?: RequestInit): Promise =>
+ String(input).includes(`/api/sessions/${SESSION}`)
+ ? Promise.resolve(
+ Response.json({
+ sessionId: SESSION,
+ scenarioId: SCENARIO.id,
+ detectiveName: '灰かぶりの探偵',
+ hint: seed.hint,
+ questionCount: seed.questionCount,
+ elapsedSeconds: elapsedOf(turn),
+ finished: false,
+ discoveries: seed.discoveries,
+ revelations: seed.revelations,
+ alibiSegments: [],
+ clash: null,
+ estimatedDeathAt: null,
+ turn: seed.turn,
+ }),
+ )
+ : original(input, init)
+
+ globalThis.fetch = Object.assign(served, { preconnect: original.preconnect })
+
+ return () => {
+ globalThis.fetch = original
+ }
}
+/** 帯を出しておく間隔。NewFactBand が引く 2.6 秒より短くして、途切れないようにする。 */
+const BAND_INTERVAL_MS = 2400
+
const Harness = ({
- seed,
+ turn,
detectiveName,
+ newFact,
segments,
- clash,
}: {
- seed: InterrogationSeed
+ turn: number
detectiveName: string | null
- segments: AlibiSegment[]
- clash?: { at: string; label: string; between: [string, string] }
+ /** 帯に出す一行。増えたことを知らせる帯は、増えた瞬間にしか出ない。 */
+ newFact?: string
+ /** 表に立てる線。台本どおりでない見え方(増えていく途中)を見たいときだけ渡す。 */
+ segments?: AlibiSegment[]
}) => {
+ const played = playUpTo(turn)
+ const [seed] = useState(() => seedOf(turn))
+ /*
+ サーバの返事は、画面が最初に取りに行くより前に用意しておく必要がある
+ ——子の effect は親の effect より先に走るので、ここは描画のうちに差し替える。
+ */
+ const [restore] = useState(() => serveSession(turn, seed))
const interrogation = useInterrogation(seed)
+ /*
+ 画面に入った時点で持っているぶんは帯に出ない(既知の手掛かりを知らされても
+ 何も増えていない)。増えたところを見たいので、開いてから足す。
+
+ 実装は 2.6 秒で帯を引くが、モックは静止画としても読まれるので出したままにしてある。
+ 突き合わせを成り立たせるため、ここでは足し続けて出したままにする。
+ */
+ const [learned, setLearned] = useState(interrogation.revelations)
+
+ useEffect(() => restore, [restore])
+
+ useEffect(() => {
+ if (newFact === undefined) {
+ return
+ }
+
+ const add = () =>
+ setLearned((prev) => [
+ ...prev,
+ {
+ id: `9d3b7c${prev.length}-0000-4000-8000-00000000000f`,
+ title: newFact,
+ text: newFact,
+ category: '証言',
+ subject: { type: 'event' as const, id: `turn-${turn}` },
+ },
+ ])
+
+ add()
+ const timer = setInterval(add, BAND_INTERVAL_MS)
+
+ return () => clearInterval(timer)
+ }, [newFact, turn])
return (
= 9 && segments === undefined ? CLASH : undefined,
+ }}
onAccuse={() => undefined}
onLeave={() => undefined}
/>
@@ -265,20 +493,19 @@ const SEGMENT_INTERVAL_MS = 1200
* AlibiChart の作りをここで確かめる。
*/
const Growing = () => {
+ const all = playUpTo(MAX_TURNS).segments
const [count, setCount] = useState(0)
const [take, setTake] = useState(0)
useEffect(() => {
- if (count >= SEGMENTS_LAST.length) {
+ if (count >= all.length) {
return
}
const timer = setTimeout(() => setCount(count + 1), SEGMENT_INTERVAL_MS)
return () => clearTimeout(timer)
- }, [count])
-
- const done = count >= SEGMENTS_LAST.length
+ }, [count, all.length])
return (
<>
@@ -288,17 +515,15 @@ const Growing = () => {
setCount(0)
setTake(take + 1)
}}
- className="fixed top-3 right-3 z-50 border border-keisen bg-sumi px-[9px] py-[3px] text-[10px] tracking-[0.16em] text-nezumi-dim hover:border-nezumi-dim hover:text-kinari"
+ className="fixed top-3 right-3 z-50 border border-keisen bg-sumi px-[9px] py-[3px] text-[10px] text-nezumi-dim tracking-[0.16em] hover:border-nezumi-dim hover:text-kinari"
>
- もう一度({count} / {SEGMENTS_LAST.length})
+ もう一度({count} / {all.length})
>
)
@@ -316,13 +541,17 @@ type Story = StoryObj
/** 中盤。何本か線が立ち、まだ訊ける。 */
export const Default: Story = {
render: () => (
-
+
),
}
/** 名乗らずに始めたセッション。会話の聞き手が一般名詞に落ちる。 */
export const Anonymous: Story = {
- render: () => ,
+ render: () => ,
}
/** 最後のターン。線が出そろい、食い違いが一本立っている。 */
@@ -332,10 +561,9 @@ export const LastTurn: Story = {
name: 'LastTurn',
render: () => (
),
}
@@ -349,10 +577,9 @@ export const LastTurn: Story = {
export const 場所を調べる: Story = {
render: () => (
),
}
diff --git a/src/client/screens/InterrogationScreen.tsx b/src/client/screens/InterrogationScreen.tsx
index f7b91a2..cf27bc7 100644
--- a/src/client/screens/InterrogationScreen.tsx
+++ b/src/client/screens/InterrogationScreen.tsx
@@ -6,7 +6,7 @@ import {
type Deadline,
} from '@/client/components/AlibiChart'
import { CaseNoteDialog } from '@/client/components/CaseNote'
-import { edgeOf, inkOf, surfaceOf } from '@/client/components/CharacterAvatar'
+import { edgeOf, inkOf } from '@/client/components/CharacterAvatar'
import { FloorPlanMap } from '@/client/components/FloorPlan'
import { NewFactBand } from '@/client/components/NewFactBand'
import { TurnAnnounce } from '@/client/components/TurnAnnounce'
@@ -16,8 +16,9 @@ import { useLitFix } from '@/client/hooks/useLitFix'
import { usePacedReveal } from '@/client/hooks/usePacedReveal'
import { fetchSessionState } from '@/client/lib/api'
import { formatSeconds } from '@/client/lib/format'
-import { settledSentences } from '@/client/lib/paragraphs'
import type { InvestigablePlace, ScenarioDetail, SessionState } from '@/client/lib/schemas'
+import { InterrogationRail } from '@/client/screens/InterrogationRail'
+import { buildBlocks, capLines, tintTimes, toMinutes } from '@/client/screens/interrogation-log'
import { MAX_TOPIC_CHARS } from '@/shared/turns'
import { VICTIM_ID } from '~/db/scenario-definition'
@@ -128,234 +129,6 @@ const lastSpokenId = (
{ id: fallback, at: -1 },
).id
-const toMinutes = (hhmm: string): number => {
- const [h, m] = hhmm.split(':')
-
- return h === undefined || m === undefined ? 0 : Number(h) * 60 + Number(m)
-}
-
-/* ---- 会話のなかの確定時刻 ---- */
-
-const KANJI_DIGITS = new Map([
- ['〇', 0],
- ['零', 0],
- ['一', 1],
- ['二', 2],
- ['三', 3],
- ['四', 4],
- ['五', 5],
- ['六', 6],
- ['七', 7],
- ['八', 8],
- ['九', 9],
-])
-
-/** 「六時二十三分」「午後七時八分」「19:08」。時だけ・分だけの言い回しは拾わない。 */
-const TIME_PATTERN =
- /(?:午前|午後)?(?:[〇零一二三四五六七八九十]+|\d{1,2})時(?:[〇零一二三四五六七八九十]+|\d{1,2})分|\d{1,2}:\d{2}/g
-
-const kanjiNumber = (text: string): number => {
- const ten = text.indexOf('十')
-
- if (ten === -1) {
- return Array.from(text).reduce((sum, char) => {
- const digit = KANJI_DIGITS.get(char)
-
- return sum * 10 + (digit === undefined ? 0 : digit)
- }, 0)
- }
-
- const upper = ten === 0 ? 1 : kanjiNumber(text.slice(0, ten))
- const lower = text.slice(ten + 1)
-
- return upper * 10 + (lower.length === 0 ? 0 : kanjiNumber(lower))
-}
-
-const numberOf = (text: string): number => (/^\d+$/.test(text) ? Number(text) : kanjiNumber(text))
-
-/** 表記を分に直す。午前・午後は読まない——時計回りの12時間ぶんは呼ぶ側で試す。 */
-const minutesOfExpression = (text: string): number | undefined => {
- const body = text.replace(/^(?:午前|午後)/, '')
- const [hh, mm] = body.includes(':') ? body.split(':') : body.replace(/分$/, '').split('時')
-
- return hh === undefined || mm === undefined ? undefined : numberOf(hh) * 60 + numberOf(mm)
-}
-
-const HALF_DAY_MINUTES = 12 * 60
-
-/** at は文の中での位置。同じ字面が二度出ても鍵がぶつからない。 */
-type Piece = { at: number; text: string; ink: string | undefined }
-
-/**
- * 確定した時刻を、その時刻が立つ列の顔料で染める。
- *
- * 喋っている人の色ではない。「六時二十三分に来た」と牧野が言っても、
- * その線が立つのは黒田の列なので、字も黒田の色になる。
- */
-const tintTimes = (text: string, inks: Map): Piece[] => {
- const found = Array.from(text.matchAll(TIME_PATTERN))
- const marked = found.reduce<{ pieces: Piece[]; at: number }>(
- (acc, match) => {
- const minutes = minutesOfExpression(match[0])
-
- if (minutes === undefined || match.index === undefined) {
- return acc
- }
-
- const noon = inks.get(minutes)
- const ink = noon === undefined ? inks.get(minutes + HALF_DAY_MINUTES) : noon
-
- return ink === undefined
- ? acc
- : {
- pieces: [
- ...acc.pieces,
- { at: acc.at, text: text.slice(acc.at, match.index), ink: undefined },
- { at: match.index, text: match[0], ink },
- ],
- at: match.index + match[0].length,
- }
- },
- { pieces: [], at: 0 },
- )
-
- return [...marked.pieces, { at: marked.at, text: text.slice(marked.at), ink: undefined }]
-}
-
-/* ---- 会話の塊 ---- */
-
-/** who は登場順の添字。探偵は列を持たないので -1。 */
-type Block = {
- id: string
- who: number
- name: string
- /** 名前の色と縦罫。人は登場順の顔料、場所は灰、探偵は罫線と同じ色。 */
- ink: string
- edge: string
- lines: { id: string; text: string }[]
-}
-
-/**
- * 相手ごとに分かれている会話を、一本の時系列に並べ直して塊にまとめる。
- *
- * 画面に映るログは一本きり。誰に何を聞いたかが順に流れるので、
- * 相手を切り替えても読んでいた場所が消えない。
- */
-const buildBlocks = (
- /** 話題を投げられる相手。被害者と場所を含むので ScenarioDetail['characters'] より広い。 */
- characters: { id: string; logName: string; ink: string; edge: string }[],
- conversations: Record,
- askerName: string,
- /** 今まさに返答が流れてきている相手。書きかけの一文を伏せるのに要る。 */
- askingCharacterId: string | undefined,
-): Block[] => {
- const said = characters.flatMap((character, index) => {
- const turns = turnsOf(conversations, character.id)
- // 流れている最中なのは、訊いている相手の末尾の返答だけ。
- const streamingSeq = character.id === askingCharacterId ? turns.length - 1 : -1
-
- return (
- turns
- .map((turn, seq) => ({
- turn,
- seq,
- index,
- name: character.logName,
- ink: character.ink,
- edge: character.edge,
- streaming: seq === streamingSeq && turn.role === 'assistant',
- }))
- // 話題はプレイヤーの指示であって発言ではない。探偵が投げた質問のほうが残る。
- .filter(({ turn }) => turn.role !== 'topic' && turn.text.length > 0)
- )
- })
-
- const ordered = [...said].sort((a, b) =>
- a.turn.askedAt === b.turn.askedAt ? a.seq - b.seq : a.turn.askedAt - b.turn.askedAt,
- )
- const blocks: Block[] = []
-
- for (const item of ordered) {
- const who = item.turn.role === 'user' ? -1 : item.index
- const id = `${item.index}:${item.turn.id}`
- const lines = settledSentences(item.turn.text, item.streaming).map((text, at) => ({
- id: `${id}:${at}`,
- text,
- }))
-
- // 一文目が出来上がるまでは何も置かない。名前だけ先に出ると、
- // 誰かが口を開いたまま黙っているように見える。
- if (lines.length === 0) {
- continue
- }
-
- const last = blocks[blocks.length - 1]
-
- // 同じ人が続けて喋るあいだ、名前は一度きり。縦罫だけが最後まで伸びる。
- if (last !== undefined && last.who === who) {
- last.lines.push(...lines)
- continue
- }
-
- blocks.push({
- id,
- who,
- name: who === -1 ? askerName : item.name,
- ink: who === -1 ? 'text-nezumi-dim' : item.ink,
- edge: who === -1 ? 'border-keisen' : item.edge,
- lines,
- })
- }
-
- return blocks
-}
-
-/**
- * 出してよい行数まで塊を切り詰める。
- *
- * 塊ごとではなく通しで数えるので、探偵の質問と相手の一文目のあいだにも間が入る。
- * 行が一つも残らない塊は落とす——名前だけが立って、口を開けたまま黙っているように
- * 見えるのを避けるため。
- */
-const capLines = (blocks: Block[], limit: number): Block[] =>
- blocks.reduce<{ left: number; kept: Block[] }>(
- (acc, block) => {
- const lines = block.lines.slice(0, acc.left)
-
- return {
- left: acc.left - lines.length,
- kept: lines.length === 0 ? acc.kept : [...acc.kept, { ...block, lines }],
- }
- },
- { left: limit, kept: [] },
- ).kept
-
-/* ---- 端末の時刻軸 ---- */
-
-type Pin = { id: string; left: string; surface: string; solid: boolean }
-
-/**
- * 帯の目盛りは供述の数だけ立つ。
- * 帯は左右 10px の余白の内側に引かれているので、%だけで置くと線からずれる。
- */
-const railPins = (
- segments: AlibiSegment[],
- keys: string[],
- span: { from: number; length: number },
-): Pin[] =>
- segments.map((segment, index) => {
- // 「19:08 受付」のように端が記録で留まっているなら、そちらが立つ時刻。
- const fixed = segment.fix === undefined ? undefined : segment.fix.split(' ')[0]
- const ratio = (toMinutes(fixed === undefined ? segment.from : fixed) - span.from) / span.length
-
- return {
- id: `${segment.who}-${segment.from}-${index}`,
- left: `calc(10px + (100% - 20px) * ${ratio.toFixed(3)})`,
- surface: surfaceOf(keys.indexOf(segment.who)),
- solid: segment.kind === 'solid',
- }
- })
-
/**
* 聞き込みのメイン画面。
*
@@ -697,24 +470,12 @@ export const InterrogationScreen = ({
)
const said = buildBlocks(subjects, conversations, askerName, askingCharacterId)
- const shown = usePacedReveal(
- said.reduce((count, block) => count + block.lines.length, 0),
- isAsking,
- )
+ const total = said.reduce((count, block) => count + block.lines.length, 0)
+ const shown = usePacedReveal(total, isAsking)
const blocks = capLines(said, shown)
+ /** 返答が出そろって、こちらの番になっているか。合図の印を置いてよい状態。 */
+ const settled = !isAsking && shown >= total
const timeWindow = scenario.timeWindow
- const pins =
- timeWindow === null
- ? []
- : railPins(
- alibi.segments,
- people.map((person) => person.key),
- {
- from: toMinutes(timeWindow.start),
- length: toMinutes(timeWindow.end) - toMinutes(timeWindow.start),
- },
- )
- const lastSolid = pins.filter((pin) => pin.solid).at(-1)
/**
* 資料への入口と、まだ見つかっていないものの数。
@@ -939,42 +700,16 @@ export const InterrogationScreen = ({
{tools('border-keisen border-t pt-2.5')}
- {/*
- 端末の時刻軸。目盛りは供述の数だけ立ち、裏付けの取れた最後の一本に白が立つ。
- 机のアリバイ表と同じものを、幅の無い画面で言い換えている。
- */}
+ {/* 端末の時刻軸。目盛りは供述の数だけ立ち、裏付けの取れた最後の一本に白が立つ。 */}
{timeWindow === null ? null : (
-
-
- {timeWindow.start}
-
-
- {timeWindow.end}
-
-
- {pins.map((pin) => (
-
- ))}
- {lastSolid === undefined ? null : (
-
- )}
-
+ person.key)}
+ deadline={alibi.deadline}
+ />
)}
- {tools('border-keisen border-b px-3 py-1.5')}
-
{nameplate(
@@ -988,6 +723,12 @@ export const InterrogationScreen = ({
途切れではなく「まだ上に続いている」ことを示す。
*/}
+ {/*
+ 端末では絶対配置で会話の中央へ被さるので、この位置に書いても見た目は動かない。
+ 机では流れの中に入り、名札の下・会話の上に置かれる。
+ */}
+ {newFact !== undefined &&
}
+
)}
- {blocks.map((block) => (
+ {blocks.map((block, blockAt) => (
{block.name}
- {block.lines.map((line) => (
+ {block.lines.map((line, lineAt) => (
))}
+ {/*
+ 返答が言い終わった合図。いちばん新しい塊の末尾にだけ置く。
+ 次の一手を待たせる印なので、色は持たせない。
+ */}
+ {settled &&
+ block.who !== -1 &&
+ blockAt === blocks.length - 1 &&
+ lineAt === block.lines.length - 1 && (
+
+ ▼
+
+ )}
))}
@@ -1050,12 +806,17 @@ export const InterrogationScreen = ({
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 hidden h-[54px] bg-gradient-to-b from-sumi to-transparent lg:block"
/>
-
- {newFact !== undefined &&
}
{error !== undefined &&
{error}
}
+ {/*
+ 端末には余りが無いので、資料への入口は会話の下端へ添える。帯の下に一段
+ 置くと、そこがモックで刻限の出ている場所なので、盤面の一部に見えてしまう。
+ 罫線は足さない——すぐ下の「訊けそうなこと」がもう一本引いている。
+ */}
+
{tools('')}
+
{/* 訊けそうなこと。畳んであり、押すと開く。 */}
{!exhausted && (
diff --git a/src/client/screens/ScenarioSelectScreen.stories.tsx b/src/client/screens/ScenarioSelectScreen.stories.tsx
index 96c720b..24a9dad 100644
--- a/src/client/screens/ScenarioSelectScreen.stories.tsx
+++ b/src/client/screens/ScenarioSelectScreen.stories.tsx
@@ -84,8 +84,14 @@ const SCENARIOS: ScenarioSummary[] = CASES.map(
}),
)
-const Paged = ({ scenarios }: { scenarios: ScenarioSummary[] }) => {
- const [page, setPage] = useState(1)
+const Paged = ({
+ scenarios,
+ initialPage = 1,
+}: {
+ scenarios: ScenarioSummary[]
+ initialPage?: number
+}) => {
+ const [page, setPage] = useState(initialPage)
return (
,
}
+/**
+ * 途中のページ。
+ *
+ * 前へも次へも押せる唯一の形なので、端のページだけを見ても確かめられない。
+ * ページの先頭では分類を必ず出す(前の行は別のページにあり、繰り返しにならない)ことも
+ * ここでしか見えない——1ページ目の先頭は、繰り返しかどうかに関わらず先頭行だから。
+ */
+export const Page2: Story = {
+ render: () => ,
+}
+
+/**
+ * 最後のページ。43件を10件ずつで割ると3件しか残らないので、
+ * 一覧が短くなってもページ送りの行が上がってこないことを確かめる。
+ * 「次へ」は消さずに薄くする。
+ */
+export const LastPage: Story = {
+ render: () => ,
+}
+
/** 1件も無いとき。ページ送りが出ないことと、空の見え方を確かめる。 */
export const Empty: Story = {
render: () => ,
diff --git a/src/client/screens/interrogation-log.ts b/src/client/screens/interrogation-log.ts
new file mode 100644
index 0000000..7f2be8e
--- /dev/null
+++ b/src/client/screens/interrogation-log.ts
@@ -0,0 +1,239 @@
+/**
+ * 聞き込みの会話ログを組み立てる、純粋な部分。
+ *
+ * 画面(InterrogationScreen)は表示に専念させたいので、
+ * 「誰の言葉をどの順で、どこで段落に割るか」はここに寄せてある。
+ * この画面からしか呼ばれない。
+ */
+import type { ChatTurn } from '@/client/hooks/useInterrogation'
+import { splitParagraphs } from '@/client/lib/paragraphs'
+
+/** `HH:mm` を分に直す。時刻軸の位置取りと、会話のなかの時刻合わせに使う。 */
+export const toMinutes = (hhmm: string): number => {
+ const [h, m] = hhmm.split(':')
+
+ return h === undefined || m === undefined ? 0 : Number(h) * 60 + Number(m)
+}
+
+/* ---- 段落 ---- */
+
+/** 書き終わった最後の一文の終わり。閉じ括弧は句点の後ろに付いてくるので、そこまで含める。 */
+const settledEnd = (text: string): number => {
+ const found = Array.from(text.matchAll(/[。!?][」』)】]*/g))
+ const last = found[found.length - 1]
+
+ return last === undefined || last.index === undefined ? 0 : last.index + last[0].length
+}
+
+/**
+ * 返答を、画面に置く段落の並びに変える。
+ *
+ * 書き手が入れた改行は呼吸なので、そこは必ず割る。そのうえで、長すぎる段落だけを
+ * 文の切れ目で割る(splitParagraphs)。一文ごとに割ると、二言三言の受け答えが
+ * 段落の数だけ間延びして、ひと続きの言葉に聞こえなくなる。
+ *
+ * `streaming` のあいだは、書きかけの一文を伏せる。届いたそばから出すと、
+ * 読むより先に目が字を追いはじめる。
+ */
+export const paragraphsOf = (text: string, streaming: boolean): string[] =>
+ splitParagraphs((streaming ? text.slice(0, settledEnd(text)) : text).split(/\n+/).join('\n\n'))
+
+/* ---- 会話のなかの確定時刻 ---- */
+
+const KANJI_DIGITS = new Map([
+ ['〇', 0],
+ ['零', 0],
+ ['一', 1],
+ ['二', 2],
+ ['三', 3],
+ ['四', 4],
+ ['五', 5],
+ ['六', 6],
+ ['七', 7],
+ ['八', 8],
+ ['九', 9],
+])
+
+/** 「六時二十三分」「午後七時八分」「19:08」。時だけ・分だけの言い回しは拾わない。 */
+const TIME_PATTERN =
+ /(?:午前|午後)?(?:[〇零一二三四五六七八九十]+|\d{1,2})時(?:[〇零一二三四五六七八九十]+|\d{1,2})分|\d{1,2}:\d{2}/g
+
+const kanjiNumber = (text: string): number => {
+ const ten = text.indexOf('十')
+
+ if (ten === -1) {
+ return Array.from(text).reduce((sum, char) => {
+ const digit = KANJI_DIGITS.get(char)
+
+ return sum * 10 + (digit === undefined ? 0 : digit)
+ }, 0)
+ }
+
+ const upper = ten === 0 ? 1 : kanjiNumber(text.slice(0, ten))
+ const lower = text.slice(ten + 1)
+
+ return upper * 10 + (lower.length === 0 ? 0 : kanjiNumber(lower))
+}
+
+const numberOf = (text: string): number => (/^\d+$/.test(text) ? Number(text) : kanjiNumber(text))
+
+/** 表記を分に直す。午前・午後は読まない——時計回りの12時間ぶんは呼ぶ側で試す。 */
+const minutesOfExpression = (text: string): number | undefined => {
+ const body = text.replace(/^(?:午前|午後)/, '')
+ const [hh, mm] = body.includes(':') ? body.split(':') : body.replace(/分$/, '').split('時')
+
+ return hh === undefined || mm === undefined ? undefined : numberOf(hh) * 60 + numberOf(mm)
+}
+
+const HALF_DAY_MINUTES = 12 * 60
+
+/** at は文の中での位置。同じ字面が二度出ても鍵がぶつからない。 */
+export type Piece = { at: number; text: string; ink: string | undefined }
+
+/**
+ * 確定した時刻を、その時刻が立つ列の顔料で染める。
+ *
+ * 喋っている人の色ではない。「六時二十三分に来た」と牧野が言っても、
+ * その線が立つのは黒田の列なので、字も黒田の色になる。
+ */
+export const tintTimes = (text: string, inks: Map): Piece[] => {
+ const found = Array.from(text.matchAll(TIME_PATTERN))
+ const marked = found.reduce<{ pieces: Piece[]; at: number }>(
+ (acc, match) => {
+ const minutes = minutesOfExpression(match[0])
+
+ if (minutes === undefined || match.index === undefined) {
+ return acc
+ }
+
+ const noon = inks.get(minutes)
+ const ink = noon === undefined ? inks.get(minutes + HALF_DAY_MINUTES) : noon
+
+ return ink === undefined
+ ? acc
+ : {
+ pieces: [
+ ...acc.pieces,
+ { at: acc.at, text: text.slice(acc.at, match.index), ink: undefined },
+ { at: match.index, text: match[0], ink },
+ ],
+ at: match.index + match[0].length,
+ }
+ },
+ { pieces: [], at: 0 },
+ )
+
+ return [...marked.pieces, { at: marked.at, text: text.slice(marked.at), ink: undefined }]
+}
+
+/* ---- 会話の塊 ---- */
+
+/** who は登場順の添字。探偵は列を持たないので -1。 */
+export type Block = {
+ id: string
+ who: number
+ name: string
+ /** 名前の色と縦罫。人は登場順の顔料、場所は灰、探偵は罫線と同じ色。 */
+ ink: string
+ edge: string
+ lines: { id: string; text: string }[]
+}
+
+/** 塊を作るのに要る、相手の最小限。 */
+export type Speaker = { id: string; logName: string; ink: string; edge: string }
+
+/**
+ * 相手ごとに分かれている会話を、一本の時系列に並べ直して塊にまとめる。
+ *
+ * 画面に映るログは一本きり。誰に何を聞いたかが順に流れるので、
+ * 相手を切り替えても読んでいた場所が消えない。
+ */
+export const buildBlocks = (
+ /** 話題を投げられる相手。被害者と場所を含むので ScenarioDetail['characters'] より広い。 */
+ characters: Speaker[],
+ conversations: Record,
+ askerName: string,
+ /** 今まさに返答が流れてきている相手。書きかけの一文を伏せるのに要る。 */
+ askingCharacterId: string | undefined,
+): Block[] => {
+ const said = characters.flatMap((character, index) => {
+ const turns = conversations[character.id]
+ // 流れている最中なのは、訊いている相手の末尾の返答だけ。
+ const streamingSeq =
+ character.id === askingCharacterId && turns !== undefined ? turns.length - 1 : -1
+
+ return (
+ (turns === undefined ? [] : turns)
+ .map((turn, seq) => ({
+ turn,
+ seq,
+ index,
+ name: character.logName,
+ ink: character.ink,
+ edge: character.edge,
+ streaming: seq === streamingSeq && turn.role === 'assistant',
+ }))
+ // 話題はプレイヤーの指示であって発言ではない。探偵が投げた質問のほうが残る。
+ .filter(({ turn }) => turn.role !== 'topic' && turn.text.length > 0)
+ )
+ })
+
+ const ordered = [...said].sort((a, b) =>
+ a.turn.askedAt === b.turn.askedAt ? a.seq - b.seq : a.turn.askedAt - b.turn.askedAt,
+ )
+ const blocks: Block[] = []
+
+ for (const item of ordered) {
+ const who = item.turn.role === 'user' ? -1 : item.index
+ const id = `${item.index}:${item.turn.id}`
+ const lines = paragraphsOf(item.turn.text, item.streaming).map((text, at) => ({
+ id: `${id}:${at}`,
+ text,
+ }))
+
+ // 一文目が出来上がるまでは何も置かない。名前だけ先に出ると、
+ // 誰かが口を開いたまま黙っているように見える。
+ if (lines.length === 0) {
+ continue
+ }
+
+ const last = blocks[blocks.length - 1]
+
+ // 同じ人が続けて喋るあいだ、名前は一度きり。縦罫だけが最後まで伸びる。
+ if (last !== undefined && last.who === who) {
+ last.lines.push(...lines)
+ continue
+ }
+
+ blocks.push({
+ id,
+ who,
+ name: who === -1 ? askerName : item.name,
+ ink: who === -1 ? 'text-nezumi-dim' : item.ink,
+ edge: who === -1 ? 'border-keisen' : item.edge,
+ lines,
+ })
+ }
+
+ return blocks
+}
+
+/**
+ * 出してよい行数まで塊を切り詰める。
+ *
+ * 塊ごとではなく通しで数えるので、探偵の質問と相手の一文目のあいだにも間が入る。
+ * 行が一つも残らない塊は落とす——名前だけが立って、口を開けたまま黙っているように
+ * 見えるのを避けるため。
+ */
+export const capLines = (blocks: Block[], limit: number): Block[] =>
+ blocks.reduce<{ left: number; kept: Block[] }>(
+ (acc, block) => {
+ const lines = block.lines.slice(0, acc.left)
+
+ return {
+ left: acc.left - lines.length,
+ kept: lines.length === 0 ? acc.kept : [...acc.kept, { ...block, lines }],
+ }
+ },
+ { left: limit, kept: [] },
+ ).kept
diff --git a/src/client/stories/fixtures.ts b/src/client/stories/fixtures.ts
index 88f1f9b..e6dc70e 100644
--- a/src/client/stories/fixtures.ts
+++ b/src/client/stories/fixtures.ts
@@ -26,10 +26,18 @@ export const SCENARIO: ScenarioDetail = {
difficulty: 2,
estimatedMinutes: 10,
synopsis: '閉店後の一時間。この六十分を、誰かひとりだけが説明しきれずにいます。',
+ /*
+ * 文面は mocks/_case.js の `briefLong` に揃えてある。
+ *
+ * モックは事件の記録を二本持つ——読み上げ用の `brief`(三段落)と、
+ * 支度の机で読みながら選ぶための `briefLong`(三人の申し立てを一段落ぶん足したもの)。
+ * こちらの `ScenarioDetail` は `briefing` 一本なので、その使い分けは再現できない。
+ * 長いほうに寄せてあるぶん、読み上げの画面だけモックより一段落多い。
+ */
briefing: [
'——事件の記録を読み上げます。',
'午後七時十五分、商店街の古書店「青雨堂」で、店主の水野英治が店の奥で死亡しているのが見つかりました。外は夕方から激しい雨。閉店時刻は午後六時半でしたが、店内には高価な初版本の商談があり、何人かが遅くまで出入りしていました。',
- '事件に関わるのは三人です。店員の牧野千尋、常連の収集家・黒田征司、向かいの喫茶店主・瀬名真琴。',
+ '事件に関わるのは三人です。店員の牧野千尋、常連の収集家・黒田征司、向かいの喫茶店主・瀬名真琴。牧野は「六時三十五分には発送のため郵便局へ出た」と話し、黒田は「閉店後は店の奥へ入っていない」と言い、瀬名は「店内の揉め事は何も知らない」と答えています。',
'雨の日の商店街では、誰がどこにいたかが意外によく見えます。時刻の入った記録と三人の証言を照らし合わせ、嘘の理由と事件の真相を分けて考えてください。',
].join('\n\n'),
floorPlan: null,
@@ -64,9 +72,24 @@ export const SCENARIO: ScenarioDetail = {
},
],
characters: [
- { id: MAKINO, name: '牧野千尋', publicIntroduction: '店員。書誌と発送手順には強い' },
- { id: KURODA, name: '黒田征司', publicIntroduction: '収集家。初版本の商談に来ていた' },
- { id: SENA, name: '瀬名真琴', publicIntroduction: '向かいの喫茶店主。雨脚を見ていた' },
+ {
+ id: MAKINO,
+ name: '牧野千尋',
+ shortName: '牧野',
+ publicIntroduction: '店員。書誌と発送手順には強い',
+ },
+ {
+ id: KURODA,
+ name: '黒田征司',
+ shortName: '黒田',
+ publicIntroduction: '収集家。初版本の商談に来ていた',
+ },
+ {
+ id: SENA,
+ name: '瀬名真琴',
+ shortName: '瀬名',
+ publicIntroduction: '向かいの喫茶店主。雨脚を見ていた',
+ },
],
}
diff --git a/src/server/read/scenarios.ts b/src/server/read/scenarios.ts
index ccd11e1..b619963 100644
--- a/src/server/read/scenarios.ts
+++ b/src/server/read/scenarios.ts
@@ -64,7 +64,7 @@ export type ScenarioDetail = {
floorPlan: FloorPlan | null
difficulty: number
estimatedMinutes: number
- characters: { id: string; name: string; publicIntroduction: string }[]
+ characters: { id: string; name: string; shortName: string; publicIntroduction: string }[]
}
const PUBLIC_INTRODUCTION_FALLBACK = 'この事件の関係者。'
@@ -82,6 +82,19 @@ export const normalizePublicIntroduction = (value: string): string => {
: trimmed
}
+/**
+ * 短い名前が使える形になっているか見て、駄目ならフルネームへ倒す。
+ *
+ * 空になり得るのは、この列より前に焼かれて移行の埋めも通っていない行。
+ * `short_name` という文字列そのものが返るのは publicIntroduction と同じ事情で、
+ * migration がコードより遅れているときに SQLite の DQS 互換挙動が起こす。
+ * どちらもフルネームなら帯が窮屈になるだけで、表示は壊れない。
+ */
+export const normalizeShortName = (value: string, name: string): string => {
+ const trimmed = value.trim()
+ return trimmed === '' || trimmed === 'short_name' ? name : trimmed
+}
+
/**
* characters.id はシナリオ投入時に独立に採番される UUID。
* UUID の辞書順を表示順として使えば、作者が characters 配列へ書いた順序(犯人を
@@ -152,12 +165,14 @@ export const findScenarioDetail = async (
.select({
id: characters.id,
name: characters.name,
+ shortName: characters.shortName,
publicIntroduction: characters.publicIntroduction,
})
.from(characters)
.where(eq(characters.scenarioId, scenarioId))
).map((character) => ({
...character,
+ shortName: normalizeShortName(character.shortName, character.name),
publicIntroduction: normalizePublicIntroduction(character.publicIntroduction),
})),
)
From 322681dbbb0e9f03683187f7b3d9a85835051a33 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Tue, 8 Sep 2026 01:31:42 +0000
Subject: [PATCH 11/41] ui(screens): add character shortName rail and expand
mock-match story coverage
Add character short-name field for narrow-width UI (interrogation rail), pad
formatSeconds to two digits so timers stay fixed-width, and add stories for
accusation filled-state, interrogation first-turn/clash, and case-overview
difficulty pass-through to support mock-to-implementation comparison.
Co-Authored-By: Claude
---
__tests__/client/format.test.ts | 11 ++-
docs/scenario-authoring.md | 5 ++
scripts/compare-int-crop.mjs | 78 +++++++++++++++++++
.../screens/AccusationScreen.stories.tsx | 40 +++++++++-
src/client/screens/AccusationScreen.tsx | 3 +-
.../screens/CaseOverviewScreen.stories.tsx | 23 ++++++
.../screens/InterrogationScreen.stories.tsx | 37 +++++++++
.../screens/ScenarioSelectScreen.stories.tsx | 3 +
8 files changed, 195 insertions(+), 5 deletions(-)
create mode 100644 scripts/compare-int-crop.mjs
diff --git a/__tests__/client/format.test.ts b/__tests__/client/format.test.ts
index edca938..deca71d 100644
--- a/__tests__/client/format.test.ts
+++ b/__tests__/client/format.test.ts
@@ -4,16 +4,21 @@ import { formatClock, formatSeconds } from '@/client/lib/format'
describe('formatSeconds', () => {
test('分と秒に分ける', () => {
- expect(formatSeconds(65)).toBe('1:05')
+ expect(formatSeconds(65)).toBe('01:05')
})
test('1分未満は0分として出す', () => {
- expect(formatSeconds(9)).toBe('0:09')
+ expect(formatSeconds(9)).toBe('00:09')
})
- test('秒はゼロ詰めする', () => {
+ test('分も秒もゼロ詰めする', () => {
expect(formatSeconds(600)).toBe('10:00')
})
+
+ // 計器として等幅で並ぶので、桁が増えても左へ伸びるだけで秒の位置は動かない。
+ test('百分を超えても桁を落とさない', () => {
+ expect(formatSeconds(6_000)).toBe('100:00')
+ })
})
describe('formatClock', () => {
diff --git a/docs/scenario-authoring.md b/docs/scenario-authoring.md
index d954151..68e9a9d 100644
--- a/docs/scenario-authoring.md
+++ b/docs/scenario-authoring.md
@@ -248,6 +248,7 @@ evidences:
characters:
- id: fukagawa
name: 深川誠也
+ shortName: 深川 # 省略可。8字まで。アリバイ表の帯に出る
publicIntroduction: 月見荘の経理を長年任されている税理士。 # プレイヤーへ最初から公開
personality: 気弱で愛想笑いが多い税理士。追い詰められると目が泳ぐ。 # NPC内部用、非公開
goals:
@@ -318,6 +319,10 @@ evidences:
場所を調べるのも質問1回ぶんを使います。三択になるぶん、場所を増やしすぎると聞き込みが痩せるので、
事件の芯に関わるものだけに絞ってください。遺体と同じで、場所は**逃げも黙秘もしない情報源**です。
+`shortName` は幅の狭いところ——アリバイ表の帯や名札——へ出す短い名前です。姓だけ、渾名だけ。
+省略できます(書かなければ `name` がそのまま入ります)。姓を機械的に切り出すことはしません。
+一文字姓や外国名では切る位置が決まらないので、書く人が決めてください。
+
### `publicIntroduction` と `personality`
`publicIntroduction` は**プレイヤーへ事件開始前から見せる人物紹介**です。名前と一緒に概要画面・聞き込み画面へ表示されます。
diff --git a/scripts/compare-int-crop.mjs b/scripts/compare-int-crop.mjs
new file mode 100644
index 0000000..7e96b0a
--- /dev/null
+++ b/scripts/compare-int-crop.mjs
@@ -0,0 +1,78 @@
+/*
+ * 聞き込みのモックと実装を、同じ切り取りで上下に並べて撮る。
+ * 三枚並びでは字が潰れて読めないので、細部を見るときだけ使う。
+ *
+ * 使い方: bun scripts/compare-int-crop.mjs [x,y,w,h]
+ */
+import { createServer } from 'node:http'
+import { existsSync, mkdirSync, readFileSync } from 'node:fs'
+import { extname, join } from 'node:path'
+import { chromium } from 'playwright'
+
+const root = process.cwd()
+const dir = join(root, 'storybook-static/intstate')
+const mime = {
+ '.html': 'text/html',
+ '.js': 'text/javascript',
+ '.mjs': 'text/javascript',
+ '.css': 'text/css',
+ '.json': 'application/json',
+ '.png': 'image/png',
+ '.svg': 'image/svg+xml',
+ '.woff2': 'font/woff2',
+}
+const { server, url } = await new Promise((resolve) => {
+ const s = createServer((req, res) => {
+ const p = decodeURIComponent(req.url.split('?')[0])
+ const f = join(dir, p === '/' ? '/index.html' : p)
+ if (!f.startsWith(dir) || !existsSync(f)) return res.writeHead(404).end('x')
+ res.writeHead(200, { 'content-type': mime[extname(f)] ?? 'application/octet-stream' })
+ res.end(readFileSync(f))
+ })
+ s.listen(0, '127.0.0.1', () => resolve({ server: s, url: `http://127.0.0.1:${s.address().port}` }))
+})
+
+const viewName = process.argv[2] ?? 'desktop'
+const storyId = process.argv[3]
+const hash = process.argv[4] ?? ''
+const clipArg = process.argv[5] ?? ''
+const out2 = process.argv[6] ?? 'crop.png'
+const view =
+ viewName === 'mobile'
+ ? { w: 390, h: 844, dir: 'mocks/mobile' }
+ : { w: 1440, h: 900, dir: 'mocks/desktop' }
+const clip = clipArg
+ ? (([x, y, w, h]) => ({ x, y, width: w, height: h }))(clipArg.split(',').map(Number))
+ : undefined
+
+const browser = await chromium.launch()
+const out = join(root, 'screenshots/match')
+mkdirSync(out, { recursive: true })
+
+const page = await browser.newPage({
+ viewport: { width: view.w, height: view.h },
+ deviceScaleFactor: 2,
+ reducedMotion: 'reduce',
+})
+await page.goto(`file://${join(root, view.dir, 'interrogation.html')}${hash ? `#${hash}` : ''}`, {
+ waitUntil: 'networkidle',
+})
+await page.evaluate(() => document.fonts.ready)
+await page.waitForTimeout(400)
+const a = (await page.screenshot({ clip })).toString('base64')
+
+await page.goto(`${url}/iframe.html?id=${storyId}&viewMode=story`, { waitUntil: 'networkidle' })
+await page.evaluate(() => document.fonts.ready)
+await page.waitForFunction(() => document.querySelector('#storybook-root')?.childElementCount > 0)
+await page.waitForTimeout(700)
+const b = (await page.screenshot({ clip })).toString('base64')
+
+const sheet = await browser.newPage({ viewport: { width: 3400, height: 3400 }, deviceScaleFactor: 1 })
+await sheet.setContent(`
+MOCK

IMPL

`)
+await sheet.waitForTimeout(200)
+await sheet.locator('.row').screenshot({ path: join(out, out2) })
+await browser.close()
+server.close()
+console.log(join(out, out2))
diff --git a/src/client/screens/AccusationScreen.stories.tsx b/src/client/screens/AccusationScreen.stories.tsx
index 5682612..dd8e00c 100644
--- a/src/client/screens/AccusationScreen.stories.tsx
+++ b/src/client/screens/AccusationScreen.stories.tsx
@@ -103,6 +103,44 @@ type Story = StoryObj
export const Default: Story = {
render: () => ,
play: ({ canvasElement }) => {
- canvasElement.querySelector('input')?.click()
+ pickFirstSuspect(canvasElement)
},
}
+
+/**
+ * 書き上げたところ。指名も二つの欄も埋まり、提出だけが残っている。
+ *
+ * 中身は真相そのもの——モックが `#filled=1` で入れているのと同じ文面にしてある。
+ * 当てずっぽうの下書きを入れると、欄の長さも改行位置もモックと違う絵になり、
+ * 突き合わせる意味が薄れる。
+ */
+export const Filled: Story = {
+ render: () => ,
+ play: ({ canvasElement }) => {
+ pickFirstSuspect(canvasElement)
+
+ const areas = canvasElement.querySelectorAll('textarea')
+
+ write(areas[0], '帳場の奥で、書架の支柱で殴打した')
+ write(areas[1], '初版本のすり替えを水野に気づかれ、問い詰められたため')
+ },
+}
+
+/** 先頭の一人を指す。素のラジオを直に押すので、状態は本物と同じ道を通る。 */
+const pickFirstSuspect = (canvasElement: HTMLElement) => {
+ canvasElement.querySelector('input')?.click()
+}
+
+/**
+ * 欄に文字を入れる。
+ * React は value を自前の記述子で握っているので、`node.value = …` では onChange が起きない。
+ * prototype 側の setter を通してから input を投げると、本物の入力と同じ経路になる。
+ */
+const write = (node: HTMLTextAreaElement | undefined, text: string) => {
+ if (node === undefined) {
+ return
+ }
+
+ Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set?.call(node, text)
+ node.dispatchEvent(new Event('input', { bubbles: true }))
+}
diff --git a/src/client/screens/AccusationScreen.tsx b/src/client/screens/AccusationScreen.tsx
index 8d9f9d9..e7b7777 100644
--- a/src/client/screens/AccusationScreen.tsx
+++ b/src/client/screens/AccusationScreen.tsx
@@ -321,7 +321,8 @@ export const AccusationScreen = ({
- {character.name}
+ {/* 帯の左端に置く名。段の幅は 3px しかないので、名字だけに畳む。 */}
+ {character.shortName}
{segments
.filter((segment) => segment.who === character.id)
diff --git a/src/client/screens/CaseOverviewScreen.stories.tsx b/src/client/screens/CaseOverviewScreen.stories.tsx
index 42a8d8a..b373ed1 100644
--- a/src/client/screens/CaseOverviewScreen.stories.tsx
+++ b/src/client/screens/CaseOverviewScreen.stories.tsx
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
+import { DEFAULT_GAME_MODE, saveGameMode } from '@/client/lib/game-mode-store'
import type { InvestigablePlace } from '@/client/lib/schemas'
import { CaseOverviewScreen } from '@/client/screens/CaseOverviewScreen'
import { SCENARIO } from '@/client/stories/fixtures'
@@ -44,6 +45,28 @@ type Story = StoryObj
export const Default: Story = {}
+/**
+ * 難易度に「手がかりを出さない」を選んだ状態。
+ *
+ * 絵は Default と変わらない。支度は難易度を表に出す場所を一つも持たないからで、
+ * モックも `#mode=nohope` と `#mode=normal` が一枚の絵として同じものになる
+ * (難易度を選ぶのは事件を選ぶ画面へ移した)。この画面が難易度に触れるのは、
+ * 開始時にセッションへ渡す一点だけ——残り件数の出し分けは聞き込みの側の仕事で、
+ * まだセッションを持たないここには現れようがない。
+ *
+ * それでも状態として残すのは、「同じ絵になる」ことを確かめておきたいから。
+ * 難易度がうっかり支度の見た目に漏れ出したら、この対で気づける。
+ */
+export const NoHope: Story = {
+ // 既定の自動命名は「No Hope」と割れる。対応表はこの名で引くので、明示しておく。
+ name: 'NoHope',
+ beforeEach: () => {
+ saveGameMode('nohope')
+
+ return () => saveGameMode(DEFAULT_GAME_MODE)
+ },
+}
+
/**
* 聞き込みから戻ってきたところ。進行中のセッションがあるあいだは
* 新しく立て直さないので、操作の並びが変わる。
diff --git a/src/client/screens/InterrogationScreen.stories.tsx b/src/client/screens/InterrogationScreen.stories.tsx
index fb44c9d..920abdf 100644
--- a/src/client/screens/InterrogationScreen.stories.tsx
+++ b/src/client/screens/InterrogationScreen.stories.tsx
@@ -549,6 +549,43 @@ export const Default: Story = {
),
}
+/**
+ * 初手。一往復だけ交わし、表には裏付けの取れた線が一本だけ立っている。
+ *
+ * 白紙に近い盤面がどう見えるかを押さえる story——線が一本しか無いあいだの
+ * 目盛りの間合いと、まだ食い違いの架かっていない列のあいだを確かめる。
+ */
+export const FirstTurn: Story = {
+ // 既定では書き出し名を単語に割って「First Turn」になる。突き合わせの対応表が
+ // 見ているのは書き出し名そのものなので、ここで留める。
+ name: 'FirstTurn',
+ render: () => (
+
+ ),
+}
+
+/**
+ * 食い違いが立つところ。九手目で瀬名が「あの戸は一度も開かなかった」と言い、
+ * 牧野の申告と噛み合わなくなる。
+ *
+ * 表の上では、噛み合わない二人の列のあいだへ十八時三十六分の高さで線が一本架かり、
+ * 両端に目盛りが立つ。盤面にひとつだけの印なので、ここが立っているかどうかで
+ * 突き合わせの成否が決まる。
+ */
+export const Clash: Story = {
+ render: () => (
+
+ ),
+}
+
/** 名乗らずに始めたセッション。会話の聞き手が一般名詞に落ちる。 */
export const Anonymous: Story = {
render: () => ,
diff --git a/src/client/screens/ScenarioSelectScreen.stories.tsx b/src/client/screens/ScenarioSelectScreen.stories.tsx
index 24a9dad..bb14759 100644
--- a/src/client/screens/ScenarioSelectScreen.stories.tsx
+++ b/src/client/screens/ScenarioSelectScreen.stories.tsx
@@ -116,6 +116,8 @@ export const Default: Story = {
* ここでしか見えない——1ページ目の先頭は、繰り返しかどうかに関わらず先頭行だから。
*/
export const Page2: Story = {
+ // Storybook は export 名を「Page 2」と割ってしまう。対応表がこの綴りで引くので明示する。
+ name: 'Page2',
render: () => ,
}
@@ -125,6 +127,7 @@ export const Page2: Story = {
* 「次へ」は消さずに薄くする。
*/
export const LastPage: Story = {
+ name: 'LastPage',
render: () => ,
}
From dc4c3ce653aed6662c70c03a84f6ee1e14246113 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Tue, 8 Sep 2026 01:39:26 +0000
Subject: [PATCH 12/41] ui(screens): show shortName in interrogation rail and
add crop scripts
Use character/place shortName instead of full name in the interrogation
subject rail to avoid wrapping on narrow headers, and add throwaway
scripts for stacking/cropping mock-vs-impl screenshots for comparison.
Co-Authored-By: Claude
---
scripts/stack-panels.mjs | 26 ++++++++++++++
scripts/tmp-ovw-crop.mjs | 40 ++++++++++++++++++++++
src/client/screens/InterrogationScreen.tsx | 24 +++++++++----
3 files changed, 83 insertions(+), 7 deletions(-)
create mode 100644 scripts/stack-panels.mjs
create mode 100644 scripts/tmp-ovw-crop.mjs
diff --git a/scripts/stack-panels.mjs b/scripts/stack-panels.mjs
new file mode 100644
index 0000000..e73f3fb
--- /dev/null
+++ b/scripts/stack-panels.mjs
@@ -0,0 +1,26 @@
+/**
+ * 三枚並びの絵から、モック側と実装側の同じ矩形を切り出して上下に積む。
+ * 横に並んだままだと同じ場所を見比べにくいので、縦へ積み替えるためだけの使い捨て。
+ *
+ * bun scripts/stack-panels.mjs [zoom]
+ */
+import { readFileSync } from 'node:fs'
+import { chromium } from 'playwright'
+
+const [, , src, out, panelW, gap, top, x, y, w, h, zoom] = process.argv
+const n = (v) => Number(v)
+const z = zoom === undefined ? 1 : n(zoom)
+const b64 = readFileSync(src).toString('base64')
+const cell = (index) => `
+
+

+
`
+
+const browser = await chromium.launch()
+const page = await browser.newPage({ viewport: { width: n(w) * z, height: n(h) * z * 2 + 8 } })
+await page.setContent(
+ `${cell(0)}${cell(1)}`,
+)
+await page.screenshot({ path: out })
+await browser.close()
diff --git a/scripts/tmp-ovw-crop.mjs b/scripts/tmp-ovw-crop.mjs
new file mode 100644
index 0000000..0f9d3f3
--- /dev/null
+++ b/scripts/tmp-ovw-crop.mjs
@@ -0,0 +1,40 @@
+/* 使い捨て。モックと story を同寸で撮り、指定の区画だけ上下に並べて見る。 */
+import { mkdirSync } from 'node:fs'
+import { chromium } from 'playwright'
+
+const [id, view, ...rects] = process.argv.slice(2)
+const w = view === 'desktop' ? 1440 : 390
+const h = view === 'desktop' ? 900 : 844
+const dir = view === 'desktop' ? 'desktop' : 'mobile'
+
+const browser = await chromium.launch()
+const shoot = async (url) => {
+ const p = await browser.newPage({ viewport: { width: w, height: h }, reducedMotion: 'reduce' })
+ await p.goto(url, { waitUntil: 'networkidle' })
+ await p.evaluate(() => document.fonts.ready)
+ if (url.includes('iframe.html')) {
+ await p.waitForFunction(() => document.querySelector('#storybook-root')?.childElementCount > 0)
+ }
+ await p.waitForTimeout(900)
+ const s = (await p.screenshot()).toString('base64')
+ await p.close()
+ return s
+}
+const mock = await shoot(`file:///home/vscode/app/mocks/${dir}/case-overview.html#mode=nohope`)
+const impl = await shoot(`http://127.0.0.1:6099/iframe.html?id=${id}&viewMode=story`)
+
+mkdirSync('screenshots/crop', { recursive: true })
+for (const rect of rects) {
+ const [x, y, cw, ch] = rect.split(',').map(Number)
+ const page = await browser.newPage({ viewport: { width: cw + 20, height: ch * 2 + 60 }, deviceScaleFactor: 2 })
+ await page.setContent(`
+
+ mock
+ impl
`)
+ await page.waitForTimeout(300)
+ await page.screenshot({ path: `screenshots/crop/${view}-${rect.replace(/,/g, '_')}.png` })
+ await page.close()
+}
+await browser.close()
diff --git a/src/client/screens/InterrogationScreen.tsx b/src/client/screens/InterrogationScreen.tsx
index cf27bc7..3a6aad3 100644
--- a/src/client/screens/InterrogationScreen.tsx
+++ b/src/client/screens/InterrogationScreen.tsx
@@ -328,6 +328,11 @@ export const InterrogationScreen = ({
...scenario.characters.map((character, index) => ({
id: character.id,
name: character.name,
+ /*
+ * 相手を替える並びに出す名前。端末の上部バーは幅が無いので、三人並ぶと
+ * 姓名では折り返す。短い名前はサーバが必ず返すので、ここで姓を切り出さない。
+ */
+ shortName: character.shortName,
logName: character.name,
introduction: character.publicIntroduction,
ink: inkOf(index),
@@ -346,6 +351,8 @@ export const InterrogationScreen = ({
{
id: VICTIM_ID,
name: scenario.victim.name,
+ // 遺体には短い名前が無い(scenarioDetail の victim は name しか持たない)。
+ shortName: scenario.victim.name,
logName: '所見',
introduction: `被害者・${scenario.victim.introduction}`,
ink: inkOf(scenario.characters.length),
@@ -357,6 +364,7 @@ export const InterrogationScreen = ({
...places.map((place) => ({
id: place.id,
name: place.name,
+ shortName: place.shortName,
logName: '所見',
/*
* 名札の下に出すのは佇まいのほう。introduction は名簿に出す紹介で、
@@ -545,7 +553,7 @@ export const InterrogationScreen = ({
onClick={() => setActiveCharacterId(subject.id)}
className={`${switchClass} text-nezumi-dim hover:text-nezumi`}
>
- {subject.name}
+ {subject.shortName}
{subject.remaining === undefined ? null : (
あと {subject.remaining}
)}
@@ -682,17 +690,19 @@ export const InterrogationScreen = ({
)}
+ {/*
+ 見本・呼び名・意味を、それぞれ間合いを空けて並べる。呼び名と意味を
+ 一続きの字にすると、和字間隔ぶんしか離れず、どこまでが呼び名か読み取れない。
+ */}
-
- 実線 裏付けあり
-
+ 実線
+ 裏付けあり
-
- 破線 本人の申告のみ
-
+ 破線
+ 本人の申告のみ
From 5f12aa56f2c8ad01ff380f14f41af4d514d636a5 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Tue, 8 Sep 2026 01:57:25 +0000
Subject: [PATCH 13/41] ui: add hover states across screens and button variants
Bring pointer/desktop hover feedback to buttons, name rows, and interactive labels, using per-row ink colors and consistent sumi-2/kinari/nezumi tones so hover reads as an extension of the existing line-based design.
Co-Authored-By: Claude
---
src/client/components/ui/button.tsx | 22 ++++++++++++++++-----
src/client/screens/AccusationScreen.tsx | 8 ++++++--
src/client/screens/BriefingScreen.tsx | 2 +-
src/client/screens/CaseOverviewScreen.tsx | 9 ++++++++-
src/client/screens/DetectiveSetupScreen.tsx | 16 ++++++++++-----
src/client/screens/InterrogationScreen.tsx | 2 +-
src/client/screens/ResultScreen.tsx | 4 ++--
src/client/screens/SettingsScreen.tsx | 2 +-
8 files changed, 47 insertions(+), 18 deletions(-)
diff --git a/src/client/components/ui/button.tsx b/src/client/components/ui/button.tsx
index c3d5041..3498923 100644
--- a/src/client/components/ui/button.tsx
+++ b/src/client/components/ui/button.tsx
@@ -16,13 +16,25 @@ const buttonVariants = cva(
{
variants: {
variant: {
- /** 画面の主たる操作。事件を始める・推理を出す、といった一段目のボタン。 */
+ /**
+ * 画面の主たる操作。事件を始める・推理を出す、といった一段目のボタン。
+ *
+ * 触れたら地を一段だけ起こし、枠を生成りまで上げる。地を上げる幅は
+ * 押せる行と同じ sumi-2 で止める——濃くすると、罫線で組んだ画面のなかで
+ * ここだけ塗った札になる。
+ */
default:
- 'border border-nezumi-dim font-semibold tracking-widest text-kinari hover:border-nezumi',
- /** 取り返しのつかない操作。朱が出るのはこの variant だけ。 */
- destructive: 'border border-shu tracking-widest text-shu hover:border-shu',
+ 'border border-nezumi-dim font-semibold tracking-widest text-kinari hover:border-kinari hover:bg-sumi-2',
+ /**
+ * 取り返しのつかない操作。朱が出るのはこの variant だけ。
+ *
+ * 触れても枠は朱のまま動かさない。取り消せない一手が、触れただけで
+ * 他の操作と同じ顔になると、押す前の身構えがひとつ抜ける。
+ */
+ destructive: 'border border-shu tracking-widest text-shu hover:bg-sumi-2',
/** 沈めた枠。選ばれていない選択肢や、副次的な入口。 */
- outline: 'border border-sumi-3 text-nezumi-dim hover:border-nezumi-dim hover:text-nezumi',
+ outline:
+ 'border border-sumi-3 text-nezumi-dim hover:border-nezumi hover:bg-sumi-2 hover:text-kinari',
/** 丸いアイコンボタン(記・図・推)。 */
icon: 'rounded-full border border-keisen text-nezumi hover:border-nezumi-dim hover:text-kinari',
/** 枠なし。列に並ぶ項目そのものを押させるとき。 */
diff --git a/src/client/screens/AccusationScreen.tsx b/src/client/screens/AccusationScreen.tsx
index e7b7777..469b395 100644
--- a/src/client/screens/AccusationScreen.tsx
+++ b/src/client/screens/AccusationScreen.tsx
@@ -451,8 +451,12 @@ export const AccusationScreen = ({
return (
-
+
@@ -288,7 +288,7 @@ const SOUND_CHOICES: readonly Choice[] = [
/**
* 二択の行。難易度の四択と同じ組みで、塗りつぶさずに選んだものだけ罫線と字を起こす。
*
- * 触れないときは「提供元が決まるまでモデルは触れない」のと同じ扱い——枠を地に沈めて、
+ * 触れないときは「一覧が空のあいだモデルは触れない」のと同じ扱い——枠を地に沈めて、
* 押せないことを枠と色で言う。灰色にするだけだと、ただの飾りに見える。
*/
const ChoiceRow = ({
@@ -378,35 +378,52 @@ const Budget = ({ settings, max }: { settings: Settings; max: number }) => (
)
-/** 鍵が入っていない提供元は選べない。理由を書かないと、灰色の行が故障に見える。 */
-const UnavailableNote = ({ catalog }: { catalog: LlmSettingsResponse }) => {
- const missing = catalog.providers.filter((entry) => !entry.available)
-
- if (missing.length === 0) {
+/**
+ * 一覧が空のときの断り書き。理由を書かないと、選べない欄が故障に見える。
+ *
+ * 「鍵が無い」と「モデルサーバに繋がらない」は画面からは区別が付かない
+ * ——応答はどちらも空の一覧で、切り分けに要る情報はサーバの外に出さない。
+ * 両方を並べて書くのは、遊ぶ人が直せるのは前者だけだから。
+ */
+const UnavailableNote = ({ models }: { models: LlmSettingsResponse['models'] }) => {
+ if (models.length > 0) {
return undefined
}
return (
- {missing.map((entry) => entry.label).join('・')} は APIキーが未設定のため選べません。
+ モデルの一覧が空です。APIキーが未設定か、モデルサーバに繋がっていません。
)
}
+/**
+ * 選択肢の並び。
+ *
+ * 保管庫にあるIDが一覧に無ければ、それも末尾に足す。落とすと選択欄が空欄になり、
+ * 選んだはずのモデルが消えたように見える——モデルサーバの構成が変わると起きる。
+ * ラベルにIDをそのまま出すのは、一覧に無い以上、表示名を知る術が無いため。
+ */
+const modelChoices = (
+ models: LlmSettingsResponse['models'],
+ chosen: string | undefined,
+): LlmSettingsResponse['models'] =>
+ chosen === undefined || models.some((model) => model.id === chosen)
+ ? models
+ : [...models, { id: chosen, label: chosen }]
+
const RoleFields = ({
role,
- catalog,
+ models,
value,
onChange,
}: {
role: LlmSettingsResponse['roles'][number]
- catalog: LlmSettingsResponse
+ models: LlmSettingsResponse['models']
value: RoleSetting | undefined
onChange: (next: RoleSetting | undefined) => void
}) => {
- const provider = value?.provider
- const models =
- provider === undefined ? [] : catalog.providers.find((entry) => entry.id === provider)?.models
+ const choices = modelChoices(models, value?.model)
return (
// 広い画面ではラベル左・操作右の一行。左を固定幅にしてあるのは、役割名の長短で
@@ -421,62 +438,40 @@ const RoleFields = ({
-
- {/*
- Select の外側が label ではなく div なのは、SelectTrigger が label と
- 結びつく種類の要素ではないため。名前は aria-label で渡す。
- */}
-
- 提供元
-
-
+ {/*
+ Select の外側が label ではなく div なのは、SelectTrigger が label と
+ 結びつく種類の要素ではないため。名前は aria-label で渡す。
-
-
モデル
-
)
@@ -486,27 +481,16 @@ const RoleFields = ({
* 選択欄の見え方。
*
* 選んでいないときは字を沈める。「既定のまま」は値ではなく値が無いことの名前なので、
- * 選んだ提供元と同じ明るさで並ぶと、二つの状態が見分けられなくなる。
- * 触れない欄(提供元が決まる前のモデル)は部品側の既定に任せる——枠も字も一緒に沈む。
+ * 選んだモデルと同じ明るさで並ぶと、二つの状態が見分けられなくなる。
+ * 触れない欄(一覧が空のとき)は部品側の既定に任せる——枠も字も一緒に沈む。
* ここで字色まで重ねて沈めると、部品の disabled 時の不透明度と二重にかかって
* 沈みすぎる(枠だけ見えて字が消える)ので、disabled のときは色を足さない。
*/
-const triggerClass = (chosen: string | undefined, disabled = false): string =>
+const triggerClass = (chosen: string | undefined, disabled: boolean): string =>
`${FIELD_BOX} lg:text-[12.5px] [&_svg]:size-3 ${
!disabled && chosen === undefined ? 'text-nezumi-dim lg:text-nezumi' : ''
}`
-/** 応答に無い提供元は選ばせない。型を通すためだけの分岐ではなく、実際の番人。 */
-const pickProvider = (catalog: LlmSettingsResponse, value: string): LlmProvider => {
- const found = catalog.providers.find((entry) => entry.id === value)
-
- if (found === undefined) {
- throw new Error(`未知の提供元: ${value}`)
- }
-
- return found.id
-}
-
/**
* 数値欄。空にされたときに NaN を書き込まないよう、読めた値だけを通す。
*/
diff --git a/src/client/stories/fixtures.ts b/src/client/stories/fixtures.ts
index e6dc70e..b8560e9 100644
--- a/src/client/stories/fixtures.ts
+++ b/src/client/stories/fixtures.ts
@@ -346,30 +346,12 @@ export const DETECTIVES: DetectiveStore = {
activeId: 'akari',
}
-/** Google だけキー未設定。選べない提供元がある状態を見る。 */
+/** 互換サーバが数種類を返している状態。名前の付け方はサーバ次第なので、揃えない。 */
export const LLM_SETTINGS: LlmSettingsResponse = {
- providers: [
- {
- id: 'anthropic',
- label: 'Anthropic',
- available: true,
- models: [
- { id: 'claude-sonnet-5', label: 'Claude Sonnet 5' },
- { id: 'claude-opus-5', label: 'Claude Opus 5' },
- ],
- },
- {
- id: 'openai',
- label: 'OpenAI',
- available: true,
- models: [{ id: 'gpt-5', label: 'GPT-5' }],
- },
- {
- id: 'google',
- label: 'Google',
- available: false,
- models: [{ id: 'gemini-3-pro', label: 'Gemini 3 Pro' }],
- },
+ models: [
+ { id: 'gpt-5.6-terra', label: 'gpt-5.6-terra' },
+ { id: 'gpt-5.6-luna', label: 'gpt-5.6-luna' },
+ { id: 'claude-sonnet-5', label: 'claude-sonnet-5' },
],
roles: [
{ id: 'actor', label: '会話', note: 'NPCの受け答えと、探偵が組み立てる質問' },
diff --git a/src/server/env.ts b/src/server/env.ts
index fe7170c..2c9013e 100644
--- a/src/server/env.ts
+++ b/src/server/env.ts
@@ -1,7 +1,6 @@
import { z } from 'zod'
import type { PlaySession } from '@/server/do/play-session'
import type { RateLimiter } from '@/server/do/rate-limiter'
-import { llmProviderSchema } from '~/db/llm-catalog'
/**
* Workers のバインディング。
@@ -54,13 +53,8 @@ const optionalString = z.preprocess(
* 未設定のまま動きだして、プレイ中に初めて落ちる事故を防ぐ。
*/
const schema = z.object({
- // 役割ごとに使うプロバイダ。3社を混在させてよい。
- // 値の正典は db/llm-catalog.ts。ここで列挙し直すと、選択肢と受け入れ値が静かにずれる。
- LLM_ACTOR_PROVIDER: llmProviderSchema.default('openai'),
- LLM_JUDGE_PROVIDER: llmProviderSchema.default('openai'),
- LLM_AUTHOR_PROVIDER: llmProviderSchema.default('openai'),
-
- // 明示するとプロバイダ既定のモデルIDを上書きできる。
+ // 役割ごとに使うモデル。未設定なら db/llm-catalog.ts の既定。
+ // 互換サーバ独自のモデル名もそのまま書ける(突き合わせる表を持たないため)。
LLM_ACTOR_MODEL: optionalString,
LLM_JUDGE_MODEL: optionalString,
LLM_AUTHOR_MODEL: optionalString,
diff --git a/src/server/llm/actor.ts b/src/server/llm/actor.ts
index f41dd08..35865a6 100644
--- a/src/server/llm/actor.ts
+++ b/src/server/llm/actor.ts
@@ -1,18 +1,18 @@
import { type ModelMessage, streamText } from 'ai'
import type { Env } from '@/server/env'
import { buildDetectiveBlock } from '@/server/llm/detective'
-import { type LlmChoice, resolveModel } from '@/server/llm/provider'
+import { resolveModel } from '@/server/llm/provider'
import type { Detective } from '~/db/detective'
export type ActorContext = {
/** リクエストスコープで検証済みの設定。APIキーとゲートウェイの出どころ。 */
env: Env
/**
- * この呼び出しで使う組み合わせ。呼び出し側がリクエストごとに一度だけ決めて渡す。
- * ここで役割から引き直さないこと——モデルとキャッシュ指定が別々に解決されると、
- * プロバイダを差し替えたときに片方だけ古い値のまま残る。
+ * この呼び出しで使うモデル。呼び出し側がリクエストごとに一度だけ決めて渡す。
+ * ここで役割から引き直さないこと——同じ往復の中で別々に解決すると、
+ * プレイヤーが設定を変えた瞬間に質問と返答が違うモデルで走る。
*/
- choice: LlmChoice
+ modelId: string
/** 全シナリオ共通のゲームルール。最も安定するのでプレフィックス先頭に置く。 */
gameRules: string
/** このNPCの人格・知識・秘密・目的・嘘・記憶。真相は絶対に含めない。 */
@@ -47,7 +47,7 @@ const buildDetectiveMessages = (detective: Detective | undefined): ModelMessage[
*/
export const streamNpcReply = ({
env,
- choice,
+ modelId,
gameRules,
characterSheet,
detective,
@@ -55,7 +55,7 @@ export const streamNpcReply = ({
utterance,
}: ActorContext) =>
streamText({
- model: resolveModel(env, choice),
+ model: resolveModel(env, modelId),
// system を messages 側に置くことを明示的に許可する。
//
// AI SDK は既定でこれを警告する(プレイヤー由来の文字列が system に混ざる経路を
diff --git a/src/server/llm/deduction.ts b/src/server/llm/deduction.ts
index 2069f6a..18785da 100644
--- a/src/server/llm/deduction.ts
+++ b/src/server/llm/deduction.ts
@@ -1,7 +1,7 @@
import { generateObject, type LanguageModelUsage, type ProviderMetadata } from 'ai'
import { z } from 'zod'
import type { Env } from '@/server/env'
-import { type LlmChoice, resolveModel } from '@/server/llm/provider'
+import { resolveModel } from '@/server/llm/provider'
/**
* 推理の採点結果。構造化出力なのでパース失敗を考えなくてよい。
@@ -45,7 +45,7 @@ export type GradeDeductionInput = {
/** リクエストスコープで検証済みの設定。 */
env: Env
/** この呼び出しで使う組み合わせ。役割から引き直さず、呼び出し側が決めた値を使う。 */
- choice: LlmChoice
+ modelId: string
truth: DeductionTruth
submission: DeductionSubmission
}
@@ -83,14 +83,14 @@ const rubricOf = (truth: DeductionTruth): string =>
/**
* プレイヤーの推理を採点する。
*
- * judge と役割を分けず 'judge' の choice に相乗りしているのは、
+ * judge と役割を分けず 'judge' のモデルに相乗りしているのは、
* LlmRole を増やすと env と wrangler vars に設定列が生えるため。
* 求めている性質(構造化出力・安価・高速)が judge とまったく同じなので、
* 設定を分ける実益がない。
*/
export const gradeDeduction = async ({
env,
- choice,
+ modelId,
truth,
submission,
}: GradeDeductionInput): Promise
=> {
@@ -104,7 +104,7 @@ export const gradeDeduction = async ({
].join('\n')
const result = await generateObject({
- model: resolveModel(env, choice),
+ model: resolveModel(env, modelId),
schema: deductionGradeSchema,
system: rubricOf(truth),
messages: [{ role: 'user', content: answer }],
diff --git a/src/server/llm/examiner.ts b/src/server/llm/examiner.ts
index ca316d8..2f089dd 100644
--- a/src/server/llm/examiner.ts
+++ b/src/server/llm/examiner.ts
@@ -8,7 +8,7 @@ import {
import type { Env } from '@/server/env'
import { buildDetectiveBlock, buildDetectiveSelfBlock } from '@/server/llm/detective'
import type { TopicExchange } from '@/server/llm/interviewer'
-import { type LlmChoice, resolveModel } from '@/server/llm/provider'
+import { resolveModel } from '@/server/llm/provider'
import type { Detective } from '~/db/detective'
export type ExaminationFocusResult = {
@@ -31,7 +31,7 @@ export type ExaminationFocusResult = {
*/
export const composeExaminationFocus = async (params: {
env: Env
- choice: LlmChoice
+ modelId: string
/** 何を調べに行くかの決まり。相手が遺体か場所かで文面が変わる。 */
intentRules: string
detective: Detective | undefined
@@ -42,7 +42,7 @@ export const composeExaminationFocus = async (params: {
}): Promise => {
// 調べどころはプレイヤー由来の文字列なので、必ず user ロールに閉じ込める。
const result = await generateText({
- model: resolveModel(params.env, params.choice),
+ model: resolveModel(params.env, params.modelId),
system:
params.detective === undefined
? params.intentRules
@@ -70,7 +70,7 @@ export const composeExaminationFocus = async (params: {
export type ExaminationContext = {
env: Env
- choice: LlmChoice
+ modelId: string
/**
* 検分の語り口の決まり。会話中変わらないので先頭に置く。
* 遺体なら `EXAMINATION_RULES`、場所なら `PLACE_EXAMINATION_RULES`。
@@ -100,7 +100,7 @@ const buildDetectiveMessages = (detective: Detective | undefined): ModelMessage[
*/
export const streamExamination = ({
env,
- choice,
+ modelId,
examinationRules,
sheet,
detective,
@@ -108,7 +108,7 @@ export const streamExamination = ({
utterance,
}: ExaminationContext) =>
streamText({
- model: resolveModel(env, choice),
+ model: resolveModel(env, modelId),
// 理由は streamNpcReply と同じ。前置きをブロックに分けて並べるため。
allowSystemInMessages: true,
messages: [
diff --git a/src/server/llm/interviewer.ts b/src/server/llm/interviewer.ts
index 3005ade..62c84cd 100644
--- a/src/server/llm/interviewer.ts
+++ b/src/server/llm/interviewer.ts
@@ -1,7 +1,7 @@
import { type ModelMessage, streamText } from 'ai'
import type { Env } from '@/server/env'
import { buildDetectiveSelfBlock } from '@/server/llm/detective'
-import { type LlmChoice, resolveModel } from '@/server/llm/provider'
+import { resolveModel } from '@/server/llm/provider'
import type { Detective } from '~/db/detective'
/**
@@ -25,7 +25,7 @@ export type TopicExchange = {
役割としては Actor と同じ「演じて喋る」仕事なので、モデルの選択も actor に相乗りする。
専用の役割を足すと env とデプロイ設定に列が増えるが、選ぶべき値は actor と同じになる。
- どの組み合わせを使うかは呼び出し側が決めて choice で渡す(src/server/routes/sessions.ts が
+ どのモデルを使うかは呼び出し側が決めて modelId で渡す(src/server/routes/sessions.ts が
actor 用に決めたものをそのまま寄越す)。ここで役割名から引き直さないので、
相乗りの事実はこのコメントと呼び出し側にだけ残る。
*/
@@ -43,7 +43,7 @@ export type InterviewerContext = {
/** リクエストスコープで検証済みの設定。 */
env: Env
/** この呼び出しで使う組み合わせ。役割から引き直さず、呼び出し側が決めた値を使う。 */
- choice: LlmChoice
+ modelId: string
/** プレイヤーが演じる探偵。名乗らずに始めることもできるので undefined を許す。 */
detective: Detective | undefined
/** 目の前の人物の名前。人物像や秘密は渡さない。 */
@@ -77,7 +77,7 @@ const toConversation = (characterName: string, exchanges: TopicExchange[]): Mode
*/
export const streamQuestion = ({
env,
- choice,
+ modelId,
detective,
characterName,
topic,
@@ -86,7 +86,7 @@ export const streamQuestion = ({
// 話題はプレイヤー由来の文字列なので、必ず user ロールに閉じ込める。
// system 側へ回すと、指示文として読ませる経路をこちらから開くことになる。
streamText({
- model: resolveModel(env, choice),
+ model: resolveModel(env, modelId),
system:
detective === undefined
? INTERVIEWER_RULES
diff --git a/src/server/llm/judge.ts b/src/server/llm/judge.ts
index 14b9c90..dbd671d 100644
--- a/src/server/llm/judge.ts
+++ b/src/server/llm/judge.ts
@@ -1,7 +1,7 @@
import { generateObject, type LanguageModelUsage, type ProviderMetadata } from 'ai'
import { z } from 'zod'
import type { Env } from '@/server/env'
-import { type LlmChoice, resolveModel } from '@/server/llm/provider'
+import { resolveModel } from '@/server/llm/provider'
/**
* 判定結果は必ずこの形に収まる。構造化出力なのでパース失敗を考えなくてよい。
@@ -25,7 +25,7 @@ export type JudgeInput = {
/** リクエストスコープで検証済みの設定。 */
env: Env
/** この呼び出しで使う組み合わせ。役割から引き直さず、呼び出し側が決めた値を使う。 */
- choice: LlmChoice
+ modelId: string
/** 判定ルールと、そのシナリオの証拠定義。共通部分が長いほどキャッシュが効く。 */
rubric: string
/** 直近のやり取り(プレイヤーの質問とNPCの返答) */
@@ -54,7 +54,7 @@ export type JudgeResult = {
*/
export const judgeTurn = async ({
env,
- choice,
+ modelId,
rubric,
exchange,
}: JudgeInput): Promise => {
@@ -63,7 +63,7 @@ export const judgeTurn = async ({
// system に紛れ込む経路を作りがちなため)。Judge はブロック単位のキャッシュ指定が
// 要らないので、素直に分けたほうが安全側に倒れる。
const result = await generateObject({
- model: resolveModel(env, choice),
+ model: resolveModel(env, modelId),
schema: judgementSchema,
system: rubric,
messages: [{ role: 'user', content: exchange }],
diff --git a/src/server/llm/models.ts b/src/server/llm/models.ts
new file mode 100644
index 0000000..97dc37a
--- /dev/null
+++ b/src/server/llm/models.ts
@@ -0,0 +1,43 @@
+import { z } from 'zod'
+
+/**
+ * 互換サーバに「いま何が生えているか」を聞く。
+ *
+ * 手で書いた表を持たないのは、サーバの構成を変えるたびに実態とずれ、しかも
+ * ずれたことに気づけないため。選択肢は必ずサーバに聞いてから並べる。
+ */
+
+/*
+ 余分な列は読まない。OpenAI 本家は object/created/owned_by を返し、互換実装は
+ context_length や capabilities を足してくることがあるが、画面が要るのは id だけ。
+ `.loose()` にしてあるのは、知らない列が来ても弾かないため。
+*/
+const modelListSchema = z.object({ data: z.array(z.object({ id: z.string().nonempty() }).loose()) })
+
+/**
+ * 生えているモデルIDを返す。取れなければ空配列。
+ *
+ * 失敗しても throw しない。一覧が出せないことと、設定が壊れていることは別なので、
+ * 画面は「選択肢が出ない」だけで開き、保存済みの設定はそのまま効き続ける。
+ */
+export const listModels = async (baseUrl: string, apiKey: string): Promise => {
+ const response = await fetch(`${baseUrl.replace(/\/+$/, '')}/models`, {
+ headers: { Accept: 'application/json', Authorization: `Bearer ${apiKey}` },
+ }).catch(() => undefined)
+
+ if (response === undefined || !response.ok) {
+ console.error('[llm] failed to list models', response?.status)
+
+ return []
+ }
+
+ const parsed = modelListSchema.safeParse(await response.json().catch(() => undefined))
+
+ if (!parsed.success) {
+ console.error('[llm] unexpected model list shape')
+
+ return []
+ }
+
+ return parsed.data.data.map((model) => model.id)
+}
diff --git a/src/server/llm/provider.ts b/src/server/llm/provider.ts
index f4d8d21..4687f60 100644
--- a/src/server/llm/provider.ts
+++ b/src/server/llm/provider.ts
@@ -1,12 +1,7 @@
import { createOpenAI } from '@ai-sdk/openai'
import type { LanguageModel } from 'ai'
import type { Env } from '@/server/env'
-import {
- isKnownModel,
- LLM_DEFAULT_MODELS,
- type LlmOverride,
- type LlmProvider,
-} from '~/db/llm-catalog'
+import { LLM_DEFAULT_MODELS, type LlmOverride } from '~/db/llm-catalog'
/**
* AlibAIはLLMを「役割」で使い分ける。
@@ -15,24 +10,18 @@ import {
* judge … 証拠開示・矛盾検出・次の質問候補。構造化出力。安価・高速・並列。
* author … シナリオ作成支援と整合性チェック。頻度が低いので最上位モデル。
*
- * 役割ごとに別プロバイダを選べる。actorはClaude、judgeはGeminiのFlash系、
- * みたいな混成構成も設定だけで組める。
+ * 役割ごとに別のモデルを当てられる。会話は賢いモデル、判定は安いモデル、といった
+ * 組み合わせが設定だけで組める。
*
- * このモジュールは「役割 → 使う値を決める」(chooseLlm)と「値 → SDKクライアント」
- * (resolveModel)に分かれている。分けてあるのは、プレイヤーが画面から
+ * このモジュールは「役割 → 使うモデルIDを決める」(chooseLlm)と「モデルID →
+ * SDKクライアント」(resolveModel)に分かれている。分けてあるのは、プレイヤーが画面から
* モデルを差し替えられるようにしたときに、決定を**リクエストごとに一度だけ**行って
* 以降は同じ値を配り回すため。役割から二度引くと、片方だけ差し替わって食い違う。
*
- * 宛先は3社ぶんではなく `OPENAI_URL` の互換サーバ1つ。プロバイダ名はモデルIDの
- * 区分けとして残っているだけで、経路は分かれない。
+ * 宛先は `OPENAI_URL` の互換サーバ1つ。どのモデルを選んでも経路は分かれない。
*/
export type LlmRole = 'actor' | 'judge' | 'author'
-export type { LlmProvider }
-
-/** 一度決めた結果。これを配り回す。 */
-export type LlmChoice = { provider: LlmProvider; modelId: string }
-
/**
* 役割ごとの設定を env から引く。
*
@@ -40,63 +29,39 @@ export type LlmChoice = { provider: LlmProvider; modelId: string }
* モジュールのトップレベルで env を読んだりクライアントを組み立てたりすると、
* デプロイした瞬間に起動しなくなる。だから全部リクエストスコープに降ろす。
*/
-const configOf = (
- env: Env,
- role: LlmRole,
-): { provider: LlmProvider; model: string | undefined } => {
+const modelOf = (env: Env, role: LlmRole): string | undefined => {
switch (role) {
case 'actor':
- return { provider: env.LLM_ACTOR_PROVIDER, model: env.LLM_ACTOR_MODEL }
+ return env.LLM_ACTOR_MODEL
case 'judge':
- return { provider: env.LLM_JUDGE_PROVIDER, model: env.LLM_JUDGE_MODEL }
+ return env.LLM_JUDGE_MODEL
case 'author':
- return { provider: env.LLM_AUTHOR_PROVIDER, model: env.LLM_AUTHOR_MODEL }
+ return env.LLM_AUTHOR_MODEL
}
}
/**
- * 設定画面が「モデルを選ばせてよいか」を決めるのに使う。鍵そのものは決して外へ出さない。
+ * どのモデルを使うかを決める。リクエストごとに一度だけ呼ぶ。
*
- * 宛先が互換サーバ1つになったので、プロバイダごとの可否は無い。
- * どのモデルが本当に生えているかは互換サーバ次第で、ここからは分からない。
+ * 優先順位は override → env → 既定表。突き合わせる許可リストは持たないので、
+ * プレイヤーが指定したIDはそのまま通る。互換サーバが知らないIDならそこでエラーになる。
*/
-export const isLlmConfigured = (env: Env): boolean => env.OPENAI_API_KEY !== undefined
+export const chooseLlm = (env: Env, role: LlmRole, override?: LlmOverride): string => {
+ const requested = override?.model
-/**
- * どのプロバイダのどのモデルを使うかを決める。リクエストごとに一度だけ呼ぶ。
- *
- * 優先順位は override → env → 既定表。ただし override のモデルIDが
- * `db/llm-catalog.ts` の表に無ければ黙って捨てる。400 にはしない——localStorage に
- * 古い設定が残っているだけのプレイヤーを、事件の途中で締め出すことになるため。
- *
- * provider が override で変わったときに env のモデルIDを引き継がないのが要点。
- * `LLM_ACTOR_MODEL` は別のプロバイダ向けの値なので、openai に `claude-sonnet-5` を
- * 投げることになる。プロバイダが変わったら、モデルは必ず既定表から引き直す。
- */
-export const chooseLlm = (env: Env, role: LlmRole, override?: LlmOverride): LlmChoice => {
- const config = configOf(env, role)
- const wanted = override?.provider
- const provider = wanted === undefined ? config.provider : wanted
+ if (requested !== undefined) {
+ return requested
+ }
- const fromEnv = provider === config.provider ? config.model : undefined
- const requested = override?.model
- const modelId =
- requested !== undefined && isKnownModel(provider, requested)
- ? requested
- : fromEnv === undefined
- ? LLM_DEFAULT_MODELS[provider][role]
- : fromEnv
+ const fromEnv = modelOf(env, role)
- return { provider, modelId }
+ return fromEnv === undefined ? LLM_DEFAULT_MODELS[role] : fromEnv
}
/**
- * プロバイダのクライアントはただのファクトリなので、リクエストごとに作っても実質コストはない。
+ * クライアントはただのファクトリなので、リクエストごとに作っても実質コストはない。
* isolate をまたいで使い回そうとするより、毎回作るほうが安全で読みやすい。
*
- * どのプロバイダのモデルも `createOpenAI` で組む。宛先は `OPENAI_URL` の一つだけで、
- * プロバイダ名はモデルIDの区分けとして残っているだけ。
- *
* `.chat` を明示するのは、省くと Responses API(`/responses`)へ行くため。
* OpenAI互換を名乗るサーバが出しているのは大抵 `/chat/completions` だけで、
* 既定のままだと 404 になる。本家も `/chat/completions` を持っているので、
@@ -105,5 +70,5 @@ export const chooseLlm = (env: Env, role: LlmRole, override?: LlmOverride): LlmC
* 向き先はクライアントから差し替えられない。公開された画面から変えられると、
* 攻撃者が自分のサーバを指定するだけで、Worker がそこへ API キーを添えて送ってしまう。
*/
-export const resolveModel = (env: Env, choice: LlmChoice): LanguageModel =>
- createOpenAI({ apiKey: env.OPENAI_API_KEY, baseURL: env.OPENAI_URL }).chat(choice.modelId)
+export const resolveModel = (env: Env, modelId: string): LanguageModel =>
+ createOpenAI({ apiKey: env.OPENAI_API_KEY, baseURL: env.OPENAI_URL }).chat(modelId)
diff --git a/src/server/llm/usage.ts b/src/server/llm/usage.ts
index 6f9d8da..75e154e 100644
--- a/src/server/llm/usage.ts
+++ b/src/server/llm/usage.ts
@@ -1,12 +1,12 @@
import type { LanguageModelUsage, ProviderMetadata } from 'ai'
-import type { LlmChoice, LlmRole } from '@/server/llm/provider'
+import type { LlmRole } from '@/server/llm/provider'
import type { llmUsages } from '~/db/schema'
/**
* LLM呼び出しの結果を llm_usages の1行に均す。
*
- * actor と judge で同じ変換が要るのでここに置く。プロバイダごとに
- * 「どのフィールドに何が入るか」が違い、素直に書くと取りこぼすため。
+ * actor と judge で同じ変換が要るのでここに置く。未報告のトークン数の埋め方と、
+ * メタデータの読み方を1箇所に閉じ込めておかないと、呼び出しごとに取りこぼす。
*/
/** usage のトークン数は軒並み number | undefined。未報告は0として数える。 */
@@ -27,11 +27,6 @@ const cacheCreationTokens = (metadata: ProviderMetadata | undefined): number =>
}
export type UsageInput = {
- /**
- * その呼び出しで実際に使った組み合わせ。env から引き直さないこと。
- * プレイヤーが画面でプロバイダを差し替えている場合、env の値は実態と食い違う。
- */
- choice: LlmChoice
role: LlmRole
/** 実際に応答したモデル。設定値ではなくレスポンスの modelId を渡すこと。 */
model: string
@@ -46,7 +41,6 @@ export const toUsageRow = (input: UsageInput): typeof llmUsages.$inferInsert =>
sessionId: input.sessionId,
scenarioId: input.scenarioId,
role: input.role,
- provider: input.choice.provider,
model: input.model,
inputTokens: tokenCount(input.usage.inputTokens),
outputTokens: tokenCount(input.usage.outputTokens),
diff --git a/src/server/routes/sessions.ts b/src/server/routes/sessions.ts
index 323e992..eb7b24d 100644
--- a/src/server/routes/sessions.ts
+++ b/src/server/routes/sessions.ts
@@ -43,7 +43,7 @@ import { composeExaminationFocus, streamExamination } from '@/server/llm/examine
import { createFilterState, FALLBACK_REPLY, feedChunk, finalizeFilter } from '@/server/llm/filter'
import { streamQuestion, type TopicExchange } from '@/server/llm/interviewer'
import { judgeTurn } from '@/server/llm/judge'
-import { chooseLlm, type LlmChoice } from '@/server/llm/provider'
+import { chooseLlm } from '@/server/llm/provider'
import { toUsageRow } from '@/server/llm/usage'
import { withEnv } from '@/server/middleware/env'
import {
@@ -880,11 +880,11 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
/*
使うモデルはここで一度だけ決めて、以降は同じ値を配り回す。
- 役割から都度引き直すと、モデルとキャッシュ指定が別々に解決されて食い違う。
+ 役割から都度引き直すと、同じ往復の中で質問と返答が違うモデルで走りうる。
env そのものには決して混ぜない——withEnv が isolate 全体で使い回している
オブジェクトなので、一人の選択が他のプレイヤーのリクエストへ漏れる。
*/
- const choices: Record<'actor' | 'judge', LlmChoice> = {
+ const choices: Record<'actor' | 'judge', string> = {
actor: chooseLlm(env, 'actor', askInput.llm?.actor),
judge: chooseLlm(env, 'judge', askInput.llm?.judge),
}
@@ -977,7 +977,7 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
if (examining) {
const focus = await composeExaminationFocus({
env,
- choice: choices.actor,
+ modelId: choices.actor,
intentRules,
detective,
topic: askInput.topic,
@@ -994,7 +994,7 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
const asking = streamQuestion({
env,
- choice: choices.actor,
+ modelId: choices.actor,
detective,
characterName: subject.name,
topic: askInput.topic,
@@ -1026,7 +1026,6 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
collected.usages.push(
toUsageRow({
- choice: choices.actor,
role: 'actor',
model: interviewer.model,
usage: interviewer.usage,
@@ -1045,7 +1044,7 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
const result = examining
? streamExamination({
env,
- choice: choices.actor,
+ modelId: choices.actor,
examinationRules,
sheet: subject.sheet,
detective,
@@ -1054,7 +1053,7 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
})
: streamNpcReply({
env,
- choice: choices.actor,
+ modelId: choices.actor,
gameRules: GAME_RULES,
characterSheet: subject.sheet,
detective,
@@ -1081,7 +1080,6 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
collected.usages.push(
toUsageRow({
- choice: choices.actor,
role: 'actor',
model: response.modelId,
usage,
@@ -1216,7 +1214,7 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
.map((entry) => `探偵: ${entry.question}\nNPC: ${entry.answer}`)
.join('\n\n')
const exchange = `プレイヤーが指定した話題: ${askInput.topic}\n\n${transcript}\n\n今回判定可能なRevelation:\n${candidateBlock}`
- const judged = await judgeTurn({ env, choice: choices.judge, rubric, exchange })
+ const judged = await judgeTurn({ env, modelId: choices.judge, rubric, exchange })
const judgement = judged.judgement
const revealedRevelationIds = acceptRevealedRevelationIds(
revelationCandidates,
@@ -1337,7 +1335,6 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
try {
await db.insert(llmUsages).values(
toUsageRow({
- choice: choices.judge,
role: 'judge',
model: judged.model,
usage: judged.usage,
@@ -1483,7 +1480,7 @@ sessionRoutes.post('/api/sessions/:id/accuse', validateAccuse, withEnv, async (c
const graded = await gradeDeduction({
env,
- choice: judgeChoice,
+ modelId: judgeChoice,
// method / motive が空のシナリオでは summary を的に使う。採点の精度は落ちるが、
// 古いシナリオで推理パートごと成立しなくなるよりはいい。
truth: {
@@ -1580,7 +1577,6 @@ sessionRoutes.post('/api/sessions/:id/accuse', validateAccuse, withEnv, async (c
try {
await db.insert(llmUsages).values(
toUsageRow({
- choice: judgeChoice,
role: 'judge',
model: graded.model,
usage: graded.usage,
diff --git a/src/server/routes/settings.ts b/src/server/routes/settings.ts
index 60cd5b9..8a39a04 100644
--- a/src/server/routes/settings.ts
+++ b/src/server/routes/settings.ts
@@ -1,29 +1,17 @@
import { Hono } from 'hono'
import type { Bindings, Env } from '@/server/env'
-import { isLlmConfigured } from '@/server/llm/provider'
+import { listModels } from '@/server/llm/models'
import { withEnv } from '@/server/middleware/env'
import { EXCHANGES_PER_TOPIC, LIMIT_CEILINGS } from '@/shared/turns'
-import {
- LLM_CATALOG,
- LLM_PROVIDER_LABELS,
- LLM_PROVIDERS,
- LLM_ROLE_LABELS,
- LLM_ROLE_NOTES,
- SETTABLE_LLM_ROLES,
-} from '~/db/llm-catalog'
+import { LLM_ROLE_LABELS, LLM_ROLE_NOTES, SETTABLE_LLM_ROLES } from '~/db/llm-catalog'
/**
* 設定画面が選択肢を組み立てるための材料。
*
- * **鍵そのものは決して返さない。** 返すのは「設定されているか」の真偽値だけで、
- * 値も、その長さも、ゲートウェイの向き先も載せない。ベースURLは設定できない方針なので、
- * 存在すら漏らさない(漏らせば、どこを狙えばよいかを教えることになる)。
- *
- * この口が無いと、鍵の無いまま選べてしまい、応答を流し始めてから
- * SDK の中で落ちることになる。一番後味の悪い壊れ方なので、先に潰しておく。
- *
- * 宛先は互換サーバ1つなので、可否はプロバイダごとに分かれない。互換サーバに
- * そのモデルが本当に生えているかまでは、ここからは分からない。
+ * **鍵そのものは決して返さない。** 値も、その長さも、互換サーバの向き先も載せない。
+ * 向き先は設定できない方針なので、存在すら漏らさない(漏らせば、どこを狙えばよいかを
+ * 教えることになる)。返すのはモデルIDの一覧までで、これはサーバに載せた時点で
+ * 選べるものとして公開しているものと同じ。
*/
export const settingsRoutes = new Hono<{ Bindings: Bindings }>()
@@ -33,14 +21,19 @@ export const settingsRoutes = new Hono<{ Bindings: Bindings }>()
* HTTP 越しに確かめようとすると、バインディングの無いテスト環境では withEnv が
* 先に落ちて 500 になり、「本文に鍵が無い」がただの空振りになる。
* 鍵の入った env を直接渡して組み立てれば、その検査が本当に働く。
+ *
+ * 一覧の取得を引数で受けるのも同じ理由。テストからネットワークを切れる形にしておかないと、
+ * 漏洩の回帰テストが互換サーバの有無で落ちるようになる。
*/
-export const buildLlmSettings = (env: Env) => ({
- providers: LLM_PROVIDERS.map((provider) => ({
- id: provider,
- label: LLM_PROVIDER_LABELS[provider],
- available: isLlmConfigured(env),
- models: LLM_CATALOG[provider],
- })),
+export const buildLlmSettings = async (env: Env, fetchModels = listModels) => ({
+ /*
+ 鍵か向き先が未設定なら、そもそも聞きに行かない。空配列で返ると画面は
+ 「選択肢が出ない」と説明を出すだけで、保存済みの設定はそのまま効き続ける。
+ */
+ models: (env.OPENAI_API_KEY === undefined || env.OPENAI_URL === undefined
+ ? []
+ : await fetchModels(env.OPENAI_URL, env.OPENAI_API_KEY)
+ ).map((id) => ({ id, label: id })),
roles: SETTABLE_LLM_ROLES.map((role) => ({
id: role,
label: LLM_ROLE_LABELS[role],
@@ -55,4 +48,6 @@ export const buildLlmSettings = (env: Env) => ({
},
})
-settingsRoutes.get('/api/settings/llm', withEnv, (c) => c.json(buildLlmSettings(c.get('env'))))
+settingsRoutes.get('/api/settings/llm', withEnv, async (c) =>
+ c.json(await buildLlmSettings(c.get('env'))),
+)
diff --git a/wrangler.jsonc b/wrangler.jsonc
index 44e1c91..f849d52 100644
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -74,9 +74,6 @@
// wrangler secret put OPENAI_API_KEY
// 向き先(OPENAI_URL)も secret 側に置く。互換サーバの所在を公開設定に晒さないため。
"vars": {
- "LLM_ACTOR_PROVIDER": "openai",
- "LLM_JUDGE_PROVIDER": "openai",
- "LLM_AUTHOR_PROVIDER": "openai",
"MAX_TURNS": "15",
"QUESTIONS_PER_TURN": "2",
// 数えるのはモデル呼び出しの回数。1つの話題で最大 2×往復数+1 回消費する。
From 37dbd9662a417985da35e164267859dc2228da17 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Tue, 8 Sep 2026 11:13:59 +0000
Subject: [PATCH 24/41] chore(llm): remove unused anthropic and google ai-sdk
provider packages
Drop now-unused @ai-sdk/anthropic and @ai-sdk/google dependencies, and
clarify comments in env.ts and usage.ts to reflect the single
OpenAI-compatible endpoint design.
Co-Authored-By: Claude
---
bun.lock | 6 ------
package.json | 2 --
src/server/env.ts | 6 +++---
src/server/llm/usage.ts | 8 +++++---
4 files changed, 8 insertions(+), 14 deletions(-)
diff --git a/bun.lock b/bun.lock
index 9fc3f96..17a20b2 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,8 +5,6 @@
"": {
"name": "alibai",
"dependencies": {
- "@ai-sdk/anthropic": "^2",
- "@ai-sdk/google": "^2",
"@ai-sdk/openai": "^2",
"@ai-sdk/provider-utils": "^3",
"@commitlint/config-conventional": "^21.2.2",
@@ -52,12 +50,8 @@
"packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
- "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.101", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.36" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jLAtSFPiB9mftqZyIbZ/FXIB/jBwgbrbVM9M6jDGbSapYCsqUlfbiWszjkEiX2nHK+BxgeQOPqKbWyuzFJPBqA=="],
-
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.145", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.36", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yDnrdxwKBF9kYHpS99c7GreufknlNU9ecXqYSjigq3Pv5OUFNVeEGwfbL7ninYNU13p4SlXoRmRlmymWPLZMVQ=="],
- "@ai-sdk/google": ["@ai-sdk/google@2.0.95", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.36" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nE45RIjdpJRMK4vFbGL1xvOBsE7jsC3niDW+UrHLBf4T3YTm7gjuX+COycsmLR6opKpZcqHwF7SY/pN9mqHfwQ=="],
-
"@ai-sdk/openai": ["@ai-sdk/openai@2.0.123", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.36" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-KB9KY1yxTGkeYE7lskYydS7AkAvEdTSALJfsSsq8oDgTj4zJMfKWS+K5yJVp7ErWZ+tRyMqx3aepnxPsv9fWeA=="],
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww=="],
diff --git a/package.json b/package.json
index 7b6db67..ec3d9ec 100644
--- a/package.json
+++ b/package.json
@@ -25,8 +25,6 @@
"deploy": "bun run se:fetch && bun run build && bun run db:migrate:remote && wrangler deploy"
},
"dependencies": {
- "@ai-sdk/anthropic": "^2",
- "@ai-sdk/google": "^2",
"@ai-sdk/openai": "^2",
"@ai-sdk/provider-utils": "^3",
"@commitlint/config-conventional": "^21.2.2",
diff --git a/src/server/env.ts b/src/server/env.ts
index 2c9013e..f1d70cb 100644
--- a/src/server/env.ts
+++ b/src/server/env.ts
@@ -63,9 +63,9 @@ const schema = z.object({
OPENAI_API_KEY: optionalString,
/*
- LLMの向き先。3社ぶんではなく1つなのは、どのプロバイダのモデルも
- OpenAI互換の `/chat/completions` へ投げるため(src/server/llm/provider.ts)。
- プロバイダ名はモデルIDの区分けとして残っているだけで、宛先は分かれない。
+ LLMの向き先。どのモデルを選んでもここ1つへ、OpenAI互換の
+ `/chat/completions` として投げる(src/server/llm/provider.ts)。
+ 選べるモデルの一覧もここに聞く(src/server/llm/models.ts)。
ここを env として明示的に持つ必要がある。AI SDK は baseURL を渡さないと
process.env の同名変数を見にいくが、Workers の isolate に process.env は無い。
diff --git a/src/server/llm/usage.ts b/src/server/llm/usage.ts
index 75e154e..de8faf4 100644
--- a/src/server/llm/usage.ts
+++ b/src/server/llm/usage.ts
@@ -16,9 +16,11 @@ const tokenCount = (value: number | undefined): number => (value === undefined ?
* キャッシュ「書き込み」量。
*
* usage には入っておらず providerMetadata 側にしかないので、明示的に拾う。
- * Anthropicではキャッシュ書き込みが通常の入力より高い。ここを落とすと、
- * actor.ts が組んだキャッシュ設計の一番高い部分が請求書にだけ現れることになる。
- * anthropic以外のプロバイダはこのキーを持たないので0になる。
+ * キャッシュ書き込みを通常の入力より高く取るモデルがあり、ここを落とすと
+ * 一番高い部分が請求書にだけ現れることになる。
+ *
+ * 読んでいる `anthropic` は AI SDK がメタデータに付ける名前で、設定とは関係ない。
+ * 互換サーバがこの形で返さなければ 0 のままになる。
*/
const cacheCreationTokens = (metadata: ProviderMetadata | undefined): number => {
const value = metadata?.anthropic?.cacheCreationInputTokens
From cadf086310c07d17291ba595e0ee15b2be557c0a Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Wed, 9 Sep 2026 01:57:16 +0000
Subject: [PATCH 25/41] fix(llm): use the responses API for compatible models
Co-Authored-By: Claude
---
src/server/llm/provider.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/server/llm/provider.ts b/src/server/llm/provider.ts
index 4687f60..24bcaaf 100644
--- a/src/server/llm/provider.ts
+++ b/src/server/llm/provider.ts
@@ -71,4 +71,4 @@ export const chooseLlm = (env: Env, role: LlmRole, override?: LlmOverride): stri
* 攻撃者が自分のサーバを指定するだけで、Worker がそこへ API キーを添えて送ってしまう。
*/
export const resolveModel = (env: Env, modelId: string): LanguageModel =>
- createOpenAI({ apiKey: env.OPENAI_API_KEY, baseURL: env.OPENAI_URL }).chat(modelId)
+ createOpenAI({ apiKey: env.OPENAI_API_KEY, baseURL: env.OPENAI_URL })(modelId)
From 1af4c20c6cb822b6879b633a798d591d14d17e13 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Wed, 9 Sep 2026 02:19:49 +0000
Subject: [PATCH 26/41] fix(llm): select default models from the compatible
server
Fetch available models once per request, prefer exact or gateway-prefixed defaults, and fall back to the first advertised model.
Co-Authored-By: Claude
---
__tests__/llm/provider.test.ts | 113 ++++++++++++++++++++++++++++-----
db/generate-scenario.ts | 2 +-
src/server/llm/provider.ts | 103 ++++++++++++++++++++++++++----
src/server/routes/sessions.ts | 12 ++--
4 files changed, 194 insertions(+), 36 deletions(-)
diff --git a/__tests__/llm/provider.test.ts b/__tests__/llm/provider.test.ts
index ca46ee4..1ecc69f 100644
--- a/__tests__/llm/provider.test.ts
+++ b/__tests__/llm/provider.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import type { Env } from '@/server/env'
-import { chooseLlm } from '@/server/llm/provider'
+import { chooseLlm, chooseLlms } from '@/server/llm/provider'
import { toUsageRow } from '@/server/llm/usage'
import { LLM_DEFAULT_MODELS } from '~/db/llm-catalog'
@@ -22,36 +22,117 @@ const makeEnv = (overrides: Partial): Env => ({
...overrides,
})
+/** 一覧を返す互換サーバの代わり。テストからネットワークを切る。 */
+const lists =
+ (...ids: string[]) =>
+ async () =>
+ ids
+
+const configured = makeEnv({ OPENAI_URL: 'https://gateway.example.internal/v1' })
+
describe('chooseLlm: 優先順位', () => {
- test('指定が無ければ既定表のモデル', () => {
- expect(chooseLlm(makeEnv({}), 'actor')).toBe(LLM_DEFAULT_MODELS.actor)
+ test('env のモデル指定があればそれを使う', async () => {
+ const env = makeEnv({ LLM_ACTOR_MODEL: 'my-local-model' })
+
+ expect(await chooseLlm(env, 'actor', undefined, lists('other'))).toBe('my-local-model')
})
- test('env のモデル指定があればそれを使う', () => {
+ test('プレイヤーの指定は env より優先される', async () => {
const env = makeEnv({ LLM_ACTOR_MODEL: 'my-local-model' })
- expect(chooseLlm(env, 'actor')).toBe('my-local-model')
+ expect(await chooseLlm(env, 'actor', { model: 'another-model' }, lists())).toBe('another-model')
})
- test('プレイヤーの指定は env より優先される', () => {
- const env = makeEnv({ LLM_ACTOR_MODEL: 'my-local-model' })
+ /*
+ 突き合わせる許可リストはもう無い。互換サーバに何が生えているかはここからは
+ 分からないので、見慣れないIDでも既定へ落とさずそのまま通す。
+ 通らないIDなら互換サーバがエラーを返す。
+ */
+ test('見慣れないモデルIDでも捨てずに通す', async () => {
+ expect(await chooseLlm(makeEnv({}), 'actor', { model: 'who-knows-9' }, lists())).toBe(
+ 'who-knows-9',
+ )
+ })
+})
- expect(chooseLlm(env, 'actor', { model: 'another-model' })).toBe('another-model')
+describe('chooseLlm: 一覧から既定を選ぶ', () => {
+ test('既定表と同じ名前が一覧にあればそれを採る', async () => {
+ const chosen = await chooseLlm(
+ configured,
+ 'actor',
+ undefined,
+ lists('something-else', LLM_DEFAULT_MODELS.actor),
+ )
+
+ expect(chosen).toBe(LLM_DEFAULT_MODELS.actor)
})
- test('役割ごとに既定のモデルが違う', () => {
- const env = makeEnv({})
+ /*
+ 接頭辞で経由先を表すゲートウェイがあり、素の名前は一つも生えていない。
+ 末尾が一致するIDを拾えないと、必ず存在しないモデルを指名することになる。
+ */
+ test('接頭辞付きのIDでも末尾が一致すれば拾う', async () => {
+ const chosen = await chooseLlm(
+ configured,
+ 'actor',
+ undefined,
+ lists('alpha,other-model', `alpha,${LLM_DEFAULT_MODELS.actor}`),
+ )
+
+ expect(chosen).toBe(`alpha,${LLM_DEFAULT_MODELS.actor}`)
+ })
- expect(chooseLlm(env, 'actor')).not.toBe(chooseLlm(env, 'judge'))
+ test('希望が一つも無ければ一覧の先頭に落ちる', async () => {
+ expect(await chooseLlm(configured, 'actor', undefined, lists('only-this'))).toBe('only-this')
})
/*
- 突き合わせる許可リストはもう無い。互換サーバに何が生えているかはここからは
- 分からないので、見慣れないIDでも既定へ落とさずそのまま通す。
- 通らないIDなら互換サーバがエラーを返す。
+ 一覧が引けないときに既定表をそのまま返すのは、動かすためではなく、
+ モデル名がエラーに出るぶん原因が分かるようにするため。
*/
- test('見慣れないモデルIDでも捨てずに通す', () => {
- expect(chooseLlm(makeEnv({}), 'actor', { model: 'who-knows-9' })).toBe('who-knows-9')
+ test('一覧が空なら既定表をそのまま返す', async () => {
+ expect(await chooseLlm(configured, 'actor', undefined, lists())).toBe(LLM_DEFAULT_MODELS.actor)
+ })
+
+ test('鍵が無ければ一覧を引きに行かない', async () => {
+ const asked = { count: 0 }
+
+ await chooseLlm(makeEnv({ OPENAI_API_KEY: undefined }), 'actor', undefined, async () => {
+ asked.count += 1
+
+ return []
+ })
+
+ expect(asked.count).toBe(0)
+ })
+})
+
+describe('chooseLlms', () => {
+ test('役割ごとに別のモデルを返す', async () => {
+ const chosen = await chooseLlms(
+ configured,
+ {},
+ lists(`x,${LLM_DEFAULT_MODELS.actor}`, `x,${LLM_DEFAULT_MODELS.judge}`),
+ )
+
+ expect(chosen).toEqual({
+ actor: `x,${LLM_DEFAULT_MODELS.actor}`,
+ judge: `x,${LLM_DEFAULT_MODELS.judge}`,
+ })
+ })
+
+ /* 両方とも決まっているなら、一覧を引く理由が無い。 */
+ test('両方指定されていれば一覧を引かない', async () => {
+ const asked = { count: 0 }
+ const env = makeEnv({ LLM_ACTOR_MODEL: 'a', LLM_JUDGE_MODEL: 'b' })
+ const chosen = await chooseLlms(env, {}, async () => {
+ asked.count += 1
+
+ return []
+ })
+
+ expect(chosen).toEqual({ actor: 'a', judge: 'b' })
+ expect(asked.count).toBe(0)
})
})
diff --git a/db/generate-scenario.ts b/db/generate-scenario.ts
index a701929..a9925ee 100644
--- a/db/generate-scenario.ts
+++ b/db/generate-scenario.ts
@@ -43,7 +43,7 @@ if (premise === undefined || premise.trim() === '') {
}
const env = parseEnv(process.env)
-const choice = chooseLlm(env, 'author')
+const choice = await chooseLlm(env, 'author')
const model = resolveModel(env, choice)
/*
diff --git a/src/server/llm/provider.ts b/src/server/llm/provider.ts
index 24bcaaf..8d3d20c 100644
--- a/src/server/llm/provider.ts
+++ b/src/server/llm/provider.ts
@@ -1,6 +1,7 @@
import { createOpenAI } from '@ai-sdk/openai'
import type { LanguageModel } from 'ai'
import type { Env } from '@/server/env'
+import { listModels } from '@/server/llm/models'
import { LLM_DEFAULT_MODELS, type LlmOverride } from '~/db/llm-catalog'
/**
@@ -41,31 +42,107 @@ const modelOf = (env: Env, role: LlmRole): string | undefined => {
}
/**
- * どのモデルを使うかを決める。リクエストごとに一度だけ呼ぶ。
- *
- * 優先順位は override → env → 既定表。突き合わせる許可リストは持たないので、
- * プレイヤーが指定したIDはそのまま通る。互換サーバが知らないIDならそこでエラーになる。
+ * 指定されているモデル。override → env の順で、どちらも無ければ undefined。
+ * 突き合わせる許可リストは持たないので、指定されたIDはそのまま通る。
*/
-export const chooseLlm = (env: Env, role: LlmRole, override?: LlmOverride): string => {
+const settledModel = (env: Env, role: LlmRole, override?: LlmOverride): string | undefined => {
const requested = override?.model
- if (requested !== undefined) {
- return requested
+ return requested === undefined ? modelOf(env, role) : requested
+}
+
+/**
+ * 何も指定が無いときのモデルを、互換サーバが返した一覧から選ぶ。
+ *
+ * `LLM_DEFAULT_MODELS` を固定のIDとして使わないのは、そのIDが相手のサーバに在るとは
+ * 限らないため。実際、モデル名を `openai,gpt-5.6-terra` のように「どのプロバイダ経由か」
+ * を接頭辞で表すゲートウェイがあり、素の名前は一つも存在しない。
+ *
+ * そこで既定表は「希望」として扱う。同じ名前で終わるIDが一覧にあればそれを採り、
+ * 無ければ一覧の先頭に落ちる。一覧そのものが引けなければ希望をそのまま返す
+ * (どのみち動かないが、モデル名がエラーに出るぶん原因が分かる)。
+ */
+const pickFromAvailable = (available: string[], preferred: string): string => {
+ const exact = available.find((id) => id === preferred)
+
+ if (exact !== undefined) {
+ return exact
+ }
+
+ const suffixed = available.find((id) => id.endsWith(`,${preferred}`))
+
+ if (suffixed !== undefined) {
+ return suffixed
+ }
+
+ const first = available[0]
+
+ return first === undefined ? preferred : first
+}
+
+/**
+ * 1リクエストぶんのモデルを決める。
+ *
+ * 役割ごとに別々に呼ばず1つにまとめてあるのは、一覧の取得を多くても一度で済ませるため。
+ * env に何も置いていない構成では、ここが唯一「実在するモデル名」を知る手がかりになる。
+ */
+export const chooseLlms = async (
+ env: Env,
+ overrides: { actor?: LlmOverride; judge?: LlmOverride },
+ fetchModels = listModels,
+): Promise<{ actor: string; judge: string }> => {
+ const settled = {
+ actor: settledModel(env, 'actor', overrides.actor),
+ judge: settledModel(env, 'judge', overrides.judge),
}
- const fromEnv = modelOf(env, role)
+ if (settled.actor !== undefined && settled.judge !== undefined) {
+ return { actor: settled.actor, judge: settled.judge }
+ }
- return fromEnv === undefined ? LLM_DEFAULT_MODELS[role] : fromEnv
+ const available = await availableModels(env, fetchModels)
+
+ return {
+ actor:
+ settled.actor === undefined
+ ? pickFromAvailable(available, LLM_DEFAULT_MODELS.actor)
+ : settled.actor,
+ judge:
+ settled.judge === undefined
+ ? pickFromAvailable(available, LLM_DEFAULT_MODELS.judge)
+ : settled.judge,
+ }
+}
+
+/** 役割ひとつぶん。CLI(`db/generate-scenario.ts` の author)から使う。 */
+export const chooseLlm = async (
+ env: Env,
+ role: LlmRole,
+ override?: LlmOverride,
+ fetchModels = listModels,
+): Promise => {
+ const settled = settledModel(env, role, override)
+
+ return settled === undefined
+ ? pickFromAvailable(await availableModels(env, fetchModels), LLM_DEFAULT_MODELS[role])
+ : settled
}
+/** 鍵か向き先が無ければ聞きに行かない。 */
+const availableModels = async (env: Env, fetchModels: typeof listModels): Promise =>
+ env.OPENAI_API_KEY === undefined || env.OPENAI_URL === undefined
+ ? []
+ : fetchModels(env.OPENAI_URL, env.OPENAI_API_KEY)
+
/**
* クライアントはただのファクトリなので、リクエストごとに作っても実質コストはない。
* isolate をまたいで使い回そうとするより、毎回作るほうが安全で読みやすい。
*
- * `.chat` を明示するのは、省くと Responses API(`/responses`)へ行くため。
- * OpenAI互換を名乗るサーバが出しているのは大抵 `/chat/completions` だけで、
- * 既定のままだと 404 になる。本家も `/chat/completions` を持っているので、
- * こちらに寄せれば両方に繋がる。
+ * 経路は SDK の既定のまま Responses API(`/responses`)を使う。chat/completions は
+ * 古いほうで、上限トークン数の指定が `max_tokens` と `max_completion_tokens` に
+ * 分かれている。SDK はどちらを送るかをモデルIDの文字列から判断するので、
+ * `openai,gpt-5.6-terra` のように接頭辞の付いたIDだとその判断を外し、
+ * 古い名前で送って 400 になる。Responses にはこの分岐が無い。
*
* 向き先はクライアントから差し替えられない。公開された画面から変えられると、
* 攻撃者が自分のサーバを指定するだけで、Worker がそこへ API キーを添えて送ってしまう。
diff --git a/src/server/routes/sessions.ts b/src/server/routes/sessions.ts
index eb7b24d..728f88e 100644
--- a/src/server/routes/sessions.ts
+++ b/src/server/routes/sessions.ts
@@ -43,7 +43,7 @@ import { composeExaminationFocus, streamExamination } from '@/server/llm/examine
import { createFilterState, FALLBACK_REPLY, feedChunk, finalizeFilter } from '@/server/llm/filter'
import { streamQuestion, type TopicExchange } from '@/server/llm/interviewer'
import { judgeTurn } from '@/server/llm/judge'
-import { chooseLlm } from '@/server/llm/provider'
+import { chooseLlms } from '@/server/llm/provider'
import { toUsageRow } from '@/server/llm/usage'
import { withEnv } from '@/server/middleware/env'
import {
@@ -884,10 +884,10 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
env そのものには決して混ぜない——withEnv が isolate 全体で使い回している
オブジェクトなので、一人の選択が他のプレイヤーのリクエストへ漏れる。
*/
- const choices: Record<'actor' | 'judge', string> = {
- actor: chooseLlm(env, 'actor', askInput.llm?.actor),
- judge: chooseLlm(env, 'judge', askInput.llm?.judge),
- }
+ const choices = await chooseLlms(env, {
+ actor: askInput.llm?.actor,
+ judge: askInput.llm?.judge,
+ })
/*
レート制限は「このリクエストが実際に走らせるモデル呼び出しの数」で消費する。
@@ -1433,7 +1433,7 @@ sessionRoutes.post('/api/sessions/:id/accuse', validateAccuse, withEnv, async (c
const accuseInput = c.get('accuseInput')
const sessionId = accuseInput.sessionId
const env = c.get('env')
- const judgeChoice = chooseLlm(env, 'judge', accuseInput.llm?.judge)
+ const judgeChoice = (await chooseLlms(env, { judge: accuseInput.llm?.judge })).judge
// LLMを呼ぶ口になったので ask と同じ上限を通す。認証がまだ無いのでキーはIP。
const clientIp = c.req.header('cf-connecting-ip')
From fe5dee0a3c30a20f22269f254f091261df65b75c Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Wed, 9 Sep 2026 06:59:00 +0000
Subject: [PATCH 27/41] feat(tts): assign cached registered speakers
deterministically by character UUID
Fetch and validate the upstream speaker list, then synthesize each character with a stable speaker assignment.
Co-Authored-By: Claude
---
src/server/routes/voice.ts | 24 ++++++-----
src/server/tts/irodori.ts | 86 +++++++++++++++++++++++++++++++++-----
2 files changed, 90 insertions(+), 20 deletions(-)
diff --git a/src/server/routes/voice.ts b/src/server/routes/voice.ts
index 2bca6e0..881b527 100644
--- a/src/server/routes/voice.ts
+++ b/src/server/routes/voice.ts
@@ -5,8 +5,8 @@ import { z } from 'zod'
import { createDb } from '@/server/db/client'
import type { Bindings } from '@/server/env'
import { withEnv } from '@/server/middleware/env'
-import { synthesize } from '@/server/tts/irodori'
-import { characters, messages } from '~/db/schema'
+import { listSpeakers, speakerFor, synthesize } from '@/server/tts/irodori'
+import { messages } from '~/db/schema'
/**
* NPCの台詞を音声にして返す。
@@ -51,11 +51,9 @@ voiceRoutes.get('/api/sessions/:id/messages/:messageId/voice', validateIds, with
.select({
content: messages.content,
role: messages.role,
- seed: characters.voiceSeed,
- caption: characters.voiceCaption,
+ characterId: messages.characterId,
})
.from(messages)
- .innerJoin(characters, eq(characters.id, messages.characterId))
.where(and(eq(messages.id, ids.messageId), eq(messages.sessionId, ids.id)))
.limit(1)
@@ -70,14 +68,20 @@ voiceRoutes.get('/api/sessions/:id/messages/:messageId/voice', validateIds, with
return c.json({ error: 'not a character message' }, 404)
}
- if (row.seed === null || row.caption === null) {
+ /*
+ 話者はキャラクターのUUIDから引く。シナリオのデータには持たせていない。
+
+ 登録話者は既存作品の登場人物なので、割り当てをリポジトリに焼き込みたくない
+ (`src/server/tts/irodori.ts` の但し書きを参照)。ここで引く形なら、公開へ向かうときに
+ この関数を差し替えるだけで済み、43本のシナリオを洗い直さずに戻せる。
+ */
+ const speaker = speakerFor(await listSpeakers(baseUrl), row.characterId)
+
+ if (speaker === undefined) {
return c.json({ error: 'character has no voice' }, 404)
}
- const upstream = await synthesize(baseUrl, row.content, {
- seed: row.seed,
- caption: row.caption,
- })
+ const upstream = await synthesize(baseUrl, row.content, { speakerId: speaker.uuid })
if (!upstream.ok) {
return c.json({ error: 'synthesis failed' }, 502)
diff --git a/src/server/tts/irodori.ts b/src/server/tts/irodori.ts
index 661243c..11b3be4 100644
--- a/src/server/tts/irodori.ts
+++ b/src/server/tts/irodori.ts
@@ -1,20 +1,86 @@
+import { z } from 'zod'
+
/**
* Irodori-TTS へ台詞を投げて音声を受け取る。
*
- * 仕様の正典は各デプロイの `/openapi.json`。この実装は `POST /synth` を持つ形に合わせてある
- * (OpenAI 互換の `/v1/audio/speech` を出す別実装もあるので、向き先を変えるときは先に確認する)。
+ * 仕様の正典は各デプロイの `/openapi.json`。この実装は `POST /synth` と `GET /speakers` を
+ * 持つ形に合わせてある。
+ *
+ * 声の指定は `speaker_id`(登録済み話者)と `caption`(自由記述)の排他。
+ *
+ * 【外に出す前に必ず外すこと】
+ * いま使っているのは speaker_id のほう。登録話者は既存作品の登場人物で、しかも一体ずつに
+ * 実在の声優名(`cv`)が紐づいている。開発中に声の付いた画を確かめるための仮配線であって、
+ * このまま公開すると、本人の同意なく複製された声を配ることになる。
+ * 公開に向かうときは caption 側(`voiceCaption` / `voiceSeed` の列)へ戻す。
+ */
+
+export type Speaker = { uuid: string; name: string }
+
+const speakerListSchema = z.object({
+ speakers: z.array(z.object({ uuid: z.uuid(), name: z.string().nonempty() }).loose()),
+})
+
+/*
+ 話者一覧は動かないデータなので isolate に溜める。台詞1つごとに引き直すと、
+ 再生のたびに往復が1つ増える。シークレットではないので置いておいて差し支えない。
+*/
+const cached: { speakers: Speaker[] | undefined } = { speakers: undefined }
+
+/** 登録話者の一覧。取れなければ空。 */
+export const listSpeakers = async (baseUrl: string): Promise => {
+ if (cached.speakers !== undefined) {
+ return cached.speakers
+ }
+
+ const response = await fetch(`${baseUrl.replace(/\/+$/, '')}/speakers`, {
+ headers: { Accept: 'application/json' },
+ }).catch(() => undefined)
+
+ if (response === undefined || !response.ok) {
+ console.error('[tts] failed to list speakers', response?.status)
+
+ return []
+ }
+
+ const parsed = speakerListSchema.safeParse(await response.json().catch(() => undefined))
+
+ if (!parsed.success) {
+ console.error('[tts] unexpected speaker list shape')
+
+ return []
+ }
+
+ cached.speakers = parsed.data.speakers
+
+ return cached.speakers
+}
+
+/**
+ * キャラクターに話者を割り当てる。
+ *
+ * 一覧に性別も年齢も入っていないので、キャラクターの `gender` や `ageGroup` とは
+ * 突き合わせられない。**声と人物像は噛み合わない**(女性NPCに男性の声が当たる)。
+ * 仮配線として割り切っている点で、直すには話者側にその情報が要る。
*
- * 声の指定は `speaker_id`(登録済み話者)と `caption`(自由記述)の排他。ここは caption だけを
- * 使う。登録済み話者は既存作品のキャラクターで、権利の面でそのまま乗せられない。
+ * UUIDから引くので、同じ人物には毎回同じ声が当たる。確かめたいのはそこ。
*/
+export const speakerFor = (speakers: Speaker[], characterId: string): Speaker | undefined => {
+ if (speakers.length === 0) {
+ return undefined
+ }
-export type Voice = {
- /** 固定しないと台詞ごとに別人の声になる。 */
- seed: number
- /** 声質を書いた一文。場面ごとの感情はここへ足してから渡す。 */
- caption: string
+ const digest = Array.from(characterId).reduce(
+ (sum, char) => (sum * 31 + char.charCodeAt(0)) % 1e9,
+ 7,
+ )
+
+ return speakers[digest % speakers.length]
}
+/** 登録話者で鳴らす。細かい調整は送らない——話者ごとの既定が上流にある。 */
+export type Voice = { speakerId: string }
+
/**
* 上流の応答をそのまま返す。本文を isolate に溜めないのは、48kHz の wav が
* 短い台詞でも数百KBあり、同時に喋る人数だけ積み上がるため。
@@ -26,5 +92,5 @@ export const synthesize = async (baseUrl: string, text: string, voice: Voice): P
fetch(`${baseUrl.replace(/\/+$/, '')}/synth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'audio/wav' },
- body: JSON.stringify({ text, caption: voice.caption, seed: voice.seed }),
+ body: JSON.stringify({ text, speaker_id: voice.speakerId }),
})
From b9f04e0088c9c904d2e5e9c3fb8558cbbaed8b1d Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Wed, 9 Sep 2026 07:08:40 +0000
Subject: [PATCH 28/41] feat(tts): persist registered speakers and assign them
to characters
Store Irodori speaker metadata in D1 and reuse deterministic character assignments.
Co-Authored-By: Claude Haiku 4.5
Co-Authored-By: Claude
---
.gitignore | 1 +
db/migrations/0029_tts-speakers.sql | 6 +
db/migrations/meta/0029_snapshot.json | 1593 +++++++++++++++++++++++++
db/migrations/meta/_journal.json | 7 +
db/schema.ts | 20 +
db/tts-speakers.ts | 133 +++
package.json | 2 +
src/server/routes/voice.ts | 31 +-
src/server/tts/irodori.ts | 72 +-
9 files changed, 1803 insertions(+), 62 deletions(-)
create mode 100644 db/migrations/0029_tts-speakers.sql
create mode 100644 db/migrations/meta/0029_snapshot.json
create mode 100644 db/tts-speakers.ts
diff --git a/.gitignore b/.gitignore
index 28e15a2..3b7b1d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -257,6 +257,7 @@ vite.config.*.timestamp-*
/db/seeds/*.local.*
# db:seed が生成する投入用SQL。中身は db/scenarios/*.yaml から再生成できる。
/db/seed.sql
+/db/tts-speakers.sql
### Cloudflare Workers ###
# wrangler のローカル状態(DO/KVのエミュレーションデータなど)
diff --git a/db/migrations/0029_tts-speakers.sql b/db/migrations/0029_tts-speakers.sql
new file mode 100644
index 0000000..2309603
--- /dev/null
+++ b/db/migrations/0029_tts-speakers.sql
@@ -0,0 +1,6 @@
+CREATE TABLE `tts_speakers` (
+ `id` text PRIMARY KEY NOT NULL,
+ `name` text NOT NULL,
+ `age_group` text DEFAULT 'unknown' NOT NULL,
+ `gender` text DEFAULT 'unknown' NOT NULL
+);
diff --git a/db/migrations/meta/0029_snapshot.json b/db/migrations/meta/0029_snapshot.json
new file mode 100644
index 0000000..5a4c27f
--- /dev/null
+++ b/db/migrations/meta/0029_snapshot.json
@@ -0,0 +1,1593 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "1a38c7d6-fe72-48c7-823b-ef2fda11faca",
+ "prevId": "a4d4ac81-17e6-4235-972e-6a800b527a6d",
+ "tables": {
+ "analytics_sessions": {
+ "name": "analytics_sessions",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "detective": {
+ "name": "detective",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "max_turns": {
+ "name": "max_turns",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "questions_per_turn": {
+ "name": "questions_per_turn",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exchanges_per_topic": {
+ "name": "exchanges_per_topic",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "culprit_character_id": {
+ "name": "culprit_character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "culprit_correct": {
+ "name": "culprit_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method_correct": {
+ "name": "method_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive_correct": {
+ "name": "motive_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reasoning": {
+ "name": "reasoning",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive": {
+ "name": "motive",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method_comment": {
+ "name": "method_comment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive_comment": {
+ "name": "motive_comment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "solved_seconds": {
+ "name": "solved_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "question_count": {
+ "name": "question_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "evidence_found": {
+ "name": "evidence_found",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "evidence_total": {
+ "name": "evidence_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contradiction_count": {
+ "name": "contradiction_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "accuracy_percent": {
+ "name": "accuracy_percent",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "analytics_sessions_scenario_id_idx": {
+ "name": "analytics_sessions_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ },
+ "analytics_sessions_started_at_idx": {
+ "name": "analytics_sessions_started_at_idx",
+ "columns": [
+ "started_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "analytics_turns": {
+ "name": "analytics_turns",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_kind": {
+ "name": "subject_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "turn_index": {
+ "name": "turn_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "question_count": {
+ "name": "question_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "topic": {
+ "name": "topic",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exchanges": {
+ "name": "exchanges",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revealed_evidence_ids": {
+ "name": "revealed_evidence_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revealed_revelation_ids": {
+ "name": "revealed_revelation_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contradiction_pointed_out": {
+ "name": "contradiction_pointed_out",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "npc_lied": {
+ "name": "npc_lied",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "analytics_turns_session_id_idx": {
+ "name": "analytics_turns_session_id_idx",
+ "columns": [
+ "session_id"
+ ],
+ "isUnique": false
+ },
+ "analytics_turns_scenario_id_idx": {
+ "name": "analytics_turns_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ },
+ "analytics_turns_created_at_idx": {
+ "name": "analytics_turns_created_at_idx",
+ "columns": [
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "characters": {
+ "name": "characters",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "short_name": {
+ "name": "short_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "age_group": {
+ "name": "age_group",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ },
+ "gender": {
+ "name": "gender",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ },
+ "voice_seed": {
+ "name": "voice_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "voice_caption": {
+ "name": "voice_caption",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "public_introduction": {
+ "name": "public_introduction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "personality": {
+ "name": "personality",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "knowledge": {
+ "name": "knowledge",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "secrets": {
+ "name": "secrets",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "goals": {
+ "name": "goals",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lies": {
+ "name": "lies",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lie_refs": {
+ "name": "lie_refs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "memories": {
+ "name": "memories",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "characters_scenario_id_idx": {
+ "name": "characters_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "characters_scenario_id_scenarios_id_fk": {
+ "name": "characters_scenario_id_scenarios_id_fk",
+ "tableFrom": "characters",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "discoveries": {
+ "name": "discoveries",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "evidence_id": {
+ "name": "evidence_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "discovered_at": {
+ "name": "discovered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "discoveries_evidence_id_idx": {
+ "name": "discoveries_evidence_id_idx",
+ "columns": [
+ "evidence_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "discoveries_session_id_play_sessions_id_fk": {
+ "name": "discoveries_session_id_play_sessions_id_fk",
+ "tableFrom": "discoveries",
+ "tableTo": "play_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "discoveries_evidence_id_evidences_id_fk": {
+ "name": "discoveries_evidence_id_evidences_id_fk",
+ "tableFrom": "discoveries",
+ "tableTo": "evidences",
+ "columnsFrom": [
+ "evidence_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "discoveries_session_id_evidence_id_pk": {
+ "columns": [
+ "session_id",
+ "evidence_id"
+ ],
+ "name": "discoveries_session_id_evidence_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "evidences": {
+ "name": "evidences",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reveal_condition": {
+ "name": "reveal_condition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "supports": {
+ "name": "supports",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "contradicts": {
+ "name": "contradicts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "reveals_death_time": {
+ "name": "reveals_death_time",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "evidences_scenario_id_idx": {
+ "name": "evidences_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "evidences_scenario_id_scenarios_id_fk": {
+ "name": "evidences_scenario_id_scenarios_id_fk",
+ "tableFrom": "evidences",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "llm_usages": {
+ "name": "llm_usages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "input_tokens": {
+ "name": "input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "output_tokens": {
+ "name": "output_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "cached_input_tokens": {
+ "name": "cached_input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "cache_creation_input_tokens": {
+ "name": "cache_creation_input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "reasoning_tokens": {
+ "name": "reasoning_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "llm_usages_created_at_idx": {
+ "name": "llm_usages_created_at_idx",
+ "columns": [
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "llm_usages_session_id_idx": {
+ "name": "llm_usages_session_id_idx",
+ "columns": [
+ "session_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "messages": {
+ "name": "messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "character_id": {
+ "name": "character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "messages_session_id_idx": {
+ "name": "messages_session_id_idx",
+ "columns": [
+ "session_id"
+ ],
+ "isUnique": false
+ },
+ "messages_character_id_idx": {
+ "name": "messages_character_id_idx",
+ "columns": [
+ "character_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "messages_session_id_play_sessions_id_fk": {
+ "name": "messages_session_id_play_sessions_id_fk",
+ "tableFrom": "messages",
+ "tableTo": "play_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "messages_character_id_characters_id_fk": {
+ "name": "messages_character_id_characters_id_fk",
+ "tableFrom": "messages",
+ "tableTo": "characters",
+ "columnsFrom": [
+ "character_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "play_sessions": {
+ "name": "play_sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "detective": {
+ "name": "detective",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "play_sessions_scenario_id_idx": {
+ "name": "play_sessions_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ },
+ "play_sessions_started_at_idx": {
+ "name": "play_sessions_started_at_idx",
+ "columns": [
+ "started_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "play_sessions_scenario_id_scenarios_id_fk": {
+ "name": "play_sessions_scenario_id_scenarios_id_fk",
+ "tableFrom": "play_sessions",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "reports": {
+ "name": "reports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reported_at": {
+ "name": "reported_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "reports_scenario_id_idx": {
+ "name": "reports_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "reports_scenario_id_scenarios_id_fk": {
+ "name": "reports_scenario_id_scenarios_id_fk",
+ "tableFrom": "reports",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "results": {
+ "name": "results",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "solved_seconds": {
+ "name": "solved_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "question_count": {
+ "name": "question_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "evidence_found": {
+ "name": "evidence_found",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contradiction_count": {
+ "name": "contradiction_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accuracy_percent": {
+ "name": "accuracy_percent",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "method_correct": {
+ "name": "method_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive_correct": {
+ "name": "motive_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deduction": {
+ "name": "deduction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "results_session_id_play_sessions_id_fk": {
+ "name": "results_session_id_play_sessions_id_fk",
+ "tableFrom": "results",
+ "tableTo": "play_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "revelation_discoveries": {
+ "name": "revelation_discoveries",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revelation_id": {
+ "name": "revelation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "discovered_at": {
+ "name": "discovered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "revelation_discoveries_revelation_id_idx": {
+ "name": "revelation_discoveries_revelation_id_idx",
+ "columns": [
+ "revelation_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "revelation_discoveries_session_id_play_sessions_id_fk": {
+ "name": "revelation_discoveries_session_id_play_sessions_id_fk",
+ "tableFrom": "revelation_discoveries",
+ "tableTo": "play_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "revelation_discoveries_revelation_id_revelations_id_fk": {
+ "name": "revelation_discoveries_revelation_id_revelations_id_fk",
+ "tableFrom": "revelation_discoveries",
+ "tableTo": "revelations",
+ "columnsFrom": [
+ "revelation_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "revelation_discoveries_session_id_revelation_id_pk": {
+ "columns": [
+ "session_id",
+ "revelation_id"
+ ],
+ "name": "revelation_discoveries_session_id_revelation_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "revelations": {
+ "name": "revelations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_type": {
+ "name": "subject_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "related_facts": {
+ "name": "related_facts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "revelations_scenario_id_idx": {
+ "name": "revelations_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "revelations_scenario_id_scenarios_id_fk": {
+ "name": "revelations_scenario_id_scenarios_id_fk",
+ "tableFrom": "revelations",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "scenario_truths": {
+ "name": "scenario_truths",
+ "columns": {
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "culprit_character_id": {
+ "name": "culprit_character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "truth": {
+ "name": "truth",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive": {
+ "name": "motive",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "timeline": {
+ "name": "timeline",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "timeline_events": {
+ "name": "timeline_events",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "victim_cause_of_death": {
+ "name": "victim_cause_of_death",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_findings": {
+ "name": "victim_findings",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "place_findings": {
+ "name": "place_findings",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "secret_keywords": {
+ "name": "secret_keywords",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "scenario_truths_scenario_id_scenarios_id_fk": {
+ "name": "scenario_truths_scenario_id_scenarios_id_fk",
+ "tableFrom": "scenario_truths",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "scenarios": {
+ "name": "scenarios",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "synopsis": {
+ "name": "synopsis",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "briefing": {
+ "name": "briefing",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "floor_plan": {
+ "name": "floor_plan",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "time_start": {
+ "name": "time_start",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "time_end": {
+ "name": "time_end",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_name": {
+ "name": "victim_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_introduction": {
+ "name": "victim_introduction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_found_at": {
+ "name": "victim_found_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_found_in": {
+ "name": "victim_found_in",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_investigable": {
+ "name": "victim_investigable",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "victim_estimated_death_at": {
+ "name": "victim_estimated_death_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "places": {
+ "name": "places",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_published": {
+ "name": "is_published",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "difficulty": {
+ "name": "difficulty",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 3
+ },
+ "estimated_minutes": {
+ "name": "estimated_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 10
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tts_speakers": {
+ "name": "tts_speakers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "age_group": {
+ "name": "age_group",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ },
+ "gender": {
+ "name": "gender",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json
index a0aa91b..88630cb 100644
--- a/db/migrations/meta/_journal.json
+++ b/db/migrations/meta/_journal.json
@@ -197,6 +197,13 @@
"when": 1788865661202,
"tag": "0028_llm-usage-drop-provider",
"breakpoints": true
+ },
+ {
+ "idx": 29,
+ "version": "6",
+ "when": 1788937520736,
+ "tag": "0029_tts-speakers",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/db/schema.ts b/db/schema.ts
index d44d030..416ffd8 100644
--- a/db/schema.ts
+++ b/db/schema.ts
@@ -628,3 +628,23 @@ export const analyticsTurns = sqliteTable(
index('analytics_turns_created_at_idx').on(table.createdAt),
],
)
+
+/**
+ * Irodori-TTS の登録話者に、年ごろと性別を付けた表。
+ *
+ * TTS 側の `/speakers` は uuid と名前しか返さないので、キャラクターの `age_group` /
+ * `gender` と突き合わせるための情報がここにしか無い。値は `db/tts-speakers.ts` が
+ * Author LLM に話者名から推定させて入れる。
+ *
+ * **開発中の仮のもの。** 登録話者は既存作品の登場人物で、公開する作品には乗せられない
+ * (`src/server/tts/irodori.ts` の但し書きを参照)。声の作り分けを画面で確かめるための
+ * 足場であって、外へ出すときはこの表ごと落とす。
+ */
+export const ttsSpeakers = sqliteTable('tts_speakers', {
+ /** TTS 側の話者 UUID。こちらでは採番しない。 */
+ id: text('id').primaryKey(),
+ /** 突き合わせには使わない。推定を人が見直すときの手がかり。 */
+ name: text('name').notNull(),
+ ageGroup: text('age_group').$type().notNull().default('unknown'),
+ gender: text('gender').$type().notNull().default('unknown'),
+})
diff --git a/db/tts-speakers.ts b/db/tts-speakers.ts
new file mode 100644
index 0000000..0940e37
--- /dev/null
+++ b/db/tts-speakers.ts
@@ -0,0 +1,133 @@
+import { generateObject } from 'ai'
+import { SQLiteSyncDialect } from 'drizzle-orm/sqlite-core'
+import { drizzle } from 'drizzle-orm/sqlite-proxy'
+import { z } from 'zod'
+import { parseEnv } from '@/server/env'
+import { chooseLlm, resolveModel } from '@/server/llm/provider'
+import { ageGroupSchema, genderSchema } from './person'
+import { ttsSpeakers } from './schema'
+
+/**
+ * Irodori-TTS の登録話者に年ごろと性別を付けて D1 へ投入する SQL を書き出す。
+ *
+ * bun run db:speakers # db/tts-speakers.sql を生成
+ * bun run db:speakers:apply # wrangler で流し込む
+ *
+ * TTS 側の `/speakers` は uuid と名前しか返さないので、キャラクターの年ごろ・性別と
+ * 突き合わせる材料がない。名前は既存作品の登場人物なので、Author LLM に推定させる。
+ *
+ * 声優名(`cv`)は渡さない。当てたいのは「その声がどう聞こえるか」であって、
+ * 演じている人の属性ではない。実在の人物を分類させる形にもしたくない。
+ *
+ * **開発中の足場。** 登録話者は公開する作品には乗せられない
+ * (`src/server/tts/irodori.ts` の但し書き)。外へ出すときはこの表ごと落とす。
+ *
+ * db/seed.ts と同じく、ここは DB に接続しない。D1 は wrangler 経由でしか触れないので、
+ * SQL テキストを書き出して流し込む。
+ */
+
+const OUTPUT_URL = new URL('./tts-speakers.sql', import.meta.url)
+
+/** 一度に分類させる数。多すぎると出力が長くなって取りこぼす。 */
+const BATCH_SIZE = 20
+
+const speakerListSchema = z.object({
+ speakers: z.array(z.object({ uuid: z.uuid(), name: z.string().nonempty() }).loose()),
+})
+
+const classificationSchema = z.object({
+ speakers: z.array(
+ z.object({
+ name: z.string(),
+ ageGroup: ageGroupSchema,
+ gender: genderSchema,
+ }),
+ ),
+})
+
+const SYSTEM_PROMPT = `あなたは声のキャスティングを手伝う人です。
+渡された名前は、いずれもゲームやアニメの登場人物です。それぞれについて、
+その人物の声がどう聞こえるかを踏まえて年ごろと性別を答えてください。
+
+年ごろ: child(12歳ごろまで) / teen(13〜19歳ごろ) / young(20代) /
+adult(30〜40代) / senior(50〜60代) / elder(70代以上) / unknown
+
+性別: male / female / other / unknown
+
+見た目の年齢と設定上の年齢が食い違う人物(長命種など)は、声の印象のほうを採ってください。
+知らない名前には unknown を返してください。推測で埋めないこと。
+渡された名前は一つ残らず、同じ表記のまま返してください。`
+
+const env = parseEnv(process.env)
+const baseUrl = env.TTS_URL
+
+if (baseUrl === undefined) {
+ throw new Error('TTS_URL が設定されていません。')
+}
+
+const response = await fetch(`${baseUrl.replace(/\/+$/, '')}/speakers`, {
+ headers: { Accept: 'application/json' },
+})
+const speakers = speakerListSchema.parse(await response.json()).speakers
+
+console.log(`話者: ${speakers.length}体`)
+
+const model = resolveModel(env, await chooseLlm(env, 'author'))
+
+const batches = Array.from({ length: Math.ceil(speakers.length / BATCH_SIZE) }, (_value, index) =>
+ speakers.slice(index * BATCH_SIZE, (index + 1) * BATCH_SIZE),
+)
+
+const classified = await Promise.all(
+ batches.map(async (batch, index) => {
+ const result = await generateObject({
+ model,
+ schema: classificationSchema,
+ system: SYSTEM_PROMPT,
+ prompt: batch.map((speaker) => speaker.name).join('\n'),
+ })
+
+ console.log(` ${index + 1}/${batches.length}`)
+
+ return result.object.speakers
+ }),
+)
+
+/*
+ 名前で引き直すのは、モデルが並び順を守るとは限らないため。
+ 返ってこなかった名前は unknown のまま入れる——行を落とすと、その話者だけ
+ 「声はあるのに突き合わせられない」状態になり、原因が追いにくい。
+*/
+const byName = new Map(classified.flat().map((entry) => [entry.name, entry]))
+const rows = speakers.map((speaker) => {
+ const found = byName.get(speaker.name)
+
+ return {
+ id: speaker.uuid,
+ name: speaker.name,
+ ageGroup: found === undefined ? ('unknown' as const) : found.ageGroup,
+ gender: found === undefined ? ('unknown' as const) : found.gender,
+ }
+})
+
+const missing = rows.filter((row) => row.gender === 'unknown').length
+
+console.log(`性別が付かなかった話者: ${missing}体`)
+
+const builder = drizzle(async () => {
+ throw new Error('[tts-speakers] このスクリプトは SQL を書き出すだけで、クエリを実行しない')
+})
+const dialect = new SQLiteSyncDialect()
+
+/** 値の埋め込みは drizzle の inlineParams に任せる。理由は db/seed.ts の render と同じ。 */
+const statements = [
+ 'DELETE FROM tts_speakers;',
+ ...rows.map(
+ (row) =>
+ `${dialect.sqlToQuery(builder.insert(ttsSpeakers).values(row).getSQL().inlineParams()).sql};`,
+ ),
+]
+
+await Bun.write(OUTPUT_URL, `${statements.join('\n')}\n`)
+
+console.log(`書き出しました: db/tts-speakers.sql(${rows.length}行)`)
diff --git a/package.json b/package.json
index ec3d9ec..d13ee33 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,8 @@
"db:migrate:remote": "wrangler d1 migrations apply alibai --remote",
"db:seed": "bun run db/seed.ts",
"db:seed:apply": "wrangler d1 execute alibai --local --file=db/seed.sql",
+ "db:speakers": "bun run db/tts-speakers.ts",
+ "db:speakers:apply": "wrangler d1 execute alibai --local --file=db/tts-speakers.sql",
"db:seed:apply:remote": "wrangler d1 execute alibai --remote --file=db/seed.sql",
"db:author": "bun run db/generate-scenario.ts",
"se:fetch": "bun run scripts/fetch-se.ts",
diff --git a/src/server/routes/voice.ts b/src/server/routes/voice.ts
index 881b527..32de4e9 100644
--- a/src/server/routes/voice.ts
+++ b/src/server/routes/voice.ts
@@ -5,8 +5,8 @@ import { z } from 'zod'
import { createDb } from '@/server/db/client'
import type { Bindings } from '@/server/env'
import { withEnv } from '@/server/middleware/env'
-import { listSpeakers, speakerFor, synthesize } from '@/server/tts/irodori'
-import { messages } from '~/db/schema'
+import { speakerFor, synthesize } from '@/server/tts/irodori'
+import { characters, messages, ttsSpeakers } from '~/db/schema'
/**
* NPCの台詞を音声にして返す。
@@ -52,8 +52,11 @@ voiceRoutes.get('/api/sessions/:id/messages/:messageId/voice', validateIds, with
content: messages.content,
role: messages.role,
characterId: messages.characterId,
+ ageGroup: characters.ageGroup,
+ gender: characters.gender,
})
.from(messages)
+ .innerJoin(characters, eq(characters.id, messages.characterId))
.where(and(eq(messages.id, ids.messageId), eq(messages.sessionId, ids.id)))
.limit(1)
@@ -69,19 +72,31 @@ voiceRoutes.get('/api/sessions/:id/messages/:messageId/voice', validateIds, with
}
/*
- 話者はキャラクターのUUIDから引く。シナリオのデータには持たせていない。
+ 話者は人物の年ごろと性別で絞ってから、UUIDで一体に決める。シナリオのデータには
+ 持たせていない。登録話者は既存作品の登場人物なので、割り当てをリポジトリに
+ 焼き込みたくない(`src/server/tts/irodori.ts` の但し書きを参照)。
- 登録話者は既存作品の登場人物なので、割り当てをリポジトリに焼き込みたくない
- (`src/server/tts/irodori.ts` の但し書きを参照)。ここで引く形なら、公開へ向かうときに
- この関数を差し替えるだけで済み、43本のシナリオを洗い直さずに戻せる。
+ 絞り込みは性別を先に、年ごろを後に落とす。声を聞いて一番先に「違う」と分かるのが
+ 性別で、年ごろの隣(young と adult など)は許容できる幅があるため。
*/
- const speaker = speakerFor(await listSpeakers(baseUrl), row.characterId)
+ const matched = await db
+ .select({ id: ttsSpeakers.id })
+ .from(ttsSpeakers)
+ .where(and(eq(ttsSpeakers.gender, row.gender), eq(ttsSpeakers.ageGroup, row.ageGroup)))
+ const sameGender =
+ matched.length > 0
+ ? matched
+ : await db
+ .select({ id: ttsSpeakers.id })
+ .from(ttsSpeakers)
+ .where(eq(ttsSpeakers.gender, row.gender))
+ const speaker = speakerFor(sameGender, row.characterId)
if (speaker === undefined) {
return c.json({ error: 'character has no voice' }, 404)
}
- const upstream = await synthesize(baseUrl, row.content, { speakerId: speaker.uuid })
+ const upstream = await synthesize(baseUrl, row.content, { speakerId: speaker.id })
if (!upstream.ok) {
return c.json({ error: 'synthesis failed' }, 502)
diff --git a/src/server/tts/irodori.ts b/src/server/tts/irodori.ts
index 11b3be4..0ba7556 100644
--- a/src/server/tts/irodori.ts
+++ b/src/server/tts/irodori.ts
@@ -1,72 +1,36 @@
-import { z } from 'zod'
-
/**
* Irodori-TTS へ台詞を投げて音声を受け取る。
*
- * 仕様の正典は各デプロイの `/openapi.json`。この実装は `POST /synth` と `GET /speakers` を
- * 持つ形に合わせてある。
+ * 仕様の正典は各デプロイの `/openapi.json`。この実装は `POST /synth` を持つ形に合わせてある。
*
* 声の指定は `speaker_id`(登録済み話者)と `caption`(自由記述)の排他。
*
* 【外に出す前に必ず外すこと】
* いま使っているのは speaker_id のほう。登録話者は既存作品の登場人物で、しかも一体ずつに
- * 実在の声優名(`cv`)が紐づいている。開発中に声の付いた画を確かめるための仮配線であって、
+ * 実在の声優名が紐づいている。開発中に声の付いた画を確かめるための仮配線であって、
* このまま公開すると、本人の同意なく複製された声を配ることになる。
* 公開に向かうときは caption 側(`voiceCaption` / `voiceSeed` の列)へ戻す。
+ *
+ * caption を使わない理由は品質のほう。**seed を固定しても、台詞が変わると別人の声になる。**
+ * 同じ文・同じ seed なら出力はバイト単位で一致するので seed 自体は効いているが、
+ * VoiceDesign モードでは文が変われば声色が動く。人物ごとに声を固定する用途には使えない。
*/
-export type Speaker = { uuid: string; name: string }
-
-const speakerListSchema = z.object({
- speakers: z.array(z.object({ uuid: z.uuid(), name: z.string().nonempty() }).loose()),
-})
-
-/*
- 話者一覧は動かないデータなので isolate に溜める。台詞1つごとに引き直すと、
- 再生のたびに往復が1つ増える。シークレットではないので置いておいて差し支えない。
-*/
-const cached: { speakers: Speaker[] | undefined } = { speakers: undefined }
-
-/** 登録話者の一覧。取れなければ空。 */
-export const listSpeakers = async (baseUrl: string): Promise => {
- if (cached.speakers !== undefined) {
- return cached.speakers
- }
-
- const response = await fetch(`${baseUrl.replace(/\/+$/, '')}/speakers`, {
- headers: { Accept: 'application/json' },
- }).catch(() => undefined)
-
- if (response === undefined || !response.ok) {
- console.error('[tts] failed to list speakers', response?.status)
-
- return []
- }
-
- const parsed = speakerListSchema.safeParse(await response.json().catch(() => undefined))
-
- if (!parsed.success) {
- console.error('[tts] unexpected speaker list shape')
-
- return []
- }
-
- cached.speakers = parsed.data.speakers
-
- return cached.speakers
-}
+/** 突き合わせに要るのは id だけ。名前や年ごろは呼ぶ側が持っていればよい。 */
+export type Speaker = { id: string }
/**
- * キャラクターに話者を割り当てる。
- *
- * 一覧に性別も年齢も入っていないので、キャラクターの `gender` や `ageGroup` とは
- * 突き合わせられない。**声と人物像は噛み合わない**(女性NPCに男性の声が当たる)。
- * 仮配線として割り切っている点で、直すには話者側にその情報が要る。
+ * 候補の中から一体を決める。
*
- * UUIDから引くので、同じ人物には毎回同じ声が当たる。確かめたいのはそこ。
+ * UUIDから引くので、同じ人物には毎回同じ声が当たる。候補の並びが変わらない限り、
+ * 話者一覧に追加があっても既存の割り当ては動かない——ただし候補が増減すれば動く。
+ * 開発中の足場なので、そこまでの安定は求めていない。
*/
-export const speakerFor = (speakers: Speaker[], characterId: string): Speaker | undefined => {
- if (speakers.length === 0) {
+export const speakerFor = (
+ candidates: T[],
+ characterId: string,
+): T | undefined => {
+ if (candidates.length === 0) {
return undefined
}
@@ -75,7 +39,7 @@ export const speakerFor = (speakers: Speaker[], characterId: string): Speaker |
7,
)
- return speakers[digest % speakers.length]
+ return candidates[digest % candidates.length]
}
/** 登録話者で鳴らす。細かい調整は送らない——話者ごとの既定が上流にある。 */
From 081f373850c079585a0bf33b88e09affdc02223e Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Wed, 9 Sep 2026 07:13:59 +0000
Subject: [PATCH 29/41] chore(tts): remove speaker classification script
Co-Authored-By: Claude
---
db/tts-speakers.ts | 133 ---------------------------------------------
package.json | 1 -
2 files changed, 134 deletions(-)
delete mode 100644 db/tts-speakers.ts
diff --git a/db/tts-speakers.ts b/db/tts-speakers.ts
deleted file mode 100644
index 0940e37..0000000
--- a/db/tts-speakers.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-import { generateObject } from 'ai'
-import { SQLiteSyncDialect } from 'drizzle-orm/sqlite-core'
-import { drizzle } from 'drizzle-orm/sqlite-proxy'
-import { z } from 'zod'
-import { parseEnv } from '@/server/env'
-import { chooseLlm, resolveModel } from '@/server/llm/provider'
-import { ageGroupSchema, genderSchema } from './person'
-import { ttsSpeakers } from './schema'
-
-/**
- * Irodori-TTS の登録話者に年ごろと性別を付けて D1 へ投入する SQL を書き出す。
- *
- * bun run db:speakers # db/tts-speakers.sql を生成
- * bun run db:speakers:apply # wrangler で流し込む
- *
- * TTS 側の `/speakers` は uuid と名前しか返さないので、キャラクターの年ごろ・性別と
- * 突き合わせる材料がない。名前は既存作品の登場人物なので、Author LLM に推定させる。
- *
- * 声優名(`cv`)は渡さない。当てたいのは「その声がどう聞こえるか」であって、
- * 演じている人の属性ではない。実在の人物を分類させる形にもしたくない。
- *
- * **開発中の足場。** 登録話者は公開する作品には乗せられない
- * (`src/server/tts/irodori.ts` の但し書き)。外へ出すときはこの表ごと落とす。
- *
- * db/seed.ts と同じく、ここは DB に接続しない。D1 は wrangler 経由でしか触れないので、
- * SQL テキストを書き出して流し込む。
- */
-
-const OUTPUT_URL = new URL('./tts-speakers.sql', import.meta.url)
-
-/** 一度に分類させる数。多すぎると出力が長くなって取りこぼす。 */
-const BATCH_SIZE = 20
-
-const speakerListSchema = z.object({
- speakers: z.array(z.object({ uuid: z.uuid(), name: z.string().nonempty() }).loose()),
-})
-
-const classificationSchema = z.object({
- speakers: z.array(
- z.object({
- name: z.string(),
- ageGroup: ageGroupSchema,
- gender: genderSchema,
- }),
- ),
-})
-
-const SYSTEM_PROMPT = `あなたは声のキャスティングを手伝う人です。
-渡された名前は、いずれもゲームやアニメの登場人物です。それぞれについて、
-その人物の声がどう聞こえるかを踏まえて年ごろと性別を答えてください。
-
-年ごろ: child(12歳ごろまで) / teen(13〜19歳ごろ) / young(20代) /
-adult(30〜40代) / senior(50〜60代) / elder(70代以上) / unknown
-
-性別: male / female / other / unknown
-
-見た目の年齢と設定上の年齢が食い違う人物(長命種など)は、声の印象のほうを採ってください。
-知らない名前には unknown を返してください。推測で埋めないこと。
-渡された名前は一つ残らず、同じ表記のまま返してください。`
-
-const env = parseEnv(process.env)
-const baseUrl = env.TTS_URL
-
-if (baseUrl === undefined) {
- throw new Error('TTS_URL が設定されていません。')
-}
-
-const response = await fetch(`${baseUrl.replace(/\/+$/, '')}/speakers`, {
- headers: { Accept: 'application/json' },
-})
-const speakers = speakerListSchema.parse(await response.json()).speakers
-
-console.log(`話者: ${speakers.length}体`)
-
-const model = resolveModel(env, await chooseLlm(env, 'author'))
-
-const batches = Array.from({ length: Math.ceil(speakers.length / BATCH_SIZE) }, (_value, index) =>
- speakers.slice(index * BATCH_SIZE, (index + 1) * BATCH_SIZE),
-)
-
-const classified = await Promise.all(
- batches.map(async (batch, index) => {
- const result = await generateObject({
- model,
- schema: classificationSchema,
- system: SYSTEM_PROMPT,
- prompt: batch.map((speaker) => speaker.name).join('\n'),
- })
-
- console.log(` ${index + 1}/${batches.length}`)
-
- return result.object.speakers
- }),
-)
-
-/*
- 名前で引き直すのは、モデルが並び順を守るとは限らないため。
- 返ってこなかった名前は unknown のまま入れる——行を落とすと、その話者だけ
- 「声はあるのに突き合わせられない」状態になり、原因が追いにくい。
-*/
-const byName = new Map(classified.flat().map((entry) => [entry.name, entry]))
-const rows = speakers.map((speaker) => {
- const found = byName.get(speaker.name)
-
- return {
- id: speaker.uuid,
- name: speaker.name,
- ageGroup: found === undefined ? ('unknown' as const) : found.ageGroup,
- gender: found === undefined ? ('unknown' as const) : found.gender,
- }
-})
-
-const missing = rows.filter((row) => row.gender === 'unknown').length
-
-console.log(`性別が付かなかった話者: ${missing}体`)
-
-const builder = drizzle(async () => {
- throw new Error('[tts-speakers] このスクリプトは SQL を書き出すだけで、クエリを実行しない')
-})
-const dialect = new SQLiteSyncDialect()
-
-/** 値の埋め込みは drizzle の inlineParams に任せる。理由は db/seed.ts の render と同じ。 */
-const statements = [
- 'DELETE FROM tts_speakers;',
- ...rows.map(
- (row) =>
- `${dialect.sqlToQuery(builder.insert(ttsSpeakers).values(row).getSQL().inlineParams()).sql};`,
- ),
-]
-
-await Bun.write(OUTPUT_URL, `${statements.join('\n')}\n`)
-
-console.log(`書き出しました: db/tts-speakers.sql(${rows.length}行)`)
diff --git a/package.json b/package.json
index d13ee33..e2dfb0b 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,6 @@
"db:migrate:remote": "wrangler d1 migrations apply alibai --remote",
"db:seed": "bun run db/seed.ts",
"db:seed:apply": "wrangler d1 execute alibai --local --file=db/seed.sql",
- "db:speakers": "bun run db/tts-speakers.ts",
"db:speakers:apply": "wrangler d1 execute alibai --local --file=db/tts-speakers.sql",
"db:seed:apply:remote": "wrangler d1 execute alibai --remote --file=db/seed.sql",
"db:author": "bun run db/generate-scenario.ts",
From b959346c2b421931165331042c38f9b07cbffea3 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Wed, 9 Sep 2026 07:17:34 +0000
Subject: [PATCH 30/41] feat(scenarios): add age group and gender metadata to
characters
Co-Authored-By: Claude
---
db/scenarios/avalanche-lodge-phantom-room.yaml | 6 ++++++
db/scenarios/avalanche-monastery-bell-window.yaml | 6 ++++++
db/scenarios/blizzard-lodge-seat-score-alibi.yaml | 8 ++++++++
db/scenarios/cave-lab-locator-cart.yaml | 8 ++++++++
db/scenarios/coldcase-lodge-borrowed-memory.yaml | 6 ++++++
db/scenarios/deepsea-habitat-batch-signature.yaml | 8 ++++++++
db/scenarios/ferry-fog-passenger-count.yaml | 6 ++++++
db/scenarios/festival-lantern-blackout.yaml | 6 ++++++
db/scenarios/flood-archive-self-locking-vault.yaml | 8 ++++++++
db/scenarios/flood-courthouse-unlogged-door.yaml | 6 ++++++
db/scenarios/gallery-blue-frame.yaml | 6 ++++++
db/scenarios/generation-ship-staggered-dawn.yaml | 6 ++++++
db/scenarios/glasshouse-dawn-irrigation.yaml | 6 ++++++
db/scenarios/ink-stained-contract.yaml | 6 ++++++
db/scenarios/landslide-clock-museum-eleven-minutes.yaml | 8 ++++++++
db/scenarios/landslide-hotel-frosted-silhouette.yaml | 6 ++++++
db/scenarios/last-train-delay-certificate.yaml | 6 ++++++
db/scenarios/mars-outpost-delayed-reply.yaml | 6 ++++++
db/scenarios/midnight-radio-rerun.yaml | 6 ++++++
db/scenarios/polar-station-shared-clock.yaml | 6 ++++++
db/scenarios/quarantine-clinic-borrowed-badge.yaml | 6 ++++++
db/scenarios/rainy-bookstore-receipt.yaml | 6 ++++++
db/scenarios/shanghai-warehouse-carbon-copy.yaml | 6 ++++++
db/scenarios/snow-gallery-erased-tracks.yaml | 6 ++++++
db/scenarios/snow-observatory-last-photo.yaml | 6 ++++++
db/scenarios/snowbound-farm-morning-chores.yaml | 6 ++++++
db/scenarios/snowbound-gallery-wrong-crime-scene.yaml | 6 ++++++
db/scenarios/snowbound-studio-roomtone-loop.yaml | 8 ++++++++
db/scenarios/snowbound-theater-auto-cues.yaml | 8 ++++++++
29 files changed, 188 insertions(+)
diff --git a/db/scenarios/avalanche-lodge-phantom-room.yaml b/db/scenarios/avalanche-lodge-phantom-room.yaml
index 62db420..7ad489d 100644
--- a/db/scenarios/avalanche-lodge-phantom-room.yaml
+++ b/db/scenarios/avalanche-lodge-phantom-room.yaml
@@ -135,6 +135,8 @@ characters:
- id: natsume
name: 夏目悠
publicIntroduction: "丁寧で機転が利く夜勤責任者。"
+ ageGroup: adult
+ gender: male
personality: 丁寧で機転が利く夜勤責任者。館内事情には誰より詳しいが、会計の話になると慎重に言葉を選ぶ。早瀬には長年世話になった一方、翌朝の監査を強く恐れていた。
goals:
- 二一四号室を探していた時間を自分のアリバイとして成立させたい
@@ -173,6 +175,8 @@ characters:
- id: oda
name: 小田真紀
publicIntroduction: "几帳面で、人の出入りをよく覚えている客室係。"
+ ageGroup: adult
+ gender: female
personality: 几帳面で、人の出入りをよく覚えている客室係。自分の備品管理ミスを隠しているため、管理記録を調べられることには抵抗がある。
goals:
- リネン破損の記録改ざんを隠したい
@@ -196,6 +200,8 @@ characters:
- id: fujino
name: 藤野修平
publicIntroduction: "気さくで話好きな料理人。"
+ ageGroup: adult
+ gender: male
personality: 気さくで話好きな料理人。細かな時刻には弱いが、厨房前で誰と話したかはよく覚えている。持ち帰ろうとした料理用ワインの件だけは触れられたくない。
goals:
- ワインの持ち帰りを隠したい
diff --git a/db/scenarios/avalanche-monastery-bell-window.yaml b/db/scenarios/avalanche-monastery-bell-window.yaml
index 1e84994..8ab6a2d 100644
--- a/db/scenarios/avalanche-monastery-bell-window.yaml
+++ b/db/scenarios/avalanche-monastery-bell-window.yaml
@@ -132,6 +132,8 @@ characters:
- id: mizuki
name: 水城奈央
publicIntroduction: "修復設計士。"
+ ageGroup: adult
+ gender: female
personality: 理屈が明快な修復設計士で、建物の構造と工事手順に詳しい。鐘は高瀬が鳴らしたという前提を強調し、自分だけが仮設試験線を熟知していることには触れたがらない。
goals:
- 架空の追加工事請求を隠したい
@@ -168,6 +170,8 @@ characters:
- id: kurokawa
name: 黒川玲
publicIntroduction: "文献には厳密だが建築設備には疎い研究者。"
+ ageGroup: adult
+ gender: female
personality: 文献には厳密だが建築設備には疎い研究者。鐘を聞いたことは確かだが、音が鳴る仕組みまでは知らない。撮影禁止史料を写したことを隠している。
goals:
- 禁止史料の無断撮影を隠したい
@@ -194,6 +198,8 @@ characters:
- id: genda
name: 玄田修
publicIntroduction: "長年勤める管理人。"
+ ageGroup: senior
+ gender: male
personality: 長年勤める管理人。鍵の扱いにはうるさいが、工事中の仮設設備には疎い。無断で予備鍵を作ったため、鍵の話題になると妙に防御的になる。
goals:
- 無許可の予備鍵を隠したい
diff --git a/db/scenarios/blizzard-lodge-seat-score-alibi.yaml b/db/scenarios/blizzard-lodge-seat-score-alibi.yaml
index f6a50a0..c870a38 100644
--- a/db/scenarios/blizzard-lodge-seat-score-alibi.yaml
+++ b/db/scenarios/blizzard-lodge-seat-score-alibi.yaml
@@ -142,6 +142,8 @@ characters:
- id: katase
name: 片瀬真央
publicIntroduction: "話術に長けた企画担当。"
+ ageGroup: adult
+ gender: female
personality: 話術に長けた企画担当。得点表に自分の名前があることを「ずっとその場にいた証明」として強調する。講師費の精算内容を詳しく聞かれると話題を変えたがる。
goals:
- 架空の講師費を隠したい
@@ -183,6 +185,8 @@ characters:
- id: todo
name: 藤堂凛
publicIntroduction: "言葉の細部をよく覚える講師。"
+ ageGroup: adult
+ gender: female
personality: 言葉の細部をよく覚える講師。片瀬が戻ってきた場面は覚えているが、研修資料の無断転用を隠したいので自分への追及には敏感。
goals:
- 資料の無断転用を隠したい
@@ -212,6 +216,8 @@ characters:
- id: yoshioka
name: 吉岡蓮
publicIntroduction: "研修の事務を担当する職員。"
+ ageGroup: young
+ gender: male
personality: 数字を整えて記録するのが好きな事務職員。得点表はラウンドごとに後から清書しており、誰が席にいたかまでは記録していない。私用交通費の混入を隠している。
goals:
- 私用交通費の精算を隠したい
@@ -241,6 +247,8 @@ characters:
- id: mamiya
name: 間宮葵
publicIntroduction: "頼まれると断れない設備係。"
+ ageGroup: young
+ gender: female
personality: 頼まれると断れない設備係。片瀬に少しだけ代わってと言われ、深く考えず青席へ座った。空き客室を私物置き場に使っていることを隠したい。
goals:
- 空き客室の私的利用を隠したい
diff --git a/db/scenarios/cave-lab-locator-cart.yaml b/db/scenarios/cave-lab-locator-cart.yaml
index 700ab96..fcaccf6 100644
--- a/db/scenarios/cave-lab-locator-cart.yaml
+++ b/db/scenarios/cave-lab-locator-cart.yaml
@@ -159,6 +159,8 @@ characters:
- id: kagawa
name: 香川紗英
publicIntroduction: "洞窟測量を担当する技術者。"
+ ageGroup: adult
+ gender: female
personality: 位置情報と測量値を絶対視する技術者。数字で説明できることには強気だが、成果の正確さを疑われると攻撃的になる。位置タグの連続軌跡を自分の在席証明として繰り返し示す。
goals:
- 試料採取地点の記録を偽ったことを隠したい
@@ -201,6 +203,8 @@ characters:
- id: shindo
name: 新堂匠
publicIntroduction: "研究成果への競争心が強い地質学者。"
+ ageGroup: adult
+ gender: male
personality: 研究成果への競争心が強い地質学者。共同試料を個人ケースへ移したことを隠すため保管区画の話を避けるが、位置タグの仕組みには詳しくない。
goals:
- 共同試料を個人研究用に移したことを隠したい
@@ -224,6 +228,8 @@ characters:
- id: yuki
name: 結城真
publicIntroduction: "実務優先の設備担当。"
+ ageGroup: adult
+ gender: male
personality: 実務優先の設備担当。換気センサーを無効化した手順違反を隠したいが、安全用位置タグの受信設備も保守しており、タグが人そのものを認識しているわけではないと知っている。
goals:
- 換気センサーの手順外無効化を隠したい
@@ -244,6 +250,8 @@ characters:
- id: tono
name: 遠野澪
publicIntroduction: "調査記録を担当する職員。"
+ ageGroup: young
+ gender: female
personality: 観察した順番を細かく記憶する記録担当。夕方の巡回時刻を書き直したことを隠したいが、21時07分の連絡通路で見た香川の姿は鮮明に覚えている。
goals:
- 夕方の巡回時刻を後から修正したことを隠したい
diff --git a/db/scenarios/coldcase-lodge-borrowed-memory.yaml b/db/scenarios/coldcase-lodge-borrowed-memory.yaml
index 833fccf..e9e0036 100644
--- a/db/scenarios/coldcase-lodge-borrowed-memory.yaml
+++ b/db/scenarios/coldcase-lodge-borrowed-memory.yaml
@@ -131,6 +131,8 @@ characters:
- id: maki
name: 高瀬真紀
publicIntroduction: "2026年現在は穏やかな元教師。"
+ ageGroup: senior
+ gender: female
personality: 2026年現在は穏やかな元教師。47年間「22時30分に暖炉前で野上を見た」と繰り返してきたため、本人にもそれが固い記憶になっている。だが事件当時の現金窃盗は今でも恥じている。
goals:
- 1979年の現金窃盗を隠したい
@@ -163,6 +165,8 @@ characters:
- id: tatsuya
name: 藤村達也
publicIntroduction: "2026年現在は慎重な会社員OB。"
+ ageGroup: senior
+ gender: male
personality: 2026年現在は慎重な会社員OB。今は自分も暖炉前の野上を見た気がしているが、古い供述を読むと自信を失う。若いころの秘密の交際だけは話したくない。
goals:
- 当時の秘密の交際を隠したい
@@ -191,6 +195,8 @@ characters:
- id: megumi
name: 倉田恵
publicIntroduction: "2026年現在は物静かな元経理職。"
+ ageGroup: senior
+ gender: female
personality: 2026年現在は物静かな元経理職。事件当夜は22時15分から眠っていたという最初の供述を強調する。仕入れ代の水増しを問われると防御的になる。
goals:
- 仕入れ代の水増しを隠したい
diff --git a/db/scenarios/deepsea-habitat-batch-signature.yaml b/db/scenarios/deepsea-habitat-batch-signature.yaml
index c75e4d5..cc70e09 100644
--- a/db/scenarios/deepsea-habitat-batch-signature.yaml
+++ b/db/scenarios/deepsea-habitat-batch-signature.yaml
@@ -140,6 +140,8 @@ characters:
- id: sagisawa
name: 鷺沢怜
publicIntroduction: "生命維持設備を担当する技術者。"
+ ageGroup: adult
+ gender: female
personality: 数字と手順を盾にする生命維持技術者。異常時でも声を荒らげないが、自分の整備判断を否定されると頑なになる。点検画面に署名が並んでいることを強調する。
goals:
- 点検記録の改ざんを隠したい
@@ -179,6 +181,8 @@ characters:
- id: narumi
name: 鳴海俊
publicIntroduction: "好奇心旺盛で研究成果への執着が強い地質学者。"
+ ageGroup: adult
+ gender: male
personality: 好奇心旺盛で研究成果への執着が強い地質学者。規約違反の試料取り置きを知られたくないため試料庫の話を避ける。
goals:
- 個人研究用に試料を取り置いたことを隠したい
@@ -199,6 +203,8 @@ characters:
- id: kohinata
name: 小日向茜
publicIntroduction: "通信担当者。"
+ ageGroup: adult
+ gender: female
personality: 時刻と通信記録に几帳面な担当者。自分の手順ミスを消したことには後ろめたさがあるが、中央通路で見た人物と時計表示は正確に覚えている。
goals:
- 支援船との通信一件を削除したことを隠したい
@@ -219,6 +225,8 @@ characters:
- id: toki
name: 土岐誠
publicIntroduction: "経験則を重んじる機械整備担当。"
+ ageGroup: senior
+ gender: male
personality: 経験則を重んじる機械整備担当。期限切れ設備への応急処置を隠すが、点検システムの画面仕様は保守教育で知っている。
goals:
- 昇降機構を手順外で使い続けていたことを隠したい
diff --git a/db/scenarios/ferry-fog-passenger-count.yaml b/db/scenarios/ferry-fog-passenger-count.yaml
index b169118..5529198 100644
--- a/db/scenarios/ferry-fog-passenger-count.yaml
+++ b/db/scenarios/ferry-fog-passenger-count.yaml
@@ -137,6 +137,8 @@ characters:
- id: kanda
name: 神田美奈
publicIntroduction: "手際がよく、乗客対応にも慣れた事務長。"
+ ageGroup: adult
+ gender: female
personality: 手際がよく、乗客対応にも慣れた事務長。数字と帳簿を扱う仕事に自信を持ち、記録さえ整っていれば説明は通ると考えがち。柴田の監査には以前から強い緊張を感じていた。
goals:
- 船内売上金の抜き取りを隠したい
@@ -176,6 +178,8 @@ characters:
- id: ogiwara
name: 荻原陸
publicIntroduction: "明るく人懐っこい若い甲板員。"
+ ageGroup: young
+ gender: male
personality: 明るく人懐っこい若い甲板員。乗客へのサービス精神が強すぎて、出航前に知人を乗員通路へ入れた規則違反を隠したい。船内で見た人の移動はよく覚えている。
goals:
- 知人を乗員通路へ入れたことを隠したい
@@ -199,6 +203,8 @@ characters:
- id: fujiwara
name: 藤原奈緒
publicIntroduction: "粘り強い地方紙記者。"
+ ageGroup: adult
+ gender: female
personality: 粘り強い地方紙記者。情報源を守る意識が強く、柴田との秘密の面会を簡単には認めない。会社の安全管理問題を追っていたため、監査役との接触自体が取材先へ漏れることを恐れている。
goals:
- 柴田から内部資料を受け取ったことを隠したい
diff --git a/db/scenarios/festival-lantern-blackout.yaml b/db/scenarios/festival-lantern-blackout.yaml
index 15a6e1b..deb95d7 100644
--- a/db/scenarios/festival-lantern-blackout.yaml
+++ b/db/scenarios/festival-lantern-blackout.yaml
@@ -145,6 +145,8 @@ characters:
- id: tozuka
name: 戸塚岳
publicIntroduction: "現場慣れした電気技師で、トラブル時ほど落ち着いて話す。"
+ ageGroup: adult
+ gender: male
personality: 現場慣れした電気技師で、トラブル時ほど落ち着いて話す。専門知識への自負が強く、素人には設備の細部まで分からないと思っている。神谷とは発注費を巡って関係が悪化していた。
goals:
- 発注額の水増しと謝礼の受領を隠したい
@@ -178,6 +180,8 @@ characters:
- id: makabe
name: 真壁聡
publicIntroduction: "真面目で融通の利かない禰宜。"
+ ageGroup: adult
+ gender: male
personality: 真面目で融通の利かない禰宜。神社を守る責任感は強いが、修繕費の不足から寄付金を一時流用したことを深く後悔している。鍵の管理について聞かれると必要以上に神経質になる。
goals:
- 寄付金の流用を隠したい
@@ -200,6 +204,8 @@ characters:
- id: aihara
name: 相原夏帆
publicIntroduction: "活発で世話焼きな屋台会のまとめ役。"
+ ageGroup: adult
+ gender: female
personality: 活発で世話焼きな屋台会のまとめ役。祭りの成功を優先するあまり、申請前の知人を出店させた後ろめたさがある。暗闇で見た人物について断定するのをためらうが、服装と歩き方には自信がある。
goals:
- 無許可に近い状態で知人の屋台を出したことを隠したい
diff --git a/db/scenarios/flood-archive-self-locking-vault.yaml b/db/scenarios/flood-archive-self-locking-vault.yaml
index c3b5a1e..865021c 100644
--- a/db/scenarios/flood-archive-self-locking-vault.yaml
+++ b/db/scenarios/flood-archive-self-locking-vault.yaml
@@ -163,6 +163,8 @@ characters:
- id: yagami
name: 八神琴子
publicIntroduction: "手仕事への誇りが高く、修復結果を批判されることに強く反発する修復士。"
+ ageGroup: adult
+ gender: female
personality: 手仕事への誇りが高く、修復結果を批判されることに強く反発する修復士。鍵が今泉のポケットにあったことを何度も強調し、資料庫へ入れるはずがないという印象を作ろうとする。
goals:
- 修復失敗と処置記録の改ざんを隠したい
@@ -204,6 +206,8 @@ characters:
- id: kuga
name: 久我遼
publicIntroduction: "細かな動線を覚えている学芸員。"
+ ageGroup: young
+ gender: male
personality: 細かな動線を覚えている学芸員。資料を傷めたことを隠したいが、21時14分ごろ八神が開いた資料庫へ入った場面ははっきり見ている。
goals:
- 資料を傷めて報告しなかったことを隠したい
@@ -224,6 +228,8 @@ characters:
- id: tozuka
name: 戸塚誠
publicIntroduction: "文書館の警備担当。"
+ ageGroup: adult
+ gender: male
personality: 規則には詳しい警備担当。閲覧室のカメラ停止を隠したいが、希少資料庫の扉が閉まるだけで自動施錠される仕様は日常業務として知っている。
goals:
- 閲覧室の監視カメラを私用のため止めたことを隠したい
@@ -244,6 +250,8 @@ characters:
- id: majima
name: 真島香苗
publicIntroduction: "自信家の外部鑑定人。"
+ ageGroup: adult
+ gender: female
personality: 自信家の外部鑑定人。来歴写真の差し替えを隠したいので今泉との鑑定上の衝突を小さく見せるが、21時台前半は別の資料室で作業していた。
goals:
- 鑑定写真を差し替えたことを隠したい
diff --git a/db/scenarios/flood-courthouse-unlogged-door.yaml b/db/scenarios/flood-courthouse-unlogged-door.yaml
index a1168ae..b0df4b3 100644
--- a/db/scenarios/flood-courthouse-unlogged-door.yaml
+++ b/db/scenarios/flood-courthouse-unlogged-door.yaml
@@ -134,6 +134,8 @@ characters:
- id: yabuki
name: 矢吹梓
publicIntroduction: "裁判所の書記官。"
+ ageGroup: adult
+ gender: female
personality: 論理的で、規則やシステムの仕様を根拠に話す書記官。記録上の証拠を重視するため、自分に有利なログだけを強調する傾向がある。
goals:
- 解錠履歴がないことを、自分が保存記録室へ入っていない証明として通したい
@@ -172,6 +174,8 @@ characters:
- id: mitamura
name: 三田村悟
publicIntroduction: "設備仕様には詳しいが、書類仕事が雑な技術職員。"
+ ageGroup: adult
+ gender: male
personality: 設備仕様には詳しいが、書類仕事が雑な技術職員。点検の一項目を飛ばしたことを隠したい一方、電子錠が何を記録するかは正確に説明できる。
goals:
- 点検項目を飛ばしたことを隠したい
@@ -203,6 +207,8 @@ characters:
- id: kise
name: 木瀬亮
publicIntroduction: "寡黙な警備員。"
+ ageGroup: adult
+ gender: male
personality: 寡黙な警備員。人の歩き方や服装をよく覚えているが、勤務中に古い事件資料を私的に読んでいたことを知られたくない。
goals:
- 私的に事件資料を読んでいたことを隠したい
diff --git a/db/scenarios/gallery-blue-frame.yaml b/db/scenarios/gallery-blue-frame.yaml
index f6eb439..d06b91b 100644
--- a/db/scenarios/gallery-blue-frame.yaml
+++ b/db/scenarios/gallery-blue-frame.yaml
@@ -157,6 +157,8 @@ characters:
- id: sakaki
name: 榊玲
publicIntroduction: "物静かで職人気質の修復士。"
+ ageGroup: adult
+ gender: female
personality: 物静かで職人気質の修復士。作品の状態については細部まで説明するが、自分の判断を疑われることを極端に嫌う。鳥羽には技術を評価されていた一方、管理の厳しさを窮屈に感じていた。
goals:
- 作品のすり替えを隠したい
@@ -192,6 +194,8 @@ characters:
- id: mido
name: 御堂和也
publicIntroduction: "洗練された物腰の画商。"
+ ageGroup: adult
+ gender: male
personality: 洗練された物腰の画商。作品の価値と人間関係を同じように交渉材料として見る癖がある。鳥羽に不適切な提案を拒絶されたため、その面会自体を消したがっている。
goals:
- 鳥羽へ謝礼を持ちかけたことを隠したい
@@ -212,6 +216,8 @@ characters:
- id: enomoto
name: 榎本駿
publicIntroduction: "口数の少ない警備員。"
+ ageGroup: adult
+ gender: male
personality: 口数の少ない警備員。巡回経路と時刻はよく覚えているが、短時間居眠りしたことを処分の対象にされるのを恐れている。見た人物を必要以上に断定しない慎重さがある。
goals:
- 居眠りを隠したい
diff --git a/db/scenarios/generation-ship-staggered-dawn.yaml b/db/scenarios/generation-ship-staggered-dawn.yaml
index ceec302..639630a 100644
--- a/db/scenarios/generation-ship-staggered-dawn.yaml
+++ b/db/scenarios/generation-ship-staggered-dawn.yaml
@@ -126,6 +126,8 @@ characters:
- id: sera
name: セラ・イワノフ
publicIntroduction: "有能な農業主任。"
+ ageGroup: adult
+ gender: female
personality: 有能な農業主任。自分の区画時間で物事を話す癖があり、「六時過ぎには農業区にいた」と繰り返す。希少種子の管理を問われると強く反発する。
goals:
- 希少種子の流用を隠したい
@@ -169,6 +171,8 @@ characters:
- id: dario
name: ダリオ・ケイン
publicIntroduction: "電力管制官。"
+ ageGroup: adult
+ gender: male
personality: 船内標準時で考える電力管制官。区画時間を標準時へ換算するのが習慣になっている。個人工作室への電力流用は隠したい。
goals:
- 個人工作室への規定外電力を隠したい
@@ -204,6 +208,8 @@ characters:
- id: yuna
name: ユナ・パク
publicIntroduction: "穏やかな医療技師。"
+ ageGroup: adult
+ gender: female
personality: 穏やかな医療技師。患者の生活周期に合わせて医療区時間で話す。友人へ医療用品を融通した件は隠したい。
goals:
- 手続き外の医療用品融通を隠したい
diff --git a/db/scenarios/glasshouse-dawn-irrigation.yaml b/db/scenarios/glasshouse-dawn-irrigation.yaml
index 9c1a666..4fd81a4 100644
--- a/db/scenarios/glasshouse-dawn-irrigation.yaml
+++ b/db/scenarios/glasshouse-dawn-irrigation.yaml
@@ -130,6 +130,8 @@ characters:
- id: narahara
name: 楢原彩
publicIntroduction: "植物園の研究助手。"
+ ageGroup: young
+ gender: female
personality: 植物の扱いは繊細で、記録にも几帳面な研究助手。希少種への愛着は強いが、自分の持ち出しを「余った挿し穂の有効利用」と正当化している。作業手順を細かく話して信用を得ようとする。
goals:
- 希少植物の無断販売を隠したい
@@ -165,6 +167,8 @@ characters:
- id: mizusawa
name: 水沢浩司
publicIntroduction: "丁寧な言葉を崩さない企業担当者。"
+ ageGroup: adult
+ gender: male
personality: 丁寧な言葉を崩さない企業担当者。寄付を交渉材料に使ったことを表へ出したくなく、木島との関係を必要以上に穏便だったように話す。
goals:
- 命名権を巡って木島へ圧力をかけたことを隠したい
@@ -185,6 +189,8 @@ characters:
- id: onishi
name: 大西蒼太
publicIntroduction: "朝が早い仕事に慣れた園芸員。"
+ ageGroup: young
+ gender: male
personality: 朝が早い仕事に慣れた園芸員。設備の数値には正確だが、自分の残業申請の水増しが見つかるのを恐れている。人間関係の揉め事には首を突っ込みたくない。
goals:
- 残業時間の水増し申請を隠したい
diff --git a/db/scenarios/ink-stained-contract.yaml b/db/scenarios/ink-stained-contract.yaml
index 74ce02c..180ef92 100644
--- a/db/scenarios/ink-stained-contract.yaml
+++ b/db/scenarios/ink-stained-contract.yaml
@@ -136,6 +136,8 @@ characters:
- id: kawase
name: 川瀬梨奈
publicIntroduction: "交渉上手で感情を表に出さない著作権エージェント。"
+ ageGroup: adult
+ gender: female
personality: 交渉上手で感情を表に出さない著作権エージェント。契約書や入金記録の細部まで把握しているが、自分の管理する口座について尋ねられると話を一般論へそらす。石橋とは長く仕事をしてきた。
goals:
- 海外版権収入の不正を隠したい
@@ -171,6 +173,8 @@ characters:
- id: fujimoto
name: 藤本圭
publicIntroduction: "几帳面で締切に厳しい編集者。"
+ ageGroup: adult
+ gender: male
personality: 几帳面で締切に厳しい編集者。話題作りのため未発表原稿を流したことを強く後悔しており、端末ログを詳しく調べられるのを嫌がる。人の出入りにはよく気づく。
goals:
- 未発表原稿の漏洩を隠したい
@@ -196,6 +200,8 @@ characters:
- id: shido
name: 志堂透
publicIntroduction: "評判を気にする中堅作家。"
+ ageGroup: adult
+ gender: male
personality: 評判を気にする中堅作家。自尊心が高く、盗用疑惑を認めるくらいなら石橋との口論自体を隠したいと思っている。感情的にはなるが、事件後は露骨に怯えている。
goals:
- 新作の盗用を隠したい
diff --git a/db/scenarios/landslide-clock-museum-eleven-minutes.yaml b/db/scenarios/landslide-clock-museum-eleven-minutes.yaml
index 114946e..23fbfbe 100644
--- a/db/scenarios/landslide-clock-museum-eleven-minutes.yaml
+++ b/db/scenarios/landslide-clock-museum-eleven-minutes.yaml
@@ -162,6 +162,8 @@ characters:
- id: shiba
name: 志波沙月
publicIntroduction: "古時計の修復技師。"
+ ageGroup: adult
+ gender: female
personality: 古時計への知識と誇りが強い修復技師。細部をよく覚えているように話すが、時刻については館内の鐘を意図的に基準にする。倉橋に記録改ざんを見抜かれたことを隠したい。
goals:
- 展示時計の来歴を偽ったことを隠したい
@@ -202,6 +204,8 @@ characters:
- id: asakura
name: 朝倉真紀
publicIntroduction: "事務処理に強い受付責任者。"
+ ageGroup: adult
+ gender: female
personality: 事務処理に強い受付責任者。売上金の集計ミスを隠したいので閉館後の帳簿作業を曖昧にするが、人とすれ違った順番はよく覚えている。時刻は大時計の鐘を信用していた。
goals:
- 売上金不足が自分の集計ミスだったことを隠したい
@@ -225,6 +229,8 @@ characters:
- id: hoshina
name: 保科悠人
publicIntroduction: "研究熱心だが承認欲求の強い若手学芸員。"
+ ageGroup: young
+ gender: male
personality: 研究熱心だが承認欲求の強い若手学芸員。未公開写真を外部へ送ったことが露見するのを恐れ、収蔵庫周辺の行動を隠す。倉橋とは展示方針で衝突していた。
goals:
- 未公開収蔵品の写真を外部へ送ったことを隠したい
@@ -250,6 +256,8 @@ characters:
- id: kido
name: 城戸篤
publicIntroduction: "時計博物館の設備技術者。"
+ ageGroup: adult
+ gender: male
personality: 機器の仕組みを平易に説明できる設備技術者。夕方の同期試験ミスを隠したいので最初は時計の精度に問題はなかったと言い張るが、防災端末と親時計が別系統であることは知っている。
goals:
- 親時計を十一分進ませたままにした作業ミスを隠したい
diff --git a/db/scenarios/landslide-hotel-frosted-silhouette.yaml b/db/scenarios/landslide-hotel-frosted-silhouette.yaml
index fc4b9ea..bff2dab 100644
--- a/db/scenarios/landslide-hotel-frosted-silhouette.yaml
+++ b/db/scenarios/landslide-hotel-frosted-silhouette.yaml
@@ -128,6 +128,8 @@ characters:
- id: ayase
name: 綾瀬環
publicIntroduction: "愛想がよく館内の細かな要望にもすぐ対応するフロント主任。"
+ ageGroup: adult
+ gender: female
personality: 愛想がよく館内の細かな要望にもすぐ対応するフロント主任。備品の位置や客の動線を熟知している。請求書の話になると説明を曖昧にし、21時半の人影を長峰本人だったと強く主張する。
goals:
- 改装費の流用を隠したい
@@ -166,6 +168,8 @@ characters:
- id: kanda
name: 神田理一
publicIntroduction: "ホテルに宿泊していた作家。"
+ ageGroup: senior
+ gender: male
personality: 観察したことを文章にする癖がある作家だが、一度意味づけした光景を事実そのものと思い込みやすい。人影を見たことには自信があるが、顔も声も確認していない。
goals:
- 無断で回想録を複写したことを隠したい
@@ -192,6 +196,8 @@ characters:
- id: hoshino
name: 星野結
publicIntroduction: "光や構図に敏感な写真家。"
+ ageGroup: young
+ gender: female
personality: 光や構図に敏感な写真家。物の位置が変わるとすぐ気づく。契約外の写真を売ろうとした後ろめたさがあり、その話題では歯切れが悪い。
goals:
- 契約外写真の売却計画を隠したい
diff --git a/db/scenarios/last-train-delay-certificate.yaml b/db/scenarios/last-train-delay-certificate.yaml
index 25b22f2..ec116e5 100644
--- a/db/scenarios/last-train-delay-certificate.yaml
+++ b/db/scenarios/last-train-delay-certificate.yaml
@@ -130,6 +130,8 @@ characters:
- id: arima
name: 有馬結衣
publicIntroduction: "接客が丁寧で、遅延時にも落ち着いて対応できる助役。"
+ ageGroup: adult
+ gender: female
personality: 接客が丁寧で、遅延時にも落ち着いて対応できる助役。記録と手順を重視する一方、少額の現金差なら見過ごされるという甘さがあった。藤崎には仕事を評価されていたため、不正を見抜かれたことへの動揺が大きい。
goals:
- 返金処理を利用した現金抜き取りを隠したい
@@ -165,6 +167,8 @@ characters:
- id: komori
name: 小森拓
publicIntroduction: "話好きな売店責任者で、ホームや改札の様子をよく見ている。"
+ ageGroup: adult
+ gender: male
personality: 話好きな売店責任者で、ホームや改札の様子をよく見ている。廃棄商品の横流しが会社へ知られるのを恐れており、帳簿の話を向けられると急に口数が減る。
goals:
- 廃棄商品の帳簿外販売を隠したい
@@ -188,6 +192,8 @@ characters:
- id: sawada
name: 沢田亮
publicIntroduction: "機械好きで、古い鉄道設備を見ると仕事の範囲を越えて確認したくなる点検員。"
+ ageGroup: adult
+ gender: male
personality: 機械好きで、古い鉄道設備を見ると仕事の範囲を越えて確認したくなる点検員。無許可で機器室へ入ったことを隠したいが、通路で会った人の時刻は点検時計と結びつけて覚えている。
goals:
- 正式な指示なしに機器室へ入ったことを隠したい
diff --git a/db/scenarios/mars-outpost-delayed-reply.yaml b/db/scenarios/mars-outpost-delayed-reply.yaml
index dc80e38..3796126 100644
--- a/db/scenarios/mars-outpost-delayed-reply.yaml
+++ b/db/scenarios/mars-outpost-delayed-reply.yaml
@@ -120,6 +120,8 @@ characters:
- id: noah
name: ノア・チェン
publicIntroduction: "火星基地の副主任。"
+ ageGroup: adult
+ gender: male
personality: 冷静で数字に強い副主任。地球からの返信が21時38分に届いたことを強調し、それを自分が通信室にいた証明として扱わせようとする。予算の細部には神経質。
goals:
- 予算流用を隠したい
@@ -158,6 +160,8 @@ characters:
- id: amira
name: アミラ・サイード
publicIntroduction: "火星基地の通信士。"
+ ageGroup: adult
+ gender: female
personality: 理屈を重んじる通信士。送受信時刻と会話の因果関係を厳密に区別する。私的通信の帯域利用は隠したい。
goals:
- 家族との規則外通信を隠したい
@@ -184,6 +188,8 @@ characters:
- id: luis
name: ルイス・オルテガ
publicIntroduction: "現場主義の整備主任。"
+ ageGroup: adult
+ gender: male
personality: 現場主義の整備主任。規定より稼働を優先して無登録部品を使うことがある。通信には疎いが、廊下で見たノアについては自信がある。
goals:
- 無登録部品の交換を隠したい
diff --git a/db/scenarios/midnight-radio-rerun.yaml b/db/scenarios/midnight-radio-rerun.yaml
index c9224e6..4436439 100644
--- a/db/scenarios/midnight-radio-rerun.yaml
+++ b/db/scenarios/midnight-radio-rerun.yaml
@@ -154,6 +154,8 @@ characters:
- id: minobe
name: 美濃部沙耶
publicIntroduction: "判断が速く、番組進行の乱れを何より嫌うプロデューサー。"
+ ageGroup: adult
+ gender: female
personality: 判断が速く、番組進行の乱れを何より嫌うプロデューサー。普段は冷静だが、制作費や報告書の話になると妙に説明が細かくなる。大門とは長年組んできた。
goals:
- 放送実績報告の不正を隠し通したい
@@ -191,6 +193,8 @@ characters:
- id: kuze
name: 久世直人
publicIntroduction: "ラジオ局の音響技師。"
+ ageGroup: adult
+ gender: male
personality: 機材の話になると饒舌だが、人間関係には深入りしたがらない音響技師。自分の小さな規則違反を大事にされるのを恐れている。
goals:
- 私用録音をしていたことを隠したい
@@ -223,6 +227,8 @@ characters:
- id: natsume
name: 夏目亮介
publicIntroduction: "愛想のよい営業担当だが、契約の話になると押しが強い。"
+ ageGroup: adult
+ gender: male
personality: 愛想のよい営業担当だが、契約の話になると押しが強い。会社に知られたくない交渉を抱えており、自分が大門と揉めていた事実を必死に軽く見せようとする。
goals:
- 大門へ個人的な謝礼を持ちかけたことを隠したい
diff --git a/db/scenarios/polar-station-shared-clock.yaml b/db/scenarios/polar-station-shared-clock.yaml
index 4a7e14e..9d5da8a 100644
--- a/db/scenarios/polar-station-shared-clock.yaml
+++ b/db/scenarios/polar-station-shared-clock.yaml
@@ -141,6 +141,8 @@ characters:
- id: shinomiya
name: 篠宮怜
publicIntroduction: "理詰めで話す観測員。"
+ ageGroup: adult
+ gender: female
personality: 理詰めで話す観測員。記録が複数一致していることを強く主張し、個人の目撃よりシステムログを信用すべきだと言う。研究成果を失うことへの焦りが強い。
goals:
- 四種類の時刻記録を独立したアリバイ証拠に見せたい
@@ -181,6 +183,8 @@ characters:
- id: higuchi
name: 樋口海
publicIntroduction: "極地観測基地の通信担当。"
+ ageGroup: young
+ gender: male
personality: 神経質な通信担当。システム障害を自分の失態と考え、最初は同期ずれを小さく見せようとする。技術仕様については正確に説明できる。
goals:
- 同期障害の共有が遅れたことを隠したい
@@ -215,6 +219,8 @@ characters:
- id: sawatari
name: 沢渡直人
publicIntroduction: "現場感覚を重視する整備員。"
+ ageGroup: adult
+ gender: male
personality: 現場感覚を重視する整備員。基地システムとは別の携帯時計を使う習慣があり、自分の見た時刻に自信がある。私物機器の無断使用だけは隠したい。
goals:
- 私物機器の無断使用を隠したい
diff --git a/db/scenarios/quarantine-clinic-borrowed-badge.yaml b/db/scenarios/quarantine-clinic-borrowed-badge.yaml
index 29cfa4c..d919c81 100644
--- a/db/scenarios/quarantine-clinic-borrowed-badge.yaml
+++ b/db/scenarios/quarantine-clinic-borrowed-badge.yaml
@@ -126,6 +126,8 @@ characters:
- id: sera
name: 世良美冬
publicIntroduction: "冷静で判断が早い看護主任。"
+ ageGroup: adult
+ gender: female
personality: 冷静で判断が早い看護主任。隔離手順を熟知し、自分の説明には必ず記録上の根拠を添える。業務効率のために手順を独断変更したことを星名に問題視されていた。
goals:
- 21時18分の橙色バッジ記録を星名の生存証明として扱わせたい
@@ -164,6 +166,8 @@ characters:
- id: kuga
name: 久我夏樹
publicIntroduction: "診療所の検査技師。"
+ ageGroup: young
+ gender: male
personality: 技術には詳しいが規則にはやや無頓着な検査技師。自分が管理バッジを借りたことを重大な規則違反だと思い込み、最初は隠そうとする。
goals:
- 星名の管理バッジを借りたことを隠したい
@@ -197,6 +201,8 @@ characters:
- id: aihara
name: 相原誠
publicIntroduction: "診療所の警備担当。"
+ ageGroup: adult
+ gender: male
personality: 真面目な警備員で、制服やバッジの色を人物識別に使う癖がある。防護服姿の顔は見分けられなかったことを認める一方、私用電話で席を外したことは隠したい。
goals:
- 監視席を離れたことを隠したい
diff --git a/db/scenarios/rainy-bookstore-receipt.yaml b/db/scenarios/rainy-bookstore-receipt.yaml
index 175b9bf..30e52d3 100644
--- a/db/scenarios/rainy-bookstore-receipt.yaml
+++ b/db/scenarios/rainy-bookstore-receipt.yaml
@@ -184,6 +184,8 @@ characters:
- id: makino
name: 牧野千尋
shortName: 牧野
+ ageGroup: young
+ gender: female
publicIntroduction: "丁寧で几帳面な古書店員。"
personality: 丁寧で几帳面な古書店員。書誌や発送手順には強いが、金銭の話を向けられると防御的になる。水野には仕事を教わった恩がある一方、給料の低さには不満を抱えていた。
goals:
@@ -216,6 +218,8 @@ characters:
- id: kuroda
name: 黒田征司
shortName: 黒田
+ ageGroup: senior
+ gender: male
publicIntroduction: "希少本の知識を誇る収集家。"
personality: 希少本の知識を誇る収集家。金で解決できると思いがちで、体面を傷つけられることを嫌う。事件そのものより、裏取引の提案が会社へ知られることを恐れている。
goals:
@@ -247,6 +251,8 @@ characters:
- id: sena
name: 瀬名真琴
shortName: 瀬名
+ ageGroup: adult
+ gender: unknown
publicIntroduction: "気さくな喫茶店主で、商店街の人間関係には詳しい。"
personality: 気さくな喫茶店主で、商店街の人間関係には詳しい。借金のことだけは強い引け目があり、水野との金銭関係を聞かれると曖昧になる。
goals:
diff --git a/db/scenarios/shanghai-warehouse-carbon-copy.yaml b/db/scenarios/shanghai-warehouse-carbon-copy.yaml
index b0d453c..cafb589 100644
--- a/db/scenarios/shanghai-warehouse-carbon-copy.yaml
+++ b/db/scenarios/shanghai-warehouse-carbon-copy.yaml
@@ -115,6 +115,8 @@ characters:
- id: lin
name: 林雪梅
publicIntroduction: "冷静で計算高い番頭。"
+ ageGroup: adult
+ gender: female
personality: 冷静で計算高い番頭。書類の扱いに慣れており、署名入り伝票を強い生存証明として押し出そうとする。帳簿外の荷について問われると警戒する。
goals:
- 帳簿外の絹荷を隠したい
@@ -151,6 +153,8 @@ characters:
- id: chen
name: 陳伯安
publicIntroduction: "細部に強い通関書記。"
+ ageGroup: adult
+ gender: male
personality: 細部に強い通関書記。紙の筆圧や控えの違いに敏感だが、自分の不正な謝礼については口を濁す。
goals:
- 役人への不正な謝礼を隠したい
@@ -179,6 +183,8 @@ characters:
- id: wang
name: 王世傑
publicIntroduction: "豪胆な荷役監督。"
+ ageGroup: adult
+ gender: male
personality: 豪胆な荷役監督。細かな帳簿は苦手だが、物品の状態には目が利く。破損品の持ち出しだけは隠したい。
goals:
- 破損品の持ち出しを隠したい
diff --git a/db/scenarios/snow-gallery-erased-tracks.yaml b/db/scenarios/snow-gallery-erased-tracks.yaml
index 7a003c9..d6c552f 100644
--- a/db/scenarios/snow-gallery-erased-tracks.yaml
+++ b/db/scenarios/snow-gallery-erased-tracks.yaml
@@ -125,6 +125,8 @@ characters:
- id: kurata
name: 倉田真帆
publicIntroduction: "彫刻館の学芸員。"
+ ageGroup: adult
+ gender: female
personality: 落ち着いた学芸員で、展示記録や時系列を細かく説明する。雪面の状態を物理的証拠として強調する一方、来歴記録の問題を青沼に見つけられていた。
goals:
- 発見時に足跡がないことを、20時10分以降誰も離れへ行っていない証拠として通したい
@@ -159,6 +161,8 @@ characters:
- id: anzai
name: 安西雄
publicIntroduction: "彫刻館の施設員。"
+ ageGroup: senior
+ gender: male
personality: 無愛想だが作業時刻には几帳面な施設員。雪害センサーを壊した失敗を隠したい。通路を掃いたことで、発見時の雪面が犯行時の雪面ではないことを知っている。
goals:
- 雪害センサーの破損を隠したい
@@ -184,6 +188,8 @@ characters:
- id: enami
name: 江波涼
publicIntroduction: "感覚派の彫刻家。"
+ ageGroup: adult
+ gender: male
personality: 感覚派の彫刻家。時刻には曖昧だが展示室の状態にはよく気づく。試作品の破損を隠しているため、青沼との口論を知られたくない。
goals:
- 試作品を壊したことを隠したい
diff --git a/db/scenarios/snow-observatory-last-photo.yaml b/db/scenarios/snow-observatory-last-photo.yaml
index d85d356..fcfa67b 100644
--- a/db/scenarios/snow-observatory-last-photo.yaml
+++ b/db/scenarios/snow-observatory-last-photo.yaml
@@ -135,6 +135,8 @@ characters:
- id: hiyama
name: 日山澪
publicIntroduction: "寡黙で観測に没頭する若い研究助手。"
+ ageGroup: young
+ gender: female
personality: 寡黙で観測に没頭する若い研究助手。数字には強い自信を持つが、研究成果を失うことへの恐怖が強い。神崎を師として尊敬していた分、見放されたと思うと感情が揺れる。
goals:
- 観測データの不正補正を隠したい
@@ -174,6 +176,8 @@ characters:
- id: kurose
name: 黒瀬俊介
publicIntroduction: "自信家で競争心の強い研究者。"
+ ageGroup: adult
+ gender: male
personality: 自信家で競争心の強い研究者。神崎とは研究方針でしばしば衝突する。未公開データを無断で持ち出そうとしたため、事件と無関係でも解析室への出入りを隠したい。
goals:
- 未公開データを無断コピーしたことを隠したい
@@ -199,6 +203,8 @@ characters:
- id: muroi
name: 室井邦彦
publicIntroduction: "無口で実務的な施設管理員。"
+ ageGroup: senior
+ gender: male
personality: 無口で実務的な施設管理員。研究内容には疎いが、誰がどの廊下を通ったかはよく覚えている。規則違反の私物ヒーターを見つけられるのを恐れている。
goals:
- 私物ヒーターの使用を隠したい
diff --git a/db/scenarios/snowbound-farm-morning-chores.yaml b/db/scenarios/snowbound-farm-morning-chores.yaml
index 38775db..13ab36a 100644
--- a/db/scenarios/snowbound-farm-morning-chores.yaml
+++ b/db/scenarios/snowbound-farm-morning-chores.yaml
@@ -133,6 +133,8 @@ characters:
- id: fuyuki
name: 冬木紬
publicIntroduction: "実務に強く、農園の細かな段取りを誰より把握している主任。"
+ ageGroup: adult
+ gender: female
personality: 実務に強く、農園の細かな段取りを誰より把握している主任。佐久間への恩義を語る一方、経営の数字を突かれると急に防御的になる。朝の作業は佐久間本人がしたという前提を崩されたくない。
goals:
- 出荷代金の流用を隠したい
@@ -171,6 +173,8 @@ characters:
- id: nozomi
name: 佐久間希
publicIntroduction: "被害者の姪。"
+ ageGroup: young
+ gender: female
personality: 被害者の姪。几帳面で数字に強いが、個人的な借金を抱えていることを恥じている。朝に台所の灯りを見たため、叔父はその時点で生きていたと強く思い込んでいる。
goals:
- 叔父から金を借りていたことを隠したい
@@ -197,6 +201,8 @@ characters:
- id: kuze
name: 久世圭太
publicIntroduction: "農園の設備担当。"
+ ageGroup: adult
+ gender: male
personality: 設備の状態を数字で覚える寡黙な技術者。予備発電機を私用した規則違反を隠しているが、人の動線については余計な推測をせず見たまま話す。
goals:
- 予備発電機の私的利用を隠したい
diff --git a/db/scenarios/snowbound-gallery-wrong-crime-scene.yaml b/db/scenarios/snowbound-gallery-wrong-crime-scene.yaml
index c88841f..fda8617 100644
--- a/db/scenarios/snowbound-gallery-wrong-crime-scene.yaml
+++ b/db/scenarios/snowbound-gallery-wrong-crime-scene.yaml
@@ -143,6 +143,8 @@ characters:
- id: kosaka
name: 香坂澪
publicIntroduction: "手先が器用で作品を丁寧に扱う修復士。"
+ ageGroup: adult
+ gender: female
personality: 手先が器用で作品を丁寧に扱う修復士。保存庫が発見時に閉鎖されていたことを強調し、「中で事件が起きたなら自分には不可能」と話す。修復費の細部を追及されると苛立つ。
goals:
- 修復費の水増しを隠したい
@@ -190,6 +192,8 @@ characters:
- id: asakura
name: 朝倉凪
publicIntroduction: "作品の所在を細かく管理する学芸員。"
+ ageGroup: adult
+ gender: female
personality: 作品の所在を細かく管理する学芸員。保存庫で発見したため当初はそこが事件現場だと思い込んだ。所蔵作品の無断貸し出しを隠している。
goals:
- 所蔵作品を無断で貸したことを隠したい
@@ -222,6 +226,8 @@ characters:
- id: tada
name: 多田圭
publicIntroduction: "画廊の警備担当。"
+ ageGroup: adult
+ gender: male
personality: 規則を重んじる警備担当だが、自分の休憩延長だけは記録から外している。保存庫の閉鎖時刻は機械記録と照合して話せる。
goals:
- 無断で休憩を延ばしたことを隠したい
diff --git a/db/scenarios/snowbound-studio-roomtone-loop.yaml b/db/scenarios/snowbound-studio-roomtone-loop.yaml
index cf2bb46..13cab25 100644
--- a/db/scenarios/snowbound-studio-roomtone-loop.yaml
+++ b/db/scenarios/snowbound-studio-roomtone-loop.yaml
@@ -158,6 +158,8 @@ characters:
- id: manabe
name: 真鍋伊織
publicIntroduction: "録音所の録音技師。"
+ ageGroup: adult
+ gender: male
personality: 音の差異に異常なほど敏感で、自分の技術に強い自負を持つ録音技師。説明は専門的だが、連続した収録ファイルを在席の客観証拠として押し出し、入力経路の話になると曖昧になる。
goals:
- 未公開音源を外部へ渡したことを隠したい
@@ -202,6 +204,8 @@ characters:
- id: shirase
name: 白瀬環
publicIntroduction: "感覚的に物事を捉える歌手。"
+ ageGroup: young
+ gender: female
personality: 感覚的に物事を捉える歌手。公開前情報を知人へ話したことを隠したいが、廊下で聞いた音については率直に答える。音が聞こえたため最初は真鍋が中にいると思い込んでいる。
goals:
- 新曲情報を知人へ漏らしたことを隠したい
@@ -225,6 +229,8 @@ characters:
- id: takano
name: 鷹野徹
publicIntroduction: "録音所の制作進行。"
+ ageGroup: adult
+ gender: male
personality: 現実的で数字に強い制作進行。契約処理の付け替えが露見するのを恐れているため冬木との事務的な衝突を小さく見せるが、音響機器の細部には疎い。
goals:
- 制作費の付け替えを隠したい
@@ -248,6 +254,8 @@ characters:
- id: makimura
name: 牧村葉月
publicIntroduction: "配線と機材配置を記憶するのが得意な機材担当。"
+ ageGroup: young
+ gender: female
personality: 配線と機材配置を記憶するのが得意な機材担当。高価なマイクを傷つけたことを隠したいが、パッチ盤の入力経路と廊下で見た真鍋については正確に話せる。
goals:
- マイクを落として傷つけたことを隠したい
diff --git a/db/scenarios/snowbound-theater-auto-cues.yaml b/db/scenarios/snowbound-theater-auto-cues.yaml
index 16337a0..2744360 100644
--- a/db/scenarios/snowbound-theater-auto-cues.yaml
+++ b/db/scenarios/snowbound-theater-auto-cues.yaml
@@ -149,6 +149,8 @@ characters:
- id: kunieda
name: 国枝美冬
publicIntroduction: "段取りに厳しく、舞台上の出来事を秒単位で管理する舞台監督。"
+ ageGroup: adult
+ gender: female
personality: 段取りに厳しく、舞台上の出来事を秒単位で管理する舞台監督。照明が予定どおり進んだ事実を自分の操作記録のように語り、自動進行機能について尋ねられると話をそらす。
goals:
- 架空スタッフ名義の残業費請求を隠したい
@@ -198,6 +200,8 @@ characters:
- id: hiiragi
name: 柊真琴
publicIntroduction: "感情表現の大きい主演俳優。"
+ ageGroup: adult
+ gender: male
personality: 感情表現の大きい主演俳優。改稿台本を知人へ送ったことを隠したいが、舞台機器の仕組みには詳しくない。瀬尾と配役を巡って口論していた。
goals:
- 公開前の台本を外部へ送ったことを隠したい
@@ -218,6 +222,8 @@ characters:
- id: toba
name: 鳥羽香
publicIntroduction: "細部を見る衣装担当。"
+ ageGroup: adult
+ gender: female
personality: 細部を見る衣装担当。衣装の損傷を隠したいが、客席から照明が二十分間切り替わった事実は正確に話す。ただし誰が調光卓を操作していたかは見えていない。
goals:
- 衣装を傷めて隠したことを秘密にしたい
@@ -244,6 +250,8 @@ characters:
- id: sasai
name: 笹井徹
publicIntroduction: "舞台機構の挙動に詳しい技術者。"
+ ageGroup: adult
+ gender: male
personality: 舞台機構の挙動に詳しい技術者。点検省略を隠したいが、22時10分の国枝の目撃と調光卓の自動進行機能は正確に説明できる。
goals:
- 舞台機構の定期点検を一項目省略したことを隠したい
From 4cd7a3046aee656a1687fee6bede162de1c58890 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Wed, 9 Sep 2026 07:19:01 +0000
Subject: [PATCH 31/41] feat(scenarios): add age group and gender metadata to
remaining characters
Co-Authored-By: Claude
---
db/scenarios/storm-aquarium-feeding-lamp.yaml | 8 ++++++++
db/scenarios/storm-hydropower-rising-walkway.yaml | 6 ++++++
db/scenarios/storm-island-scheduled-mail.yaml | 8 ++++++++
db/scenarios/storm-lighthouse-relief-slate.yaml | 6 ++++++
db/scenarios/storm-mountain-inn-echoed-cane.yaml | 6 ++++++
db/scenarios/storm-planetarium-reflected-witness.yaml | 6 ++++++
db/scenarios/storm-research-vessel-missing-launch.yaml | 6 ++++++
db/scenarios/storm-seminar-echoed-testimony.yaml | 6 ++++++
db/scenarios/thunder-cablecar-occupied-cabin.yaml | 8 ++++++++
db/scenarios/tsukimisou.yaml | 6 ++++++
db/scenarios/typhoon-datacenter-maintenance-window.yaml | 8 ++++++++
db/scenarios/typhoon-offshore-drone-route.yaml | 8 ++++++++
db/scenarios/venetian-lazaretto-sunset-bell.yaml | 6 ++++++
db/scenarios/victorian-underground-last-telegram.yaml | 6 ++++++
14 files changed, 94 insertions(+)
diff --git a/db/scenarios/storm-aquarium-feeding-lamp.yaml b/db/scenarios/storm-aquarium-feeding-lamp.yaml
index 85fad9e..999d90a 100644
--- a/db/scenarios/storm-aquarium-feeding-lamp.yaml
+++ b/db/scenarios/storm-aquarium-feeding-lamp.yaml
@@ -141,6 +141,8 @@ characters:
- id: morishita
name: 森下莉央
publicIntroduction: "生き物の状態変化には鋭いが、責任を問われると防御的になる飼育員。"
+ ageGroup: young
+ gender: female
personality: 生き物の状態変化には鋭いが、責任を問われると防御的になる飼育員。担当展示への愛着が強く、給餌灯を自分のアリバイとして強調する。
goals:
- 飼育記録を改ざんしたことを隠したい
@@ -187,6 +189,8 @@ characters:
- id: sakakibara
name: 榊原直
publicIntroduction: "几帳面で理屈っぽい獣医師。"
+ ageGroup: adult
+ gender: unknown
personality: 几帳面で理屈っぽい獣医師。在庫管理ミスが表に出るのを恐れているが、給餌設備の基本仕様は把握している。
goals:
- 在庫記録を後から修正したことを隠したい
@@ -213,6 +217,8 @@ characters:
- id: mikoshiba
name: 御子柴徹
publicIntroduction: "豪放で口数の多い潜水設備担当。"
+ ageGroup: adult
+ gender: male
personality: 豪放で口数の多い潜水設備担当。無断の私物カメラを隠したいが、給餌装置の整備も担当しており機械の挙動には詳しい。
goals:
- 私物カメラの無断設置を隠したい
@@ -239,6 +245,8 @@ characters:
- id: sagara
name: 相良芳江
publicIntroduction: "落ち着いた夜間警備員。"
+ ageGroup: senior
+ gender: female
personality: 落ち着いた夜間警備員。巡回を一回省略したことだけは隠したいが、零時すぎにバックヤードで森下とすれ違った記憶は鮮明。
goals:
- 西展示区画の巡回を省略したことを隠したい
diff --git a/db/scenarios/storm-hydropower-rising-walkway.yaml b/db/scenarios/storm-hydropower-rising-walkway.yaml
index f4bbc0d..a059e64 100644
--- a/db/scenarios/storm-hydropower-rising-walkway.yaml
+++ b/db/scenarios/storm-hydropower-rising-walkway.yaml
@@ -154,6 +154,8 @@ characters:
- id: kido
name: 城戸真琴
publicIntroduction: "古い設備の癖まで覚えている保守主任。"
+ ageGroup: adult
+ gender: unknown
personality: 古い設備の癖まで覚えている保守主任。発見時の歩廊が通れなかった事実を強調し、「あの部屋へは誰も入れない」と言い張る。点検記録の話には神経質になる。
goals:
- 点検記録の改ざんを隠したい
@@ -202,6 +204,8 @@ characters:
- id: saionji
name: 西園寺悠
publicIntroduction: "発電所の安全監査員。"
+ ageGroup: adult
+ gender: unknown
personality: 記録を一つずつ照合する監査員。発見時の現場を見て「密室」と判断したが、その状態がいつ成立したかまでは考えていなかった。報告書漏洩を隠している。
goals:
- 監査報告の漏洩を隠したい
@@ -234,6 +238,8 @@ characters:
- id: taniguchi
name: 谷口航
publicIntroduction: "数値監視を担当する若い運転員。"
+ ageGroup: young
+ gender: male
personality: 数値監視を担当する若い運転員。警報の扱いで規則違反をしたためログの話題を避けるが、水位の上昇時刻については正確に答えられる。
goals:
- 警報を非表示にした規則違反を隠したい
diff --git a/db/scenarios/storm-island-scheduled-mail.yaml b/db/scenarios/storm-island-scheduled-mail.yaml
index aa2de48..2181392 100644
--- a/db/scenarios/storm-island-scheduled-mail.yaml
+++ b/db/scenarios/storm-island-scheduled-mail.yaml
@@ -153,6 +153,8 @@ characters:
- id: aizawa
name: 相沢奈緒
publicIntroduction: "予定と文書管理に几帳面で、榊原の癖もよく知る秘書。"
+ ageGroup: adult
+ gender: female
personality: 予定と文書管理に几帳面で、榊原の癖もよく知る秘書。落ち着いて時系列を整理するように話すが、21時42分のメールを本人の生存証明として意図的に強調する。
goals:
- 財団資金を私的に流用したことを隠したい
@@ -207,6 +209,8 @@ characters:
- id: mikage
name: 御影崇
publicIntroduction: "財団の会計担当。"
+ ageGroup: adult
+ gender: male
personality: 数字に細かく、他人の経費処理にも厳しい会計担当。自分も予算項目を付け替えていたため監査の話題を嫌うが、メールの送信情報を見る知識はある。
goals:
- 予算項目の付け替えを隠したい
@@ -230,6 +234,8 @@ characters:
- id: hatori
name: 羽鳥栞
publicIntroduction: "話し方が明快な外部講師。"
+ ageGroup: adult
+ gender: female
personality: 話し方が明快な外部講師。未公開教材を別講座で使ったことは隠したいが、21時35分から55分まで相沢と一緒に準備していたことは正確に証言する。
goals:
- 未公開教材を別講座で先に使ったことを隠したい
@@ -256,6 +262,8 @@ characters:
- id: horie
name: 堀江充
publicIntroduction: "施設内の人の出入りをよく見ている管理担当。"
+ ageGroup: adult
+ gender: male
personality: 施設内の人の出入りをよく見ている管理担当。空き客室を私物置き場にしていたことを隠したいが、21時29分に執務室前で見た相沢については時刻も場所も覚えている。
goals:
- 空き客室を私物の荷物置き場にしていたことを隠したい
diff --git a/db/scenarios/storm-lighthouse-relief-slate.yaml b/db/scenarios/storm-lighthouse-relief-slate.yaml
index 9f9089f..e62b3ab 100644
--- a/db/scenarios/storm-lighthouse-relief-slate.yaml
+++ b/db/scenarios/storm-lighthouse-relief-slate.yaml
@@ -134,6 +134,8 @@ characters:
- id: torigoe
name: 鳥越玲
publicIntroduction: "実直に見える設備当直員。"
+ ageGroup: adult
+ gender: female
personality: 実直に見える設備当直員。手順や記録を重視する話し方をし、数字を示して自分の説明を固めようとする。記録ミスを倉橋に見つけられたことを強く恐れている。
goals:
- 当直板と濡れた外套を西側巡回の証拠として通したい
@@ -174,6 +176,8 @@ characters:
- id: hashiba
name: 橋場圭
publicIntroduction: "灯台の通信担当。"
+ ageGroup: adult
+ gender: male
personality: 観察力のある通信担当。時刻は通信時計で確認する癖がある一方、規定外の個人的な受信をしていたため通信記録を調べられるのを嫌がる。
goals:
- 個人的な無線受信を隠したい
@@ -197,6 +201,8 @@ characters:
- id: morishita
name: 森下透
publicIntroduction: "若い補助員。"
+ ageGroup: young
+ gender: male
personality: 若い補助員。素直だが自分の勤務態度に自信がなく、居眠りを知られるのを恐れている。設備の封鎖確認には橋場より詳しい。
goals:
- 発電室で眠っていたことを隠したい
diff --git a/db/scenarios/storm-mountain-inn-echoed-cane.yaml b/db/scenarios/storm-mountain-inn-echoed-cane.yaml
index 66f6223..3108397 100644
--- a/db/scenarios/storm-mountain-inn-echoed-cane.yaml
+++ b/db/scenarios/storm-mountain-inn-echoed-cane.yaml
@@ -142,6 +142,8 @@ characters:
- id: akiwa
name: 秋庭澄
publicIntroduction: "穏やかな料理長で、客の好みをよく覚えている。"
+ ageGroup: adult
+ gender: unknown
personality: 穏やかな料理長で、客の好みをよく覚えている。厨房と旧階段を自由に使える。九時二十五分の杖音を桐谷本人の行動と断定し、仕入れの話を避けようとする。
goals:
- 業者から謝礼を受け取っていたことを隠したい
@@ -182,6 +184,8 @@ characters:
- id: morisaki
name: 森崎透
publicIntroduction: "山宿の常連客。"
+ ageGroup: senior
+ gender: male
personality: 桐谷と長い付き合いがあり、杖の音を何度も聞いてきた常連客。その親しさゆえに、特徴的な音を聞いた瞬間に桐谷本人だと決めつけた。宿泊代の未払いを隠している。
goals:
- 宿泊代の未払いを隠したい
@@ -208,6 +212,8 @@ characters:
- id: sakakibara
name: 榊原蓮
publicIntroduction: "古い木造建築の癖を体で覚えている大工。"
+ ageGroup: adult
+ gender: male
personality: 古い木造建築の癖を体で覚えている大工。余剰材料を持ち帰ろうとした後ろめたさはあるが、音の伝わり方については具体的に説明できる。
goals:
- 余剰木材の持ち出し計画を隠したい
diff --git a/db/scenarios/storm-planetarium-reflected-witness.yaml b/db/scenarios/storm-planetarium-reflected-witness.yaml
index 8d63211..4d78744 100644
--- a/db/scenarios/storm-planetarium-reflected-witness.yaml
+++ b/db/scenarios/storm-planetarium-reflected-witness.yaml
@@ -129,6 +129,8 @@ characters:
- id: onodera
name: 小野寺莉香
publicIntroduction: "空間演出に詳しく、人の視線や展示の見え方を意識する企画担当。"
+ ageGroup: adult
+ gender: female
personality: 空間演出に詳しく、人の視線や展示の見え方を意識する企画担当。落ち着いて松田の目撃を肯定するが、ガラスの反射条件を誰より理解している。
goals:
- 21時35分の白い人物像を犬塚の生存証明として成立させたい
@@ -169,6 +171,8 @@ characters:
- id: matsuda
name: 松田圭介
publicIntroduction: "天象館の投影技師。"
+ ageGroup: adult
+ gender: male
personality: 機械には強いが、視覚的な思い込みには無自覚な投影技師。白い上着を見て犬塚だと判断したが、顔までは確認していない。機器テストを省略したことを隠したい。
goals:
- 投影機器の事前テストを省略したことを隠したい
@@ -196,6 +200,8 @@ characters:
- id: asakura
name: 朝倉葉月
publicIntroduction: "現実的な売店責任者で、人の服装をよく覚えている。"
+ ageGroup: adult
+ gender: female
personality: 現実的な売店責任者で、人の服装をよく覚えている。閉館作業中に小野寺が白い展示用上着を着ていたことを見ているが、在庫不足を隠している。
goals:
- 売店在庫の不足を隠したい
diff --git a/db/scenarios/storm-research-vessel-missing-launch.yaml b/db/scenarios/storm-research-vessel-missing-launch.yaml
index 1685113..3e85ac5 100644
--- a/db/scenarios/storm-research-vessel-missing-launch.yaml
+++ b/db/scenarios/storm-research-vessel-missing-launch.yaml
@@ -134,6 +134,8 @@ characters:
- id: kariya
name: 狩谷琴音
publicIntroduction: "頭の回転が速く、可能性を広げて話す研究員。"
+ ageGroup: young
+ gender: female
personality: 頭の回転が速く、可能性を広げて話す研究員。作業艇の消失を強調し、船内の三人に限定せず外部犯人の可能性を残そうとする。自分の研究上の不正を瀬尾に見つけられていた。
goals:
- 消えた作業艇を外部犯人の逃走手段と思わせたい
@@ -174,6 +176,8 @@ characters:
- id: mizuki
name: 水城明
publicIntroduction: "現場優先の機関担当。"
+ ageGroup: adult
+ gender: male
personality: 現場優先の機関担当。設備点検を延期した件を隠したいが、作業艇が失われた時刻と狩谷の署名については記録通り話す。
goals:
- 設備点検の延期を隠したい
@@ -207,6 +211,8 @@ characters:
- id: nonomura
name: 野々村岳
publicIntroduction: "調査船の通信担当。"
+ ageGroup: adult
+ gender: male
personality: 正確な通信記録を好む担当者。勤務中の私的通信を知られたくない。19時56分に瀬尾本人と話したことには自信がある。
goals:
- 業務外の個人的な通信を隠したい
diff --git a/db/scenarios/storm-seminar-echoed-testimony.yaml b/db/scenarios/storm-seminar-echoed-testimony.yaml
index 9da83ff..c90706b 100644
--- a/db/scenarios/storm-seminar-echoed-testimony.yaml
+++ b/db/scenarios/storm-seminar-echoed-testimony.yaml
@@ -140,6 +140,8 @@ characters:
- id: kuga
name: 久我真帆
publicIntroduction: "自信が強く議論の組み立てが速い研究員。"
+ ageGroup: young
+ gender: female
personality: 自信が強く議論の組み立てが速い研究員。自分の証言だけでなく「成瀬も白石も同じ認識だった」と一致の数を強調する。未発表成果の扱いを問われると攻撃的になる。
goals:
- 未発表成果を無断投稿しようとしたことを隠したい
@@ -185,6 +187,8 @@ characters:
- id: naruse
name: 成瀬灯
publicIntroduction: "情報整理が得意な編集者。"
+ ageGroup: adult
+ gender: female
personality: 情報整理が得意な編集者。ただし、誰から聞いた情報かを省いて「分かっていること」として話す癖がある。公開前情報の漏洩を隠している。
goals:
- 論文集の内容を漏らしたことを隠したい
@@ -217,6 +221,8 @@ characters:
- id: shiraishi
name: 白石慧
publicIntroduction: "研究会に参加していた大学院生。"
+ ageGroup: young
+ gender: male
personality: 慎重な大学院生で、人の発言を時刻と一緒に覚えている。参加者データを無断利用した秘密はあるが、21時台の廊下で見た久我については具体的に話せる。
goals:
- 参加者データの無断流用を隠したい
diff --git a/db/scenarios/thunder-cablecar-occupied-cabin.yaml b/db/scenarios/thunder-cablecar-occupied-cabin.yaml
index b2230f7..fdd2df2 100644
--- a/db/scenarios/thunder-cablecar-occupied-cabin.yaml
+++ b/db/scenarios/thunder-cablecar-occupied-cabin.yaml
@@ -160,6 +160,8 @@ characters:
- id: sudo
name: 須藤拓海
publicIntroduction: "ロープウェイの運転主任。"
+ ageGroup: adult
+ gender: male
personality: 安全規程を熟知し、自信を持って機器表示を説明する運転主任。自分の点検記録の不備を隠し、監視盤の『乗員1』を人物の在席記録であるかのように語る。
goals:
- 制動点検記録の虚偽を隠したい
@@ -209,6 +211,8 @@ characters:
- id: enomoto
name: 榎本澄
publicIntroduction: "丁寧で記憶力のよい案内係。"
+ ageGroup: adult
+ gender: unknown
personality: 丁寧で記憶力のよい案内係。拾った財布を正式処理しなかったことを隠したいが、21時18分に職員通路で須藤とすれ違ったことは明確に覚えている。
goals:
- 拾得物を自分のロッカーへ入れたことを隠したい
@@ -235,6 +239,8 @@ characters:
- id: nagamine
name: 長峰礼
publicIntroduction: "機械の仕様を優先して考える設備担当。"
+ ageGroup: adult
+ gender: unknown
personality: 機械の仕様を優先して考える設備担当。融雪設備の手順違反を隠したいが、非常用搬器の乗員表示が単なる荷重判定だと知っている。
goals:
- 融雪ヒーターの警報を無効化したことを隠したい
@@ -265,6 +271,8 @@ characters:
- id: orihara
name: 折原壮
publicIntroduction: "山頂駅にいた山岳写真家。"
+ ageGroup: adult
+ gender: male
personality: 好奇心が強く規則を軽視しがちな山岳写真家。立入禁止の保守デッキへ出たことを隠すため行動をぼかすが、職員同士の事情には詳しくない。
goals:
- 保守デッキへ無断で出たことを隠したい
diff --git a/db/scenarios/tsukimisou.yaml b/db/scenarios/tsukimisou.yaml
index 7a2ad5f..2f0a299 100644
--- a/db/scenarios/tsukimisou.yaml
+++ b/db/scenarios/tsukimisou.yaml
@@ -422,6 +422,8 @@ characters:
- id: fukagawa
name: 深川誠也
publicIntroduction: "気弱で愛想笑いが多い税理士。"
+ ageGroup: senior
+ gender: male
personality: 気弱で愛想笑いが多い税理士。人当たりは柔らかいが、追い詰められるとしどろもどろになり目が泳ぐ。涼子には昔から頭が上がらない。
goals:
- 横領が誰にもバレないまま今夜をやり過ごしたい
@@ -455,6 +457,8 @@ characters:
- id: mizuki
name: 早坂美月
publicIntroduction: "明るく気配り上手で場の空気をよく読む。"
+ ageGroup: adult
+ gender: female
personality: 明るく気配り上手で場の空気をよく読む。涼子の親戚の中でも一番可愛がられてきた自覚がある。内心は打算的で、追い詰められると笑顔の下で早口になる。
goals:
- 月見荘と遺産を確実に手に入れたい
@@ -488,6 +492,8 @@ characters:
- id: kiryu
name: 桐生涼
publicIntroduction: "落ち着いた物腰で観察力が鋭い医師。"
+ ageGroup: senior
+ gender: male
personality: 落ち着いた物腰で観察力が鋭い医師。淡々と話すが、涼子への想いだけは昔から変わらず深い。
goals:
- 涼子への想いを誰にも知られたくない
diff --git a/db/scenarios/typhoon-datacenter-maintenance-window.yaml b/db/scenarios/typhoon-datacenter-maintenance-window.yaml
index a669692..c4e9275 100644
--- a/db/scenarios/typhoon-datacenter-maintenance-window.yaml
+++ b/db/scenarios/typhoon-datacenter-maintenance-window.yaml
@@ -145,6 +145,8 @@ characters:
- id: kuze
name: 久世秋穂
publicIntroduction: "データセンターのシステム技術者。"
+ ageGroup: adult
+ gender: female
personality: 冷静で手順を重んじるシステム技術者。ログを強い根拠として話す一方、自分の不正を追及されると急に言葉数が減る。
goals:
- 保守費の水増しを隠したい
@@ -184,6 +186,8 @@ characters:
- id: koda
name: 甲田修
publicIntroduction: "口が達者な外部技術者。"
+ ageGroup: adult
+ gender: male
personality: 口が達者な外部技術者。契約外の設定ファイルを持ち出していたため、自分の作業内容を細かく話したがらない。
goals:
- 顧客設定ファイルの無断複製を隠したい
@@ -204,6 +208,8 @@ characters:
- id: hatano
name: 波多野結
publicIntroduction: "現場優先で判断の速い設備技術者。"
+ ageGroup: adult
+ gender: female
personality: 現場優先で判断の速い設備技術者。警報を勝手に止めたことには後ろめたさがあるが、部品庫の扉の状態はよく覚えている。
goals:
- 温度警報を無断停止したことを隠したい
@@ -224,6 +230,8 @@ characters:
- id: makabe
name: 真壁徹
publicIntroduction: "データセンターの警備員。"
+ ageGroup: adult
+ gender: male
personality: 規則に厳しい警備員。長時間勤務で居眠りしたことを隠したいが、廊下ですれ違った人物の記憶には自信がある。
goals:
- 監視席で居眠りしたことを隠したい
diff --git a/db/scenarios/typhoon-offshore-drone-route.yaml b/db/scenarios/typhoon-offshore-drone-route.yaml
index 67449e7..25a89b1 100644
--- a/db/scenarios/typhoon-offshore-drone-route.yaml
+++ b/db/scenarios/typhoon-offshore-drone-route.yaml
@@ -148,6 +148,8 @@ characters:
- id: hiiragi
name: 柊木慧
publicIntroduction: "洋上基地の点検技師。"
+ ageGroup: adult
+ gender: male
personality: 機器の性能を熟知し、点検記録を客観的な証拠として扱う技師。自分の作業効率に自信があり、不正を「現場の合理化」と言い換えがち。飛行軌跡と写真を自分の在席証明として押し出す。
goals:
- 過去写真を使った点検不正を隠したい
@@ -190,6 +192,8 @@ characters:
- id: saegusa
name: 三枝千尋
publicIntroduction: "保護装置の理屈に厳しい電気主任。"
+ ageGroup: adult
+ gender: female
personality: 保護装置の理屈に厳しい電気主任。無断リセットを隠したいが、01時08分に連絡階段で柊木を見た記憶は明確。ドローンが自動運転できることも知っている。
goals:
- 保護装置を無断リセットしたことを隠したい
@@ -210,6 +214,8 @@ characters:
- id: nami
name: 波木恵
publicIntroduction: "洋上基地の気象担当。"
+ ageGroup: adult
+ gender: female
personality: 数字に慎重な気象担当。風速記録の書き直しを隠したいが、点検ドローンが事前経路を自律飛行する運用は日常的に見ている。
goals:
- 風速の手書き記録を後から修正したことを隠したい
@@ -233,6 +239,8 @@ characters:
- id: kokubu
name: 国分透
publicIntroduction: "洋上基地の資材担当。"
+ ageGroup: adult
+ gender: male
personality: 在庫と数字に敏感な資材担当。棚卸し不足を隠すため部品の付け替えをしており、芳賀に追及されていた。事件を自分の不正と結びつけられたくない。
goals:
- 予備部品の在庫付け替えを隠したい
diff --git a/db/scenarios/venetian-lazaretto-sunset-bell.yaml b/db/scenarios/venetian-lazaretto-sunset-bell.yaml
index ecc256f..5490ea1 100644
--- a/db/scenarios/venetian-lazaretto-sunset-bell.yaml
+++ b/db/scenarios/venetian-lazaretto-sunset-bell.yaml
@@ -117,6 +117,8 @@ characters:
- id: marta
name: マルタ・ベッリーニ
publicIntroduction: "実務に強く落ち着いた薬剤係。"
+ ageGroup: adult
+ gender: female
personality: 実務に強く落ち着いた薬剤係。時刻を尋ねられると時計ではなく鐘を基準に話す癖がある。不足した薬剤の話題では急に言葉が硬くなる。
goals:
- 薬剤の横流しを隠したい
@@ -155,6 +157,8 @@ characters:
- id: nicolo
name: ニコロ・フェッリ
publicIntroduction: "几帳面な書記。"
+ ageGroup: adult
+ gender: male
personality: 几帳面な書記。紙の記録を重視し、人の記憶には懐疑的。配給帳を書き換えていたため帳簿全体を調べられるのは困るが、廊下で見たマルタについては正確に話す。
goals:
- 配給帳の改ざんを隠したい
@@ -181,6 +185,8 @@ characters:
- id: pietro
name: ピエトロ・サルヴィ
publicIntroduction: "潟の天候と潮を読むことに長けた舟番。"
+ ageGroup: senior
+ gender: male
personality: 潟の天候と潮を読むことに長けた舟番。時計より空の明るさや鐘で時間を捉える。私信を運んだ規則違反だけは隠したい。
goals:
- 私信を密かに舟へ載せたことを隠したい
diff --git a/db/scenarios/victorian-underground-last-telegram.yaml b/db/scenarios/victorian-underground-last-telegram.yaml
index 7b3b9ca..19bb397 100644
--- a/db/scenarios/victorian-underground-last-telegram.yaml
+++ b/db/scenarios/victorian-underground-last-telegram.yaml
@@ -142,6 +142,8 @@ characters:
- id: bell
name: アーサー・ベル
publicIntroduction: "実務能力の高い施工監督。"
+ ageGroup: adult
+ gender: male
personality: 実務能力の高い施工監督。工期と費用の話になると強気だが、帳簿の細部を問われると苛立つ。電信の符号がヘイル本人の生存証明だと言い張りたい。
goals:
- 資材費の水増しを隠したい
@@ -180,6 +182,8 @@ characters:
- id: clara
name: クララ・ウェッブ
publicIntroduction: "正確さを誇る若い電信係。"
+ ageGroup: young
+ gender: female
personality: 正確さを誇る若い電信係。受信した文字列については自信がある一方、私的な通信を写していた規則違反は隠したい。送信者を実際に見てはいない。
goals:
- 無断で私信を写していたことを隠したい
@@ -206,6 +210,8 @@ characters:
- id: thomas
name: トーマス・リード
publicIntroduction: "無骨な機関助手。"
+ ageGroup: adult
+ gender: male
personality: 無骨な機関助手。工事用石炭の横流しをしていたため帳簿調査を恐れている。坑道で見たベルの位置については妙に具体的に覚えている。
goals:
- 石炭の横流しを隠したい
From 9ef8fafda280f7f8358274fb31c06e94d9c4a1b1 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Wed, 9 Sep 2026 07:23:10 +0000
Subject: [PATCH 32/41] chore(vscode): hide generated files and build artifacts
Co-Authored-By: Claude
---
.vscode/settings.json | 23 ++++++++++++++++++++---
1 file changed, 20 insertions(+), 3 deletions(-)
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 3f68bf3..e62c32a 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -32,20 +32,37 @@
"**/.turbo": true,
"**/.cache": true,
"**/coverage": true,
- "**/.tanstack": true
+ "**/.tanstack": true,
+ "**/.vite": true,
+ "**/.wrangler": true,
+ "**/*.tsbuildinfo": true,
+ "storybook-static": true,
+ "screenshots": true,
+ "db/seed.sql": true
},
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/node_modules/**": true,
- "**/dist/**": true
+ "**/dist/**": true,
+ "**/.tanstack/**": true,
+ "**/.vite/**": true,
+ "**/.wrangler/**": true,
+ "storybook-static/**": true,
+ "screenshots/**": true
},
"search.exclude": {
"**/node_modules": true,
"**/dist": true,
"**/coverage": true,
+ "**/.tanstack": true,
+ "**/.wrangler": true,
"**/*.gen.ts": true,
+ "**/*.tsbuildinfo": true,
"**/bun.lock": true,
- "**/db/migrations/**": true
+ "**/db/migrations/**": true,
+ "storybook-static": true,
+ "screenshots": true,
+ "db/seed.sql": true
},
// ─────────────────────────────────────────────────────────────
From 2bec38a77384ef827a067eeea641824db5ac453e Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Wed, 9 Sep 2026 07:35:26 +0000
Subject: [PATCH 33/41] fix(llm): send prompts through the top-level system
option
Avoid 400 responses when compatibility servers forward requests to Anthropic.
Co-Authored-By: Claude
---
docs/architecture/llm.md | 14 ++++----------
src/server/llm/actor.ts | 33 ++++++++++++++-------------------
src/server/llm/examiner.ts | 19 +++++++------------
3 files changed, 25 insertions(+), 41 deletions(-)
diff --git a/docs/architecture/llm.md b/docs/architecture/llm.md
index 9f6a86c..1da32d2 100644
--- a/docs/architecture/llm.md
+++ b/docs/architecture/llm.md
@@ -80,15 +80,9 @@ LLM_AUTHOR_MODEL=
```typescript
streamText({
- model: resolveModel(env, choice),
- allowSystemInMessages: true,
- messages: [
- { role: 'system', content: gameRules },
- { role: 'system', content: characterSheet },
- ...buildDetectiveMessages(detective),
- ...history,
- { role: 'user', content: question },
- ],
+ model: resolveModel(env, modelId),
+ system: [gameRules, characterSheet, ...detectiveBlocks(detective)].join('\n\n'),
+ messages: [...history, { role: 'user', content: question }],
maxOutputTokens: 1024,
})
```
@@ -193,7 +187,7 @@ Judge は往復ごとではなく**話題ごとに1回**呼びます。往復ご
- 2つ目: NPCのキャラクターシート(そのNPCとの会話中は不変)
- 3つ目以降: 探偵、会話履歴
-`actor.ts` / `examiner.ts` が `allowSystemInMessages: true` を立てているのはこのためです。system オプション(ただの文字列)ではこの並びを作れません。
+前置きは system オプションに一本の文字列としてまとめます。以前は messages の先頭に system ロールで積んでいましたが、互換サーバが Anthropic へ中継する構成だと 400 になります(あちらは system を最上位でしか受けません)。ブロックに分けていた理由(`cache_control` をブロック単位で打つため)はもう無いので、順序さえ保てば足ります。
**3. キャッシュはモデル単位・NPC単位で分かれる。**
同一シナリオでもNPCが違えばキャッシュは別物です。1プレイ中に何度も同じNPCへ聞き直す設計は、キャッシュ効率の面でも有利になります。
diff --git a/src/server/llm/actor.ts b/src/server/llm/actor.ts
index 35865a6..cbdddbf 100644
--- a/src/server/llm/actor.ts
+++ b/src/server/llm/actor.ts
@@ -33,8 +33,8 @@ export type ActorContext = {
* `@/server/llm/detective` が持つ。ただし「探偵が何を知っているか」は書かない。
* NPCが勝手にプレイヤーの推理状況を前提にして喋り出すと、ゲームが先回りしてしまう。
*/
-const buildDetectiveMessages = (detective: Detective | undefined): ModelMessage[] =>
- detective === undefined ? [] : [{ role: 'system', content: buildDetectiveBlock(detective) }]
+const detectiveBlocks = (detective: Detective | undefined): string[] =>
+ detective === undefined ? [] : [buildDetectiveBlock(detective)]
/**
* NPCの返答をストリーミングで返す。
@@ -56,23 +56,18 @@ export const streamNpcReply = ({
}: ActorContext) =>
streamText({
model: resolveModel(env, modelId),
- // system を messages 側に置くことを明示的に許可する。
- //
- // AI SDK は既定でこれを警告する(プレイヤー由来の文字列が system に混ざる経路を
- // 作りやすいため)。ここでそれでも messages に置くのは、ゲームルールと
- // キャラクターシートを別々のブロックとして前置きに並べたいから。
- // 共通の前置きが長いほど、互換サーバ側の自動プレフィックスキャッシュが効きやすい。
- //
- // プレイヤーの発話は必ず user ロールに閉じ込めており(utterance を
- // system 側へ混ぜる経路はこの関数に存在しない)、警告が想定する事故は起きない。
- allowSystemInMessages: true,
- messages: [
- { role: 'system', content: gameRules },
- { role: 'system', content: characterSheet },
- ...buildDetectiveMessages(detective),
- ...history,
- { role: 'user', content: utterance },
- ],
+ /*
+ 前置きは system オプションに一本化する。messages の先頭に system ロールで
+ 積む書き方もできるが、互換サーバが Anthropic へ中継する構成だと 400 になる
+ (あちらは system を最上位でしか受けない)。
+
+ 以前ブロックを分けていたのは、Anthropic のキャッシュ指定をブロック単位で
+ 打つためだった。その指定はもう送っていないので、分ける理由が無い。
+ 並び(変わらないものから先に)はそのまま保つ——自動のプレフィックスキャッシュは
+ 文字列の先頭一致で効くので、順序だけが効き目を決める。
+ */
+ system: [gameRules, characterSheet, ...detectiveBlocks(detective)].join('\n\n'),
+ messages: [...history, { role: 'user', content: utterance }],
// NPCの返答はテンポが命。深い推論より即応性を優先する。
maxOutputTokens: 1024,
})
diff --git a/src/server/llm/examiner.ts b/src/server/llm/examiner.ts
index 2f089dd..7525d84 100644
--- a/src/server/llm/examiner.ts
+++ b/src/server/llm/examiner.ts
@@ -85,13 +85,14 @@ export type ExaminationContext = {
utterance: string
}
-const buildDetectiveMessages = (detective: Detective | undefined): ModelMessage[] =>
- detective === undefined ? [] : [{ role: 'system', content: buildDetectiveBlock(detective) }]
+/** 探偵の人物像。名乗らずに始めることもできるので、無ければ前置きごと出さない。 */
+const detectiveBlocks = (detective: Detective | undefined): string[] =>
+ detective === undefined ? [] : [buildDetectiveBlock(detective)]
/**
* 検分を書き起こす。遺体でも場所でも、渡すものが変わるだけで作りは同じ。
*
- * 作りは `streamNpcReply` と同じ(プレフィックスを二段に分けてキャッシュを効かせる)。
+ * 作りは `streamNpcReply` と同じ(変わらない前置きを先に置いてキャッシュを効かせる)。
* 違うのは相手が喋らないことだけで、渡すのが人物像ではなく所見のシートになる。
*
* ここは**言い換えだけをさせる経路**で、所見そのものはシナリオが決めている。
@@ -109,14 +110,8 @@ export const streamExamination = ({
}: ExaminationContext) =>
streamText({
model: resolveModel(env, modelId),
- // 理由は streamNpcReply と同じ。前置きをブロックに分けて並べるため。
- allowSystemInMessages: true,
- messages: [
- { role: 'system', content: examinationRules },
- { role: 'system', content: sheet },
- ...buildDetectiveMessages(detective),
- ...history,
- { role: 'user', content: utterance },
- ],
+ // 理由は streamNpcReply と同じ。前置きは system オプションに一本化する。
+ system: [examinationRules, sheet, ...detectiveBlocks(detective)].join('\n\n'),
+ messages: [...history, { role: 'user', content: utterance }],
maxOutputTokens: 1024,
})
From 2cffd5bd4aff920edcecc2d580d4ae0c2fb7d425 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Wed, 9 Sep 2026 14:38:15 +0000
Subject: [PATCH 34/41] feat(voice): add character voice playback and speaker
assignment
Add configurable narration, deterministic Irodori speaker selection, paragraph-level audio delivery, and schema support for preserved messages.
Co-Authored-By: Claude
---
__tests__/client/voice.test.ts | 61 +
__tests__/server/speakers.test.ts | 72 +
.../{client => shared}/paragraphs.test.ts | 28 +-
bun.lock | 184 +-
.../0030_messages-drop-character-fk.sql | 17 +
db/migrations/meta/0030_snapshot.json | 1580 +++++++++++++++++
db/migrations/meta/_journal.json | 7 +
db/schema.ts | 11 +-
mocks/desktop/settings.html | 40 +-
mocks/mobile/settings.html | 37 +-
package.json | 30 +-
src/client/components/CaseNote.tsx | 2 +-
src/client/components/ChatLog.tsx | 2 +-
src/client/components/RouteStatus.tsx | 6 +-
src/client/hooks/useInterrogation.ts | 38 +
src/client/hooks/useVoicedReveal.ts | 221 +++
src/client/lib/api.ts | 25 +
src/client/lib/voice.ts | 35 +
src/client/screens/InterrogationScreen.tsx | 21 +-
src/client/screens/SettingsScreen.stories.tsx | 1 +
src/client/screens/SettingsScreen.tsx | 38 +
src/client/screens/interrogation-log.ts | 29 +-
src/server/routes/sessions.ts | 95 +-
src/server/routes/voice.ts | 136 +-
src/server/tts/irodori.ts | 55 +
src/{client/lib => shared}/paragraphs.ts | 10 +
26 files changed, 2571 insertions(+), 210 deletions(-)
create mode 100644 __tests__/client/voice.test.ts
create mode 100644 __tests__/server/speakers.test.ts
rename __tests__/{client => shared}/paragraphs.test.ts (74%)
create mode 100644 db/migrations/0030_messages-drop-character-fk.sql
create mode 100644 db/migrations/meta/0030_snapshot.json
create mode 100644 src/client/hooks/useVoicedReveal.ts
create mode 100644 src/client/lib/voice.ts
rename src/{client/lib => shared}/paragraphs.ts (88%)
diff --git a/__tests__/client/voice.test.ts b/__tests__/client/voice.test.ts
new file mode 100644
index 0000000..e174fd8
--- /dev/null
+++ b/__tests__/client/voice.test.ts
@@ -0,0 +1,61 @@
+import { afterEach, describe, expect, test } from 'bun:test'
+import { DEFAULT_VOICE, loadVoiceSetting, saveVoiceSetting } from '@/client/lib/voice'
+
+/**
+ * localStorage は「使えない環境がある」前提で触っている。
+ * bun のテストには最初から生えていないので、必要なテストだけ差し込む。
+ */
+const useStorage = (initial: Record = {}) => {
+ const store = new Map(Object.entries(initial))
+
+ Object.defineProperty(globalThis, 'localStorage', {
+ configurable: true,
+ value: {
+ getItem: (key: string) => {
+ const value = store.get(key)
+
+ return value === undefined ? null : value
+ },
+ setItem: (key: string, value: string) => {
+ store.set(key, value)
+ },
+ },
+ })
+}
+
+afterEach(() => {
+ Reflect.deleteProperty(globalThis, 'localStorage')
+})
+
+describe('loadVoiceSetting', () => {
+ test('保存されていなければ読み上げる', () => {
+ useStorage()
+
+ expect(loadVoiceSetting()).toBe(DEFAULT_VOICE)
+ expect(DEFAULT_VOICE).toBe('on')
+ })
+
+ test('保存した値を読み戻せる', () => {
+ useStorage()
+ saveVoiceSetting('off')
+
+ expect(loadVoiceSetting()).toBe('off')
+ })
+
+ test('知らない値は既定に落とす', () => {
+ useStorage({ 'alibai:voice': 'loud' })
+
+ expect(loadVoiceSetting()).toBe(DEFAULT_VOICE)
+ })
+
+ /** localStorage ごと無い環境。声が出ないだけで、プレイは続けられる。 */
+ test('localStorage が無くても既定を返す', () => {
+ expect(loadVoiceSetting()).toBe(DEFAULT_VOICE)
+ })
+
+ test('打鍵音とは別の鍵に住む', () => {
+ useStorage({ 'alibai:sound': 'off' })
+
+ expect(loadVoiceSetting()).toBe('on')
+ })
+})
diff --git a/__tests__/server/speakers.test.ts b/__tests__/server/speakers.test.ts
new file mode 100644
index 0000000..6894c18
--- /dev/null
+++ b/__tests__/server/speakers.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, test } from 'bun:test'
+import { assignSpeakers, speakerFor } from '@/server/tts/irodori'
+
+const pool = (prefix: string, count: number) =>
+ Array.from({ length: count }, (_value, index) => ({ id: `${prefix}-${index}` }))
+
+/** 束は「年ごろまで合う声」「性別だけ合う声」「全員」の順を想定している。 */
+const person = (id: string, tiers: { id: string }[][]) => ({ id, tiers })
+
+describe('assignSpeakers', () => {
+ test('同じ束から取る二人に、同じ声を当てない', () => {
+ const same = pool('young-female', 4)
+ const assigned = assignSpeakers([person('a', [same]), person('b', [same])])
+
+ expect(assigned.get('a')?.id).not.toBe(assigned.get('b')?.id)
+ })
+
+ test('年ごろまで合う声が一体しか無いときは、二人目が年ごろを譲る', () => {
+ const senior = pool('senior', 1)
+ const sameGender = [...senior, ...pool('adult', 3)]
+ const assigned = assignSpeakers([
+ person('a', [senior, sameGender]),
+ person('b', [senior, sameGender]),
+ ])
+
+ // 一人目は年ごろまで合う一体を取り、二人目は同じ性別の別の声へ落ちる。
+ expect(new Set([assigned.get('a')?.id, assigned.get('b')?.id]).size).toBe(2)
+ expect([assigned.get('a')?.id, assigned.get('b')?.id]).toContain('senior-0')
+ })
+
+ test('性別の合う声が無ければ、最後の束(全員)から取る', () => {
+ const everyone = pool('any', 3)
+ const assigned = assignSpeakers([person('a', [[], [], everyone])])
+
+ expect(assigned.get('a')?.id).toStartWith('any-')
+ })
+
+ test('どの束も空なら声を当てない', () => {
+ expect(assignSpeakers([person('a', [[], [], []])]).size).toBe(0)
+ })
+
+ test('全部塞がったら重なりを許す。声が出ないより似ているほうを採る', () => {
+ const only = pool('only', 1)
+ const assigned = assignSpeakers([person('a', [only]), person('b', [only])])
+
+ expect(assigned.get('a')?.id).toBe('only-0')
+ expect(assigned.get('b')?.id).toBe('only-0')
+ })
+
+ test('渡す順を変えても同じ割り当てになる(IDの昇順で配る)', () => {
+ const same = pool('same', 5)
+ const cast = [person('c', [same]), person('a', [same]), person('b', [same])]
+ const forward = assignSpeakers(cast)
+ const backward = assignSpeakers([...cast].reverse())
+
+ for (const id of ['a', 'b', 'c']) {
+ expect(forward.get(id)?.id).toBe(backward.get(id)?.id)
+ }
+ })
+})
+
+describe('speakerFor', () => {
+ test('同じ人物には毎回同じ声が当たる', () => {
+ const candidates = pool('voice', 7)
+
+ expect(speakerFor(candidates, 'person-1')?.id).toBe(speakerFor(candidates, 'person-1')?.id)
+ })
+
+ test('候補が空なら決まらない', () => {
+ expect(speakerFor([], 'person-1')).toBeUndefined()
+ })
+})
diff --git a/__tests__/client/paragraphs.test.ts b/__tests__/shared/paragraphs.test.ts
similarity index 74%
rename from __tests__/client/paragraphs.test.ts
rename to __tests__/shared/paragraphs.test.ts
index ef79e94..91fc06f 100644
--- a/__tests__/client/paragraphs.test.ts
+++ b/__tests__/shared/paragraphs.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test'
-import { splitParagraphs } from '@/client/lib/paragraphs'
+import { messageParagraphs, splitParagraphs } from '@/shared/paragraphs'
describe('splitParagraphs', () => {
test('空行1つで区切られた段落を分割する', () => {
@@ -79,3 +79,29 @@ describe('splitParagraphs(長い段落の分割)', () => {
])
})
})
+
+/**
+ * 読み上げは「この発言の N 行目」で頼むので、画面とサーバがここを同じに割る。
+ * ずれると、出ている行と読まれる行が食い違う。
+ */
+describe('messageParagraphs', () => {
+ const sentence = (chars: number) => `${'あ'.repeat(chars - 1)}。`
+
+ test('単なる改行も段落の切れ目として扱う', () => {
+ expect(messageParagraphs(`${sentence(10)}\n${sentence(10)}`)).toEqual([
+ sentence(10),
+ sentence(10),
+ ])
+ })
+
+ test('連続した改行はひとつの切れ目', () => {
+ expect(messageParagraphs(`${sentence(10)}\n\n\n${sentence(10)}`)).toHaveLength(2)
+ })
+
+ test('長い段落は文の切れ目で割れ、添字がそのまま行番号になる', () => {
+ const said = messageParagraphs(`${sentence(40)}${sentence(40)}`)
+
+ expect(said).toHaveLength(2)
+ expect(said[1]).toBe(sentence(40))
+ })
+})
diff --git a/bun.lock b/bun.lock
index 17a20b2..504a1dd 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,21 +5,21 @@
"": {
"name": "alibai",
"dependencies": {
- "@ai-sdk/openai": "^2",
- "@ai-sdk/provider-utils": "^3",
+ "@ai-sdk/openai": "^2.0.126",
+ "@ai-sdk/provider-utils": "^3.0.36",
"@commitlint/config-conventional": "^21.2.2",
- "@tanstack/react-router": "^1.170.32",
- "@tanstack/react-start": "^1.168.49",
+ "@tanstack/react-router": "^1.170.33",
+ "@tanstack/react-start": "^1.168.50",
"@vitejs/plugin-react": "^6.1.1",
- "ai": "^5",
+ "ai": "^5.0.254",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"commitlint": "^21.2.2",
"conventional-changelog-conventionalcommits": "^10.4.0",
"dayjs": "^1.11.23",
"drizzle-orm": "^0.45.2",
- "hono": "^4.13.5",
- "lucide-react": "^1.38.0",
+ "hono": "^4.13.7",
+ "lucide-react": "^1.43.0",
"radix-ui": "^1.6.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
@@ -27,22 +27,22 @@
"zod": "^4.5.4",
},
"devDependencies": {
- "@biomejs/biome": "^2.5.11",
- "@cloudflare/vite-plugin": "^1.54.2",
- "@cloudflare/workers-types": "^5.20260831.1",
+ "@biomejs/biome": "^2.5.12",
+ "@cloudflare/vite-plugin": "^1.54.6",
+ "@cloudflare/workers-types": "^5.20260908.1",
"@storybook/react-vite": "10.5.10",
"@tailwindcss/vite": "^4.3.3",
- "@types/bun": "^1.4.0",
- "@types/node": "^26.4.0",
+ "@types/bun": "^1.4.2",
+ "@types/node": "^26.5.0",
"@types/react": "^19.2.18",
- "@types/react-dom": "^19.2.5",
+ "@types/react-dom": "^19.2.7",
"drizzle-kit": "^0.31.10",
- "playwright": "^1.62.1",
+ "playwright": "^1.63.0",
"storybook": "10.5.10",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.2.2",
- "wrangler": "^4.127.1",
+ "wrangler": "^4.130.0",
"yaml": "^2.9.0",
},
},
@@ -50,9 +50,9 @@
"packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
- "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.145", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.36", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yDnrdxwKBF9kYHpS99c7GreufknlNU9ecXqYSjigq3Pv5OUFNVeEGwfbL7ninYNU13p4SlXoRmRlmymWPLZMVQ=="],
+ "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.148", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.36", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-j6ObDCcvDNUtEUzPLiBVB6EsJNbeIAGtWd4cxW0MQvQldllrLGPRvEDJNfYP7MbSmRDDjfsNnDnH5jmRwXngVw=="],
- "@ai-sdk/openai": ["@ai-sdk/openai@2.0.123", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.36" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-KB9KY1yxTGkeYE7lskYydS7AkAvEdTSALJfsSsq8oDgTj4zJMfKWS+K5yJVp7ErWZ+tRyMqx3aepnxPsv9fWeA=="],
+ "@ai-sdk/openai": ["@ai-sdk/openai@2.0.126", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.36" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LNNz+yUEVxIKmTCBJhzk1k7fKij4gGJCGsPUK98vBpv5pEKp1FQ9Um2vcm+XbaankRw+jeXuk9GMOoMVrDdfFQ=="],
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww=="],
@@ -92,41 +92,41 @@
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
- "@biomejs/biome": ["@biomejs/biome@2.5.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.11", "@biomejs/cli-darwin-x64": "2.5.11", "@biomejs/cli-linux-arm64": "2.5.11", "@biomejs/cli-linux-arm64-musl": "2.5.11", "@biomejs/cli-linux-x64": "2.5.11", "@biomejs/cli-linux-x64-musl": "2.5.11", "@biomejs/cli-win32-arm64": "2.5.11", "@biomejs/cli-win32-x64": "2.5.11" }, "bin": { "biome": "bin/biome" } }, "sha512-Tj0dnkLPdW0ASjHfj2D/ZkkvPU2wrFmnE1jWTD2xzV1ycapV1DutbYXk4NDnR3rYTi1ZCbNFD4G2gRMEY65WaA=="],
+ "@biomejs/biome": ["@biomejs/biome@2.5.12", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.12", "@biomejs/cli-darwin-x64": "2.5.12", "@biomejs/cli-linux-arm64": "2.5.12", "@biomejs/cli-linux-arm64-musl": "2.5.12", "@biomejs/cli-linux-x64": "2.5.12", "@biomejs/cli-linux-x64-musl": "2.5.12", "@biomejs/cli-win32-arm64": "2.5.12", "@biomejs/cli-win32-x64": "2.5.12" }, "bin": { "biome": "bin/biome" } }, "sha512-Lw4VHZRebrReBBnlHa12JQjnIBm3JJAA55PDB9LbBVBF0q4RYphm6KfmIjqtPhf61MxZ5Q9KoK8R8x+7per5Aw=="],
- "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6SGZxoKbXvUjMn1t6A98HqWISPnGNbYs0R/Rt2JarmXBSev+lva4QxUMWEBX9lX1Wo1XTJ78uk5xVDtG58SRZg=="],
+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lCRY1rwgNeWNgTr4DI/u6ZwXTRwRLHAvbaio1YLLGS+4r1nhvB2ssyPqIpfUSmRveNfv0fn/N58C7CAdK2XVrg=="],
- "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-nYkXY7tLBEgnGbYapDKAyKzgt44ZEyG+AKalvTXtCWKYgepI9dw327q+cVgedxm+Udi1ZzHKUyZrIusHi/KQbw=="],
+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-vhPgwnh+6tN3ArdAXuET99xaNbFt7CG82Bqn+omHVLC5xdVx45JsYjGPmUIGNzjDek5XdNCP1HKksK7fn8+3bQ=="],
- "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-3PVLSTD9RR73rvVPt5G3T1gc+ycggWEGfTD7RvzzbtcDPD27NxgxBbAFfpm7DXJKW6VLHWE1lLMGvFt2Qxjcow=="],
+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-2gp8aVwXYKdAtmBfRFCUuyDMcfN1ahHqUkGfLYrZlNRFmryMATLVvJgWKvyA8wu4Rwn5OSxM1UcUmOuOFNGeBQ=="],
- "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-qhyZUMyCbWYFV2bAwRNVvfMVZ+hv7WYl6mossGrxC+uiQQXhvsuWWU8zz6jYX0mChZd9MgQZbm4vozTmG/5iGw=="],
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-couHYjFLL5uuI8ne6zhT7KwEsXo5YP7ry/2xmEqah7qanu0YmfDi3mwJg47YXSuv/NpZj22CZzcRH/5c4gjPSQ=="],
- "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.11", "", { "os": "linux", "cpu": "x64" }, "sha512-JOytptlsgM33B2MMFUg8iBrb4IKpbD5JnJrSeYiaFEeAj4vuXx0iQSQZ4qK7sqyMtfjZxxPdNdMZZVL4y/mFyA=="],
+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-SnvOs3TSTiuia4SQOUNe1aWC9RT4+YkjcKnOhL/nsKOV0k5ycgBkDzF0lUxKn1V7Q8CLTRq6iV23ZAivHomRoA=="],
- "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.11", "", { "os": "linux", "cpu": "x64" }, "sha512-oRRlrchG5EfrEL/EmtT1qUjSNHk3/5LGeZhQqADBBAJF1b1ET6964xEKe7aGlGARzDfza8H/seEsFJl7S6Ql9w=="],
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-8A0oDW58/w9f/PQNYuq0sGUZtGtGrkNF4Z6n0PUoXpLCshi85vtKTv1XSznQawhdE4MXJ8ufpzHXyLFe87M/+w=="],
- "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-e49E6K9hzH/ohJNx8Y26mY8HaV4I4ZViIeoqhKsmoXLKHhQnMeBAVqCgsGf2Wa3lXlS7RkporDXMHHWkzvZzFw=="],
+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-b9vtoZFsuZt1pdjNwJvXl0f+BpayRzV008uS2+JpmwIKdSE2qdu4A/l04FESwLoou5g2E/Qlec0xwJydZplH+A=="],
- "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.11", "", { "os": "win32", "cpu": "x64" }, "sha512-QSQr/KjOgXA7OzXJUWS+oguKyAZ3Q0l/lnlDGbu397eKo83atuWUjBPJrsqbKNF6CARGw8XXJLGzpHC8Ryhd4Q=="],
+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.12", "", { "os": "win32", "cpu": "x64" }, "sha512-B1R/l+CwEpKFSuqiwePzPNRk1EiJN8kc0UhdafNz6MZN9v5OFP9HYP1irptvWzHrwVI4blVNGMbxc5zt70m3IA=="],
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
- "@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.54.2", "", { "dependencies": { "@cloudflare/unenv-preset": "2.16.1", "miniflare": "5.20260828.0-alpha", "unenv": "2.0.0-rc.24", "workerd": "1.20260828.1", "wrangler": "4.127.1", "ws": "8.21.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0 || ^8.0.0" }, "bin": { "cf-vite": "bin/cf-vite" } }, "sha512-15mH5ARHTkNZAqa5GhedjLAt/MCiGBEo09Hgf82EiWuIm/5yOaMVL6ABSun/W8C3Vbw1clzW/2i9IfH5zT/Kvg=="],
+ "@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.54.6", "", { "dependencies": { "@cloudflare/unenv-preset": "2.16.1", "miniflare": "5.20260908.0-alpha", "unenv": "2.0.0-rc.24", "workerd": "1.20260908.1", "wrangler": "4.130.0", "ws": "8.21.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0 || ^8.0.0" }, "bin": { "cf-vite": "bin/cf-vite" } }, "sha512-7G85rvY9gaLYOLA/0Q1rQPx6l7wrY763b67FEVXyWoFUriiLljemBkbsFsnVYjrJyZ+yqHQ6gNCRX//hAzSc0g=="],
- "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260828.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-CVd+xPhqUESg8Xhq09TZx0wl4FSirfJGOzvbPz2yHhBIvmNHFFQkSN3rkd7wEwnhQQk37Xi0/aD6ykPLJbmGiQ=="],
+ "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260908.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-t3juyCXFn12OklBL0S7UC98py3nLEmuioBOUOazeyeVsLUu8fv+pfJrR+XzwSH2Uh6rCXZNogRh+LtV0YpzMcQ=="],
- "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260828.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5HDPXRM152vU5JveByGFk34X57TVyIsfp4cabepAf45DC0MKvm52ucJqAjW1h8bvW4X+zRw9GU35OHF9FEC9Ww=="],
+ "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260908.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-I1nwA4qm/fUNSKhPSO76YA6JAhcA0KU63yFcYHN6AiDowGbDyfjDPH9KCVopT5rI1LY4GfbdAi5b9gODqZsAkQ=="],
- "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260828.1", "", { "os": "linux", "cpu": "x64" }, "sha512-MQ1Ll9P7F72HHUKizbb7BlDfbY8fRoNMpbIpZoU6uKsSkneFICWSKv6UlgU9EQZ+w0i7TMa12iUgJ8l29eRI9A=="],
+ "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260908.1", "", { "os": "linux", "cpu": "x64" }, "sha512-s/h5uSW1UC6dGeVKSqrPLpTu+vo0fKJZCNEokW1Ol1qcCB2oN6WCRHhoyzo4Y69oONAXRgLfLBYaHWe7z51Vnw=="],
- "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260828.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-FBTaUQ1xcU9jcp4OyBPcH8x0QiFvc1iuZL2GkD8zp2q1WyTVHYOptRDQUU+cuHjt0rQ2EIKVPBjahPxfa0joBw=="],
+ "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260908.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-PP/nUKl0R6colwfocggL8dctO/dFMqMft12unzFUkoAJr0aXQeT+ia0JuNmOUWXe6EfvyCSmAbijEsTKQ5t/ew=="],
- "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260828.1", "", { "os": "win32", "cpu": "x64" }, "sha512-yvr77hC7dUbvK5K+SCg062kkPq3sx+drV1PcgHslzHDYcJBtT0V3X80qLE49LW1vq2svaeNmsVQS+vHsqWu8cQ=="],
+ "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260908.1", "", { "os": "win32", "cpu": "x64" }, "sha512-jkaS5EKKTvzAdIlbMfOYrHoTucWVRwYBwIig+xRsjXQv5BXTLA2Llg+iM8djlKtk0CjkztZhXmuxuqoqWKZmFw=="],
- "@cloudflare/workers-types": ["@cloudflare/workers-types@5.20260831.1", "", {}, "sha512-yXg4pwfYjhsDH9rYc3qZ3K+z62DCSvO/aj7GiZo6AyDeWGZpyFRpPMYcQ6LF/zfaf1x0Ngw2gSqL8JjuUtMGlA=="],
+ "@cloudflare/workers-types": ["@cloudflare/workers-types@5.20260908.1", "", {}, "sha512-cILmYEd/YtL+Hlwytc5n+QVV8F+E5doQOtc6QiBtPb51kK+5fkk909db+G8dxeUsMd2ytJgdpAt/lyciUEobcw=="],
"@commitlint/cli": ["@commitlint/cli@21.2.2", "", { "dependencies": { "@commitlint/config-conventional": "^21.2.2", "@commitlint/format": "^21.2.2", "@commitlint/lint": "^21.2.2", "@commitlint/load": "^21.2.2", "@commitlint/read": "^21.2.1", "@commitlint/types": "^21.2.0", "tinyexec": "^1.0.0", "yargs": "^18.0.0" }, "bin": { "commitlint": "cli.js" } }, "sha512-a+6hQxIxnpdvSvS2apvttPNbEliYsVC3PqFYDiiB2kjbwIsQsj1urvQ4Tkf70pKYozPalKAuRQmm/GHwndduqA=="],
@@ -526,35 +526,35 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.3", "", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="],
- "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.6", "", { "os": "android", "cpu": "arm" }, "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ=="],
+ "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.8", "", { "os": "android", "cpu": "arm" }, "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw=="],
- "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.6", "", { "os": "android", "cpu": "arm64" }, "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q=="],
+ "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.8", "", { "os": "android", "cpu": "arm64" }, "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ=="],
- "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA=="],
+ "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA=="],
- "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q=="],
+ "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA=="],
- "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA=="],
+ "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.8", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ=="],
- "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.6", "", { "os": "linux", "cpu": "arm" }, "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w=="],
+ "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.8", "", { "os": "linux", "cpu": "arm" }, "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q=="],
- "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg=="],
+ "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew=="],
- "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw=="],
+ "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA=="],
- "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ=="],
+ "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.8", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA=="],
- "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA=="],
+ "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.8", "", { "os": "linux", "cpu": "s390x" }, "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ=="],
- "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w=="],
+ "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.8", "", { "os": "linux", "cpu": "x64" }, "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g=="],
- "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ=="],
+ "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.8", "", { "os": "linux", "cpu": "x64" }, "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA=="],
- "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.6", "", { "os": "none", "cpu": "arm64" }, "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg=="],
+ "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.8", "", { "os": "none", "cpu": "arm64" }, "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg=="],
- "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A=="],
+ "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A=="],
- "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ=="],
+ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.8", "", { "os": "win32", "cpu": "x64" }, "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
@@ -614,37 +614,37 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
- "@tanstack/history": ["@tanstack/history@1.162.1", "", {}, "sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w=="],
+ "@tanstack/history": ["@tanstack/history@1.162.2", "", {}, "sha512-Lemp3DJbzNqcin/nZpWxycDaEqySDbnIshDbyHJMMCapD4ZQMe57szRpBXOfzfP6fyWAtHNrLrcBUyANJ6Vlow=="],
- "@tanstack/react-router": ["@tanstack/react-router@1.170.32", "", { "dependencies": { "@tanstack/history": "1.162.1", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.27", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-SIpxvaTKco100a5ZR3ePmArbhtm3XOx+w1dpGYY9gxHDta4iXSKDdQuhLonwJbIMkVJsU1rwXf0UDHMrF/1snw=="],
+ "@tanstack/react-router": ["@tanstack/react-router@1.170.33", "", { "dependencies": { "@tanstack/history": "1.162.2", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.28", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-iNnI98vH3kO/V4dy6YM0CInhqwWBddU0G5wZK5jiMvr3HsK2avDQSRx3RY/y6v+6zQAqb2kD6hUPHIenrJBTSw=="],
- "@tanstack/react-start": ["@tanstack/react-start@1.168.49", "", { "dependencies": { "@tanstack/react-router": "1.170.32", "@tanstack/react-start-client": "1.168.30", "@tanstack/react-start-rsc": "0.1.48", "@tanstack/react-start-server": "1.167.37", "@tanstack/router-utils": "1.162.2", "@tanstack/start-client-core": "1.170.27", "@tanstack/start-plugin-core": "1.171.39", "@tanstack/start-server-core": "1.169.31", "pathe": "^2.0.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "@vitejs/plugin-rsc": "*", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "@vitejs/plugin-rsc", "vite"] }, "sha512-iQb1ZoEHqvZMGLR4G7v3tTtgL06yv/bwxvGP3waVHxVn7bRpyopM44YbOluaGkcJzc13ZTvdLKWYNAN3bxMX/Q=="],
+ "@tanstack/react-start": ["@tanstack/react-start@1.168.50", "", { "dependencies": { "@tanstack/react-router": "1.170.33", "@tanstack/react-start-client": "1.168.31", "@tanstack/react-start-rsc": "0.1.49", "@tanstack/react-start-server": "1.167.38", "@tanstack/router-utils": "1.162.2", "@tanstack/start-client-core": "1.170.28", "@tanstack/start-plugin-core": "1.171.40", "@tanstack/start-server-core": "1.169.32", "pathe": "^2.0.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "@vitejs/plugin-rsc": "*", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "@vitejs/plugin-rsc", "vite"] }, "sha512-QtC9gSEQBE3Nnx24ZpbhSO+agNR9Wv/fkW03yORFLtDxMenqNYISrsBPEH2GpRElGofATaLWkCShlUwilaYksw=="],
- "@tanstack/react-start-client": ["@tanstack/react-start-client@1.168.30", "", { "dependencies": { "@tanstack/react-router": "1.170.32", "@tanstack/router-core": "1.171.27", "@tanstack/start-client-core": "1.170.27" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-qsZuykUl1EF0/rc1bin1RtjFzz07YMOTBzhOstSDzbOVm/WKf1QKFTN+qAZi74xlbXSWNuT2MiFRyg4RKwj8iw=="],
+ "@tanstack/react-start-client": ["@tanstack/react-start-client@1.168.31", "", { "dependencies": { "@tanstack/react-router": "1.170.33", "@tanstack/router-core": "1.171.28", "@tanstack/start-client-core": "1.170.28" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-zpvlHd4Bq75tCc86Yz5GYWrO/6pWu4KL4q2q9RkBLAZE56epBuSvRBVZaMclaN9/pXIuW8R73fejKrSVNtJSbw=="],
- "@tanstack/react-start-rsc": ["@tanstack/react-start-rsc@0.1.48", "", { "dependencies": { "@tanstack/react-router": "1.170.32", "@tanstack/router-core": "1.171.27", "@tanstack/router-utils": "1.162.2", "@tanstack/start-client-core": "1.170.27", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-plugin-core": "1.171.39", "@tanstack/start-storage-context": "1.167.29", "pathe": "^2.0.3" }, "peerDependencies": { "@rspack/core": ">=2.0.0-0", "@vitejs/plugin-rsc": ">=0.5.30", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "react-server-dom-rspack": ">=0.0.2" }, "optionalPeers": ["@rspack/core", "@vitejs/plugin-rsc", "react-server-dom-rspack"] }, "sha512-UglRdTMuF3c4dvzL/gh4dMVbMWHsPy8ZgTQdT2qpTlyt1b/3m+R40tBFdsfTgw76VTXivW+qV/ih0PN9000XTw=="],
+ "@tanstack/react-start-rsc": ["@tanstack/react-start-rsc@0.1.49", "", { "dependencies": { "@tanstack/react-router": "1.170.33", "@tanstack/router-core": "1.171.28", "@tanstack/router-utils": "1.162.2", "@tanstack/start-client-core": "1.170.28", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-plugin-core": "1.171.40", "@tanstack/start-storage-context": "1.167.30", "pathe": "^2.0.3" }, "peerDependencies": { "@rspack/core": ">=2.0.0-0", "@vitejs/plugin-rsc": ">=0.5.30", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "react-server-dom-rspack": ">=0.0.2" }, "optionalPeers": ["@rspack/core", "@vitejs/plugin-rsc", "react-server-dom-rspack"] }, "sha512-2c/k2pT2gCvEpymug9sBqJ8klKGNnazxqbfdEMQi9rI5FXoB1W6TBt3XEQa3V5FvTxk7p7xKmgOqVhzR+bDKdw=="],
- "@tanstack/react-start-server": ["@tanstack/react-start-server@1.167.37", "", { "dependencies": { "@tanstack/react-router": "1.170.32", "@tanstack/router-core": "1.171.27", "@tanstack/start-server-core": "1.169.31" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-cODHpFU8vIm7AdHii9W3NEuwyruNmT5wLDZjRsJLT9Jp+7FACnfJrRbvxnp1ldSv/9mxcHKi/OgwbdHQeMZcAQ=="],
+ "@tanstack/react-start-server": ["@tanstack/react-start-server@1.167.38", "", { "dependencies": { "@tanstack/react-router": "1.170.33", "@tanstack/router-core": "1.171.28", "@tanstack/start-server-core": "1.169.32" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-7Txo84AYNlSW0kLfd4HDccD5mgI/Atc0z29AvRMafS5KOjEb2j9tyOGDI0KRmTMAg/3zk9EIgBe8w7yDrysMEg=="],
"@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="],
- "@tanstack/router-core": ["@tanstack/router-core@1.171.27", "", { "dependencies": { "@tanstack/history": "1.162.1", "cookie-es": "^3.0.0", "seroval": "^1.6.2", "seroval-plugins": "^1.6.2" } }, "sha512-wDwSLvoLwIaNcnx9UNcN9Mb7Y8QwCYq1U1RQZwyN186gnkIoIYI2SOxy8VqH1vFigbkHkk4FmwMAQlghPgDK2g=="],
+ "@tanstack/router-core": ["@tanstack/router-core@1.171.28", "", { "dependencies": { "@tanstack/history": "1.162.2", "cookie-es": "^3.0.0", "seroval": "^1.6.2", "seroval-plugins": "^1.6.2" } }, "sha512-PvPWSklhw6i9b0rzScVh0btQsK5u/gBYN3mBHyDzhC/U4LrB3WzPXPkUunQUKvQOGXCp16UEb7Htc/ITGm5DkQ=="],
- "@tanstack/router-generator": ["@tanstack/router-generator@1.167.33", "", { "dependencies": { "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.27", "@tanstack/router-utils": "1.162.2", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.5.0", "zod": "^4.4.3" } }, "sha512-Z3lCWIPuRUMPmuI8Mm48x/s49TxmHOaFVZ52j1W1QKYrsFHyT6U/h9bqfHJDxfQ8kz7y9q+W1YPKZ15Ee7yuCA=="],
+ "@tanstack/router-generator": ["@tanstack/router-generator@1.167.34", "", { "dependencies": { "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.28", "@tanstack/router-utils": "1.162.2", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.5.0", "zod": "^4.4.3" } }, "sha512-sbCQrpYd2MGQXVXltUyPNLQFl924kLXBYp6Gq4keb1GPuDKfxH+yfcI3Qv9RQxI3ZErjgDZE7sOhRDXIf7xzxQ=="],
- "@tanstack/router-plugin": ["@tanstack/router-plugin@1.168.35", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.27", "@tanstack/router-generator": "1.167.33", "@tanstack/router-utils": "1.162.2", "chokidar": "^5.0.0", "unplugin": "^3.0.0", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.170.32", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-foDAZKFqHXae+oFbIgcsSvy2QCVRn7XdS3nhwcRvD+ed6JrKPUP/1lQMsZLJqWycgR1vkZF7gs955KGa0NZQ0w=="],
+ "@tanstack/router-plugin": ["@tanstack/router-plugin@1.168.36", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.28", "@tanstack/router-generator": "1.167.34", "@tanstack/router-utils": "1.162.2", "chokidar": "^5.0.0", "unplugin": "^3.0.0", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.170.33", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-A/Ama19yfgbgC3QZgQGwC+wceK8cYiqqFgRjTuNxhPCrrrdUrP1SKnCKvLvHISkx6xgLnDDMl2wJyP9PMgrjYg=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.162.2", "", { "dependencies": { "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ=="],
- "@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.27", "", { "dependencies": { "@tanstack/router-core": "1.171.27", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.29", "seroval": "^1.6.2" } }, "sha512-Ro6ZSM0NgYKDMxM0e8qyU4mBfnld2Zb74BA/9f4i35C0Y3IAA8Zxs/DIPfOo9VRYWp5c5n5Q8dRBn2qn0vtPhQ=="],
+ "@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.28", "", { "dependencies": { "@tanstack/router-core": "1.171.28", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.30", "seroval": "^1.6.2" } }, "sha512-WbMnrvYzsE4TtqotX0HeRnBI8wyMbFbg/uLzcY23Omk5BiduwlyIWEnBZsac3AjdK8C0BP1XTafp2p4kwhhNeA=="],
"@tanstack/start-fn-stubs": ["@tanstack/start-fn-stubs@1.162.0", "", {}, "sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ=="],
- "@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.171.39", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.27", "@tanstack/router-generator": "1.167.33", "@tanstack/router-plugin": "1.168.35", "@tanstack/router-utils": "1.162.2", "@tanstack/start-server-core": "1.169.31", "exsolve": "^1.0.7", "lightningcss": "^1.32.0", "pathe": "^2.0.3", "picomatch": "^4.0.3", "seroval": "^1.6.2", "source-map": "^0.7.6", "srvx": "^0.11.9", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "vite"] }, "sha512-Zyj6G4MDFLXcHYhPavhewCBo8dsxi3qvPk31zl/QtTzYzOY9rJHDDBGarKsJq20ziChmkGn/sttYqb4vGCxJDA=="],
+ "@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.171.40", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "@tanstack/router-core": "1.171.28", "@tanstack/router-generator": "1.167.34", "@tanstack/router-plugin": "1.168.36", "@tanstack/router-utils": "1.162.2", "@tanstack/start-server-core": "1.169.32", "exsolve": "^1.0.7", "lightningcss": "^1.32.0", "pathe": "^2.0.3", "picomatch": "^4.0.3", "seroval": "^1.6.2", "source-map": "^0.7.6", "srvx": "^0.11.9", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "vite"] }, "sha512-AWKf7Lr14qd+G24K5AJTHsxKrgzmYnk0a521O80ZMAkVWXeNZZ8A2L4qyNTL+eVk9i5vssMhPoSgulJqPEvUHg=="],
- "@tanstack/start-server-core": ["@tanstack/start-server-core@1.169.31", "", { "dependencies": { "@tanstack/history": "1.162.1", "@tanstack/router-core": "1.171.27", "@tanstack/start-client-core": "1.170.27", "@tanstack/start-storage-context": "1.167.29", "fetchdts": "^0.1.6", "h3-v2": "npm:h3@2.0.1-rc.20", "seroval": "^1.6.2" } }, "sha512-56w8l+Fao01YCrmv0hzNxL3b3FRmqLRGdE11izZNQsW+1CcSYuehAM5khWKABuI1DI/UIBLGV7BwL4Dlg0eHCw=="],
+ "@tanstack/start-server-core": ["@tanstack/start-server-core@1.169.32", "", { "dependencies": { "@tanstack/history": "1.162.2", "@tanstack/router-core": "1.171.28", "@tanstack/start-client-core": "1.170.28", "@tanstack/start-storage-context": "1.167.30", "fetchdts": "^0.1.6", "h3-v2": "npm:h3@2.0.1-rc.20", "seroval": "^1.6.2" } }, "sha512-/r10WTBAncF1llVqxc7r+a5Y3Lkd685gMbFCl4udV+9022bW40or3K4mmNI5q+LceZgI/HFdHF4+j54wikFrNw=="],
- "@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.167.29", "", { "dependencies": { "@tanstack/router-core": "1.171.27" } }, "sha512-8qfprC5774XMRDQlMogkfiGpFLiBf0xDG4bMFUbfkSzpCAQwpLbgGY4Zwft22O9rKYq2vUXusKAdVjvJTUEquQ=="],
+ "@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.167.30", "", { "dependencies": { "@tanstack/router-core": "1.171.28" } }, "sha512-NR4AmF7PjnOyQ47pMc9pC1IpOKGmLMjTOKkGzRzipluzLE8gfEsrapbef/rSRGAYyGU0oHDGm3Fz4itFnK1eUQ=="],
"@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
@@ -654,7 +654,7 @@
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
- "@testing-library/user-event": ["@testing-library/user-event@14.6.6", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw=="],
+ "@testing-library/user-event": ["@testing-library/user-event@14.6.7", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
@@ -668,7 +668,7 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
- "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
+ "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
@@ -678,11 +678,11 @@
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
- "@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
+ "@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="],
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
- "@types/react-dom": ["@types/react-dom@19.2.5", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg=="],
+ "@types/react-dom": ["@types/react-dom@19.2.7", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ=="],
"@types/resolve": ["@types/resolve@1.20.6", "", {}, "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ=="],
@@ -742,7 +742,7 @@
"acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
- "ai": ["ai@5.0.251", "", { "dependencies": { "@ai-sdk/gateway": "2.0.145", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.36", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-d4mwmJ10uvyFqq5nOYrIgRtuadDc6ipjiN9X0HKRpM/z96zt9crQemuOXUaaa9XWufyXbYTP1kEAfjnTn+Ru+w=="],
+ "ai": ["ai@5.0.254", "", { "dependencies": { "@ai-sdk/gateway": "2.0.148", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.36", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RLetELlXNhg1s+u/7UwO5PAF8NiG/zMdSz7ZX/5hTfb4xYVWzOenLu3uRVrc8iKgC2Fkskn+LGdqUc7el0GvMw=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
@@ -754,7 +754,7 @@
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
- "argue-cli": ["argue-cli@3.1.0", "", {}, "sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw=="],
+ "argue-cli": ["argue-cli@3.2.0", "", {}, "sha512-VipTB0gXgGIFO2Rg9yEVN5wLt2AurJcZqDbgmYSwPwsykhLrQhs240/bfceev4w68lI8JshoOJIk9uW7tFzROw=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
@@ -768,17 +768,17 @@
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
- "baseline-browser-mapping": ["baseline-browser-mapping@2.11.20", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw=="],
+ "baseline-browser-mapping": ["baseline-browser-mapping@2.11.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ=="],
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
"brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
- "browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="],
+ "browserslist": ["browserslist@4.28.9", "", { "dependencies": { "baseline-browser-mapping": "^2.11.20", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.420", "node-releases": "^2.0.54", "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" } }, "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
- "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
+ "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -848,7 +848,7 @@
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
- "electron-to-chromium": ["electron-to-chromium@1.5.417", "", {}, "sha512-4T+DTDWuMPM4aHlHwWdAVCVWwp7LDilnhzkj+c/Lbj91XSQrLuOmZSLtS9Q4iIqjlPUbPOnC624zDVVHCHaolQ=="],
+ "electron-to-chromium": ["electron-to-chromium@1.5.425", "", {}, "sha512-QvPtl41EUOnuT1HBvMKgxXRIaHNcagBPs50u7VULzhZXaGfqTbZyE16LQsctZ/RQHlGu+FOWeDTR4mY6YbeF1g=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
@@ -882,13 +882,13 @@
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
- "fast-uri": ["fast-uri@3.1.6", "", {}, "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q=="],
+ "fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fetchdts": ["fetchdts@0.1.7", "", {}, "sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA=="],
- "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -912,7 +912,7 @@
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
- "hono": ["hono@4.13.5", "", {}, "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw=="],
+ "hono": ["hono@4.13.7", "", {}, "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -984,7 +984,7 @@
"lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
- "lucide-react": ["lucide-react@1.38.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-xZCyBd/wiVUDactoCc+42TjL0aB7EBOXsuX+tjz+W/sGzw2KhHpL1NOH3FIaVUcpimvUBpIYfz34Ofj9S5JEzQ=="],
+ "lucide-react": ["lucide-react@1.43.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ubtnda1fVK5ky0PNEpOmB0wiwhpZUyJiol4K14KCm+QKvuCkfUu54Et/rIjyupJpwiJ5Hg+BLQE86/GEsS+IgQ=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
@@ -992,7 +992,7 @@
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
- "miniflare": ["miniflare@5.20260828.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260828.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-6nbxhZEcz/UET3Y1OnYPsrAUjUmuFoib3ynUqteRdn1YnDxsLg8cwgZJZCk9QmtOmGzXwzXzgE/d/C0dJAPtVw=="],
+ "miniflare": ["miniflare@5.20260908.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260908.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-BHIknb0u6vLIvb+R8PsUAzgoWWk3OlW85DrV2SG0EydfSpIba28YT0rH6xZThjnSwDxzNuW3FDRNSWvbiI/DVw=="],
"minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="],
@@ -1004,7 +1004,7 @@
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
- "node-releases": ["node-releases@2.0.54", "", {}, "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ=="],
+ "node-releases": ["node-releases@2.0.55", "", {}, "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ=="],
"open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
@@ -1030,11 +1030,11 @@
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
- "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="],
+ "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="],
- "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="],
+ "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
- "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
+ "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
"prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="],
@@ -1072,7 +1072,7 @@
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
- "rolldown": ["rolldown@1.2.6", "", { "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.6", "@rolldown/binding-android-arm64": "1.2.6", "@rolldown/binding-darwin-arm64": "1.2.6", "@rolldown/binding-darwin-x64": "1.2.6", "@rolldown/binding-freebsd-x64": "1.2.6", "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", "@rolldown/binding-linux-arm64-gnu": "1.2.6", "@rolldown/binding-linux-arm64-musl": "1.2.6", "@rolldown/binding-linux-ppc64-gnu": "1.2.6", "@rolldown/binding-linux-s390x-gnu": "1.2.6", "@rolldown/binding-linux-x64-gnu": "1.2.6", "@rolldown/binding-linux-x64-musl": "1.2.6", "@rolldown/binding-openharmony-arm64": "1.2.6", "@rolldown/binding-win32-arm64-msvc": "1.2.6", "@rolldown/binding-win32-x64-msvc": "1.2.6" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA=="],
+ "rolldown": ["rolldown@1.2.8", "", { "dependencies": { "@oxc-project/types": "=0.149.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.8", "@rolldown/binding-android-arm64": "1.2.8", "@rolldown/binding-darwin-arm64": "1.2.8", "@rolldown/binding-darwin-x64": "1.2.8", "@rolldown/binding-freebsd-x64": "1.2.8", "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", "@rolldown/binding-linux-arm64-gnu": "1.2.8", "@rolldown/binding-linux-arm64-musl": "1.2.8", "@rolldown/binding-linux-ppc64-gnu": "1.2.8", "@rolldown/binding-linux-s390x-gnu": "1.2.8", "@rolldown/binding-linux-x64-gnu": "1.2.8", "@rolldown/binding-linux-x64-musl": "1.2.8", "@rolldown/binding-openharmony-arm64": "1.2.8", "@rolldown/binding-win32-arm64-msvc": "1.2.8", "@rolldown/binding-win32-x64-msvc": "1.2.8" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ=="],
"rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
@@ -1082,9 +1082,9 @@
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
- "seroval": ["seroval@1.6.4", "", {}, "sha512-LErWMNS2RRFdu2RMA5u/PA59/IWs0XsikyEXGQ2/36iEWFrdG0ABmg17E17cikrv76891kOAMq3TkTFXpwAHXw=="],
+ "seroval": ["seroval@1.6.7", "", {}, "sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ=="],
- "seroval-plugins": ["seroval-plugins@1.6.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-R0f1U9hmn38+dFMz6b6ab8lwucmw4AtiY7St+JPWudy1dm+Bs3g884nyrsH9Cy6rKpZKLYayXuMda9GZ/fl8JQ=="],
+ "seroval-plugins": ["seroval-plugins@1.6.7", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4Nk35ttD3DTDJW4hgw5StsVAPeU6qnDFnULAouw6tQ7oLTV/ICXrWpsXo2EE52eSP2joUMazbVf52mFEcADqRw=="],
"sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="],
@@ -1118,13 +1118,13 @@
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
- "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
+ "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
- "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
+ "tinyspy": ["tinyspy@4.0.6", "", {}, "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg=="],
"ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="],
@@ -1140,7 +1140,7 @@
"undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
- "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
+ "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
@@ -1160,9 +1160,9 @@
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
- "workerd": ["workerd@1.20260828.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260828.1", "@cloudflare/workerd-darwin-arm64": "1.20260828.1", "@cloudflare/workerd-linux-64": "1.20260828.1", "@cloudflare/workerd-linux-arm64": "1.20260828.1", "@cloudflare/workerd-windows-64": "1.20260828.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-pB9yvt0kkwZDAGZHmpY59r0o3hM0DzdW6BJERqwZOhunZ3ssOyDSgQxOQer2cSZW4YCFeOTIQYN1qwhK5wv/Cw=="],
+ "workerd": ["workerd@1.20260908.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260908.1", "@cloudflare/workerd-darwin-arm64": "1.20260908.1", "@cloudflare/workerd-linux-64": "1.20260908.1", "@cloudflare/workerd-linux-arm64": "1.20260908.1", "@cloudflare/workerd-windows-64": "1.20260908.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-rYhpW6NWHD++p34ej+VXWzi5Pdnmd7ncdbB/ux4s+i3mf7IeOpW3O4roqClQ+Z8ernvyMCknBLCb76z1rA+a7Q=="],
- "wrangler": ["wrangler@4.127.1", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260828.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260828.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260828.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-OzsiNgaI8i681L/+KnAKc+uEZ5D57xK5JuNvCOpRKICF4/5Q3Cu1oTGuUiT/f3GDUqQb3gzXNT0tfOHGMEtknw=="],
+ "wrangler": ["wrangler@4.130.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260908.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260908.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260908.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-fzNjnTzyZl31PGJOFXbiLeZqEIToQ9KOzkkvGdQz4wlB+BoHnl3BRwCR5xg8AqYyzVunvvMHlMzvlbThQ7rrAA=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
@@ -1242,7 +1242,7 @@
"redent/strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
- "rolldown/@oxc-project/types": ["@oxc-project/types@0.147.0", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="],
+ "rolldown/@oxc-project/types": ["@oxc-project/types@0.149.0", "", {}, "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA=="],
"storybook/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
@@ -1252,14 +1252,8 @@
"tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
- "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
-
- "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
-
"wrangler/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
- "wrangler/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
-
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
diff --git a/db/migrations/0030_messages-drop-character-fk.sql b/db/migrations/0030_messages-drop-character-fk.sql
new file mode 100644
index 0000000..51a8602
--- /dev/null
+++ b/db/migrations/0030_messages-drop-character-fk.sql
@@ -0,0 +1,17 @@
+PRAGMA foreign_keys=OFF;--> statement-breakpoint
+CREATE TABLE `__new_messages` (
+ `id` text PRIMARY KEY NOT NULL,
+ `session_id` text NOT NULL,
+ `character_id` text NOT NULL,
+ `role` text NOT NULL,
+ `content` text NOT NULL,
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL,
+ FOREIGN KEY (`session_id`) REFERENCES `play_sessions`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+INSERT INTO `__new_messages`("id", "session_id", "character_id", "role", "content", "created_at") SELECT "id", "session_id", "character_id", "role", "content", "created_at" FROM `messages`;--> statement-breakpoint
+DROP TABLE `messages`;--> statement-breakpoint
+ALTER TABLE `__new_messages` RENAME TO `messages`;--> statement-breakpoint
+PRAGMA foreign_keys=ON;--> statement-breakpoint
+CREATE INDEX `messages_session_id_idx` ON `messages` (`session_id`);--> statement-breakpoint
+CREATE INDEX `messages_character_id_idx` ON `messages` (`character_id`);
\ No newline at end of file
diff --git a/db/migrations/meta/0030_snapshot.json b/db/migrations/meta/0030_snapshot.json
new file mode 100644
index 0000000..c1262ed
--- /dev/null
+++ b/db/migrations/meta/0030_snapshot.json
@@ -0,0 +1,1580 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "6e6c8bca-ef92-4a53-8cbb-793a4c0c0a9c",
+ "prevId": "1a38c7d6-fe72-48c7-823b-ef2fda11faca",
+ "tables": {
+ "analytics_sessions": {
+ "name": "analytics_sessions",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "detective": {
+ "name": "detective",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "max_turns": {
+ "name": "max_turns",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "questions_per_turn": {
+ "name": "questions_per_turn",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exchanges_per_topic": {
+ "name": "exchanges_per_topic",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "culprit_character_id": {
+ "name": "culprit_character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "culprit_correct": {
+ "name": "culprit_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method_correct": {
+ "name": "method_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive_correct": {
+ "name": "motive_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reasoning": {
+ "name": "reasoning",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive": {
+ "name": "motive",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method_comment": {
+ "name": "method_comment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive_comment": {
+ "name": "motive_comment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "solved_seconds": {
+ "name": "solved_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "question_count": {
+ "name": "question_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "evidence_found": {
+ "name": "evidence_found",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "evidence_total": {
+ "name": "evidence_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contradiction_count": {
+ "name": "contradiction_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "accuracy_percent": {
+ "name": "accuracy_percent",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "analytics_sessions_scenario_id_idx": {
+ "name": "analytics_sessions_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ },
+ "analytics_sessions_started_at_idx": {
+ "name": "analytics_sessions_started_at_idx",
+ "columns": [
+ "started_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "analytics_turns": {
+ "name": "analytics_turns",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_kind": {
+ "name": "subject_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "turn_index": {
+ "name": "turn_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "question_count": {
+ "name": "question_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "topic": {
+ "name": "topic",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exchanges": {
+ "name": "exchanges",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revealed_evidence_ids": {
+ "name": "revealed_evidence_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revealed_revelation_ids": {
+ "name": "revealed_revelation_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contradiction_pointed_out": {
+ "name": "contradiction_pointed_out",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "npc_lied": {
+ "name": "npc_lied",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "analytics_turns_session_id_idx": {
+ "name": "analytics_turns_session_id_idx",
+ "columns": [
+ "session_id"
+ ],
+ "isUnique": false
+ },
+ "analytics_turns_scenario_id_idx": {
+ "name": "analytics_turns_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ },
+ "analytics_turns_created_at_idx": {
+ "name": "analytics_turns_created_at_idx",
+ "columns": [
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "characters": {
+ "name": "characters",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "short_name": {
+ "name": "short_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "age_group": {
+ "name": "age_group",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ },
+ "gender": {
+ "name": "gender",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ },
+ "voice_seed": {
+ "name": "voice_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "voice_caption": {
+ "name": "voice_caption",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "public_introduction": {
+ "name": "public_introduction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "personality": {
+ "name": "personality",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "knowledge": {
+ "name": "knowledge",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "secrets": {
+ "name": "secrets",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "goals": {
+ "name": "goals",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lies": {
+ "name": "lies",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lie_refs": {
+ "name": "lie_refs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "memories": {
+ "name": "memories",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "characters_scenario_id_idx": {
+ "name": "characters_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "characters_scenario_id_scenarios_id_fk": {
+ "name": "characters_scenario_id_scenarios_id_fk",
+ "tableFrom": "characters",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "discoveries": {
+ "name": "discoveries",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "evidence_id": {
+ "name": "evidence_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "discovered_at": {
+ "name": "discovered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "discoveries_evidence_id_idx": {
+ "name": "discoveries_evidence_id_idx",
+ "columns": [
+ "evidence_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "discoveries_session_id_play_sessions_id_fk": {
+ "name": "discoveries_session_id_play_sessions_id_fk",
+ "tableFrom": "discoveries",
+ "tableTo": "play_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "discoveries_evidence_id_evidences_id_fk": {
+ "name": "discoveries_evidence_id_evidences_id_fk",
+ "tableFrom": "discoveries",
+ "tableTo": "evidences",
+ "columnsFrom": [
+ "evidence_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "discoveries_session_id_evidence_id_pk": {
+ "columns": [
+ "session_id",
+ "evidence_id"
+ ],
+ "name": "discoveries_session_id_evidence_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "evidences": {
+ "name": "evidences",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reveal_condition": {
+ "name": "reveal_condition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "supports": {
+ "name": "supports",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "contradicts": {
+ "name": "contradicts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "reveals_death_time": {
+ "name": "reveals_death_time",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "evidences_scenario_id_idx": {
+ "name": "evidences_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "evidences_scenario_id_scenarios_id_fk": {
+ "name": "evidences_scenario_id_scenarios_id_fk",
+ "tableFrom": "evidences",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "llm_usages": {
+ "name": "llm_usages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "input_tokens": {
+ "name": "input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "output_tokens": {
+ "name": "output_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "cached_input_tokens": {
+ "name": "cached_input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "cache_creation_input_tokens": {
+ "name": "cache_creation_input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "reasoning_tokens": {
+ "name": "reasoning_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "llm_usages_created_at_idx": {
+ "name": "llm_usages_created_at_idx",
+ "columns": [
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "llm_usages_session_id_idx": {
+ "name": "llm_usages_session_id_idx",
+ "columns": [
+ "session_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "messages": {
+ "name": "messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "character_id": {
+ "name": "character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "messages_session_id_idx": {
+ "name": "messages_session_id_idx",
+ "columns": [
+ "session_id"
+ ],
+ "isUnique": false
+ },
+ "messages_character_id_idx": {
+ "name": "messages_character_id_idx",
+ "columns": [
+ "character_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "messages_session_id_play_sessions_id_fk": {
+ "name": "messages_session_id_play_sessions_id_fk",
+ "tableFrom": "messages",
+ "tableTo": "play_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "play_sessions": {
+ "name": "play_sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "detective": {
+ "name": "detective",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "play_sessions_scenario_id_idx": {
+ "name": "play_sessions_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ },
+ "play_sessions_started_at_idx": {
+ "name": "play_sessions_started_at_idx",
+ "columns": [
+ "started_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "play_sessions_scenario_id_scenarios_id_fk": {
+ "name": "play_sessions_scenario_id_scenarios_id_fk",
+ "tableFrom": "play_sessions",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "reports": {
+ "name": "reports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reported_at": {
+ "name": "reported_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "reports_scenario_id_idx": {
+ "name": "reports_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "reports_scenario_id_scenarios_id_fk": {
+ "name": "reports_scenario_id_scenarios_id_fk",
+ "tableFrom": "reports",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "results": {
+ "name": "results",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "solved_seconds": {
+ "name": "solved_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "question_count": {
+ "name": "question_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "evidence_found": {
+ "name": "evidence_found",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contradiction_count": {
+ "name": "contradiction_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accuracy_percent": {
+ "name": "accuracy_percent",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "method_correct": {
+ "name": "method_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive_correct": {
+ "name": "motive_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deduction": {
+ "name": "deduction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "results_session_id_play_sessions_id_fk": {
+ "name": "results_session_id_play_sessions_id_fk",
+ "tableFrom": "results",
+ "tableTo": "play_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "revelation_discoveries": {
+ "name": "revelation_discoveries",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revelation_id": {
+ "name": "revelation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "discovered_at": {
+ "name": "discovered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "revelation_discoveries_revelation_id_idx": {
+ "name": "revelation_discoveries_revelation_id_idx",
+ "columns": [
+ "revelation_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "revelation_discoveries_session_id_play_sessions_id_fk": {
+ "name": "revelation_discoveries_session_id_play_sessions_id_fk",
+ "tableFrom": "revelation_discoveries",
+ "tableTo": "play_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "revelation_discoveries_revelation_id_revelations_id_fk": {
+ "name": "revelation_discoveries_revelation_id_revelations_id_fk",
+ "tableFrom": "revelation_discoveries",
+ "tableTo": "revelations",
+ "columnsFrom": [
+ "revelation_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "revelation_discoveries_session_id_revelation_id_pk": {
+ "columns": [
+ "session_id",
+ "revelation_id"
+ ],
+ "name": "revelation_discoveries_session_id_revelation_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "revelations": {
+ "name": "revelations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_type": {
+ "name": "subject_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "related_facts": {
+ "name": "related_facts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "revelations_scenario_id_idx": {
+ "name": "revelations_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "revelations_scenario_id_scenarios_id_fk": {
+ "name": "revelations_scenario_id_scenarios_id_fk",
+ "tableFrom": "revelations",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "scenario_truths": {
+ "name": "scenario_truths",
+ "columns": {
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "culprit_character_id": {
+ "name": "culprit_character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "truth": {
+ "name": "truth",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive": {
+ "name": "motive",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "timeline": {
+ "name": "timeline",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "timeline_events": {
+ "name": "timeline_events",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "victim_cause_of_death": {
+ "name": "victim_cause_of_death",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_findings": {
+ "name": "victim_findings",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "place_findings": {
+ "name": "place_findings",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "secret_keywords": {
+ "name": "secret_keywords",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "scenario_truths_scenario_id_scenarios_id_fk": {
+ "name": "scenario_truths_scenario_id_scenarios_id_fk",
+ "tableFrom": "scenario_truths",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "scenarios": {
+ "name": "scenarios",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "synopsis": {
+ "name": "synopsis",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "briefing": {
+ "name": "briefing",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "floor_plan": {
+ "name": "floor_plan",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "time_start": {
+ "name": "time_start",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "time_end": {
+ "name": "time_end",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_name": {
+ "name": "victim_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_introduction": {
+ "name": "victim_introduction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_found_at": {
+ "name": "victim_found_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_found_in": {
+ "name": "victim_found_in",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_investigable": {
+ "name": "victim_investigable",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "victim_estimated_death_at": {
+ "name": "victim_estimated_death_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "places": {
+ "name": "places",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_published": {
+ "name": "is_published",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "difficulty": {
+ "name": "difficulty",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 3
+ },
+ "estimated_minutes": {
+ "name": "estimated_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 10
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tts_speakers": {
+ "name": "tts_speakers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "age_group": {
+ "name": "age_group",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ },
+ "gender": {
+ "name": "gender",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json
index 88630cb..e085775 100644
--- a/db/migrations/meta/_journal.json
+++ b/db/migrations/meta/_journal.json
@@ -204,6 +204,13 @@
"when": 1788937520736,
"tag": "0029_tts-speakers",
"breakpoints": true
+ },
+ {
+ "idx": 30,
+ "version": "6",
+ "when": 1788940795359,
+ "tag": "0030_messages-drop-character-fk",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/db/schema.ts b/db/schema.ts
index 416ffd8..3692dba 100644
--- a/db/schema.ts
+++ b/db/schema.ts
@@ -378,9 +378,14 @@ export const messages = sqliteTable(
sessionId: text('session_id')
.notNull()
.references(() => playSessions.id, { onDelete: 'cascade' }),
- characterId: text('character_id')
- .notNull()
- .references(() => characters.id, { onDelete: 'cascade' }),
+ /**
+ * 話しかけた(あるいは調べた)相手。
+ *
+ * `characters` への外部キーは張らない。遺体は `victim` 固定、場所はシナリオの
+ * JSON 列が持つ ID で、どちらも `characters` に行が無いため。外部キーがあった頃は
+ * 検分の往復だけ記録ごと落としていた。
+ */
+ characterId: text('character_id').notNull(),
role: text('role').notNull(),
content: text('content').notNull(),
createdAt: createdTimestamp('created_at'),
diff --git a/mocks/desktop/settings.html b/mocks/desktop/settings.html
index d2df634..2e27189 100644
--- a/mocks/desktop/settings.html
+++ b/mocks/desktop/settings.html
@@ -34,7 +34,7 @@ 設定
使うモデル
-
Google は APIキーが未設定のため選べません。
+
@@ -77,6 +77,23 @@
設定
+
+
+
+
聞き込み
+
+
+
読み上げ
+
読み終わるまで次の行を待ちます
+
+
+
+
読み上げないときは、これまで通り一定の間で流れます。
+
@@ -86,16 +103,27 @@
設定
;(function () {
// 設定は物語の外の値なので、事件データではなくここに素で置く。
var unset = Mock.str('unset', '0') === '1'
+ /*
+ 互換サーバが返すモデルIDは「提供元,モデル」のカンマ区切り。
+ 欄を二つに割るのは、一覧が全提供元ぶん一列に並ぶと二十行を超えるため。
+ 並べる値はここで割ったもので、保存されるのは連結し直した一つの文字列。
+ */
+ var nomodels = Mock.str('nomodels', '0') === '1'
+ var chosen = unset || nomodels ? null : 'claude-code,claude-opus-5'
var roles = [
{
name: '会話',
note: 'NPCの受け答えと、探偵が組み立てる質問',
- provider: unset ? null : 'Anthropic',
- model: unset ? null : 'Claude Sonnet 5',
+ provider: chosen && chosen.split(',')[0],
+ model: chosen && chosen.split(',')[1],
},
{ name: '判定', note: '証拠の開示と、推理の採点', provider: null, model: null },
]
+ document.getElementById('nomodels').textContent = nomodels
+ ? 'モデルの一覧が空です。APIキーが未設定か、モデルサーバに繋がっていません。'
+ : ''
+
// 提供元が決まるまでモデルは触れない。枠を地に沈めて、押せないことを枠で言う。
var cell = function (value, locked, id) {
var cls = value ? 'sel2' : locked ? 'sel2 locked' : 'sel2 unset'
@@ -186,6 +214,12 @@
設定
}
choices('brief', [{ key: 'typewriter', name: '一段落ずつ' }, { key: 'crawl', name: 'せり上がる' }], brief, true)
choices('sound', [{ key: 'on', name: '鳴らす' }, { key: 'off', name: '鳴らさない' }], sound, pickable)
+ choices(
+ 'voice',
+ [{ key: 'on', name: '読み上げる' }, { key: 'off', name: '読み上げない' }],
+ Mock.str('voice', 'on'),
+ true,
+ )
document.getElementById('brieffine').textContent = pickable
? '一段落ずつは、読む速さをあなたが握ります。'
diff --git a/mocks/mobile/settings.html b/mocks/mobile/settings.html
index b640d58..0027e05 100644
--- a/mocks/mobile/settings.html
+++ b/mocks/mobile/settings.html
@@ -31,7 +31,7 @@
設定
使うモデル
-
Google は APIキーが未設定のため選べません。
+
@@ -70,6 +70,17 @@
設定
+
+
+
+
聞き込み
+
+
読み上げ
+
読み終わるまで次の行を待ちます
+
+
+
読み上げないときは、これまで通り一定の間で流れます。
+
@@ -81,16 +92,27 @@ 設定
// 選んだ状態とを、見た目でも分けられるかを確かめるための切り替え。
var unset = Mock.str('unset', '0') === '1'
+ /*
+ 互換サーバが返すモデルIDは「提供元,モデル」のカンマ区切り。欄を二つに割るのは、
+ 一覧が全提供元ぶん一列に並ぶと二十行を超えるため。並べる値はここで割ったもので、
+ 保存されるのは連結し直した一つの文字列。
+ */
+ var nomodels = Mock.str('nomodels', '0') === '1'
+ var chosen = unset || nomodels ? null : 'claude-code,claude-opus-5'
var roles = [
{
nm: '会話',
ps: 'NPCの受け答えと、探偵が組み立てる質問',
- vendor: unset ? null : 'Anthropic',
- model: unset ? null : 'Claude Sonnet 5',
+ vendor: chosen && chosen.split(',')[0],
+ model: chosen && chosen.split(',')[1],
},
{ nm: '判定', ps: '証拠の開示と、推理の採点', vendor: null, model: null },
]
+ document.getElementById('nomodels').textContent = nomodels
+ ? 'モデルの一覧が空です。APIキーが未設定か、モデルサーバに繋がっていません。'
+ : ''
+
document.getElementById('roles').innerHTML = roles
.map(function (r) {
var vendor = r.vendor
@@ -173,6 +195,15 @@ 設定
sound,
pickable,
)
+ choices(
+ 'voice',
+ [
+ { key: 'on', name: '読み上げる' },
+ { key: 'off', name: '読み上げない' },
+ ],
+ Mock.str('voice', 'on'),
+ true,
+ )
document.getElementById('brieffine').textContent = pickable
? '一段落ずつは、読む速さをあなたが握ります。'
diff --git a/package.json b/package.json
index e2dfb0b..a1d498f 100644
--- a/package.json
+++ b/package.json
@@ -26,21 +26,21 @@
"deploy": "bun run se:fetch && bun run build && bun run db:migrate:remote && wrangler deploy"
},
"dependencies": {
- "@ai-sdk/openai": "^2",
- "@ai-sdk/provider-utils": "^3",
+ "@ai-sdk/openai": "^2.0.126",
+ "@ai-sdk/provider-utils": "^3.0.36",
"@commitlint/config-conventional": "^21.2.2",
- "@tanstack/react-router": "^1.170.32",
- "@tanstack/react-start": "^1.168.49",
+ "@tanstack/react-router": "^1.170.33",
+ "@tanstack/react-start": "^1.168.50",
"@vitejs/plugin-react": "^6.1.1",
- "ai": "^5",
+ "ai": "^5.0.254",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"commitlint": "^21.2.2",
"conventional-changelog-conventionalcommits": "^10.4.0",
"dayjs": "^1.11.23",
"drizzle-orm": "^0.45.2",
- "hono": "^4.13.5",
- "lucide-react": "^1.38.0",
+ "hono": "^4.13.7",
+ "lucide-react": "^1.43.0",
"radix-ui": "^1.6.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
@@ -48,22 +48,22 @@
"zod": "^4.5.4"
},
"devDependencies": {
- "@biomejs/biome": "^2.5.11",
- "@cloudflare/vite-plugin": "^1.54.2",
- "@cloudflare/workers-types": "^5.20260831.1",
+ "@biomejs/biome": "^2.5.12",
+ "@cloudflare/vite-plugin": "^1.54.6",
+ "@cloudflare/workers-types": "^5.20260908.1",
"@storybook/react-vite": "10.5.10",
"@tailwindcss/vite": "^4.3.3",
- "@types/bun": "^1.4.0",
- "@types/node": "^26.4.0",
+ "@types/bun": "^1.4.2",
+ "@types/node": "^26.5.0",
"@types/react": "^19.2.18",
- "@types/react-dom": "^19.2.5",
+ "@types/react-dom": "^19.2.7",
"drizzle-kit": "^0.31.10",
- "playwright": "^1.62.1",
+ "playwright": "^1.63.0",
"storybook": "10.5.10",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.2.2",
- "wrangler": "^4.127.1",
+ "wrangler": "^4.130.0",
"yaml": "^2.9.0"
}
}
diff --git a/src/client/components/CaseNote.tsx b/src/client/components/CaseNote.tsx
index 187e404..3b5ddef 100644
--- a/src/client/components/CaseNote.tsx
+++ b/src/client/components/CaseNote.tsx
@@ -6,7 +6,7 @@ import {
DialogTitle,
DialogTrigger,
} from '@/client/components/ui/dialog'
-import { splitParagraphs } from '@/client/lib/paragraphs'
+import { splitParagraphs } from '@/shared/paragraphs'
type Props = {
briefing: string
diff --git a/src/client/components/ChatLog.tsx b/src/client/components/ChatLog.tsx
index 182e8d7..1e78a20 100644
--- a/src/client/components/ChatLog.tsx
+++ b/src/client/components/ChatLog.tsx
@@ -1,7 +1,7 @@
import { edgeOf, inkOf } from '@/client/components/CharacterAvatar'
import type { ChatTurn } from '@/client/hooks/useInterrogation'
import { usePacedReveal } from '@/client/hooks/usePacedReveal'
-import { settledSentences } from '@/client/lib/paragraphs'
+import { settledSentences } from '@/shared/paragraphs'
type Props = {
turns: ChatTurn[]
diff --git a/src/client/components/RouteStatus.tsx b/src/client/components/RouteStatus.tsx
index d676a9e..33a9534 100644
--- a/src/client/components/RouteStatus.tsx
+++ b/src/client/components/RouteStatus.tsx
@@ -1,4 +1,4 @@
-import { Link } from '@tanstack/react-router'
+import { type ErrorComponentProps, Link } from '@tanstack/react-router'
/**
* ルータが直接描く3つの状態(読み込み中・見つからない・エラー)。
@@ -26,11 +26,11 @@ export const RouteNotFound = () => (
)
-export const RouteError = ({ error }: { error: Error }) => (
+export const RouteError = ({ error }: ErrorComponentProps) => (
うまく開けませんでした
{/* 何が起きたかは出す。黙って戻す画面は、同じ操作をもう一度させるだけになる。 */}
-
{error.message}
+
{error instanceof Error ? error.message : String(error)}
最初から
diff --git a/src/client/hooks/useInterrogation.ts b/src/client/hooks/useInterrogation.ts
index c1ecebb..245ecdc 100644
--- a/src/client/hooks/useInterrogation.ts
+++ b/src/client/hooks/useInterrogation.ts
@@ -37,6 +37,14 @@ export type ChatTurn = {
* 投げた時点では分からず、判定が返ってきて初めて決まるので、後から立てる。
*/
notable?: boolean
+ /**
+ * サーバに記録されたこの発言の行のID。読み上げを頼む宛先。
+ *
+ * 立つのは配信の途中で、その時点で本文は確定している。裏を返すと、これが無いのは
+ * 「まだ書いている途中」か「記録に失敗した」かのどちらかで、どちらも声は付かない。
+ * 開き直して復元した会話にも無い——読み直しに読み上げは要らないので、取りに行かない。
+ */
+ messageId?: string
}
/**
@@ -160,6 +168,34 @@ export const useInterrogation = (seed: InterrogationSeed) => {
})
}
+ /**
+ * 記録された発言のIDを、その行に立てる。
+ *
+ * 宛先は末尾からの位置で指す(`appendQuestionDelta` と同じ手)。質問のIDが届く時点で
+ * 末尾は返答待ちの空の行なので、質問はその1つ手前。返答のIDは末尾に立つ。
+ */
+ const markMessageId = (characterId: string, fromEnd: number, messageId: string) => {
+ setConversations((prev) => {
+ const current = prev[characterId]
+ const turns = current === undefined ? [] : current
+ const index = turns.length - fromEnd
+ const target = turns[index]
+
+ if (target === undefined) {
+ return prev
+ }
+
+ return {
+ ...prev,
+ [characterId]: [
+ ...turns.slice(0, index),
+ { ...target, messageId },
+ ...turns.slice(index + 1),
+ ],
+ }
+ })
+ }
+
/**
* その話題が何かを引き出したことを、会話ログの上でも印にする。
*
@@ -215,7 +251,9 @@ export const useInterrogation = (seed: InterrogationSeed) => {
{ role: 'assistant', text: '', askedAt },
]),
onQuestion: (chunk) => appendQuestionDelta(params.characterId, chunk),
+ onQuestionId: (id) => markMessageId(params.characterId, 2, id),
onDelta: (chunk) => appendAssistantDelta(params.characterId, chunk),
+ onAnswerId: (id) => markMessageId(params.characterId, 1, id),
onJudgement: (judgement) => {
if (judgement.revealedEvidences.length > 0 || judgement.revealedRevelations.length > 0) {
markTopicNotable(params.characterId, askedAt)
diff --git a/src/client/hooks/useVoicedReveal.ts b/src/client/hooks/useVoicedReveal.ts
new file mode 100644
index 0000000..f5a1b81
--- /dev/null
+++ b/src/client/hooks/useVoicedReveal.ts
@@ -0,0 +1,221 @@
+import { useEffect, useRef, useState } from 'react'
+import { loadVoiceSetting } from '@/client/lib/voice'
+
+/**
+ * 声が付かない行の間。
+ *
+ * 返答は文の単位で届くが、モデルが速いと二文がほぼ同時に着地して、
+ * 読んでいる最中に次が積まれる。届いた順はそのままに、通す間隔にだけ下限を設ける。
+ */
+const SILENT_INTERVAL_MS = 800
+
+/**
+ * `shown` は画面に出してよい行数、`spoken` は読み終わった行数。
+ *
+ * 二つ持つのは、**読んでいる行が画面に出ている**ようにするため。出す前に鳴らすと、
+ * まだ無い行を読み上げることになる。順は「出す → 読む → 次を出す」で、
+ * 読んでいる最中は `shown === spoken + 1`——最後に出た一行がいま読まれている行。
+ */
+type State = { shown: number; spoken: number; armed: boolean }
+
+/**
+ * 鳴らせる形にして返す。取れなければ undefined——呼ぶ側は時間送りへ落とす。
+ *
+ * Blob に落としてから要素を作る。URL を要素に渡して読み込ませると、失敗を
+ * `error` イベントで待つことになり、待ちの管理がもう一つ増える。ここで待てば
+ * 「取れた/取れない」が返り値だけで決まる。
+ */
+const load = async (url: string): Promise
=> {
+ try {
+ const response = await fetch(url)
+
+ return response.ok ? new Audio(URL.createObjectURL(await response.blob())) : undefined
+ } catch {
+ return undefined
+ }
+}
+
+/**
+ * 積まれた行を、読み上げに合わせて一つずつ通す。
+ * 返すのは「今いくつまで出してよいか」と「まだ読んでいる最中か」。
+ *
+ * 一行を出して読み上げ、読み終わってから次の行を出す。声の付かない行——書いている途中、
+ * 記録に失敗した行、読み上げを切っているとき、合成に失敗したとき——は
+ * これまで通り一定の間で通す。**声が出ないことで会話が止まらない**のが要点で、
+ * どの失敗も時間送りへ落ちる。
+ *
+ * 間を置くのは `live`(返答が流れている)あいだに増えた分だけ。履歴の読み込みや
+ * 画面の復帰で一気に増えたときは全部そのまま出す——読み直しに間も声も要らない。
+ * 流れ終わっても、溜まっているぶんは間を置いて出し切る。
+ */
+export const useVoicedReveal = (
+ /**
+ * 通す順に並んだ行の、音を取りに行く先。声が付かない行は undefined。
+ *
+ * 毎回作り直される配列なので、依存には入れない(入れると返答が伸びるたびに
+ * 鳴っている途中の音が止まる)。読むのは行が入れ替わった瞬間だけなので、
+ * 参照で持ち回る。
+ */
+ urls: (string | undefined)[],
+ live: boolean,
+): { shown: number; speaking: boolean } => {
+ const total = urls.length
+ const [state, setState] = useState({ shown: total, spoken: total, armed: false })
+ /*
+ * 読み上げの入切は聞き始めに一度だけ見る。行ごとに localStorage を読むと、
+ * 一行ごとに同期の読み取りが挟まる(`useReadOut` の打鍵音と同じ理由)。
+ */
+ const [voiceOn] = useState(() => loadVoiceSetting() === 'on')
+ const urlsRef = useRef(urls)
+ urlsRef.current = urls
+ /**
+ * 合成を頼んである音。宛先ごとに1つ。
+ *
+ * 合成には一行あたり数秒かかるので、順番が来てから頼むと、行と行のあいだが
+ * そのぶん空く。鳴らす順を待たずに、宛先が分かった端から並行で頼んでおく。
+ *
+ * 読み終えても表からは落とさない。落とすと、次の行が届いて頼み直すときに
+ * 読み終えた行まで作り直してしまう。音そのもの(blob)は鳴らし終えた時点で
+ * 手放すので、残るのは空になった要素だけ。
+ */
+ const pending = useRef(new Map>())
+ /** いま鳴っているもの。画面を離れるときに止める。 */
+ const playing = useRef(undefined)
+
+ /*
+ 宛先が分かった端から合成を頼む。宛先が立つのはサーバが発言を記録した時点なので、
+ 探偵の質問を読んでいるあいだに返答の合成が進む。
+ */
+ const known = urls.join('|')
+
+ useEffect(() => {
+ if (!voiceOn) {
+ return
+ }
+
+ // 声の付かない行はここで空になる。宛先に区切りの字は入らない(問い合わせは ?line=N)。
+ for (const url of known.split('|')) {
+ if (url.length > 0 && !pending.current.has(url)) {
+ pending.current.set(url, load(url))
+ }
+ }
+ }, [known, voiceOn])
+
+ /* 読み切らずに離れたぶん。blob は明示的に手放さないと残る。 */
+ useEffect(() => {
+ const held = pending.current
+
+ return () => {
+ for (const audio of held.values()) {
+ void audio.then((element) => {
+ if (element !== undefined) {
+ URL.revokeObjectURL(element.src)
+ }
+ })
+ }
+
+ held.clear()
+ }
+ }, [])
+
+ /*
+ 次の一行を出す。訊いた瞬間に「間を置く相手」になり、読み終えてこちらの番に
+ 戻ったら外れる。外れているあいだは届いたぶんをそのまま出す。
+
+ 出すのは前の行を読み終えてから(`shown === spoken`)。読んでいる最中に
+ 次を出すと、読み上げより先に字が進む。
+ */
+ useEffect(() => {
+ if (live && !state.armed) {
+ setState({ shown: state.shown, spoken: state.spoken, armed: true })
+
+ return
+ }
+
+ if (!state.armed) {
+ if (state.shown !== total || state.spoken !== total) {
+ setState({ shown: total, spoken: total, armed: false })
+ }
+
+ return
+ }
+
+ if (state.spoken >= total && !live) {
+ setState({ shown: state.shown, spoken: state.spoken, armed: false })
+
+ return
+ }
+
+ if (state.shown === state.spoken && state.shown < total) {
+ setState({ shown: state.shown + 1, spoken: state.spoken, armed: true })
+ }
+ }, [total, live, state])
+
+ /** いま読んでいる最中か。出ているのに読み終わっていない行があるあいだ。 */
+ const reading = state.armed && state.spoken < state.shown
+
+ /*
+ 出ている一行を読み上げる。依存に total を入れないのは、返答が伸びるたびに
+ 作り直されると、鳴っている途中の音が止まって頭から鳴り直すため。
+ */
+ useEffect(() => {
+ if (!reading) {
+ return
+ }
+
+ const url = urlsRef.current[state.spoken]
+ const cancelled = { value: false }
+ /*
+ 畳んだ後は進めない。鳴らし終える前に離れた回に、要素へ残った ended が
+ 後から届いて、もう居ない行の位置を立て直すのを防ぐ。
+ */
+ const done = () => {
+ if (!cancelled.value) {
+ setState((prev) => ({ shown: prev.shown, spoken: prev.spoken + 1, armed: prev.armed }))
+ }
+ }
+
+ if (!voiceOn || url === undefined) {
+ const timer = setTimeout(done, SILENT_INTERVAL_MS)
+
+ return () => clearTimeout(timer)
+ }
+
+ // 先に頼んである音。まだ頼んでいなければ(切から入に変わった直後など)ここで頼む。
+ const requested = pending.current.get(url)
+ const audio = requested === undefined ? load(url) : requested
+
+ void audio.then((element) => {
+ if (cancelled.value) {
+ return
+ }
+
+ // 取れなかった・鳴らせなかったときは待たずに次へ。無音のまま止まるより出す。
+ if (element === undefined) {
+ done()
+
+ return
+ }
+
+ playing.current = element
+ element.addEventListener('ended', done, { once: true })
+ element.addEventListener('error', done, { once: true })
+ void element.play().catch(done)
+ })
+
+ return () => {
+ cancelled.value = true
+
+ const element = playing.current
+
+ if (element !== undefined) {
+ element.pause()
+ // 音は行ごとに使い捨て。放っておくと、聞き込みのあいだ blob が積み上がる。
+ URL.revokeObjectURL(element.src)
+ playing.current = undefined
+ }
+ }
+ }, [reading, state.spoken, voiceOn])
+
+ return { shown: state.shown, speaking: reading }
+}
diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts
index b81e91b..10c6a82 100644
--- a/src/client/lib/api.ts
+++ b/src/client/lib/api.ts
@@ -160,8 +160,16 @@ export type AskCallbacks = {
onQuestionStart: () => void
/** 探偵の質問の断片。直前の `onQuestionStart` で始まった往復の質問として継ぎ足す。 */
onQuestion: (chunk: string) => void
+ /**
+ * 質問が記録された合図。届く `id` は messages の行のIDで、読み上げを頼む宛先になる。
+ * ここまで来たら本文は確定している——以後この質問に断片は継ぎ足されない。
+ * 記録に失敗した回は届かない(声が付かないだけで、会話は続く)。
+ */
+ onQuestionId: (id: string) => void
/** NPCの返答の断片。直前の `onQuestion` で始まった往復の答えとして継ぎ足す。 */
onDelta: (chunk: string) => void
+ /** 返答が記録された合図。`onQuestionId` と同じ扱い。 */
+ onAnswerId: (id: string) => void
onJudgement: (judgement: Judgement) => void
onDone: () => void
}
@@ -206,10 +214,18 @@ export const askTopic = async (
callbacks.onQuestion(event.data)
}
+ if (event.event === 'question-id') {
+ callbacks.onQuestionId(event.data)
+ }
+
if (event.event === 'delta') {
callbacks.onDelta(event.data)
}
+ if (event.event === 'answer-id') {
+ callbacks.onAnswerId(event.data)
+ }
+
if (event.event === 'judgement') {
const parsedJson: unknown = JSON.parse(event.data)
callbacks.onJudgement(decode(judgementSchema, parsedJson))
@@ -221,6 +237,15 @@ export const askTopic = async (
}
}
+/**
+ * 発言の1行を読み上げてもらう先。
+ *
+ * 文そのものは渡さない。サーバが messages から読んで同じ割り方で切り出す
+ * (`src/server/routes/voice.ts`)。声の付かない行はここを呼ばない。
+ */
+export const voiceUrl = (sessionId: string, messageId: string, line: number): string =>
+ `/api/sessions/${sessionId}/messages/${messageId}/voice?line=${line}`
+
/** catch (error: unknown) から表示用の一言を取り出す。 */
export const describeError = (error: unknown): string => {
if (error instanceof ApiError) {
diff --git a/src/client/lib/voice.ts b/src/client/lib/voice.ts
new file mode 100644
index 0000000..8b9cd46
--- /dev/null
+++ b/src/client/lib/voice.ts
@@ -0,0 +1,35 @@
+import { z } from 'zod'
+
+/**
+ * 聞き込みの読み上げ。
+ *
+ * 効果音(`sound.ts`)とは別に持つ。あちらは打鍵と場面の音で、こちらは人の声。
+ * 声だけ切りたい人と、音だけ切りたい人が別々に居る。
+ *
+ * 切ってあるあいだ、会話は読み上げが無かった頃と同じ時間送りで出る。
+ */
+
+const STORAGE_KEY = 'alibai:voice'
+const voiceSettingSchema = z.enum(['on', 'off'])
+
+export type VoiceSetting = z.infer
+
+export const DEFAULT_VOICE: VoiceSetting = 'on'
+
+export const loadVoiceSetting = (): VoiceSetting => {
+ try {
+ const parsed = voiceSettingSchema.safeParse(localStorage.getItem(STORAGE_KEY))
+
+ return parsed.success ? parsed.data : DEFAULT_VOICE
+ } catch {
+ return DEFAULT_VOICE
+ }
+}
+
+export const saveVoiceSetting = (setting: VoiceSetting): void => {
+ try {
+ localStorage.setItem(STORAGE_KEY, setting)
+ } catch {
+ // 保存できなくても今回のプレイには影響しない。
+ }
+}
diff --git a/src/client/screens/InterrogationScreen.tsx b/src/client/screens/InterrogationScreen.tsx
index c79b3b0..c1cb1e8 100644
--- a/src/client/screens/InterrogationScreen.tsx
+++ b/src/client/screens/InterrogationScreen.tsx
@@ -13,8 +13,8 @@ import { TurnAnnounce } from '@/client/components/TurnAnnounce'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/client/components/ui/dialog'
import type { ChatTurn, UseInterrogation } from '@/client/hooks/useInterrogation'
import { useLitFix } from '@/client/hooks/useLitFix'
-import { usePacedReveal } from '@/client/hooks/usePacedReveal'
-import { fetchSessionState } from '@/client/lib/api'
+import { useVoicedReveal } from '@/client/hooks/useVoicedReveal'
+import { fetchSessionState, voiceUrl } from '@/client/lib/api'
import { formatSeconds } from '@/client/lib/format'
import type { InvestigablePlace, ScenarioDetail, SessionState } from '@/client/lib/schemas'
import { InterrogationRail } from '@/client/screens/InterrogationRail'
@@ -478,11 +478,22 @@ export const InterrogationScreen = ({
)
const said = buildBlocks(subjects, conversations, askerName, askingCharacterId)
- const total = said.reduce((count, block) => count + block.lines.length, 0)
- const shown = usePacedReveal(total, isAsking)
+ /*
+ * 通す順に並べた、行ごとの音の在りか。記録の済んでいない行(書いている途中・
+ * 記録に失敗した行)には無く、そこは声の無かった頃と同じ時間送りで出る。
+ */
+ const voices = said.flatMap((block) =>
+ block.lines.map((line) =>
+ line.voice === undefined
+ ? undefined
+ : voiceUrl(sessionId, line.voice.messageId, line.voice.line),
+ ),
+ )
+ const total = voices.length
+ const { shown, speaking } = useVoicedReveal(voices, isAsking)
const blocks = capLines(said, shown)
/** 返答が出そろって、こちらの番になっているか。合図の印を置いてよい状態。 */
- const settled = !isAsking && shown >= total
+ const settled = !isAsking && shown >= total && !speaking
const timeWindow = scenario.timeWindow
/**
diff --git a/src/client/screens/SettingsScreen.stories.tsx b/src/client/screens/SettingsScreen.stories.tsx
index 205d7d2..5e07312 100644
--- a/src/client/screens/SettingsScreen.stories.tsx
+++ b/src/client/screens/SettingsScreen.stories.tsx
@@ -18,6 +18,7 @@ const meta: Meta = {
// 保管庫を読ませない。story ごとに前の操作が残ると、同じ絵が二度出ない。
readBriefing: () => 'typewriter',
readSound: () => 'on',
+ readVoice: () => 'on',
},
}
diff --git a/src/client/screens/SettingsScreen.tsx b/src/client/screens/SettingsScreen.tsx
index 2bd24a5..af63f49 100644
--- a/src/client/screens/SettingsScreen.tsx
+++ b/src/client/screens/SettingsScreen.tsx
@@ -17,6 +17,7 @@ import {
saveSettings,
} from '@/client/lib/settings-store'
import { loadSoundSetting, type SoundSetting, saveSoundSetting } from '@/client/lib/sound'
+import { loadVoiceSetting, saveVoiceSetting, type VoiceSetting } from '@/client/lib/voice'
import { clampLimits, modelCallsPerTopic } from '@/shared/turns'
/**
@@ -70,6 +71,7 @@ type Props = {
/** 演出の設定は別の保管庫に住んでいる(記録の画面が単独で読むため)。差し替える理由は同じ。 */
readBriefing?: () => BriefingMode
readSound?: () => SoundSetting
+ readVoice?: () => VoiceSetting
/** 戻り先はルートが決める。他の画面と同じく、ここは表示に専念する。 */
onBack: () => void
}
@@ -79,11 +81,13 @@ export const SettingsScreen = ({
readSettings = loadSettings,
readBriefing = loadBriefingMode,
readSound = loadSoundSetting,
+ readVoice = loadVoiceSetting,
onBack,
}: Props) => {
const [settings, setSettings] = useState(readSettings)
const [briefing, setBriefing] = useState(readBriefing)
const [sound, setSound] = useState(readSound)
+ const [voice, setVoice] = useState(readVoice)
const [catalog, setCatalog] = useState(undefined)
const [failed, setFailed] = useState(false)
@@ -120,6 +124,11 @@ export const SettingsScreen = ({
setSound(next)
}
+ const chooseVoice = (next: VoiceSetting) => {
+ saveVoiceSetting(next)
+ setVoice(next)
+ }
+
// 音は打鍵のときしか鳴らない。せり上がるを選んでいるあいだは選ばせない。
const soundPickable = briefing === 'typewriter'
@@ -269,6 +278,30 @@ export const SettingsScreen = ({
: 'せり上がるは速さが決まっているぶん、音は鳴りません。'}
+
+ {/*
+ 聞き込みの声。記録の見せ方と同じで、会話の最中に切り替えを置くと毎回そこで手が止まる。
+ モデルの一覧を待たずに決められる値なので、取得の成否とは切り離して置く。
+ */}
+
+ 聞き込み
+
+
+
+
+
+
+ 読み上げないときは、これまで通り一定の間で流れます。
+
+
)
}
@@ -285,6 +318,11 @@ const SOUND_CHOICES: readonly Choice[] = [
{ key: 'off', label: '鳴らさない' },
]
+const VOICE_CHOICES: readonly Choice[] = [
+ { key: 'on', label: '読み上げる' },
+ { key: 'off', label: '読み上げない' },
+]
+
/**
* 二択の行。難易度の四択と同じ組みで、塗りつぶさずに選んだものだけ罫線と字を起こす。
*
diff --git a/src/client/screens/interrogation-log.ts b/src/client/screens/interrogation-log.ts
index 7f2be8e..29cda60 100644
--- a/src/client/screens/interrogation-log.ts
+++ b/src/client/screens/interrogation-log.ts
@@ -6,7 +6,7 @@
* この画面からしか呼ばれない。
*/
import type { ChatTurn } from '@/client/hooks/useInterrogation'
-import { splitParagraphs } from '@/client/lib/paragraphs'
+import { messageParagraphs } from '@/shared/paragraphs'
/** `HH:mm` を分に直す。時刻軸の位置取りと、会話のなかの時刻合わせに使う。 */
export const toMinutes = (hhmm: string): number => {
@@ -34,9 +34,12 @@ const settledEnd = (text: string): number => {
*
* `streaming` のあいだは、書きかけの一文を伏せる。届いたそばから出すと、
* 読むより先に目が字を追いはじめる。
+ *
+ * 割り方そのものは `messageParagraphs`(shared)。読み上げを頼むときの行番号は
+ * ここが返した添字なので、サーバも同じ関数で割る。
*/
export const paragraphsOf = (text: string, streaming: boolean): string[] =>
- splitParagraphs((streaming ? text.slice(0, settledEnd(text)) : text).split(/\n+/).join('\n\n'))
+ messageParagraphs(streaming ? text.slice(0, settledEnd(text)) : text)
/* ---- 会話のなかの確定時刻 ---- */
@@ -128,6 +131,15 @@ export const tintTimes = (text: string, inks: Map): Piece[] => {
/* ---- 会話の塊 ---- */
+/**
+ * その行を読み上げてもらう宛先。`line` は発言の中での段落の位置。
+ *
+ * 記録が済んでいない行(書いている途中・記録に失敗した行)には無い。
+ */
+export type VoiceRef = { messageId: string; line: number }
+
+export type Line = { id: string; text: string; voice: VoiceRef | undefined }
+
/** who は登場順の添字。探偵は列を持たないので -1。 */
export type Block = {
id: string
@@ -136,7 +148,7 @@ export type Block = {
/** 名前の色と縦罫。人は登場順の顔料、場所は灰、探偵は罫線と同じ色。 */
ink: string
edge: string
- lines: { id: string; text: string }[]
+ lines: Line[]
}
/** 塊を作るのに要る、相手の最小限。 */
@@ -171,7 +183,13 @@ export const buildBlocks = (
name: character.logName,
ink: character.ink,
edge: character.edge,
- streaming: seq === streamingSeq && turn.role === 'assistant',
+ /*
+ * IDが立った行はもう書き終わっている(サーバは記録してからIDを配る)。
+ * そこから先は書きかけを伏せない——伏せたままだと、画面が割った段落と
+ * サーバが割った段落がずれて、出ている行と読まれる行が食い違う。
+ */
+ streaming:
+ seq === streamingSeq && turn.role === 'assistant' && turn.messageId === undefined,
}))
// 話題はプレイヤーの指示であって発言ではない。探偵が投げた質問のほうが残る。
.filter(({ turn }) => turn.role !== 'topic' && turn.text.length > 0)
@@ -186,9 +204,12 @@ export const buildBlocks = (
for (const item of ordered) {
const who = item.turn.role === 'user' ? -1 : item.index
const id = `${item.index}:${item.turn.id}`
+ const messageId = item.turn.messageId
const lines = paragraphsOf(item.turn.text, item.streaming).map((text, at) => ({
id: `${id}:${at}`,
text,
+ // 段落の添字がそのまま読み上げの行番号になる。サーバも同じ割り方で切る。
+ voice: messageId === undefined ? undefined : { messageId, line: at },
}))
// 一文目が出来上がるまでは何も置かない。名前だけ先に出ると、
diff --git a/src/server/routes/sessions.ts b/src/server/routes/sessions.ts
index 728f88e..c909f3d 100644
--- a/src/server/routes/sessions.ts
+++ b/src/server/routes/sessions.ts
@@ -954,6 +954,51 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
blocked: boolean
} = { exchanges: [], usages: [], blocked: false }
+ /*
+ 発言を書き終えた時点で messages へ入れて、その行のIDを配る。
+
+ 読み上げは「この発言の N 行目を読んで」と頼む形なので、画面が行を出す前に
+ IDが要る。話題が終わってからまとめて書いていた頃は、配れるIDが無かった。
+
+ 話題の行は最初の1回だけ、質問と一緒に入れる。往復が一つも成立しなかった話題は
+ 何も残さない、という性質をそこで保つ。
+
+ 書けなくてもIDを配らないだけで、配信は止めない。IDの無い行は声が付かず、
+ 画面はこれまで通りの時間送りで出す——記録の取りこぼしでプレイを止めない。
+ */
+ const recorded = { topic: false }
+
+ const record = async (
+ role: 'user' | 'assistant',
+ content: string,
+ event: 'question-id' | 'answer-id',
+ ) => {
+ const id = crypto.randomUUID()
+
+ try {
+ await db.insert(messages).values([
+ ...(recorded.topic
+ ? []
+ : [
+ {
+ sessionId,
+ characterId: askInput.characterId,
+ role: 'topic',
+ content: askInput.topic,
+ },
+ ]),
+ { id, sessionId, characterId: askInput.characterId, role, content },
+ ])
+ recorded.topic = true
+ } catch (error) {
+ console.error('[ask] failed to record message', error)
+
+ return
+ }
+
+ await stream.writeSSE({ event, data: id })
+ }
+
/*
往復の上限はセッションに固定された値。ここが1つの話題のコストを決める。
検分だけは1回きり——相手が喋らないので、答えを受けて掘り下げる相手が居ない。
@@ -1041,6 +1086,8 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
break
}
+ await record('user', interviewer.question, 'question-id')
+
const result = examining
? streamExamination({
env,
@@ -1097,14 +1144,19 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
break
}
+ await record('assistant', streamed.text, 'answer-id')
+
collected.exchanges.push({ question: interviewer.question, answer: streamed.text })
}
if (collected.blocked) {
- // 秘匿キーワードの漏洩を検知。この往復はDBにもDOにも記録せず、
+ // 秘匿キーワードの漏洩を検知。漏れた返答はDBにもDOにも記録せず、
// プレイヤーには当たり障りのない代替応答を返す。すでに安全な断片は流れているので、
// 「話の途中で言葉を止めた」ように読める文で締める。
//
+ // 探偵の質問のほうは、この時点で既に messages に入っている(record)。
+ // 漏れているのは相手の返答で、こちらは探偵が自分で言ったことなので残してよい。
+ //
// 同じ話題の中で先に成立した往復は捨てない。プレイヤーはもう読んでいるし、
// それ自体は漏洩していない。捨てると画面の会話と記録が食い違う。
console.error('[ask] blocked reply: secret keyword detected', {
@@ -1145,45 +1197,8 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
persisted.questionCount = appended.questionCount
persisted.round = appended.round
- /*
- 会話ログとコストは別のテーブルへ、同時に書く。ここは Judge の手前なので、
- 直列にすると往復ぶんだけ判定の開始が遅れる。
-
- 話題そのものも1行として残す。探偵の質問だけを並べると、プレイヤーが
- 何を指示したのかが後から辿れない。
-
- 検分だけは messages に残さない。あの列は characters への外部キーを持っていて、
- 被害者も場所も characters に居ないため入らない。読み返しに使うのは DO の側なので
- (履歴の復元も判定もそちらを見る)、プレイには影響しない。
- あの表は後から分析するための控えなので、そこだけ欠けることになる。
- */
- await Promise.all([
- examining
- ? Promise.resolve()
- : db.insert(messages).values([
- {
- sessionId,
- characterId: askInput.characterId,
- role: 'topic',
- content: askInput.topic,
- },
- ...collected.exchanges.flatMap((exchange) => [
- {
- sessionId,
- characterId: askInput.characterId,
- role: 'user',
- content: exchange.question,
- },
- {
- sessionId,
- characterId: askInput.characterId,
- role: 'assistant',
- content: exchange.answer,
- },
- ]),
- ]),
- db.insert(llmUsages).values(collected.usages),
- ])
+ // 会話ログは往復ごとに書き終えている(record)。ここに残るのはコストだけ。
+ await db.insert(llmUsages).values(collected.usages)
} catch (error) {
console.error('[ask] failed to persist turn', error)
}
diff --git a/src/server/routes/voice.ts b/src/server/routes/voice.ts
index 32de4e9..de404b5 100644
--- a/src/server/routes/voice.ts
+++ b/src/server/routes/voice.ts
@@ -5,16 +5,22 @@ import { z } from 'zod'
import { createDb } from '@/server/db/client'
import type { Bindings } from '@/server/env'
import { withEnv } from '@/server/middleware/env'
-import { speakerFor, synthesize } from '@/server/tts/irodori'
+import { assignSpeakers, DETECTIVE_SPEAKER_ID, synthesize } from '@/server/tts/irodori'
+import { messageParagraphs } from '@/shared/paragraphs'
+import type { AgeGroup, Gender } from '~/db/person'
import { characters, messages, ttsSpeakers } from '~/db/schema'
/**
* NPCの台詞を音声にして返す。
*
* 合成する文をクライアントから受け取らないのは、受け取ると誰でも好きな文を投げられる
- * 読み上げ機になるため(GPUを他人に貸すことになる)。渡せるのは記録済みの発言のIDだけで、
- * 文はサーバが messages から読む。セッションIDとの一致も確かめるので、
- * 他人のセッションの発言は引けない。
+ * 読み上げ機になるため(GPUを他人に貸すことになる)。渡せるのは記録済みの発言のIDと
+ * その中の行番号だけで、文はサーバが messages から読む。セッションIDとの一致も
+ * 確かめるので、他人のセッションの発言は引けない。
+ *
+ * 行に割るのは、画面が一行ずつ出して一行ずつ読み上げるため。割り方は
+ * `messageParagraphs`(shared)で画面と揃えてある。ここがずれると、
+ * 出ている行と読まれる行が食い違う。
*/
export const voiceRoutes = new Hono<{ Bindings: Bindings }>()
@@ -24,11 +30,15 @@ export const voiceRoutes = new Hono<{ Bindings: Bindings }>()
*/
const validateIds = createMiddleware<{
Bindings: Bindings
- Variables: { ids: { id: string; messageId: string } }
+ Variables: { ids: { id: string; messageId: string; line: number } }
}>(async (c, next) => {
const parsed = z
- .object({ id: z.uuid(), messageId: z.uuid() })
- .safeParse({ id: c.req.param('id'), messageId: c.req.param('messageId') })
+ .object({ id: z.uuid(), messageId: z.uuid(), line: z.coerce.number().int().min(0) })
+ .safeParse({
+ id: c.req.param('id'),
+ messageId: c.req.param('messageId'),
+ line: c.req.query('line'),
+ })
if (!parsed.success) {
return c.json({ error: 'invalid id' }, 400)
@@ -47,16 +57,19 @@ voiceRoutes.get('/api/sessions/:id/messages/:messageId/voice', validateIds, with
}
const db = createDb(c.env.DB)
+ /*
+ 人物への外部結合。遺体と場所は characters に行を持たないので、内部結合にすると
+ 検分の所見が丸ごと引けなくなる。
+ */
const rows = await db
.select({
content: messages.content,
role: messages.role,
characterId: messages.characterId,
- ageGroup: characters.ageGroup,
- gender: characters.gender,
+ scenarioId: characters.scenarioId,
})
.from(messages)
- .innerJoin(characters, eq(characters.id, messages.characterId))
+ .leftJoin(characters, eq(characters.id, messages.characterId))
.where(and(eq(messages.id, ids.messageId), eq(messages.sessionId, ids.id)))
.limit(1)
@@ -66,44 +79,31 @@ voiceRoutes.get('/api/sessions/:id/messages/:messageId/voice', validateIds, with
return c.json({ error: 'message not found' }, 404)
}
- // 探偵の質問は読み上げない。声はNPCのものだけで、プレイヤーの発言に声は無い。
- if (row.role !== 'assistant') {
- return c.json({ error: 'not a character message' }, 404)
+ // 話題はプレイヤーが探偵へ渡した指示で、誰も口にしていない。声は付かない。
+ if (row.role !== 'user' && row.role !== 'assistant') {
+ return c.json({ error: 'not a spoken message' }, 404)
}
- /*
- 話者は人物の年ごろと性別で絞ってから、UUIDで一体に決める。シナリオのデータには
- 持たせていない。登録話者は既存作品の登場人物なので、割り当てをリポジトリに
- 焼き込みたくない(`src/server/tts/irodori.ts` の但し書きを参照)。
+ const text = messageParagraphs(row.content)[ids.line]
- 絞り込みは性別を先に、年ごろを後に落とす。声を聞いて一番先に「違う」と分かるのが
- 性別で、年ごろの隣(young と adult など)は許容できる幅があるため。
- */
- const matched = await db
- .select({ id: ttsSpeakers.id })
- .from(ttsSpeakers)
- .where(and(eq(ttsSpeakers.gender, row.gender), eq(ttsSpeakers.ageGroup, row.ageGroup)))
- const sameGender =
- matched.length > 0
- ? matched
- : await db
- .select({ id: ttsSpeakers.id })
- .from(ttsSpeakers)
- .where(eq(ttsSpeakers.gender, row.gender))
- const speaker = speakerFor(sameGender, row.characterId)
-
- if (speaker === undefined) {
+ if (text === undefined) {
+ return c.json({ error: 'line not found' }, 404)
+ }
+
+ const speakerId = await speakerIdFor(db, row)
+
+ if (speakerId === undefined) {
return c.json({ error: 'character has no voice' }, 404)
}
- const upstream = await synthesize(baseUrl, row.content, { speakerId: speaker.id })
+ const upstream = await synthesize(baseUrl, text, { speakerId })
if (!upstream.ok) {
return c.json({ error: 'synthesis failed' }, 502)
}
/*
- 同じ発言IDと同じシードなら音は変わらないので、クライアントとエッジに預けてよい。
+ 同じ発言IDと同じ行なら音は変わらないので、クライアントとエッジに預けてよい。
private にしてあるのは、発言の中身がセッションの持ち主のものだから。
*/
return new Response(upstream.body, {
@@ -113,3 +113,67 @@ voiceRoutes.get('/api/sessions/:id/messages/:messageId/voice', validateIds, with
},
})
})
+
+type Person = { ageGroup: AgeGroup; gender: Gender }
+
+type VoicedRow = {
+ role: string
+ characterId: string
+ /** 人物ならシナリオを持つ。遺体と場所は characters に行が無いので null。 */
+ scenarioId: string | null
+}
+
+/**
+ * その人物に当ててよい話者を、望ましい順の束にして返す。
+ *
+ * 年ごろを先に、性別を後に譲る。声を聞いて一番先に「違う」と分かるのが性別で、
+ * 年ごろの隣(young と adult など)は許容できる幅があるため。最後の束が
+ * 全員なのは、性別の分からない人物(`unknown`)に当たる話者が一体も居ないため
+ * ——そこで諦めるとその人だけ黙ることになる。
+ *
+ * 探偵の声はどの束からも外す。事件の関係者と探偵が同じ声だと、聞き手が
+ * 入れ替わって聞こえる。
+ */
+const tiersFor = (speakers: T[], person: Person): T[][] => {
+ const free = speakers.filter((speaker) => speaker.id !== DETECTIVE_SPEAKER_ID)
+ const sameGender = free.filter((speaker) => speaker.gender === person.gender)
+
+ return [sameGender.filter((speaker) => speaker.ageGroup === person.ageGroup), sameGender, free]
+}
+
+/**
+ * その行を誰の声で読むか。
+ *
+ * 探偵の質問と、遺体・現場の所見は探偵の声。所見は死者や部屋が喋ったのではなく、
+ * 探偵が見て取ったものなので、その人物の声を当てる相手がそもそも居ない。
+ *
+ * 人物の返答は、事件に出る全員ぶんをまとめて配ってから自分のぶんを取る。一人ずつ
+ * 独立に決めると、同じ年ごろ・同じ性別の二人に同じ声が当たることがある
+ * (`assignSpeakers`)。シナリオのデータには持たせていない——登録話者は既存作品の
+ * 登場人物なので、割り当てをリポジトリに焼き込みたくない
+ * (`src/server/tts/irodori.ts` の但し書きを参照)。
+ */
+const speakerIdFor = async (
+ db: ReturnType,
+ row: VoicedRow,
+): Promise => {
+ if (row.role === 'user' || row.scenarioId === null) {
+ return DETECTIVE_SPEAKER_ID
+ }
+
+ const [people, speakers] = await Promise.all([
+ db
+ .select({ id: characters.id, ageGroup: characters.ageGroup, gender: characters.gender })
+ .from(characters)
+ .where(eq(characters.scenarioId, row.scenarioId)),
+ db
+ .select({ id: ttsSpeakers.id, ageGroup: ttsSpeakers.ageGroup, gender: ttsSpeakers.gender })
+ .from(ttsSpeakers),
+ ])
+
+ const assigned = assignSpeakers(
+ people.map((person) => ({ id: person.id, tiers: tiersFor(speakers, person) })),
+ )
+
+ return assigned.get(row.characterId)?.id
+}
diff --git a/src/server/tts/irodori.ts b/src/server/tts/irodori.ts
index 0ba7556..5b9cc36 100644
--- a/src/server/tts/irodori.ts
+++ b/src/server/tts/irodori.ts
@@ -19,6 +19,17 @@
/** 突き合わせに要るのは id だけ。名前や年ごろは呼ぶ側が持っていればよい。 */
export type Speaker = { id: string }
+/**
+ * 探偵の声。話者は「橘シェリー」(CV 柊優花)で固定。
+ *
+ * 人物と違って探偵は `characters` に行を持たないので、年ごろと性別で絞る道が無い。
+ * プレイヤーが名前を決めるだけの役なので、選ばせる仕組みも要らない——一体に決め打つ。
+ * 遺体や現場を調べたときの所見も、探偵が見たものなのでこの声で読む。
+ *
+ * 上の但し書きと同じ扱い。公開に向かうときはこの定数ごと落とす。
+ */
+export const DETECTIVE_SPEAKER_ID = '64cbf822-f7ee-525f-8d2f-8ad7a89664e8'
+
/**
* 候補の中から一体を決める。
*
@@ -42,6 +53,50 @@ export const speakerFor = (
return candidates[digest % candidates.length]
}
+/**
+ * 事件に出る人物へ、まとめて声を配る。
+ *
+ * 一人ずつ独立に決めると、年ごろと性別が同じ二人に同じ声が当たることがある。
+ * 同じ事件のなかで声が重なると、誰が喋っているのか耳では分からなくなる——
+ * 名前が見えている画面でも、聞いているあいだは声が人物の見分けになっている。
+ *
+ * `tiers` は望ましい順に並べた候補の束。先の束に空きがあるあいだはそこから取り、
+ * 尽きたら次の束へ落ちる。年ごろまで合う声が一体しか居ない組(senior は男女とも
+ * 一体ずつ)で二人目が来たとき、重ねるのではなく年ごろを譲るための段。
+ *
+ * 配る順はIDの昇順。登場順ではないのは、並びの根拠をこの関数の中だけで完結させるため
+ * (呼ぶ側が順番を間違えると、人物ごとに違う声が返る関数になってしまう)。
+ * どの束にも空きが無くなったら重なりを許す——声が出ないより、二人が似ているほうが
+ * まだ遊べる。
+ */
+export const assignSpeakers = (
+ people: { id: string; tiers: T[][] }[],
+): Map =>
+ [...people]
+ .sort((a, b) => (a.id < b.id ? -1 : 1))
+ .reduce(
+ (acc, person) => {
+ const open = person.tiers.find((tier) =>
+ tier.some((candidate) => !acc.taken.has(candidate.id)),
+ )
+ const pool =
+ open === undefined
+ ? person.tiers.flat()
+ : open.filter((candidate) => !acc.taken.has(candidate.id))
+ const picked = speakerFor(pool, person.id)
+
+ if (picked === undefined) {
+ return acc
+ }
+
+ acc.taken.add(picked.id)
+ acc.byPerson.set(person.id, picked)
+
+ return acc
+ },
+ { taken: new Set(), byPerson: new Map() },
+ ).byPerson
+
/** 登録話者で鳴らす。細かい調整は送らない——話者ごとの既定が上流にある。 */
export type Voice = { speakerId: string }
diff --git a/src/client/lib/paragraphs.ts b/src/shared/paragraphs.ts
similarity index 88%
rename from src/client/lib/paragraphs.ts
rename to src/shared/paragraphs.ts
index a69c2ad..a8142f7 100644
--- a/src/client/lib/paragraphs.ts
+++ b/src/shared/paragraphs.ts
@@ -88,3 +88,13 @@ export const splitParagraphs = (text: string): string[] =>
.filter((paragraph) => paragraph.length > 0)
.flatMap(chunkParagraph)
.map((paragraph) => paragraph.trim())
+
+/**
+ * 書き終わった発言を、画面に置く段落の並びに変える。
+ *
+ * このモジュールが client ではなく shared に居るのはこの関数のため。読み上げは
+ * 「この発言の N 行目」で合成を頼むので、画面が割った位置とサーバが割った位置が
+ * ずれると、出ている行と読まれる行が食い違う。両方ここを通す。
+ */
+export const messageParagraphs = (text: string): string[] =>
+ splitParagraphs(text.split(/\n+/).join('\n\n'))
From 1cd93190e14cc4e83aac2d899f8746d74f204bc4 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Wed, 9 Sep 2026 15:50:11 +0000
Subject: [PATCH 35/41] feat(detectives): add Tachibana Sherry as a preset
detective
Keep the preset outside local storage while allowing it to be selected, displayed first in the roster, and protected from editing or deletion.
Co-Authored-By: Claude
---
__tests__/client/detective-store.test.ts | 37 ++++++
src/client/lib/detective-store.ts | 40 ++++++-
.../screens/DetectiveSetupScreen.stories.tsx | 9 +-
src/client/screens/DetectiveSetupScreen.tsx | 106 +++++++++---------
4 files changed, 136 insertions(+), 56 deletions(-)
diff --git a/__tests__/client/detective-store.test.ts b/__tests__/client/detective-store.test.ts
index 5f0b1be..b1628ec 100644
--- a/__tests__/client/detective-store.test.ts
+++ b/__tests__/client/detective-store.test.ts
@@ -3,11 +3,15 @@ import {
activeDetective,
clearActiveDetective,
type DetectiveStore,
+ detectiveRoster,
EMPTY_STORE,
+ isPresetDetective,
+ PRESET_DETECTIVES,
parseDetectiveStore,
removeDetective,
type StoredDetective,
setActiveDetective,
+ TACHIBANA_SHERRY,
toDetective,
upsertDetective,
} from '@/client/lib/detective-store'
@@ -109,6 +113,39 @@ describe('activeDetective', () => {
})
})
+describe('備え付けの探偵', () => {
+ test('1人も作っていなくても名簿には並ぶ', () => {
+ expect(detectiveRoster(EMPTY_STORE)).toEqual(PRESET_DETECTIVES)
+ })
+
+ test('作った探偵より前に並ぶ', () => {
+ const store: DetectiveStore = { profiles: [akari], activeId: undefined }
+
+ expect(detectiveRoster(store).map((p) => p.id)).toEqual([TACHIBANA_SHERRY.id, 'a'])
+ })
+
+ test('選べる', () => {
+ const selected = setActiveDetective(EMPTY_STORE, TACHIBANA_SHERRY.id)
+
+ expect(activeDetective(selected)?.name).toBe('橘シェリー')
+ })
+
+ test('選んでも保管庫には書かれない', () => {
+ expect(setActiveDetective(EMPTY_STORE, TACHIBANA_SHERRY.id).profiles).toEqual([])
+ })
+
+ test('選択中のまま読み直しても、選択が外れない', () => {
+ const parsed = parseDetectiveStore({ profiles: [], activeId: TACHIBANA_SHERRY.id })
+
+ expect(parsed.store.activeId).toBe(TACHIBANA_SHERRY.id)
+ })
+
+ test('備え付けかどうかを見分けられる', () => {
+ expect(isPresetDetective(TACHIBANA_SHERRY.id)).toBe(true)
+ expect(isPresetDetective('a')).toBe(false)
+ })
+})
+
describe('clearActiveDetective', () => {
test('選択だけ外し、保存済みの探偵は消さない', () => {
const store: DetectiveStore = { profiles: [akari, tsubaki], activeId: 'a' }
diff --git a/src/client/lib/detective-store.ts b/src/client/lib/detective-store.ts
index bf03e7b..0df3c27 100644
--- a/src/client/lib/detective-store.ts
+++ b/src/client/lib/detective-store.ts
@@ -26,6 +26,38 @@ export type DetectiveStore = z.infer
export const EMPTY_STORE: DetectiveStore = { profiles: [], activeId: undefined }
+/**
+ * 備え付けの探偵。作らなくても、この一体を選べばすぐ事件に向かえる。
+ *
+ * localStorage には書かない。保管庫は「プレイヤーが作ったもの」だけを持ち、
+ * 備え付けは名簿に並べるときに前へ足す。書き込むと、消せない行が保存され続けるうえ、
+ * 中身を直したときに古い写しが各人の端末に残る。
+ *
+ * 【外に出す前に必ず外すこと】
+ * 「橘シェリー」は既存作品の登場人物で、声も実在の声優に紐づく登録話者を借りている。
+ * src/server/tts/irodori.ts の但し書きと同じ扱いで、公開に向かうときは
+ * DETECTIVE_SPEAKER_ID と一緒にこの定数ごと落とす。
+ */
+export const TACHIBANA_SHERRY: StoredDetective = {
+ id: 'preset:tachibana-sherry',
+ name: '橘シェリー',
+ ageGroup: 'young',
+ gender: 'female',
+ appearance: '黒のロングコートに白手袋。物腰は柔らかいが、目だけは笑っていない。',
+}
+
+export const PRESET_DETECTIVES: StoredDetective[] = [TACHIBANA_SHERRY]
+
+/** 備え付けかどうか。編集と削除を出すかがここで決まる。 */
+export const isPresetDetective = (id: string): boolean =>
+ PRESET_DETECTIVES.some((preset) => preset.id === id)
+
+/** 名簿。備え付けが先、そのあとに作った順で並ぶ。 */
+export const detectiveRoster = (store: DetectiveStore): StoredDetective[] => [
+ ...PRESET_DETECTIVES,
+ ...store.profiles,
+]
+
const STORAGE_KEY = 'alibai:detectives'
/**
@@ -161,7 +193,9 @@ export const parseDetectiveStore = (raw: unknown): ParsedStore => {
const profiles = loose.data.profiles.flatMap(recoverProfile)
// 選択中だった探偵が復元できなかったなら、選択は外す。
// 居ない相手を選んだままにすると、そのまま事件に向かえてしまう。
- const activeId = profiles.some((profile) => profile.id === loose.data.activeId)
+ const activeId = detectiveRoster({ profiles, activeId: undefined }).some(
+ (profile) => profile.id === loose.data.activeId,
+ )
? loose.data.activeId
: undefined
@@ -176,7 +210,7 @@ export const parseDetectiveStore = (raw: unknown): ParsedStore => {
* プレイが始まってしまう。
*/
export const activeDetective = (store: DetectiveStore): StoredDetective | undefined =>
- store.profiles.find((profile) => profile.id === store.activeId)
+ detectiveRoster(store).find((profile) => profile.id === store.activeId)
/**
* 追加または更新。同じ id があれば置き換え、無ければ末尾に足す。
@@ -207,7 +241,7 @@ export const removeDetective = (store: DetectiveStore, id: string): DetectiveSto
/** 選択の切り替え。存在しない id を渡された場合は何も変えない。 */
export const setActiveDetective = (store: DetectiveStore, id: string): DetectiveStore =>
- store.profiles.some((profile) => profile.id === id) ? { ...store, activeId: id } : store
+ detectiveRoster(store).some((profile) => profile.id === id) ? { ...store, activeId: id } : store
/** 名乗らずに始めるときのために、選択を外す。 */
export const clearActiveDetective = (store: DetectiveStore): DetectiveStore => ({
diff --git a/src/client/screens/DetectiveSetupScreen.stories.tsx b/src/client/screens/DetectiveSetupScreen.stories.tsx
index aa10b92..0d8bebd 100644
--- a/src/client/screens/DetectiveSetupScreen.stories.tsx
+++ b/src/client/screens/DetectiveSetupScreen.stories.tsx
@@ -6,7 +6,7 @@ import { DETECTIVES, SCENARIO } from '@/client/stories/fixtures'
/**
* ALI_DET — 探偵を決める。
* readStore と initialDraft を差し替えられるように作られているので、
- * localStorage を仕込まずに名簿・つくる・編集する・空の4状態を出せる。
+ * localStorage を仕込まずに名簿・つくる・編集する・作る前の4状態を出せる。
*/
const meta: Meta = {
title: 'Screens/DET 探偵を決める',
@@ -44,7 +44,10 @@ export const Edit: Story = {
args: { initialDraft: EDIT_DRAFT },
}
-/** 名簿が空。「まだ探偵がいません」と出て、事件に向かうは押せない。 */
-export const Empty: Story = {
+/**
+ * まだ1人も作っていない状態。備え付けの橘シェリーだけが並ぶ。
+ * 選ぶまで「事件に向かう」は押せず、その一体には編集も削除も出ない。
+ */
+export const PresetOnly: Story = {
args: { readStore: () => EMPTY_STORE },
}
diff --git a/src/client/screens/DetectiveSetupScreen.tsx b/src/client/screens/DetectiveSetupScreen.tsx
index 12d2931..52bacb0 100644
--- a/src/client/screens/DetectiveSetupScreen.tsx
+++ b/src/client/screens/DetectiveSetupScreen.tsx
@@ -5,6 +5,8 @@ import {
activeDetective,
clearActiveDetective,
type DetectiveStore,
+ detectiveRoster,
+ isPresetDetective,
loadDetectiveStore,
newDetectiveId,
removeDetective,
@@ -127,6 +129,7 @@ export const DetectiveSetupScreen = ({
}
const selected = activeDetective(store)
+ const roster = detectiveRoster(store)
const canSave = draft !== undefined && draft.name.trim().length > 0
// 名簿は端末では draft が無いあいだだけ、机では常に見せる。
const rosterVisibleOnPhone = draft === undefined
@@ -164,55 +167,56 @@ export const DetectiveSetupScreen = ({
探偵
- {store.profiles.length === 0 ? (
-
- まだ探偵がいません。作るか、名乗らずに始めることもできます。
-
- ) : (
-
- {store.profiles.map((profile) => {
- const isActive = profile.id === store.activeId
+ {/* 備え付けの一体が常に居るので、名簿が空になることはない。 */}
+
+ {roster.map((profile) => {
+ const isActive = profile.id === store.activeId
+ const isPreset = isPresetDetective(profile.id)
- return (
- -
- {/*
+ className={`flex items-start gap-[10px] border-keisen border-b py-[7px] pl-3 lg:gap-3 lg:py-[10px] lg:pl-[14px] ${
+ isActive
+ ? 'shadow-[inset_2px_0_0_var(--color-kinari)]'
+ : 'lg:hover:bg-sumi-2 lg:hover:shadow-[inset_2px_0_0_var(--color-nezumi)]'
+ }`}
+ >
+ {/*
行そのものが押す場所なので、ここは素のボタンのまま。名前と説明を
積む形は Button の一行に詰める組みとは噛み合わない。
*/}
- update(setActiveDetective(store, profile.id))}
- className="flex min-w-0 flex-1 flex-col gap-px text-left lg:gap-0"
+ update(setActiveDetective(store, profile.id))}
+ className="flex min-w-0 flex-1 flex-col gap-px text-left lg:gap-0"
+ >
+
-
- {profile.name}
-
+ {profile.name}
+
+
+ {describeDetective(profile)}
+
+ {profile.appearance.length > 0 && (
- {describeDetective(profile)}
+ {profile.appearance}
- {profile.appearance.length > 0 && (
-
- {profile.appearance}
-
- )}
-
+ )}
+
- {/* 消すほうは一段沈める。並べて置くと押し間違える。 */}
+ {/*
+ 消すほうは一段沈める。並べて置くと押し間違える。
+ 備え付けは直しも消しもできないので、行ごと出さない。
+ */}
+ {!isPreset && (
-
- )
- })}
-
- )}
+ )}
+
+ )
+ })}
+
{selected.appearance.length > 0 ? selected.appearance : '容姿は書かれていません。'}
- setDraft({ ...selected })}
- className="mt-5 block text-[12.5px] text-nezumi"
- >
- この探偵を編集する
-
+ {!isPresetDetective(selected.id) && (
+ setDraft({ ...selected })}
+ className="mt-5 block text-[12.5px] text-nezumi"
+ >
+ この探偵を編集する
+
+ )}
)
) : (
From 90f9c55eebbd0b383d36f7876066d24f8e820573 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Wed, 9 Sep 2026 15:54:19 +0000
Subject: [PATCH 36/41] fix(detectives): align Tachibana Sherry profile with
preset voice
Clarify that custom detectives continue to use the fixed speaker.
Co-Authored-By: Claude
---
src/client/lib/detective-store.ts | 4 ++--
src/server/tts/irodori.ts | 8 ++++++--
2 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/src/client/lib/detective-store.ts b/src/client/lib/detective-store.ts
index 0df3c27..cb29bcf 100644
--- a/src/client/lib/detective-store.ts
+++ b/src/client/lib/detective-store.ts
@@ -41,9 +41,9 @@ export const EMPTY_STORE: DetectiveStore = { profiles: [], activeId: undefined }
export const TACHIBANA_SHERRY: StoredDetective = {
id: 'preset:tachibana-sherry',
name: '橘シェリー',
- ageGroup: 'young',
+ ageGroup: 'teen',
gender: 'female',
- appearance: '黒のロングコートに白手袋。物腰は柔らかいが、目だけは笑っていない。',
+ appearance: '上背のある少女。いつもにこにこしていて、面白そうなものには迷わず飛びついてくる。',
}
export const PRESET_DETECTIVES: StoredDetective[] = [TACHIBANA_SHERRY]
diff --git a/src/server/tts/irodori.ts b/src/server/tts/irodori.ts
index 5b9cc36..bb59345 100644
--- a/src/server/tts/irodori.ts
+++ b/src/server/tts/irodori.ts
@@ -23,10 +23,14 @@ export type Speaker = { id: string }
* 探偵の声。話者は「橘シェリー」(CV 柊優花)で固定。
*
* 人物と違って探偵は `characters` に行を持たないので、年ごろと性別で絞る道が無い。
- * プレイヤーが名前を決めるだけの役なので、選ばせる仕組みも要らない——一体に決め打つ。
+ * プレイヤーが名前を決める役なので、選ばせる仕組みも要らない——一体に決め打つ。
* 遺体や現場を調べたときの所見も、探偵が見たものなのでこの声で読む。
*
- * 上の但し書きと同じ扱い。公開に向かうときはこの定数ごと落とす。
+ * 備え付けの探偵(src/client/lib/detective-store.ts の TACHIBANA_SHERRY)は
+ * この話者と同じ人物にしてあるので、名前と声が揃う。ただし自分で作った探偵を
+ * 選んでも声はこのままで、そこは揃わない。
+ *
+ * 上の但し書きと同じ扱い。公開に向かうときは TACHIBANA_SHERRY と一緒に落とす。
*/
export const DETECTIVE_SPEAKER_ID = '64cbf822-f7ee-525f-8d2f-8ad7a89664e8'
From c0567eb96856c1e5ab5376530b2e2df6f74d0ed0 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Wed, 9 Sep 2026 16:05:47 +0000
Subject: [PATCH 37/41] feat(detectives): add configurable speech style
Persist speech preferences, migrate existing detective profiles, and include the configured style in LLM prompts.
Co-Authored-By: Claude
---
__tests__/client/detective-store.test.ts | 14 +++++++++-
__tests__/llm/detective.test.ts | 1 +
db/detective.ts | 6 ++++-
src/client/lib/detective-store.ts | 20 +++++++++++++-
.../screens/DetectiveSetupScreen.stories.tsx | 1 +
src/client/screens/DetectiveSetupScreen.tsx | 26 +++++++++++++++++++
src/client/stories/fixtures.ts | 11 +++++++-
src/server/llm/detective.ts | 8 +++++-
8 files changed, 82 insertions(+), 5 deletions(-)
diff --git a/__tests__/client/detective-store.test.ts b/__tests__/client/detective-store.test.ts
index b1628ec..50030b4 100644
--- a/__tests__/client/detective-store.test.ts
+++ b/__tests__/client/detective-store.test.ts
@@ -22,6 +22,7 @@ const profile = (id: string, name: string): StoredDetective => ({
ageGroup: 'young',
gender: 'female',
appearance: 'くたびれたコート',
+ speech: '',
})
const akari = profile('a', '日下部 灯')
@@ -163,6 +164,7 @@ describe('toDetective', () => {
ageGroup: 'young',
gender: 'female',
appearance: 'くたびれたコート',
+ speech: '',
})
})
})
@@ -187,8 +189,9 @@ describe('parseDetectiveStore', () => {
ageGroup: 'young',
gender: 'female',
appearance: 'くたびれたコート',
+ speech: '',
},
- { id: 'b', name: '八千代 椿', ageGroup: 'elder', gender: 'male', appearance: '' },
+ { id: 'b', name: '八千代 椿', ageGroup: 'elder', gender: 'male', appearance: '', speech: '' },
])
expect(parsed.store.activeId).toBe('a')
})
@@ -226,6 +229,15 @@ describe('parseDetectiveStore', () => {
expect(parsed.store.activeId).toBeUndefined()
})
+ test('口調の欄が無かった頃の探偵を、消さずに空の口調で読み替える', () => {
+ const { speech, ...speechless } = akari
+ const parsed = parseDetectiveStore({ profiles: [speechless], activeId: 'a' })
+
+ expect(parsed.migrated).toBe(true)
+ expect(parsed.store.profiles).toEqual([{ ...akari, speech: '' }])
+ expect(parsed.store.activeId).toBe('a')
+ })
+
test('今の形はそのまま通り、書き戻しも起こさない', () => {
const parsed = parseDetectiveStore({ profiles: [akari], activeId: 'a' })
diff --git a/__tests__/llm/detective.test.ts b/__tests__/llm/detective.test.ts
index 691b4b0..1a939a0 100644
--- a/__tests__/llm/detective.test.ts
+++ b/__tests__/llm/detective.test.ts
@@ -7,6 +7,7 @@ const detective = (patch: Partial): Detective => ({
ageGroup: 'young',
gender: 'female',
appearance: '',
+ speech: '',
...patch,
})
diff --git a/db/detective.ts b/db/detective.ts
index 1e04154..f2aa191 100644
--- a/db/detective.ts
+++ b/db/detective.ts
@@ -15,14 +15,18 @@ import { AGE_GROUP_LABELS, ageGroupSchema, GENDER_LABELS, genderSchema } from '.
/**
* 探偵そのもの。名乗らずに始めることもできるので、持たないセッションもある。
*
- * appearance だけが自由記述。NPC のプロンプトにそのまま入る=そのままトークン数に
+ * appearance と speech が自由記述。NPC のプロンプトにそのまま入る=そのままトークン数に
* なるので、上限を切る。空でも構わない(書かなければプロンプトにも出さない)。
+ *
+ * speech が appearance より短いのは、口調は例を一つ二つ挙げれば足りるため。
+ * 長く書けるようにすると人物設定をここへ書き始めてしまい、appearance と役が重なる。
*/
export const detectiveSchema = z.object({
name: z.string().nonempty().max(40),
ageGroup: ageGroupSchema,
gender: genderSchema,
appearance: z.string().max(200),
+ speech: z.string().max(100),
})
export type Detective = z.infer
diff --git a/src/client/lib/detective-store.ts b/src/client/lib/detective-store.ts
index cb29bcf..c2d38c7 100644
--- a/src/client/lib/detective-store.ts
+++ b/src/client/lib/detective-store.ts
@@ -43,7 +43,9 @@ export const TACHIBANA_SHERRY: StoredDetective = {
name: '橘シェリー',
ageGroup: 'teen',
gender: 'female',
- appearance: '上背のある少女。いつもにこにこしていて、面白そうなものには迷わず飛びついてくる。',
+ appearance:
+ '水色の髪を三つ編みでまとめた団子頭に、オレンジの瞳。右の横髪に金と黒の小さなリボン。紺と青と水色のチェックのシャーロックハットをかぶり、首元はリボンとネクタイ。くすんだ水色のウェストコートを黒いリボンで留め、上から紺のインバネスコート。帽子と同じチェックのスカートは膝が見える丈で、左足だけ黒いタイツ。靴は黒く、白い小さなリボンの飾りがついている。',
+ speech: '敬語を使わず、明るく砕けた口ぶり。面白がっているのがそのまま声に出る。',
}
export const PRESET_DETECTIVES: StoredDetective[] = [TACHIBANA_SHERRY]
@@ -79,6 +81,14 @@ const legacyStoredDetectiveSchema = z.object({
appearance: z.string().max(200),
})
+/**
+ * 口調の欄が無かった頃の形。空の口調として読み替える。
+ *
+ * 足したばかりの欄なので、保管庫にある探偵はまだ誰も持っていない。ここを飛ばすと
+ * 全員が今の形にも旧い形にも当てはまらなくなり、次に開いた瞬間まとめて消える。
+ */
+const speechlessStoredDetectiveSchema = storedDetectiveSchema.omit({ speech: true })
+
/** 中身の検証は1人ずつやるので、ここでは器の形だけ見る。 */
const looseStoreSchema = z.object({
profiles: z.array(z.unknown()),
@@ -151,6 +161,12 @@ const recoverProfile = (raw: unknown): StoredDetective[] => {
return [current.data]
}
+ const speechless = speechlessStoredDetectiveSchema.safeParse(raw)
+
+ if (speechless.success) {
+ return [{ ...speechless.data, speech: '' }]
+ }
+
const legacy = legacyStoredDetectiveSchema.safeParse(raw)
if (!legacy.success) {
@@ -164,6 +180,7 @@ const recoverProfile = (raw: unknown): StoredDetective[] => {
ageGroup: toAgeGroup(legacy.data.age),
gender: toGender(legacy.data.gender),
appearance: legacy.data.appearance,
+ speech: '',
},
]
}
@@ -255,6 +272,7 @@ export const toDetective = (stored: StoredDetective): Detective => ({
ageGroup: stored.ageGroup,
gender: stored.gender,
appearance: stored.appearance,
+ speech: stored.speech,
})
export const newDetectiveId = (): string => crypto.randomUUID()
diff --git a/src/client/screens/DetectiveSetupScreen.stories.tsx b/src/client/screens/DetectiveSetupScreen.stories.tsx
index 0d8bebd..0bf1744 100644
--- a/src/client/screens/DetectiveSetupScreen.stories.tsx
+++ b/src/client/screens/DetectiveSetupScreen.stories.tsx
@@ -37,6 +37,7 @@ const EDIT_DRAFT: Draft = {
ageGroup: 'adult',
gender: 'female',
appearance: '背の低い痩身。読みかけの文庫をいつも外套の右に入れている。',
+ speech: '丁寧語だが素っ気ない。相手を名字で呼ぶ。',
}
/** 探偵を編集する。1人目の値が入っていて、保存が押せる。 */
diff --git a/src/client/screens/DetectiveSetupScreen.tsx b/src/client/screens/DetectiveSetupScreen.tsx
index 52bacb0..63f9000 100644
--- a/src/client/screens/DetectiveSetupScreen.tsx
+++ b/src/client/screens/DetectiveSetupScreen.tsx
@@ -66,6 +66,7 @@ export const emptyDraft = (): Draft => ({
ageGroup: 'unknown',
gender: 'unknown',
appearance: '',
+ speech: '',
})
/**
@@ -91,6 +92,7 @@ export const DetectiveSetupScreen = ({
}: Props) => {
const nameId = useId()
const appearanceId = useId()
+ const speechId = useId()
const [store, setStore] = useState(readStore)
const [draft, setDraft] = useState(initialDraft)
@@ -122,6 +124,7 @@ export const DetectiveSetupScreen = ({
ageGroup: draft.ageGroup,
gender: draft.gender,
appearance: draft.appearance,
+ speech: draft.speech,
}
update(upsertDetective(store, saved))
@@ -306,6 +309,12 @@ export const DetectiveSetupScreen = ({
>
{selected.appearance.length > 0 ? selected.appearance : '容姿は書かれていません。'}
+ {/* 書かれていないときは行ごと出さない。容姿と違って、無いなら黙っていてよい。 */}
+ {selected.speech.length > 0 && (
+
+ {selected.speech}
+
+ )}
{!isPresetDetective(selected.id) && (
+
+ {/*
+ 容姿より短い欄にしてある。口調は例を一つ二つ挙げれば足りて、
+ 長く書けるようにすると人物設定をこちらへ書き始めてしまう。
+ */}
+
{/*
diff --git a/src/client/stories/fixtures.ts b/src/client/stories/fixtures.ts
index b8560e9..b56eb82 100644
--- a/src/client/stories/fixtures.ts
+++ b/src/client/stories/fixtures.ts
@@ -333,6 +333,7 @@ export const DETECTIVES: DetectiveStore = {
ageGroup: 'adult',
gender: 'female',
appearance: '背の低い痩身。読みかけの文庫をいつも外套の右に入れている。',
+ speech: '丁寧語だが素っ気ない。相手を名字で呼ぶ。',
},
{
id: 'tohru',
@@ -340,8 +341,16 @@ export const DETECTIVES: DetectiveStore = {
ageGroup: 'elder',
gender: 'male',
appearance: '白髪を短く刈った長身。杖はつくが足取りは速い。',
+ speech: '',
+ },
+ {
+ id: 'ash',
+ name: '灰かぶり',
+ ageGroup: 'unknown',
+ gender: 'unknown',
+ appearance: '',
+ speech: '',
},
- { id: 'ash', name: '灰かぶり', ageGroup: 'unknown', gender: 'unknown', appearance: '' },
],
activeId: 'akari',
}
diff --git a/src/server/llm/detective.ts b/src/server/llm/detective.ts
index 7e9fc8c..dae9be0 100644
--- a/src/server/llm/detective.ts
+++ b/src/server/llm/detective.ts
@@ -94,7 +94,10 @@ export const buildDetectiveSelfBlock = (detective: Detective): string =>
`あなたの名前は${detective.name}。`,
`年ごろは${AGE_GROUP_LABELS[detective.ageGroup]}、性別は${GENDER_LABELS[detective.gender]}。`,
...(detective.appearance.length > 0 ? [`外見: ${detective.appearance}`] : []),
- 'この人物像に合った口調で話す。',
+ ...(detective.speech.length > 0 ? [`口調: ${detective.speech}`] : []),
+ // 口調が書かれていれば、それに従わせる。書かれていなければ、残りの人物像から
+ // 探らせる——ここを空にすると、どの探偵も同じ調子で喋る。
+ detective.speech.length > 0 ? 'この口調を保って話す。' : 'この人物像に合った口調で話す。',
].join('\n')
export const buildDetectiveBlock = (detective: Detective): string => {
@@ -109,6 +112,9 @@ export const buildDetectiveBlock = (detective: Detective): string => {
`性別: ${GENDER_LABELS[detective.gender]}`,
// 空のまま「外見: 」と書くと、モデルが行を埋めようとして勝手に外見を作る。
...(detective.appearance.length > 0 ? [`外見: ${detective.appearance}`] : []),
+ // NPC からも聞こえているものなので、こちらにも渡す。相手の砕けた口ぶりに
+ // 気を悪くするか合わせるかは、NPC 自身の人物像が決める。
+ ...(detective.speech.length > 0 ? [`口調: ${detective.speech}`] : []),
'',
'呼びかけと態度:',
`- あなたのほうが年上なら、${hint.fromElder} のように呼びかける。`,
From 89baf9461e4b40d2d18c75bb26219a6bcfac880f Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Wed, 9 Sep 2026 16:06:21 +0000
Subject: [PATCH 38/41] fix(detectives): refine Tachibana Sherry description
and speech style
Co-Authored-By: Claude
---
src/client/lib/detective-store.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/client/lib/detective-store.ts b/src/client/lib/detective-store.ts
index c2d38c7..6b29656 100644
--- a/src/client/lib/detective-store.ts
+++ b/src/client/lib/detective-store.ts
@@ -44,8 +44,8 @@ export const TACHIBANA_SHERRY: StoredDetective = {
ageGroup: 'teen',
gender: 'female',
appearance:
- '水色の髪を三つ編みでまとめた団子頭に、オレンジの瞳。右の横髪に金と黒の小さなリボン。紺と青と水色のチェックのシャーロックハットをかぶり、首元はリボンとネクタイ。くすんだ水色のウェストコートを黒いリボンで留め、上から紺のインバネスコート。帽子と同じチェックのスカートは膝が見える丈で、左足だけ黒いタイツ。靴は黒く、白い小さなリボンの飾りがついている。',
- speech: '敬語を使わず、明るく砕けた口ぶり。面白がっているのがそのまま声に出る。',
+ '水色の髪を三つ編みでまとめた団子頭に、オレンジの瞳。右の横髪に金と黒の小さなリボン。紺と青と水色のチェックのシャーロックハットとスカート、くすんだ水色のウェストコートの上に紺のインバネスコート。左足だけ黒いタイツ。どんなときもにこやかで、楽しそうなことには迷わず飛びつく。気になったことはとことん追いかけ、周りを驚かせることもある。',
+ speech: 'いつもですます口調。明るく弾んだ調子で、面白がっているのがそのまま声に出る。',
}
export const PRESET_DETECTIVES: StoredDetective[] = [TACHIBANA_SHERRY]
From e0a06c7cfd58cc401021110af17450f741376c81 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Wed, 9 Sep 2026 17:07:41 +0000
Subject: [PATCH 39/41] chore(db): add remote speaker seed command
Co-Authored-By: Claude
---
package.json | 1 +
1 file changed, 1 insertion(+)
diff --git a/package.json b/package.json
index a1d498f..5846842 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"db:seed:apply": "wrangler d1 execute alibai --local --file=db/seed.sql",
"db:speakers:apply": "wrangler d1 execute alibai --local --file=db/tts-speakers.sql",
"db:seed:apply:remote": "wrangler d1 execute alibai --remote --file=db/seed.sql",
+ "db:speakers:apply:remote": "wrangler d1 execute alibai --remote --file=db/tts-speakers.sql",
"db:author": "bun run db/generate-scenario.ts",
"se:fetch": "bun run scripts/fetch-se.ts",
"deploy": "bun run se:fetch && bun run build && bun run db:migrate:remote && wrangler deploy"
From bdb7ece69c624eab8616631894cef0ad1ea38b23 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Wed, 9 Sep 2026 17:47:14 +0000
Subject: [PATCH 40/41] feat(judge): add configurable judge tuning and
analytics tracking
Add opt-in evidence validation, conversation history, deterministic temperature, and retry behavior with persisted settings and per-turn analytics.
Co-Authored-By: Claude
---
__tests__/client/settings-store.test.ts | 51 +
__tests__/server/judge-tuning.test.ts | 31 +
db/judge-tuning.ts | 71 +
db/migrations/0031_skinny_pet_avengers.sql | 1 +
db/migrations/meta/0031_snapshot.json | 1587 ++++++++++++++++++++
db/migrations/meta/_journal.json | 7 +
db/schema.ts | 8 +
src/client/lib/api.ts | 25 +-
src/client/lib/settings-store.ts | 40 +-
src/client/screens/SettingsScreen.tsx | 37 +
src/server/cache/scenario.ts | 36 +
src/server/db/analytics.ts | 9 +
src/server/game/discovery-state.ts | 22 +
src/server/llm/judge.ts | 26 +-
src/server/routes/sessions.ts | 81 +-
15 files changed, 2016 insertions(+), 16 deletions(-)
create mode 100644 __tests__/server/judge-tuning.test.ts
create mode 100644 db/judge-tuning.ts
create mode 100644 db/migrations/0031_skinny_pet_avengers.sql
create mode 100644 db/migrations/meta/0031_snapshot.json
diff --git a/__tests__/client/settings-store.test.ts b/__tests__/client/settings-store.test.ts
index d579d77..d3ec982 100644
--- a/__tests__/client/settings-store.test.ts
+++ b/__tests__/client/settings-store.test.ts
@@ -24,6 +24,7 @@ describe('parseSettings: モデルの選択', () => {
const parsed = parseSettings({
llm: { actor: { model: 'gpt-5.6-terra' } },
limits: DEFAULT_SETTINGS.limits,
+ judge: DEFAULT_SETTINGS.judge,
})
expect(parsed.settings.llm.actor).toEqual({ model: 'gpt-5.6-terra' })
@@ -153,3 +154,53 @@ describe('parseSettings: 進行の数値', () => {
expect(parsed.settings.limits).toEqual(DEFAULT_SETTINGS.limits)
})
})
+
+describe('parseSettings: 判定の直し', () => {
+ test('保管庫に無ければ全部オフ', () => {
+ const parsed = parseSettings({ llm: {}, limits: DEFAULT_SETTINGS.limits })
+
+ expect(parsed.settings.judge).toEqual(DEFAULT_SETTINGS.judge)
+ // この区画より前の保管庫なので、その場で書き戻させて形を揃える。
+ expect(parsed.migrated).toBe(true)
+ })
+
+ test('入れてある切り替えはそのまま通る', () => {
+ const parsed = parseSettings({
+ llm: {},
+ limits: DEFAULT_SETTINGS.limits,
+ judge: { ...DEFAULT_SETTINGS.judge, checkEvidenceIds: true, retryOnce: true },
+ })
+
+ expect(parsed.settings.judge.checkEvidenceIds).toBe(true)
+ expect(parsed.settings.judge.retryOnce).toBe(true)
+ expect(parsed.settings.judge.fixedTemperature).toBe(false)
+ expect(parsed.migrated).toBe(false)
+ })
+
+ /*
+ 切り替えを増やしたとき、保存済みの端末には新しい鍵が無い。丸ごと捨てると
+ 既に入れてあったぶんまでオフに戻る。
+ */
+ test('鍵が欠けていても、他の切り替えは残す', () => {
+ const parsed = parseSettings({
+ llm: {},
+ limits: DEFAULT_SETTINGS.limits,
+ judge: { checkEvidenceIds: true },
+ })
+
+ expect(parsed.settings.judge.checkEvidenceIds).toBe(true)
+ expect(parsed.settings.judge.retryOnce).toBe(false)
+ expect(parsed.migrated).toBe(true)
+ })
+
+ test('真偽値でない値はオフに落とす', () => {
+ const parsed = parseSettings({
+ llm: {},
+ limits: DEFAULT_SETTINGS.limits,
+ judge: { ...DEFAULT_SETTINGS.judge, checkEvidenceIds: 'はい' },
+ })
+
+ expect(parsed.settings.judge.checkEvidenceIds).toBe(false)
+ expect(parsed.migrated).toBe(true)
+ })
+})
diff --git a/__tests__/server/judge-tuning.test.ts b/__tests__/server/judge-tuning.test.ts
new file mode 100644
index 0000000..c8ffd46
--- /dev/null
+++ b/__tests__/server/judge-tuning.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, test } from 'bun:test'
+import { acceptRevealedEvidenceIds } from '@/server/game/discovery-state'
+
+/*
+ 証拠の突き合わせは、Revelation 側の acceptRevealedRevelationIds と同じ役目を
+ 証拠に付けたもの(__tests__/server/revelations.test.ts と対になる)。
+*/
+
+const scenarioEvidenceIds = ['glass', 'letter', 'key']
+
+describe('acceptRevealedEvidenceIds', () => {
+ test('シナリオに無いIDは採用しない', () => {
+ expect(acceptRevealedEvidenceIds(scenarioEvidenceIds, [], ['glass', 'hallucinated'])).toEqual([
+ 'glass',
+ ])
+ })
+
+ test('すでに見つけている証拠は採用しない', () => {
+ expect(acceptRevealedEvidenceIds(scenarioEvidenceIds, ['glass'], ['glass', 'letter'])).toEqual([
+ 'letter',
+ ])
+ })
+
+ test('同じIDを二度返されても一度だけ採る', () => {
+ expect(acceptRevealedEvidenceIds(scenarioEvidenceIds, [], ['key', 'key'])).toEqual(['key'])
+ })
+
+ test('採るものが無ければ空', () => {
+ expect(acceptRevealedEvidenceIds(scenarioEvidenceIds, ['glass'], ['glass'])).toEqual([])
+ })
+})
diff --git a/db/judge-tuning.ts b/db/judge-tuning.ts
new file mode 100644
index 0000000..f366009
--- /dev/null
+++ b/db/judge-tuning.ts
@@ -0,0 +1,71 @@
+import { z } from 'zod'
+
+/**
+ * 判定役の振る舞いを、一つずつ入れたり切ったりするための語彙。
+ *
+ * 正典はここ1箇所。サーバ・クライアントのどこからでも同じものを読む
+ * (`db/game-mode.ts` や `db/detective.ts` と同じ立ち位置)。
+ *
+ * 四つとも「判定の精度を上げるはずの直し」で、既定は全部オフ——つまり今までの挙動。
+ * 入れた回と切った回を混ぜて遊び、`analytics_turns` に残る記録を後から見比べて
+ * 採否を決めるためのもの。良くなると分かっているなら切り替えは要らないので、
+ * 決着がついたら勝った側を既定に畳んで、この語彙ごと消す。
+ */
+
+export const judgeTuningSchema = z.object({
+ /**
+ * 判定が返した証拠IDを、そのシナリオに実在する未発見の証拠と突き合わせて弾く。
+ *
+ * Revelation 側は最初からこの形(`src/server/game/revelations.ts`)。証拠だけが
+ * 素通しで、実在しないIDでも既に出ているIDでもそのまま記録に入る。
+ */
+ checkEvidenceIds: z.boolean(),
+ /**
+ * 矛盾の指摘を判定するときに、それまでのやり取りも渡す。
+ *
+ * `contradictionPointedOut` は「過去の発言との矛盾を指摘できたか」を訊いているのに、
+ * 判定へ渡しているのはその回のやり取りだけ。突き合わせる相手が無い。
+ */
+ contradictionNeedsHistory: z.boolean(),
+ /**
+ * 判定の温度を 0 に固定する。
+ *
+ * 指定しないと提供元の既定に従うので、同じやり取りでも回ごとに判定が揺れる。
+ */
+ fixedTemperature: z.boolean(),
+ /** 判定の呼び出しが落ちたとき、一度だけやり直す。 */
+ retryOnce: z.boolean(),
+})
+
+export type JudgeTuning = z.infer
+
+/** 画面に並べる順。増減したときに拾い漏らさないよう、鍵の一覧もここで持つ。 */
+export const JUDGE_TUNING_KEYS = [
+ 'checkEvidenceIds',
+ 'contradictionNeedsHistory',
+ 'fixedTemperature',
+ 'retryOnce',
+] as const satisfies readonly (keyof JudgeTuning)[]
+
+/** 何も入れていない状態。今までの挙動と同じ。 */
+export const DEFAULT_JUDGE_TUNING: JudgeTuning = {
+ checkEvidenceIds: false,
+ contradictionNeedsHistory: false,
+ fixedTemperature: false,
+ retryOnce: false,
+}
+
+/** 画面に出す名前。内部の名前をそのまま見せない。 */
+export const JUDGE_TUNING_LABELS: Record = {
+ checkEvidenceIds: '証拠のIDを確かめる',
+ contradictionNeedsHistory: '矛盾の判定に前のやり取りを渡す',
+ fixedTemperature: '判定の揺れを抑える',
+ retryOnce: '判定が落ちたらやり直す',
+}
+
+export const JUDGE_TUNING_NOTES: Record = {
+ checkEvidenceIds: '実在しない証拠や、すでに見つけた証拠を弾く',
+ contradictionNeedsHistory: '前の発言と食い違っているかを、実際に読み比べて決める',
+ fixedTemperature: '同じやり取りなら毎回同じ判定になるようにする',
+ retryOnce: '一度だけやり直す。落ちた回は発見が丸ごと消えるため',
+}
diff --git a/db/migrations/0031_skinny_pet_avengers.sql b/db/migrations/0031_skinny_pet_avengers.sql
new file mode 100644
index 0000000..b0cd038
--- /dev/null
+++ b/db/migrations/0031_skinny_pet_avengers.sql
@@ -0,0 +1 @@
+ALTER TABLE `analytics_turns` ADD `judge_tuning` text;
\ No newline at end of file
diff --git a/db/migrations/meta/0031_snapshot.json b/db/migrations/meta/0031_snapshot.json
new file mode 100644
index 0000000..eee51bb
--- /dev/null
+++ b/db/migrations/meta/0031_snapshot.json
@@ -0,0 +1,1587 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "1c206771-7d75-4b20-9e11-c0f901e0d636",
+ "prevId": "6e6c8bca-ef92-4a53-8cbb-793a4c0c0a9c",
+ "tables": {
+ "analytics_sessions": {
+ "name": "analytics_sessions",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "detective": {
+ "name": "detective",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "max_turns": {
+ "name": "max_turns",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "questions_per_turn": {
+ "name": "questions_per_turn",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exchanges_per_topic": {
+ "name": "exchanges_per_topic",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "culprit_character_id": {
+ "name": "culprit_character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "culprit_correct": {
+ "name": "culprit_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method_correct": {
+ "name": "method_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive_correct": {
+ "name": "motive_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reasoning": {
+ "name": "reasoning",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive": {
+ "name": "motive",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method_comment": {
+ "name": "method_comment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive_comment": {
+ "name": "motive_comment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "solved_seconds": {
+ "name": "solved_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "question_count": {
+ "name": "question_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "evidence_found": {
+ "name": "evidence_found",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "evidence_total": {
+ "name": "evidence_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contradiction_count": {
+ "name": "contradiction_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "accuracy_percent": {
+ "name": "accuracy_percent",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "analytics_sessions_scenario_id_idx": {
+ "name": "analytics_sessions_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ },
+ "analytics_sessions_started_at_idx": {
+ "name": "analytics_sessions_started_at_idx",
+ "columns": [
+ "started_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "analytics_turns": {
+ "name": "analytics_turns",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_kind": {
+ "name": "subject_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "turn_index": {
+ "name": "turn_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "question_count": {
+ "name": "question_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "topic": {
+ "name": "topic",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exchanges": {
+ "name": "exchanges",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revealed_evidence_ids": {
+ "name": "revealed_evidence_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revealed_revelation_ids": {
+ "name": "revealed_revelation_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contradiction_pointed_out": {
+ "name": "contradiction_pointed_out",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "npc_lied": {
+ "name": "npc_lied",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "judge_tuning": {
+ "name": "judge_tuning",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "analytics_turns_session_id_idx": {
+ "name": "analytics_turns_session_id_idx",
+ "columns": [
+ "session_id"
+ ],
+ "isUnique": false
+ },
+ "analytics_turns_scenario_id_idx": {
+ "name": "analytics_turns_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ },
+ "analytics_turns_created_at_idx": {
+ "name": "analytics_turns_created_at_idx",
+ "columns": [
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "characters": {
+ "name": "characters",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "short_name": {
+ "name": "short_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "age_group": {
+ "name": "age_group",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ },
+ "gender": {
+ "name": "gender",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ },
+ "voice_seed": {
+ "name": "voice_seed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "voice_caption": {
+ "name": "voice_caption",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "public_introduction": {
+ "name": "public_introduction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "personality": {
+ "name": "personality",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "knowledge": {
+ "name": "knowledge",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "secrets": {
+ "name": "secrets",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "goals": {
+ "name": "goals",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lies": {
+ "name": "lies",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lie_refs": {
+ "name": "lie_refs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "memories": {
+ "name": "memories",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "characters_scenario_id_idx": {
+ "name": "characters_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "characters_scenario_id_scenarios_id_fk": {
+ "name": "characters_scenario_id_scenarios_id_fk",
+ "tableFrom": "characters",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "discoveries": {
+ "name": "discoveries",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "evidence_id": {
+ "name": "evidence_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "discovered_at": {
+ "name": "discovered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "discoveries_evidence_id_idx": {
+ "name": "discoveries_evidence_id_idx",
+ "columns": [
+ "evidence_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "discoveries_session_id_play_sessions_id_fk": {
+ "name": "discoveries_session_id_play_sessions_id_fk",
+ "tableFrom": "discoveries",
+ "tableTo": "play_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "discoveries_evidence_id_evidences_id_fk": {
+ "name": "discoveries_evidence_id_evidences_id_fk",
+ "tableFrom": "discoveries",
+ "tableTo": "evidences",
+ "columnsFrom": [
+ "evidence_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "discoveries_session_id_evidence_id_pk": {
+ "columns": [
+ "session_id",
+ "evidence_id"
+ ],
+ "name": "discoveries_session_id_evidence_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "evidences": {
+ "name": "evidences",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reveal_condition": {
+ "name": "reveal_condition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "supports": {
+ "name": "supports",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "contradicts": {
+ "name": "contradicts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "reveals_death_time": {
+ "name": "reveals_death_time",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "evidences_scenario_id_idx": {
+ "name": "evidences_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "evidences_scenario_id_scenarios_id_fk": {
+ "name": "evidences_scenario_id_scenarios_id_fk",
+ "tableFrom": "evidences",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "llm_usages": {
+ "name": "llm_usages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "input_tokens": {
+ "name": "input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "output_tokens": {
+ "name": "output_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "cached_input_tokens": {
+ "name": "cached_input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "cache_creation_input_tokens": {
+ "name": "cache_creation_input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "reasoning_tokens": {
+ "name": "reasoning_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "llm_usages_created_at_idx": {
+ "name": "llm_usages_created_at_idx",
+ "columns": [
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "llm_usages_session_id_idx": {
+ "name": "llm_usages_session_id_idx",
+ "columns": [
+ "session_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "messages": {
+ "name": "messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "character_id": {
+ "name": "character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "messages_session_id_idx": {
+ "name": "messages_session_id_idx",
+ "columns": [
+ "session_id"
+ ],
+ "isUnique": false
+ },
+ "messages_character_id_idx": {
+ "name": "messages_character_id_idx",
+ "columns": [
+ "character_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "messages_session_id_play_sessions_id_fk": {
+ "name": "messages_session_id_play_sessions_id_fk",
+ "tableFrom": "messages",
+ "tableTo": "play_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "play_sessions": {
+ "name": "play_sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "detective": {
+ "name": "detective",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "play_sessions_scenario_id_idx": {
+ "name": "play_sessions_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ },
+ "play_sessions_started_at_idx": {
+ "name": "play_sessions_started_at_idx",
+ "columns": [
+ "started_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "play_sessions_scenario_id_scenarios_id_fk": {
+ "name": "play_sessions_scenario_id_scenarios_id_fk",
+ "tableFrom": "play_sessions",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "reports": {
+ "name": "reports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reported_at": {
+ "name": "reported_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "reports_scenario_id_idx": {
+ "name": "reports_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "reports_scenario_id_scenarios_id_fk": {
+ "name": "reports_scenario_id_scenarios_id_fk",
+ "tableFrom": "reports",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "results": {
+ "name": "results",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "solved_seconds": {
+ "name": "solved_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "question_count": {
+ "name": "question_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "evidence_found": {
+ "name": "evidence_found",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "contradiction_count": {
+ "name": "contradiction_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accuracy_percent": {
+ "name": "accuracy_percent",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "method_correct": {
+ "name": "method_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive_correct": {
+ "name": "motive_correct",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deduction": {
+ "name": "deduction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "results_session_id_play_sessions_id_fk": {
+ "name": "results_session_id_play_sessions_id_fk",
+ "tableFrom": "results",
+ "tableTo": "play_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "revelation_discoveries": {
+ "name": "revelation_discoveries",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revelation_id": {
+ "name": "revelation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "discovered_at": {
+ "name": "discovered_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "revelation_discoveries_revelation_id_idx": {
+ "name": "revelation_discoveries_revelation_id_idx",
+ "columns": [
+ "revelation_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "revelation_discoveries_session_id_play_sessions_id_fk": {
+ "name": "revelation_discoveries_session_id_play_sessions_id_fk",
+ "tableFrom": "revelation_discoveries",
+ "tableTo": "play_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "revelation_discoveries_revelation_id_revelations_id_fk": {
+ "name": "revelation_discoveries_revelation_id_revelations_id_fk",
+ "tableFrom": "revelation_discoveries",
+ "tableTo": "revelations",
+ "columnsFrom": [
+ "revelation_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "revelation_discoveries_session_id_revelation_id_pk": {
+ "columns": [
+ "session_id",
+ "revelation_id"
+ ],
+ "name": "revelation_discoveries_session_id_revelation_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "revelations": {
+ "name": "revelations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_type": {
+ "name": "subject_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "related_facts": {
+ "name": "related_facts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "revelations_scenario_id_idx": {
+ "name": "revelations_scenario_id_idx",
+ "columns": [
+ "scenario_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "revelations_scenario_id_scenarios_id_fk": {
+ "name": "revelations_scenario_id_scenarios_id_fk",
+ "tableFrom": "revelations",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "scenario_truths": {
+ "name": "scenario_truths",
+ "columns": {
+ "scenario_id": {
+ "name": "scenario_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "culprit_character_id": {
+ "name": "culprit_character_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "truth": {
+ "name": "truth",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "motive": {
+ "name": "motive",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "timeline": {
+ "name": "timeline",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "timeline_events": {
+ "name": "timeline_events",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "victim_cause_of_death": {
+ "name": "victim_cause_of_death",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_findings": {
+ "name": "victim_findings",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "place_findings": {
+ "name": "place_findings",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "secret_keywords": {
+ "name": "secret_keywords",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "scenario_truths_scenario_id_scenarios_id_fk": {
+ "name": "scenario_truths_scenario_id_scenarios_id_fk",
+ "tableFrom": "scenario_truths",
+ "tableTo": "scenarios",
+ "columnsFrom": [
+ "scenario_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "scenarios": {
+ "name": "scenarios",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "synopsis": {
+ "name": "synopsis",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "briefing": {
+ "name": "briefing",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "floor_plan": {
+ "name": "floor_plan",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "time_start": {
+ "name": "time_start",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "time_end": {
+ "name": "time_end",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_name": {
+ "name": "victim_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_introduction": {
+ "name": "victim_introduction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_found_at": {
+ "name": "victim_found_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_found_in": {
+ "name": "victim_found_in",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "victim_investigable": {
+ "name": "victim_investigable",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "victim_estimated_death_at": {
+ "name": "victim_estimated_death_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "places": {
+ "name": "places",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_published": {
+ "name": "is_published",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "difficulty": {
+ "name": "difficulty",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 3
+ },
+ "estimated_minutes": {
+ "name": "estimated_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 10
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tts_speakers": {
+ "name": "tts_speakers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "age_group": {
+ "name": "age_group",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ },
+ "gender": {
+ "name": "gender",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json
index e085775..bb85f5f 100644
--- a/db/migrations/meta/_journal.json
+++ b/db/migrations/meta/_journal.json
@@ -211,6 +211,13 @@
"when": 1788940795359,
"tag": "0030_messages-drop-character-fk",
"breakpoints": true
+ },
+ {
+ "idx": 31,
+ "version": "6",
+ "when": 1788975525922,
+ "tag": "0031_skinny_pet_avengers",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/db/schema.ts b/db/schema.ts
index 3692dba..a18cc29 100644
--- a/db/schema.ts
+++ b/db/schema.ts
@@ -2,6 +2,7 @@ import { sql } from 'drizzle-orm'
import { index, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import type { Detective } from './detective'
import type { FloorPlanInput } from './floor-plan'
+import type { JudgeTuning } from './judge-tuning'
import type { AgeGroup, Gender } from './person'
import type { InvestigablePlace, PlaceFindings } from './place'
import type { ScenarioEvidenceSource, ScenarioRevelationSource } from './scenario-definition'
@@ -625,6 +626,13 @@ export const analyticsTurns = sqliteTable(
revealedRevelationIds: text('revealed_revelation_ids', { mode: 'json' }).$type(),
contradictionPointedOut: integer('contradiction_pointed_out', { mode: 'boolean' }),
npcLied: integer('npc_lied', { mode: 'boolean' }),
+ /**
+ * その回に入れてあった判定の直し(`db/judge-tuning.ts`)。判定が落ちた回は無い。
+ *
+ * 列を四つに割らず1つのJSONで持つのは、切り替え自体が試すためのもので、
+ * 増えたり減ったり、決着がついたら丸ごと消える見込みのため。
+ */
+ judgeTuning: text('judge_tuning', { mode: 'json' }).$type(),
createdAt: createdTimestamp('created_at'),
},
(table) => [
diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts
index 10c6a82..6a0bf14 100644
--- a/src/client/lib/api.ts
+++ b/src/client/lib/api.ts
@@ -25,8 +25,14 @@ import {
画面から引数で渡して回すと、設定と関係のない関数にまで引数が生えるので、
送る直前にここで読む。サーバはこの値を信用せず、許可リストと上限で検め直す。
*/
-import { loadSettings, settingsLimits, toLlmOverrides } from '@/client/lib/settings-store'
+import {
+ loadSettings,
+ settingsLimits,
+ toJudgeTuning,
+ toLlmOverrides,
+} from '@/client/lib/settings-store'
import { parseSseStream } from '@/client/lib/sse'
+import type { JudgeTuning } from '~/db/judge-tuning'
/**
* HTTPエラーとレスポンス形状の不一致を1つの型にまとめる。
@@ -101,6 +107,19 @@ export const fetchScenarios = (): Promise =>
export const fetchScenarioDetail = (scenarioId: string): Promise =>
requestJson(`/api/scenarios/${scenarioId}`, scenarioDetailSchema, undefined)
+/**
+ * リクエストに載せるプレイヤー側の設定。
+ *
+ * モデルの指名と判定の切り替えは、どちらも「その回に何を使ったか」なので一緒に載せる。
+ * 保管庫を毎回読み直すのは、設定画面で切り替えた直後の話題から効かせるため
+ * (読み込み時に一度だけ読むと、遊びながら入れ替えて比べるという使い方ができない)。
+ */
+const playerSettings = (): { llm: ReturnType; judge: JudgeTuning } => {
+ const settings = loadSettings()
+
+ return { llm: toLlmOverrides(settings), judge: toJudgeTuning(settings) }
+}
+
/**
* セッション開始。ここで計時が始まるので、事件の記録を読み終えてから呼ぶ。
* 探偵は名乗らずに始めることもできるので undefined を許す。
@@ -149,6 +168,8 @@ export const submitAccusation = (params: {
requestJson(
`/api/sessions/${params.sessionId}/accuse`,
accuseResultSchema,
+ // 判定の直しは載せない。推理の採点は別の呼び出し(`deduction.ts`)で、
+ // 話題ごとの判定とは通る道が違う。
jsonInit('POST', { ...params, llm: toLlmOverrides(loadSettings()) }),
)
@@ -187,7 +208,7 @@ export const askTopic = async (
): Promise => {
const res = await fetch(
`/api/sessions/${params.sessionId}/ask`,
- jsonInit('POST', { ...params, llm: toLlmOverrides(loadSettings()) }),
+ jsonInit('POST', { ...params, ...playerSettings() }),
)
if (!res.ok) {
diff --git a/src/client/lib/settings-store.ts b/src/client/lib/settings-store.ts
index 2df7840..17fb486 100644
--- a/src/client/lib/settings-store.ts
+++ b/src/client/lib/settings-store.ts
@@ -1,5 +1,6 @@
import { z } from 'zod'
import { clampLimits, EXCHANGES_PER_TOPIC, type SessionLimits } from '@/shared/turns'
+import { DEFAULT_JUDGE_TUNING, type JudgeTuning, judgeTuningSchema } from '~/db/judge-tuning'
import { type SettableLlmRole, settableLlmRoleSchema } from '~/db/llm-catalog'
/**
@@ -30,6 +31,7 @@ const settingsSchema = z.object({
questionsPerTurn: z.int().positive(),
exchangesPerTopic: z.int().positive(),
}),
+ judge: judgeTuningSchema,
})
export type Settings = z.infer
@@ -54,12 +56,18 @@ export const DEFAULT_SETTINGS: Settings = {
questionsPerTurn: 2,
exchangesPerTopic: EXCHANGES_PER_TOPIC,
},
+ /*
+ * 判定の直しは既定で全部オフ。今までの挙動のまま始まり、入れたときだけ変わる。
+ * 入れた回と切った回を見比べたいので、既定を良いほうに寄せない。
+ */
+ judge: DEFAULT_JUDGE_TUNING,
}
/** 器だけを見るための緩いスキーマ。中身の妥当性は要素ごとに判断する。 */
const looseSettingsSchema = z.object({
llm: z.record(z.string().nonempty(), z.unknown()).optional(),
limits: z.record(z.string().nonempty(), z.unknown()).optional(),
+ judge: z.record(z.string().nonempty(), z.unknown()).optional(),
})
/**
@@ -124,7 +132,7 @@ export const parseSettings = (raw: unknown): ParsedSettings => {
DEFAULT_SETTINGS.limits,
)
- const settings: Settings = { llm, limits }
+ const settings: Settings = { llm, limits, judge: recoverJudge(loose.data.judge) }
// 現行スキーマで読めて、かつ切り詰めも取りこぼしも起きていなければ、書き戻す必要はない。
return { settings, migrated: !current.success || !isSameSettings(current.data, settings) }
@@ -133,6 +141,27 @@ export const parseSettings = (raw: unknown): ParsedSettings => {
const numberOf = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined
+const judgeFlag = (raw: Record | undefined, key: keyof JudgeTuning): boolean => {
+ const parsed = z.boolean().safeParse(raw?.[key])
+
+ return parsed.success ? parsed.data : DEFAULT_JUDGE_TUNING[key]
+}
+
+/**
+ * 判定の直しの読み替え。鍵ごとに見て、真偽値でないものは既定(オフ)に落とす。
+ *
+ * 器ごと捨てないのは他の項目と同じ理由だが、ここはもう一つある——切り替えを増やしたとき、
+ * 保存済みの端末には新しい鍵が無い。丸ごと捨てると、既に入れてあったぶんまでオフに戻る。
+ *
+ * 鍵を並べて書いてあるのは、増やしたときにここを直し忘れると型が通らないため。
+ */
+const recoverJudge = (raw: Record | undefined): JudgeTuning => ({
+ checkEvidenceIds: judgeFlag(raw, 'checkEvidenceIds'),
+ contradictionNeedsHistory: judgeFlag(raw, 'contradictionNeedsHistory'),
+ fixedTemperature: judgeFlag(raw, 'fixedTemperature'),
+ retryOnce: judgeFlag(raw, 'retryOnce'),
+})
+
const isSameSettings = (a: Settings, b: Settings): boolean =>
JSON.stringify(a) === JSON.stringify(b)
@@ -142,6 +171,15 @@ export const toLlmOverrides = (settings: Settings): Partial settings.limits
+/**
+ * サーバへ載せる形。limits と同じで、常に四つとも載る。
+ *
+ * llm のような「未選択」を作らないのは、切り替えの既定がサーバ側にも同じ形で
+ * 置いてあるため(`DEFAULT_JUDGE_TUNING`)。載せないと既定に落ちるだけなので、
+ * 「送っていない」と「オフを送った」を区別する意味が無い。
+ */
+export const toJudgeTuning = (settings: Settings): JudgeTuning => settings.judge
+
/**
* localStorage は「使えない環境がある」前提で触る。
* 読めなければ既定から始めればよく、設定の都合でプレイが始まらないほうが困る。
diff --git a/src/client/screens/SettingsScreen.tsx b/src/client/screens/SettingsScreen.tsx
index af63f49..fc1f260 100644
--- a/src/client/screens/SettingsScreen.tsx
+++ b/src/client/screens/SettingsScreen.tsx
@@ -19,6 +19,12 @@ import {
import { loadSoundSetting, type SoundSetting, saveSoundSetting } from '@/client/lib/sound'
import { loadVoiceSetting, saveVoiceSetting, type VoiceSetting } from '@/client/lib/voice'
import { clampLimits, modelCallsPerTopic } from '@/shared/turns'
+import {
+ JUDGE_TUNING_KEYS,
+ JUDGE_TUNING_LABELS,
+ JUDGE_TUNING_NOTES,
+ type JudgeTuning,
+} from '~/db/judge-tuning'
/**
* このブラウザで使うモデルと、進行の数値を選ぶ画面。
@@ -114,6 +120,10 @@ export const SettingsScreen = ({
update({ ...settings, llm })
}
+ const updateJudge = (key: keyof JudgeTuning, enabled: boolean) => {
+ update({ ...settings, judge: { ...settings.judge, [key]: enabled } })
+ }
+
const chooseBriefing = (next: BriefingMode) => {
saveBriefingMode(next)
setBriefing(next)
@@ -241,6 +251,28 @@ export const SettingsScreen = ({
>
)}
+
+ 判定の調整
+
+ 判定役の振る舞いを一つずつ試せます。既定はすべてオフで、入れたものだけ判定の出方が変わります。
+
+
+
+ {JUDGE_TUNING_KEYS.map((key) => (
+ updateJudge(key, next === 'on')}
+ />
+ ))}
+
+
+
{/*
事件の記録の見せ方。演出の好みなので、記録の画面に切り替えを置くと毎回そこで一拍止まる。
物語の外にあるこの画面へ寄せて、始める前に一度だけ決めてもらう。
@@ -323,6 +355,11 @@ const VOICE_CHOICES: readonly Choice[] = [
{ key: 'off', label: '読み上げない' },
]
+const JUDGE_TUNING_CHOICES: readonly Choice<'on' | 'off'>[] = [
+ { key: 'on', label: '入れる' },
+ { key: 'off', label: '入れない' },
+]
+
/**
* 二択の行。難易度の四択と同じ組みで、塗りつぶさずに選んだものだけ罫線と字を起こす。
*
diff --git a/src/server/cache/scenario.ts b/src/server/cache/scenario.ts
index fee3285..ff1a7ee 100644
--- a/src/server/cache/scenario.ts
+++ b/src/server/cache/scenario.ts
@@ -1,4 +1,5 @@
import { count, eq } from 'drizzle-orm'
+import { z } from 'zod'
import type { Db } from '@/server/db/client'
import type { HintItem, HintSource } from '@/server/game/hints'
import {
@@ -33,6 +34,7 @@ const characterKey = (characterId: string) => `character:v3:${characterId}`
*/
const judgeRubricKey = (scenarioId: string) => `judge-rubric:v3:${scenarioId}`
const judgeRevelationsKey = (scenarioId: string) => `judge-revelations:${scenarioId}`
+const evidenceIdsKey = (scenarioId: string) => `evidence-ids:${scenarioId}`
// 版を付けてある。数える相手の並びが変わっても、1時間の TTL を待たずに切り替わるように。
const hintSubjectsKey = (scenarioId: string) => `hint-subjects:v2:${scenarioId}`
const SCENARIO_LIST_KEY = 'scenarios:published'
@@ -236,6 +238,39 @@ ${evidenceList}`
return rubric
}
+/**
+ * そのシナリオに実在する証拠のID。判定が返したIDを突き合わせるためだけに使う。
+ *
+ * 判定ルールと同じ行から作れるが、あちらは1本の文字列なので読み返せない。
+ * 条件文を含まないぶん短く、真相も混ざらないので、別の鍵で持つ。
+ */
+export const loadEvidenceIds = async (
+ kv: KVNamespace,
+ db: Db,
+ scenarioId: string,
+): Promise => {
+ const cached = await kv.get(evidenceIdsKey(scenarioId), 'json')
+
+ const parsed = z.array(z.string().nonempty()).safeParse(cached)
+
+ if (parsed.success) {
+ return parsed.data
+ }
+
+ const rows = await db
+ .select({ id: evidences.id })
+ .from(evidences)
+ .where(eq(evidences.scenarioId, scenarioId))
+
+ const ids = rows.map((row) => row.id)
+
+ await kv.put(evidenceIdsKey(scenarioId), JSON.stringify(ids), {
+ expirationTtl: JUDGE_RUBRIC_TTL_SECONDS,
+ })
+
+ return ids
+}
+
const loadRevelationRules = async (
kv: KVNamespace,
db: Db,
@@ -386,6 +421,7 @@ export const invalidateScenario = async (
kv.delete(SCENARIO_LIST_KEY),
kv.delete(judgeRubricKey(scenarioId)),
kv.delete(judgeRevelationsKey(scenarioId)),
+ kv.delete(evidenceIdsKey(scenarioId)),
kv.delete(hintSubjectsKey(scenarioId)),
...characterIds.map((id) => kv.delete(characterKey(id))),
])
diff --git a/src/server/db/analytics.ts b/src/server/db/analytics.ts
index 313643e..56c5951 100644
--- a/src/server/db/analytics.ts
+++ b/src/server/db/analytics.ts
@@ -3,6 +3,7 @@ import type { Db } from '@/server/db/client'
import type { Score } from '@/server/game/scoring'
import type { SessionLimits } from '@/shared/turns'
import type { Detective } from '~/db/detective'
+import type { JudgeTuning } from '~/db/judge-tuning'
import { analyticsSessions, analyticsTurns, type LoggedExchange } from '~/db/schema'
/**
@@ -111,6 +112,13 @@ export type TurnJudgementRecord = {
revealedRevelationIds: string[]
contradictionPointedOut: boolean
npcLied: boolean
+ /**
+ * その回に入れてあった判定の直し。
+ *
+ * これが無いと、この表を見比べても「判定が良くなった」のか「入れた回が
+ * たまたま易しい話題だった」のかが分けられない。
+ */
+ tuning: JudgeTuning
}
/**
@@ -148,4 +156,5 @@ export const recordTurn = (
revealedRevelationIds: input.judgement?.revealedRevelationIds,
contradictionPointedOut: input.judgement?.contradictionPointedOut,
npcLied: input.judgement?.npcLied,
+ judgeTuning: input.judgement?.tuning,
})
diff --git a/src/server/game/discovery-state.ts b/src/server/game/discovery-state.ts
index 9a83347..513d3ce 100644
--- a/src/server/game/discovery-state.ts
+++ b/src/server/game/discovery-state.ts
@@ -1,3 +1,25 @@
+/**
+ * 判定が返した証拠IDのうち、実際に採用してよいものだけを取る。
+ *
+ * Revelation 側の `acceptRevealedRevelationIds` と同じ役目。あちらは最初からこの形で、
+ * 証拠だけが素通しだった——実在しないIDも、すでに見つけている証拠も、そのまま
+ * 発見として記録に入っていた。
+ *
+ * すでに見つけた証拠を落とすのは、記録の重複を防ぐためだけではない。残したままだと
+ * 「この回に何が新しく出たか」が回ごとの記録から読めなくなり、判定の良し悪しを
+ * 後から見比べられない。
+ */
+export const acceptRevealedEvidenceIds = (
+ scenarioEvidenceIds: string[],
+ discoveredEvidenceIds: string[],
+ judgedIds: string[],
+): string[] => {
+ const known = new Set(scenarioEvidenceIds)
+ const found = new Set(discoveredEvidenceIds)
+
+ return [...new Set(judgedIds.filter((id) => known.has(id) && !found.has(id)))]
+}
+
export type DiscoveryState = {
evidenceIds: string[]
revelationIds: string[]
diff --git a/src/server/llm/judge.ts b/src/server/llm/judge.ts
index dbd671d..d2b0550 100644
--- a/src/server/llm/judge.ts
+++ b/src/server/llm/judge.ts
@@ -2,6 +2,7 @@ import { generateObject, type LanguageModelUsage, type ProviderMetadata } from '
import { z } from 'zod'
import type { Env } from '@/server/env'
import { resolveModel } from '@/server/llm/provider'
+import type { JudgeTuning } from '~/db/judge-tuning'
/**
* 判定結果は必ずこの形に収まる。構造化出力なのでパース失敗を考えなくてよい。
@@ -30,6 +31,8 @@ export type JudgeInput = {
rubric: string
/** 直近のやり取り(プレイヤーの質問とNPCの返答) */
exchange: string
+ /** その回に入れてある判定の直し。`db/judge-tuning.ts` を参照。 */
+ tuning: JudgeTuning
}
/**
@@ -52,11 +55,27 @@ export type JudgeResult = {
* Actorと分けているのは、1つのモデルに「演じつつ進行も管理して」と頼むと
* 演技が崩れるか判定が曖昧になるため。役者は役者に徹してもらう。
*/
-export const judgeTurn = async ({
+export const judgeTurn = async (input: JudgeInput): Promise => {
+ if (!input.tuning.retryOnce) {
+ return callJudge(input)
+ }
+
+ /*
+ やり直すのは、判定が落ちた回の発見が丸ごと消えるため。呼び出し側は握り潰して
+ 先へ進む作りなので、プレイヤーには「訊いたのに何も出なかった」としか見えない。
+
+ AI SDK 自体も通信の失敗は数回やり直す。ここで拾えるのはその先——構造化出力が
+ こちらの形に収まらずに投げられた場合で、同じ入力でも二度目に通ることがある。
+ */
+ return callJudge(input).catch(() => callJudge(input))
+}
+
+const callJudge = async ({
env,
modelId,
rubric,
exchange,
+ tuning,
}: JudgeInput): Promise => {
// 判定ルールは messages ではなく system オプションに置く。
// AI SDK は messages 側の system ロールを警告する(プレイヤー由来の文字列が
@@ -67,6 +86,11 @@ export const judgeTurn = async ({
schema: judgementSchema,
system: rubric,
messages: [{ role: 'user', content: exchange }],
+ /*
+ 温度を渡さなければ提供元の既定に従う。判定は同じやり取りなら同じ答えが出て
+ ほしい仕事なので、切り替えを入れた回だけ 0 に固定する。
+ */
+ temperature: tuning.fixedTemperature ? 0 : undefined,
})
return {
diff --git a/src/server/routes/sessions.ts b/src/server/routes/sessions.ts
index c909f3d..91dc685 100644
--- a/src/server/routes/sessions.ts
+++ b/src/server/routes/sessions.ts
@@ -7,6 +7,7 @@ import { z } from 'zod'
import {
loadCharacterSheet,
loadEligibleRevelationCandidates,
+ loadEvidenceIds,
loadHintSubjects,
loadJudgeRubric,
} from '@/server/cache/scenario'
@@ -26,6 +27,7 @@ import {
clashOf,
deathEstimateOf,
} from '@/server/game/alibi'
+import { acceptRevealedEvidenceIds } from '@/server/game/discovery-state'
import { buildPlaceSheet, buildVictimSheet, type DiscoveredIds } from '@/server/game/examination'
import { remainingHints } from '@/server/game/hints'
import { acceptRevealedRevelationIds, type RevelationSourceType } from '@/server/game/revelations'
@@ -59,6 +61,7 @@ import {
// クライアントの選択肢とAPIが受ける値が静かにずれる。
import { type Detective, detectiveSchema } from '~/db/detective'
import { type GameMode, gameModeOf, gameModeSchema } from '~/db/game-mode'
+import { DEFAULT_JUDGE_TUNING, judgeTuningSchema } from '~/db/judge-tuning'
import { llmOverridesSchema } from '~/db/llm-catalog'
import { findingsOfPlace, parseInvestigablePlaces } from '~/db/place'
import { placeIdSchema, VICTIM_ID } from '~/db/scenario-definition'
@@ -183,6 +186,23 @@ type Subject = { kind: 'character' | 'victim' | 'place'; name: string; sheet: st
const sourceTypeOf = (kind: Subject['kind']): RevelationSourceType =>
kind === 'place' ? 'location' : kind
+/**
+ * Actor 用の履歴を、判定役に読ませる会話へ書き出す。
+ *
+ * user が探偵、assistant がNPC。文字列で持っていない要素は落とす——この履歴は
+ * こちらが文字列だけを積んで作っているので、当てはまるのは将来ここに別の形を
+ * 混ぜたときだけで、そのときは読めるところまでを渡すのが安全側になる。
+ */
+const transcriptOf = (history: ModelMessage[]): string =>
+ history
+ .map((message) =>
+ typeof message.content === 'string'
+ ? `${message.role === 'user' ? '探偵' : 'NPC'}: ${message.content}`
+ : '',
+ )
+ .filter((line) => line !== '')
+ .join('\n')
+
const loadCharacterSubject = async (
kv: KVNamespace,
db: Db,
@@ -727,6 +747,13 @@ const askSchema = z.object({
* 残っているだけのプレイヤーを、事件の途中で締め出すことになるため。
*/
llm: llmOverridesSchema.optional(),
+ /**
+ * その回に入れてある判定の直し(`db/judge-tuning.ts`)。
+ *
+ * 送られてこなければ全部オフ、つまり今までの挙動。llm と同じで、知らない形でも
+ * 400 にはせず既定へ落とす。
+ */
+ judge: judgeTuningSchema.optional(),
})
/**
@@ -889,6 +916,12 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
judge: askInput.llm?.judge,
})
+ /*
+ 判定の直しも同じで、この回のぶんをここで一度決める。送られてこなければ全部オフ
+ ——この機能より前のクライアントや、設定を触っていない端末がそこへ落ちる。
+ */
+ const tuning = askInput.judge === undefined ? DEFAULT_JUDGE_TUNING : askInput.judge
+
/*
レート制限は「このリクエストが実際に走らせるモデル呼び出しの数」で消費する。
1リクエスト=1消費にすると、往復数を増やしたプレイヤーだけが同じ予算で
@@ -1207,7 +1240,7 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
// judgementイベントを送らずにdoneへ進むだけにする(500にしない)。
try {
const current = await session.snapshot()
- const [rubric, revelationCandidates] = await Promise.all([
+ const [rubric, revelationCandidates, scenarioEvidenceIds] = await Promise.all([
loadJudgeRubric(c.env.SCENARIO_CACHE, db, scenarioId),
loadEligibleRevelationCandidates(c.env.SCENARIO_CACHE, db, scenarioId, {
// 場所は `location`。作者が `type: location` で書いた出どころと同じ道に乗る。
@@ -1215,6 +1248,10 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
discoveredEvidenceIds: current.discoveredEvidenceIds,
discoveredRevelationIds: current.discoveredRevelationIds,
}),
+ // 突き合わせを入れていない回は引かない。KVへの読みが1回増えるだけの回になる。
+ tuning.checkEvidenceIds
+ ? loadEvidenceIds(c.env.SCENARIO_CACHE, db, scenarioId)
+ : Promise.resolve([]),
])
const candidateBlock =
revelationCandidates.length === 0
@@ -1228,45 +1265,65 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
const transcript = collected.exchanges
.map((entry) => `探偵: ${entry.question}\nNPC: ${entry.answer}`)
.join('\n\n')
- const exchange = `プレイヤーが指定した話題: ${askInput.topic}\n\n${transcript}\n\n今回判定可能なRevelation:\n${candidateBlock}`
- const judged = await judgeTurn({ env, modelId: choices.judge, rubric, exchange })
+ /*
+ 矛盾の判定には、この相手とのそれまでのやり取りが要る。「過去の発言との矛盾を
+ 指摘できたか」を訊いておきながら、渡しているのが今回のぶんだけでは、
+ 突き合わせる相手がない。
+
+ 渡すのはこの相手との分だけ(`history` が既にそう絞られている)。他の人物との
+ 会話まで混ぜると、別人が別のことを言っただけの食い違いを矛盾と読みはじめる。
+
+ 中身を見ずに `transcriptOf` へ通してから空かどうかを見るのは、DO 越しの
+ 戻り値の型が never に潰れていて、配列として触れないため(1131 行の渡し方と同じ)。
+ */
+ const priorTranscript = tuning.contradictionNeedsHistory ? transcriptOf(history) : ''
+ const priorBlock =
+ priorTranscript === '' ? '' : `\n\nこの相手とのこれまでのやり取り:\n${priorTranscript}`
+ const exchange = `プレイヤーが指定した話題: ${askInput.topic}${priorBlock}\n\n今回のやり取り:\n${transcript}\n\n今回判定可能なRevelation:\n${candidateBlock}`
+ const judged = await judgeTurn({ env, modelId: choices.judge, rubric, exchange, tuning })
const judgement = judged.judgement
const revealedRevelationIds = acceptRevealedRevelationIds(
revelationCandidates,
judgement.revealedRevelationIds,
)
+ const revealedEvidenceIds = tuning.checkEvidenceIds
+ ? acceptRevealedEvidenceIds(
+ scenarioEvidenceIds,
+ current.discoveredEvidenceIds,
+ judgement.revealedEvidenceIds,
+ )
+ : judgement.revealedEvidenceIds
const snapshot = await session.recordJudgement({
- revealedEvidenceIds: judgement.revealedEvidenceIds,
+ revealedEvidenceIds,
revealedRevelationIds,
contradictionPointedOut: judgement.contradictionPointedOut,
npcLied: judgement.npcLied,
})
judgementRecord.value = {
- revealedEvidenceIds: judgement.revealedEvidenceIds,
+ revealedEvidenceIds,
revealedRevelationIds,
contradictionPointedOut: judgement.contradictionPointedOut,
npcLied: judgement.npcLied,
+ tuning,
}
// 実りのあった話題に印を付ける。会話ログを遡ったときに、どこが効いたのかが
// 分かるようにするためのもの。往復番号が要るので、記録そのものが落ちていた
// 回は印も付けない。
- const yielded = judgement.revealedEvidenceIds.length > 0 || revealedRevelationIds.length > 0
+ const yielded = revealedEvidenceIds.length > 0 || revealedRevelationIds.length > 0
if (yielded && persisted.round !== undefined) {
await session.markTopicYield(askInput.characterId, persisted.round)
}
await Promise.all([
- judgement.revealedEvidenceIds.length === 0
+ revealedEvidenceIds.length === 0
? Promise.resolve()
: db
.insert(discoveries)
- .values(
- judgement.revealedEvidenceIds.map((evidenceId) => ({ sessionId, evidenceId })),
- )
+ .values(revealedEvidenceIds.map((evidenceId) => ({ sessionId, evidenceId })))
.onConflictDoNothing(),
revealedRevelationIds.length === 0
? Promise.resolve()
@@ -1277,7 +1334,7 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
])
const [revealedRows, revealedRevelationRows] = await Promise.all([
- judgement.revealedEvidenceIds.length === 0
+ revealedEvidenceIds.length === 0
? Promise.resolve([])
: db
.select({
@@ -1286,7 +1343,7 @@ sessionRoutes.post('/api/sessions/:id/ask', validateAsk, withEnv, async (c) => {
description: evidences.description,
})
.from(evidences)
- .where(inArray(evidences.id, judgement.revealedEvidenceIds)),
+ .where(inArray(evidences.id, revealedEvidenceIds)),
revealedRevelationIds.length === 0
? Promise.resolve([])
: db
From 42ac1f90eb42867e1731f17c3496ccbc1c58dcc3 Mon Sep 17 00:00:00 2001
From: tkgstrator <29420801+tkgstrator@users.noreply.github.com>
Date: Sat, 12 Sep 2026 16:35:11 +0000
Subject: [PATCH 41/41] fix(settings): tighten judge tuning controls
---
mocks/desktop/settings.html | 63 +++++++++++++++++++++++-
mocks/mobile/settings.html | 70 +++++++++++++++++++++++++--
src/client/screens/SettingsScreen.tsx | 6 ++-
3 files changed, 131 insertions(+), 8 deletions(-)
diff --git a/mocks/desktop/settings.html b/mocks/desktop/settings.html
index 2e27189..5adc7bf 100644
--- a/mocks/desktop/settings.html
+++ b/mocks/desktop/settings.html
@@ -54,6 +54,17 @@ 設定
+
+
+
判定の調整
+
判定役の振る舞いを一つずつ試せます。既定はすべてオフで、入れたものだけ判定の出方が変わります。
+
+
+
+
+
判定の調整
+
判定役の振る舞いを一つずつ試せます。既定はすべてオフで、入れたものだけ判定の出方が変わります。
+
+
+
事件の記録
@@ -209,14 +219,64 @@
設定
? '一段落ずつは、読む速さをあなたが握ります。'
: 'せり上がるは速さが決まっているぶん、音は鳴りません。'
+ /*
+ 判定の直しは四つとも入切だけ。名前と説明の出どころは db/judge-tuning.ts で、
+ ここはその写し——増減したときは向こうを直してからここへ写す。
+
+ 狭い画面では名前・説明・入切を縦に積む。打鍵音の行と同じ組みで、
+ 説明が操作の直前に来るぶん、名前に貼り付けると一かたまりに見える。
+ */
+ var JUDGE = [
+ { key: 'checkEvidenceIds', name: '証拠のIDを確かめる', note: '実在しない証拠や、すでに見つけた証拠を弾く' },
+ {
+ key: 'contradictionNeedsHistory',
+ name: '矛盾の判定に前のやり取りを渡す',
+ note: '前の発言と食い違っているかを、実際に読み比べて決める',
+ },
+ { key: 'fixedTemperature', name: '判定の揺れを抑える', note: '同じやり取りなら毎回同じ判定になるようにする' },
+ { key: 'retryOnce', name: '判定が落ちたらやり直す', note: '一度だけやり直す。落ちた回は発見が丸ごと消えるため' },
+ ]
+
+ document.getElementById('judge').innerHTML = JUDGE.map(function (t) {
+ return (
+ '
' +
+ '
' +
+ Mock.esc(t.name) +
+ '
' +
+ Mock.esc(t.note) +
+ '
'
+ )
+ }).join('')
+
+ JUDGE.forEach(function (t) {
+ choices(
+ 'judge-' + t.key,
+ [
+ { key: 'on', name: '入れる' },
+ { key: 'off', name: '入れない' },
+ ],
+ Mock.str(t.key, 'off'),
+ true,
+ )
+ })
+
Array.prototype.forEach.call(document.querySelectorAll('[data-set]'), function (el) {
el.addEventListener('click', function () {
var kv = el.dataset.set.split('=')
- location.hash =
- 'brief=' +
- (kv[0] === 'brief' ? kv[1] : brief) +
- '&sound=' +
- (kv[0] === 'sound' ? kv[1] : sound)
+ // 判定の欄は id が judge- 付き。ハッシュへ書くときは接頭辞を外す。
+ var name = kv[0].indexOf('judge-') === 0 ? kv[0].slice(6) : kv[0]
+ var state = { brief: brief, sound: sound }
+ JUDGE.forEach(function (t) {
+ state[t.key] = Mock.str(t.key, 'off')
+ })
+ state[name] = kv[1]
+ location.hash = Object.keys(state)
+ .map(function (k) {
+ return k + '=' + state[k]
+ })
+ .join('&')
location.reload()
})
})
diff --git a/src/client/screens/SettingsScreen.tsx b/src/client/screens/SettingsScreen.tsx
index fc1f260..162abf3 100644
--- a/src/client/screens/SettingsScreen.tsx
+++ b/src/client/screens/SettingsScreen.tsx
@@ -257,7 +257,11 @@ export const SettingsScreen = ({
判定役の振る舞いを一つずつ試せます。既定はすべてオフで、入れたものだけ判定の出方が変わります。
-
+ {/*
+ 端末でも行のあいだを空けない。他の節は二つ三つの行が離れて並ぶが、ここは四つで一組
+ ——同じ 13px を挟むと、切り替えが四つの節に分かれて見える。
+ */}
+
{JUDGE_TUNING_KEYS.map((key) => (