Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
9fbd260
fix(pwa): lock the viewport scale on touch devices
tkgstrator Aug 31, 2026
8725cf9
feat(client): reveal chat log one sentence at a time and raise turn l…
tkgstrator Aug 31, 2026
5e5e83f
feat(docs): add effects motion catalog mock and embed it in storyboard
tkgstrator Aug 31, 2026
1943900
feat(alibi): derive alibi segments from discovered clues and add time…
tkgstrator Aug 31, 2026
2dca06f
docs(scenario-authoring): document how timeline, evidence, and revela…
tkgstrator Aug 31, 2026
990db8d
feat(scenario): add victim findings and investigability to scenario s…
tkgstrator Aug 31, 2026
171f5f2
feat(examination): let players examine the victim during interrogation
tkgstrator Aug 31, 2026
a250b78
feat(interrogation): carry chosen target from case prep into interrog…
tkgstrator Aug 31, 2026
b8354a2
feat(examination): give the detective an independent inquiry step for…
tkgstrator Aug 31, 2026
d35e5cd
feat(interrogation): disable victim column when case is not investigable
tkgstrator Aug 31, 2026
92f1240
feat(interrogation): keep re-chosen target when resuming a case
tkgstrator Aug 31, 2026
3b10920
fix(alibi): make alibi chart scroll instead of overflow on tall cases
tkgstrator Sep 1, 2026
38987ae
feat(db): add victim examination data and current-authoring migration…
tkgstrator Sep 1, 2026
a5bb47e
feat(interrogation): allow examining investigable victims in mocks
tkgstrator Sep 1, 2026
2ea003f
fix(mocks): keep explicitly chosen interrogation target on resume
tkgstrator Sep 1, 2026
73565ea
fix(db): resolve floor plan room ids to labels in timeline place
tkgstrator Sep 1, 2026
bde73dc
fix(db): resolve victim foundIn room ids to labels like timeline loca…
tkgstrator Sep 1, 2026
27d36ca
fix(db): key scenario re-seed deletion on a stable id instead of title
tkgstrator Sep 1, 2026
86169ce
fix(cache): version judge rubric cache key and rewrite suggestedQuest…
tkgstrator Sep 1, 2026
f65744b
fix(db): split timeline event with mismatched participant location
tkgstrator Sep 1, 2026
d81caf0
docs(db): clarify timeline event location comment for room-id resolution
tkgstrator Sep 1, 2026
efc302b
feat(db): rework scenario definition schema and enforce timeline records
tkgstrator Sep 1, 2026
c944417
feat(mocks): drive clash line endpoints from column position instead …
tkgstrator Sep 1, 2026
c9ab87a
feat(alibi): draw clash line between the two contradicting people
tkgstrator Sep 1, 2026
a0883be
chore: add clash line screenshot
tkgstrator Sep 1, 2026
112819d
fix(db): correct flight record label in typhoon-offshore-drone-route …
tkgstrator Sep 1, 2026
a1e3fd0
fix(alibi): require distinct-character source for clash line detection
tkgstrator Sep 1, 2026
dfa7126
feat(motions): redraw motion three in the vertical chart form
tkgstrator Sep 1, 2026
fdead88
chore: move clash line screenshot out of the repository root
tkgstrator Sep 1, 2026
f8c94a3
docs(db): illustrate the clash line in the authoring guide
tkgstrator Sep 1, 2026
341bd4d
feat(mocks): redraw motion three in the vertical chart form
tkgstrator Sep 1, 2026
4008308
feat(mocks): add investigable places alongside cast in overview and i…
tkgstrator Sep 2, 2026
5424803
fix(interrogation): auto-scroll log by observed content height, not c…
tkgstrator Sep 2, 2026
917a17c
docs(motions): record why pin-lift stays without a caller
tkgstrator Sep 2, 2026
aa2ab26
fix(ui): match every screen to its mock
tkgstrator Sep 2, 2026
39e8c25
Merge origin/develop into fix/disable-pinch-zoom
tkgstrator Sep 2, 2026
f2c521f
feat: land the accumulated local work — examinations, alibi clashes, …
tkgstrator Sep 2, 2026
df09cc2
refactor(turns): share topic max length constant between client and s…
tkgstrator Sep 2, 2026
3a9d0cc
feat(interrogation): mark topics that yielded, and stream the detecti…
tkgstrator Sep 2, 2026
f28ffb8
feat(interrogation): mark topics that yielded, and stream the detecti…
tkgstrator Sep 2, 2026
8caf11a
feat(mocks): draw the deadline as a window, in its four states
tkgstrator Sep 2, 2026
7e9bbbe
feat(mocks): add a sheet that puts the four deadline states side by side
tkgstrator Sep 2, 2026
57559a7
feat(mocks): let the desk switch to a place, as the phone already does
tkgstrator Sep 2, 2026
1f84c69
feat(mocks): offer only the same kind of target in the switcher
tkgstrator Sep 2, 2026
38e2389
feat: add investigable places and surface the interrogation deadline
tkgstrator Sep 2, 2026
356d739
feat(places): hand places to the screens, and document authoring them
tkgstrator Sep 2, 2026
fe36fa9
feat(hints): show what is left at a place, beside its name in the swi…
tkgstrator Sep 2, 2026
42ee636
feat(mocks): give the phone the same four deadline states as the desk
tkgstrator Sep 2, 2026
c6f5a78
feat(deadline): let the author decide what opens the death estimate
tkgstrator Sep 2, 2026
f8e952f
feat: the deadline as a window, investigable places, and the disclosu…
tkgstrator Sep 2, 2026
0b57f08
db(deadline): add reveals_death_time flag to evidences
tkgstrator Sep 2, 2026
5170bac
feat(deadline): distinguish no death estimate from unknown death esti…
tkgstrator Sep 2, 2026
b3b2379
docs(scenario): say what happens when a case has no death estimate
tkgstrator Sep 2, 2026
c4ca2b6
fix(deadline): distinguish "not found yet" from "never existed", and …
tkgstrator Sep 2, 2026
7b9d0ec
feat(scenario): give the cases places to investigate and routes to th…
tkgstrator Sep 2, 2026
6b2cbe6
chore: bump version to 0.4.0 for release (#18)
tkgstrator Sep 2, 2026
2fbcad9
chore: back-merge master into develop before the release
tkgstrator Sep 2, 2026
67ae881
chore: back-merge master into develop before the release (#20)
tkgstrator Sep 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

# public/se/ は規約上リポジトリに置けないので、checkout 直後は空。
# ここで取らないと音源の無いアセットがそのまま公開される。
# キャッシュが当たれば fetch-se は既存ファイルを skip し、相手のサイトは叩かない。
- name: Cache sound effects
uses: actions/cache@v4
with:
path: public/se
key: se-${{ hashFiles('scripts/fetch-se.ts') }}

- name: Fetch sound effects
run: bun run se:fetch

- name: Build
run: bun run build

Expand Down
31 changes: 27 additions & 4 deletions __tests__/api/validation.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test'
import app from '@/server/index'
import { MAX_TOPIC_CHARS } from '@/shared/turns'

/**
* bun test は Workers ランタイムの外で動くので、バインディング(DO / KV / Hyperdrive)を
Expand Down Expand Up @@ -120,21 +121,21 @@ describe('POST /api/sessions/:id/ask', () => {
expect(res.status).toBe(400)
})

test('話題が501文字なら 400', async () => {
test('話題が上限を1文字でも超えれば 400', async () => {
const res = await postJson(`/api/sessions/${SESSION_ID}/ask`, {
sessionId: SESSION_ID,
characterId: CHARACTER_ID,
topic: 'あ'.repeat(501),
topic: 'あ'.repeat(MAX_TOPIC_CHARS + 1),
})

expect(res.status).toBe(400)
})

test('500文字ちょうどはバリデーションを通過する(境界の内側)', async () => {
test('上限ちょうどはバリデーションを通過する(境界の内側)', async () => {
const res = await postJson(`/api/sessions/${SESSION_ID}/ask`, {
sessionId: SESSION_ID,
characterId: CHARACTER_ID,
topic: 'あ'.repeat(500),
topic: 'あ'.repeat(MAX_TOPIC_CHARS),
})

// バインディングが無いのでこの先は進めない。ここで見たいのは
Expand All @@ -151,6 +152,28 @@ describe('POST /api/sessions/:id/ask', () => {

expect(res.status).toBe(400)
})

test('調べられる場所も相手として受ける', async () => {
// 人物は uuid、遺体は victim、場所は作者が書いたローカルID。同じ口へ来る。
const res = await postJson(`/api/sessions/${SESSION_ID}/ask`, {
sessionId: SESSION_ID,
characterId: 'choba',
topic: '帳面を見てみる',
})

expect(res.status).not.toBe(400)
})

test('相手の形をしていない文字列は 400', async () => {
// uuid でも victim でも、場所の ID の形でもないもの。
const res = await postJson(`/api/sessions/${SESSION_ID}/ask`, {
sessionId: SESSION_ID,
characterId: '帳場',
topic: '帳面を見てみる',
})

expect(res.status).toBe(400)
})
})

describe('POST /api/sessions/:id/accuse', () => {
Expand Down
65 changes: 47 additions & 18 deletions __tests__/client/chat-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,53 +15,82 @@ const textsOf = (item: ReturnType<typeof groupTurns>[number]): string[] =>
item.kind === 'topic' ? [item.text] : item.lines.map((line) => line.text)

describe('groupTurns', () => {
test('空行で区切られた返答を行に割る', () => {
const items = groupTurns([
turn('a1', 'assistant', 'えっ、Yさんですか?\n\nそうですね。いい人でしたよ。'),
])
test('返答を一文ずつに割る', () => {
const items = groupTurns(
[turn('a1', 'assistant', 'えっ、Yさんですか?\n\nそうですね。いい人でしたよ。')],
false,
)

expect(items).toHaveLength(1)
expect(textsOf(items[0])).toEqual(['えっ、Yさんですか?', 'そうですね。いい人でしたよ。'])
expect(textsOf(items[0])).toEqual(['えっ、Yさんですか?', 'そうですね。', 'いい人でしたよ。'])
})

test('改行が続いても区切りは1つと数える', () => {
const items = groupTurns([turn('a1', 'assistant', '前半\n\n\n\n後半')])
const items = groupTurns([turn('a1', 'assistant', '前半\n\n\n\n後半')], false)

expect(textsOf(items[0])).toEqual(['前半', '後半'])
})

test('割った行にはそれぞれ別の鍵が付く', () => {
const items = groupTurns([turn('a1', 'assistant', '一つ目\n\n二つ目')])
const items = groupTurns([turn('a1', 'assistant', '一つ目\n\n二つ目')], false)
const item = items[0]

expect(item.kind === 'block' ? item.lines.map((line) => line.id) : []).toEqual(['a1:0', 'a1:1'])
})

test('続けて喋った分は塊にまとまり、行が並ぶ', () => {
const items = groupTurns([
turn('a1', 'assistant', '一つ目\n\n二つ目'),
turn('a2', 'assistant', '三つ目'),
])
const items = groupTurns(
[turn('a1', 'assistant', '一つ目\n\n二つ目'), turn('a2', 'assistant', '三つ目')],
false,
)

expect(items).toHaveLength(1)
expect(textsOf(items[0])).toEqual(['一つ目', '二つ目', '三つ目'])
})

test('話題を挟むと塊が分かれる', () => {
const items = groupTurns([
turn('a1', 'assistant', '前の返答'),
turn('t1', 'topic', '次の話題'),
turn('u1', 'user', '次の質問'),
])
const items = groupTurns(
[
turn('a1', 'assistant', '前の返答'),
turn('t1', 'topic', '次の話題'),
turn('u1', 'user', '次の質問'),
],
false,
)

expect(items.map((item) => item.kind)).toEqual(['block', 'topic', 'block'])
})

test('流れている最中は、書きかけの一文を出さない', () => {
const items = groupTurns([turn('a1', 'assistant', 'そうですね。いい人でし')], true)

expect(textsOf(items[0])).toEqual(['そうですね。'])
})

test('流れ終われば、句点で終わらない一文も出す', () => {
const items = groupTurns([turn('a1', 'assistant', 'そうですね。……雨でしたから')], false)

expect(textsOf(items[0])).toEqual(['そうですね。', '……雨でしたから'])
})

test('末尾より前の返答は、流れている最中でも全部出す', () => {
const items = groupTurns(
[turn('a1', 'assistant', '前の返答は書き終わっている'), turn('a2', 'assistant', '書きかけ')],
true,
)

expect(textsOf(items[0])).toEqual(['前の返答は書き終わっている'])
})

test('返答待ちの空のターンは置かない', () => {
expect(groupTurns([turn('a1', 'assistant', '')])).toEqual([])
expect(groupTurns([turn('a1', 'assistant', '')], true)).toEqual([])
})

test('最初に届いたのが改行だけでも、まだ置かない', () => {
expect(groupTurns([turn('a1', 'assistant', '\n\n')])).toEqual([])
expect(groupTurns([turn('a1', 'assistant', '\n\n')], true)).toEqual([])
})

test('一文目が出来上がるまでは、名前も出さない', () => {
expect(groupTurns([turn('a1', 'assistant', 'そうですね')], true)).toEqual([])
})
})
66 changes: 66 additions & 0 deletions __tests__/client/deadline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, test } from 'bun:test'
import { deadlineOf } from '@/client/lib/deadline'

/**
* 被害者。死亡推定時刻は持っていない——あれは開示済みかどうかをサーバが判断して
* セッションの状態で運ぶもので、シナリオ詳細には最初から入ってこない
* (docs/design/deadline-window.md)。
*/
const VICTIM = {
name: '水野英治',
introduction: '青雨堂店主',
foundAt: '19:10',
foundIn: '店の奥',
hasEstimatedDeathAt: true,
investigable: true,
}

describe('deadlineOf', () => {
test('開示前は死亡推定が不明のまま', () => {
expect(deadlineOf(VICTIM, null)).toEqual({
foundAt: '19:10',
label: '死亡推定',
death: { kind: 'unknown' },
})
})

/*
* 「まだ見つけていない」と「最初から無い」は別のこと。点線と ? は「ここに探すものがある」
* という誘いなので、死亡推定時刻を持たない事件に出すと、無いものを探させることになる。
*/
test('死亡推定時刻を持たない事件では、印そのものを出さない', () => {
expect(deadlineOf({ ...VICTIM, hasEstimatedDeathAt: false }, null)).toEqual({
foundAt: '19:10',
label: '死亡推定',
death: undefined,
})
})

test('開示されると死亡推定が確定して出る', () => {
expect(deadlineOf(VICTIM, '18:50')).toEqual({
foundAt: '19:10',
label: '死亡推定',
death: { kind: 'fixed', at: '18:50' },
})
})

test('発見時刻を持たない事件では、遺体発見の線を出さない', () => {
expect(deadlineOf({ ...VICTIM, foundAt: null }, null)).toEqual({
foundAt: undefined,
label: '死亡推定',
death: { kind: 'unknown' },
})
})

test('発見時刻が分かっていなくても、開示された死亡推定は出る', () => {
expect(deadlineOf({ ...VICTIM, foundAt: null }, '18:50')).toEqual({
foundAt: undefined,
label: '死亡推定',
death: { kind: 'fixed', at: '18:50' },
})
})

test('被害者のいない事件では刻限そのものが無い', () => {
expect(deadlineOf(null, '18:50')).toBeUndefined()
})
})
70 changes: 61 additions & 9 deletions __tests__/client/restore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,15 @@ describe('restoreConversations', () => {
history([
{
characterId: 'a',
exchanges: [{ question: '昨夜どこに?', answer: '書斎です', askedAt: 100, topic: null }],
exchanges: [
{
question: '昨夜どこに?',
answer: '書斎です',
askedAt: 100,
topic: null,
yielded: false,
},
],
},
]),
)
Expand All @@ -32,32 +40,71 @@ describe('restoreConversations', () => {
{
characterId: 'a',
exchanges: [
{ question: '昨夜どこに?', answer: '書斎です', askedAt: 100, topic: 'アリバイ' },
{ question: '何時まで?', answer: '日付が変わる頃まで', askedAt: 100, topic: null },
{
question: '昨夜どこに?',
answer: '書斎です',
askedAt: 100,
topic: 'アリバイ',
yielded: false,
},
{
question: '何時まで?',
answer: '日付が変わる頃まで',
askedAt: 100,
topic: null,
yielded: false,
},
],
},
]),
)

expect(result.a).toEqual([
{ id: '100:0', role: 'topic', text: 'アリバイ', askedAt: 100 },
{ id: '100:0', role: 'topic', text: 'アリバイ', askedAt: 100, notable: false },
{ id: '100:1', role: 'user', text: '昨夜どこに?', askedAt: 100 },
{ id: '100:2', role: 'assistant', text: '書斎です', askedAt: 100 },
{ id: '100:3', role: 'user', text: '何時まで?', askedAt: 100 },
{ id: '100:4', role: 'assistant', text: '日付が変わる頃まで', askedAt: 100 },
])
})

