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] 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(),