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 }) => ( +