test('何かを引き出した話題には印が立つ', () => {
const result = restoreConversations(
history([
{
characterId: 'a',
exchanges: [
{
question: '傘は?',
answer: '差していません',
askedAt: 100,
topic: '雨',
yielded: true,
},
],
},
]),
)

expect(result.a?.[0]).toEqual({
id: '100:0',
role: 'topic',
text: '雨',
askedAt: 100,
notable: true,
})
})

test('質問と答えは同じ時刻を持つ(NPCをまたいで並べ直すため)', () => {
const result = restoreConversations(
history([
{
characterId: 'a',
exchanges: [{ question: 'q1', answer: 'a1', askedAt: 300, topic: null }],
exchanges: [{ question: 'q1', answer: 'a1', askedAt: 300, topic: null, yielded: false }],
},
{
characterId: 'b',
exchanges: [{ question: 'q2', answer: 'a2', askedAt: 200, topic: null }],
exchanges: [{ question: 'q2', answer: 'a2', askedAt: 200, topic: null, yielded: false }],
},
]),
)
Expand All @@ -71,21 +118,26 @@ describe('restoreConversations', () => {
history([
{
characterId: 'a',
exchanges: [{ question: '聞きかけ', answer: '', askedAt: 1, topic: '話題' }],
exchanges: [
{ question: '聞きかけ', answer: '', askedAt: 1, topic: '話題', yielded: false },
],
},
]),
)

expect(result.a).toEqual([
{ id: '1:0', role: 'topic', text: '話題', askedAt: 1 },
{ id: '1:0', role: 'topic', text: '話題', askedAt: 1, notable: false },
{ id: '1:1', role: 'user', text: '聞きかけ', askedAt: 1 },
])
})

test('一度も話していないNPCはキーごと作らない', () => {
const result = restoreConversations(
history([
{ characterId: 'a', exchanges: [{ question: 'q', answer: 'a', askedAt: 1, topic: null }] },
{
characterId: 'a',
exchanges: [{ question: 'q', answer: 'a', askedAt: 1, topic: null, yielded: false }],
},
{ characterId: 'b', exchanges: [] },
]),
)
Expand Down
Loading
Loading