diff --git a/.dockerignore b/.dockerignore index 659f986..9593201 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,6 @@ **/node_modules **/.next +**/.chros **/.env **/.env.local .git diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..346048f --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,24 @@ +name: Code quality + +on: + pull_request: + push: + branches: [develop] + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 22 + - uses: pnpm/action-setup@v6 + - run: pnpm install --frozen-lockfile + - run: pnpm format:check + - run: pnpm typecheck + - run: pnpm test diff --git a/.gitignore b/.gitignore index 373b4d4..3a2510d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules/ .env .env.local *.tsbuildinfo +.chros/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..7ae92fa --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +**/node_modules/ +**/.next/ +**/.chros/ +chros/app/generated/ +pnpm-lock.yaml diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..554f2a3 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "printWidth": 100 +} diff --git a/README.md b/README.md index 3994e29..903b7fa 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ 通称 : CHroS(チュロス) 大会の進行と、配信に載せる表示を管理するシステムです。 +対人戦の結果記録から順位集計・本戦・会場掲示まで操作できる試作を実装しました。 +**まず試す場合は [試作提案・操作手順・判断記録](./docs/prototype-proposal.md) を参照してください。** 要件は [docs/requirements.md](./docs/requirements.md)、技術要件は [docs/technical-requirements.md](./docs/technical-requirements.md) を参照してください。 @@ -17,25 +19,26 @@ packages/scoring/ スコア計算の純粋ロジック(I/O を持たない) ## 環境構築 -Docker を使用する場合は、リポジトリのルートで `docker compose up -d` を実行してください。 -`app` (Next.js, :3000) と `db` (PostgreSQL 17, :5432) が立ち上がります。 - -ローカルで動かす場合: +Node.js 22以降とpnpmで起動できます。今回の試作はファイル保存のためDBを必要としません。 ```sh -# DB だけ Docker で立てる -docker compose up -d db - pnpm install -cp chros/.env.example chros/.env -pnpm --filter chros exec prisma migrate deploy pnpm dev ``` -- Console: http://localhost:3000/console -- Viewer: http://localhost:3000/display +- [Console](http://localhost:3000/console) +- [Viewer](http://localhost:3000/display) + +初回は架空の8名によるサンプル大会です。「設定・履歴」から空の大会を作成できます。 +データは `chros/.chros/` に自動保存されます。保存先は `CHROS_DATA_DIR` で変更できます。 +大会切り替え前のデータも同ディレクトリに退避します。復旧手順は [技術要件](./docs/technical-requirements.md) を参照してください。 + +本番形式で確認する場合は `pnpm build` → `pnpm start`。 +Dockerでは `docker compose up -d --build` でアプリと永続化用volumeを使用します。 +従来のPostgreSQLサービスは `database` プロファイルに残していますが、今回の試作では使いません。 ## リアルタイム配信 Viewer は `GET /api/stream` の SSE を購読します。接続直後に全状態のスナップショットが -1件届き、以降は差分イベントが流れます。WebSocket は使用しません(理由は技術要件 §1.1)。 +1件届き、以降も保存のたびに最新の全状態が流れます。WebSocketは使用しません。 +これは掲示状態の即時反映であり、試合の得点入力は各戦終了後に行います。 diff --git a/chros/.env.example b/chros/.env.example index db0fa90..803a927 100644 --- a/chros/.env.example +++ b/chros/.env.example @@ -1,5 +1,7 @@ -# PostgreSQL 接続文字列 +# 今回の試作: 永続化ディレクトリ。未指定はNext.jsの作業ディレクトリの .chros +# CHROS_DATA_DIR=/absolute/path/to/chros-data + +# 将来のPostgreSQL実装用(現在の試作では未使用) DATABASE_URL=postgresql://user:password@localhost:5432/database -# Viewer が SSE を張る先。同一オリジンで運用する場合は空でよい。 -NEXT_PUBLIC_APP_URL=http://localhost:3000 +# ViewerのSSE接続先は同一オリジン /api/stream。 diff --git a/chros/Dockerfile b/chros/Dockerfile index e9b463d..c58f755 100644 --- a/chros/Dockerfile +++ b/chros/Dockerfile @@ -15,7 +15,6 @@ RUN pnpm install --frozen-lockfile COPY . . -RUN pnpm --filter chros exec prisma generate RUN pnpm --filter chros build EXPOSE 3000 diff --git a/chros/app/api/export/route.ts b/chros/app/api/export/route.ts new file mode 100644 index 0000000..5fd1ee6 --- /dev/null +++ b/chros/app/api/export/route.ts @@ -0,0 +1,21 @@ +import { exportResultsCsv } from '@/lib/export-results'; +import { readState } from '@/lib/store'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +export function GET(request: Request) { + const state = readState(); + const format = new URL(request.url).searchParams.get('format') === 'csv' ? 'csv' : 'json'; + const content = format === 'csv' ? exportResultsCsv(state) : JSON.stringify(state, null, 2); + const contentType = format === 'csv' ? 'text/csv' : 'application/json'; + const date = new Date().toISOString().slice(0, 10); + + return new Response(content, { + headers: { + 'Content-Type': `${contentType}; charset=utf-8`, + 'Content-Disposition': `attachment; filename="chros-${date}.${format}"`, + 'Cache-Control': 'no-store', + }, + }); +} diff --git a/chros/app/api/screen/route.ts b/chros/app/api/screen/route.ts index 0d51bee..8dc469e 100644 --- a/chros/app/api/screen/route.ts +++ b/chros/app/api/screen/route.ts @@ -1,36 +1,12 @@ -import { apiError, changeScreenRequestSchema } from '@chros/shared'; -import { broadcast, getCurrentScreen } from '@/lib/broadcast'; - +import { readState } from '@/lib/store'; export const dynamic = 'force-dynamic'; -export const runtime = 'nodejs'; - -/** GET /api/screen — 現在の配信画面。 */ -export function GET(): Response { - return Response.json({ screen: getCurrentScreen() }); +export function GET() { + const state = readState(); + return Response.json({ screen: state.scene, phase: state.phase, revision: state.revision }); } - -/** - * POST /api/screen — 配信画面の切り替え。§7 - * 旧 chros-websock の POST /api/change-screen の置き換え。 - */ -export async function POST(request: Request): Promise { - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json(apiError('invalid_json', 'リクエストボディが JSON ではありません'), { - status: 400, - }); - } - - const parsed = changeScreenRequestSchema.safeParse(body); - if (!parsed.success) { - return Response.json(apiError('invalid_request', parsed.error.issues[0]?.message ?? '不正なリクエストです'), { - status: 400, - }); - } - - broadcast({ type: 'screen.changed', payload: { screen: parsed.data.screen } }); - - return Response.json({ screen: parsed.data.screen }); +export function POST() { + return Response.json( + { error: '画面変更は /api/state の show コマンドを使用してください。' }, + { status: 410 }, + ); } diff --git a/chros/app/api/state/route.ts b/chros/app/api/state/route.ts new file mode 100644 index 0000000..a86bcb8 --- /dev/null +++ b/chros/app/api/state/route.ts @@ -0,0 +1,55 @@ +import { requestSchema } from '@chros/shared'; +import { dispatch, readState, RevisionConflict } from '@/lib/store'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +const MAX_REQUEST_LENGTH = 2_000_000; + +export function GET() { + return Response.json(readState(), { headers: { 'Cache-Control': 'no-store' } }); +} +export async function POST(request: Request) { + const origin = request.headers.get('origin'); + if ( + origin && + origin !== new URL(request.url).origin && + new URL(origin).host !== request.headers.get('host') + ) { + return Response.json({ error: '異なるサイトからの操作は受け付けません。' }, { status: 403 }); + } + + const body = await request.text(); + if (body.length > MAX_REQUEST_LENGTH) { + return Response.json({ error: 'データが大きすぎます。' }, { status: 413 }); + } + + let input; + try { + input = requestSchema.safeParse(JSON.parse(body)); + } catch { + return Response.json({ error: '入力形式が不正です。' }, { status: 400 }); + } + if (!input.success) { + const validationErrors = input.error.issues.map((issue) => issue.message).join(' / '); + return Response.json( + { + error: `入力内容を確認してください: ${validationErrors}`, + }, + { status: 400 }, + ); + } + + try { + return Response.json(dispatch(input.data.command, input.data.expectedRevision)); + } catch (error) { + const conflict = error instanceof RevisionConflict; + return Response.json( + { + error: error instanceof Error ? error.message : '保存できませんでした。', + ...(conflict ? { state: readState() } : {}), + }, + { status: conflict ? 409 : 400 }, + ); + } +} diff --git a/chros/app/api/stream/route.ts b/chros/app/api/stream/route.ts index ad8a41a..13ca9cd 100644 --- a/chros/app/api/stream/route.ts +++ b/chros/app/api/stream/route.ts @@ -1,75 +1,56 @@ -import type { ChrosEvent } from '@chros/shared'; -import { buildSnapshot, nextEventId, replayAfter, subscribe } from '@/lib/broadcast'; +import { readState, subscribeState } from '@/lib/store'; +import type { ControlState } from '@chros/shared'; export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; -/** プロキシによるアイドル切断を防ぐコメント行の間隔。§6.1 */ -const PING_INTERVAL_MS = 15_000; +const HEARTBEAT_INTERVAL_MS = 15_000; -function encodeEvent(id: number, event: ChrosEvent): string { - return `id: ${id}\nevent: ${event.type}\ndata: ${JSON.stringify(event.payload)}\n\n`; +function encodeSnapshot(state: ControlState) { + return `id: ${state.revision}\nevent: snapshot\ndata: ${JSON.stringify(state)}\n\n`; } -/** - * GET /api/stream — 全 Viewer が購読する SSE ストリーム。§6.1 - * 旧 chros-websock (Express + socket.io) の置き換え。 - */ -export function GET(request: Request): Response { +export function GET(request: Request) { const encoder = new TextEncoder(); - const lastEventIdHeader = request.headers.get('last-event-id'); - const lastEventId = lastEventIdHeader ? Number.parseInt(lastEventIdHeader, 10) : NaN; - - let unsubscribe: (() => void) | undefined; - let ping: ReturnType | undefined; - - const stream = new ReadableStream({ + let cleanup = () => {}; + const stream = new ReadableStream({ start(controller) { let closed = false; - const send = (chunk: string) => { + const send = (message: string) => { if (closed) return; try { - controller.enqueue(encoder.encode(chunk)); + controller.enqueue(encoder.encode(message)); } catch { - // 送信先が既に切れている。次の abort で後始末される。 - closed = true; + cleanup(); } }; - - // 再送可能なら差分だけ、無理なら全状態を送り直す。 - const replayed = Number.isNaN(lastEventId) ? null : replayAfter(lastEventId); - if (replayed) { - for (const entry of replayed) send(encodeEvent(entry.id, entry.event)); - } else { - send(encodeEvent(nextEventId() - 1, { type: 'snapshot', payload: buildSnapshot() })); - } - - unsubscribe = subscribe((entry) => send(encodeEvent(entry.id, entry.event))); - ping = setInterval(() => send(': ping\n\n'), PING_INTERVAL_MS); - - request.signal.addEventListener('abort', () => { + const sendSnapshot = (state: ControlState) => send(encodeSnapshot(state)); + const unsubscribe = subscribeState(sendSnapshot); + const heartbeat = setInterval(() => send(': heartbeat\n\n'), HEARTBEAT_INTERVAL_MS); + cleanup = () => { + if (closed) return; closed = true; - unsubscribe?.(); - if (ping) clearInterval(ping); + unsubscribe(); + clearInterval(heartbeat); + request.signal.removeEventListener('abort', cleanup); try { controller.close(); - } catch { - // 既に閉じている - } - }); + } catch {} + }; + request.signal.addEventListener('abort', cleanup); + // 再接続時も保存済みの全状態を返す。再起動・取りこぼしに依存しない。 + sendSnapshot(readState()); + if (request.signal.aborted) cleanup(); }, cancel() { - unsubscribe?.(); - if (ping) clearInterval(ping); + cleanup(); }, }); - return new Response(stream, { headers: { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', - // Nginx 等のバッファリングを止めないとイベントが溜まってから届く 'X-Accel-Buffering': 'no', }, }); diff --git a/chros/app/console/actions.ts b/chros/app/console/actions.ts deleted file mode 100644 index 9a0cfcd..0000000 --- a/chros/app/console/actions.ts +++ /dev/null @@ -1,13 +0,0 @@ -'use server'; - -import { changeScreenRequestSchema, type ScreenId } from '@chros/shared'; -import { broadcast } from '@/lib/broadcast'; - -/** - * 配信画面を切り替える。§3.2 のとおり Console の操作は Server Actions を基本とする。 - * 将来 DB に進行状態を持たせた際も、書き込みとブロードキャストはこの関数内で完結させる(§7 規約)。 - */ -export async function changeScreen(screen: ScreenId): Promise { - const parsed = changeScreenRequestSchema.parse({ screen }); - broadcast({ type: 'screen.changed', payload: { screen: parsed.screen } }); -} diff --git a/chros/app/console/components/ConsoleNavTabs.tsx b/chros/app/console/components/ConsoleNavTabs.tsx deleted file mode 100644 index 1ccce49..0000000 --- a/chros/app/console/components/ConsoleNavTabs.tsx +++ /dev/null @@ -1,31 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; - -const tabs = [ - { href: '/console', label: 'ホーム' }, - { href: '/console/scores', label: 'スコア' }, - { href: '/console/users', label: 'ユーザー' }, - { href: '/console/matches', label: 'マッチ' }, -]; - -export default function ConsoleNavTabs() { - const pathname = usePathname(); - - return ( - - ); -} diff --git a/chros/app/console/components/DisplayPreview.tsx b/chros/app/console/components/DisplayPreview.tsx deleted file mode 100644 index d5277d5..0000000 --- a/chros/app/console/components/DisplayPreview.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function DisplayPreview() { - return ( -
- わかりやすいプレビュー -
- ); -} diff --git a/chros/app/console/components/NextDisplayControl.tsx b/chros/app/console/components/NextDisplayControl.tsx deleted file mode 100644 index 16be639..0000000 --- a/chros/app/console/components/NextDisplayControl.tsx +++ /dev/null @@ -1,48 +0,0 @@ -'use client'; - -import { useState, useTransition } from 'react'; -import type { ScreenId } from '@chros/shared'; -import { changeScreen } from '../actions'; - -const options: { label: string; value: ScreenId }[] = [ - { label: '次の対戦(next-game)', value: 'next-game' }, - { label: '対戦中(in-game)', value: 'in-game' }, - { label: '結果(result)', value: 'result' }, - { label: '順位表(standings)', value: 'standings' }, - { label: '対戦表(bracket)', value: 'bracket' }, - { label: '休憩中(intermission)', value: 'intermission' }, -]; - -export default function NextDisplayControl() { - const [selected, setSelected] = useState('next-game'); - const [pending, startTransition] = useTransition(); - - return ( -
- {/* ダミー用ビジュアルボックス */} -
- - - - - -
- ); -} diff --git a/chros/app/console/layout.tsx b/chros/app/console/layout.tsx index e075f9e..c1492b7 100644 --- a/chros/app/console/layout.tsx +++ b/chros/app/console/layout.tsx @@ -1,23 +1,3 @@ -import { ReactNode } from 'react'; -import DisplayPreview from './components/DisplayPreview'; -import NextDisplayControl from './components/NextDisplayControl'; -import ConsoleNavTabs from './components/ConsoleNavTabs'; - -export default function AdminLayout({ children }: { children: ReactNode }) { - return ( -
- {/* 左:表示ビュー & 操作 */} - - - {/* 右:タブで切り替える内容 */} -
- -
{children}
-
-
- ); +export default function Layout({ children }: { children: React.ReactNode }) { + return children; } diff --git a/chros/app/console/matches/page.tsx b/chros/app/console/matches/page.tsx index bc5e0ea..8c21d4e 100644 --- a/chros/app/console/matches/page.tsx +++ b/chros/app/console/matches/page.tsx @@ -1,7 +1,4 @@ -export default function MatchesPage() { - return ( -
-

ようこそ!ここはマッチ管理ページです。

-
- ); +import ConsoleApp from '@/components/ConsoleApp'; +export default function Page() { + return ; } diff --git a/chros/app/console/page.tsx b/chros/app/console/page.tsx index 98a9e69..8530361 100644 --- a/chros/app/console/page.tsx +++ b/chros/app/console/page.tsx @@ -1,7 +1,4 @@ -export default function ConsolePage() { - return ( -
-

ようこそ!ここは管理画面のトップページです。

-
- ); +import ConsoleApp from '@/components/ConsoleApp'; +export default function Page() { + return ; } diff --git a/chros/app/console/scores/page.tsx b/chros/app/console/scores/page.tsx index c2a7a14..8c21d4e 100644 --- a/chros/app/console/scores/page.tsx +++ b/chros/app/console/scores/page.tsx @@ -1,24 +1,4 @@ -export default function ScoresPage() { - return ( -
-

スコア管理

- -
-
-

Cool : player 1

- - - -
-
-

Hot : player 2

- - - -
-
- - -
- ); +import ConsoleApp from '@/components/ConsoleApp'; +export default function Page() { + return ; } diff --git a/chros/app/console/users/page.tsx b/chros/app/console/users/page.tsx index 65c1169..edc7aed 100644 --- a/chros/app/console/users/page.tsx +++ b/chros/app/console/users/page.tsx @@ -1,7 +1,4 @@ -export default function UsersPage() { - return ( -
-

ようこそ!ここはユーザー管理ページです。

-
- ); +import ConsoleApp from '@/components/ConsoleApp'; +export default function Page() { + return ; } diff --git a/chros/app/display/SnapshotProvider.tsx b/chros/app/display/SnapshotProvider.tsx deleted file mode 100644 index 3c1d691..0000000 --- a/chros/app/display/SnapshotProvider.tsx +++ /dev/null @@ -1,78 +0,0 @@ -'use client'; - -import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; -import type { ChrosEvent, ScreenId, Snapshot } from '@chros/shared'; - -const SnapshotContext = createContext(null); - -/** - * Viewer の全状態。SSE のスナップショットが届くまでは null。 - * §6.4 のとおり、切断中も直前の状態を保持し、画面を白くしない。 - */ -export function useSnapshot(): Snapshot | null { - return useContext(SnapshotContext); -} - -export function useScreen(): ScreenId | null { - return useSnapshot()?.screen ?? null; -} - -function reduce(current: Snapshot | null, event: ChrosEvent): Snapshot | null { - switch (event.type) { - case 'snapshot': - return event.payload; - case 'screen.changed': - return current ? { ...current, screen: event.payload.screen } : current; - case 'match.started': - return current ? { ...current, currentMatchId: event.payload.matchId } : current; - case 'standings.updated': - return current ? { ...current, standings: event.payload } : current; - // match.scored / match.finished の反映は対戦のドメインモデル(§4)確定後に実装する。 - default: - return current; - } -} - -/** - * SSE (`EventSource`) で状態に追従する。§3.2 - * EventSource は自動再接続し、`Last-Event-ID` の送出もブラウザが行うため、 - * 再接続時の取りこぼし復帰はサーバー側(/api/stream)だけで完結する。 - */ -export default function SnapshotProvider({ children }: { children: ReactNode }) { - const [snapshot, setSnapshot] = useState(null); - - useEffect(() => { - const base = process.env.NEXT_PUBLIC_APP_URL ?? ''; - const source = new EventSource(`${base}/api/stream`); - - const types = [ - 'snapshot', - 'screen.changed', - 'match.started', - 'match.scored', - 'match.finished', - 'standings.updated', - ] as const; - - const handlers = types.map((type) => { - const handler = (message: MessageEvent) => { - const event = { type, payload: JSON.parse(message.data) } as ChrosEvent; - setSnapshot((current) => reduce(current, event)); - }; - source.addEventListener(type, handler); - return [type, handler] as const; - }); - - // 異常は配信に出さず、Console 側にだけ通知する(§6.4)。ここでは記録に留める。 - source.onerror = () => { - console.warn('[display] SSE disconnected; retrying'); - }; - - return () => { - for (const [type, handler] of handlers) source.removeEventListener(type, handler); - source.close(); - }; - }, []); - - return {children}; -} diff --git a/chros/app/display/layout.tsx b/chros/app/display/layout.tsx index b667d40..c1492b7 100644 --- a/chros/app/display/layout.tsx +++ b/chros/app/display/layout.tsx @@ -1,6 +1,3 @@ -import type { ReactNode } from 'react'; -import SnapshotProvider from './SnapshotProvider'; - -export default function DisplayLayout({ children }: { children: ReactNode }) { - return {children}; +export default function Layout({ children }: { children: React.ReactNode }) { + return children; } diff --git a/chros/app/display/page.tsx b/chros/app/display/page.tsx index 9447716..179aded 100644 --- a/chros/app/display/page.tsx +++ b/chros/app/display/page.tsx @@ -1,44 +1,22 @@ 'use client'; - -import { useSnapshot } from './SnapshotProvider'; - -/** - * 配信用 Viewer。画面の切り替えはルーティングではなく状態で行う(§10)。 - * `router.push` を使うと切り替えのたびに白画面と再接続が挟まるため、 - * 単一のルートに留まったまま描画内容だけを差し替える。 - * - * 各画面の中身(§8.1)は進行まわりのドメインモデル確定後に実装する。 - * ここでは screen に対応する枠だけを置く。 - */ -export default function DisplayPage() { - const snapshot = useSnapshot(); - - // 初回スナップショット到着前。§6.4 によりスピナーは出さず、黒のまま待つ。 - if (!snapshot) return
; - +import Board from '@/components/Board'; +import { useControl } from '@/lib/use-control'; +export default function Display() { + const { state, connected } = useControl(); return ( -
-

{snapshot.tournament.name}

-

{screenLabel(snapshot.screen)}

-
+
+ {state ? ( + + ) : ( +
+ CHroS大会掲示に接続しています… +
+ )} + {!connected && ( +
+ {state ? '接続を確認中 · 最後に受信した結果を表示しています' : 'サーバーへの接続を確認中'} +
+ )} +
); } - -function screenLabel(screen: string): string { - switch (screen) { - case 'next-game': - return '次の対戦'; - case 'in-game': - return '対戦中'; - case 'result': - return '結果'; - case 'standings': - return '順位表'; - case 'bracket': - return '対戦表'; - case 'intermission': - return '休憩中'; - default: - return screen; - } -} diff --git a/chros/app/globals.css b/chros/app/globals.css index a2dc41e..df5ce95 100644 --- a/chros/app/globals.css +++ b/chros/app/globals.css @@ -1,26 +1,2191 @@ -@import "tailwindcss"; +@import 'tailwindcss'; :root { - --background: #ffffff; - --foreground: #171717; + --ink: #203837; + --muted: #768381; + --paper: #f3f5f1; + --line: #e2e8e1; + --green: #22645b; + --lime: #d2ef82; + --sidebar: #142f2d; + --white: #fff; } - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); +* { + box-sizing: border-box; +} +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font-family: Inter, 'Hiragino Kaku Gothic ProN', 'Noto Sans JP', 'Yu Gothic', sans-serif; + font-size: 14px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} +button, +input, +select { + font: inherit; +} +button, +a, +input, +select { + -webkit-tap-highlight-color: transparent; +} +button { + cursor: pointer; +} +button:disabled, +input:disabled, +select:disabled { + cursor: not-allowed; + opacity: 0.46; +} +button:focus-visible, +a:focus-visible { + outline: 3px solid #64b7ad; + outline-offset: 3px; +} +a { + text-decoration: none; + color: inherit; +} +h1, +h2, +h3, +p { + margin: 0; +} +button { + border: 0; + background: none; + color: inherit; +} +fieldset { + border: 0; + padding: 0; + margin: 0; + min-width: 0; +} +input, +select { + width: 100%; + min-height: 42px; + border: 1px solid #d4ded7; + border-radius: 7px; + background: white; + padding: 9px 12px; + color: var(--ink); + outline: none; +} +input:focus, +select:focus { + border-color: var(--green); + box-shadow: 0 0 0 3px #22645b16; +} +input::placeholder { + color: #9aa59d; +} +label { + display: flex; + flex-direction: column; + gap: 7px; + font-weight: 600; + font-size: 12px; +} +label small { + color: var(--muted); + font-weight: 400; + font-size: 11px; +} +small { + font-size: 12px; +} +.muted { + color: var(--muted); +} +.numeric { + font-variant-numeric: tabular-nums; +} +.eyebrow { + font-size: 10px; + font-weight: 700; + letter-spacing: 1.7px; + color: var(--muted); +} +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 13px; + border-radius: 7px; + min-height: 43px; + padding: 10px 18px; + font-size: 12px; + font-weight: 650; + white-space: normal; + text-align: center; + transition: + background 0.15s, + transform 0.15s; +} +.button:hover:not(:disabled) { + transform: translateY(-1px); +} +.button.primary { + background: var(--green); + color: white; +} +.button.primary:hover:not(:disabled) { + background: #194f47; +} +.button.dark { + background: var(--ink); + color: white; +} +.button.subtle { + border: 1px solid #d8e0d8; + background: white; +} +.button.subtle:hover:not(:disabled) { + background: #f0f5ec; +} +.button.danger { + color: #993c31; + border: 1px solid #e9bcb4; + background: #fff6f2; +} +.button.compact { + min-height: 34px; + padding: 7px 12px; + font-size: 11px; +} +.full-width { + width: 100%; +} +.text-button { + font-size: 11px; + color: var(--green); + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px; + text-decoration: none; +} +.text-button:hover:not(:disabled) { + text-decoration: underline; +} +.danger-text { + color: #a34636; +} +.required { + color: #a65945; + font-size: 10px; +} +.green-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 100%; + background: #76a96f; + flex-shrink: 0; +} +.console-shell { + min-height: 100vh; +} +.sidebar { + width: 214px; + background: var(--sidebar); + color: #dae6d8; + position: fixed; + inset: 0 auto 0 0; + padding: 35px 22px 20px; + display: flex; + flex-direction: column; + z-index: 20; +} +.wordmark { + font-size: 36px; + font-weight: 850; + letter-spacing: -1.8px; + line-height: 1.15; + display: block; + color: #f5f7ec; +} +.wordmark .brand-dot { + color: var(--lime); +} +.wordmark small { + display: block; + font-size: 7px; + letter-spacing: 2.2px; + margin: 11px 0 49px 1px; + color: #a5c0b0; + font-weight: 600; +} +.sidebar-section-label { + color: #7f9a8d; + font-size: 9px; + letter-spacing: 1.8px; + margin: 0 12px 12px; +} +.sidebar nav { + display: flex; + flex-direction: column; + gap: 6px; +} +.sidebar nav button { + display: flex; + align-items: center; + padding: 13px 14px; + gap: 13px; + border-radius: 6px; + text-align: left; + font-size: 12px; + color: #a9c0b5; +} +.sidebar nav button > span { + width: 18px; + font-size: 20px; + font-weight: 400; + line-height: 1; +} +.sidebar nav button.active { + background: var(--lime); + color: #243e32; + font-weight: 750; +} +.sidebar nav button:not(.active):hover { + background: #27443c; + color: white; +} +.sidebar nav button i { + width: 5px; + height: 5px; + border-radius: 50%; + background: #4a6946; + margin-left: auto; +} +.sidebar-bottom { + margin-top: auto; + padding-top: 40px; +} +.sidebar-tournament { + border-top: 1px solid #355047; + padding: 20px 0; + display: flex; + align-items: center; + gap: 12px; +} +.sidebar-mark { + display: grid; + place-items: center; + background: #345144; + border: 1px solid #4a6552; + color: #d2ef82; + border-radius: 7px; + width: 34px; + height: 34px; + font-weight: bold; +} +.sidebar-tournament strong { + font-size: 11px; + display: block; +} +.sidebar-tournament small { + font-size: 10px; + color: #8fa899; +} +.sidebar-footnote { + color: #7f9a8d; + font-size: 8px; + letter-spacing: 1px; + display: flex; + justify-content: space-between; +} +.workspace { + margin-left: 214px; +} +.workspace-topbar { + height: 64px; + background: #fff; + border-bottom: 1px solid var(--line); + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 32px; + font-size: 11px; +} +.topbar-breadcrumb { + color: var(--muted); +} +.slash { + margin: 0 16px; + color: #b8c1b8; +} +.topbar-right { + display: flex; + align-items: center; + gap: 24px; +} +.connection-state { + display: flex; + align-items: center; + gap: 7px; + color: #95613c; + font-size: 10px; +} +.connection-state i { + width: 5px; + height: 5px; + border-radius: 50%; + background: #c28641; +} +.connection-state.connected { + color: #537761; +} +.connection-state.connected i { + background: #7aab70; +} +.workspace-main { + padding: 29px 32px 16px; + max-width: 1750px; + margin: auto; +} +.page-heading { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 25px; +} +.page-heading h1 { + font-size: 27px; + font-weight: 750; + letter-spacing: -0.7px; + margin: 4px 0 5px; + line-height: 1.45; +} +.page-heading p { + font-size: 11px; + color: var(--muted); +} +.heading-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + font-size: 10px; + color: var(--muted); +} +.demo-badge { + border: 1px solid #d1dabd; + background: #eaf0df; + color: #688047; + border-radius: 4px; + padding: 3px 8px; + font-size: 10px; +} +.overview-strip { + background: #fff; + border: 1px solid var(--line); + border-radius: 10px; + display: grid; + grid-template-columns: 1.2fr 1.3fr 0.8fr 1fr; + padding: 18px 0; + margin-bottom: 24px; +} +.overview-strip > div { + padding: 0 25px; + border-right: 1px solid var(--line); +} +.overview-strip > div:last-child { + border: 0; +} +.stat-label { + display: block; + font-size: 10px; + color: var(--muted); + margin-bottom: 7px; +} +.overview-strip strong { + font-size: 22px; + font-weight: 650; + display: flex; + align-items: center; + gap: 10px; + line-height: 1.3; + font-variant-numeric: tabular-nums; +} +.overview-strip small { + font-size: 11px; + color: var(--muted); + font-weight: 400; + margin-left: -5px; +} +.mini-progress { + background: #eef1e9; + height: 3px; + border-radius: 10px; + width: 100%; + margin-top: 9px; +} +.mini-progress i { + background: #9fbd65; + display: block; + height: 100%; + border-radius: 10px; +} +.control-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 302px; + gap: 22px; + align-items: start; +} +.stack { + display: flex; + flex-direction: column; + gap: 22px; + min-width: 0; +} +.panel { + background: white; + border: 1px solid var(--line); + border-radius: 10px; + overflow: hidden; + min-width: 0; +} +.panel-heading { + padding: 18px 20px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 15px; + border-bottom: 1px solid #edf0e9; + min-height: 63px; +} +.panel-heading h2 { + font-size: 14px; + font-weight: 700; +} +.panel-heading .eyebrow { + font-size: 8px; +} +.panel-heading select { + width: auto; + min-height: 32px; + padding: 5px 10px; + font-size: 11px; +} +.inline-heading { + display: flex; + align-items: center; + gap: 12px; +} +.on-air-label { + font-size: 8px; + color: #9d5141; + background: #fff0e9; + padding: 3px 6px; + border-radius: 3px; + font-weight: 750; + letter-spacing: 0.7px; + display: flex; + align-items: center; + gap: 4px; +} +.on-air-label i { + width: 4px; + height: 4px; + background: #c77462; + border-radius: 50%; +} +.preview-frame { + padding: 17px 18px 0; + background: #fafbf8; +} +.preview-frame > .board { + border-radius: 5px; + overflow: hidden; + box-shadow: 0 3px 5px #122e2415; +} +.preview-caption { + display: flex; + justify-content: space-between; + font-size: 9px; + color: #809180; + background: #fafbf8; + padding: 11px 20px 15px; +} +.preview-caption > span { + display: flex; + align-items: center; + gap: 5px; +} +.preview-caption small { + font-size: 9px; +} +.scene-controls { + padding: 17px 20px 20px; + border-top: 1px solid var(--line); +} +.scene-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +} +.scene-toolbar > b { + font-size: 11px; +} +.scene-toolbar label { + flex-direction: row; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 10px; + font-weight: 400; +} +.scene-toolbar select { + min-height: 30px; + padding: 4px 7px; + font-size: 10px; + width: 105px; +} +.scene-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 8px; +} +.scene-button { + position: relative; + border: 1px solid #e0e7db; + background: #fcfdfb; + border-radius: 6px; + min-height: 93px; + padding: 13px 3px 11px; + display: flex; + align-items: center; + flex-direction: column; + gap: 5px; +} +.scene-button:hover { + background: #f0f5e9; +} +.scene-button.selected { + background: #edf5df; + border-color: #8cab5d; + box-shadow: 0 0 0 1px #8cab5d; +} +.scene-number { + position: absolute; + top: 5px; + left: 7px; + color: #9ba891; + font-size: 8px; +} +.scene-symbol { + height: 25px; + font-size: 20px; + line-height: 1.3; + font-weight: 600; + letter-spacing: -1px; + color: #698464; +} +.scene-button strong { + font-size: 9px; + line-height: 1.4; + white-space: nowrap; + letter-spacing: -0.3px; +} +.scene-selected { + font-size: 7px; + color: #758f43; + position: absolute; + bottom: 3px; +} +.board-pagination { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px 18px; + gap: 10px; + font-size: 11px; + color: var(--muted); +} +.rundown .panel-heading { + border-bottom: 0; + padding-bottom: 5px; +} +.panel-help { + font-size: 10px; + line-height: 1.8; + color: var(--muted); + padding: 0 20px; +} +.padded { + padding: 17px 20px; +} +.rundown > .panel-help { + padding-bottom: 18px; +} +.cue-list { + border-top: 1px solid #edf0e9; +} +.cue-row { + display: flex; + width: 100%; + align-items: center; + text-align: left; + gap: 13px; + padding: 15px 17px; + border-bottom: 1px solid #edf0e9; + min-height: 65px; +} +.cue-index { + font-size: 10px; + font-variant-numeric: tabular-nums; + color: #9ea899; +} +.cue-row > span:nth-child(2) { + flex: 1; +} +.cue-row strong { + display: block; + font-weight: 550; + font-size: 11px; +} +.cue-row small { + color: #9aa497; + font-size: 9px; +} +.cue-arrow { + color: #a1afa0; + font-size: 17px; +} +.cue-row:hover { + background: #fafcf6; +} +.cue-row.on-air { + background: #eff5e5; + box-shadow: inset 3px 0 0 #8bab55; +} +.cue-row.on-air strong { + color: #43643c; + font-weight: 750; +} +.cue-live { + font-size: 8px; + background: #deebc7; + color: #658142; + border-radius: 4px; + padding: 3px 6px; +} +.rundown > .button { + margin: 15px; + width: calc(100% - 30px); + justify-content: space-between; + font-size: 10px; +} +.cue-editor { + padding: 15px; +} +.cue-edit-row { + display: grid; + grid-template-columns: 16px 1fr 20px; + align-items: center; + gap: 6px; + margin-bottom: 15px; +} +.cue-edit-row input { + font-size: 11px; + min-height: 32px; + padding: 6px; +} +.cue-edit-row select { + font-size: 9px; + min-height: 28px; + padding: 4px 1px; + margin-top: 5px; +} +.cue-edit-row .form-grid { + gap: 5px; +} +.cue-move { + display: flex; + flex-direction: column; + font-size: 13px; +} +.operator-note { + padding: 5px 5px 15px; +} +.operator-note > span { + font-size: 8px; + color: #8b9b7d; + letter-spacing: 1.5px; +} +.operator-note h3 { + font-size: 16px; + margin: 10px 0; + font-weight: 650; +} +.operator-note p { + color: #7d8c79; + font-size: 11px; + line-height: 1.9; +} +.note-steps { + display: flex; + align-items: center; + justify-content: space-between; + gap: 3px; + font-size: 8px; + padding-top: 18px; + color: #87977c; +} +.note-steps i { + font-style: normal; +} +.match-selector { + padding: 15px 20px; + background: #fafbf8; + border-bottom: 1px solid var(--line); +} +.match-selector label { + font-size: 10px; + color: #87967d; +} +.match-selector select { + font-size: 12px; +} +.match-editor { + padding: 21px; +} +.section-heading { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + gap: 15px; +} +.section-heading h3 { + font-size: 16px; + margin-top: 6px; + font-weight: 700; +} +.section-heading h3 .muted { + font-size: 10px; + font-weight: 400; + padding: 0 5px; +} +.section-heading .eyebrow { + font-size: 8px; +} +.status { + border-radius: 4px; + padding: 3px 7px; + font-size: 9px; + white-space: nowrap; +} +.status-pending { + background: #f0f2ee; + color: #8a9585; +} +.status-running { + background: #eaf2d7; + color: #6b803f; +} +.status-finished { + background: #e4efea; + color: #477964; +} +.status-replay { + background: #fff0d7; + color: #a67835; +} +.game-tabs { + display: flex; + border-bottom: 1px solid var(--line); + gap: 20px; + margin-bottom: 19px; +} +.game-tabs button { + font-size: 11px; + padding: 10px 0; + border-bottom: 2px solid transparent; +} +.game-tabs button.active { + border-color: #437758; + font-weight: bold; + color: #37644a; +} +.game-tabs button span { + font-size: 9px; + font-weight: 400; + color: #9ba28f; + margin-left: 8px; +} +.game-form { + display: flex; + flex-direction: column; + gap: 17px; +} +.game-form-top { + display: flex; + justify-content: space-between; + align-items: center; +} +.game-form-top b { + font-size: 12px; +} +.game-form-top span { + color: #96a08d; + font-size: 9px; +} +.score-inputs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 15px; +} +.score-input { + background: #f2f7f2; + border: 1px solid #dfe9dc; + border-radius: 7px; + padding: 14px; + gap: 9px; +} +.score-input-b { + background: #fcf4ee; + border-color: #eee2d9; +} +.side-badge { + color: #518a81; + font-size: 8px; + letter-spacing: 1px; +} +.score-input-b .side-badge { + color: #b58268; +} +.score-input strong { + font-size: 13px; + font-weight: 650; +} +.score-input > div { + display: flex; + align-items: center; + gap: 10px; +} +.score-input > div > span { + font-size: 11px; + color: #8b9b85; +} +.score-input input { + width: 92px; + height: 51px; + font-size: 29px; + font-weight: 600; + text-align: center; + padding: 5px; + font-variant-numeric: tabular-nums; + background: white; +} +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 15px; +} +.calculation-preview { + padding: 12px 15px; + background: #f7f9f3; + border-radius: 6px; + display: flex; + align-items: center; + gap: 13px; + flex-wrap: wrap; +} +.calculation-preview > span { + color: #8d9b7d; + font-size: 9px; +} +.calculation-preview strong { + font-size: 11px; + color: #637c48; +} +.calculation-preview small { + font-size: 9px; + color: #8b9b78; + margin-left: auto; +} +.form-bottom { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding-top: 5px; +} +.form-bottom small { + color: #9aa48f; + font-size: 9px; +} +.replay-area { + border-top: 1px solid var(--line); + margin-top: 22px; + padding-top: 10px; +} +.replay-area > .text-button { + font-size: 10px; + color: #929d88; +} +.replay-area form { + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 10px; +} +.replay-area p { + color: #947457; + font-size: 11px; +} +.result-summary { + display: flex; + align-items: center; + gap: 18px; + padding: 19px; + background: #ebf4df; + border: 1px solid #d5e4bc; + border-radius: 7px; + margin-bottom: 17px; +} +.result-trophy { + font-size: 36px; + color: #8eae53; +} +.result-summary small { + color: #8aa663; + letter-spacing: 1.5px; + font-size: 8px; + display: block; +} +.result-summary strong { + font-size: 20px; + display: block; + font-weight: 700; +} +.result-summary div > span { + display: block; + color: #859775; + font-size: 10px; +} +.start-form { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 20px; +} +.start-form p { + color: var(--muted); + font-size: 12px; +} +.start-form .button { + align-self: flex-end; +} +.workspace-footer { + display: flex; + justify-content: space-between; + gap: 20px; + padding-top: 32px; + color: #a0ab99; + font-size: 9px; +} +.notice { + background: #f1f6e6; + color: #65814b; + padding: 12px 16px; + border: 1px solid #dce6c9; + font-size: 12px; + border-radius: 6px; + margin-bottom: 18px; +} +.notice.warning { + background: #fff6df; + color: #9c793b; + border-color: #eeddba; +} +.notice.error { + background: #fff0eb; + color: #9c574a; + border-color: #efcabd; + display: flex; + justify-content: space-between; + gap: 20px; + position: sticky; + top: 10px; + z-index: 30; +} +.notice.error button { + font-size: 20px; +} +.loading-panel { + display: grid; + place-content: center; + min-height: 400px; + text-align: center; + color: var(--muted); +} +.loading-mark { + font-size: 55px; + font-weight: 800; + letter-spacing: -3px; + color: var(--green); +} +.loading-panel p { + font-size: 12px; +} +.confirmation-dialog { + margin: auto; + border: 1px solid #d8e2cf; + border-radius: 12px; + padding: 28px; + width: min(460px, calc(100vw - 32px)); + color: var(--ink); + box-shadow: 0 22px 80px #1029203b; +} +.confirmation-dialog::backdrop { + background: #0d251f88; + backdrop-filter: blur(3px); +} +.confirmation-dialog h2 { + font-size: 21px; + font-weight: 700; + margin: 8px 0 18px; +} +.confirmation-dialog p { + color: #75856b; + font-size: 13px; + line-height: 1.9; + margin-bottom: 25px; +} +.empty-state { + padding: 40px 25px; + text-align: center; + color: #8b9885; + font-size: 12px; + line-height: 1.9; +} +.empty-state small { + display: block; + margin-top: 10px; + font-size: 11px; +} +.toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 20px; + margin-bottom: 20px; +} +.segmented { + display: flex; + padding: 4px; + border: 1px solid var(--line); + border-radius: 8px; + gap: 4px; + background: #ebefe7; +} +.segmented button { + padding: 8px 17px; + font-size: 11px; + border-radius: 5px; + color: #8a987e; +} +.segmented button.active { + color: #476a4f; + background: white; + box-shadow: 0 1px 2px #243e3110; + font-weight: 600; +} +.match-management { + display: grid; + grid-template-columns: minmax(280px, 0.78fr) minmax(0, 1.2fr); + gap: 22px; + align-items: start; +} +.match-list { + max-height: 900px; + overflow: auto; +} +.match-row { + width: 100%; + display: flex; + flex-direction: column; + text-align: left; + padding: 18px 20px; + gap: 9px; + border-bottom: 1px solid var(--line); +} +.match-row.selected { + background: #f0f6e9; + box-shadow: inset 3px 0 #91ad6c; +} +.match-row:hover { + background: #f6f9f0; +} +.match-row-meta { + display: flex; + align-items: center; + justify-content: space-between; +} +.match-row-meta > b { + font-size: 10px; + color: #8a987c; +} +.match-row-players { + display: flex; + gap: 12px; + align-items: center; +} +.match-row-players strong { + font-size: 12px; + font-weight: 600; +} +.match-row-players > span { + color: #a7b199; + font-size: 9px; +} +.match-row > small { + font-size: 9px; + color: #99a28e; +} +.management-grid { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.8fr); + gap: 22px; + align-items: start; +} +.stack-form { + padding: 22px; + display: flex; + flex-direction: column; + gap: 19px; +} +.stack-form p { + font-size: 12px; + color: var(--muted); +} +.stack-form > .button { + align-self: flex-end; +} +.table-scroll { + overflow: auto; +} +.data-table { + width: 100%; + border-collapse: collapse; + white-space: nowrap; + font-size: 12px; +} +.data-table th { + background: #f9fbf6; + font-size: 10px; + text-align: left; + padding: 13px 18px; + color: #94a087; + font-weight: 500; +} +.data-table td { + padding: 16px 18px; + border-bottom: 1px solid var(--line); + color: #84917b; +} +.data-table td strong { + color: var(--ink); + font-weight: 600; +} +.data-table .text-button { + margin-right: 7px; + font-size: 10px; +} +.count-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 23px; + height: 22px; + border-radius: 5px; + color: #8a997c; + background: #f0f4ea; + padding: 0 6px; + font-size: 10px; + margin-left: 5px; +} +.group-badge { + display: inline-grid; + place-items: center; + width: 24px; + height: 24px; + border: 1px solid #dce5d3; + background: #f3f6ed; + border-radius: 4px; + font-size: 11px; + color: #7c915e; +} +.panel-footer { + padding: 20px; + display: flex; + align-items: flex-start; + flex-direction: column; + gap: 18px; +} +.panel-footer p { + font-size: 11px; + color: #8c9980; + line-height: 1.8; +} +.standings-management { + display: flex; + flex-direction: column; + gap: 22px; +} +.standings-panels { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(310px, 1fr)); + gap: 22px; +} +.settings-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 22px; + align-items: start; +} +.rule-explanation { + border-radius: 6px; + background: #f5f8ef; + padding: 17px; +} +.rule-explanation > b { + font-size: 12px; + color: #69804f; +} +.rule-explanation p { + font-size: 11px; + line-height: 1.9; + margin-top: 9px; + color: #849573; +} +.logo-thumbnail { + width: 100%; + height: 100px; + object-fit: contain; + background: #f4f7f0; + padding: 15px; +} +.upload-box { + border: 1px dashed #bdcfac; + background: #fafcf7; + border-radius: 7px; + text-align: center; + padding: 20px; + color: #78965a; + align-items: center; + cursor: pointer; +} +.upload-box input { + font-size: 10px; + width: 100%; + background: transparent; + border: 0; +} +.saved-label { + color: #87a26c; + font-size: 10px; +} +.export-buttons { + display: flex; + gap: 12px; +} +.reset-options { + border-top: 1px solid var(--line); + padding-top: 20px; +} +.reset-options > b { + font-size: 12px; +} +.reset-options p { + margin: 10px 0 20px; + font-size: 11px; + line-height: 1.8; +} +.reset-options .button { + margin-right: 10px; +} +.audit-list { + max-height: 480px; + overflow: auto; +} +.audit-item { + padding: 15px 20px; + border-bottom: 1px solid var(--line); +} +.audit-item time { + color: #9ba48d; + font-size: 9px; +} +.audit-item p { + font-size: 11px; + margin-top: 5px; + overflow-wrap: anywhere; + color: #708164; } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; +/* The audience board uses container units: preview and projection share one render. */ +.board { + aspect-ratio: 16/9; + width: 100%; + container-type: inline-size; + background: #123b36; + color: #f4f4df; + display: flex; + flex-direction: column; + position: relative; +} +.board-header { + display: flex; + align-items: center; + gap: 2cqw; + padding: 2.1cqw 3.5cqw; + border-bottom: 1px solid #3d5d4b; + height: 9.5cqw; + flex-shrink: 0; +} +.board-brand { + font-weight: 850; + font-size: 3.5cqw; + letter-spacing: -0.17cqw; + line-height: 1; +} +.board-brand span { + display: block; + font-size: 0.43cqw; + letter-spacing: 0.2cqw; + font-weight: 400; + margin-top: 0.6cqw; + color: #a2b49b; +} +.board-title { + font-size: 1.2cqw; + color: #bccab2; + letter-spacing: 0.06cqw; + max-width: 58cqw; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.board-phase { + margin-left: auto; + border: 1px solid #49624c; + border-radius: 3cqw; + padding: 0.45cqw 1.2cqw; + font-size: 1cqw; + color: #d8e6b7; + white-space: nowrap; + display: flex; + align-items: center; + gap: 0.7cqw; +} +.board-phase i { + width: 0.45cqw; + height: 0.45cqw; + background: #d3ee7e; + border-radius: 50%; +} +.board-main { + padding: 2.7cqw 3.5cqw 1.5cqw; + flex: 1; + min-height: 0; + overflow: auto; + scrollbar-width: thin; + scrollbar-color: #778968 transparent; +} +.board-kicker { + color: #93ac86; + font-size: 0.85cqw; + letter-spacing: 0.2cqw; + display: flex; + justify-content: space-between; + align-items: center; +} +.board-kicker span:last-child { + letter-spacing: 0.05cqw; +} +.board-heading { + font-size: 2.4cqw; + font-weight: 600; + letter-spacing: 0.08cqw; + margin: 1.3cqw 0 2.1cqw; +} +.versus-grid { + position: relative; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.8cqw; +} +.competitor { + border: 1px solid #587a63; + border-radius: 0.5cqw; + padding: 1.7cqw 2.2cqw; + position: relative; + overflow: hidden; + background: #204d45; +} +.competitor-b { + background: #394c3e; + border-color: #74765a; +} +.competitor.is-winner { + border-color: #d0eb83; + box-shadow: inset 0 0.3cqw #d0eb83; +} +.competitor-label { + display: flex; + justify-content: space-between; + color: #80b1a0; + font-size: 0.75cqw; + letter-spacing: 0.15cqw; + margin-bottom: 1cqw; +} +.competitor-b .competitor-label { + color: #c2bd91; +} +.competitor-label span { + color: #d4ef8c; +} +.competitor h3 { + font-size: 3.1cqw; + letter-spacing: 0.08cqw; + font-weight: 650; + line-height: 1.35; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.competitor p { + color: #a4bca2; + font-size: 0.95cqw; + margin-top: 0.4cqw; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.competitor-score { + display: flex; + align-items: center; + gap: 2.3cqw; + margin-top: 1.1cqw; +} +.competitor-score > strong { + font-size: 9cqw; + line-height: 1.1; + font-weight: 700; + letter-spacing: -0.6cqw; + font-variant-numeric: tabular-nums; +} +.competitor-score > div { + display: flex; + flex-direction: column; + gap: 0.9cqw; +} +.competitor-score span { + color: #aebf9f; + font-size: 1cqw; +} +.competitor-score b { + font-weight: 500; + font-size: 1.2cqw; + color: #d2e894; + background: #4c664443; + border: 1px solid #638061; + border-radius: 0.3cqw; + padding: 0.3cqw 0.9cqw; +} +.versus-mark { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + background: #d4e68a; + color: #2c4a37; + width: 3.5cqw; + height: 3.5cqw; + border-radius: 50%; + font-size: 1cqw; + font-weight: 800; + display: grid; + place-items: center; + border: 0.45cqw solid #163f37; + box-sizing: content-box; +} +.board-games { + margin-top: 1.6cqw; + display: flex; + flex-direction: column; + gap: 0.7cqw; +} +.board-games > div { + display: flex; + align-items: center; + gap: 2.3cqw; + background: #20483b; + border-radius: 0.2cqw; + padding: 0.6cqw 1.4cqw; + font-size: 1cqw; + color: #a9b994; +} +.board-games b { + font-size: 0.75cqw; + font-weight: 400; + color: #82a174; + letter-spacing: 0.12cqw; +} +.board-games > div > span:first-of-type { + font-weight: 600; + color: #d7ddbf; + min-width: 9cqw; + font-size: 1.2cqw; +} +.board-games em { + font-style: normal; + margin-left: auto; + font-size: 0.8cqw; + color: #d2e690; +} +.board-footer { + height: 3.4cqw; + flex-shrink: 0; + border-top: 1px solid #3f5b43; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 3.5cqw; + color: #809777; + font-size: 0.65cqw; + letter-spacing: 0.08cqw; +} +.board-footer b { + font-weight: 500; + color: #bacaa0; +} +.winner-headline { + display: flex; + align-items: baseline; + gap: 1.8cqw; + font-size: 1.6cqw; + color: #d6ed89; + font-weight: 750; + margin: 1.15cqw 0 1.7cqw; + letter-spacing: 0.15cqw; +} +.winner-headline > span { + font-size: 2.9cqw; + letter-spacing: 0; + color: #eaf3c7; +} +.winner-headline small { + font-size: 1cqw; + font-weight: 400; + color: #a8bc8d; + margin-left: auto; + letter-spacing: 0; +} +.ceremony { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; +} +.ceremony-eyebrow { + color: #a4bc89; + letter-spacing: 0.5cqw; + font-size: 1cqw; +} +.ceremony-logo { + font-size: 15cqw; + font-weight: 850; + letter-spacing: -1cqw; + line-height: 1.25; + margin: 0.5cqw 0 0.6cqw; + color: #f0f0d6; +} +.ceremony-logo > span { + color: #a7c48e; +} +.ceremony-logo .logo-period { + color: #d0eb78; +} +.ceremony h2 { + font-size: 3cqw; + font-weight: 650; + letter-spacing: 0.5cqw; +} +.ceremony p { + font-size: 1.2cqw; + color: #a9c196; + margin-top: 1.2cqw; + letter-spacing: 0.15cqw; +} +.intermission-logo { + max-width: 65cqw; + max-height: 20cqw; + object-fit: contain; + margin: 2cqw; +} +.board-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; + gap: 1.5cqw; + color: #d3e4b8; +} +.board-empty h2 { + font-size: 2.5cqw; + font-weight: 500; +} +.board-empty p { + font-size: 1.2cqw; + color: #90aa7c; +} +.large-symbol { + font-size: 9cqw; + line-height: 1; + color: #cde282; +} +.board-standings { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 2cqw; +} +.standings-group { + overflow: auto; +} +.group-title { + display: flex; + justify-content: space-between; + align-items: center; + padding: 18px; + font-size: 11px; + font-weight: 650; + color: #637e4e; + background: #f7f9f1; + border-bottom: 1px solid var(--line); +} +.group-title small { + font-size: 9px; + color: #9ca88b; + font-weight: 400; +} +.standings-group table { + width: 100%; + border-collapse: collapse; + white-space: nowrap; +} +.standings-group th { + text-align: left; + padding: 10px 12px; + color: #97a285; + font-size: 9px; + font-weight: 400; +} +.standings-group td { + padding: 14px 12px; + border-top: 1px solid #edf1e7; + font-size: 12px; +} +.standings-group td strong { + font-size: 12px; + font-weight: 550; +} +.standings-group td small { + display: block; + color: #9ca88d; + font-size: 9px; +} +.standings-group td:last-child { + font-weight: 700; +} +.rank-number { + color: #8fa47a; +} +.qualifier-row .rank-number { + background: #e9f2dc; + color: #69804d; + border-radius: 4px; + padding: 3px 6px; +} +.board .standings-group { + border: 1px solid #46664b; + border-radius: 0.4cqw; +} +.board .group-title { + background: #28513e; + border: 0; + padding: 1.1cqw 1.3cqw; + color: #c8dc9a; + font-size: 1cqw; + letter-spacing: 0.13cqw; +} +.board .group-title small { + font-size: 0.75cqw; + color: #96b37d; + letter-spacing: 0; +} +.board .standings-group th { + padding: 0.7cqw 1cqw; + font-size: 0.7cqw; + color: #8fa67c; +} +.board .standings-group td { + padding: 0.8cqw 1cqw; + font-size: 1.3cqw; + border-top: 1px solid #365a42; +} +.board .standings-group td strong { + font-size: 1.35cqw; + color: #e5edcf; +} +.board .standings-group td small { + font-size: 0.65cqw; + color: #8da47b; +} +.board .rank-number { + color: #8aa079; +} +.board .qualifier-row .rank-number { + background: #516c37; + color: #d4e999; + padding: 0.2cqw 0.6cqw; + border-radius: 0.25cqw; +} +.board .standings-group td:last-child { + color: #d4e59e; + font-size: 1.7cqw; +} +.board-note { + font-size: 0.8cqw; + color: #86a071; + margin-top: 1.2cqw; +} +.bracket { + display: flex; + gap: 35px; + padding: 20px; + overflow: auto; + min-height: 270px; +} +.bracket-round { + min-width: 180px; + flex: 1; + display: flex; + flex-direction: column; +} +.round-title { + font-size: 10px; + letter-spacing: 1px; + color: #889c77; + text-align: center; + margin-bottom: 20px; +} +.round-matches { + display: flex; + flex-direction: column; + justify-content: space-around; + gap: 20px; + flex: 1; +} +.bracket-match { + border: 1px solid #d7e3c8; + border-radius: 5px; + overflow: hidden; + position: relative; + background: #f8fbf2; +} +.bracket-match > small { + font-size: 9px; + color: #8f9e7c; + display: block; + padding: 7px 11px; + border-bottom: 1px solid #e2ebd9; +} +.bracket-match > div { + display: flex; + justify-content: space-between; + gap: 15px; + padding: 10px; + font-size: 11px; + border-bottom: 1px solid #e2ebd9; +} +.bracket-match > div:last-child { + border: 0; +} +.bracket-match b { + color: #74984d; + font-weight: 600; +} +.bracket-winner { + background: #e7f1d4; + color: #537338; + font-weight: 650; +} +.board .bracket { + height: 100%; + min-height: 0; + gap: 3cqw; + padding: 2cqw 0; +} +.board .bracket-round { + min-width: 23cqw; +} +.board .round-title { + font-size: 0.8cqw; + letter-spacing: 0.2cqw; + color: #b0c48e; + margin-bottom: 1cqw; +} +.board .round-matches { + gap: 1cqw; +} +.board .bracket-match { + border-color: #567049; + background: #244a39; + border-radius: 0.4cqw; +} +.board .bracket-match > small { + padding: 0.4cqw 1cqw; + border-color: #48613d; + font-size: 0.65cqw; + color: #97b178; +} +.board .bracket-match > div { + padding: 0.8cqw 1cqw; + font-size: 1.2cqw; + border-color: #48613d; + color: #becfa0; + gap: 1cqw; +} +.board .bracket-match b { + color: #cbdf90; +} +.board .bracket-winner { + background: #3c5e34; + color: #def0b5 !important; +} +.board .bracket-paged .round-matches { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-content: center; + gap: 2cqw; +} +.display-page { + background: #0c211e; + min-height: 100dvh; + width: 100vw; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +.display-page > .board { + width: min(100vw, 177.7778dvh); + max-height: 100dvh; +} +.connection-banner { + position: fixed; + bottom: 15px; + left: 50%; + transform: translateX(-50%); + z-index: 50; + padding: 9px 18px; + border-radius: 5px; + background: #e6aa46; + color: #3d341f; + font-size: 14px; +} +.display-loading { + color: #d2e49b; + font-size: 70px; + font-weight: 800; + letter-spacing: -3px; + text-align: center; +} +.display-loading span { + display: block; + font-size: 14px; + font-weight: 400; + letter-spacing: 1px; +} +@media (min-width: 1600px) { + .control-grid { + grid-template-columns: minmax(0, 1fr) 345px; + } + .scene-button { + min-height: 103px; + } + .scene-button strong { + font-size: 11px; } } - -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; +@media (max-width: 1250px) { + .sidebar { + width: 185px; + padding: 32px 16px 20px; + } + .workspace { + margin-left: 185px; + } + .workspace-main { + padding: 24px; + } + .workspace-topbar { + padding: 0 24px; + } + .control-grid { + grid-template-columns: minmax(0, 1fr) 265px; + gap: 17px; + } + .scene-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .scene-button { + min-height: 78px; + } + .scene-button strong { + font-size: 10px; + } + .overview-strip > div { + padding: 0 18px; + } + .inline-heading { + gap: 8px; + } + .inline-heading h2 { + font-size: 12px; + } + .match-management { + grid-template-columns: minmax(230px, 0.75fr) minmax(0, 1.25fr); + } + .match-row { + padding: 16px 14px; + } + .section-heading h3 { + font-size: 14px; + } + .match-editor { + padding: 17px; + } +} +@media (max-width: 1000px) { + .sidebar { + width: 170px; + } + .workspace { + margin-left: 170px; + } + .workspace-main { + padding: 20px; + } + .control-grid { + grid-template-columns: 1fr; + } + .control-grid > .stack:last-child { + display: grid; + grid-template-columns: 1.5fr 1fr; + align-items: start; + } + .scene-grid { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } + .match-management, + .management-grid, + .settings-grid { + grid-template-columns: 1fr; + } + .match-list { + max-height: 380px; + } + .overview-strip > div { + padding: 0 14px; + } + .topbar-right { + gap: 12px; + } + .sidebar nav button { + padding: 12px 9px; + gap: 9px; + } + .sidebar nav button > span { + font-size: 17px; + } +} +@media (max-width: 760px) { + .sidebar { + position: relative; + width: 100%; + padding: 13px 16px; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 15px; + } + .wordmark { + font-size: 25px; + } + .wordmark small, + .sidebar-section-label, + .sidebar-bottom { + display: none; + } + .sidebar nav { + flex-direction: row; + gap: 4px; + } + .sidebar nav button { + font-size: 10px; + padding: 9px; + gap: 5px; + } + .sidebar nav button > span { + display: none; + } + .sidebar nav button i { + display: none; + } + .workspace { + margin: 0; + } + .workspace-topbar { + height: 53px; + padding: 0 17px; + font-size: 10px; + } + .workspace-main { + padding: 21px 16px 12px; + } + .page-heading h1 { + font-size: 23px; + } + .page-heading .eyebrow { + font-size: 8px; + } + .page-heading p { + font-size: 10px; + } + .connection-state { + font-size: 9px; + } + .topbar-right .button { + font-size: 10px; + padding: 6px 9px; + } + .overview-strip { + grid-template-columns: 1fr 1fr; + row-gap: 17px; + padding: 16px 0; + } + .overview-strip > div:nth-child(2) { + border: 0; + } + .overview-strip strong { + font-size: 21px; + } + .control-grid > .stack:last-child { + display: flex; + } + .preview-frame { + padding: 12px 12px 0; + } + .panel-heading { + padding: 15px; + } + .scene-controls { + padding: 16px; + } + .scene-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .scene-button { + min-height: 84px; + } + .heading-meta { + font-size: 9px; + } + .preview-caption { + padding: 10px 14px; + } + .preview-caption small { + font-size: 8px; + } + .section-heading { + align-items: flex-start; + } + .section-heading h3 { + font-size: 13px; + } + .game-form-top > span { + font-size: 8px; + } + .form-bottom { + flex-wrap: wrap; + } + .form-bottom > .button { + margin-left: auto; + } + .form-bottom small { + font-size: 9px; + } + .game-tabs { + gap: 13px; + } + .score-input { + padding: 12px; + } + .score-input strong { + font-size: 12px; + } + .score-input input { + width: 80px; + } + .workspace-footer { + flex-direction: column; + gap: 5px; + } + .standings-panels { + grid-template-columns: 1fr; + } + .data-table td, + .data-table th { + padding: 12px; + } + .stack-form { + padding: 18px; + } + .toolbar { + gap: 8px; + } + .segmented button { + padding: 8px 10px; + font-size: 10px; + } + .toolbar > .button { + font-size: 10px; + padding: 8px 10px; + } +} +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + } } diff --git a/chros/app/layout.tsx b/chros/app/layout.tsx index f7fa87e..ce6ea92 100644 --- a/chros/app/layout.tsx +++ b/chros/app/layout.tsx @@ -1,34 +1,13 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - +import type { Metadata } from 'next'; +import './globals.css'; export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: 'CHroS — 大会コントロール', + description: 'CHaser の対戦記録と大会掲示をひとつに。', }; - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { +export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - - {children} - + + {children} ); } diff --git a/chros/app/page.tsx b/chros/app/page.tsx index 21b686d..8a83491 100644 --- a/chros/app/page.tsx +++ b/chros/app/page.tsx @@ -1,103 +1,4 @@ -import Image from "next/image"; - +import { redirect } from 'next/navigation'; export default function Home() { - return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
- - -
- -
- ); + redirect('/console'); } diff --git a/chros/components/Board.tsx b/chros/components/Board.tsx new file mode 100644 index 0000000..26be7f0 --- /dev/null +++ b/chros/components/Board.tsx @@ -0,0 +1,58 @@ +import { phaseLabels, type ControlState } from '@chros/shared'; +import { MatchScene } from './board/MatchScene'; +import { BracketScene, CeremonyScene, LogoScene, StandingsScene } from './board/OverviewScenes'; +export { Bracket } from './board/Bracket'; +export { StandingsTable } from './board/StandingsTable'; + +function BoardContent({ state }: { state: ControlState }) { + switch (state.scene) { + case 'match': + case 'result': { + const match = state.matches.find((candidate) => candidate.id === state.currentMatchId); + if (match) return ; + return ( +
+

次の対戦を準備しています

+

組み合わせが決まり次第、ご案内します

+
+ ); + } + case 'standings': + return ; + case 'bracket': + return ; + case 'logo': + return ; + default: + return ; + } +} + +export default function Board({ state }: { state: ControlState }) { + return ( +
+
+
+ CHroSCHASER TOURNAMENT +
+ {state.title} + + + {phaseLabels[state.phase]} + +
+
+ +
+
+ + {state.demo + ? 'SAMPLE TOURNAMENT · 架空の参加者によるデモ' + : 'CHASER PROGRESSION CONTROL SYSTEM'} + + 得点は各戦の終了後に更新 + CHroS / {String(state.revision).padStart(3, '0')} +
+
+ ); +} diff --git a/chros/components/Confirmation.tsx b/chros/components/Confirmation.tsx new file mode 100644 index 0000000..774f978 --- /dev/null +++ b/chros/components/Confirmation.tsx @@ -0,0 +1,52 @@ +'use client'; +import { createContext, useContext, useEffect, useRef, useState } from 'react'; +type ConfirmAction = (message: string) => Promise; +const ConfirmationContext = createContext(async () => false); +export const useConfirmation = () => useContext(ConfirmationContext); +export default function ConfirmationProvider({ children }: { children: React.ReactNode }) { + const [message, setMessage] = useState(''); + const resolvePending = useRef<((accepted: boolean) => void) | null>(null); + const dialogRef = useRef(null); + const requestConfirmation: ConfirmAction = (text) => + new Promise((resolve) => { + resolvePending.current?.(false); + resolvePending.current = resolve; + setMessage(text); + }); + const resolveConfirmation = (accepted: boolean) => { + dialogRef.current?.close(); + setMessage(''); + resolvePending.current?.(accepted); + resolvePending.current = null; + }; + useEffect(() => { + if (message && !dialogRef.current?.open) dialogRef.current?.showModal(); + }, [message]); + return ( + + {children} + { + event.preventDefault(); + resolveConfirmation(false); + }} + > + CONFIRM ACTION +

操作を確認

+

{message}

+
+ + +
+
+
+ ); +} diff --git a/chros/components/ConsoleApp.tsx b/chros/components/ConsoleApp.tsx new file mode 100644 index 0000000..6aa3eff --- /dev/null +++ b/chros/components/ConsoleApp.tsx @@ -0,0 +1,166 @@ +'use client'; + +import type { ControlProps } from '@/lib/control-types'; +import { useControl } from '@/lib/use-control'; +import { useState } from 'react'; +import ConfirmationProvider from './Confirmation'; +import ControlDesk from './console/ControlDesk'; +import { Matches } from './management/Matches'; +import { Players } from './management/Players'; +import { Settings } from './management/Settings'; + +type Tab = 'control' | 'matches' | 'players' | 'settings'; +const tabs: { id: Tab; label: string; symbol: string; caption: string }[] = [ + { id: 'control', label: 'コントロール', symbol: '◫', caption: '大会コントロール' }, + { id: 'matches', label: '対戦・記録', symbol: '↔', caption: '対戦と結果の記録' }, + { id: 'players', label: '参加者', symbol: '♧', caption: '参加者の管理' }, + { id: 'settings', label: '設定・履歴', symbol: '⚙', caption: '大会の設定と記録' }, +]; + +function WorkspaceTab({ + state, + send, + tab, + openMatches, +}: ControlProps & { tab: Tab; openMatches: () => void }) { + switch (tab) { + case 'control': + return ; + case 'matches': + return ; + case 'players': + return ; + case 'settings': + return ; + } +} + +export default function ConsoleApp(props: { initialTab?: Tab }) { + return ( + + + + ); +} +function ConsoleShell({ initialTab = 'control' }: { initialTab?: Tab }) { + const [tab, setTab] = useState(initialTab); + const { state, connected, pending, error, setError, send } = useControl(); + const activeTab = tabs.find((candidate) => candidate.id === tab)!; + let connectionLabel = '再接続中…'; + if (connected) connectionLabel = pending ? '保存中…' : 'サーバー接続中'; + + return ( +
+ +
+
+
+ 大会運営 + / + {activeTab.label} +
+
+ + + {connectionLabel} + + + 会場スクリーン ↗ + +
+
+
+
+
+ CHASER / CONTROL ROOM +

{activeTab.caption}

+

{state?.title ?? '大会の情報を読み込んでいます'}

+
+
+ {state?.demo && サンプルデータ} + + {state + ? `保存 ${new Date(state.updatedAt).toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit' })}` + : '接続待ち'} + +
+
+ {!connected && ( +
+ {state + ? '接続を確認中です。最後に受信した内容を表示しています。' + : 'サーバーに接続しています…'} +
+ )} + {error && ( +
+ {error} + +
+ )} + {state ? ( +
+ setTab('matches')} + /> +
+ ) : ( +
+ CHroS. +

記録と掲示を、ひとつに。

+
+ )} +
+ CHroS — CHaser Progression Control System + 対人戦 / 1試合2戦 / 終了後更新 +
+
+
+
+ ); +} diff --git a/chros/components/MatchEditor.tsx b/chros/components/MatchEditor.tsx new file mode 100644 index 0000000..ee00335 --- /dev/null +++ b/chros/components/MatchEditor.tsx @@ -0,0 +1,62 @@ +'use client'; + +import type { SendCommand } from '@/lib/control-types'; +import { evaluateMatch, playerName } from '@chros/scoring'; +import { type ControlState, type Match } from '@chros/shared'; +import { MatchControls } from './matches/MatchControls'; +export const statusLabels: Record = { + pending: '未開始', + running: '対戦中', + finished: '確定', + replay: '再試合待ち', +}; + +export default function MatchEditor({ + state, + match, + send, +}: { + state: ControlState; + match: Match; + send: SendCommand; +}) { + const result = evaluateMatch(match, state.profile); + return ( +
+
+
+ MATCH RECORD / {match.label} +

+ {playerName(state, match.a)} vs{' '} + {playerName(state, match.b)} +

+
+ {statusLabels[result.status]} +
+ {match.attempt > 1 && ( +
+ 再試合 {match.attempt - 1} 回目 · 以前の結果は無効として保管しています。 +
+ )} + {result.winnerId && ( +
+ ↗ +
+ MATCH WINNER + {playerName(state, result.winnerId)} + + {result.decidedBy}で勝利 · 特殊P {result.a.special}–{result.b.special} / 合計得点{' '} + {result.a.points}–{result.b.points} + +
+
+ )} + {result.tied && ( +
+ 2戦合計が同値です。新しいマップで両戦を仕切り直してください。 +
+ )} + +
+ ); +} diff --git a/chros/components/Rundown.tsx b/chros/components/Rundown.tsx new file mode 100644 index 0000000..83386fe --- /dev/null +++ b/chros/components/Rundown.tsx @@ -0,0 +1,66 @@ +'use client'; + +import type { ControlProps } from '@/lib/control-types'; +import { sceneLabels } from '@chros/shared'; +import { useState } from 'react'; +import CueEditor from './rundown/CueEditor'; + +export default function Rundown({ state, send }: ControlProps) { + const [editing, setEditing] = useState(false); + const currentCueIndex = state.cues.findIndex((cue) => cue.id === state.cueId); + const nextCue = state.cues[currentCueIndex + 1]; + let nextCueLabel = '表示順の最後です'; + if (nextCue) { + nextCueLabel = currentCueIndex < 0 ? '表示順の先頭を表示' : `次へ · ${nextCue.label}`; + } + + return ( +
+
+
+ RUNDOWN +

表示の進行表

+
+ +
+ {editing ? ( + setEditing(false)} /> + ) : ( + <> +

順番を準備して、当日は「次へ」。

+
+ {state.cues.map((cue, index) => ( + + ))} +
+ + + )} +
+ ); +} diff --git a/chros/components/board/Bracket.tsx b/chros/components/board/Bracket.tsx new file mode 100644 index 0000000..ce477f8 --- /dev/null +++ b/chros/components/board/Bracket.tsx @@ -0,0 +1,73 @@ +import { evaluateMatch, playerName } from '@chros/scoring'; +import { BRACKET_MATCHES_PER_PAGE } from '@/lib/board-pages'; +import { type ControlState } from '@chros/shared'; + +function roundTitle(round: number, finalRound: number) { + if (round === finalRound) return 'FINAL'; + if (round === finalRound - 1) return 'SEMI FINAL'; + return `ROUND ${round}`; +} + +export function Bracket({ + state, + page, +}: { + state: ControlState; + page?: { round: number; offset: number }; +}) { + const allMatches = state.matches.filter((match) => match.stage === 'finals'); + const finalRound = Math.max(...allMatches.map((match) => match.round)); + const matches = page + ? allMatches + .filter((match) => match.round === page.round) + .slice(page.offset, page.offset + BRACKET_MATCHES_PER_PAGE) + : allMatches; + const rounds = [...new Set(matches.map((match) => match.round))].sort((a, b) => a - b); + if (!matches.length) + return ( +
+ ↗ +

本戦への道は、ここから。

+

予選終了後に組み合わせを発表します

+
+ ); + return ( +
+ {rounds.map((round) => ( +
+
+ {roundTitle(round, finalRound)} + {page ? ` · ${page.offset + 1}–${page.offset + matches.length}` : ''} +
+
+ {matches + .filter((match) => match.round === round) + .map((match) => { + const result = evaluateMatch(match, state.profile); + return ( +
+ + {match.label} + {match.bye ? ' · 不戦進出' : ''} + + {(['a', 'b'] as const).map((side) => { + const playerId = match[side]; + const isWinner = result.winnerId === playerId && !!playerId; + const emptySlotLabel = match.bye ? '不戦枠' : '対戦者未定'; + const pointsLabel = match.games.length ? result[side].points : '–'; + return ( +
+ {playerId ? playerName(state, playerId) : emptySlotLabel} + {isWinner ? 'WIN' : pointsLabel} +
+ ); + })} +
+ ); + })} +
+
+ ))} +
+ ); +} diff --git a/chros/components/board/MatchScene.tsx b/chros/components/board/MatchScene.tsx new file mode 100644 index 0000000..5b6d375 --- /dev/null +++ b/chros/components/board/MatchScene.tsx @@ -0,0 +1,90 @@ +import { evaluateMatch, playerName } from '@chros/scoring'; +import { reasonLabels, type ControlState } from '@chros/shared'; + +import type { Match } from '@chros/shared'; + +function progressLabel(match: Match, result: ReturnType) { + if (result.winnerId) return '試合確定'; + if (result.tied) return '同点 · 再試合待ち'; + if (match.games.length) return `第${match.games.length}戦 終了時点`; + return match.started ? '第1戦 対戦中' : 'まもなく対戦'; +} + +function coolPlayerId(match: Match, gameNumber: 1 | 2) { + if (!match.firstCool) return null; + if (gameNumber === 1) return match.firstCool; + return match.firstCool === match.a ? match.b : match.a; +} + +export function MatchScene({ state, match }: { state: ControlState; match: Match }) { + const result = evaluateMatch(match, state.profile); + return ( + <> +
+ + {match.stage === 'qualifying' ? `QUALIFYING · GROUP ${match.group}` : 'CHAMPIONSHIP'} /{' '} + {match.label} + {match.attempt > 1 ? ` · 再試合 ${match.attempt - 1}` : ''} + + {progressLabel(match, result)} +
+ {state.scene === 'result' && result.winnerId ? ( +
+ WINNER {playerName(state, result.winnerId)} + {result.decidedBy}で勝利 +
+ ) : ( +

+ {state.scene === 'result' ? '結果の確定をお待ちください' : 'その一手が、未来を変える。'} +

+ )} +
+ {(['a', 'b'] as const).map((side, index) => { + const player = state.players.find((player) => player.id === match[side]); + return ( +
+
+ PLAYER {index + 1} + {result.winnerId === match[side] ? 'WINNER ↗' : ''} +
+

{player?.name ?? '対戦者未定'}

+

{player?.affiliation || 'CHaser challenger'}

+
+ {match.games.length ? result[side].points : '–'} +
+ {state.profile === 'asahikawa' ? '換算得点' : '合計得点'} + + {state.profile === 'kushiro' + ? `特殊P ${result[side].special}` + : `${result[side].wins} ゲーム勝利`} + +
+
+
+ ); + })} +
VS
+
+
+ {([1, 2] as const).map((number) => { + const game = match.games.find((game) => game.number === number); + const cool = coolPlayerId(match, number); + const startingPlayerLabel = cool + ? `先攻 ${playerName(state, cool)}` + : '先攻・後攻はじゃんけんで決定'; + return ( +
+ GAME 0{number} + {game ? `${game.scoreA} — ${game.scoreB}` : '未記録'} + {game ? reasonLabels[game.reason] : startingPlayerLabel} + {game?.specialWinner && {playerName(state, match[game.specialWinner])} +1P} +
+ ); + })} +
+ + ); +} diff --git a/chros/components/board/OverviewScenes.tsx b/chros/components/board/OverviewScenes.tsx new file mode 100644 index 0000000..585d79b --- /dev/null +++ b/chros/components/board/OverviewScenes.tsx @@ -0,0 +1,88 @@ +import { boardPages, STANDINGS_ROWS_PER_CARD } from '@/lib/board-pages'; +import { evaluateMatch } from '@chros/scoring'; +import { phaseLabels, type ControlState } from '@chros/shared'; + +import { Bracket } from './Bracket'; +import { StandingsTable } from './StandingsTable'; + +export function StandingsScene({ state }: { state: ControlState }) { + const qualifying = state.matches.filter((match) => match.stage === 'qualifying'); + const completed = qualifying.filter( + (match) => evaluateMatch(match, state.profile).winnerId, + ).length; + const pages = boardPages(state); + return ( + <> +
+ QUALIFYING STANDINGS + + {completed} / {qualifying.length} 試合確定 · PAGE {pages.index + 1}/{pages.count} + +
+

予選の、その先へ。

+
+ {pages.cards.map((card) => ( + + ))} +
+

確定した試合のみ集計 · 同順位の進出は主催者の裁定で確定

+ + ); +} + +export function BracketScene({ state }: { state: ControlState }) { + const pages = boardPages(state); + return ( + <> +
+ CHAMPIONSHIP BRACKET + + 本戦トーナメント · PAGE {pages.index + 1}/{pages.count} + +
+ + + ); +} + +export function LogoScene({ state }: { state: ControlState }) { + return ( +
+ {phaseLabels[state.phase]} / INTERMISSION + {state.logo ? ( + 大会ロゴ + ) : ( +
+ CHroS. +
+ )} +

次の挑戦まで、ひと息。

+

{state.title}

+
+ ); +} + +export function CeremonyScene({ state }: { state: ControlState }) { + return ( +
+ {state.subtitle || 'U-16 PROGRAMMING CONTEST'} +
+ CHroS. +
+

{phaseLabels[state.phase]}

+

+ {state.phase === 'closing' + ? 'すべての挑戦に、ありがとう。' + : state.phase === 'opening' + ? 'ここから、次の一手がはじまる。' + : '自分のコードで、勝利をつかめ。'} +

+
+ ); +} diff --git a/chros/components/board/StandingsTable.tsx b/chros/components/board/StandingsTable.tsx new file mode 100644 index 0000000..860c7e2 --- /dev/null +++ b/chros/components/board/StandingsTable.tsx @@ -0,0 +1,60 @@ +import { standings } from '@chros/scoring'; +import { type ControlState } from '@chros/shared'; + +export function StandingsTable({ + state, + group, + offset = 0, + limit, +}: { + state: ControlState; + group: string; + offset?: number; + limit?: number; +}) { + const allRows = standings(state, group); + const rows = allRows.slice(offset, limit ? offset + limit : undefined); + return ( +
+
+ + GROUP {group} + {limit && allRows.length > limit ? ` · ${offset + 1}–${offset + rows.length}` : ''} + + 上位 {Math.min(allRows.length, state.advancePerGroup)} 名が本戦へ +
+ + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + ))} + +
順位参加者勝–敗特殊P{state.profile === 'asahikawa' ? '換算得点' : '得点'}
+ {row.played ? row.rank : '–'} + + {row.player.name} + {row.player.affiliation} + + {row.wins}–{row.losses} + {row.special}{row.points}
+
+ ); +} diff --git a/chros/components/console/BroadcastPanel.tsx b/chros/components/console/BroadcastPanel.tsx new file mode 100644 index 0000000..10bfafb --- /dev/null +++ b/chros/components/console/BroadcastPanel.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { boardPages } from '@/lib/board-pages'; +import type { ControlProps } from '@/lib/control-types'; +import { + phaseLabels, + phases, + sceneLabels, + scenes, + type ControlState, + type Scene, +} from '@chros/shared'; +import Board from '../Board'; + +const sceneSymbols: Record = { + ceremony: '◷', + match: 'VS', + result: '↗', + standings: '≡', + bracket: '⑂', + logo: 'CH', +}; + +export function BroadcastPanel({ state, send }: ControlProps) { + const pages = boardPages(state); + return ( +
+
+
+ + + ON AIR + +

いまの会場スクリーン

+
+ + 掲示画面を開く ↗ + +
+
+ +
+
+ + + {sceneLabels[state.scene]} を掲示中 + + 各戦の記録後に得点を更新 +
+
+
+ 画面を切り替え + +
+
+ {scenes.map((scene, index) => ( + + ))} +
+
+ {pages.count > 1 && ( +
+ + + 掲示ページ {pages.index + 1} / {pages.count} + + +
+ )} +
+ ); +} diff --git a/chros/components/console/ControlDesk.tsx b/chros/components/console/ControlDesk.tsx new file mode 100644 index 0000000..499dd6f --- /dev/null +++ b/chros/components/console/ControlDesk.tsx @@ -0,0 +1,46 @@ +'use client'; + +import type { SendCommand } from '@/lib/control-types'; +import { type ControlState } from '@chros/shared'; +import Rundown from '../Rundown'; + +import { BroadcastPanel } from './BroadcastPanel'; +import { CurrentMatchPanel } from './CurrentMatchPanel'; +import { TournamentOverview } from './TournamentOverview'; + +export default function ControlDesk({ + state, + send, + goMatches, +}: { + state: ControlState; + send: SendCommand; + goMatches: () => void; +}) { + return ( + <> + +
+
+ + +
+
+ +
+ OPERATOR’S NOTE +

記録して、伝える。

+

第1戦を保存したら、先攻・後攻を交代。第2戦の保存で試合結果と順位が確定します。

+
+ 01 得点・勝因 + → + 02 自動集計 + → + 03 会場へ +
+
+
+
+ + ); +} diff --git a/chros/components/console/CurrentMatchPanel.tsx b/chros/components/console/CurrentMatchPanel.tsx new file mode 100644 index 0000000..7086fee --- /dev/null +++ b/chros/components/console/CurrentMatchPanel.tsx @@ -0,0 +1,55 @@ +'use client'; + +import type { SendCommand } from '@/lib/control-types'; +import { playerName } from '@chros/scoring'; +import { type ControlState } from '@chros/shared'; +import MatchEditor from '../MatchEditor'; + +export function CurrentMatchPanel({ + state, + send, + goMatches, +}: { + state: ControlState; + send: SendCommand; + goMatches: () => void; +}) { + const match = state.matches.find((candidate) => candidate.id === state.currentMatchId); + return ( +
+
+
+ NOW PLAYING +

掲示する試合・得点入力

+
+ +
+
+ +
+ {match ? ( + + ) : ( +
参加者を登録して、対戦を作成しましょう。
+ )} +
+ ); +} diff --git a/chros/components/console/TournamentOverview.tsx b/chros/components/console/TournamentOverview.tsx new file mode 100644 index 0000000..35604eb --- /dev/null +++ b/chros/components/console/TournamentOverview.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { evaluateMatch } from '@chros/scoring'; +import { phaseLabels, type ControlState } from '@chros/shared'; + +export function TournamentOverview({ state }: { state: ControlState }) { + const match = state.matches.find((candidate) => candidate.id === state.currentMatchId); + const qualifying = state.matches.filter((candidate) => candidate.stage === 'qualifying'); + const finished = qualifying.filter( + (candidate) => evaluateMatch(candidate, state.profile).winnerId, + ).length; + const progressPercent = qualifying.length ? (finished / qualifying.length) * 100 : 0; + return ( +
+
+ 現在の進行 + + + {phaseLabels[state.phase]} + +
+
+ 予選の進捗 + + {finished} + / {qualifying.length} 試合 + +
+ +
+
+
+ 参加者 + + {state.players.length} + 名 + +
+
+ 掲示対象 + + {match?.label ?? '未選択'} + {match ? ` · ${match.games.length}/2戦` : ''} + +
+
+ ); +} diff --git a/chros/components/management/AuditHistory.tsx b/chros/components/management/AuditHistory.tsx new file mode 100644 index 0000000..07fd921 --- /dev/null +++ b/chros/components/management/AuditHistory.tsx @@ -0,0 +1,37 @@ +'use client'; + +import type { ControlProps } from '@/lib/control-types'; + +export function AuditHistory({ state }: Pick) { + return ( +
+
+

操作履歴

+ {state.audit.length} +
+
+ {state.audit + .slice(-40) + .reverse() + .map((log) => ( +
+ +

{log.detail}

+
+ ))} + {!state.audit.length && ( +
操作すると、ここに履歴が残ります。
+ )} +
+

最新40件を表示。全件はJSONで保存できます。

+
+ ); +} diff --git a/chros/components/management/DataActions.tsx b/chros/components/management/DataActions.tsx new file mode 100644 index 0000000..46452a9 --- /dev/null +++ b/chros/components/management/DataActions.tsx @@ -0,0 +1,52 @@ +'use client'; + +import type { ControlProps } from '@/lib/control-types'; +import { useConfirmation } from '../Confirmation'; + +export function DataActions({ send }: Pick) { + const confirm = useConfirmation(); + async function createEmptyTournament() { + if (await confirm('現在の大会を退避して、参加者が空の新しい大会を作成しますか?')) + void send({ type: 'reset', demo: false, confirmation: 'RESET' }); + } + + async function loadSampleTournament() { + if (await confirm('現在の大会を退避して、架空の参加者によるサンプルに切り替えますか?')) + void send({ type: 'reset', demo: true, confirmation: 'RESET' }); + } + + return ( +
+
+
+ DATA & HISTORY +

記録を手元に

+
+ ● 保存済み +
+
+

結果と操作履歴は、このサーバーに自動保存されます。

+ +
+ 大会を切り替える +

+ 現在の大会はサーバー内に退避してから切り替えます。確認用に全記録JSONも保存しておくことをおすすめします。 +

+ + +
+
+
+ ); +} diff --git a/chros/components/management/FinalsSetup.tsx b/chros/components/management/FinalsSetup.tsx new file mode 100644 index 0000000..a6f7459 --- /dev/null +++ b/chros/components/management/FinalsSetup.tsx @@ -0,0 +1,88 @@ +'use client'; +import type { FormEvent } from 'react'; + +import type { ControlProps } from '@/lib/control-types'; +import { suggestedQualifiers } from '@/lib/tournament'; +import { evaluateMatch, groupNames, standings } from '@chros/scoring'; +import { useState } from 'react'; +import { useConfirmation } from '../Confirmation'; + +export function FinalsSetup({ state, send }: ControlProps) { + const confirm = useConfirmation(); + const [selected, setSelected] = useState(suggestedQualifiers(state)); + const [justification, setJustification] = useState(''); + const qualifying = state.matches.filter((match) => match.stage === 'qualifying'); + const qualifyingComplete = + qualifying.length > 0 && + qualifying.every((match) => evaluateMatch(match, state.profile).winnerId); + const hasTiedQualifiers = groupNames(state).some((group) => + standings(state, group).some((row) => row.tied && row.rank <= state.advancePerGroup), + ); + async function createFinals(event: FormEvent) { + event.preventDefault(); + if ( + await confirm( + 'この進出者とシード順で本戦を作成します。確定後は予選結果を変更できません。作成しますか?', + ) + ) + await send({ type: 'generate-finals', qualifiers: selected, justification }); + } + + return ( +
+
+
+ NEXT STAGE +

本戦へ進む

+
+ 各組 上位{state.advancePerGroup}名 +
+ {!qualifyingComplete ? ( +

+ すべての予選試合が確定すると、進出者を確認して本戦を作成できます。 +

+ ) : ( +
+

+ 予選順位から進出者を選びました。上からシード順です。同順位の裁定と入れ替えは、理由を記録します。 +

+ {selected.map((id, index) => ( + + ))} + + +
+ )} +
+ ); +} diff --git a/chros/components/management/LogoSettings.tsx b/chros/components/management/LogoSettings.tsx new file mode 100644 index 0000000..ad2ced3 --- /dev/null +++ b/chros/components/management/LogoSettings.tsx @@ -0,0 +1,58 @@ +'use client'; +import type { ChangeEvent } from 'react'; + +import type { ControlProps } from '@/lib/control-types'; +import { useState } from 'react'; + +export function LogoSettings({ state, send }: ControlProps) { + const [uploadError, setUploadError] = useState(''); + async function uploadLogo(event: ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) return; + setUploadError(''); + if (file.size > 1_000_000 || !['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) { + setUploadError('PNG・JPEG・WebPの1 MB以下の画像を選んでください。'); + return; + } + const reader = new FileReader(); + reader.onerror = () => setUploadError('画像を読み込めませんでした。'); + reader.onload = () => { + void send({ type: 'logo', data: String(reader.result) }); + }; + reader.readAsDataURL(file); + } + + return ( +
+
+

幕間のロゴ

+
+
+ {state.logo && 登録中の大会ロゴ} + + {uploadError && ( +

+ {uploadError} +

+ )} + {state.logo && ( + + )} +
+
+ ); +} diff --git a/chros/components/management/Matches.tsx b/chros/components/management/Matches.tsx new file mode 100644 index 0000000..9287872 --- /dev/null +++ b/chros/components/management/Matches.tsx @@ -0,0 +1,149 @@ +'use client'; + +import type { ControlProps } from '@/lib/control-types'; +import { evaluateMatch, groupNames, playerName } from '@chros/scoring'; +import { useState } from 'react'; +import { Bracket, StandingsTable } from '../Board'; +import MatchEditor, { statusLabels } from '../MatchEditor'; + +import { FinalsSetup } from './FinalsSetup'; + +export function Matches({ state, send }: ControlProps) { + const [filter, setFilter] = useState<'all' | 'qualifying' | 'finals'>('all'); + const [selectedId, setSelectedId] = useState(state.currentMatchId); + const selected = state.matches.find((match) => match.id === selectedId); + const [view, setView] = useState<'list' | 'standings'>('list'); + return ( + <> +
+
+ + +
+ + ↓ 結果CSV + +
+ {view === 'list' ? ( +
+
+
+

+ 対戦一覧 {state.matches.length} +

+ +
+
+ {state.matches + .filter((match) => filter === 'all' || match.stage === filter) + .map((match) => { + const result = evaluateMatch(match, state.profile); + return ( + + ); + })} + {!state.matches.length && ( +
+ 参加者を登録し、予選の組み合わせを作成してください。 +
+ )} +
+
+
+ {selected ? ( + <> +
+ SCORE DESK + +
+ + + ) : ( +
一覧から試合を選択してください。
+ )} +
+
+ ) : ( +
+
+ {groupNames(state).map((group) => ( +
+ +
+ ))} +
+ {state.matches.some((match) => match.stage === 'finals') ? ( +
+
+

本戦トーナメント

+
+ +
+ ) : ( + evaluateMatch(match, state.profile).winnerId).length + } + state={state} + send={send} + /> + )} +
+ )} + + ); +} diff --git a/chros/components/management/Players.tsx b/chros/components/management/Players.tsx new file mode 100644 index 0000000..75ac717 --- /dev/null +++ b/chros/components/management/Players.tsx @@ -0,0 +1,168 @@ +'use client'; +import type { FormEvent } from 'react'; + +import { clientId } from '@/lib/client-id'; +import type { ControlProps } from '@/lib/control-types'; +import { groupNames } from '@chros/scoring'; +import type { Player } from '@chros/shared'; +import { useState } from 'react'; +import { useConfirmation } from '../Confirmation'; + +export function Players({ state, send }: ControlProps) { + const confirm = useConfirmation(); + const fresh = (): Player => ({ id: clientId(), name: '', affiliation: '', group: 'A' }); + const [draft, setDraft] = useState(fresh); + const editing = state.players.some((player) => player.id === draft.id); + async function removePlayer(player: Player) { + if (await confirm(`${player.name}を参加者から削除しますか?`)) + void send({ type: 'remove-player', playerId: player.id }); + } + + async function createQualifyingMatches() { + if ( + await confirm( + '現在の参加者とグループで予選を作成します。以降、参加者の追加・削除とグループ変更はできません。作成しますか?', + ) + ) + void send({ type: 'generate-qualifying' }); + } + + async function savePlayer(event: FormEvent) { + event.preventDefault(); + if (await send({ type: 'save-player', player: draft })) setDraft(fresh()); + } + + return ( +
+
+
+
+ PARTICIPANTS +

+ 挑戦者たち {state.players.length} +

+
+ {groupNames(state).length} グループ +
+ {state.players.length ? ( +
+ + + + + + + + + + {state.players.map((player) => ( + + + + + + + ))} + +
参加者所属組 +
+ {player.name} + {player.affiliation || '–'} + {player.group} + + + {!state.matches.length && ( + + )} +
+
+ ) : ( +
+ まず参加者を登録しましょう。 + グループをすべて「A」にすると、全員で総当たりになります。 +
+ )} +
+

+ {state.matches.length + ? '組み合わせ作成済み · 表示名と所属は編集できます。' + : '各グループの全員が1回ずつ対戦します。1試合は先攻・後攻を入れ替える2戦です。'} +

+ +
+
+
+
+
+ ENTRY +

{editing ? '参加者を編集' : '参加者を追加'}

+
+
+
+ + + +
+ {editing && ( + + )} + +
+
+
+
+ ); +} diff --git a/chros/components/management/Settings.tsx b/chros/components/management/Settings.tsx new file mode 100644 index 0000000..e5d3b4c --- /dev/null +++ b/chros/components/management/Settings.tsx @@ -0,0 +1,20 @@ +import type { ControlProps } from '@/lib/control-types'; +import { AuditHistory } from './AuditHistory'; +import { DataActions } from './DataActions'; +import { LogoSettings } from './LogoSettings'; +import { TournamentSettings } from './TournamentSettings'; + +export function Settings({ state, send }: ControlProps) { + return ( +
+
+ + +
+
+ + +
+
+ ); +} diff --git a/chros/components/management/TournamentSettings.tsx b/chros/components/management/TournamentSettings.tsx new file mode 100644 index 0000000..f2f5634 --- /dev/null +++ b/chros/components/management/TournamentSettings.tsx @@ -0,0 +1,85 @@ +'use client'; +import type { FormEvent } from 'react'; + +import type { ControlProps } from '@/lib/control-types'; +import { useState } from 'react'; + +export function TournamentSettings({ state, send }: ControlProps) { + const [title, setTitle] = useState(state.title); + const [subtitle, setSubtitle] = useState(state.subtitle); + const [profile, setProfile] = useState(state.profile); + const [advance, setAdvance] = useState(state.advancePerGroup); + async function saveSettings(event: FormEvent) { + event.preventDefault(); + await send({ type: 'settings', title, subtitle, profile, advancePerGroup: advance }); + } + + return ( +
+
+
+ TOURNAMENT +

大会の設定

+
+
+
+ + + + +
+ 判定の見える化 +

+ {profile === 'kushiro' + ? 'PUT・包囲・相手の自滅による勝利を各1特殊Pとして集計。2戦の特殊Pが多い側、同値なら合計得点が多い側が勝者です。' + : '2戦のゲーム勝数を比較し、同値なら換算得点を比較。PUT・包囲による敗者は0点、通信切断・移動違反・自己包囲による敗者は残りターン数のマイナスに換算します。'} +

+

+ 予選順位:試合勝数 → {profile === 'kushiro' ? '特殊P → ' : ''} + 得点。同順位は主催者が理由を記録して進出を裁定します。 +

+
+ +
+
+ ); +} diff --git a/chros/components/matches/GameForm.tsx b/chros/components/matches/GameForm.tsx new file mode 100644 index 0000000..bb46965 --- /dev/null +++ b/chros/components/matches/GameForm.tsx @@ -0,0 +1,176 @@ +'use client'; +import type { FormEvent } from 'react'; + +import type { SendCommand } from '@/lib/control-types'; +import { evaluateGame, playerName } from '@chros/scoring'; +import { reasonLabels, reasons, type ControlState, type Game, type Match } from '@chros/shared'; +import { useState } from 'react'; + +export default function GameForm({ + state, + match, + number, + send, + onSaved, +}: { + state: ControlState; + match: Match; + number: 1 | 2; + send: SendCommand; + onSaved: () => void; +}) { + const existing = match.games.find((game) => game.number === number); + const [game, setGame] = useState( + existing ?? { + number, + scoreA: 0, + scoreB: 0, + reason: 'points', + specialWinner: null, + remainingTurns: 0, + }, + ); + const [scoreInputs, setScoreInputs] = useState({ + a: existing ? String(existing.scoreA) : '', + b: existing ? String(existing.scoreB) : '', + }); + const [turnsInput, setTurnsInput] = useState(existing ? String(existing.remainingTurns) : ''); + const [correction, setCorrection] = useState(''); + const result = evaluateGame(game, state.profile); + const secondCool = match.firstCool === match.a ? match.b : match.a; + const cool = number === 1 ? match.firstCool : secondCool; + const updateGame = (patch: Partial) => setGame((current) => ({ ...current, ...patch })); + const needsRemainingTurns = + state.profile === 'asahikawa' && !['points', 'put', 'surround'].includes(game.reason); + + let resultLabel = '勝者を選択してください'; + if (!scoreInputs.a || !scoreInputs.b) { + resultLabel = '両者の得点を入力してください'; + } else if (result.winner) { + resultLabel = `${playerName(state, match[result.winner])} の勝利`; + } else if (game.reason === 'points') { + resultLabel = '同点'; + } + + async function saveResult(event: FormEvent) { + event.preventDefault(); + if (await send({ type: 'save-game', matchId: match.id, game, correction })) onSaved(); + } + + return ( +
+
+ 第{number}戦の結果 + {number === 2 ? '先攻・後攻を入れ替え' : 'じゃんけんで決めた先攻・後攻'} +
+
+ {(['a', 'b'] as const).map((side) => ( + + ))} +
+
+ + {game.reason !== 'points' && ( + + )} + {needsRemainingTurns && ( + + )} +
+
+ この戦の判定 + {resultLabel} + + {game.reason !== 'points' ? '勝者に特殊ポイント +1' : '特殊ポイントなし'} + {state.profile === 'asahikawa' + ? ` · 換算得点 ${result.a.points} : ${result.b.points}` + : ''} + +
+ {existing && ( + + )} +
+ + {existing ? '変更前の結果と理由を履歴に残します' : '保存すると掲示と集計に反映されます'} + + +
+
+ ); +} diff --git a/chros/components/matches/MatchControls.tsx b/chros/components/matches/MatchControls.tsx new file mode 100644 index 0000000..cdff313 --- /dev/null +++ b/chros/components/matches/MatchControls.tsx @@ -0,0 +1,124 @@ +'use client'; +import type { FormEvent } from 'react'; + +import type { SendCommand } from '@/lib/control-types'; +import { playerName } from '@chros/scoring'; +import { type ControlState, type Match } from '@chros/shared'; +import { useState } from 'react'; +import GameForm from './GameForm'; +export function MatchControls({ + state, + match, + send, +}: { + state: ControlState; + match: Match; + send: SendCommand; +}) { + const [number, setNumber] = useState<1 | 2>(match.games.length === 1 ? 2 : 1); + const [firstCool, setFirstCool] = useState(''); + const [replayOpen, setReplayOpen] = useState(false); + const [replayReason, setReplayReason] = useState(''); + const locked = + match.stage === 'qualifying' && state.matches.some((match) => match.stage === 'finals'); + async function startMatch(event: FormEvent) { + event.preventDefault(); + await send({ type: 'start-match', matchId: match.id, firstCool }); + } + + async function replayMatch(event: FormEvent) { + event.preventDefault(); + if (await send({ type: 'replay', matchId: match.id, reason: replayReason })) { + setReplayOpen(false); + setReplayReason(''); + setNumber(1); + setFirstCool(''); + } + } + + if (match.bye) { + return

不戦進出として次の試合へ反映済みです。

; + } + if (!match.a || !match.b) { + return
前の試合の勝者が確定すると、対戦者が入ります。
; + } + if (!match.started) { + return ( +
+

じゃんけんの結果に従って、第1戦の先攻を登録します。第2戦は自動で入れ替わります。

+ + +
+ ); + } + return ( + <> +
+ {([1, 2] as const).map((gameNumber) => ( + + ))} +
+ {locked ? ( +
本戦の組み合わせ確定済みのため、予選結果は保護されています。
+ ) : ( + game.number === number))}`} + state={state} + match={match} + number={number} + send={send} + onSaved={() => { + if (number === 1 && !match.games.some((game) => game.number === 2)) setNumber(2); + }} + /> + )} + {!locked && ( +
+ + {replayOpen && ( +
+ +

現在の両戦の結果を無効にし、先攻の登録からやり直します。履歴は残ります。

+ +
+ )} +
+ )} + + ); +} diff --git a/chros/components/rundown/CueEditor.tsx b/chros/components/rundown/CueEditor.tsx new file mode 100644 index 0000000..4552b35 --- /dev/null +++ b/chros/components/rundown/CueEditor.tsx @@ -0,0 +1,124 @@ +'use client'; +import type { FormEvent } from 'react'; + +import { clientId } from '@/lib/client-id'; +import type { SendCommand } from '@/lib/control-types'; +import { + phaseLabels, + phases, + sceneLabels, + scenes, + type ControlState, + type Cue, +} from '@chros/shared'; +import { useState } from 'react'; + +export default function CueEditor({ + state, + send, + close, +}: { + state: ControlState; + send: SendCommand; + close: () => void; +}) { + const [cues, setCues] = useState(state.cues); + const change = (id: string, patch: Partial) => + setCues((current) => current.map((cue) => (cue.id === id ? { ...cue, ...patch } : cue))); + const move = (index: number, direction: number) => { + const next = [...cues]; + [next[index], next[index + direction]] = [next[index + direction]!, next[index]!]; + setCues(next); + }; + async function saveCues(event: FormEvent) { + event.preventDefault(); + if (await send({ type: 'save-cues', cues })) close(); + } + + return ( +
+ {cues.map((cue, index) => ( +
+ {String(index + 1).padStart(2, '0')} +
+ change(cue.id, { label: event.target.value })} + /> +
+ + +
+
+
+ + + +
+
+ ))} + +
+ + +
+
+ ); +} diff --git a/chros/lib/board-pages.ts b/chros/lib/board-pages.ts new file mode 100644 index 0000000..2c62834 --- /dev/null +++ b/chros/lib/board-pages.ts @@ -0,0 +1,51 @@ +import type { ControlState } from '@chros/shared'; +import { groupNames } from '@chros/scoring'; + +export const STANDINGS_ROWS_PER_CARD = 6; +const STANDINGS_CARDS_PER_PAGE = 2; +export const BRACKET_MATCHES_PER_PAGE = 4; + +function createStandingsPages(state: ControlState) { + const cards = groupNames(state).flatMap((group) => { + const playerCount = state.players.filter((player) => player.group === group).length; + const cardCount = Math.ceil(playerCount / STANDINGS_ROWS_PER_CARD); + return Array.from({ length: cardCount }, (_, index) => ({ + group, + offset: index * STANDINGS_ROWS_PER_CARD, + })); + }); + + const pageCount = Math.ceil(cards.length / STANDINGS_CARDS_PER_PAGE); + return Array.from({ length: pageCount }, (_, index) => { + const offset = index * STANDINGS_CARDS_PER_PAGE; + return cards.slice(offset, offset + STANDINGS_CARDS_PER_PAGE); + }); +} + +function createBracketPages(state: ControlState) { + const finals = state.matches.filter((match) => match.stage === 'finals'); + const firstRound = finals.filter((match) => match.round === 1); + if (firstRound.length <= BRACKET_MATCHES_PER_PAGE) return [undefined]; + + const rounds = new Set(finals.map((match) => match.round)); + return [...rounds].flatMap((round) => { + const matchCount = finals.filter((match) => match.round === round).length; + const pageCount = Math.ceil(matchCount / BRACKET_MATCHES_PER_PAGE); + return Array.from({ length: pageCount }, (_, index) => ({ + round, + offset: index * BRACKET_MATCHES_PER_PAGE, + })); + }); +} + +export function boardPages(state: ControlState) { + const standingsPages = createStandingsPages(state); + const bracketPages = createBracketPages(state); + let pageCount = 1; + if (state.scene === 'standings') pageCount = standingsPages.length; + if (state.scene === 'bracket') pageCount = bracketPages.length; + + const count = Math.max(1, pageCount); + const index = Math.min(state.boardPage, count - 1); + return { count, index, cards: standingsPages[index] ?? [], bracket: bracketPages[index] }; +} diff --git a/chros/lib/broadcast.ts b/chros/lib/broadcast.ts deleted file mode 100644 index a8df575..0000000 --- a/chros/lib/broadcast.ts +++ /dev/null @@ -1,105 +0,0 @@ -import 'server-only'; -import { EventEmitter } from 'node:events'; -import type { ChrosEvent, ScreenId, Snapshot } from '@chros/shared'; -import { DUMMY_SLOTS } from '@chros/scoring'; - -/** - * SSE の配信バス。§6.3 - * Next.js が単一プロセスで動く限り、インメモリの EventEmitter で足りる。 - * 複数インスタンスが必要になった場合のみ PostgreSQL の LISTEN/NOTIFY を挟む。 - */ - -/** 再送に使う直近イベントの保持数。これを超えて遡る要求にはスナップショットを送り直す。 */ -const REPLAY_BUFFER_SIZE = 256; - -export type SequencedEvent = { id: number; event: ChrosEvent }; - -type Bus = { - emitter: EventEmitter; - buffer: SequencedEvent[]; - lastId: number; - /** 現在の配信状態。DB のドメインモデル(§4)が入るまでの暫定の置き場。 */ - screen: ScreenId; -}; - -// 開発時の HMR でバスが作り直されると購読者が孤児になるため、グローバルに退避する。 -const globalForBus = globalThis as unknown as { __chrosBus?: Bus }; - -const bus: Bus = (globalForBus.__chrosBus ??= { - emitter: new EventEmitter(), - buffer: [], - lastId: 0, - screen: 'next-game', -}); - -bus.emitter.setMaxListeners(0); - -const CHANNEL = 'chros'; - -export function getCurrentScreen(): ScreenId { - return bus.screen; -} - -/** - * 現在の完全な状態。接続直後に1件送る(§6.1)ので、Viewer は別 API を叩かなくてよい。 - * - * 進行まわりの Prisma スキーマ(§4)は未着手のため、いまは静的な内容を返す。 - * DB が入った時点でここだけが差し替わり、`Snapshot` 型と Viewer は変わらない。 - */ -export function buildSnapshot(): Snapshot { - return { - screen: bus.screen, - tournament: { id: 1, name: 'CHaser 釧路大会', format: 'round-robin' }, - slots: DUMMY_SLOTS, - participants: [], - matches: [], - currentMatchId: null, - standings: null, - }; -} - -/** - * イベントを全購読者に配る。状態を変える操作は DB 書き込みとこの呼び出しを - * 同一の関数内で行うこと(§7 規約)。片方だけ実行される経路を作らない。 - */ -export function broadcast(event: ChrosEvent): SequencedEvent { - if (event.type === 'screen.changed') { - bus.screen = event.payload.screen; - } - - const sequenced: SequencedEvent = { id: ++bus.lastId, event }; - - bus.buffer.push(sequenced); - if (bus.buffer.length > REPLAY_BUFFER_SIZE) { - bus.buffer.splice(0, bus.buffer.length - REPLAY_BUFFER_SIZE); - } - - bus.emitter.emit(CHANNEL, sequenced); - return sequenced; -} - -export function subscribe(listener: (event: SequencedEvent) => void): () => void { - bus.emitter.on(CHANNEL, listener); - return () => bus.emitter.off(CHANNEL, listener); -} - -/** - * `Last-Event-ID` からの再送分を返す。遡れないほど古い場合は null を返し、 - * 呼び出し側はスナップショットを送り直す(§6.1)。 - */ -export function replayAfter(lastEventId: number): SequencedEvent[] | null { - // 追いついている - if (lastEventId === bus.lastId) return []; - // サーバー再起動などで採番がリセットされ、クライアントが先の id を持っている。 - // 差分では整合が取れないのでスナップショットを送り直させる。 - if (lastEventId > bus.lastId) return null; - - const oldest = bus.buffer[0]; - if (!oldest || oldest.id > lastEventId + 1) return null; - - return bus.buffer.filter((entry) => entry.id > lastEventId); -} - -export function nextEventId(): number { - return bus.lastId + 1; -} diff --git a/chros/lib/client-id.ts b/chros/lib/client-id.ts new file mode 100644 index 0000000..b8c9cbd --- /dev/null +++ b/chros/lib/client-id.ts @@ -0,0 +1,3 @@ +export const clientId = () => + globalThis.crypto?.randomUUID?.() ?? + `client-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; diff --git a/chros/lib/control-types.ts b/chros/lib/control-types.ts new file mode 100644 index 0000000..cfbe98c --- /dev/null +++ b/chros/lib/control-types.ts @@ -0,0 +1,8 @@ +import type { Command, ControlState } from '@chros/shared'; + +export type SendCommand = (command: Command) => Promise; + +export type ControlProps = { + state: ControlState; + send: SendCommand; +}; diff --git a/chros/lib/export-results.ts b/chros/lib/export-results.ts new file mode 100644 index 0000000..06e4536 --- /dev/null +++ b/chros/lib/export-results.ts @@ -0,0 +1,111 @@ +import { evaluateGame, evaluateMatch, playerName } from '@chros/scoring'; +import { reasonLabels, type ControlState, type Game, type Match } from '@chros/shared'; + +type MatchAttempt = { + games: Game[]; + firstCool: string | null; + number: number; + active: boolean; + reason: string; +}; + +const CSV_COLUMNS = [ + '試合', + '段階', + '組', + '実施回', + '有効', + 'A', + 'B', + '第何戦', + 'COOL・先攻', + 'HOT・後攻', + 'A素点', + 'B素点', + '勝因', + '特殊勝者', + '残りターン', + 'A換算得点', + 'B換算得点', + 'A特殊P', + 'B特殊P', + '試合勝者', + '判定', + '無効理由', + '計算方式', + '規則版', +]; + +function matchAttempts(match: Match): MatchAttempt[] { + return [ + ...match.previousAttempts.map((attempt, index) => ({ + ...attempt, + number: index + 1, + active: false, + })), + { + games: match.games, + firstCool: match.firstCool, + number: match.attempt, + active: true, + reason: '', + }, + ]; +} + +function gameRow(state: ControlState, match: Match, attempt: MatchAttempt, game: Game) { + const matchResult = evaluateMatch(match, state.profile); + const gameResult = evaluateGame(game, state.profile); + const secondCool = attempt.firstCool === match.a ? match.b : match.a; + const coolPlayerId = game.number === 1 ? attempt.firstCool : secondCool; + const hotPlayerId = coolPlayerId === match.a ? match.b : match.a; + let decision = ''; + if (attempt.active && matchResult.complete) { + decision = matchResult.tied ? '再試合待ち' : matchResult.decidedBy; + } + return [ + match.label, + match.stage === 'qualifying' ? '予選' : '本戦', + match.group, + attempt.number, + attempt.active ? '有効' : '無効', + playerName(state, match.a), + playerName(state, match.b), + game.number, + playerName(state, coolPlayerId), + playerName(state, hotPlayerId), + game.scoreA, + game.scoreB, + reasonLabels[game.reason], + game.specialWinner ? playerName(state, match[game.specialWinner]) : '', + game.remainingTurns, + gameResult.a.points, + gameResult.b.points, + gameResult.a.special, + gameResult.b.special, + attempt.active && matchResult.winnerId ? playerName(state, matchResult.winnerId) : '', + decision, + attempt.reason, + state.profile, + state.ruleVersion, + ]; +} + +function escapeCsvCell(value: string | number) { + // 負点は数値のまま、文字列だけ数式として解釈されるのを防ぐ。 + const safeValue = + typeof value === 'number' ? String(value) : value.replace(/^[=+\-@\t\r]/, "'$&"); + return `"${safeValue.replaceAll('"', '""')}"`; +} + +export function exportResultsCsv(state: ControlState) { + const rows: (string | number)[][] = [CSV_COLUMNS]; + for (const match of state.matches) { + for (const attempt of matchAttempts(match)) { + for (const game of attempt.games) { + rows.push(gameRow(state, match, attempt, game)); + } + } + } + return '\uFEFF' + rows.map((row) => row.map(escapeCsvCell).join(',')).join('\r\n'); +} diff --git a/chros/lib/store.ts b/chros/lib/store.ts new file mode 100644 index 0000000..a47b27a --- /dev/null +++ b/chros/lib/store.ts @@ -0,0 +1,67 @@ +import 'server-only'; +import { EventEmitter } from 'node:events'; +import { + closeSync, + existsSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + writeFileSync, +} from 'node:fs'; +import path from 'node:path'; +import { stateSchema, type Command, type ControlState } from '@chros/shared'; +import { applyCommand, createInitialState } from './tournament'; + +const dataDir = process.env.CHROS_DATA_DIR || path.join(process.cwd(), '.chros'); +const dataFile = path.join(dataDir, 'state.json'); +const globalBus = globalThis as unknown as { __chrosControlBus?: EventEmitter }; +const bus = (globalBus.__chrosControlBus ??= new EventEmitter()); +bus.setMaxListeners(0); + +function writeStateAtomically(state: ControlState) { + mkdirSync(dataDir, { recursive: true }); + const temporaryFile = `${dataFile}.tmp`; + const fileDescriptor = openSync(temporaryFile, 'w', 0o600); + try { + writeFileSync(fileDescriptor, JSON.stringify(state)); + fsyncSync(fileDescriptor); + } finally { + closeSync(fileDescriptor); + } + renameSync(temporaryFile, dataFile); +} +export function readState(): ControlState { + if (!existsSync(dataFile)) { + const state = createInitialState(); + writeStateAtomically(state); + return state; + } + // 壊れた保存ファイルはサンプルで上書きしない。 + return stateSchema.parse(JSON.parse(readFileSync(dataFile, 'utf8'))); +} +export class RevisionConflict extends Error {} +export function dispatch(command: Command, expectedRevision: number): ControlState { + // 単一Nodeプロセス内で、読取→検証→保存→通知を同期実行する。 + const previous = readState(); + if (previous.revision !== expectedRevision) + throw new RevisionConflict( + '別の操作で更新されました。最新の表示を確認してから、もう一度操作してください。', + ); + const next = stateSchema.parse(applyCommand(previous, command)); + const backupFilename = + command.type === 'reset' + ? `archive-${Date.now()}-${previous.revision}.json` + : 'state.previous.json'; + writeFileSync(path.join(dataDir, backupFilename), JSON.stringify(previous), { mode: 0o600 }); + writeStateAtomically(next); + bus.emit('state', next); + return next; +} +export function subscribeState(listener: (state: ControlState) => void) { + bus.on('state', listener); + return () => { + bus.off('state', listener); + }; +} diff --git a/chros/lib/tournament.ts b/chros/lib/tournament.ts new file mode 100644 index 0000000..40d2d92 --- /dev/null +++ b/chros/lib/tournament.ts @@ -0,0 +1,105 @@ +import type { Command, ControlState } from '@chros/shared'; +import { phaseLabels, sceneLabels } from '@chros/shared'; +import { createInitialState } from './tournament/initial-state'; +import { assertAllowed, findMatch } from './tournament/matches'; +import { replayMatch, saveGame, startMatch } from './tournament/match-commands'; +import { removePlayer, savePlayer } from './tournament/players'; +import { generateQualifying } from './tournament/qualifying'; +import { generateFinals } from './tournament/finals'; + +export { createInitialState } from './tournament/initial-state'; +export { createMatch as emptyMatch } from './tournament/matches'; +export { suggestedQualifiers } from './tournament/finals'; + +function updateSettings(state: ControlState, command: Extract) { + const matchesStarted = state.matches.some((match) => match.started); + const finalsExist = state.matches.some((match) => match.stage === 'finals'); + assertAllowed( + command.profile === state.profile || !matchesStarted, + '試合開始後は計算方式を変更できません。', + ); + assertAllowed( + command.advancePerGroup === state.advancePerGroup || !finalsExist, + '本戦確定後は進出人数を変更できません。', + ); + + state.title = command.title; + state.subtitle = command.subtitle; + state.profile = command.profile; + state.advancePerGroup = command.advancePerGroup; + return `大会設定: ${command.title} / ${command.profile} / 各組上位${command.advancePerGroup}名`; +} + +function applyStateChange(state: ControlState, command: Command): string { + switch (command.type) { + case 'show': + state.scene = command.scene; + state.phase = command.phase; + state.cueId = null; + state.boardPage = 0; + return `${phaseLabels[state.phase]} / ${sceneLabels[state.scene]}`; + + case 'board-page': + state.boardPage = command.page; + return `掲示ページ: ${command.page + 1}`; + + case 'select-match': { + const match = findMatch(state, command.matchId); + state.currentMatchId = match.id; + return `掲示対象: ${match.label}`; + } + + case 'start-match': + return startMatch(state, command); + case 'save-game': + return saveGame(state, command); + case 'replay': + return replayMatch(state, command); + case 'save-player': + return savePlayer(state, command.player); + case 'remove-player': + return removePlayer(state, command.playerId); + case 'generate-qualifying': + return generateQualifying(state); + case 'generate-finals': + return generateFinals(state, command); + case 'settings': + return updateSettings(state, command); + + case 'logo': + state.logo = command.data; + return command.data ? '幕間ロゴを更新' : '幕間ロゴを解除'; + + case 'save-cues': { + const cueIds = new Set(command.cues.map((cue) => cue.id)); + assertAllowed(cueIds.size === command.cues.length, '表示順のIDが重複しています。'); + state.cues = command.cues; + if (!state.cues.some((cue) => cue.id === state.cueId)) state.cueId = null; + return `表示順を更新: ${state.cues.map((cue) => cue.label).join(' → ')}`; + } + + case 'play-cue': { + const cue = state.cues.find((candidate) => candidate.id === command.cueId); + assertAllowed(cue, '表示順が見つかりません。'); + state.cueId = cue.id; + state.phase = cue.phase; + state.scene = cue.scene; + state.boardPage = 0; + return `表示順: ${cue.label}`; + } + + case 'reset': + return command.demo ? 'サンプル大会を読み込み' : '空の大会を作成'; + } +} + +export function applyCommand(previous: ControlState, command: Command): ControlState { + const state = + command.type === 'reset' ? createInitialState(command.demo) : structuredClone(previous); + const detail = applyStateChange(state, command); + + state.revision = previous.revision + 1; + state.updatedAt = new Date().toISOString(); + state.audit.push({ id: crypto.randomUUID(), at: state.updatedAt, action: command.type, detail }); + return state; +} diff --git a/chros/lib/tournament/finals.ts b/chros/lib/tournament/finals.ts new file mode 100644 index 0000000..f493038 --- /dev/null +++ b/chros/lib/tournament/finals.ts @@ -0,0 +1,151 @@ +import type { Command, ControlState, Match } from '@chros/shared'; +import { evaluateMatch, groupNames, playerName, standings } from '@chros/scoring'; +import { assertAllowed, createMatch } from './matches'; + +type GenerateFinals = Extract; + +export function suggestedQualifiers(state: ControlState) { + const groups = groupNames(state).map((group) => + standings(state, group).slice(0, state.advancePerGroup), + ); + // A1, B1, A2, B2 の順に渡すと、シード配置が A1-B2 / B1-A2 になる。 + return Array.from({ length: state.advancePerGroup }, (_, rank) => + groups.map((rows) => rows[rank]?.player.id).filter((id): id is string => !!id), + ).flat(); +} + +function validateGroupQualifiers(state: ControlState, command: GenerateFinals, group: string) { + const rows = standings(state, group); + const qualifierCount = Math.min(state.advancePerGroup, rows.length); + const selectedRows = rows.filter((row) => command.qualifiers.includes(row.player.id)); + assertAllowed(selectedRows.length === qualifierCount, `${group}組の進出人数が違います。`); + + const cutoffRank = rows[qualifierCount - 1]!.rank; + const selectedWithinCutoff = selectedRows.every((row) => row.rank <= cutoffRank); + const higherRanksIncluded = rows + .filter((row) => row.rank < cutoffRank) + .every((row) => command.qualifiers.includes(row.player.id)); + assertAllowed(selectedWithinCutoff && higherRanksIncluded, '同順位以外の順位は変更できません。'); + + const hasTiedQualifiers = rows.some((row) => row.tied && row.rank <= cutoffRank); + if (hasTiedQualifiers) { + assertAllowed( + command.justification.length, + '同順位のシード順・進出者を選んだ理由を記録してください。', + ); + } +} + +function validateQualifiers(state: ControlState, command: GenerateFinals) { + const finalsExist = state.matches.some((match) => match.stage === 'finals'); + assertAllowed(!finalsExist, '本戦はすでに作成されています。'); + + const qualifying = state.matches.filter((match) => match.stage === 'qualifying'); + const qualifyingComplete = + qualifying.length > 0 && + qualifying.every((match) => evaluateMatch(match, state.profile).winnerId); + assertAllowed(qualifyingComplete, 'すべての予選試合を確定してから本戦を作成してください。'); + + const suggested = suggestedQualifiers(state); + const correctCount = command.qualifiers.length === suggested.length; + const noDuplicates = new Set(command.qualifiers).size === command.qualifiers.length; + assertAllowed(correctCount && noDuplicates, '各組の進出人数と重複を確認してください。'); + + for (const group of groupNames(state)) { + validateGroupQualifiers(state, command, group); + } + + const allRegistered = command.qualifiers.every((id) => + state.players.some((player) => player.id === id), + ); + assertAllowed(allRegistered, '未登録の進出者です。'); + + const seedingChanged = command.qualifiers.some((id, index) => id !== suggested[index]); + if (seedingChanged) { + assertAllowed( + command.justification.length, + '候補から進出者・シード順を変更した理由を記録してください。', + ); + } +} + +function seedOrder(qualifierCount: number) { + let bracketSize = 2; + while (bracketSize < qualifierCount) bracketSize *= 2; + + let seeds = [1, 2]; + while (seeds.length < bracketSize) { + const pairedSeedTotal = seeds.length * 2 + 1; + seeds = seeds.flatMap((seed) => [seed, pairedSeedTotal - seed]); + } + return seeds; +} + +function roundLabel(round: number, totalRounds: number, matchNumber: number) { + if (round === totalRounds) return '決勝'; + if (round === totalRounds - 1) return `準決勝 ${matchNumber}`; + return `本戦 R${round}-${matchNumber}`; +} + +function createFinalsMatches(qualifiers: string[]) { + const seeds = seedOrder(qualifiers.length); + const totalRounds = Math.log2(seeds.length); + const matches: Match[] = []; + let previousRound: Match[] = []; + + for (let round = 1; round <= totalRounds; round++) { + const currentRound: Match[] = []; + const matchesInRound = seeds.length / 2 ** round; + + for (let index = 0; index < matchesInRound; index++) { + const label = roundLabel(round, totalRounds, index + 1); + const playerA = round === 1 ? (qualifiers[seeds[index * 2]! - 1] ?? null) : null; + const playerB = round === 1 ? (qualifiers[seeds[index * 2 + 1]! - 1] ?? null) : null; + const match = createMatch(label, playerA, playerB, ''); + match.stage = 'finals'; + match.round = round; + + if (round === 1) { + match.bye = !playerA || !playerB; + } else { + match.sources = [previousRound[index * 2]!.id, previousRound[index * 2 + 1]!.id]; + } + + currentRound.push(match); + matches.push(match); + } + previousRound = currentRound; + } + + return matches; +} + +export function synchronizeFinals(state: ControlState) { + const finals = state.matches + .filter((match) => match.stage === 'finals') + .sort((first, second) => first.round - second.round); + + for (const match of finals) { + match.sources.forEach((sourceId, index) => { + if (!sourceId) return; + const source = state.matches.find((candidate) => candidate.id === sourceId)!; + const winnerId = evaluateMatch(source, state.profile).winnerId; + const side = index === 0 ? 'a' : 'b'; + const changesStartedMatch = match.started && match[side] !== winnerId; + assertAllowed( + !changesStartedMatch, + '次の試合が開始済みです。進出者が変わる修正はできません。', + ); + match[side] = winnerId; + }); + } +} + +export function generateFinals(state: ControlState, command: GenerateFinals) { + validateQualifiers(state, command); + state.matches.push(...createFinalsMatches(command.qualifiers)); + synchronizeFinals(state); + + const seededNames = command.qualifiers.map((id) => playerName(state, id)).join(' → '); + return `本戦作成 / シード順: ${seededNames} / 裁定: ${command.justification || '規定順位による'}`; +} diff --git a/chros/lib/tournament/initial-state.ts b/chros/lib/tournament/initial-state.ts new file mode 100644 index 0000000..13ae82a --- /dev/null +++ b/chros/lib/tournament/initial-state.ts @@ -0,0 +1,71 @@ +import type { ControlState, Game } from '@chros/shared'; +import { createQualifyingMatches } from './qualifying'; + +export function createInitialState(demo = true): ControlState { + const state: ControlState = { + schemaVersion: 1, + tournamentId: crypto.randomUUID(), + revision: 0, + updatedAt: new Date().toISOString(), + demo, + title: demo ? 'CHaser 釧路大会|サンプル' : 'CHaser 釧路大会', + subtitle: 'U-16 PROGRAMMING CONTEST', + phase: demo ? 'qualifying' : 'opening', + scene: demo ? 'match' : 'ceremony', + profile: 'kushiro', + advancePerGroup: 2, + ruleVersion: '2026-09-18.1', + boardPage: 0, + players: [], + matches: [], + currentMatchId: null, + logo: null, + cueId: null, + audit: [], + cues: [ + { id: 'opening', label: '開会式', scene: 'ceremony', phase: 'opening' }, + { id: 'qualifying', label: '予選・対戦開始', scene: 'match', phase: 'qualifying' }, + { id: 'rank', label: '予選結果の発表', scene: 'standings', phase: 'qualifying' }, + { id: 'break', label: '休憩', scene: 'logo', phase: 'break' }, + { id: 'finals', label: '本戦の組み合わせ', scene: 'bracket', phase: 'finals' }, + { id: 'final-match', label: '本戦・対戦', scene: 'match', phase: 'finals' }, + { id: 'result', label: '結果発表', scene: 'result', phase: 'finals' }, + { id: 'closing', label: '閉会式', scene: 'ceremony', phase: 'closing' }, + ], + }; + if (demo) { + state.players = [ + '青葉 はる', + '北野 そら', + '白石 りく', + '港 みなと', + '星野 あお', + '大地 れん', + '若葉 ひなた', + '高原 ゆう', + ].map((name, i) => ({ + id: `player-${i + 1}`, + name, + affiliation: ['青空中学校', 'みらい中学校', 'つばさ高校', 'プログラミングクラブ'][i % 4]!, + group: i < 4 ? 'A' : 'B', + })); + state.matches = createQualifyingMatches(state); + state.matches.forEach((match, i) => { + if (i > 10) return; + match.firstCool = match.a; + match.started = true; + const game = (number: 1 | 2): Game => ({ + number, + scoreA: 18 + i * 2 + number, + scoreB: 13 + i + number, + reason: i === 3 && number === 1 ? 'put' : 'points', + specialWinner: i === 3 && number === 1 ? 'a' : null, + remainingTurns: 40, + }); + match.games = i === 10 ? [game(1)] : [game(1), game(2)]; + }); + state.currentMatchId = state.matches[10]!.id; + state.cueId = 'qualifying'; + } + return state; +} diff --git a/chros/lib/tournament/match-commands.ts b/chros/lib/tournament/match-commands.ts new file mode 100644 index 0000000..4043981 --- /dev/null +++ b/chros/lib/tournament/match-commands.ts @@ -0,0 +1,82 @@ +import type { Command, ControlState, Game, Match } from '@chros/shared'; +import { reasonLabels } from '@chros/shared'; +import { playerName } from '@chros/scoring'; +import { assertAllowed, assertEditableMatch, findMatch } from './matches'; +import { synchronizeFinals } from './finals'; + +export function startMatch( + state: ControlState, + command: Extract, +) { + const match = findMatch(state, command.matchId); + assertEditableMatch(state, match); + assertAllowed(match.a && match.b, '両方の対戦者が確定してから開始してください。'); + assertAllowed(!match.started, 'この試合は開始済みです。'); + assertAllowed([match.a, match.b].includes(command.firstCool), '先攻は対戦者から選んでください。'); + + match.firstCool = command.firstCool; + match.started = true; + return `${match.label}: じゃんけん後の第1戦先攻 ${playerName(state, command.firstCool)}`; +} + +function gameRecordDetail(state: ControlState, match: Match, game: Game) { + const specialWinnerName = game.specialWinner + ? playerName(state, match[game.specialWinner]) + : 'なし'; + + return [ + `${match.label} 第${game.number}戦: ${game.scoreA} - ${game.scoreB}`, + reasonLabels[game.reason], + `特殊勝者 ${specialWinnerName}`, + `残り ${game.remainingTurns}ターン`, + ].join(' / '); +} + +export function saveGame(state: ControlState, command: Extract) { + const match = findMatch(state, command.matchId); + const { game, correction } = command; + assertEditableMatch(state, match); + assertAllowed(match.started && match.firstCool, '先攻を登録して試合を開始してください。'); + + const firstGameRecorded = match.games.some((recorded) => recorded.number === 1); + assertAllowed(game.number === 1 || firstGameRecorded, '第1戦を先に記録してください。'); + const winnerMatchesReason = + game.reason === 'points' ? game.specialWinner === null : game.specialWinner !== null; + assertAllowed(winnerMatchesReason, '勝因と特殊ポイントの勝者が一致していません。'); + + const previousGame = match.games.find((recorded) => recorded.number === game.number); + assertAllowed( + !previousGame || correction.length > 0, + '記録済みの結果を直す場合は訂正理由が必要です。', + ); + match.games = [...match.games.filter((recorded) => recorded.number !== game.number), game].sort( + (first, second) => first.number - second.number, + ); + + let detail = gameRecordDetail(state, match, game); + if (previousGame) { + detail += ` / 訂正理由: ${correction} / 変更前: ${JSON.stringify(previousGame)}`; + } + synchronizeFinals(state); + return detail; +} + +export function replayMatch(state: ControlState, command: Extract) { + const match = findMatch(state, command.matchId); + assertEditableMatch(state, match); + assertAllowed(match.started, '未開始の試合は仕切り直せません。'); + + match.previousAttempts.push({ + games: structuredClone(match.games), + firstCool: match.firstCool, + at: new Date().toISOString(), + reason: command.reason, + }); + match.games = []; + match.firstCool = null; + match.started = false; + match.attempt++; + + synchronizeFinals(state); + return `${match.label}: 両戦を無効にして再試合 / ${command.reason}`; +} diff --git a/chros/lib/tournament/matches.ts b/chros/lib/tournament/matches.ts new file mode 100644 index 0000000..ab70e79 --- /dev/null +++ b/chros/lib/tournament/matches.ts @@ -0,0 +1,42 @@ +import type { ControlState, Match } from '@chros/shared'; + +export function assertAllowed(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +export function createMatch( + label: string, + a: string | null, + b: string | null, + group: string, +): Match { + return { + id: crypto.randomUUID(), + label, + stage: 'qualifying', + group, + a, + b, + sources: [null, null], + round: 0, + firstCool: null, + started: false, + bye: false, + games: [], + attempt: 1, + previousAttempts: [], + }; +} + +export function findMatch(state: ControlState, matchId: string): Match { + const match = state.matches.find((candidate) => candidate.id === matchId); + assertAllowed(match, '試合が見つかりません。'); + return match; +} + +export function assertEditableMatch(state: ControlState, match: Match) { + const finalsExist = state.matches.some((candidate) => candidate.stage === 'finals'); + const qualifyingIsLocked = match.stage === 'qualifying' && finalsExist; + assertAllowed(!qualifyingIsLocked, '本戦の組み合わせ確定後は予選結果を変更できません。'); + assertAllowed(!match.bye, '不戦進出の枠には得点を入力できません。'); +} diff --git a/chros/lib/tournament/players.ts b/chros/lib/tournament/players.ts new file mode 100644 index 0000000..390f504 --- /dev/null +++ b/chros/lib/tournament/players.ts @@ -0,0 +1,35 @@ +import type { ControlState, Player } from '@chros/shared'; +import { playerName } from '@chros/scoring'; +import { assertAllowed } from './matches'; + +export function savePlayer(state: ControlState, player: Player) { + const previousPlayer = state.players.find((candidate) => candidate.id === player.id); + const fixturesExist = state.matches.length > 0; + const keepsExistingGroup = previousPlayer && previousPlayer.group === player.group; + assertAllowed( + !fixturesExist || keepsExistingGroup, + '組み合わせ作成後の追加・グループ変更はできません。', + ); + + const duplicateName = state.players.some( + (candidate) => candidate.id !== player.id && candidate.name === player.name, + ); + assertAllowed(!duplicateName, '同じ表示名があります。区別できる名前にしてください。'); + assertAllowed(previousPlayer || state.players.length < 128, '参加者は128名まで登録できます。'); + + if (previousPlayer) { + state.players = state.players.map((candidate) => + candidate.id === player.id ? player : candidate, + ); + } else { + state.players.push(player); + } + return `${previousPlayer ? '参加者更新' : '参加者追加'}: ${player.name} / ${player.group}組`; +} + +export function removePlayer(state: ControlState, playerId: string) { + assertAllowed(!state.matches.length, '組み合わせ作成後の参加者削除はできません。'); + const removedName = playerName(state, playerId); + state.players = state.players.filter((player) => player.id !== playerId); + return `参加者削除: ${removedName}`; +} diff --git a/chros/lib/tournament/qualifying.ts b/chros/lib/tournament/qualifying.ts new file mode 100644 index 0000000..c904801 --- /dev/null +++ b/chros/lib/tournament/qualifying.ts @@ -0,0 +1,33 @@ +import type { ControlState, Match } from '@chros/shared'; +import { groupNames } from '@chros/scoring'; +import { assertAllowed, createMatch } from './matches'; + +export function createQualifyingMatches(state: ControlState) { + return groupNames(state).flatMap((group) => { + const players = state.players.filter((player) => player.group === group); + const matches: Match[] = []; + + for (let first = 0; first < players.length; first++) { + for (let second = first + 1; second < players.length; second++) { + const label = `${group}-${String(matches.length + 1).padStart(2, '0')}`; + matches.push(createMatch(label, players[first]!.id, players[second]!.id, group)); + } + } + + return matches; + }); +} + +export function generateQualifying(state: ControlState) { + const groups = groupNames(state); + assertAllowed(!state.matches.length, '組み合わせはすでに作成されています。'); + assertAllowed(state.players.length >= 2, '参加者を2名以上登録してください。'); + const everyGroupHasOpponents = groups.every( + (group) => state.players.filter((player) => player.group === group).length >= 2, + ); + assertAllowed(everyGroupHasOpponents, '各グループに2名以上登録してください。'); + + state.matches = createQualifyingMatches(state); + state.currentMatchId = state.matches[0]!.id; + return `${groups.length}組 / 総当たり ${state.matches.length}試合を作成`; +} diff --git a/chros/lib/use-control.ts b/chros/lib/use-control.ts new file mode 100644 index 0000000..1769941 --- /dev/null +++ b/chros/lib/use-control.ts @@ -0,0 +1,61 @@ +'use client'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { ControlState } from '@chros/shared'; +import type { SendCommand } from './control-types'; +export function useControl() { + const [state, setState] = useState(null); + const [connected, setConnected] = useState(false); + const [pending, setPending] = useState(false); + const [error, setError] = useState(''); + const latestRevision = useRef(0); + + const sendingCommand = useRef(false); + + const acceptSnapshot = useCallback((next: ControlState) => { + if (next.revision < latestRevision.current) return; + latestRevision.current = next.revision; + setState(next); + }, []); + + useEffect(() => { + const events = new EventSource('/api/stream'); + events.addEventListener('snapshot', (event) => { + acceptSnapshot(JSON.parse((event as MessageEvent).data)); + setConnected(true); + }); + events.onerror = () => setConnected(false); + return () => events.close(); + }, [acceptSnapshot]); + + const send = useCallback( + async (command) => { + if (sendingCommand.current) return false; + sendingCommand.current = true; + setPending(true); + setError(''); + try { + const response = await fetch('/api/state', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ expectedRevision: latestRevision.current, command }), + }); + const responseBody = await response.json(); + if (!response.ok) { + if (responseBody.state) acceptSnapshot(responseBody.state); + throw new Error(responseBody.error || '保存できませんでした。'); + } + acceptSnapshot(responseBody); + return true; + } catch (error) { + setError(error instanceof Error ? error.message : '接続を確認してください。'); + return false; + } finally { + sendingCommand.current = false; + setPending(false); + } + }, + [acceptSnapshot], + ); + + return { state, connected, pending, error, setError, send }; +} diff --git a/docker-compose.yaml b/docker-compose.yaml index 272aa25..8ff76fd 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -7,15 +7,13 @@ services: ports: - "3000:3000" environment: - #NOTE: 本番環境の時は、以下の変数を変えること。 - - NODE_ENV=development - - DATABASE_URL=postgresql://user:password@db:5432/database - - NEXT_PUBLIC_APP_URL=http://localhost:3000 - depends_on: - db: - condition: service_healthy + - NODE_ENV=production + - CHROS_DATA_DIR=/data/chros + volumes: + - chros-data:/data/chros db: + profiles: [database] image: postgres:17 environment: - POSTGRES_USER=user @@ -32,4 +30,5 @@ services: retries: 10 volumes: + chros-data: db-data: diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..8acfa25 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,52 @@ +# コードの読みやすさと検証 + +## 日常の確認 + +```sh +pnpm format +pnpm format:check +pnpm typecheck +pnpm test +``` + +PRではGitHub Actionsが整形・型・テストを確認します。Prettierの版と設定を固定し、 +インデントや改行の差を持ち込まないようにしています。生成物と大会の保存データは整形対象外です。 +整形チェックだけでは命名や責務分割の良し悪しは判断できないため、以下もレビューで確認します。 + +## 書き方 + +- 変数名は役割を表す名前にします。試合・参加者・記録を `m`・`p`・`g` だけで表さず、 + `match`・`player`・`recordedGame` など、その場所で意味が分かる名前を使います。 +- 一つの宣言に複数の変数を並べず、複雑な条件には `qualifyingComplete` などの名前を付けます。 +- 大会規則、保存、HTTP応答、画面の描画を別の役割として扱います。 + 関数の分割は処理の目的を区切るために行い、単純な一行まで細切れにはしません。 +- 例外的な状態は早めに返し、複数の画面を選ぶ処理は `switch` などで並べて読みます。 + 大きなJSXに入れ子の三項演算子や複数段階の保存処理を埋め込みません。 +- 複数の処理を行うイベントハンドラーには、`saveResult`・`uploadLogo` などの名前を付けます。 + 単純な入力値の更新は、その入力欄の近くに残します。 +- コメントはコードだけでは分からない理由や制約に限ります。処理を日本語に言い換えるコメントや、 + 全関数への機械的な説明は追加しません。大会運用上の判断は設計資料に記録します。 +- 整形だけの変更と処理構造の変更は別コミットにし、仕様変更を混ぜません。 + +## 実装の入口 + +| 確認したい内容 | 入口 | +| --- | --- | +| 1戦・2戦の判定 | `packages/scoring/src/match.ts` | +| 予選順位 | `packages/scoring/src/standings.ts` | +| 操作の振り分け・履歴 | `chros/lib/tournament.ts` | +| 試合記録・予選生成・本戦生成 | `chros/lib/tournament/` | +| 保存と書き出し | `chros/lib/store.ts`、`chros/lib/export-results.ts` | +| 運営画面全体 | `chros/components/ConsoleApp.tsx`、`console/` | +| 参加者・試合一覧・設定 | `chros/components/management/` | +| 試合の記録入力 | `chros/components/MatchEditor.tsx`、`matches/` | +| 掲示内容の選択・描画 | `chros/components/Board.tsx`、`board/` | + +## 参考にした記事 + +- [Zenn:可読性の低いコードはAIも読めない](https://zenn.dev/aircloset/articles/0b2753cc2f4efe) + — 役割が伝わる命名、関連処理のまとまり、一貫した書式、理由に絞ったコメントを参考にしました。 +- [Zenn:早期リターンを書こう](https://zenn.dev/media_engine/articles/early_return) + — 状態の判定とその結果を近くに置き、深い条件分岐を減らす方針を参考にしました。 +- [Prettier公式:Install](https://prettier.io/docs/install) + — 版の固定と `--check` による整形確認の設定を参照しています。 diff --git a/docs/prototype-proposal.md b/docs/prototype-proposal.md new file mode 100644 index 0000000..339cad2 --- /dev/null +++ b/docs/prototype-proposal.md @@ -0,0 +1,140 @@ +# 提案:記録と掲示をひとつにした CHroS + +作成日: 2026-09-18。**対人戦の得点・勝因を入力すると、2戦の勝敗、予選順位、本戦への +勝ち上がり、会場の掲示までつながる試作**です。画面のモックだけでなく、保存と集計まで実装しています。 + +今回の確認用サーバーは [運営画面](http://127.0.0.1:3100/console) と +[会場掲示](http://127.0.0.1:3100/display) で起動します。 +サーバーを停止した場合の再起動方法は下記を参照してください。 + +## まず5分で試す + +初期サンプルは、架空の参加者8名・A/Bの2グループです。 +予選12試合のうち10試合が終了し、「B-05」の第1戦まで記録された状態から始まります。 + +1. 運営画面と会場掲示を別タブ、または別のモニターで開きます。 +2. 運営画面の下側にある第2戦で、大地れん **15点**、高原ゆう **18点**、勝因「得点比較」を保存します。 + 大地れん **54点**、高原ゆう **42点**となり、試合の勝者は大地れんになります。 +3. 「試合結果」ボタンを押すと、会場に勝者を掲示します。「予選順位」では順位が更新されています。 +4. 「掲示対象の試合」をB-06に変え、じゃんけんで決めた先攻を選んで開始します。 + 第1戦を若葉ひなた **12点**/高原ゆう **20点**、PUTで若葉ひなた勝利、 + 第2戦を **5点/35点**、得点比較として保存すると、合計17対55でも特殊P1対0で若葉ひなたが勝ちます。 + **この例が、特殊ポイントの優先順位を確認していただくための例です。** +5. 「対戦・記録 → 順位・トーナメント」で進出者とシード順を確認し、本戦を作ります。 + A1-B2、B1-A2の準決勝と、その勝者を受け取る決勝ができます。 +6. 「コントロール」の進行表で「本戦の組み合わせ」を選び、本戦表を掲示します。 + 「次へ」で本戦の対戦表示へ進みます。対戦用の表示は、選択中の掲示対象を使います。 + +確認が終わったら「設定・履歴 → サンプルに戻す」で初期状態を再作成できます。 +それまでの大会はサーバー内に退避されます。実データを入力する場合は「空の大会を作成」を選びます。 + +## 操作できること + +| 操作場所 | 実装した内容 | +| --- | --- | +| コントロール | 会場のプレビュー、6画面の切替、進行区分、掲示対象の選択、得点入力 | +| 表示の進行表 | 項目の追加・編集・並べ替え・削除、任意項目の表示、「次へ」 | +| 対戦・記録 | 全試合と状態、各戦の得点・勝因・先後、訂正理由、再試合、勝者・順位、本戦作成 | +| 参加者 | 氏名・所属・グループ、総当たりの組み合わせ生成 | +| 設定・履歴 | 大会名、計算方式、各組の進出人数、幕間ロゴ、操作履歴、CSV・JSON出力 | +| 会場掲示 | 開会式等の案内、対戦途中の確定値、勝者、予選順位、本戦表、幕間ロゴ | + +得点はゲーム終了時に更新します。画面の切り替えは運営が行い、結果保存だけで自動切り替えはしません。 +全結果はサーバーに保存し、ブラウザーを閉じても残ります。 + +## 判断記録 — 確認してほしいこと + +「未確定だから実装を止める」のではなく、以下の判断で試作しました。 +ユーザーから明示された要件、一次資料、自主判断を混同しないように分けています。 + +| ID | 採用した判断 | 根拠・理由 | 確認時の見どころ | +| --- | --- | --- | --- | +| D01 | 特殊勝利1回を1特殊Pとし、2戦の特殊P→得点で試合判定。PUT・切断等に種類別の重みは付けない | 特殊Pが勝因の抽象化であることは依頼者の明示。1Pの付与と合計の比較順は実装者の判断 | 上記17対55でも特殊Pで勝つ例が、実際の運用と一致するか | +| D02 | 釧路の予選順位は試合勝数→特殊P→合計得点 | 予選順位の詳細は未指定のため自主判断。確定試合だけで順位を計算 | 順位基準に直接対決等を加える必要があるか | +| D03 | 全比較が同じ試合は新マップで2戦とも再試合。予選の完全同順位は主催者が理由を付けて進出を裁定 | 再試合の運用資料と、勝者を推測しない記録設計 | じゃんけん・抽選・再試合など裁定の実際の手順が記録しやすいか | +| D04 | 先攻の選択は手入力。第2戦は自動反転。A/Bの入力欄の人物は固定 | じゃんけん・先後交代は依頼者の明示。入力欄を移動させないのは取り違えを防ぐ判断 | COOL/HOT表示と実機側の運用が対応しているか | +| D05 | 2グループの上位2名ならA1-B2/B1-A2。不足枠は上位シード側の不戦進出 | 本戦の具体的な配置が未指定のため標準的なシード配置を採用 | 組み合わせ抽選や同組の再戦回避に別のルールが必要か | +| D06 | 画面切替は手動。表示順も手動の「次へ」。進行区分・掲示対象・表示内容を別々に選べる | OBS型操作と事前の順番という依頼者の希望。入力途中の意図しない掲示を避ける | ボタンの数と操作順が大会当日の担当者に合うか | +| D07 | 表示順の対戦画面は現在の掲示対象を参照。時刻指定や項目ごとの試合固定は今回含めない | 同じ対戦画面を何試合にも再利用する基本形を優先 | 試合単位の細かな台本を事前登録する必要があるか | +| D08 | 得点訂正は理由必須。本戦確定後の予選修正、後続試合開始後の進出者変更は拒否 | 進行済みの対戦表を黙って書き換えないための自主判断 | 実運用で必要な取消・巻き戻しの範囲が足りるか | +| D09 | PostgreSQL・任意式のノード編集を今回の試作から外し、計算方式2つとJSONファイル保存を使う | 基本操作を一続きに評価できる成果物を優先。既存のNext.jsは継続 | 操作評価後にDB・規則編集のどちらを先に広げるか | +| D10 | 旭川向けは対人戦の計算のみ。Botなし。総当たり順位は試合勝数→換算得点 | 北海道v3.1.0の対人ルールと、対人限定という依頼者の明示。旭川の総当たり順位は試作判断 | 大会全体の正式再現ではないことを前提に、方式の差し替えが役立つか | +| D11 | 通信切断は「参加者に起因する敗北」の裁定後に選ぶ。タイムアウトやサーバー障害は手動で仕切り直す | 自動連携はまだないため、運営判断を勝手に代行しない | 勝因の選択肢に足りないものがあるか | +| D12 | 会場LAN・単一サーバー・認証なし。外部フォント等を使わず動作する | 既存のネットワーク要件を継続。公開ホスティングは行わない | 実際の会場接続にこの運用形態が合うか | +| D13 | 大きな順位表・本戦表はページ切替。自動巡回は行わない | 投影時に文字を過度に小さくしないため | 全体図と個別ページの使い分けが足りるか | + +最初に確認していただきたいのは **D01の試合判定、D02の予選順位、D05の本戦配置** です。 +決定後はこの表に採否を記録し、計算方式の規則版を更新して実装へ反映します。 + +## 確認して改善した点 + +- ダミーの足し算を、素点・特殊P・勝因・各戦勝数を区別する計算に置き換えました。 +- 第1戦だけで試合勝者を確定することや、再試合待ちの得点が順位に混ざることを防ぎました。 +- 入力欄は空欄から始め、未入力のまま0対0として保存しにくくしました。 +- 記録を訂正すると順位も再計算され、後続の試合が変わる危険な修正は拒否します。 +- 入力と別タブの掲示が連動すること、画面選択と表示順が保存されることを確認しました。 +- ブラウザー標準の確認ダイアログがアプリ内ブラウザーで停止したため、画面内の確認ダイアログに置き換えました。 +- 長い表はページ化し、CSVでは文字列が数式として解釈されないようにしています。 +- 外部Google Fontsへの依存を取り除き、会場LANでの動作を優先しました。 + +## 検証結果 + +2026-09-23に可読性改善後の型検査・テスト・本番ビルド・API結合テスト・ブラウザー操作を再確認しました。 +書き方と実装の入口は [開発ガイド](./development.md) にまとめています。 + +| 検証 | 結果 | +| --- | --- | +| 計算・進行のユニットテスト | 29件通過。通常得点、特殊勝利、旭川換算、左右反転、訂正、再試合、同順位、勝ち上がり、不戦枠等 | +| TypeScript | アプリ・shared・scoringで型検査と未使用変数・引数の検査を通過 | +| 整形 | Prettierで書式を統一し、整形・型・テストのPR自動検査を追加 | +| 可読性改善前後の比較 | 採点・大会操作705件、掲示HTMLとページ分割90件、CSV24件を一時検証スクリプトで比較し、一致を確認 | +| Next.js本番ビルド | 成功 | +| API結合テスト | 参加者6名→2組の予選6試合→本戦3試合→決勝まで通過 | +| SSE | 初期状態、更新通知、別掲示画面への得点・結果・順位・表示順反映を確認 | +| ブラウザー操作 | 得点入力、先後交代、特殊勝利、別画面への結果反映、設定保存、順位表示、表示順の編集・再生・「次へ」を確認。画面内確認のキャンセル・実行と本戦生成も確認済み | +| 更新競合 | 古い更新番号の保存を409で拒否し、最新状態を返すことを確認 | +| エクスポート | CSVの各戦結果・数式対策、JSONの全記録・履歴を確認 | +| 永続化 | 別のテスト用サーバーを停止・再起動し、更新番号40・決勝2戦・結果掲示状態の保持を確認 | +| Docker | 永続化volumeを設定。Dockerでのビルド・起動は未検証 | + +API結合テストは `scripts/smoke-prototype.mjs` に残しています。 +**対象サーバーの大会を切り替えるテスト**のため、必ず別の `CHROS_DATA_DIR` とポートで実行してください。 + +## 起動と保存 + +通常の開発: + +```sh +pnpm install +pnpm dev +``` + +本番形式: + +```sh +pnpm build +pnpm start +``` + +この環境で用意した3100番ポートの試作を再起動する場合: + +```sh +cd chros +./node_modules/.bin/next start --hostname 0.0.0.0 --port 3100 +``` + +既に依存関係が入っている環境では上記の直接起動が可能です。 +Node.js 22以降を使います。ファイル保存は `chros/.chros`、または `CHROS_DATA_DIR` の指定先です。 +各種の詳細は [技術要件](./technical-requirements.md) にまとめています。 + +## 今回の限界 + +- 試合サーバーとの連携、Bot戦、マップ管理、全国固有の採点方式は実装していません。 +- 会場で複数コートを同時進行する割当や対戦順の最適化は含みません。掲示対象は一度に1試合です。 +- 参加者は最大128名。負荷・数十端末での連続運転・実際のプロジェクターでの視認性は未検証です。 +- 任意の規則エディター、複数サーバー運用、DB連携、復元専用の画面はありません。 +- 一般公開用の認証や個人情報の公開範囲管理はありません。従来要件どおり会場LAN内の試作です。 +- データ退避からの復元は、サーバー停止中にJSONを戻す手順です。大会の切替画面で過去大会を選ぶ機能は今後の対象です。 + +サンプルの数値・名前・所属は実際の大会記録ではありません。 +公開資料と現行運用の区別は [大会ルール調査](./tournament-rules.md) に残しています。 diff --git a/docs/requirements.md b/docs/requirements.md index 3f2328a..437f89d 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -1,103 +1,85 @@ # CHroS 要件定義 -## 背景 +更新: 2026-09-18。今回の依頼者の補足を優先し、対人戦の記録と掲示をつなぐ試作を作成した。 +動作確認の手順と自主判断は [試作提案・判断記録](./prototype-proposal.md)、実装の構成は +[技術要件](./technical-requirements.md)、一次資料の調査は [大会ルール調査](./tournament-rules.md) を参照。 -CHaser 大会の進行(対戦順・スコア・配信画面)は、これまでスプレッドシートによる人力運用で行われていた。 -スコアの集計ミス、配信スライドのその場作成、進行担当の負荷集中が慢性的な課題となっている。 +## 目的 -CHroS はこれを一部または全部自動化し、大会運営の人的負担を減らすことを目的とする。 +「誰と誰が戦い、何点取り、どの条件で勝ったか」をスプレッドシートより簡単かつ確実に +記録・計算・集計する。その結果を会場の掲示画面につなぎ、大会の進行に合わせて表示する。 +試合サーバーとは接続せず、運営が各戦の終了時に結果を入力する。 -## スコープの分割 +## 対象と大会進行 -一度に全体を作ると設計が破綻するため、以下の2ドメインに分離して設計・実装し、最終的に統合する。 +- 主対象は釧路大会。現在の対象は **対人戦のみ**。Bot戦は実装しない。 +- 予選は総当たり。本戦は勝ち抜きトーナメント。 +- 多人数時はグループ内で総当たりを行い、原則各組上位2名が本戦へ進む。 + グループの割り当ては運営が行う。進出人数は設定できる。 +- 1試合は2戦。第1戦の先攻・後攻はじゃんけんで決めた結果を入力し、第2戦で交代する。 +- 旭川向けには対人戦の計算方式を選択できる。Bot予選を含む旭川大会全体の再現は対象外。 +- 全国交流大会は詳細な規則が未確認。全国用のプリセットがあるとは表示しない。 -| ドメイン | 責務 | -| --- | --- | -| スコア管理 | 参加者・対戦・得点の登録と集計、順位計算 | -| 画面管理 | 配信に載せる画面の切り替えと、進行状況に同期した表示 | - -両者は「現在の大会進行状態」を共有する。統合時はこの状態モデルが接続点になる。 - -## 機能 - -- 得点管理 -- 対戦順管理 -- 得点表示 -- 対戦順表示 -- 対戦状況表示 - -### 得点管理 - -スプレッドシートで管理されていたものを、このシステムで管理できるようにする。 - -- 参加者の登録・編集 -- 対戦ごとの前半/後半のスコア入力 -- 勝敗判定(得点、PUT、通信切断などの条件を含む) -- 入力ミスの訂正(履歴が追える形で) - -#### 可搬性 — 得点管理における最重要要件 - -釧路大会は他大会と比べてスコア計算が複雑であり、規則は大会ごとに異なる。 -計算式をシステムに固定すると、規則が変わるたびにエンジニアの改修が必要になり、可搬性を失う。 - -したがって、**計算式を外部から与えられるデータとして扱う**ことを本システムの核とする。 - -- 計算式は非エンジニア(大会運営)が編集できること -- 曖昧な状態のまま大会に適用されないよう、検証の仕組みを持つこと -- どの規則で算出した結果なのかが、後から辿れること - -編集手段は「コードを書かせる」形を採らない。どんな言語であっても非エンジニアには負担が大きいため、 -**ノードを繋いで組み立てる方式**とする。実装方針は -[技術要件 §1.2 / §5](./technical-requirements.md) を参照。 +## 得点・結果の管理 -### 対戦順管理 +1. 参加者、所属、予選グループを登録・編集する。 +2. 各組の総当たり表を生成し、対戦者と先攻・後攻を確定する。 +3. 各戦の取得得点、勝因、特殊勝利の勝者を記録する。必要な方式では残りターン数も入力する。 +4. 2戦の集計結果、試合の勝者、判定根拠を示し、予選順位に反映する。 +5. 同点の試合は勝者を推定せず、両戦を無効にして再試合できるようにする。 +6. 記録済み結果の訂正には理由を残し、修正前の値を履歴で追えるようにする。 +7. 予選終了後に進出者とシード順を確認して本戦を生成する。同順位の裁定理由も保存する。 +8. 本戦の勝者を次の試合へ反映する。不戦枠にも対応する。 +9. CSVで各戦の結果、JSONで全記録・計算方式・操作履歴を取り出せるようにする。 -スプレッドシートで管理されていたものを、このシステムで管理できるようにする。 +### 特殊ポイント -- 総当たり戦 / トーナメントの対戦表生成 -- 現在の対戦、次の対戦の管理 -- 進行状態(未実施 / 進行中 / 完了)の遷移 +依頼者の説明に従い、特殊ポイントは勝因をわかりやすくまとめる **抽象化** とする。 +PUT・包囲・相手の通信切断などによる勝利を同じ1特殊Pとして扱う。 +防げる敗因をプログラムで防げていないことを重く見る、という導入背景も踏まえる。 +数値にまとめても、元の勝因と素点は消さない。 -### 得点表示 +試作の釧路向け判定は **2戦の特殊P合計 → 得点合計 → 完全同値なら再試合** とする。 +特殊Pの優先順位と、予選順位の **試合勝数 → 特殊P → 得点** は試作の自主判断であり、 +依頼者に確認してもらう項目として記録する。公開資料だけで確定した公式計算式とは呼ばない。 -各対戦で、最終的に誰が何点取り、どのような勝ち方をしたかなどを一覧で表示する。 -従来はスプレッドシートのデータをもとにその場でスライドを作成していたため、 -得点管理から表示までをシステムで一元的に行う。 +旭川向けの対人戦は **ゲーム勝数 → 換算得点合計 → 再試合** とする。 +PUT・相手による包囲で敗れた側は0点、自分に起因する通信切断・移動違反・自己包囲で +敗れた側は残りターン数の負点に換算し、生の得点を保存する。 -### 対戦順表示 +## 掲示と操作 -総当たり戦やトーナメントなど、一目で見て理解できるリッチな UI を作成する。 - -### 対戦状況表示 - -以下の内容を一目で理解できる UI を作成する。 - -- 誰と誰が戦っているか -- 前半戦ではどちらが何点取ったか -- どちらの方が有利 / 不利か -- 最終的な勝者はどちらか - -## 利用者と画面 - -| ロール | 画面 | 用途 | -| --- | --- | --- | -| 運営(進行・記録) | Console | データ入力、画面切り替え操作 | -| 視聴者(配信越し) | Display (Viewer) | 配信に載る表示専用画面。操作は行わない | - -Viewer はサーバーの状態に追従して表示を変えるだけであり、Viewer からサーバーへの入力は発生しない。 -この非対称性が通信方式の選定根拠になる([技術要件](./technical-requirements.md) 参照)。 - -## 非機能要件 - -- **同時接続数**: 数十程度(運営端末数台 + 配信用ブラウザ数枚)。大規模スケールは不要。 -- **可用性**: 大会当日の数時間、無停止で動けばよい。ただし配信中の再起動は許容できないため、Viewer は再接続で自動復帰すること。 -- **ネットワーク**: 会場 LAN での運用を前提とする。インターネット接続が不安定でも動作すること。 -- **セキュリティ**: 認証・権限管理は設けない。会場 LAN 内に閉じた運用を前提とした - 意図的な判断であり、前提が変わる場合は再検討する([技術要件 §1.3](./technical-requirements.md))。 -- **データ**: 大会終了後にスコアを永続的に参照できること(後日の記録公開に使う)。 -- **再現性**: 過去の対戦結果は、当時適用した計算規則とともに再現できること。 - -## 今後の目標(初版スコープ外) - -- **自動抽選システム**: 対戦組み合わせの自動生成。データ設計の段階で、 - 抽選結果を後から流し込める形にしておく。 +| 表示 | 内容 | +| --- | --- | +| 進行案内 | 開会式、予選、本戦、休憩、閉会式の現在の区分 | +| 対戦・得点 | 対戦者、所属、各戦終了後の得点、特殊P、記録済み戦数 | +| 試合結果 | 勝者、合計値、勝敗を決めた比較基準、各戦の勝因 | +| 予選順位 | グループ別順位、勝敗、特殊P、得点、全体の消化試合数 | +| 本戦表 | 組み合わせ、勝者、次の試合への進出 | +| 幕間・ロゴ | 登録したロゴまたは標準ロゴ、現在の進行区分 | + +- OBSのシーン操作に相当する6つの画面ボタンで掲示内容を切り替える。 +- 進行区分と掲示対象の試合は明示的に選ぶ。得点保存だけで表示画面を勝手に変えない。 +- 表示順に名前・進行区分・画面を登録し、並べ替え・追加・削除できる。 + 当日は任意の項目、または「次へ」で進める。時刻による自動再生は行わない。 +- 試合用の表示順は、現在の「掲示対象の試合」を参照する。試合ごとの固定指定は今後の拡張。 +- 大きな順位表・本戦表はページに分け、運営側から掲示ページを切り替える。 +- 運営画面内のプレビューと `/display` は同一の表示部品を使う。 +- 掲示は「各戦終了後の値」であることを明示する。試合中のリアルタイム得点は将来のサーバー連携で扱う。 + +## データと運用 + +- 会場LAN内で単一サーバーを使い、運営画面と掲示画面をブラウザーで開く。 +- 保存成功後に全画面へ通知する。別端末の同時更新で古い結果を上書きしない。 +- サーバー再起動後も記録が残り、掲示は再接続すると現在の状態に戻る。 +- 後続の試合が開始済みなら、進出者を変える結果訂正を拒否する。 +- 本戦作成後の予選結果変更、試合開始後の計算方式変更は拒否する。 +- 認証は従来要件どおり設けない。インターネットへの公開を想定しない。 +- 初回は架空の参加者によるサンプルを表示し、空の大会を作成できるようにする。 + +## 今回は実装しないもの + +Bot戦、試合サーバー連携、マップ管理、審判判断の自動化、タイマーによる画面再生、 +任意の計算式を組むノードエディター、全国大会固有ルール、複数大会の同時進行。 +ノード式・PostgreSQLなどの従来構想は [将来検討用の技術設計](./technical-roadmap.md) に残す。 +まず記録から掲示までの基本操作を評価し、その結果で拡張の順番を決める。 diff --git a/docs/technical-requirements.md b/docs/technical-requirements.md index 40efe43..34a1a4d 100644 --- a/docs/technical-requirements.md +++ b/docs/technical-requirements.md @@ -1,970 +1,115 @@ -# CHroS 技術要件 +# CHroS 技術要件 — 対人戦の試作 -対象読者: CHroS の実装者。要件そのものは [requirements.md](./requirements.md) を参照。 +実装日: 2026-09-18。ユーザーが操作して評価できる、会場LAN向けの単一Next.jsアプリ。 +要求は [要件定義](./requirements.md)、試用手順と自主判断は [試作提案](./prototype-proposal.md) を参照。 +従来のノードグラフ・PostgreSQLを中心とする詳細設計は [technical-roadmap.md](./technical-roadmap.md) に保存した。 +**現在の実装を説明する文書は本書**であり、将来設計の実装完了を意味しない。 -このドキュメントは、既存実装(Next.js + Express/socket.io + FastAPI の3サービス構成)を -ゼロベースで再設計した結果を定める。既存コードとの差分は末尾の「現行構成からの移行」に記す。 +## 構成 -## 1. 決定事項サマリ - -| 項目 | 決定 | 理由 | -| --- | --- | --- | -| 言語 | TypeScript に統一 | フロントが存在する以上 TS は必須。型を共有パッケージで持てば境界のズレが消える | -| 構成 | pnpm workspace による monorepo、アプリは Next.js 単体 | サービス分割のコストに見合う独立性が現時点で無い。統合の逆は容易だが分割の巻き戻しは高くつく | -| サーバー → Viewer 通信 | **SSE**(WebSocket は採用しない) | Viewer は受信専用。双方向性が要らないなら SSE の方が単純で、HTTP のまま扱える | -| DB | PostgreSQL | 変更なし | -| ORM | Prisma | 変更なし | -| スコア計算 | **ノードグラフで定義し JSON として永続化**、評価器は TS | 計算式を非エンジニアが編集できることが本システムの核(§5) | -| 計算式の保存先 | PostgreSQL の `jsonb` | 形が変わりうるデータを構造化のまま持て、検索も効く | -| 認証・権限 | **設けない** | 会場 LAN 内でのみ動かす前提。意図的な決定であり、未検討ではない(§1.3) | - -### 1.1 なぜ WebSocket をやめるのか - -当初は WebSocket を前提にしていたが、通信要件を洗うと以下だった。 - -- Console → サーバー: 通常の HTTP リクエスト(スコア入力、画面切替)で足りる -- サーバー → Viewer: 一方向のプッシュのみ。Viewer からの送信は無い - -双方向のフレームが必要な箇所が無いため、SSE で要件を満たす。SSE を選ぶ利点は具体的に、 - -- 再接続とイベント ID による取りこぼし復帰がプロトコル標準(`Last-Event-ID`)で提供される -- 素の HTTP なので、リバースプロキシ・Next.js の Route Handler にそのまま載る -- socket.io / 別プロセスの WS サーバーが不要になり、サービスが1つ減る - -将来 Viewer 側から入力を受ける要件(観客投票など)が出た場合のみ WebSocket を再検討する。 - -### 1.2 なぜ計算式をコードで書かせないのか - -釧路大会は他大会と比べてスコア計算が複雑であり、大会ごとに規則も変わる。 -これをアプリのコードに埋め込むと、規則が変わるたびにエンジニアの改修が必要になる。 -そこで**計算式をデータとして外部から与えられる**構造を、本システムの中核に据える。 - -当初案は Lua または Python の式を外部入力として取り込み評価する方式だった。これは撤回する。 - -- 触ってほしい相手は非エンジニアであり、どんな言語であってもコードを書かせるのは負担が大きい -- 任意コード評価はサンドボックス・実行時間・例外の扱いを自前で抱えることになる -- 別言語ランタイムを持ち込むと TS 統一の利点(型共有)が計算式の境界で切れる - -代わりに、**ノードを繋いで計算を組み立てる方式**(Scratch 的な編集 UI)を採る。 -実体は JSON のグラフであり、`jsonb` に格納して TS の評価器で解釈する。 -これにより、編集 UI・保存形式・評価器のすべてが型を共有した1つの TS 世界に収まる。 - -一方で Rume の指摘どおり、ノード方式であっても「任意の式が組める」ことを目指すと結局複雑になる。 -**用意するノードの語彙は大会規則の実例から決める**(§5.8 未決事項)。 -表現力は後から足せるが、一度公開した保存形式は減らせないため、初版は狭く始める。 - -### 1.3 認証を設けない判断 - -Console にログインを設けない。運営が触れる端末は会場 LAN 内にあり、 -外部から到達できないネットワークで運用することを前提とする。 - -この判断が成立する条件を明記しておく。**満たせなくなった時点で再検討が必要**である。 - -- アプリを会場 LAN の外に公開しない(インターネット経由での運営操作を行わない) -- 配信用の Viewer も同じ LAN 内のブラウザから開く -- 大会後にシステムを稼働させたまま記録公開に使う場合は、 - **Console への到達を塞ぐか、そこで初めて認証を導入する**(§11) - -したがって、Console と Viewer をネットワーク的に分離する必要はないが、 -**Viewer 側の画面から Console へ遷移できる導線は置かない**(配信事故を避けるため)。 - -## 2. リポジトリ構成 - -``` -CHaserProgressionControlSystem/ -├── chros/ # Next.js (App Router) — Console / Viewer / API を内包 -│ ├── app/ -│ │ ├── console/ # 運営向け操作画面 -│ │ ├── display/ # 配信用 Viewer 画面 -│ │ └── api/ # Route Handlers (REST + SSE) -│ └── prisma/ -│ ├── schema.prisma -│ └── migrations/ -├── packages/ -│ ├── shared/ # ドメイン型・イベント定義・Zod スキーマ(DB 非依存) -│ └── scoring/ # スコア計算・順位計算の純粋ロジック -├── docs/ -├── docker-compose.yaml -└── pnpm-workspace.yaml -``` - -方針: - -- `packages/shared` と `packages/scoring` は**副作用と I/O を持たない**。DB・HTTP・React に依存しない。 - こうしておくと、後で API を別プロセスに切り出す判断をしたときに、そのまま持ち出せる。 -- Next.js アプリの中では Console / Viewer / API がディレクトリで分かれているだけであり、 - 依存の向きは `chros/** → packages/**` の一方向に限る。 - -### 2.1 `apps/` を設けない理由 - -当初は `apps/chros/` として monorepo の慣例どおり app 階層を設ける案だったが、これは採らない。 -この階層が要るのは**別々にデプロイされる成果物が複数ある**場合であり、CHroS では該当しないため。 - -想定していた同居先はいずれも別リポジトリで管理する。 - -| 想定 | 扱い | -| --- | --- | -| 大会後の記録公開(§11.3) | 別リポジトリ | -| CHaServer の隣に常駐するログ取り込み(§11.3) | 別リポジトリ | - -ノードエディタ UI(§5.8)や自動抽選は工数こそ大きいが、いずれも運営が Console で触る画面であり、 -`chros/app/console/**` の一部にすぎない。**機能の独立性や工数はデプロイ単位を分ける理由にならない。** - -一方 `packages/` は app が1つでも残す。別リポジトリへ持ち出す際にそのまま動くことに加え、 -`package.json` に next / react / prisma を持たせないことで、 -上記の「副作用と I/O を持たない」制約がレビューではなくビルドで守られるため。 - -## 3. 技術スタック - -### 3.1 共通 - -| 項目 | 採用 | -| --- | --- | -| パッケージマネージャ | pnpm (workspace) | -| Node.js | 22 LTS | -| TypeScript | 5.x, `strict: true`, `noUncheckedIndexedAccess: true` | -| バリデーション | Zod(API 入出力・SSE ペイロードの単一の真実) | -| Lint / Format | ESLint (flat config) + Prettier | -| テスト | Vitest(`packages/scoring` は必須、API は主要経路) | - -### 3.2 フロントエンド - -| 項目 | 採用 | +| 配置 | 責務 | | --- | --- | -| フレームワーク | Next.js 15 (App Router) | -| React | 19 | -| スタイル | Tailwind CSS v4 | -| Console のデータ取得 | Server Components + Server Actions を基本とする | -| Viewer のデータ取得 | SSE (`EventSource`) による push のみ | - -Console は「操作して結果が返る」画面なので Server Actions と再検証で足りる。 -Viewer は「状態に追従する」画面なので SSE。この2つを混ぜない。 - -### 3.3 バックエンド - -| 項目 | 採用 | +| `packages/shared/src/control.ts` | 状態・コマンド・表示ラベル・Zod検証 | +| `packages/scoring/src/match.ts` | 各戦・2戦合計の判定、予選順位。I/Oを持たない | +| `chros/lib/tournament.ts` | 総当たり・本戦の生成、入力保護、再試合、監査記録を含む状態遷移 | +| `chros/lib/store.ts` | JSON永続化、更新番号検証、保存後のSSE通知 | +| `chros/lib/use-control.ts` | ブラウザーでのSSE購読、コマンド送信、通信状態とエラー表示 | +| `chros/components/ConsoleApp.tsx` | 進行管理と画面選択 | +| `chros/components/MatchEditor.tsx` | 先攻登録、各戦の結果入力・訂正・再試合 | +| `chros/components/Management.tsx` | 参加者、順位、本戦作成、大会設定、エクスポート、履歴 | +| `chros/components/Rundown.tsx` | 表示順の編集と手動進行 | +| `chros/components/Board.tsx` | 会場掲示とプレビューの共通表示 | + +既存のNext.js 15 / React 19 / TypeScript / pnpm workspaceを継続し、新たな実行時依存は加えていない。 +外部フォント・外部画像・CDNを必須にしない。旧ダミー計算と未接続の画面を置き換えた。 + +## 保存する状態 + +`ControlState` は大会ID、状態リビジョン、保存時刻、計算方式と規則版 `2026-09-18.1`、 +参加者、試合、各戦の結果、過去の無効試合、操作履歴、進行区分、掲示画面・対象試合・ページ、 +表示順、ロゴを持つ。1プロセスにつき1つの大会が現在の操作対象。 + +- 参加者は人間のみ。IDは得点とは独立し、表示名の重複を防ぐ。 +- `Match.a/b` は先攻側ではなく固定の参加者枠。先攻は `firstCool` に記録し、第2戦は反転する。 +- `games` は番号1・2、A/Bの素点、勝因、特殊勝者、残りターン数を保持する。 +- 試合の状態と勝者は保存データから同じ計算関数で導出する。状態と集計の二重管理をしない。 +- `previousAttempts` に無効化した両戦・先攻・理由・時刻を保持し、現在の順位計算から除く。 +- 本戦の `sources` は各枠の元になる試合ID。確定勝者をラウンド順に伝播する。 +- 操作履歴に更新内容を残す。結果訂正では変更前の入力値と訂正理由も記録する。 + +## 計算 + +`evaluateGame` → `evaluateMatch` → `standings` の順に純粋関数で計算する。 +方式を切り替えても素点・勝因の意味が変わらないようにする。 + +| 方式 | 各戦の扱い | 2戦の比較 | 予選順位(試作判断) | +| --- | --- | --- | --- | +| `kushiro` | 得点比較は特殊Pなし。それ以外の入力済み勝因は勝者に1特殊P | 特殊P合計、同値なら得点合計 | 試合勝数、特殊P、得点 | +| `asahikawa` | 勝数を数え、敗因に応じて0または負の残ターンへ得点を換算。素点は残す | ゲーム勝数、同値なら換算得点合計 | 試合勝数、換算得点 | + +両方式とも2戦終了前は試合勝者を確定しない。完全同値は再試合待ちとし、順位に算入しない。 +予選順位が同じ場合、表示上の名前順を順位差とみなさず同順位で示す。 +本戦生成時に同順位のシード順・進出者を確定する理由を要求する。 +下位者への無条件の繰上げや進出者の重複は拒否する。 + +シードの初期候補は各組1位→各組2位の順。一般的なシード配置に展開する。 +2組×2名なら A1-B2 / B1-A2。2の累乗に不足する枠は上位シード側の不戦進出。 +予選対戦順は組・参加者の登録順からの全ペア列挙。コート割当・連戦回避の最適化はしない。 + +通信切断は、審判が「その参加者に起因する敗北」と裁定した場合に選ぶ。 +応答タイムアウト・サーバー故障などの再試合判断は「両戦を仕切り直す」で理由を記録する。 +サーバー事象の自動判別・同時事象の優先順位判定は行わない。 + +## API・配信 + +| API | 用途 | | --- | --- | -| API | Next.js Route Handlers (`app/api/**`) | -| DB | PostgreSQL 17 | -| ORM | Prisma 6 | -| リアルタイム | SSE (`text/event-stream`) | - -Python (FastAPI, lupa) は廃止し、スコア計算は `packages/scoring` に TS で実装する。 -CHaser のログ解析が必要になった場合も TS のパーサとして同パッケージ内に置く。 - -## 4. ドメインモデル - -既存スキーマを土台に、進行状態を一級市民として持たせる。 - -```prisma -enum Side { COOL, HOT } -enum Half { FIRST, SECOND } -enum MatchState { PENDING, IN_PROGRESS, FINISHED } - -model Tournament { - id Int @id @default(autoincrement()) - name String - format Format // ROUND_ROBIN | SINGLE_ELIMINATION - scoreRuleId Int // この大会に適用する計算規則(§5) - matches Match[] - entries Participant[] -} - -/// スコア計算規則。graph にノードグラフの JSON を持つ。 -/// 一度大会で使用した規則は書き換えず、新しい version を作る(§5.7)。 -model ScoreRule { - id Int @id @default(autoincrement()) - name String - version Int - graph Json // jsonb。ScoreGraph 型(packages/shared) - publishedAt DateTime? // 検証を通過し使用可能になった時刻 - createdAt DateTime @default(now()) - - @@unique([name, version]) -} - -model Participant { - id Int @id @default(autoincrement()) - tournamentId Int - name String - // 対戦・スコアへの逆参照 -} - -model Match { - id Int @id @default(autoincrement()) - tournamentId Int - order Int // 対戦順。進行はこの順に従う - state MatchState @default(PENDING) - agent1Id Int - agent2Id Int - scores Score[] - result MatchResult? // 勝敗は計算結果側が持つ(下記) - - @@unique([tournamentId, order]) -} - -/// 対戦の生データ。計算結果ではなく「観測された事実」のみを持つ。 -/// ここに並ぶ項目が、そのまま得点算出ブロックの「入力の箱」になる(§5.4)。 -model Score { - id Int @id @default(autoincrement()) - matchId Int - half Half - coolId Int - hotId Int - coolScore Int - hotScore Int - coolItems Int // 取得アイテム数 - hotItems Int - remainingTime Int // ハーフ終了時点の残り時間 - blocked Side? // 進行不能になった側(壁埋まり・行動不能・応答なし) - - @@unique([matchId, half]) -} - -/// 計算規則を Score に適用した結果。どの規則で出したかを必ず添える。 -model MatchResult { - id Int @id @default(autoincrement()) - matchId Int @unique - scoreRuleId Int // 再現性のため、適用した規則の version を固定して記録 - agent1Output Json // 出力スロット名 -> 値。例 { score: 12, specialPoint: 1 } - agent2Output Json // 何を出すかは規則が決めるため固定カラムにしない(§5.3) - winnerId Int? - winReason String? // 到達した終端ノードと決め手のスロット名(§5.3) - trace Json? // 評価の中間値と通過した制御経路(§5.6 検証・説明用) - computedAt DateTime @default(now()) -} -``` - -追加・変更の意図: - -- `Tournament`: 複数大会・複数部門を扱えるようにする。既存スキーマには無く、後付けが最も高くつく箇所。 -- `Match.order` / `Match.state`: 「今どの対戦をやっているか」を DB の状態として持つ。 - Viewer の表示はこの状態から導出され、画面切替のためだけの独立した状態を持たない。 -- `Score.blocked` が現行の `put` / `lostConnect` を置き換える: どちらも「進行不能になった側」の - 一種であり、区別が要るかは規則ではなく大会次第。**事実を1つにまとめ、意味付けは規則に委ねる**(§5.4)。 - 記録上の詳細な理由が必要になった場合は、`blocked` に理由の列挙を添える形で拡張する。 -- `MatchResult.winReason`: 「どのような勝ち方をしたか」の表示要件(得点表示)に直接対応する。 - 現行は `put` / `lostConnect` から表示側が推論する必要があり、ロジックが散る。 - 値は enum ではなく文字列とする。勝因は規則側の終端ノード構成で決まるため、 - システム側で列挙し切れない。 -- `MatchResult.agent*Output` を `Json` にする理由: **何を得点として外に出すかは規則が決める**(§5.3)。 - `score` 1つの大会もあれば `score` と `specialPoint` を出す大会もあるため、固定カラムにできない。 - 表示側は出力スロットの定義(名前・表示名・型)を規則から引いて描画する。 -- `Score` と `MatchResult` の分離: **入力された事実と、規則を適用した結果を混ぜない**。 - 規則が変わったとき、事実はそのままに結果だけ再計算できる。可搬性を掲げる以上ここは譲れない。 - -勝敗判定は DB に書く前に `packages/scoring` の評価器で決定し、結果を `MatchResult` に永続化する。 -表示のたびに再計算しない。 - -## 5. スコア計算エンジン - -本システムの核。大会ごとに異なる複雑な計算規則を、エンジニアの改修なしに差し替えられるようにする -(動機は §1.2)。 - -### 5.1 全体像 - -``` -[Console: ノードエディタ] --(ScoreGraph JSON)--> [検証器] --> [ScoreRule テーブル(jsonb)] - | - [Score(事実)] ------> [評価器 evaluate()] <---+ - | - v - [MatchResult(結果 + trace)] -``` - -すべて `packages/scoring` に置く。**I/O も DB も React も参照しない純粋な TS** とし、 -評価器・検証器・型定義を1か所にまとめる。これにより計算規則の単体テストが容易になり、 -将来 CLI や別サービスから同じ規則を再利用できる。 - -### 5.2 用語 - -「ブロック」「演算子」「ノード」が混ざると議論が滑るため、以下に固定する。 - -| 用語 | 指すもの | 例 | -| --- | --- | --- | -| **ブロック** | 編集単位。ユーザーが追加・削除できない固定の区画 | 得点算出ブロック、勝敗判定ブロック | -| **ノード** | ブロック内に置く処理の1単位。ユーザーが自由に配置する | 加算ノード、同値判定ノード | -| **ポート** | ノードの入出力の接続口。名前と型を持つ | `left` / `right` / `yes` / `no` | -| **値エッジ** | 数値・真偽値を運ぶ線。得点算出ブロックのみ | 加算ノード → 出力スロット | -| **制御エッジ** | 「次にどのノードへ進むか」を運ぶ線。勝敗判定ブロックのみ | 同値判定の `no` → 終端ノード | - -以降、ユーザーが「演算子」と呼んでいたものは**ノード**と呼ぶ。 - -### 5.3 グラフの表現 - -グラフは平坦な1枚ではなく、**2種類・3ブロック**の構造を持つ。 -ブロックの単位は**参加者**であり、COOL/HOT の側ではない(理由は後述)。 - -``` -┌─ 得点算出ブロック (参加者A) ─┐ ┌─ 得点算出ブロック (参加者B) ─┐ -│ 入力: A の対戦データ │ │ 入力: B の対戦データ │ -│ 前半 COOL として / 後半 HOT│ │ 前半 HOT として / 後半 COOL│ -│ 演算ノード群 │ │ 演算ノード群 │ -│ 出力: 数値 (A の得点) │ │ 出力: 数値 (B の得点) │ -└──────────────┬───────────────┘ └──────────────┬───────────────┘ - │ │ - └────────────────┬────────────────┘ - v - ┌─ 勝敗判定ブロック (1つ) ──────────────┐ - │ 入力: 両者の得点 │ - │ if ノードを主体とした比較 │ - │ 出力: 勝者 (A / B / DRAW) + 勝因 │ - └───────────────────────────────────────┘ -``` - -**得点算出ブロックの定義は1つだけ持ち、評価時に両参加者へ適用する。** -2ブロックに見えるのは評価時の姿であり、編集対象は1つ。 -両者に同じ規則が当たることを構造で保証でき、「片方だけ直し忘れて不公平になる」バグが起きない。 -先攻/後攻による差はブロックを分けるのではなく、入力(前半/後半、COOL/HOT)を参照して -ブロック内の分岐として表現する。 - -この分割が持つ意味: - -- **「何点になるか」と「どちらが勝ちか」を混ぜない。** 得点算出ブロックは数値を出すことだけに責任を持ち、 - 勝敗の概念を知らない。勝敗判定ブロックは得点の作り方を知らない。 - 規則変更の多くは片方だけの修正で済む。 -- 得点算出ブロックは**片側ぶんの入力しか受け取れない**。相手の得点を参照できないため、 - 「相手より1点多ければ」のような比較は構造上ここに書けず、必ず勝敗判定ブロックに寄る。 - この制約が、規則の置き場所の曖昧さを消す。 -- 勝敗判定ブロックの出力は勝者と勝因のみ。得点をここで書き換えることはできない。 - -```ts -export type ScoreRuleGraph = { - formatVersion: 1; // 保存形式の版。移行判定に使う - points: Block; // 得点算出ブロック(1つ。両参加者へ適用) - decision: Block; // 勝敗判定ブロック -}; - -/** 得点算出ブロック: 値エッジのみの純粋な式。複数の値を名前付きで外に出す */ -export type PointsBlock = { - nodes: PointsNode[]; - valueEdges: ValueEdge[]; - outputs: OutputSlot[]; // 1つ以上。名前は勝敗判定ブロックから参照される -}; - -export type OutputSlot = { - name: string; // 例: 'score' / 'specialPoint' / 'items' - label: string; // 表示名(Console・配信画面で使う) - type: 'number' | 'boolean'; - from: NodeId; // この値を出すノード - fromPort: string; -}; - -/** - * 勝敗判定ブロック: 制御エッジのみのフローチャート。 - * 値は線で運ばず、各ノードが「どの出力スロットを見るか」を属性として持つ。 - */ -export type DecisionBlock = { - nodes: DecisionNode[]; // 分岐ノード | 終端ノード - controlEdges: ControlEdge[]; // 進行先を運ぶ。値は持たない - entry: NodeId; // 制御の開始点。ちょうど1つ -}; - -export type DecisionNode = - | { id: NodeId; kind: 'branch.equal'; slot: string } // 数値スロットが同値か - | { id: NodeId; kind: 'branch.flag'; slot: string } // 真偽スロットで分岐 - | { id: NodeId; kind: 'terminal.higher'; slot: string } // 高い方の勝利 - | { id: NodeId; kind: 'terminal.rematch' } // 仕切り直し(再戦) - | { id: NodeId; kind: 'terminal.draw' }; // 引き分け - -type ValueEdge = { from: NodeId; fromPort: string; to: NodeId; toPort: string }; -type ControlEdge = { from: NodeId; branch: 'yes' | 'no'; to: NodeId }; -``` - -`slot` は得点算出ブロックの出力スロット名であり、**両参加者の同名スロットを対象とする**。 -`a.score` と `b.score` を個別に繋ぐのではなく、「`score` を比較する」と1つ選ぶ。 - -ブロックごとに使えるノードの語彙を変える。全ブロック共通の語彙は持たない。 - -**得点算出ブロック**(初版の想定。確定は §5.8) - -| 分類 | 例 | 備考 | -| --- | --- | --- | -| 入力 | §5.4 の入力の箱 | 相手の**出力スロット**は参照できない(§5.4) | -| 演算 | 加減乗除、`min` / `max` | | -| 条件 | `if`(条件・真の値・偽の値の3入力) | 先攻/後攻で差をつけたい場合はここで分岐 | -| 定数 | 数値・真偽値 | 非エンジニアが調整する主な対象 | -| 出力 | **出力スロット**。数値または真偽値を、名前を付けて1つ以上 | 下記 | - -入力名は `cool` / `hot` ではなく `asCool` / `asHot`(自分が COOL だった側 / HOT だった側)とする。 -参加者は前半と後半で側が入れ替わるため、「自分が COOL として取った点」という視点でないと式が書けない。 - -**出力は1つの数値に固定しない。何をいくつ外に出すかを規則の作成者が決める。** -これは大会ごとの差を吸収するために必要な自由度である。 - -- 「取得アイテム数 + 残り時間」を1つの得点として出す大会 → `score` を1つ出す -- 特殊ポイントを得点とは別に判定へ渡す大会 → `score` と `specialPoint` の2つを出す(§5.5 大会A) -- アイテム数と時間を別々に見せたい大会 → `items` と `timeBonus` を分けて出す - -出力スロットは名前と型を持ち、勝敗判定ブロックの各ノードが名前で1つ選択する(両参加者の同名スロットが対象)。 -出力スロットの追加・削除・改名は、それを参照している勝敗判定ブロックを壊すため、検証で検出する(§5.6)。 - -**勝敗判定ブロック** - -このブロックだけは式ではなく**フローチャート**である。 -制御エッジをたどって進み、終端ノードに到達した時点で勝敗が確定して評価が終わる。 - -**各ノードは「どのスコアを見るか」を選択として持ち、値は線で運ばない。** -得点算出ブロックの出力スロットが選択肢として並び、その中から1つ選ぶ。 - -| 種別 | ノード | 見るもの | 制御出力 | 意味 | -| --- | --- | --- | --- | --- | -| 分岐 | **同値判定** | 数値スロット1つ | `yes` / `no` | 両者のそのスコアが等しいかで進む先を分ける | -| 分岐 | **フラグ判定** | 真偽スロット1つ | `yes` / `no` | 真偽の要因で進む先を分ける | -| 終端 | **高い方の勝利** | 数値スロット1つ | なし | そのスコアが大きい側を勝者として確定する | -| 終端 | **仕切り直し** | なし | なし | 勝敗を付けず、同一カードの再戦とする | -| 終端 | **引き分け** | なし | なし | 引き分けとして確定する | - -**「仕切り直し」は引き分けとは別物**であり、両者を区別して持つ。 -既知の大会(§5.5)はいずれも同点時に再戦を行っており、引き分けで確定させない。 -引き分け終端は、順位計算上で引き分けを認める大会のために残すが、初版の実例では使われない。 - -- **終端ノードは制御出力を持たない。** これが「確定して終わる」ことの表現であり、 - 出力が無いこと自体が意味を担う。終端に到達したら以降のノードは評価しない。 -- **分岐ノードは値を出力しない。** 真偽値を後段に配って使い回すのではなく、 - 制御の行き先そのものを分ける。真偽値が線として漂わないので、 - 「この true はどこで使われるのか」を読み解く必要がない。 -- **このブロックに値エッジは存在しない。線は制御エッジ1種類だけである。** - スロットの選択がその役割を果たす。これにより得られるものが3つある。 - - **編集が「線を2種類引き分ける」から「ドロップダウンで選ぶ」に落ちる。** - 非エンジニアに触らせるという目的に対して、UI の難所が1つ消える。 - - **無意味な接続が原理的に作れない。** 値エッジがあると - 「A の `score` と B の `specialPoint` を比べる」という非対称で無意味な繋ぎ方が書けてしまう。 - スロットを1つ選ぶ形なら、比較は常に両参加者の同名スロット同士になる。 - §5.3 冒頭で述べた**規則の対称性が、判定ブロック側でも構造として保証される**。 - - 型検証が「線の型整合」ではなく「選んだスロットの型がノードの要求と合うか」に単純化される。 - ドロップダウンに合う型のスロットだけを並べれば、誤りは選択肢として存在しなくなる。 - -値エッジを持つのは得点算出ブロックだけになる。§5.2 の用語表では2種類の線を定義しているが、 -**1つのブロックが両方の線を持つことはない**。 - -#### 判定の優先順位はフローの順序そのもの - -「スコアより特殊ポイントの判定が先に来る」大会(§5.5 の大会A)は、 -優先度という別概念を導入せず、**制御エッジを繋ぐ順序**で表現する。 -先に置いた分岐が先に評価される。 - -``` - [開始] - │ - v - ┌────────────────────────┐ - │ 同値判定 │ - │ 見るスコア: specialPoint │ - └────┬──────────────┬────┘ - no │ │ yes (特殊ポイントが同点) - v v -┌────────────────────────┐ ┌──────────────────┐ -│ 高い方の勝利 │ │ 同値判定 │ -│ 見るスコア: specialPoint │ │ 見るスコア: score │ -└────────────────────────┘ └──┬────────────┬──┘ - (終端) no │ │ yes - v v - ┌──────────────────┐ ┌────────────┐ - │ 高い方の勝利 │ │ 仕切り直し │ - │ 見るスコア: score │ └────────────┘ - └──────────────────┘ (終端) - (終端) -``` - -線は制御エッジ1種類だけであり、各ノードの中に「見るスコア」の選択が入っている。 -大会B(得点のみで判定)は、この図から先頭の `specialPoint` の分岐と -その `no` 側の終端を取り除いた形になる。**語彙は同じで、配置だけが違う。** - -同じ語彙のまま、分岐を置く順を入れ替えるだけで「得点が先、要因が後」の大会にも対応できる。 -**優先順位を数値やフィールドで持たない**ため、順序が図として一目で読めるという利点がある。 - -**勝因(`WinReason`)は、どの終端ノードに到達したかそのものである。** -別途フィールドとして組み立てる必要はなく、終端ノードの種別と、 -そこに繋がれていた出力スロット名(例: `specialPoint` で決着)を記録すれば足りる。 -これは trace(§5.6)とも自然に一致する。 - -ここで A / B は `Match.agent1` / `agent2` を指す。 -評価器は結果を実際の `Participant.id` に解決してから `MatchResult` に書く。 - -制御フローを持つのは勝敗判定ブロックだけであり、得点算出ブロックには制御エッジを定義しない。 -得点算出は常に全ノードを評価して出力スロットを埋める純粋な式のままとする。 - -制約として、 - -- **ループ構造を持たない。値エッジ・制御エッジのどちらについても、である。** - ノードの再帰参照と後方への辺を検証器で弾き、両方のグラフを DAG に限る。 - 制御エッジを導入するとフローチャートになり「戻る線」を引きたくなるが、これを許した瞬間に - 下記の保証がすべて失われるため、明示的に禁止する。 - これは意図的にチューリング完全性を捨てる設計判断であり、見返りとして次を無条件に得る。 - - **停止性が構造的に保証される。** 実行時間の監視、タイムアウト、無限ループの検出が不要になる。 - - **状態を持たない。** 評価は入力から出力への写像であり、途中経過が観測に影響しない。 - そのため任意の順で評価しても、途中で打ち切って再開しても結果が同じになる。 - - **静的に全ノードの値域と型を追える。** 検証(§5.6)が実行なしで完結し、 - エディタ上で「繋いだ瞬間に誤りが分かる」形にできる。 - - **trace が常に有限で全ノードぶん取れる。** 説明可能性(§5.6)がここに依存する。 -- ブロック間の接続は固定であり、ユーザーが編集できるのは各ブロックの内側だけ。 - ブロックを跨ぐ辺、ブロックの追加・削除はできない。 -- ノードの種別は列挙型であり、任意の関数呼び出しやコード片は持てない。 -- 評価は純粋。乱数・現在時刻・外部参照を持つノードを定義しない - (抽選など乱数が要る機能はスコア計算とは別系統で扱う。§5.8)。 - -評価順序は `points`(参加者A) → `points`(参加者B) → `decision` に固定する。 -前2つは同一の定義を異なる入力で評価するだけで相互に依存しないため、 -順序を入れ替えても結果は変わらない(上記の無状態性による)。 - -これにより**規則の対称性が構造的に保証される**。 -「A に有利な式が紛れ込んでいないか」を検証する必要が無く、 -非対称性は勝敗判定ブロックに書かれた場合にのみ発生しうるので、レビュー範囲がそこに限定される。 - -### 5.4 入力の箱 - -得点算出ブロックが参照できる入力を「箱」として定義する。 -箱は**参加者1人ぶんの、1試合ぶんの事実**を表す。 - -初版で用意する箱(`asCool.` / `asHot.` の2組を持つ。以下は片側ぶんの一覧): - -| 箱 | 型 | 内容 | -| --- | --- | --- | -| `score` | number | そのハーフで自分が取得した点 | -| `items` | number | そのハーフで自分が取得したアイテム数 | -| `remainingTime` | number | そのハーフ終了時点の残り時間 | -| `selfBlocked` | boolean | そのハーフで自分が進行不能になったか | -| `opponentBlocked` | boolean | そのハーフで相手が進行不能になったか | - -「進行不能」は、壁に埋まる・四方が塞がって行動できない・エージェントが規定時間内に応答しない、 -といった対戦続行不能の状態をまとめたものである。 -**それが勝敗や得点にどう効くかはシステムが決めない。** 事実として記録するだけで、 -扱いは規則側に委ねる(大会Aは相手の進行不能を特殊ポイントに換算し、大会Bは得点の符号に使う。§5.5)。 - -#### 相手の事実は参照してよい - -§5.3 で「得点算出ブロックは自分の値のみを参照できる」と述べたが、正確には次のとおり。 - -- **参照できない**: 相手の**出力スロット**(相手の算出済み得点)。 - これを許すと得点算出が相互参照になり、対称性の保証とブロック分割の意味が失われる。 -- **参照してよい**: 相手についての**生の事実**(`opponentBlocked` など)。 - 「相手が進行不能になったので自分に加点」という規則が現に存在するため、これを禁じると書けない。 - -境界は「事実か、算出結果か」であって「自分か、相手か」ではない。 - -#### 箱を増やすことの重さ - -箱を増やす場合は `Score` モデルへのフィールド追加と `formatVersion` の更新を伴う。 -**箱は後から足せるが、一度公開した箱は減らせない。** - -なお「アイテム数×3 + 残り時間」のような合成は箱としては持たない。 -それは得点算出ブロックで組み立てるものであり(§5.5 の大会B)、 -箱の側で合成してしまうと規則を差し替えられるという前提が崩れる。 - -### 5.5 規則の実例 - -既知の2大会を、上記の語彙だけで表現できるか検証した結果を記す。 -**どちらも表現できる。** 以下は実装時の受け入れ基準として使う。 - -#### 大会A — 特殊ポイント優先 - -規則: 対戦中に進行不能になると相手に特殊ポイントが入る。前後半を集計し、 -まず特殊ポイントが高い方が勝ち。同点ならスコアで比較。それも同点なら仕切り直し。 - -得点算出ブロック(出力スロット2つ): - -| スロット | 型 | 組み立て | -| --- | --- | --- | -| `specialPoint` | number | `(asCool.opponentBlocked ? 1 : 0) + (asHot.opponentBlocked ? 1 : 0)` | -| `score` | number | `asCool.score + asHot.score` | - -勝敗判定ブロック: - -``` -[開始] → 同値判定(specialPoint) - ├ no → 高い方の勝利(specialPoint) (終端) - └ yes → 同値判定(score) - ├ no → 高い方の勝利(score) (終端) - └ yes → 仕切り直し (終端) -``` - -**「特殊ポイント」は真偽値ではなく数値である。** 前後半で最大2点入りうるため、 -以前の設計にあった `specialWin: boolean` では表現できなかった。 -これが §5.4 で箱を真偽値の `opponentBlocked` に変え、 -点数への換算を規則側(得点算出ブロック)に移した理由である。 - -#### 大会B — 得点のみ - -規則: 進行不能で決着した場合、勝った側の得点は `アイテム数×3 + 残り時間`、 -負けた側は `アイテム数×3 - 残り時間`。特殊条件は考えず得点だけで比較。同点なら仕切り直し。 - -得点算出ブロック(出力スロット1つ): - -| スロット | 型 | 組み立て | -| --- | --- | --- | -| `score` | number | 各ハーフで `items×3 + (selfBlocked ? -remainingTime : +remainingTime)` を求め、前後半を加算 | - -勝敗判定ブロック: - -``` -[開始] → 同値判定(score) - ├ no → 高い方の勝利(score) (終端) - └ yes → 仕切り直し (終端) -``` - -#### この2例から確定したこと - -- **得点算出ブロックに `if` は必要**。§5.8 で「不要なら削れる」としていたが、 - 大会Bの残り時間の符号反転、大会Aの真偽値から点数への換算の双方で使う。削れない。 -- **「仕切り直し」終端が必須**。両大会とも同点を引き分けで確定させず再戦する。 - 引き分け終端だけでは表現できない。 -- **判定の優先順位がフローの順序で表現できている**。 - 大会Aは特殊ポイントの分岐を先に置き、大会Bは置かない。それだけの差になる。 -- **箱に相手の事実(`opponentBlocked`)が要る**。大会Aの特殊ポイントは - 相手が進行不能になったことで自分に入るため、これが無いと書けない(§5.4)。 -- 大会Aと大会Bで**語彙は同一**であり、違いはノードの配置だけ。 - 可搬性の目標に対する具体的な裏付けになっている。 - -#### 仕切り直しのデータ上の扱い - -「仕切り直し」に到達した対戦は、勝敗が未確定のまま再戦を行う。 -既存の `Score` を書き換えて上書きすると事実が失われるため、**再戦は新しい `Match` として作る**。 - -```prisma -model Match { - // ... - attempt Int @default(1) // 何回目の対戦か - rematchOfId Int? // 仕切り直しの元になった Match - rematchOf Match? @relation("Rematch", fields: [rematchOfId], references: [id]) - rematches Match[] @relation("Rematch") -} -``` - -- 元の `Match` は `MatchResult` に「仕切り直し」の結果を残したまま確定する。 - `winnerId` は `null` になる。 -- 順位計算は最終的に勝敗が付いた `Match` のみを数え、 - 仕切り直しに終わった対戦は集計から除外する。 -- 配信画面には「仕切り直し」を明示できるようにする(§8 の `result` 画面)。 - -### 5.6 検証システム - -「曖昧な状態を防ぐ」ことを明示的な仕組みとして持つ。規則は検証を通過するまで大会に適用できない。 - -検証は5段階で行う。 - -1. **構造検証**: Zod スキーマ適合、得点算出ブロックの値エッジと - 勝敗判定ブロックの制御エッジがそれぞれ DAG であること、 - 未接続の入力ポートが無いこと、到達不能なノードが無いこと、 - ブロックを跨ぐ辺が無いこと、そのブロックで許可された種別のノードのみを使っていること。 - 得点算出ブロックは出力スロットが1つ以上あり、名前が重複していないこと。 -2. **制御フロー検証**(勝敗判定ブロックのみ): - - `entry` がちょうど1つあること。 - - **すべての制御経路が終端ノードに到達すること。** 分岐ノードの `yes` / `no` のうち - 片方でも繋がっていなければ検証エラーとする。 - - 終端ノードから出る制御エッジが無いこと。 - - 到達しうる終端が1つも無い、または到達できない終端がある場合はエラーとする。 - - この段が「曖昧な状態を防ぐ」の実体である。**判定漏れ=勝敗が決まらない状態**を、 - 実行時の例外ではなく規則の保存時に構造として弾く。 - ループが無いため、全経路の列挙は有限で網羅的に行える。 -3. **スロット参照検証**: 勝敗判定ブロックの各ノードが選んでいる `slot` が、 - 得点算出ブロックに実在し、かつノードの要求する型と一致すること - (同値判定・高い方の勝利は数値、フラグ判定は真偽値)。 - スロットの改名・削除で判定側が壊れるのを保存時に検出する。 - また、どのノードからも選ばれていない出力スロットは警告として提示する - (表示専用として意図的に出す場合があるため、エラーにはしない)。 - - スロットを選択にしたことで、この検証は**エディタのドロップダウンに - 適合する型のスロットだけを並べる**ことと等価になる。 - 誤りを検出するのではなく、選択肢として存在させない形にできる。 -4. **型検証**(得点算出ブロックのみ): 値エッジのポート間の型(数値 / 真偽値)の整合。 - ループが無いため、この検証は評価せずに完了する。 - 勝敗判定ブロックには値エッジが無いため、この段の対象外となる。 -5. **振る舞い検証**: 規則ごとに**テストケース**(入力の `Score` と期待する結果の組)を登録できる。 - 過去大会の実データを流し、結果が変わらないことを確認する回帰テストとして使う。 - -評価器は `trace` を返し、`MatchResult.trace` に保存する。trace は2部構成になる。 - -- 得点算出ブロック: 各ノードの中間値(なぜこの点数になったか) -- 勝敗判定ブロック: **通過した制御経路と、到達した終端ノード**(なぜこの勝敗になったか) - -後者は制御エッジを導入したことで自然に得られる。「同値判定(score) → no → 高い方の勝利(score)」という -経路そのものが説明になっており、非エンジニアが規則を信頼する条件を満たす。 - -### 5.7 版管理 - -- 一度大会で使用した `ScoreRule` は**書き換えない**。編集は新しい `version` の作成として扱う。 -- `MatchResult` は適用した `scoreRuleId` を保持するため、過去の結果は常に再現できる。 -- 大会中に規則の誤りが見つかった場合は、新版を作り、影響する `Match` を明示的に再計算する。 - 再計算は運営の操作として行い、暗黙に走らせない。 - -### 5.8 未決事項 - -- **ノード語彙の確定**: 各大会のルール一覧を突き合わせ、実際に必要な表現力を洗い出してから決める。 - §5.5 の2大会は現行の語彙で表現できているため、**これ以上広げない**ことを初版の方針とする。 - ここを想像で広げると、非エンジニア向けという目的から外れる。 -- **3大会目以降での再検証**: 既知の大会が2つでは語彙の十分性を主張しきれない。 - 別の大会規則が手に入り次第、同じ形式(§5.5)で書き下せるかを確認する。 - 書けない規則が出た場合に何を足すかは、そのときの実例に従う。 -- **`min` / `max` ノードの要否**: §5.5 の2大会では使っていない。 - 実際に使う規則が現れるまで実装を保留してよい。 -- **分岐ノードの追加**: 初版は「同値判定」「フラグ判定」の2つ。 - 「指定値以上か」などの比較が必要になるかを実例で確認する。 - なお §5.5 の2大会はいずれも `フラグ判定` を使っていない - (真偽の箱は得点算出ブロック内の `if` で消費される)。初版から外す判断もありうる。 -- **出力スロットと表示の結びつけ**: 配信画面(§8)が出力スロットをどう描画するか。 - 規則ごとにスロット構成が変わるため、表示側をスロット定義から駆動するか、 - 表示に使うスロットを規則側で指定させるかを決める必要がある。 -- **仕切り直しの回数上限**: 再戦を繰り返しても同点が続いた場合の扱い。 - 規則側では表現できないため、運営の判断で打ち切る運用とするか、 - 大会設定として上限回数を持つかを決める(§5.5)。 -- **引き分けの扱い**: 「引き分け」終端に到達した対戦を、 - 総当たり戦の順位計算がどう解釈するか(§11 の順位決定規則と接続する)。 - 既知の2大会は引き分けを使わないため、優先度は低い。 -- **3ブロックで足りない規則が出た場合の拡張方法**: ブロック構成は固定であり、 - ユーザーは追加できない。構成自体の変更は `formatVersion` を上げる移行として扱う。 -- **自動抽選システム**: 対戦組み合わせの自動生成。今後の目標とし、初版のスコープ外。 - 乱数を含むためスコア計算グラフとは分離し、抽選結果は確定値として `Match` に永続化する。 - -## 6. リアルタイム配信設計(SSE) - -### 6.1 エンドポイント - -``` -GET /api/stream → text/event-stream -``` - -- 全 Viewer が同一ストリームを購読する。大会単位で分けたい場合のみ `?tournamentId=` を受ける。 -- 接続時にまず現在の完全な状態(スナップショット)を1件送る。Viewer は初期取得のための - 別 API を叩かなくてよい。 -- 以降は差分イベントを送る。 -- 各イベントに単調増加の `id:` を付与し、`Last-Event-ID` ヘッダによる再送に対応する。 - 再送不能なほど古い場合はスナップショットを送り直す。 -- 15 秒ごとにコメント行 (`: ping`) を送出し、プロキシによるアイドル切断を防ぐ。 - -### 6.2 イベント定義 - -型は `packages/shared` に置き、サーバーと Viewer が同じ定義を import する。 - -| event | ペイロード | 意味 | -| --- | --- | --- | -| `snapshot` | 下記 `Snapshot` | 接続直後・復帰時の全状態 | -| `screen.changed` | `{ screen: ScreenId }` | 配信画面の切り替え | -| `match.started` | `{ matchId }` | 対戦開始 | -| `match.scored` | `{ matchId, half, ... }` | ハーフのスコア確定 | -| `match.finished` | 対戦結果 | 対戦終了(仕切り直しを含む) | -| `standings.updated` | 順位表 | 順位の再計算結果 | - -```ts -// packages/shared/src/events.ts -export type ChrosEvent = - | { type: 'snapshot'; payload: Snapshot } - | { type: 'screen.changed'; payload: { screen: ScreenId } } - | { type: 'match.started'; payload: { matchId: number } } - | { type: 'match.scored'; payload: ScorePayload } - | { type: 'match.finished'; payload: MatchResultPayload } - | { type: 'standings.updated'; payload: Standings }; - -/** 接続直後に1件送る。Viewer が全画面を描くのに必要なものを漏れなく含む(§8.4) */ -export type Snapshot = { - screen: ScreenId; - tournament: { id: number; name: string; format: Format }; - slots: OutputSlot[]; // 表示に使う出力スロットの定義(§8.2) - participants: ParticipantView[]; - matches: MatchView[]; // 対戦表を描くのに足りる全対戦(状態つき) - currentMatchId: number | null; - standings: Standings | null; -}; -``` - -`Snapshot` に `slots` を含めるのは §8.2 のためである。 -Viewer はスコアの意味を知らず、スロット定義に従って描画する。 - -### 6.3 プロセス間の配信 - -Next.js が単一プロセスで動く限り、インメモリの EventEmitter で購読者に配れば足りる。 -複数インスタンスで動かす必要が出た場合に限り、PostgreSQL の `LISTEN/NOTIFY` を経由させる -(Redis を新規に持ち込まない。DB は既にあるため)。 - -### 6.4 Viewer 側の要求 - -- `EventSource` は自動再接続を行う。切断中も直前の描画を保持し、画面を白くしない。 -- 会場ネットワークが落ちても、復帰時に `Last-Event-ID` またはスナップショットで整合が取れること。 -- Viewer は配信に載るため、**エラーダイアログやローディングスピナーを画面に出さない**。 - 異常時は直前の正常な表示を維持し、状態は運営側の Console にのみ通知する。 - -## 7. API 設計 - -Route Handlers で REST を提供する。Console からの操作は Server Actions を優先し、 -外部ツール連携や冪等性が必要な操作のみ REST に置く。 - -| Method | Path | 用途 | -| --- | --- | --- | -| `GET` | `/api/stream` | SSE | -| `GET` | `/api/tournaments/:id/matches` | 対戦表取得 | -| `POST` | `/api/matches/:id/start` | 対戦開始 | -| `POST` | `/api/matches/:id/scores` | ハーフのスコア登録 | -| `POST` | `/api/matches/:id/finish` | 対戦終了・勝敗確定 | -| `POST` | `/api/matches/:id/recompute` | 規則の新版で再計算(§5.7) | -| `POST` | `/api/screen` | 配信画面の切り替え | -| `GET` | `/api/score-rules` | 計算規則の一覧(版つき) | -| `POST` | `/api/score-rules` | 新しい版の作成 | -| `POST` | `/api/score-rules/:id/validate` | 検証の実行(§5.6) | -| `POST` | `/api/score-rules/:id/publish` | 検証通過後、大会に適用可能にする | - -規約: - -- リクエスト / レスポンスは Zod スキーマで検証し、スキーマは `packages/shared` に置く。 -- 状態を変える操作はすべて、DB 書き込みと SSE ブロードキャストを同一の関数内で行う。 - 片方だけ実行される経路を作らない。 -- エラーは HTTP ステータス + `{ error: { code, message } }` の形に統一する。 - -## 8. 画面(Viewer) - -配信に載る表示専用の画面。操作は受け付けず、サーバーの状態に追従して表示だけを変える。 - -### 8.1 画面の種別と表示内容 - -`ScreenId` は `packages/shared` の union 型で定義し、URL とイベントの両方でこれを使う。 - -| ScreenId | 用途 | 表示する内容 | -| --- | --- | --- | -| `interval` | つなぎ | 大会名、ロゴ、次に始まる内容の予告 | -| `next-match` | 次の対戦の紹介 | 対戦者2名の情報、どの試合か(回戦・組) | -| `in-match` | 対戦状況 | 対戦者2名、確定済みハーフのスコア、優劣 | -| `result` | 対戦結果 | 対戦者2名、両者の最終スコア、勝者、決め手 | -| `bracket` | 進行中の対戦表 | トーナメント表 / 総当たり表と、その中の現在地 | -| `standings` | 順位表 | 参加者の順位(詳細は未定。§11) | - -各画面が必要とするデータは以下の3つに集約され、いずれも既存のモデルから導出できる。 - -- **対戦者情報**: `Participant`(名前、および将来的に所属・アイコン等) -- **スコア**: `MatchResult.agent1Output` / `agent2Output`(出力スロット名 → 値) -- **進行状況**: `Match.order` / `Match.state` と、大会全体の `Match` 一覧 - -### 8.2 スコアの表示は規則から駆動する - -**表示側にスロット名をハードコードしない。** -何を得点として出すかは規則が決めるため(§5.3)、画面は `ScoreRule` の出力スロット定義 -(`name` / `label` / `type`)を引き、宣言されている順に `label` と値を並べる。 - -- 大会Aなら「特殊ポイント 2 / スコア 15」の2行 -- 大会Bなら「スコア 41」の1行 - -これにより、大会が変わっても表示コンポーネントを改修しなくてよい。 -`OutputSlot.label` を持たせているのはこのためである。 - -なお「どのスロットを大きく見せるか」など演出上の優劣は、この仕組みでは決まらない。 -初版は宣言順の先頭を主表示とし、細かい制御が必要になった時点で §5.8 の未決事項として扱う。 - -### 8.3 対戦表の表示 - -トーナメントと総当たりの両方を扱う。どちらも「対戦一覧 + 各対戦の状態」から描画でき、 -`Tournament.format` で描き分ける。 - -- **トーナメント**: `Match` を回戦ごとに配置し、勝者を次の対戦へ繋いで描く。 -- **総当たり**: 参加者 × 参加者の表とし、各セルに対戦の状態と結果を入れる。 - -いずれも `Match.state`(`PENDING` / `IN_PROGRESS` / `FINISHED`)で見た目を変え、 -**進行中の対戦が一目で分かる**ことを要件とする。これは「対戦状況表示」の要件に直結する。 - -仕切り直しになった対戦(§5.5)は、元の対戦と再戦を1つの枠にまとめて表示する。 -別々の対戦として2つ並べると、対戦表の構造が崩れて読めなくなるため。 - -### 8.4 実装上の制約 - -- Viewer は `/display` の単一ルートで受け、`screen.changed` に応じて描画を切り替える。 - 現行のようにページ遷移(`router.push`)で切り替えると、遷移のたびに白画面と再接続が挟まり - 配信に映るため、**クライアント側の状態切り替えとして実装し、ページ遷移は行わない**。 -- 画面遷移にはアニメーションを入れられる余地を残す(配信の見栄えのため)。 - ただし遷移中に SSE の状態更新が届いても破綻しないこと。 -- 表示に必要なデータはすべて `snapshot` イベントに含める。 - 画面を切り替えた瞬間に追加の API を叩く設計にしない。切り替えが表示の遅延として見えるため。 -- **画面は固定解像度(1920×1080)を前提に組む。** 配信に載せる用途であり、 - レスポンシブ対応は不要。中途半端に対応すると、実際に使う解像度での作り込みが甘くなる。 - -## 9. 実行環境 - -`docker compose up -d` で以下が立ち上がること。 - -| サービス | 内容 | ポート | -| --- | --- | --- | -| `app` | Next.js (Console / Viewer / API) | 3000 | -| `db` | PostgreSQL 17 | 5432 | - -- 開発時は `pnpm dev` でも同等に動くこと(DB のみ Docker で立てる運用を許容)。 -- 環境変数は `.env.example` に列挙し、起動時に Zod で検証して欠落を即座に落とす。 - -| 変数 | 用途 | -| --- | --- | -| `DATABASE_URL` | PostgreSQL 接続文字列 | -| `NEXT_PUBLIC_APP_URL` | Viewer が SSE を張る先 | - -`chros-websock` / `chros-score` のコンテナは廃止する。 - -## 10. 現行構成からの移行 - -| 現行 | 移行後 | 備考 | -| --- | --- | --- | -| `chros/` (Next.js) | `chros/`(移動なし) | App Router 構造はおおむね流用。§2.1 | -| `chros-websock/` (Express + socket.io) | 廃止 | SSE を `app/api/stream` に実装 | -| `chros-score/` (FastAPI + lupa) | 廃止 | `packages/scoring` に TS で再実装 | -| `chros/docs/overview.md` | `docs/requirements.md` | 集約済み | -| `socket.io-client` 依存 | 削除 | `EventSource` を使用 | -| `display/layout.tsx` の `router.push` 切替 | 状態による切替 | 白画面と再接続を避けるため | -| Prisma スキーマ | `Tournament` / `ScoreRule` / `MatchResult` / `MatchState` を追加 | §4 | -| 勝敗をコードに直書き | `ScoreRule` のグラフとして外部化 | §5 | - -### 10.1 移行の順序 - -**スコア計算まわり(§5)は設計を継続中のため、そこに依存しない部分を先に進める。** -以下の2トラックは独立して動かせる。 - -**トラックA — 土台と画面(先行)** - -1. pnpm workspace 化と `packages/shared` の切り出し -2. イベント定義と `Snapshot` 型の確定(§6.2)。スコアの中身は `slots` 経由で扱うため、 - 計算規則が未確定でもこの型は決められる -3. 進行まわりの Prisma スキーマ(`Tournament` / `Participant` / `Match`)とマイグレーション。 - **`ScoreRule` / `MatchResult` は含めない** -4. API Route Handlers + SSE の実装、`chros-websock` の削除 -5. Viewer の SSE 化と各画面の実装(§8)、`socket.io-client` の削除 -6. Console の Server Actions 化(進行操作・画面切り替え) -7. `chros-score` の削除と docker-compose の整理 - -**トラックB — スコア計算(並行して設計を継続)** - -8. ノード語彙と `ScoreRuleGraph` 保存形式の確定(§5.8 の未決を解消) -9. `Score` / `ScoreRule` / `MatchResult` のスキーマ確定とマイグレーション -10. `packages/scoring` の評価器・検証器の実装とテスト(純粋関数なので単独で固められる) -11. ノードエディタ UI - -トラックAを進めるあいだ、スコアは**固定のダミー実装**で代替してよい。 -§8.2 のとおり Viewer はスロット定義に従って描画するため、 -スロットが `{ name: 'score' }` 1つだけのダミーを返しておけば画面は完成させられる。 -評価器が乗った時点で差し替わり、Viewer 側の改修は不要になる。 - -ノードエディタ UI(手順11)は工数が大きい。先に手順10までを済ませ、 -**JSON を直接投入して評価できる状態**を作る。こうすればエディタが未完成でも大会運用は成立する。 - -## 11. 未決事項 - -未決のうち**着手前に決める必要があるもの**と、**後回しでよいもの**を分けて記す。 - -### 11.1 スコア計算まわり(設計継続中・トラックB) - -- ノード語彙の確定、`min`/`max` と `フラグ判定` の要否(§5.8) -- `Score` / `ScoreRule` / `MatchResult` のスキーマ確定 -- 3大会目の規則での語彙の再検証(§5.8) - -### 11.2 順位計算 — 未着手 - -**§5 と同等の検討が必要だが、まだ何も決まっていない。** -スコア計算を規則で差し替え可能にした以上、順位規則も大会ごとに変わるはずであり、 -これをコードに直書きすると §5 で得た可搬性がここで途切れる。 - -- 順位の決め方(勝ち点制 / 勝率 / 得失点差 / 直接対決) -- 同順位の扱いと、仕切り直しに終わった対戦の集計上の扱い(§5.5) -- 予選総当たり → 決勝トーナメントの接続方法、シード扱い -- 順位規則も `ScoreRule` と同じくデータとして持つか、初版はコードに置くか - -トラックA(§10.1)は順位計算に依存しないため、これを保留したまま進められる。 -ただし `standings` 画面(§8.1)だけは中身が決まらない。 - -### 11.3 後回しでよいもの - -- CHaser 本体との連携有無(現状は手入力前提。将来ログ取り込みを行うか) -- 大会後の記録公開の形式(静的書き出しか、システムを稼働させ続けるか)。 - 稼働させ続ける場合は認証の要否を再検討する(§1.3) -- 画面遷移の演出、出力スロットの表示上の優劣(§8.2) +| `GET /api/state` | 現在の保存済み全状態 | +| `POST /api/state` | `{ expectedRevision, command }` を検証して適用 | +| `GET /api/stream` | `snapshot` SSEイベントで全状態を配信 | +| `GET /api/export` | 状態・履歴を含むJSONダウンロード | +| `GET /api/export?format=csv` | 各戦の対戦者・先後・素点・勝因・勝者・無効理由をCSV出力 | +| `GET /api/screen` | 現在の画面・進行区分・更新番号。旧POSTは410で新APIへ案内 | + +Zodでコマンドの形・得点範囲・文字数などを検証し、状態遷移側で対戦者・進行順・訂正条件を検証する。 +更新番号が不一致なら409と最新状態を返す。クライアントは確認を促して自動再送しない。 +別サイトからのOriginを伴う書込を拒否する。認証の代わりではない。 + +保存後にEventEmitterから全状態を送る。再接続時も必ず全状態を送り直すため、 +イベント差分の再送履歴・サーバー再起動時の連番リセットに依存しない。 +15秒おきにSSEのコメントを送り、切断時は購読とタイマーを解除する。 +接続が切れた掲示は最終受信値を残し、再接続中であることを表示する。 + +## 永続化と復旧 + +データディレクトリは `CHROS_DATA_DIR`。未指定ならNext.jsプロセスの作業ディレクトリにある `.chros`。 +通常の起動では `chros/.chros` となる。GitとDockerのビルドコンテキストから除外する。 + +- `state.json` が現行状態。 +- `state.previous.json` は直前の変更前状態。 +- 大会切替時は `archive-<時刻>-.json` に大会を退避する。 +- 一時ファイルへ書き、ファイルをfsyncした後にrenameして現行ファイルを置き換える。 +- 読取・更新検証・保存・通知を単一プロセス内で同期的に行う。 +- 保存ファイルが壊れている場合、サンプルを生成して上書きしない。 + +復旧する場合はサーバーを停止し、現行ファイルを別名で保管したうえで、正常な退避JSONまたは +エクスポートJSONを `state.json` にコピーして再起動する。復元画面は未実装。 +ファイル保存は今回の試作判断であり、PostgreSQL設計の完成を意味しない。 +複数Nodeプロセス・共有ディスクからの同時書込は非対応。これらが必要になればDBへ移行する。 + +## 表示 + +掲示は16:9。コンテナー単位でプレビューと会場表示を同じ比率にする。 +予選順位は1カード6名・1ページ2カード。本戦が8名以下なら全体図、それ以上は各ラウンド4試合ずつ。 +ページ番号は状態に保存し、運営と会場で一致させる。 +長い名前の一部は会場カード上で省略表示となるため、登録名は会場で読める長さにする。 + +ロゴはPNG/JPEG/WebP・1 MBまで。外部URLは使用せず、画像データを状態に含める。 +画面選択・表示順再生は進行区分と画面をまとめて保存する。 +現在の試合は独立して選択し、得点保存で意図せず画面を切り替えない。 + +## 検証 + +判定、旭川換算、左右入替の対称性、訂正、再試合、予選順位、進出、同順位裁定、不戦枠、 +開始後の修正保護を `packages/scoring/src/index.test.ts` で検証する。 +実施済みの検証と既知の制約は [試作提案](./prototype-proposal.md) に記録する。 diff --git a/docs/technical-roadmap.md b/docs/technical-roadmap.md new file mode 100644 index 0000000..fa70d64 --- /dev/null +++ b/docs/technical-roadmap.md @@ -0,0 +1,1136 @@ +# CHroS 技術要件 + +> 将来検討用の旧設計。2026-09-18の依頼者の補足により、現在の対象は対人戦の記録・集計・掲示をつなぐ試作に絞った。 +> Bot戦、ノード式エディター、PostgreSQLなど本書の全項目を実装したわけではない。 +> 現在の実装仕様は [technical-requirements.md](./technical-requirements.md)、自主判断は [prototype-proposal.md](./prototype-proposal.md) を優先する。 + +対象読者: CHroS の実装者。要件そのものは [requirements.md](./requirements.md) を参照。 + +このドキュメントは、既存実装(Next.js + Express/socket.io + FastAPI の3サービス構成)を +ゼロベースで再設計した結果を定める。既存コードとの差分は末尾の「現行構成からの移行」に記す。 + +2026-09-18追記: 釧路を主対象とし、旭川・北海道系と全国交流大会への拡張を整理した。 +出典・確認版・未確認事項は [大会ルール調査](./tournament-rules.md) を参照。 +以下は設計であり、既存コードの実装完了を示すものではない。 + +## 1. 決定事項サマリ + +| 項目 | 決定 | 理由 | +| --- | --- | --- | +| 言語 | TypeScript に統一 | フロントが存在する以上 TS は必須。型を共有パッケージで持てば境界のズレが消える | +| 構成 | pnpm workspace による monorepo、アプリは Next.js 単体 | サービス分割のコストに見合う独立性が現時点で無い。統合の逆は容易だが分割の巻き戻しは高くつく | +| サーバー → Viewer 通信 | **SSE**(WebSocket は採用しない) | Viewer は受信専用。双方向性が要らないなら SSE の方が単純で、HTTP のまま扱える | +| DB | PostgreSQL | 変更なし | +| ORM | Prisma | 変更なし | +| スコア計算 | **ノードグラフで定義し JSON として永続化**、評価器は TS | 計算式を非エンジニアが編集できることが本システムの核(§5) | +| 計算式の保存先 | PostgreSQL の `jsonb` | 形が変わりうるデータを構造化のまま持て、検索も効く | +| 認証・権限 | **設けない** | 会場 LAN 内でのみ動かす前提。意図的な決定であり、未検討ではない(§1.3) | +| 対象大会 | **釧路を優先**し、旭川・北海道系、全国交流大会へ拡張 | 開催年度・ステージ別の規則プロファイルで差分を持つ(§4.1) | +| 判定の入力 | 終了原因・残りターン・審判裁定を区別 | Put負け、自滅、タイムアウトを一律の `blocked` にすると実規則を再現できない | + +### 1.1 なぜ WebSocket をやめるのか + +当初は WebSocket を前提にしていたが、通信要件を洗うと以下だった。 + +- Console → サーバー: 通常の HTTP リクエスト(スコア入力、画面切替)で足りる +- サーバー → Viewer: 一方向のプッシュのみ。Viewer からの送信は無い + +双方向のフレームが必要な箇所が無いため、SSE で要件を満たす。SSE を選ぶ利点は具体的に、 + +- 再接続とイベント ID による取りこぼし復帰がプロトコル標準(`Last-Event-ID`)で提供される +- 素の HTTP なので、リバースプロキシ・Next.js の Route Handler にそのまま載る +- socket.io / 別プロセスの WS サーバーが不要になり、サービスが1つ減る + +将来 Viewer 側から入力を受ける要件(観客投票など)が出た場合のみ WebSocket を再検討する。 + +### 1.2 なぜ計算式をコードで書かせないのか + +釧路大会は他大会と比べてスコア計算が複雑であり、大会ごとに規則も変わる。 +これをアプリのコードに埋め込むと、規則が変わるたびにエンジニアの改修が必要になる。 +そこで**計算式をデータとして外部から与えられる**構造を、本システムの中核に据える。 + +当初案は Lua または Python の式を外部入力として取り込み評価する方式だった。これは撤回する。 + +- 触ってほしい相手は非エンジニアであり、どんな言語であってもコードを書かせるのは負担が大きい +- 任意コード評価はサンドボックス・実行時間・例外の扱いを自前で抱えることになる +- 別言語ランタイムを持ち込むと TS 統一の利点(型共有)が計算式の境界で切れる + +代わりに、**ノードを繋いで計算を組み立てる方式**(Scratch 的な編集 UI)を採る。 +実体は JSON のグラフであり、`jsonb` に格納して TS の評価器で解釈する。 +これにより、編集 UI・保存形式・評価器のすべてが型を共有した1つの TS 世界に収まる。 + +一方で Rume の指摘どおり、ノード方式であっても「任意の式が組める」ことを目指すと結局複雑になる。 +**用意するノードの語彙は大会規則の実例から決める**(§5.8 未決事項)。 +表現力は後から足せるが、一度公開した保存形式は減らせないため、初版は狭く始める。 + +### 1.3 認証を設けない判断 + +Console にログインを設けない。運営が触れる端末は会場 LAN 内にあり、 +外部から到達できないネットワークで運用することを前提とする。 + +この判断が成立する条件を明記しておく。**満たせなくなった時点で再検討が必要**である。 + +- アプリを会場 LAN の外に公開しない(インターネット経由での運営操作を行わない) +- 配信用の Viewer も同じ LAN 内のブラウザから開く +- 大会後にシステムを稼働させたまま記録公開に使う場合は、 + **Console への到達を塞ぐか、そこで初めて認証を導入する**(§11) + +したがって、Console と Viewer をネットワーク的に分離する必要はないが、 +**Viewer 側の画面から Console へ遷移できる導線は置かない**(配信事故を避けるため)。 + +## 2. リポジトリ構成 + +``` +CHaserProgressionControlSystem/ +├── chros/ # Next.js (App Router) — Console / Viewer / API を内包 +│ ├── app/ +│ │ ├── console/ # 運営向け操作画面 +│ │ ├── display/ # 配信用 Viewer 画面 +│ │ └── api/ # Route Handlers (REST + SSE) +│ └── prisma/ +│ ├── schema.prisma +│ └── migrations/ +├── packages/ +│ ├── shared/ # ドメイン型・イベント定義・Zod スキーマ(DB 非依存) +│ └── scoring/ # スコア計算・順位計算の純粋ロジック +├── docs/ +├── docker-compose.yaml +└── pnpm-workspace.yaml +``` + +方針: + +- `packages/shared` と `packages/scoring` は**副作用と I/O を持たない**。DB・HTTP・React に依存しない。 + こうしておくと、後で API を別プロセスに切り出す判断をしたときに、そのまま持ち出せる。 +- Next.js アプリの中では Console / Viewer / API がディレクトリで分かれているだけであり、 + 依存の向きは `chros/** → packages/**` の一方向に限る。 + +### 2.1 `apps/` を設けない理由 + +当初は `apps/chros/` として monorepo の慣例どおり app 階層を設ける案だったが、これは採らない。 +この階層が要るのは**別々にデプロイされる成果物が複数ある**場合であり、CHroS では該当しないため。 + +想定していた同居先はいずれも別リポジトリで管理する。 + +| 想定 | 扱い | +| --- | --- | +| 大会後の記録公開(§11.3) | 別リポジトリ | +| CHaServer の隣に常駐するログ取り込み(§11.3) | 別リポジトリ | + +ノードエディタ UI(§5.8)や自動抽選は工数こそ大きいが、いずれも運営が Console で触る画面であり、 +`chros/app/console/**` の一部にすぎない。**機能の独立性や工数はデプロイ単位を分ける理由にならない。** + +一方 `packages/` は app が1つでも残す。別リポジトリへ持ち出す際にそのまま動くことに加え、 +`package.json` に next / react / prisma を持たせないことで、 +上記の「副作用と I/O を持たない」制約がレビューではなくビルドで守られるため。 + +## 3. 技術スタック + +### 3.1 共通 + +| 項目 | 採用 | +| --- | --- | +| パッケージマネージャ | pnpm (workspace) | +| Node.js | 22 LTS | +| TypeScript | 5.x, `strict: true`, `noUncheckedIndexedAccess: true` | +| バリデーション | Zod(API 入出力・SSE ペイロードの単一の真実) | +| Lint / Format | ESLint (flat config) + Prettier | +| テスト | Vitest(`packages/scoring` は必須、API は主要経路) | + +### 3.2 フロントエンド + +| 項目 | 採用 | +| --- | --- | +| フレームワーク | Next.js 15 (App Router) | +| React | 19 | +| スタイル | Tailwind CSS v4 | +| Console のデータ取得 | Server Components + Server Actions を基本とする | +| Viewer のデータ取得 | SSE (`EventSource`) による push のみ | + +Console は「操作して結果が返る」画面なので Server Actions と再検証で足りる。 +Viewer は「状態に追従する」画面なので SSE。この2つを混ぜない。 + +### 3.3 バックエンド + +| 項目 | 採用 | +| --- | --- | +| API | Next.js Route Handlers (`app/api/**`) | +| DB | PostgreSQL 17 | +| ORM | Prisma 6 | +| リアルタイム | SSE (`text/event-stream`) | + +Python (FastAPI, lupa) は廃止し、スコア計算は `packages/scoring` に TS で実装する。 +CHaser のログ解析が必要になった場合も TS のパーサとして同パッケージ内に置く。 + +## 4. ドメインモデル + +既存スキーマを土台に、進行状態と大会のステージを持たせる。 +`Match` は試合、`Score` はその中の1対戦の事実を指す。 +以下は主要フィールドの設計スケッチであり、逆参照や一部の型・制約を省略している。 +そのまま実行するPrismaスキーマではない。 + +```prisma +enum Side { COOL, HOT } +enum MatchState { PENDING, IN_PROGRESS, REVIEW_REQUIRED, FINISHED } + +model Tournament { + id Int @id @default(autoincrement()) + name String + edition String // 開催年度・開催回 + stages Stage[] + entries Participant[] +} + +model Stage { + id Int @id @default(autoincrement()) + tournamentId Int + order Int + name String // 予選、決勝など + format Format // BOT_QUALIFYING | ROUND_ROBIN | SINGLE_ELIMINATION + ruleProfileId Int // 対戦構成・裁定・計算・順位・マップ設定の確定版 + matches Match[] + + @@unique([tournamentId, order]) +} + +model RuleProfile { + id Int @id @default(autoincrement()) + name String + version Int + sourceReferences Json // URL、改訂日/版/commit/hash、条項、確認日 + gamePlan Json // 対戦回数、COOL/HOT割り当て、同一マップ条件 + gameOutcomePolicy Json // 終了事象から対戦勝敗を決める条件・優先順 + replayPolicy Json // 自動再試合/裁定待ち、無効範囲、新マップ等 + rankingPolicy Json? // 順位・組ごとの通過者・同点処理。未確認ならnull + mapPolicy Json // 寸法、ターン範囲、公開方針、使用サーバー版等 + scoreRuleId Int? // トラックAでは未設定可。本採点には検証済み版が必要 + confirmedAt DateTime? // 対象大会への適用条件を確認した日時 + + @@unique([name, version]) +} + +/// スコア計算規則。graph にノードグラフの JSON を持つ。 +/// 一度大会で使用した規則は書き換えず、新しい version を作る(§5.7)。 +model ScoreRule { + id Int @id @default(autoincrement()) + name String + version Int + graph Json // jsonb。ScoreGraph 型(packages/shared) + publishedAt DateTime? // 検証を通過し使用可能になった時刻 + createdAt DateTime @default(now()) + + @@unique([name, version]) +} + +model Participant { + id Int @id @default(autoincrement()) + tournamentId Int + name String + kind ParticipantKind // COMPETITOR | BOT。BOTは選手順位の対象外 + // 対戦・スコアへの逆参照 +} + +model Match { + id Int @id @default(autoincrement()) + stageId Int + groupId Int? // グループ予選なら所属グループ + order Int // ステージ内の対戦枠。再試合でも同じ値 + attempt Int @default(1) + rematchOfId Int? + ruleProfileId Int // この試行に適用した版を固定 + state MatchState @default(PENDING) + agent1Id Int + agent2Id Int + firstCoolId Int? // じゃんけん等で確定した初戦の先攻 + sideAssignmentMethod String? // JANKEN | FIXED_BY_RULE | MANUAL + scores Score[] + results MatchResult[] // 再計算・裁定の履歴を保持 + activeResultId Int? // 採用中の結果。変更を履歴に残す + + @@unique([stageId, order, attempt]) +} + +/// 対戦の生データ。計算結果ではなく「観測された事実」のみを持つ。 +/// 事実と裁定から得点算出ブロックの「入力の箱」を構成する(§5.4)。 +model Score { + id Int @id @default(autoincrement()) + matchId Int + gameNumber Int // 1から。1対戦のみのBot戦に後半を作らない + coolId Int + hotId Int + mapVersionId Int // マップの識別子と版。内容の公開とは別 + turnLimit Int + coolItems Int? // 生の取得個数。中断等で未確認ならnull + hotItems Int? + remainingTurns Int? // 秒ではない。未確認と0を区別する + endFacts Json // 終了事象の種別・行為者・影響を受けた側 + inputRevision Int // 訂正前の事実は別履歴に残す + + @@unique([matchId, gameNumber]) +} + +// 対戦単位/試合単位の裁定は別の追記型履歴として保持する。 +// 対象ID、事実の版、適用プロファイル、採用する終了原因、対戦勝者、 +// 再試合/有効/裁定待ち、理由、裁定者の記録名、日時を持つ(§4.2)。 + +/// 計算規則を Score に適用した結果。どの規則で出したかを必ず添える。 +model MatchResult { + id Int @id @default(autoincrement()) + matchId Int + revision Int + ruleProfileId Int // 採点以外の条件も含む適用版 + scoreRuleId Int? // 採点前の無効化・裁定だけならnull + inputRefs Json // 入力・裁定の版を固定して参照 + outcome Outcome // WIN | DRAW | REPLAY | VOID + agent1Output Json? // 出力スロット名 -> 値。採点しない結果ならnull + agent2Output Json? + winnerId Int? + winReason String? // 決め手のスロット/適用条項と裁定理由 + trace Json? // 評価の中間値と通過した制御経路(§5.6 検証・説明用) + computedAt DateTime @default(now()) + + @@unique([matchId, revision]) +} +``` + +追加・変更の意図: + +- `Tournament` / `Stage`: 大会と予選・決勝を分離する。同一大会内でBot予選と対人戦の規則が変わる。 +- `StageGroup` / `GroupEntry`(上のスケッチでは省略): グループの所属ステージと参加者を持つ。 + グループ予選では同じ組の選手同士だけを対戦させ、順位・通過者を組単位で確定する。 + 通常の総当たり予選はグループなしでも表せる。組数やグループ化する人数の閾値は固定しない。 +- `Match.order` / `Match.state`: 「今どの対戦をやっているか」を DB の状態として持つ。 + Viewer の表示はこの状態から導出され、画面切替のためだけの独立した状態を持たない。 +- `Score.endFacts`: **終了原因を潰さず保存し、意味付けを規則に委ねる**。 + 旭川の相手Put負けは比較値0、自滅負けは負の残りターンとなり、釧路のタイムアウトは再試合に + なるため、従来案の `blocked: Side?` への統合は撤回する(§4.2、§5.4)。 +- `MatchResult.winReason`: 「どのような勝ち方をしたか」の表示要件(得点表示)に直接対応する。 + 現行は `put` / `lostConnect` から表示側が推論する必要があり、ロジックが散る。 + 値は enum ではなく文字列とする。勝因は規則側の終端ノード構成で決まるため、 + システム側で列挙し切れない。 +- `MatchResult.agent*Output` を `Json` にする理由: **何を得点として外に出すかは規則が決める**(§5.3)。 + 予選得点1つの規則もあれば勝利数と補正アイテム数を出す規則もあるため、固定カラムにできない。 + 表示側は出力スロットの定義(名前・表示名・型)を規則から引いて描画する。 +- `Score` と `MatchResult` の分離: **入力された事実と、規則を適用した結果を混ぜない**。 + 規則が変わったとき、事実はそのままに結果だけ再計算できる。可搬性を掲げる以上ここは譲れない。 + +事実は先に保存できる。裁定と必要な入力が揃った時点で `packages/scoring` を実行し、 +結果を `MatchResult` に永続化する。表示のたびに再計算しない。 + +### 4.1 大会別・ステージ別の規則プロファイル + +公式資料の比較は [大会ルール調査 §2–5](./tournament-rules.md) による。 +`RuleProfile` は実行コードではなく検証可能なデータとし、次を一緒に固定する。 + +- 資料の出典と版、対象の開催回、追加細則、未確認項目と確認記録。 +- 対戦構成: 初版は1対戦または先後交代2対戦。対戦ごとのマップ、Botの側を明示する。 + 釧路はじゃんけんで決まった初戦のCOOL/HOTを保存し、2戦目は同じ選手の側を反転する。 +- 対戦勝敗・再試合の条件表と優先順、試合の採点グラフ、順位と通過者決定の設定。 +- マップ・運用条件: 寸法、ターン上限と偶数制約、応答制限時間、公開時点、サーバー版。 + マップ自動生成やクライアント実行はCHroSの初版には含めない。 + +釧路を最初の運用対象とするが、未確認の集計方法を初期値で埋めない。 +旭川ではBot予選と決勝で別プロファイルを使う。全国交流大会は詳細細則を確認するまで草案とする。 +規則の技術的検証と、運営による適用条件の確認は別に記録し、自動採点に必要な未確認事項が +残るプロファイルは公開不可とする。トラックAの入力・表示・裁定記録は未公開でも実装できる。 + +釧路のステージ構成は、依頼者(元実行委員長、2026-09-18)の現行運用説明を根拠に、 +`ROUND_ROBIN` の予選と `SINGLE_ELIMINATION` の本戦を基本とする。 +グループ予選を選んだ場合は組ごとに総当たりを組み、各組上位2名を通過対象とする。 +順位規則、同順位処理、通常予選の通過人数、本戦の配置方法は別の設定・確認事項。 +公式サイトの「予選なし」との相違は [調査資料U1・§3.3](./tournament-rules.md) に記録した。 + +初戦の先後が未確定なら開始不可とする。じゃんけんの勝者からCOOLを推定するのでなく、 +確定したCOOLの参加者IDを入力し、相手をHOTとする。記録するIDはその試合の参加者に限る。 +Bot戦はプロファイルから割り当て、じゃんけん入力を要求しない。 +再試合は別試行なので、先後を引き継ぐか決め直すかも確定してから開始する。 +各対戦のCOOL/HOTは試合の2参加者と一致し、先後交代モードでは必ず反転することを検証する。 +グループと試合が同じステージに属し、両参加者がそのグループに登録済みであることも検証する。 + +### 4.2 終了事象・裁定・無効化 + +記録すべき事象は、規定ターン終了、直接Put、相手による封鎖、自分による封鎖、 +ブロックへの移動、場外移動、クライアント異常終了、応答タイムアウト、通信エラー、 +サーバー障害、審判が認定したその他の異常。行為者・影響を受けた側・元の観測情報を持つ。 +**同時成立を保存できる配列**とし、勝敗はその配列に適用するプロファイルと裁定から求める。 + +自分による封鎖と相手による封鎖は、旭川の減点に必要なため別事象にする。 +原因不明の通信断を自動で選手の責任とせず、`REVIEW_REQUIRED` へ送る。 +釧路の直接Put優先と、旭川のPutによる同時成立時の勝利優先も条件表に明記する。 + +裁定履歴は原記録を編集せず追記し、理由・裁定者の記録名・日時・対象範囲・適用条項を残す。 +認証機能は追加しないため記録名は本人認証を意味しない。 +訂正・再計算・無効化で採用結果が変わったら、順位と勝ち上がりへの影響を示し、 +既に開始した後続試合がある場合は進行を保留して運営が処理を確定する。 + +旧データの `put` / `lostConnect` だけでは新しい終了原因を復元できない場合がある。 +推定で自滅や封鎖に変換せず、旧版での結果と元データを残し、再計算には原因の補完を要する。 + +## 5. スコア計算エンジン + +本システムの核。大会ごとに異なる複雑な計算規則を、エンジニアの改修なしに差し替えられるようにする +(動機は §1.2)。 + +### 5.1 全体像 + +``` +[Console: ノードエディタ] --(ScoreGraph JSON)--> [検証器] --> [ScoreRule テーブル(jsonb)] + | + [Score(事実)] ------> [評価器 evaluate()] <---+ + | + v + [MatchResult(結果 + trace)] +``` + +すべて `packages/scoring` に置く。**I/O も DB も React も参照しない純粋な TS** とし、 +評価器・検証器・型定義を1か所にまとめる。これにより計算規則の単体テストが容易になり、 +将来 CLI や別サービスから同じ規則を再利用できる。 + +評価器の前段で、`RuleProfile` の条件表と裁定履歴から対戦の有効性・勝者・終了分類を +確定する。再試合条件に該当すれば採点せず `REPLAY`、判断や入力が不足すれば +`REVIEW_REQUIRED` とする。これは大会名で分岐する処理ではなく、版を持つ条件データの適用である。 +条件表も純粋関数として検証・評価し、規則に一致しない事象を黙って敗北へ変換しない。 + +### 5.2 用語 + +「ブロック」「演算子」「ノード」が混ざると議論が滑るため、以下に固定する。 + +| 用語 | 指すもの | 例 | +| --- | --- | --- | +| **ブロック** | 編集単位。ユーザーが追加・削除できない固定の区画 | 得点算出ブロック、勝敗判定ブロック | +| **ノード** | ブロック内に置く処理の1単位。ユーザーが自由に配置する | 加算ノード、同値判定ノード | +| **ポート** | ノードの入出力の接続口。名前と型を持つ | `left` / `right` / `yes` / `no` | +| **値エッジ** | 数値・真偽値を運ぶ線。得点算出ブロックのみ | 加算ノード → 出力スロット | +| **制御エッジ** | 「次にどのノードへ進むか」を運ぶ線。勝敗判定ブロックのみ | 同値判定の `no` → 終端ノード | + +以降、ユーザーが「演算子」と呼んでいたものは**ノード**と呼ぶ。 + +### 5.3 グラフの表現 + +対人の試合勝敗を求めるグラフは、**2種類・3ブロック**の構造を持つ。 +ブロックの単位は**参加者**であり、COOL/HOT の側ではない(理由は後述)。 + +``` +┌─ 得点算出ブロック (参加者A) ─┐ ┌─ 得点算出ブロック (参加者B) ─┐ +│ 入力: A の対戦データ │ │ 入力: B の対戦データ │ +│ 前半 COOL として / 後半 HOT│ │ 前半 HOT として / 後半 COOL│ +│ 演算ノード群 │ │ 演算ノード群 │ +│ 出力: 数値 (A の得点) │ │ 出力: 数値 (B の得点) │ +└──────────────┬───────────────┘ └──────────────┬───────────────┘ + │ │ + └────────────────┬────────────────┘ + v + ┌─ 勝敗判定ブロック (1つ) ──────────────┐ + │ 入力: 両者の得点 │ + │ if ノードを主体とした比較 │ + │ 出力: 勝者 (A / B / DRAW) + 勝因 │ + └───────────────────────────────────────┘ +``` + +**得点算出ブロックの定義は1つだけ持ち、評価時に両参加者へ適用する。** +2ブロックに見えるのは評価時の姿であり、編集対象は1つ。 +両者に同じ規則が当たることを構造で保証でき、「片方だけ直し忘れて不公平になる」バグが起きない。 +先攻/後攻による差はブロックを分けるのではなく、入力(前半/後半、COOL/HOT)を参照して +ブロック内の分岐として表現する。 + +上図は先後交代2対戦の例。Bot予選は1対戦の得点算出と順位への出力で足り、 +相手Botの換算得点と比較して試合勝敗を決めるブロックを付けない。 +対戦勝敗は前段の裁定結果を用いる。`purpose` と `inputShape` で両者を区別する。 + +この分割が持つ意味: + +- **「何点になるか」と「どちらが勝ちか」を混ぜない。** 得点算出ブロックは数値を出すことだけに責任を持ち、 + 勝敗の概念を知らない。勝敗判定ブロックは得点の作り方を知らない。 + 規則変更の多くは片方だけの修正で済む。 +- 得点算出ブロックは**片側ぶんの入力しか受け取れない**。相手の得点を参照できないため、 + 「相手より1点多ければ」のような比較は構造上ここに書けず、必ず勝敗判定ブロックに寄る。 + この制約が、規則の置き場所の曖昧さを消す。 +- 勝敗判定ブロックの出力は勝者と勝因のみ。得点をここで書き換えることはできない。 + +```ts +export type ScoreRuleGraph = { + formatVersion: 1; // 保存形式の版。移行判定に使う + inputShape: 'single' | 'swap-pair'; + points: PointsBlock; // 同じ定義を参加者ごとに適用 +} & ( + | { purpose: 'match-decision'; decision: DecisionBlock } + | { purpose: 'ranking-score'; rankingSlot: string } +); + +/** 得点算出ブロック: 値エッジのみの純粋な式。複数の値を名前付きで外に出す */ +export type PointsBlock = { + nodes: PointsNode[]; + valueEdges: ValueEdge[]; + outputs: OutputSlot[]; // 1つ以上。名前は勝敗判定ブロックから参照される +}; + +export type OutputSlot = { + name: string; // 例: 'qualifyingScore' / 'gameWins' / 'adjustedItems' + label: string; // 表示名(Console・配信画面で使う) + type: 'number' | 'boolean'; + from: NodeId; // この値を出すノード + fromPort: string; +}; + +/** + * 勝敗判定ブロック: 制御エッジのみのフローチャート。 + * 値は線で運ばず、各ノードが「どの出力スロットを見るか」を属性として持つ。 + */ +export type DecisionBlock = { + nodes: DecisionNode[]; // 分岐ノード | 終端ノード + controlEdges: ControlEdge[]; // 進行先を運ぶ。値は持たない + entry: NodeId; // 制御の開始点。ちょうど1つ +}; + +export type DecisionNode = + | { id: NodeId; kind: 'branch.equal'; slot: string } // 数値スロットが同値か + | { id: NodeId; kind: 'branch.flag'; slot: string } // 真偽スロットで分岐 + | { id: NodeId; kind: 'terminal.higher'; slot: string } // 高い方の勝利 + | { id: NodeId; kind: 'terminal.rematch' } // 仕切り直し(再戦) + | { id: NodeId; kind: 'terminal.draw' }; // 引き分け + +type ValueEdge = { from: NodeId; fromPort: string; to: NodeId; toPort: string }; +type ControlEdge = { from: NodeId; branch: 'yes' | 'no'; to: NodeId }; +``` + +`slot` は得点算出ブロックの出力スロット名であり、**両参加者の同名スロットを対象とする**。 +`a.score` と `b.score` を個別に繋ぐのではなく、「`score` を比較する」と1つ選ぶ。 + +ブロックごとに使えるノードの語彙を変える。全ブロック共通の語彙は持たない。 + +**得点算出ブロック**(初版の想定。確定は §5.8) + +| 分類 | 例 | 備考 | +| --- | --- | --- | +| 入力 | §5.4 の入力の箱 | 相手の**出力スロット**は参照できない(§5.4) | +| 演算 | 加減乗除、`min` / `max` | | +| 条件 | `if`(条件・真の値・偽の値の3入力) | 先攻/後攻で差をつけたい場合はここで分岐 | +| 定数 | 数値・真偽値 | 非エンジニアが調整する主な対象 | +| 出力 | **出力スロット**。数値または真偽値を、名前を付けて1つ以上 | 下記 | + +入力は参加者視点の `games[0]` / `games[1]` とする。 +`swap-pair` では `asCool` / `asHot` の別名も使えるが、`single` には存在しない。 +存在しない後半を0で補わず、規則の `inputShape` に合わない参照を構造検証で弾く。 + +**出力は1つの数値に固定しない。何をいくつ外に出すかを規則の作成者が決める。** +これは大会ごとの差を吸収するために必要な自由度である。 + +- 旭川Bot予選 → `qualifyingScore` を出す +- 旭川の対人戦 → `gameWins` と `adjustedItems` を出す(§5.5) +- 釧路 → 自滅等・Put・アイテムの分類を分離した出力を用意する。集計式は運営確認後に確定する + +出力スロットは名前と型を持ち、勝敗判定ブロックの各ノードが名前で1つ選択する(両参加者の同名スロットが対象)。 +出力スロットの追加・削除・改名は、それを参照している勝敗判定ブロックを壊すため、検証で検出する(§5.6)。 + +**勝敗判定ブロック** + +このブロックだけは式ではなく**フローチャート**である。 +制御エッジをたどって進み、終端ノードに到達した時点で勝敗が確定して評価が終わる。 + +**各ノードは「どのスコアを見るか」を選択として持ち、値は線で運ばない。** +得点算出ブロックの出力スロットが選択肢として並び、その中から1つ選ぶ。 + +| 種別 | ノード | 見るもの | 制御出力 | 意味 | +| --- | --- | --- | --- | --- | +| 分岐 | **同値判定** | 数値スロット1つ | `yes` / `no` | 両者のそのスコアが等しいかで進む先を分ける | +| 分岐 | **フラグ判定** | 真偽スロット1つ | `yes` / `no` | 真偽の要因で進む先を分ける | +| 終端 | **高い方の勝利** | 数値スロット1つ | なし | そのスコアが大きい側を勝者として確定する | +| 終端 | **仕切り直し** | なし | なし | 勝敗を付けず、同一カードの再戦とする | +| 終端 | **引き分け** | なし | なし | 引き分けとして確定する | + +**「仕切り直し」は引き分けとは別物**であり、両者を区別して持つ。 +釧路の試合と旭川の対人戦(§5.5)は、規定の比較でも決まらなければ再試合となる。 +これはBot戦の同点時処理を定めるものではない。引き分け終端は対応を明示したプロファイルだけで +使い、未確認の同点を引き分け確定に置き換えない。 + +- **終端ノードは制御出力を持たない。** これが「確定して終わる」ことの表現であり、 + 出力が無いこと自体が意味を担う。終端に到達したら以降のノードは評価しない。 +- **分岐ノードは値を出力しない。** 真偽値を後段に配って使い回すのではなく、 + 制御の行き先そのものを分ける。真偽値が線として漂わないので、 + 「この true はどこで使われるのか」を読み解く必要がない。 +- **このブロックに値エッジは存在しない。線は制御エッジ1種類だけである。** + スロットの選択がその役割を果たす。これにより得られるものが3つある。 + - **編集が「線を2種類引き分ける」から「ドロップダウンで選ぶ」に落ちる。** + 非エンジニアに触らせるという目的に対して、UI の難所が1つ消える。 + - **無意味な接続が原理的に作れない。** 値エッジがあると + 「A の `gameWins` と B の `adjustedItems` を比べる」という非対称で無意味な繋ぎ方が書けてしまう。 + スロットを1つ選ぶ形なら、比較は常に両参加者の同名スロット同士になる。 + §5.3 冒頭で述べた**規則の対称性が、判定ブロック側でも構造として保証される**。 + - 型検証が「線の型整合」ではなく「選んだスロットの型がノードの要求と合うか」に単純化される。 + ドロップダウンに合う型のスロットだけを並べれば、誤りは選択肢として存在しなくなる。 + +値エッジを持つのは得点算出ブロックだけになる。§5.2 の用語表では2種類の線を定義しているが、 +**1つのブロックが両方の線を持つことはない**。 + +#### 判定の優先順位はフローの順序そのもの + +「補正アイテム数より対戦勝利数を先に比較する」旭川の対人戦(§5.5)は、 +優先度という別概念を導入せず、**制御エッジを繋ぐ順序**で表現する。 +先に置いた分岐が先に評価される。 + +```mermaid +flowchart TD + start[開始] --> wins{gameWins が同じか} + wins -->|いいえ| winByWins[gameWins が高い方の勝利] + wins -->|はい| items{adjustedItems が同じか} + items -->|いいえ| winByItems[adjustedItems が高い方の勝利] + items -->|はい| replay[仕切り直し] +``` + +線は制御エッジ1種類だけであり、各ノードの中に「見るスコア」の選択が入っている。 +釧路の比較順も同値判定を連ねる形で表現できるが、比較する値の算出方法を先に確定する必要がある。 +Bot予選の順位付けはこの対人勝敗フローを使わず、ステージの `rankingPolicy` が担当する。 + +同じ語彙のまま、分岐を置く順を入れ替えるだけで「得点が先、要因が後」の大会にも対応できる。 +**優先順位を数値やフィールドで持たない**ため、順序が図として一目で読めるという利点がある。 + +**勝因(`WinReason`)は、どの終端ノードに到達したかそのものである。** +別途フィールドとして組み立てる必要はなく、終端ノードの種別と、 +そこに繋がれていた出力スロット名(例: `gameWins` で決着)を記録すれば足りる。 +これは trace(§5.6)とも自然に一致する。 +採点前に再試合・失格等の裁定で決めた場合は、終端ノードの代わりに条件表の条項と裁定理由を記録する。 + +ここで A / B は `Match.agent1` / `agent2` を指す。 +評価器は結果を実際の `Participant.id` に解決してから `MatchResult` に書く。 + +制御フローを持つのは勝敗判定ブロックだけであり、得点算出ブロックには制御エッジを定義しない。 +得点算出は常に全ノードを評価して出力スロットを埋める純粋な式のままとする。 + +制約として、 + +- **ループ構造を持たない。値エッジ・制御エッジのどちらについても、である。** + ノードの再帰参照と後方への辺を検証器で弾き、両方のグラフを DAG に限る。 + 制御エッジを導入するとフローチャートになり「戻る線」を引きたくなるが、これを許した瞬間に + 下記の保証がすべて失われるため、明示的に禁止する。 + これは意図的にチューリング完全性を捨てる設計判断であり、見返りとして次を無条件に得る。 + - **停止性が構造的に保証される。** 実行時間の監視、タイムアウト、無限ループの検出が不要になる。 + - **状態を持たない。** 評価は入力から出力への写像であり、途中経過が観測に影響しない。 + そのため任意の順で評価しても、途中で打ち切って再開しても結果が同じになる。 + - **静的に全ノードの値域と型を追える。** 検証(§5.6)が実行なしで完結し、 + エディタ上で「繋いだ瞬間に誤りが分かる」形にできる。 + - **trace が常に有限で全ノードぶん取れる。** 説明可能性(§5.6)がここに依存する。 +- ブロック間の接続は固定であり、ユーザーが編集できるのは各ブロックの内側だけ。 + ブロックを跨ぐ辺、ブロックの追加・削除はできない。 +- ノードの種別は列挙型であり、任意の関数呼び出しやコード片は持てない。 +- 評価は純粋。乱数・現在時刻・外部参照を持つノードを定義しない + (抽選など乱数が要る機能はスコア計算とは別系統で扱う。§5.8)。 + +対人試合の評価順序は `points`(参加者A) → `points`(参加者B) → `decision` に固定する。 +前2つは同一の定義を異なる入力で評価するだけで相互に依存しないため、 +順序を入れ替えても結果は変わらない(上記の無状態性による)。 + +これにより**規則の対称性が構造的に保証される**。 +「A に有利な式が紛れ込んでいないか」を検証する必要が無く、 +非対称性は勝敗判定ブロックに書かれた場合にのみ発生しうるので、レビュー範囲がそこに限定される。 +Bot予選は `points` の指定スロットを順位計算へ渡す。試合の採点結果には前段で確定した +対戦勝者と参加者の得点を残し、Botを順位対象から除くのはステージ側の責務とする。 + +### 5.4 入力の箱 + +得点算出ブロックが参照できる入力を「箱」として定義する。 +箱は**参加者1人ぶんの、1試合ぶんの事実**を表す。 + +初版で用意する箱(`games[n].` の下に置く。以下は自分の1対戦ぶんの一覧): + +| 箱 | 型 | 内容 | +| --- | --- | --- | +| `items` | number | 生の取得アイテム数。補正値で上書きしない | +| `remainingTurns` | number | 終了時の残りターン数。`remainingTime`(秒)とは分ける | +| `selfWon` / `selfLost` | boolean | 終了事象と適用規則・裁定から確定した対戦勝敗 | +| `lostByOpponentPut` | boolean | 相手の直接Putによる敗北として確定 | +| `lostByOpponentEnclosure` | boolean | 相手からの封鎖による敗北として確定 | +| `lostBySelfBlockMove` / `lostBySelfEnclosure` | boolean | 自滅の種類を区別した敗北 | +| `lostByOutOfBounds` | boolean | 自らの場外移動による敗北として確定 | +| `lostByClientCrash` / `lostByCommunicationError` | boolean | 異常終了と責任の確定した通信エラーによる敗北 | +| `lostByAdjudicatedClientFault` | boolean | その他の異常を裁定で敗北扱いとしたもの | +| `opponentLostBy…` | boolean | 上記と同じ分類を相手側について参照 | +| `isCool` | boolean | この対戦で自分がCOOLだったか | + +これらは原記録を上書きするフィールドではなく、§4.2の事実と版を固定した裁定から作る +**評価用の入力ビュー**である。失格・再試合・裁定待ちは前段で扱い、`selfLost` へ押し込まない。 +直接Putと自分の封鎖の同時成立では、観測事象は両方残し、敗因の箱には勝利優先を適用した +最終分類を入れる。未確定の勝敗をfalse/falseとして採点に進めない。 + +`remainingTurns` は整数かつ `0 <= remainingTurns <= turnLimit`、アイテム数は非負整数とする。 +補正後の値は負数を許容する。未実施の対戦と未入力値は0に変換しない。 +規定ターン終了時の勝敗など、規則から決まる値と入力した裁定に矛盾があれば確認を求める。 +タイムアウトと通信エラーは別事象であり、釧路のタイムアウトをこの表の敗北へ自動変換しない。 + +#### 相手の事実は参照してよい + +§5.3 で「得点算出ブロックは自分の値のみを参照できる」と述べたが、正確には次のとおり。 + +- **参照できない**: 相手の**出力スロット**(相手の算出済み得点)。 + これを許すと得点算出が相互参照になり、対称性の保証とブロック分割の意味が失われる。 +- **参照してよい**: 相手についての**事実と確定済みの終了分類**(`opponentLostByClientCrash` など)。 + 釧路では相手の自滅等による勝利とPut勝ちを区別するため、相手の終了原因も必要になる。 + +境界は「観測・裁定に基づく入力か、相手の算出済みスコアか」であり、「自分か、相手か」ではない。 + +#### 箱を増やすことの重さ + +箱を増やす場合は元の事実・裁定の保存方法と入力スキーマを更新し、 +互換性のない変更は `formatVersion` を上げる。 +**箱は後から足せるが、一度公開した箱は減らせない。** + +なお「アイテム数×3 + 残りターン数」のような合成は箱としては持たない。 +それは得点算出ブロックで組み立てるものであり(§5.5 のBot予選)、 +箱の側で合成してしまうと規則を差し替えられるという前提が崩れる。 + +### 5.5 規則の実例 + +2026-09-18に公開資料を照合し、従来の匿名の「大会A/大会B」の例を置き換えた。 +以前の「特殊ポイント1種類」と「全大会で前後半の `items×3 ± remainingTime` を合算する」 +という説明は、対象大会の実規則を表していない。 +出典と数値付きの受け入れ例は [大会ルール調査 §3–6](./tournament-rules.md) にまとめる。 + +#### 釧路 — 終了分類の優先順と再試合 + +公開細則§1-3の比較順は **相手の自滅等による勝利 → Put勝ち → アイテム数優勢**。 +各分類を別スロットにし、同値なら次のスロットへ進む構造は採用できる。 +ただし、前後半を集計する具体式は運営確認後に確定する。 +分類ごとの勝利回数を合計する案や重み付けの係数を、公式に確定した仕様として実装しない。 + +公開細則§1-4の再試合条件は、このスロット比較より先に判定する。 +応答タイムアウトやサーバー障害なら再試合、審判判断が必要なら裁定待ちとなり、 +採点対象になった試合だけをグラフに渡す。 + +#### 旭川/北海道系 — Bot予選 + +北海道大会ルールブックv3.1.0 p.4による。 +`inputShape: 'single'`、`purpose: 'ranking-score'` とし、BotがHOTの1対戦を採点する。 + +```text +qualifyingScore = games[0].items * 3 + + (games[0].selfWon ? games[0].remainingTurns : -games[0].remainingTurns) +``` + +上式の前提は対戦勝敗の確定であり、未確定・同点を負けの分岐へ入れない。 +参加選手の `qualifyingScore` を順位計算へ渡す。存在しない後半の値は要求しない。 + +#### 旭川/北海道系 — 対人トーナメント + +同じルールブックの同ページでもBot戦とは別規則。 +`inputShape: 'swap-pair'`、`purpose: 'match-decision'` とし、同じマップで先後交代の2対戦を扱う。 + +| スロット | 算出方法 | +| --- | --- | +| `gameWins` | 2対戦の `selfWon ? 1 : 0` の合計 | +| `adjustedItems` | 各対戦の下記補正値の合計 | + +各対戦の補正値は、相手Put/相手からの封鎖による敗北なら0、 +自分のブロック移動/自己封鎖/自分に起因する通信エラー等による敗北なら +`-remainingTurns`、それ以外は生の `items`。 +判断できない終了原因を「それ以外」に落とさず、先に裁定で分類する。 + +```text +同値判定(gameWins) + no -> 高い方の勝利(gameWins) + yes -> 同値判定(adjustedItems) + no -> 高い方の勝利(adjustedItems) + yes -> 仕切り直し +``` + +#### 必要な表現力と確認範囲 + +- 数値・真偽値、加減乗算、`if`、複数の出力スロット、同値判定、大小による勝利終端で、 + 旭川の確認済み計算を表現できる。複数敗因の判定は `if` の連結で書ける。 +- 1/2対戦の入力構成と、採点前の終了原因・再試合処理が別途必要。 + 得点グラフだけで大会の全運営規則を表現できるとはしない。 +- 釧路の集計式と全国交流大会の詳細細則は未確定。全大会への十分性はまだ主張しない。 + +#### 仕切り直しのデータ上の扱い + +「仕切り直し」に到達した対戦は、勝敗が未確定のまま再戦を行う。 +既存の `Score` を書き換えて上書きすると事実が失われるため、**再戦は新しい `Match` として作る**。 + +```prisma +model Match { + // ... + attempt Int @default(1) // 何回目の対戦か + rematchOfId Int? // 仕切り直しの元になった Match + rematchOf Match? @relation("Rematch", fields: [rematchOfId], references: [id]) + rematches Match[] @relation("Rematch") +} +``` + +- 元の `Match` は `MatchResult` に「仕切り直し」の結果を残したまま確定する。 + `outcome=REPLAY`、`winnerId=null` とし、順位・勝ち上がりへの寄与を無効にする。 +- 順位計算は最終的に有効と確定した試行だけを数え、仕切り直しに終わった試行は除外する。 + 引き分け確定を認める別プロファイルでは、その引き分けも順位規則に従って扱う。 +- 配信画面には「仕切り直し」を明示できるようにする(§8 の `result` 画面)。 +- 釧路は原因が後半だけでも新マップで前後半ともやり直し、前半の得点を持ち越さない。 +- 旭川の対人戦も同点時は新マップで再試合とする。Bot戦の未確認の同点処理には流用しない。 +- 再試合は同じ `stageId` / `order` を持ち `attempt` を増やす。元試行を残しても + 一意制約に衝突せず、同じ対戦枠から二重に勝ち上がらないようにする。 + +### 5.6 検証システム + +「曖昧な状態を防ぐ」ことを明示的な仕組みとして持つ。規則は検証を通過するまで大会に適用できない。 + +検証は5段階で行う。 + +その前提として、プロファイルの必須設定・確認状況、`inputShape` と対戦構成の整合を検証する。 +`ranking-score` では得点算出と順位用スロットを検証し、以下の勝敗判定ブロック固有の検証は +`match-decision` のみへ適用する。存在しない対戦への参照、不明な終了原因、未確定の勝敗、 +未入力値、未設定の残りターンを含む結果は本採点へ渡さない。 + +1. **構造検証**: Zod スキーマ適合、得点算出ブロックの値エッジと + 勝敗判定ブロックの制御エッジがそれぞれ DAG であること、 + 未接続の入力ポートが無いこと、到達不能なノードが無いこと、 + ブロックを跨ぐ辺が無いこと、そのブロックで許可された種別のノードのみを使っていること。 + 得点算出ブロックは出力スロットが1つ以上あり、名前が重複していないこと。 +2. **制御フロー検証**(勝敗判定ブロックのみ): + - `entry` がちょうど1つあること。 + - **すべての制御経路が終端ノードに到達すること。** 分岐ノードの `yes` / `no` のうち + 片方でも繋がっていなければ検証エラーとする。 + - 終端ノードから出る制御エッジが無いこと。 + - 到達しうる終端が1つも無い、または到達できない終端がある場合はエラーとする。 + + この段が「曖昧な状態を防ぐ」の実体である。**判定漏れ=勝敗が決まらない状態**を、 + 実行時の例外ではなく規則の保存時に構造として弾く。 + ループが無いため、全経路の列挙は有限で網羅的に行える。 +3. **スロット参照検証**: 勝敗判定ブロックの各ノードが選んでいる `slot` が、 + 得点算出ブロックに実在し、かつノードの要求する型と一致すること + (同値判定・高い方の勝利は数値、フラグ判定は真偽値)。 + スロットの改名・削除で判定側が壊れるのを保存時に検出する。 + また、どのノードからも選ばれていない出力スロットは警告として提示する + (表示専用として意図的に出す場合があるため、エラーにはしない)。 + + スロットを選択にしたことで、この検証は**エディタのドロップダウンに + 適合する型のスロットだけを並べる**ことと等価になる。 + 誤りを検出するのではなく、選択肢として存在させない形にできる。 +4. **型検証**(得点算出ブロックのみ): 値エッジのポート間の型(数値 / 真偽値)の整合。 + ループが無いため、この検証は評価せずに完了する。 + 勝敗判定ブロックには値エッジが無いため、この段の対象外となる。 +5. **振る舞い検証**: 規則ごとに**テストケース**(入力の `Score` と期待する結果の組)を登録できる。 + 過去大会の実データを流し、結果が変わらないことを確認する回帰テストとして使う。 + +[大会ルール調査 §6.1](./tournament-rules.md) の例を受け入れ基準に含める。 +特に相手Putと自滅の補正差、Bot戦の負得点、釧路の後半障害による全試合無効化、 +同時成立の優先順位、じゃんけん後の側交換、グループ予選の通過境界を確認する。 +最後の3つなど、採点関数以外の条件表・進行処理にも検証対象があることに注意する。 + +評価器は `trace` を返し、`MatchResult.trace` に保存する。対人試合のグラフのtraceは2部構成になる。 + +- 得点算出ブロック: 各ノードの中間値(なぜこの点数になったか) +- 勝敗判定ブロック: **通過した制御経路と、到達した終端ノード**(なぜこの勝敗になったか) + +後者は制御エッジを導入したことで自然に得られる。「同値判定(score) → no → 高い方の勝利(score)」という +経路そのものが説明になっており、非エンジニアが規則を信頼する条件を満たす。 +Bot予選には得点算出のtraceだけがあり、前段の原因分類・裁定の参照は別途共通で残す。 + +### 5.7 版管理 + +- 一度大会で使用した `ScoreRule` は**書き換えない**。編集は新しい `version` の作成として扱う。 +- `RuleProfile` も同様に版を固定する。資料の更新で採用版を自動的に切り替えない。 +- `MatchResult` は規則・プロファイル・入力・裁定の参照版を保持し、過去の結果を再現する。 +- 大会中に規則の誤りが見つかった場合は、新版を作り、影響する `Match` を明示的に再計算する。 + 再計算は運営の操作として行い、暗黙に走らせない。 +- 新結果は追記して採用結果を切り替え、旧結果を残す。順位や通過者にも適用版と入力結果の版を + 持たせ、試合結果を訂正した場合は再確定が必要な箇所を特定できるようにする。 + +### 5.8 未決事項 + +- **釧路の前後半集計**: 判定の優先順と2対戦を集計する運用は確認済み。 + 各分類の数値的な重み・合算・アイテムの比較範囲を確定し、実例で検証する。 +- **全国交流大会の細則**: 開催回を特定して入手し、既存の入力とノードで表せるか確認する。 + 旭川の計算例が表せるだけでは全国対応が完了したことにならない。 +- **入力と条件表の保存形式**: §4.2・§5.4の事象・裁定・入力ビューについて、 + Zodスキーマと版移行を確定する。必要な原因分類を省略して簡素化しない。 +- **`min` / `max` ノードの要否**: §5.5の確認済み計算では使っていない。 + 実際に使う規則が現れるまで実装を保留してよい。 +- **分岐ノードの追加**: 初版は「同値判定」「フラグ判定」の2つ。 + 「指定値以上か」などの比較が必要になるかを実例で確認する。 + §5.5の旭川の計算例では真偽値を得点算出の `if` で消費できる。 + 勝敗判定のフラグ分岐を実装する場合は、両者の値が違うときの意味を先に定める。 +- **出力スロットと表示の結びつけ**: 配信画面(§8)が出力スロットをどう描画するか。 + スロット定義から駆動する方針は§8.2で確定済み。表示専用スロットや強調方法の詳細は後続で決める。 +- **仕切り直しの回数上限**: 再戦を繰り返しても同点が続いた場合の扱い。 + 規則側では表現できないため、運営の判断で打ち切る運用とするか、 + 大会設定として上限回数を持つかを決める(§5.5)。 +- **引き分けの扱い**: 「引き分け」終端に到達した対戦を、 + 総当たり戦の順位計算がどう解釈するか(§11 の順位決定規則と接続する)。 + Bot戦の対戦自体が同点となった場合や予選の同得点順位は、対人試合の再試合とは別に確認する。 +- **3ブロックで足りない規則が出た場合の拡張方法**: ブロック構成は固定であり、 + ユーザーは追加できない。構成自体の変更は `formatVersion` を上げる移行として扱う。 +- **自動抽選システム**: 対戦組み合わせの自動生成。今後の目標とし、初版のスコープ外。 + 乱数を含むためスコア計算グラフとは分離し、抽選結果は確定値として `Match` に永続化する。 + +## 6. リアルタイム配信設計(SSE) + +### 6.1 エンドポイント + +``` +GET /api/stream → text/event-stream +``` + +- 全 Viewer が同一ストリームを購読する。大会単位で分けたい場合のみ `?tournamentId=` を受ける。 +- 接続時にまず現在の完全な状態(スナップショット)を1件送る。Viewer は初期取得のための + 別 API を叩かなくてよい。 +- 以降は差分イベントを送る。 +- 各イベントに単調増加の `id:` を付与し、`Last-Event-ID` ヘッダによる再送に対応する。 + 再送不能なほど古い場合はスナップショットを送り直す。 +- 15 秒ごとにコメント行 (`: ping`) を送出し、プロキシによるアイドル切断を防ぐ。 + +### 6.2 イベント定義 + +型は `packages/shared` に置き、サーバーと Viewer が同じ定義を import する。 + +| event | ペイロード | 意味 | +| --- | --- | --- | +| `snapshot` | 下記 `Snapshot` | 接続直後・復帰時の全状態 | +| `screen.changed` | `{ screen: ScreenId }` | 配信画面の切り替え | +| `match.started` | `{ matchId }` | 対戦開始 | +| `match.scored` | `{ matchId, gameNumber, ... }` | 1対戦の事実・裁定・算出値の更新 | +| `match.finished` | 対戦結果 | 対戦終了(仕切り直しを含む) | +| `standings.updated` | 順位表 | 順位の再計算結果 | + +```ts +// packages/shared/src/events.ts +export type ChrosEvent = + | { type: 'snapshot'; payload: Snapshot } + | { type: 'screen.changed'; payload: { screen: ScreenId } } + | { type: 'match.started'; payload: { matchId: number } } + | { type: 'match.scored'; payload: ScorePayload } + | { type: 'match.finished'; payload: MatchResultPayload } + | { type: 'standings.updated'; payload: Standings }; + +/** 接続直後に1件送る。Viewer が全画面を描くのに必要なものを漏れなく含む(§8.4) */ +export type Snapshot = { + screen: ScreenId; + tournament: { id: number; name: string; edition: string }; + stages: StageView[]; // 方式、グループ、規則版、公開用の対戦構成 + currentStageId: number | null; + slots: OutputSlot[]; // 表示に使う出力スロットの定義(§8.2) + participants: ParticipantView[]; + matches: MatchView[]; // 対戦表を描くのに足りる全対戦(状態つき) + currentMatchId: number | null; + standings: Standings | null; +}; +``` + +`Snapshot` に `slots` を含めるのは §8.2 のためである。 +Viewer はスコアの意味を知らず、スロット定義に従って描画する。 +`slots` は表示対象ステージの規則から選び、ステージ切り替え時に関連状態と一緒に更新する。 +`MatchView` には対戦番号・先後・試行番号・裁定待ち・結果の有効性を、順位にはグループと +暫定/確定・通過者の状態を含める。再試合/無効化では対戦表と順位を同じ結果版に揃える。 +`half` のみを持つ現行型からの移行では、送信側とViewerを同時に更新する。 +公開前のマップ内容、運営専用メモ、原規則JSONの全体は配信しない。 + +### 6.3 プロセス間の配信 + +Next.js が単一プロセスで動く限り、インメモリの EventEmitter で購読者に配れば足りる。 +複数インスタンスで動かす必要が出た場合に限り、PostgreSQL の `LISTEN/NOTIFY` を経由させる +(Redis を新規に持ち込まない。DB は既にあるため)。 + +### 6.4 Viewer 側の要求 + +- `EventSource` は自動再接続を行う。切断中も直前の描画を保持し、画面を白くしない。 +- 会場ネットワークが落ちても、復帰時に `Last-Event-ID` またはスナップショットで整合が取れること。 +- Viewer は配信に載るため、**エラーダイアログやローディングスピナーを画面に出さない**。 + 異常時は直前の正常な表示を維持し、状態は運営側の Console にのみ通知する。 + +## 7. API 設計 + +Route Handlers で REST を提供する。Console からの操作は Server Actions を優先し、 +外部ツール連携や冪等性が必要な操作のみ REST に置く。 + +| Method | Path | 用途 | +| --- | --- | --- | +| `GET` | `/api/stream` | SSE | +| `GET` | `/api/tournaments/:id/matches` | 対戦表取得 | +| `POST` | `/api/matches/:id/start` | 対戦開始 | +| `POST` | `/api/matches/:id/scores` | `gameNumber` を指定して対戦の事実を登録 | +| `POST` | `/api/matches/:id/side-assignment` | 初戦のCOOL/HOTと決定方法を登録 | +| `POST` | `/api/matches/:id/adjudications` | 理由と対象範囲を持つ裁定を追記 | +| `POST` | `/api/matches/:id/rematch` | 元試行の無効化と次の試行の作成 | +| `POST` | `/api/stages/:id/advance` | 予選の確定順位から本戦の出場者を確定 | +| `POST` | `/api/matches/:id/finish` | 対戦終了・勝敗確定 | +| `POST` | `/api/matches/:id/recompute` | 規則の新版で再計算(§5.7) | +| `POST` | `/api/screen` | 配信画面の切り替え | +| `GET` | `/api/score-rules` | 計算規則の一覧(版つき) | +| `POST` | `/api/score-rules` | 新しい版の作成 | +| `POST` | `/api/score-rules/:id/validate` | 検証の実行(§5.6) | +| `POST` | `/api/score-rules/:id/publish` | 検証通過後、大会に適用可能にする | + +規約: + +- リクエスト / レスポンスは Zod スキーマで検証し、スキーマは `packages/shared` に置く。 +- 状態を変える操作はすべて、DB 書き込みと SSE ブロードキャストを同一の関数内で行う。 + 片方だけ実行される経路を作らない。 +- エラーは HTTP ステータス + `{ error: { code, message } }` の形に統一する。 +- 裁定・再試合・通過確定は操作IDと対象の結果版を受け、二重登録と古い画面からの確定を防ぐ。 + 再試合作成と元試行の集計除外は同一トランザクションで行う。 + 対戦の不足・先後未確定・裁定待ち・進出境界の同順位未解決をエラーとして説明する。 + +## 8. 画面(Viewer) + +配信に載る表示専用の画面。操作は受け付けず、サーバーの状態に追従して表示だけを変える。 + +### 8.1 画面の種別と表示内容 + +`ScreenId` は `packages/shared` の union 型で定義し、URL とイベントの両方でこれを使う。 + +| ScreenId | 用途 | 表示する内容 | +| --- | --- | --- | +| `interval` | つなぎ | 大会名、ロゴ、次に始まる内容の予告 | +| `next-match` | 次の対戦の紹介 | 対戦者2名の情報、どの試合か(回戦・組) | +| `in-match` | 対戦状況 | 対戦者2名、各対戦の先後と確定済みスコア、優劣 | +| `result` | 対戦結果 | 対戦者2名、両者の最終スコア、勝者、決め手 | +| `bracket` | 進行中の対戦表 | トーナメント表 / 総当たり表と、その中の現在地 | +| `standings` | 順位表 | 参加者の順位(詳細は未定。§11) | + +各画面が必要とするデータは以下の3つに集約され、いずれも既存のモデルから導出できる。 + +- **対戦者情報**: `Participant`(名前、および将来的に所属・アイコン等) +- **スコア**: `MatchResult.agent1Output` / `agent2Output`(出力スロット名 → 値) +- **進行状況**: `Match.order` / `Match.state` と、大会全体の `Match` 一覧 + +### 8.2 スコアの表示は規則から駆動する + +**表示側にスロット名をハードコードしない。** +何を得点として出すかは規則が決めるため(§5.3)、画面は `ScoreRule` の出力スロット定義 +(`name` / `label` / `type`)を引き、宣言されている順に `label` と値を並べる。 + +- 旭川の対人戦なら「対戦勝利数 1 / 補正アイテム数 8」の2行 +- Bot予選なら「予選得点 44」の1行 +- 釧路は運営確認した集計式の分類別スロットを表示する + +これにより、大会が変わっても表示コンポーネントを改修しなくてよい。 +`OutputSlot.label` を持たせているのはこのためである。 + +なお「どのスロットを大きく見せるか」など演出上の優劣は、この仕組みでは決まらない。 +初版は宣言順の先頭を主表示とし、細かい制御が必要になった時点で §5.8 の未決事項として扱う。 + +### 8.3 対戦表の表示 + +トーナメントと総当たりの両方を扱う。どちらも「対戦一覧 + 各対戦の状態」から描画でき、 +`Stage.format` で描き分ける。Bot予選は参加選手とBot・得点の一覧とする。 + +- **トーナメント**: `Match` を回戦ごとに配置し、勝者を次の対戦へ繋いで描く。 +- **総当たり**: 参加者 × 参加者の表とし、各セルに対戦の状態と結果を入れる。 +- **グループ予選**: 組ごとの総当たり表・順位・通過枠を表示する。釧路は各組上位2名。 + 組を跨ぐ全体順位を勝手に作らず、同順位が進出境界にかかる場合は未確定を示す。 + +いずれも `Match.state`(`PENDING` / `IN_PROGRESS` / `REVIEW_REQUIRED` / `FINISHED`)で見た目を変え、 +**進行中の対戦が一目で分かる**ことを要件とする。これは「対戦状況表示」の要件に直結する。 + +仕切り直しになった対戦(§5.5)は、元の対戦と再戦を1つの枠にまとめて表示する。 +別々の対戦として2つ並べると、対戦表の構造が崩れて読めなくなるため。 + +### 8.4 実装上の制約 + +- Viewer は `/display` の単一ルートで受け、`screen.changed` に応じて描画を切り替える。 + 現行のようにページ遷移(`router.push`)で切り替えると、遷移のたびに白画面と再接続が挟まり + 配信に映るため、**クライアント側の状態切り替えとして実装し、ページ遷移は行わない**。 +- 画面遷移にはアニメーションを入れられる余地を残す(配信の見栄えのため)。 + ただし遷移中に SSE の状態更新が届いても破綻しないこと。 +- 表示に必要なデータはすべて `snapshot` イベントに含める。 + 画面を切り替えた瞬間に追加の API を叩く設計にしない。切り替えが表示の遅延として見えるため。 +- **画面は固定解像度(1920×1080)を前提に組む。** 配信に載せる用途であり、 + レスポンシブ対応は不要。中途半端に対応すると、実際に使う解像度での作り込みが甘くなる。 + +## 9. 実行環境 + +`docker compose up -d` で以下が立ち上がること。 + +| サービス | 内容 | ポート | +| --- | --- | --- | +| `app` | Next.js (Console / Viewer / API) | 3000 | +| `db` | PostgreSQL 17 | 5432 | + +- 開発時は `pnpm dev` でも同等に動くこと(DB のみ Docker で立てる運用を許容)。 +- 環境変数は `.env.example` に列挙し、起動時に Zod で検証して欠落を即座に落とす。 + +| 変数 | 用途 | +| --- | --- | +| `DATABASE_URL` | PostgreSQL 接続文字列 | +| `NEXT_PUBLIC_APP_URL` | Viewer が SSE を張る先 | + +`chros-websock` / `chros-score` のコンテナは廃止する。 + +## 10. 現行構成からの移行 + +| 現行 | 移行後 | 備考 | +| --- | --- | --- | +| `chros/` (Next.js) | `chros/`(移動なし) | App Router 構造はおおむね流用。§2.1 | +| `chros-websock/` (Express + socket.io) | 廃止 | SSE を `app/api/stream` に実装 | +| `chros-score/` (FastAPI + lupa) | 廃止 | `packages/scoring` に TS で再実装 | +| `chros/docs/overview.md` | `docs/requirements.md` | 集約済み | +| `socket.io-client` 依存 | 削除 | `EventSource` を使用 | +| `display/layout.tsx` の `router.push` 切替 | 状態による切替 | 白画面と再接続を避けるため | +| Prisma スキーマ | 大会・ステージ・グループ・規則版・対戦事実・裁定・結果履歴を追加 | §4。現行の原因情報だけでは再計算できない場合がある | +| 勝敗をコードに直書き | `ScoreRule` のグラフとして外部化 | §5 | + +### 10.1 移行の順序 + +**スコア計算まわり(§5)は設計を継続中のため、そこに依存しない部分を先に進める。** +以下の2トラックは独立して動かせる。 + +**トラックA — 土台と画面(先行)** + +1. pnpm workspace 化と `packages/shared` の切り出し +2. イベント定義と `Snapshot` 型の確定(§6.2)。スコアの中身は `slots` 経由で扱うため、 + 計算規則が未確定でもこの型は決められる +3. 進行まわりの Prisma スキーマ(大会・ステージ・グループ・参加者・試合)とマイグレーション。 + 1/2対戦、初戦の先後決定、再試行、裁定待ちを扱う。対戦構成と出典を持つ `RuleProfile` の + 骨格は先行してよいが、**`ScoreRule` と計算結果の確定保存はトラックB**とする +4. API Route Handlers + SSE の実装、`chros-websock` の削除 +5. Viewer の SSE 化と各画面の実装(§8)、`socket.io-client` の削除 +6. Console の Server Actions 化(進行操作・画面切り替え)。釧路の総当たり/グループ予選から + 本戦への接続、じゃんけん結果の入力と側交換を扱う。順位自動計算が入る前の通過者は + 運営確認の手動記録とし、未確認の順位を自動生成しない +7. `chros-score` の削除と docker-compose の整理 + +**トラックB — スコア計算(並行して設計を継続)** + +8. 釧路の集計式を確認し、対戦終了・再試合の条件表、入力型と `ScoreRuleGraph` を確定する +9. `Score` / 裁定履歴 / `ScoreRule` / `MatchResult` と規則プロファイルの計算部分を確定・移行する +10. `packages/scoring` の評価器・検証器とステージの順位計算を実装し、 + [受け入れ例](./tournament-rules.md) と運営確認したケースで検証する +11. ノードエディタ UI + +トラックAを進めるあいだ、スコアは**固定のダミー実装**で代替してよい。 +§8.2 のとおり Viewer はスロット定義に従って描画するため、 +スロットが `{ name: 'score' }` 1つだけのダミーを返しておけば画面は完成させられる。 +評価器が乗った時点で差し替わり、Viewer 側の改修は不要になる。 + +ノードエディタ UI(手順11)は工数が大きい。先に手順10までを済ませ、 +**JSON を直接投入して評価できる状態**を作る。こうすればエディタが未完成でも大会運用は成立する。 + +## 11. 未決事項 + +未決のうち**着手前に決める必要があるもの**と、**後回しでよいもの**を分けて記す。 + +### 11.1 スコア計算まわり(設計継続中・トラックB) + +- 釧路の前後半集計の具体式と、当該年度の追加細則(§5.8) +- 終了事象・裁定・規則・結果履歴のスキーマ確定 +- 全国交流大会の開催回と詳細細則の確認、語彙の再検証 +- `min`/`max` と `フラグ判定` の要否。確認済みの計算に不要なら後回しにする + +### 11.2 順位計算 — 進行方式は確認済み、集計規則は要確認 + +釧路の総当たり予選→本戦トーナメント、グループ予選時の各組上位2名、 +旭川のBot予選→決勝トーナメントは確認できた。 +一方、試合の勝者が分かるだけでは予選順位や通過者は確定しない。 +順位規則はステージの `rankingPolicy` に版を持つデータとして置き、採点グラフとは分離する。 + +- 要確認: 釧路の予選順位の集計・同順位処理、通常予選の通過人数、グループの組数・割り当て、 + 本戦への配置とシード。勝数、勝率、直接対決などを確認前に採用しない。 +- 要確認: 旭川のBot戦が同点の場合、予選得点が同じ場合、通過人数・組み合わせ。 +- 設計として確定: 再試合で無効になった試行を集計に含めず、Botを選手順位から除く。 + グループ予選は組ごとに集計し、進出境界の同順位が未解決なら通過確定を止める。 +- 設計として確定: 通過者と本戦の割り当ては根拠となる順位版とともに保存する。 + 元の試合の訂正で順位が変われば再確認し、既存の本戦を黙って書き換えない。 + +トラックA(§10.1)の保存・入力・表示の骨格は、これらの確認を待たず進められる。 +`standings` は暫定/確定とグループを表示できる構造を先に用意し、未確認の規則では自動順位を出さない。 + +### 11.3 後回しでよいもの + +- CHaser 本体との連携有無(現状は手入力前提。将来ログ取り込みを行うか) +- 大会後の記録公開の形式(静的書き出しか、システムを稼働させ続けるか)。 + 稼働させ続ける場合は認証の要否を再検討する(§1.3) +- 画面遷移の演出、出力スロットの表示上の優劣(§8.2) diff --git a/docs/tournament-rules.md b/docs/tournament-rules.md new file mode 100644 index 0000000..5de1eea --- /dev/null +++ b/docs/tournament-rules.md @@ -0,0 +1,210 @@ +# 対象大会のルール調査と設計への反映 + +確認日: **2026-09-18**。主対象は **U-16プログラミングコンテスト釧路大会の競技部門**。 +同日の追加説明(U2)により、**現在のシステム対象は対人戦のみ**とする。 +本書のBot戦の記述は資料調査の記録として残し、実装対象とはしない。 +実際に採用した試作の計算と自主判断は [試作提案](./prototype-proposal.md) を優先する。 +旭川大会・北海道大会、および U-16 CHaser 全国交流大会を追加できる設計とする。 +ここでの「全国大会」は U-16 CHaser 全国交流大会を想定する。全情研の高校生向け大会や +CHaserOnline の規則を、そのまま全国交流大会の規則として流用しない。 + +本書は公開資料・運営経験者から確認できた事実と、そこから導く CHroS の設計判断を分けて記す。 +**公開資料の最新版を確認したことと、その版が対象年度の大会に正式適用されることは別**である。 +大会作成時に採用版と大会固有の細則を確定する。実装方針は +[技術要件](./technical-requirements.md)、利用者向け要件は [要件定義](./requirements.md) を参照。 + +## 1. 参照資料と適用範囲 + +| ID | 一次資料 | 確認した版・範囲 | 本設計での位置づけ | +| --- | --- | --- | --- | +| K1 | [釧路大会公式サイト](https://procon.946oss.net/)・[競技部門](https://procon.946oss.net/comp/) | 2026年大会の案内。競技部門ページは予選を実施しない旨を掲載 | 現行進行についてU1と食い違うため、CHroSの進行設計はU1を優先する | +| K2 | [CHaserRuleGuide-Pub](https://github.com/KPC-U16/CHaserRuleGuide-Pub)・[公開ルール細則](https://kpc-u16.github.io/CHaserRuleGuide-Pub/) | 本文の最終改訂表示は2024-11-11。確認時の `main` は `0d86d4c175321f80c18e5ea88a4fe334c90b81bd`(2024-11-17) | 釧路の得点・勝敗・再試合・マップ条件の根拠。2026年度の追加細則の有無は別途確認 | +| K3 | [CHaserDoc-Pub](https://github.com/KPC-U16/CHaserDoc-Pub) | `ef47564744e6c7c59f93d0a0041efc01fd6bb366`(2022-07-23)の `CHaserチートシートVer1.0.1.pdf` | 命令と周辺情報の読み方の資料。採点規則・大会進行規則の根拠にはしない | +| A1 | [旭川大会公式サイト](https://www.procon-asahikawa.org/)・[競技部門の説明](https://www.procon-asahikawa.org/contest.html) | 2026年案内は北海道大会のルールを使用すると明記。Bot予選と決勝トーナメントを案内 | 旭川向けプロファイルの構成と採用ルールの根拠 | +| A2 | [北海道大会ルールブック v3.1.0](https://www.procon-asahikawa.org/files/U16Procon-RuleBookV310.pdf) | 2026-09-10改訂、全5ページ。p.1–3は共通規則、p.4はBot戦/トーナメント戦、p.5は変更履歴 | 旭川/北海道系の計算・対戦回数・マップ条件の根拠 | +| A3 | [2025年北海道大会の開催概要](https://sapporo.u16procon.org/overview_2025do/) | v3.0.1を採用し、初期配置の罠を使用しない追加条件、サーバーv0.6.3を指定 | 同じ地域・系列でも年度と大会固有設定を分ける必要がある実例。2026年への流用はしない | +| N1 | [全国交流大会の開催案内](https://yume-m.org/event/314/)・[第2回の開催報告](https://yume-m.org/info/) | 2025年の案内に予選と決勝トーナメント、報告に地区大会代表の参加を記載 | 大会の存在と複数ステージの確認。採点式・同点処理は確認できない | +| N2 | [主催協会 A.JITEP](https://www.ajitep.org/)・[協会通信一覧](https://www.ajitep.org/archive/news) | 公開案内を調査したが、全国交流大会の詳細な競技細則を本調査では確認できず | 全国用の採点プリセットは未確定とする | +| U1 | 本リポジトリの依頼者による釧路大会の運用説明(2026-09-18、本設計更新時) | 元実行委員長としての実運用を説明し、現在も変更されていない旨を確認 | 釧路の予選・本戦、グループ予選、2対戦、じゃんけんによる初戦の先後決定の根拠。公開資料とは別の運用情報として保存 | +| U2 | 同依頼者による実装方針と特殊ポイントの追加説明(2026-09-18) | 対人戦に限定。得点比較を基本とし、切断やPutなどによる勝利を同一視するため特殊ポイントと呼ぶ。防げる敗因の未対策を重く見る背景を説明 | 記録・計算・集計と掲示の基本操作を優先。勝因を保存しつつ特殊Pに抽象化する根拠。Bot対応や汎用計算エディターより動く試作を先に作る | + +K2 の本文は [コミット固定リンク](https://github.com/KPC-U16/CHaserRuleGuide-Pub/blob/0d86d4c175321f80c18e5ea88a4fe334c90b81bd/docs/index.md) で参照する。 +K3 の資料は [コミット固定のファイル](https://github.com/KPC-U16/CHaserDoc-Pub/blob/ef47564744e6c7c59f93d0a0041efc01fd6bb366/CHaser%E3%83%81%E3%83%BC%E3%83%88%E3%82%B7%E3%83%BC%E3%83%88Ver1.0.1.pdf) を確認した。 +A2 の取得ファイルの SHA-256 は `f2d8fdd7b3e88cb0c823004bf157f9acd9018e421a2d92b276e1c99dacdaf9a1`。 +更新されるWebページの確認日、資料の改訂日、取得ファイルの識別子を混同しない。 + +## 2. 用語と比較 + +CHroS では **対戦 = CHaserを1回開始して終了する単位**、**試合 = 対戦結果をまとめて +勝ち上がりや予選得点を確定する単位**とする。釧路の試合は先後交代の2対戦だが、 +旭川のBot戦は1対戦で1試合になる(K2 §0、A2 p.4)。 + +| 観点 | 釧路(K2・U1) | 旭川/北海道系(A1・A2) | 全国交流大会(N1・N2) | +| --- | --- | --- | --- | +| 大会構成 | 総当たり予選→本戦トーナメント。多人数時はグループ予選、各組上位2名が進出(U1) | Bot予選から対人の決勝トーナメントへ | 2025年は予選+決勝。詳細は未確認 | +| 対戦回数 | COOL/HOTを交代する2対戦 | Bot戦はBotがHOTの1対戦。対人戦は原則同一マップで先後交代の2対戦 | 未確認 | +| 初戦の先後 | じゃんけんで決定し、2戦目で交代(U1) | Bot戦は参加者COOL。対人戦の初戦決定手順は別途確認 | 未確認 | +| 試合の比較基準 | 相手の自滅等による勝利、Put勝ち、アイテム数優勢の順 | Bot戦は換算得点。対人戦は対戦勝利数、補正後アイテム数合計の順 | 未確認 | +| 再試合 | 同点以外にも発生。旧試合を無効にして新マップで両方やり直す | 対人戦は比較後も同点ならマップを変更して再試合 | 未確認 | +| ターン上限 | 100–240の偶数 | 100–240。偶数限定の記載はない | 未確認 | +| マップ事前公開 | 対戦開始までは非公開 | Bot戦・対人戦ともプログラム選定のためレビューを実施 | 未確認 | + +## 3. 釧路を主対象にした確認事項 + +### 3.1 勝敗と採点 + +K2 §1-2・§1-3では、終了の原因を区別して扱う。相手への直接Putと封鎖による勝利、 +規定ターン終了時のアイテム優勢、相手の自滅・場外移動・クライアント異常などを記録する。 +直接Putの勝利と自分の封鎖負けが同時に成立する場合は、直接Putを優先する。 + +試合結果の比較順は **相手の自滅等 → Put勝ち → アイテム数優勢**。 +ただし公開細則は、各分類の数値的な重み、前後半の合算方法、アイテムを比較する範囲を +数式として定義していない。これは公開資料K2から読み取れた範囲である。 + +その後のU2により、このシステムでは切断やPutによる勝利を同じ特殊ポイントとして扱う。 +**分類別の勝利回数を優先比較する以前の設計候補は、現在の試作では採用しない。** +各勝因の記録を残しながら、該当する勝者へ1特殊Pを付ける。 +試作では「2戦の特殊P合計 → 得点合計」を採用した。この優先順位は実装者の判断であり、 +公開資料の文言と同一だと主張せず、依頼者に確認してもらう項目にする。 + +### 3.2 再試合は採点に優先する + +K2 §1-4に基づき、応答タイムアウト、サーバー側の続行不能、審判が必要とした場合などは、 +単なる自滅負けとして確定させない。意図的なエラーやその他のクライアント異常については、 +審判が再試合とするか、自滅として扱うかを記録する。 + +再試合では元の試合結果を順位・勝ち上がり計算から除外し、新マップで前後半とも再実施する。 +障害が後半に発生しても、前半の得点だけを次の試合に持ち越さない。 +元データは監査用に保存し、「無効化」と「削除」を分ける。 + +### 3.3 進行とマップ + +U1により、釧路の現行運用は **予選と本戦の2段階** とする。 +通常は予選で参加者同士の総当たりを行い、本戦はトーナメントで実施する。 +人数が多い場合にグループを設け、各グループ内で総当たりを行い、上位2名ずつを本戦へ送る +運用実績がある。グループ化する人数の閾値、グループ数、通常予選の通過人数は固定しない。 + +各試合は原則2対戦。初戦のCOOL/HOTはじゃんけんで決め、その決定結果を入力する。 +2戦目は同じ参加者の先後を交換し、2対戦の結果を集計して細則の順に試合勝者を決める。 +じゃんけんの勝者が先攻になるのか選択権を得るのかは今回の説明では確定しないため、 +CHroSは勝者から先攻を推定せず、**確定した初戦の先後割り当て**を記録する。 +大会の組み合わせ抽選と、個々の試合の先後決定は別の操作とする。 + +K1の「予選を実施しない」という記載とU1の現行運用は一致しない。 +記載の経緯を推測せず、この設計では依頼者から確認したU1を進行の根拠として採用する。 + +K2 §1-1・§2では、応答制限時間の具体値は別途設定する。 +マップ寸法は15×17または21×17で、配置の対称性、奇数個のアイテムと中央アイテム、 +外周、開始位置の観測範囲、上下9マス目のブロック配置に条件がある。 +これらは採点式とは別のマップ/運用設定に属する。 + +CHroS の今回の試作はマップ生成・管理やクライアント実行を行わない。 +マップ識別子・版・寸法・使用条件の記録は将来設計とし、非公開マップの内容は保存・配信しない。 +確認待ちなのは予選順位の決め方と同順位処理、通常予選の通過枠、本戦への配置方法、 +ターン上限の実値、応答制限時間、再試合の反復上限など。 + +## 4. 旭川・北海道系で必要になる差分 + +### 4.1 Bot予選 + +A2 p.4では参加者がCOOL、BotがHOTの1対戦。参加者の取得アイテム数を `I`、 +終了時の残りターン数を `R` とすると、勝ちは `3I + R`、負けは `3I - R`。 +**時間(秒)ではなく残りターン数**を使い、負の得点も有効とする。 +Botは対戦相手として登録するが、参加選手の予選順位には含めない。 + +Bot戦自体の同点時処理、同得点の予選順位、通過人数・シード配置の細部は +A1・A2だけでは確定しない。対人戦用の再試合規則を自動で当てはめない。 + +### 4.2 対人トーナメント + +A2 p.4では2対戦の勝利数が第1基準。勝利数が並んだ場合に、各対戦の補正値を合計して比較する。 +補正値は、その参加者について次のように求める。 + +| 対戦の結果・敗因 | 比較用アイテム値 | +| --- | --- | +| 相手のPut/相手からの封鎖による敗北 | `0` | +| 自らブロックへ移動/自分による封鎖/自分に起因する通信エラー等で敗北 | `-R` | +| 上記の敗北補正が適用されない場合 | `I` | + +負けた側の生のアイテム数を上書きせず、補正値を計算結果として別に持つ。 +特に自滅時は **`I - R` ではなく `0 - R`**。合計も同じなら新マップで再試合となる。 +Bot予選の `3I ± R` を対人戦へ適用してはいけない。 + +共通規則(A2 p.1)には、Putによって勝利条件と敗北条件が同時に成立する場合の勝利優先もある。 +一方、判定できない同時事象やサーバー障害は、釧路の細則を流用せず審判判断として保留する。 + +### 4.3 マップ・運用条件 + +A2 p.1–3は15×17のマップ、開始位置・ブロック配置の制約、罠を初期配置する際の事前周知、 +マップレビューを定める。クライアントの選択・持ち込み等は運営設定として記録する。 +v3.1.0で追加されたAI利用の申告なども採点とは別の参加手続きであり、 +CHroS の得点計算エンジンがプログラムを検査する要件にはしない。 + +A3のように、年度固有のルール版・罠の有無・CHaserサーバー版を組み合わせるため、 +`asahikawa` という地域名だけでは規則を識別できない。 + +## 5. 全国交流大会の扱い + +N1から予選と決勝の存在は確認できるが、今回参照した公開一次資料では、 +採点式、対戦回数、同点時処理、マップ条件を確定できなかった。 +北海道大会から出場することや同じCHaserを使うことは、規則が同じである根拠にならない。 + +全国用プロファイルは **未検証の草案** とし、開催回・開催日・主催者提供の細則・追加裁定を +入手してから有効化する。Bot予選、1/2対戦、ステージ別規則、順位・勝ち上がり設定を +差し替えられる構造を用意することを、現時点での全国対応の範囲とする。 + +## 6. 設計判断 + +以下は公開資料調査時の将来設計。U2による現在の対象範囲と、試作の自主判断は +[要件定義](./requirements.md) と [試作提案](./prototype-proposal.md) を参照する。 + +以下は公式規則そのものではなく、上記の差分を失わず扱うための CHroS の方針。 + +1. **大会・年度・ステージごとに規則を固定する。** `RuleProfile` に資料の参照版、 + 対戦構成、採点規則、順位規則、マップ・再試合方針をまとめる。地域名でコードを分岐しない。 +2. **事実・裁定・計算結果を分ける。** 生のアイテム数、残りターン数、終了事象、行為者と + 影響を受けた側を残し、裁定と補正値は別に保存する。`blocked` の1フラグへ潰さない。 +3. **対戦数を固定しない。** `Match` は試合、`Score` はその中の1対戦の記録とし、 + 対戦番号とCOOL/HOT割り当てを持つ。Bot戦に架空の後半を作らない。 +4. **採点前に再試合・審判待ちを扱う。** 未入力、未開催、無効試合、引き分けを区別する。 + 理由と対象範囲を記録し、無効な結果を順位や次の対戦へ流さない。 +5. **順位規則と1試合の勝敗規則を分離する。** Bot予選の得点順位、対人戦の勝ち上がり、 + 総当たりの順位を別に設定する。釧路のグループ予選では組ごとに順位を計算し、上位2名を + 通過候補とする。2位境界が同順位なら、未解決のまま通過者を確定しない。 +6. **表示はステージの規則から構成する。** 生アイテム、補正値、勝利数、再試合の理由を + 必要に応じて区別し、Bot戦を前半/後半として表示しない。変更時はSSEで一貫した状態を送る。 + +### 6.1 受け入れ例 + +以下の数値は設計検証用に作成した例であり、大会の実際の試合記録ではない。 + +| ID | 入力・操作 | 期待する結果 | 根拠 | +| --- | --- | --- | --- | +| K-01 | 釧路で直接Putの勝利と自身の封鎖負けが同時成立 | 直接Putによる勝利を優先 | K2 §1-2(5) | +| K-02 | 釧路の後半に応答タイムアウト | 前半も含め旧試合は集計対象外。新マップで両対戦を再実施 | K2 §1-4 | +| K-03 | 釧路で意図的なエラーを認定 | 裁定未登録なら保留。再試合/自滅扱いの裁定後に分岐 | K2 §1-4(1-d, 2) | +| K-04 | 自滅による勝ちとPutによる勝ちを入力 | 元の勝因を保持し、試作では同じ1特殊Pとして2戦を集計する | U2と試作判断D01 | +| K-05 | 初戦をA=HOT、B=COOLとしてじゃんけん結果を登録 | 2戦目はA=COOL、B=HOT。全試合でAを先攻に固定しない | U1 | +| K-06 | グループ予選の全試合・順位を確定 | 各組上位2名を本戦へ渡す。組を跨いで予選対戦を作らない | U1 | +| K-07 | グループ内で2位境界の同順位が未解決 | 勝手にID順等で選ばず、通過確定を保留する | U1から導く設計 | +| A-01 | Bot予選、`I=8, R=20` | 勝利なら44、敗北なら4。必要対戦は1回だけ | A2 p.4 | +| A-02 | Bot予選、`I=2, R=20`で敗北 | -14。0への切り上げはしない | A2 p.4 | +| A-03 | 対人戦、敗北時の生アイテム7・残り30 | 相手Put負けなら補正0、自滅負けなら補正-30。生アイテム7は保持 | A2 p.4 | +| A-04 | ターン上限でAが10対8、次もAが9対7で勝利 | Aが2勝0敗で勝ち。アイテム比較は第2基準 | A2 p.1, p.4 | +| A-05 | 前半AがPut勝ちして6個、Bは補正0。後半BがPut勝ちして8個、Aは補正0 | 1勝1敗の後、補正合計6対8でB勝ち | A2 p.4 | +| A-06 | 前後半それぞれPut勝ちし、勝った対戦のアイテムが両者6個 | 1勝1敗、補正合計6対6。マップを変更して再試合 | A2 p.4 | +| C-01 | 新しい規則版で再計算 | 元の事実・旧結果を残し、新版と裁定を追跡できる | 設計判断 | +| C-02 | 釧路でマップ未公開のままViewer接続・再接続 | snapshot/差分のいずれにも未公開マップ内容を含めない | K2 §2-2から導く設計 | +| N-01 | 全国の詳細規則が未登録 | 他大会の規則で自動採点せず、草案として保持 | 本書§5 | + +### 6.2 試作を評価する際に確認する項目 + +- 釧路: §1-3の前後半集計・重み・比較アイテムの範囲、当該年度の追加細則、 + 予選の順位・同順位処理、通常予選の通過枠、本戦への配置、グループ構成、 + ターン上限・応答制限時間の実値、再試合を繰り返した場合の扱い。 +- 旭川: Bot対戦が同点の場合の扱い、予選同得点の順位決定、通過枠と組み合わせ、 + 未定義の同時事象や障害の処理。規定ターン終了時のアイテム同数も勝者を推定しない。 +- 全国交流大会: 対象の開催回と、採用する細則・年度固有の運用一式。 + +これらは調査で不足している具体的な情報であり、全機能の着手を止めるものではない。 +依頼者の指示に従って自主判断を記録し、対人戦の保存・入力・集計・掲示までを試作した。 diff --git a/package.json b/package.json index de0ae82..9fbf154 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,13 @@ "dev": "pnpm --filter chros dev", "build": "pnpm --filter chros build", "start": "pnpm --filter chros start", - "test": "pnpm -r test" + "test": "pnpm -r test", + "typecheck": "tsc --noEmit --noUnusedLocals --noUnusedParameters -p packages/shared && tsc --noEmit --noUnusedLocals --noUnusedParameters -p packages/scoring && tsc --noEmit --noUnusedLocals --noUnusedParameters -p chros", + "format": "prettier --write \"chros/{app,components,lib}/**/*.{ts,tsx,css}\" \"packages/*/src/**/*.ts\" \"scripts/**/*.mjs\"", + "format:check": "prettier --check \"chros/{app,components,lib}/**/*.{ts,tsx,css}\" \"packages/*/src/**/*.ts\" \"scripts/**/*.mjs\"" }, "devDependencies": { + "prettier": "3.9.8", "typescript": "^5.6.0" } } diff --git a/packages/scoring/src/index.test.ts b/packages/scoring/src/index.test.ts index e14380e..13fbb31 100644 --- a/packages/scoring/src/index.test.ts +++ b/packages/scoring/src/index.test.ts @@ -1,22 +1,389 @@ import { describe, expect, it } from 'vitest'; -import { aggregateMatch, evaluateHalf } from './index'; +import { + commandSchema, + stateSchema, + type ControlState, + type Game, + type Match, +} from '@chros/shared'; +import { evaluateGame, evaluateMatch, standings } from './index'; +import { + applyCommand, + createInitialState, + emptyMatch, + suggestedQualifiers, +} from '../../../chros/lib/tournament'; -describe('evaluateHalf', () => { - it('素点をそのまま score スロットに置く', () => { - expect(evaluateHalf({ rawScore: 12 })).toEqual({ score: 12 }); - }); +const points = (scoreA: number, scoreB: number, number: 1 | 2 = 1): Game => ({ + number, + scoreA, + scoreB, + reason: 'points', + specialWinner: null, + remainingTurns: 0, +}); +const special = (reason: Game['reason'], winner: 'a' | 'b' = 'a', number: 1 | 2 = 1): Game => ({ + ...points(2, 90, number), + reason, + specialWinner: winner, + remainingTurns: 37, +}); +const matchWith = (...games: Game[]): Match => ({ + ...emptyMatch('test', 'a', 'b', 'A'), + started: true, + firstCool: 'a', + games, }); -describe('aggregateMatch', () => { - it('ハーフごとのスロット値をスロット名で合算する', () => { - expect(aggregateMatch([{ score: 3 }, { score: 4 }])).toEqual({ score: 7 }); +describe('対人戦の判定', () => { + it('1戦目で勝者を確定せず、2戦の合計で判定する', () => { + expect(evaluateMatch(matchWith(points(100, 0)), 'kushiro').winnerId).toBeNull(); + const result = evaluateMatch(matchWith(points(10, 20), points(50, 10, 2)), 'kushiro'); + expect(result.winnerId).toBe('a'); + expect(result.a.points).toBe(60); + expect(result.b.points).toBe(30); + }); + it.each(['put', 'surround', 'disconnect', 'illegal-move', 'self-surround'] as const)( + '%s は同じ1特殊Pとして扱い、得点より優先する', + (reason) => { + const result = evaluateMatch(matchWith(special(reason), points(1, 100, 2)), 'kushiro'); + expect(result.a.special).toBe(1); + expect(result.winnerId).toBe('a'); + expect(result.a.raw).toBe(3); + expect(result.b.raw).toBe(190); + }, + ); + it('双方1特殊Pなら得点で決め、完全同値なら再試合にする', () => { + const match = matchWith(special('put'), special('disconnect', 'b', 2)); + expect(evaluateMatch(match, 'kushiro').winnerId).toBe('b'); + match.games[1]!.scoreA = 90; + match.games[1]!.scoreB = 2; + expect(evaluateMatch(match, 'kushiro')).toMatchObject({ + winnerId: null, + tied: true, + status: 'replay', + }); + }); + it('得点による同点戦に特殊Pやゲーム勝数を付けない', () => { + expect(evaluateGame(points(15, 15), 'kushiro')).toMatchObject({ + winner: null, + a: { special: 0, wins: 0 }, + b: { special: 0, wins: 0 }, + }); + }); + it('旭川ではゲーム勝数、次に換算得点で決める', () => { + const match = matchWith(special('put'), { ...points(1, 0, 2) }); + expect(evaluateMatch(match, 'asahikawa')).toMatchObject({ + winnerId: 'a', + a: { wins: 2 }, + b: { points: 0 }, + }); + expect(evaluateMatch(matchWith(points(10, 9), points(0, 30, 2)), 'asahikawa').winnerId).toBe( + 'b', + ); }); + it.each(['put', 'surround'] as const)('旭川の%s敗者は0点、素点は保持する', (reason) => { + expect(evaluateGame(special(reason), 'asahikawa').b).toMatchObject({ raw: 90, points: 0 }); + }); + it.each(['disconnect', 'illegal-move', 'self-surround'] as const)( + '旭川の%s敗者は残ターン分の負点にする', + (reason) => { + expect(evaluateGame(special(reason), 'asahikawa').b).toMatchObject({ raw: 90, points: -37 }); + }, + ); + it('参加者を左右反転しても勝者と得点の意味が変わらない', () => { + for (let i = 0; i < 30; i++) { + const original = matchWith(points(i, 30 - i), { + ...special(i % 2 ? 'put' : 'disconnect', i % 3 ? 'a' : 'b', 2), + scoreA: 4, + scoreB: 5, + }); + const mirrored = { + ...original, + a: original.b, + b: original.a, + games: original.games.map((g) => ({ + ...g, + scoreA: g.scoreB, + scoreB: g.scoreA, + specialWinner: + g.specialWinner === 'a' + ? ('b' as const) + : g.specialWinner === 'b' + ? ('a' as const) + : null, + })), + }; + for (const profile of ['kushiro', 'asahikawa'] as const) { + expect(evaluateMatch(original, profile).winnerId).toBe( + evaluateMatch(mirrored, profile).winnerId, + ); + expect(evaluateMatch(original, profile).a).toEqual(evaluateMatch(mirrored, profile).b); + } + } + }); +}); - it('一方にしか現れないスロットも欠落させない', () => { - expect(aggregateMatch([{ score: 3 }, { score: 1, bonus: 2 }])).toEqual({ score: 4, bonus: 2 }); +function tournament(groups = 2, playersPerGroup = 3) { + let state = createInitialState(false); + state.players = Array.from({ length: groups * playersPerGroup }, (_, i) => ({ + id: `p${i}`, + name: `参加者${i}`, + affiliation: '', + group: String(Math.floor(i / playersPerGroup) + 1), + })); + return applyCommand(state, { type: 'generate-qualifying' }); +} +function completeMatch(state: ControlState, id: string, flip = false) { + const match = state.matches.find((m) => m.id === id)!; + if (!match.started) + state = applyCommand(state, { type: 'start-match', matchId: id, firstCool: match.a! }); + for (const n of [1, 2] as const) + state = applyCommand(state, { + type: 'save-game', + matchId: id, + game: points(flip ? 1 : 20, flip ? 20 : 1, n), + correction: 'テスト訂正', + }); + return state; +} +function completedQualifying(groups = 2) { + let state = tournament(groups); + for (const match of state.matches) state = completeMatch(state, match.id); + return state; +} +function withFinals(groups = 2) { + const state = completedQualifying(groups); + return applyCommand(state, { + type: 'generate-finals', + qualifiers: suggestedQualifiers(state), + justification: '', }); +} - it('ハーフが無ければ空を返す', () => { - expect(aggregateMatch([])).toEqual({}); +describe('大会進行と入力保護', () => { + it('サンプルが保存スキーマに適合し、途中の1戦が順位に混ざらない', () => { + const state = stateSchema.parse(createInitialState()); + expect(state.matches).toHaveLength(12); + expect(standings(state).reduce((sum, r) => sum + r.played, 0)).toBe(20); + }); + it('各組で全ペアを1回ずつ作成する', () => { + const state = tournament(2, 4); + expect(state.matches).toHaveLength(12); + expect(new Set(state.matches.map((m) => [m.a, m.b].sort().join('/'))).size).toBe(12); + expect( + state.matches.every( + (m) => + state.players.find((p) => p.id === m.a)!.group === + state.players.find((p) => p.id === m.b)!.group, + ), + ).toBe(true); + }); + it('先攻の選択と第1戦の記録を省略できない', () => { + const state = tournament(), + id = state.matches[0]!.id; + expect(() => + applyCommand(state, { type: 'save-game', matchId: id, game: points(1, 2), correction: '' }), + ).toThrow('先攻'); + expect(() => + applyCommand(state, { type: 'start-match', matchId: id, firstCool: 'outside' }), + ).toThrow('対戦者'); + const started = applyCommand(state, { type: 'start-match', matchId: id, firstCool: 'p0' }); + expect(() => + applyCommand(started, { + type: 'save-game', + matchId: id, + game: points(1, 2, 2), + correction: '', + }), + ).toThrow('第1戦'); + }); + it('特殊勝因の勝者未選択・得点戦の特殊勝者指定を拒否する', () => { + let state = tournament(); + const id = state.matches[0]!.id; + state = applyCommand(state, { type: 'start-match', matchId: id, firstCool: 'p0' }); + expect(() => + applyCommand(state, { + type: 'save-game', + matchId: id, + game: { ...special('put'), specialWinner: null }, + correction: '', + }), + ).toThrow('一致'); + expect(() => + applyCommand(state, { + type: 'save-game', + matchId: id, + game: { ...points(1, 2), specialWinner: 'a' }, + correction: '', + }), + ).toThrow('一致'); + }); + it('負の素点・小数・大きすぎる値をAPIスキーマで拒否する', () => { + for (const scoreA of [-1, 1.2, 1000]) + expect( + commandSchema.safeParse({ + type: 'save-game', + matchId: 'm', + game: { ...points(1, 2), scoreA }, + correction: '', + }).success, + ).toBe(false); + }); + it('訂正理由を必須にし、変更前の値を履歴に残して再集計する', () => { + let state = tournament(); + const id = state.matches[0]!.id; + state = completeMatch(state, id); + expect(() => + applyCommand(state, { type: 'save-game', matchId: id, game: points(0, 90), correction: '' }), + ).toThrow('訂正理由'); + const corrected = applyCommand(state, { + type: 'save-game', + matchId: id, + game: points(0, 90), + correction: '用紙との照合', + }); + expect(corrected.audit.at(-1)!.detail).toContain('"scoreA":20'); + expect(standings(corrected).find((r) => r.player.id === 'p1')!.wins).toBe(1); + expect(state.matches[0]!.games[0]!.scoreA).toBe(20); + }); + it('再試合は両戦を無効にして履歴を保存し、順位から除く', () => { + let state = tournament(); + const id = state.matches[0]!.id; + state = completeMatch(state, id); + state = applyCommand(state, { type: 'replay', matchId: id, reason: 'サーバー不具合' }); + expect(state.matches[0]).toMatchObject({ + games: [], + started: false, + firstCool: null, + attempt: 2, + }); + expect(state.matches[0]!.previousAttempts[0]!.games).toHaveLength(2); + expect(standings(state).every((r) => r.played === 0)).toBe(true); + }); + it('同点の試合は順位に算入せず、本戦へ進めない', () => { + let state = tournament(1, 2); + const id = state.matches[0]!.id; + state = completeMatch(state, id); + state = applyCommand(state, { + type: 'save-game', + matchId: id, + game: points(1, 20, 2), + correction: '同点に訂正', + }); + expect(standings(state).every((r) => r.played === 0)).toBe(true); + expect(() => + applyCommand(state, { type: 'generate-finals', qualifiers: ['p0', 'p1'], justification: '' }), + ).toThrow('すべての予選'); + }); + it('本戦作成後の予選修正と試合開始後の計算方式変更を拒否する', () => { + const state = withFinals(); + expect(() => + applyCommand(state, { + type: 'save-game', + matchId: state.matches[0]!.id, + game: points(0, 90), + correction: '変更', + }), + ).toThrow('本戦'); + expect(() => + applyCommand(state, { + type: 'settings', + title: state.title, + subtitle: '', + profile: 'asahikawa', + advancePerGroup: 2, + }), + ).toThrow('計算方式'); + }); + it('2組の上位2名は準決勝で別組と戦い、勝者を決勝に送る', () => { + let state = withFinals(); + const semi = state.matches.filter((m) => m.stage === 'finals' && m.round === 1); + expect(semi).toHaveLength(2); + expect( + semi.every( + (m) => + state.players.find((p) => p.id === m.a)!.group !== + state.players.find((p) => p.id === m.b)!.group, + ), + ).toBe(true); + for (const match of semi) state = completeMatch(state, match.id); + const final = state.matches.find((m) => m.round === 2)!; + expect([final.a, final.b]).toEqual(semi.map((m) => m.a)); + }); + it('6名の本戦は不戦進出2枠を作り、自動で次戦へ進める', () => { + const state = withFinals(3); + const byes = state.matches.filter((m) => m.bye); + expect(byes).toHaveLength(2); + for (const bye of byes) + expect( + state.matches.find((m) => m.sources.includes(bye.id))![ + state.matches.find((m) => m.sources.includes(bye.id))!.sources[0] === bye.id ? 'a' : 'b' + ], + ).toBe(evaluateMatch(bye, state.profile).winnerId); + }); + it('次戦が始まった後に勝者が変わる修正を拒否し、元状態を保つ', () => { + let state = withFinals(); + const semi = state.matches.filter((m) => m.stage === 'finals' && m.round === 1); + for (const match of semi) state = completeMatch(state, match.id); + const final = state.matches.find((m) => m.round === 2)!; + state = applyCommand(state, { type: 'start-match', matchId: final.id, firstCool: final.a! }); + const before = JSON.stringify(state); + expect(() => + applyCommand(state, { + type: 'save-game', + matchId: semi[0]!.id, + game: points(0, 999), + correction: '変更', + }), + ).toThrow('次の試合'); + expect(JSON.stringify(state)).toBe(before); + }); + it('同順位の進出裁定には理由が必要で、重複と下位の繰上げを拒否する', () => { + let state = tournament(1, 3); + for (const [i, match] of state.matches.entries()) + state = completeMatch(state, match.id, i === 1); + expect(standings(state).every((r) => r.rank === 1 && r.tied)).toBe(true); + expect(() => + applyCommand(state, { type: 'generate-finals', qualifiers: ['p0', 'p1'], justification: '' }), + ).toThrow('理由'); + expect(() => + applyCommand(state, { + type: 'generate-finals', + qualifiers: ['p0', 'p0'], + justification: '抽選', + }), + ).toThrow('重複'); + expect( + applyCommand(state, { + type: 'generate-finals', + qualifiers: ['p1', 'p2'], + justification: '抽選で決定', + }).matches.filter((m) => m.stage === 'finals'), + ).toHaveLength(1); + const ranked = completedQualifying(); + expect(() => + applyCommand(ranked, { + type: 'generate-finals', + qualifiers: ['p0', 'p3', 'p2', 'p4'], + justification: '独断', + }), + ).toThrow('同順位以外'); + }); + it('表示順と直接切り替えの状態を保存し、空の大会に安全に切り替える', () => { + const state = createInitialState(); + const played = applyCommand(state, { type: 'play-cue', cueId: 'closing' }); + expect(played).toMatchObject({ + phase: 'closing', + scene: 'ceremony', + cueId: 'closing', + revision: 1, + }); + const shown = applyCommand(played, { type: 'show', scene: 'logo', phase: 'break' }); + expect(shown.cueId).toBeNull(); + const reset = applyCommand(shown, { type: 'reset', demo: false, confirmation: 'RESET' }); + expect(reset.tournamentId).not.toBe(shown.tournamentId); + expect(reset.players).toHaveLength(0); + expect(reset.revision).toBe(3); }); }); diff --git a/packages/scoring/src/index.ts b/packages/scoring/src/index.ts index 0c1f1a2..05e0836 100644 --- a/packages/scoring/src/index.ts +++ b/packages/scoring/src/index.ts @@ -1,33 +1,2 @@ -import type { OutputSlot, SlotValues } from '@chros/shared'; - -/** - * スコア計算エンジン(§5)は設計継続中(トラックB)。 - * ここは §10.1 が許す「固定のダミー実装」であり、ノードグラフ評価器が乗った時点で - * 差し替わる。Viewer はスロット定義に従って描画するため、差し替え時の画面側の改修は不要。 - * - * 副作用と I/O を持たない。DB・HTTP・React に依存しない(§2)。 - */ - -/** ダミーのスロット定義。スロットは `score` 1つだけ。 */ -export const DUMMY_SLOTS: OutputSlot[] = [{ name: 'score', label: 'スコア' }]; - -export type HalfInput = { - /** 素点。CHaser の取得アイテム数などをそのまま入れる */ - rawScore: number; -}; - -/** ハーフ1つ分のスロット値を求める。現状は素点をそのまま `score` に置くだけ。 */ -export function evaluateHalf(input: HalfInput): SlotValues { - return { score: input.rawScore }; -} - -/** 対戦全体(両ハーフ)のスロット値を合算する。 */ -export function aggregateMatch(halves: SlotValues[]): SlotValues { - const result: SlotValues = {}; - for (const half of halves) { - for (const [name, value] of Object.entries(half)) { - result[name] = (result[name] ?? 0) + value; - } - } - return result; -} +export * from './match'; +export * from './standings'; diff --git a/packages/scoring/src/match.ts b/packages/scoring/src/match.ts new file mode 100644 index 0000000..ace3436 --- /dev/null +++ b/packages/scoring/src/match.ts @@ -0,0 +1,90 @@ +import type { Game, Match, Profile } from '@chros/shared'; + +type Side = 'a' | 'b'; + +export type ScoreTotals = { + raw: number; + points: number; + special: number; + wins: number; +}; + +function gameWinner(game: Game): Side | null { + if (game.reason !== 'points') return game.specialWinner; + if (game.scoreA === game.scoreB) return null; + return game.scoreA > game.scoreB ? 'a' : 'b'; +} + +export function evaluateGame(game: Game, profile: Profile) { + const winner = gameWinner(game); + const isSpecialVictory = game.reason !== 'points'; + const adjustedPoints = { a: game.scoreA, b: game.scoreB }; + + if (profile === 'asahikawa' && isSpecialVictory && winner) { + const losingSide = winner === 'a' ? 'b' : 'a'; + const lostToOpponent = game.reason === 'put' || game.reason === 'surround'; + adjustedPoints[losingSide] = lostToOpponent ? 0 : -game.remainingTurns; + } + + return { + winner, + a: { + raw: game.scoreA, + points: adjustedPoints.a, + special: Number(isSpecialVictory && winner === 'a'), + wins: Number(winner === 'a'), + }, + b: { + raw: game.scoreB, + points: adjustedPoints.b, + special: Number(isSpecialVictory && winner === 'b'), + wins: Number(winner === 'b'), + }, + }; +} + +function matchStatus(complete: boolean, winnerId: string | null, started: boolean) { + if (complete) return winnerId ? 'finished' : 'replay'; + return started ? 'running' : 'pending'; +} + +function decidingCriterion(match: Match, profile: Profile, primaryDifference: number) { + if (match.bye) return '不戦進出'; + if (!primaryDifference) return '合計得点'; + return profile === 'kushiro' ? '特殊ポイント' : 'ゲーム勝数'; +} + +export function evaluateMatch(match: Match, profile: Profile) { + const totalsA: ScoreTotals = { raw: 0, points: 0, special: 0, wins: 0 }; + const totalsB: ScoreTotals = { raw: 0, points: 0, special: 0, wins: 0 }; + + for (const game of match.games) { + const result = evaluateGame(game, profile); + for (const metric of ['raw', 'points', 'special', 'wins'] as const) { + totalsA[metric] += result.a[metric]; + totalsB[metric] += result.b[metric]; + } + } + + const complete = match.games.length === 2 || match.bye; + const primaryDifference = + profile === 'kushiro' ? totalsA.special - totalsB.special : totalsA.wins - totalsB.wins; + const comparison = primaryDifference || totalsA.points - totalsB.points; + + let winnerId: string | null = null; + if (match.bye) { + winnerId = match.a ?? match.b; + } else if (complete && comparison !== 0) { + winnerId = comparison > 0 ? match.a : match.b; + } + + return { + a: totalsA, + b: totalsB, + complete, + winnerId, + tied: complete && !winnerId, + status: matchStatus(complete, winnerId, match.started), + decidedBy: decidingCriterion(match, profile, primaryDifference), + }; +} diff --git a/packages/scoring/src/standings.ts b/packages/scoring/src/standings.ts new file mode 100644 index 0000000..3153e34 --- /dev/null +++ b/packages/scoring/src/standings.ts @@ -0,0 +1,78 @@ +import type { ControlState, Player, Profile } from '@chros/shared'; +import { evaluateMatch } from './match'; + +export type Standing = { + player: Player; + played: number; + wins: number; + losses: number; + special: number; + points: number; + rank: number; + tied: boolean; +}; + +function compareResults(first: Standing, second: Standing, profile: Profile) { + const winsDifference = second.wins - first.wins; + const specialDifference = profile === 'kushiro' ? second.special - first.special : 0; + return winsDifference || specialDifference || second.points - first.points; +} + +export function standings(state: ControlState, group?: string): Standing[] { + const rows = state.players + .filter((player) => group === undefined || player.group === group) + .map((player) => ({ + player, + played: 0, + wins: 0, + losses: 0, + special: 0, + points: 0, + rank: 0, + tied: false, + })); + + for (const match of state.matches) { + if (match.stage !== 'qualifying') continue; + const result = evaluateMatch(match, state.profile); + if (!result.winnerId) continue; + + for (const row of rows) { + const side = row.player.id === match.a ? 'a' : row.player.id === match.b ? 'b' : null; + if (!side) continue; + + row.played++; + row.wins += Number(result.winnerId === row.player.id); + row.losses += Number(result.winnerId !== row.player.id); + row.special += result[side].special; + row.points += result[side].points; + } + } + + rows.sort( + (first, second) => + compareResults(first, second, state.profile) || + first.player.name.localeCompare(second.player.name, 'ja'), + ); + + rows.forEach((row, index) => { + const previous = rows[index - 1]; + const next = rows[index + 1]; + const tiedWithPrevious = !!previous && compareResults(previous, row, state.profile) === 0; + const tiedWithNext = !!next && compareResults(row, next, state.profile) === 0; + + row.rank = tiedWithPrevious ? previous.rank : index + 1; + row.tied = tiedWithPrevious || tiedWithNext; + }); + + return rows; +} + +export function groupNames(state: ControlState) { + const groups = new Set(state.players.map((player) => player.group)); + return [...groups].sort((first, second) => first.localeCompare(second, 'ja', { numeric: true })); +} + +export function playerName(state: ControlState, id: string | null) { + return state.players.find((player) => player.id === id)?.name ?? '対戦者未定'; +} diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts deleted file mode 100644 index bb7e309..0000000 --- a/packages/shared/src/api.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { z } from 'zod'; -import { screenIdSchema } from './domain'; - -/** POST /api/screen */ -export const changeScreenRequestSchema = z.object({ - screen: screenIdSchema, -}); -export type ChangeScreenRequest = z.infer; - -/** エラーレスポンスの統一形。§7 */ -export const apiErrorSchema = z.object({ - error: z.object({ - code: z.string(), - message: z.string(), - }), -}); -export type ApiError = z.infer; - -export function apiError(code: string, message: string): ApiError { - return { error: { code, message } }; -} diff --git a/packages/shared/src/control.ts b/packages/shared/src/control.ts new file mode 100644 index 0000000..ba67be3 --- /dev/null +++ b/packages/shared/src/control.ts @@ -0,0 +1,150 @@ +import { z } from 'zod'; + +export const phases = ['opening', 'qualifying', 'finals', 'break', 'closing'] as const; +export type Phase = (typeof phases)[number]; +export const phaseLabels: Record = { + opening: '開会式', + qualifying: '予選', + finals: '本戦', + break: '休憩・幕間', + closing: '閉会式', +}; +export const scenes = ['ceremony', 'match', 'result', 'standings', 'bracket', 'logo'] as const; +export type Scene = (typeof scenes)[number]; +export const sceneLabels: Record = { + ceremony: '進行案内', + match: '対戦・得点', + result: '試合結果', + standings: '予選順位', + bracket: '本戦トーナメント', + logo: '幕間・ロゴ', +}; +export const reasons = [ + 'points', + 'put', + 'surround', + 'disconnect', + 'illegal-move', + 'self-surround', +] as const; +export type Reason = (typeof reasons)[number]; +export const reasonLabels: Record = { + points: '得点比較', + put: 'PUT', + surround: '相手を包囲', + disconnect: '相手の通信切断', + 'illegal-move': '相手の移動違反', + 'self-surround': '相手の自己包囲', +}; +const id = z.string().min(1).max(100); +const label = z.string().trim().min(1).max(60); +export const playerSchema = z.object({ + id, + name: label, + affiliation: z.string().max(60), + group: z.string().trim().min(1).max(12), +}); +export type Player = z.infer; +export const gameSchema = z.object({ + number: z.union([z.literal(1), z.literal(2)]), + scoreA: z.number().int().min(0).max(999), + scoreB: z.number().int().min(0).max(999), + reason: z.enum(reasons), + specialWinner: z.enum(['a', 'b']).nullable(), + remainingTurns: z.number().int().min(0).max(999), +}); +export type Game = z.infer; +export const matchSchema = z.object({ + id, + label, + stage: z.enum(['qualifying', 'finals']), + group: z.string(), + a: id.nullable(), + b: id.nullable(), + sources: z.tuple([id.nullable(), id.nullable()]), + round: z.number().int().min(0), + firstCool: id.nullable(), + started: z.boolean(), + bye: z.boolean(), + games: z.array(gameSchema).max(2), + attempt: z.number().int().positive(), + previousAttempts: z.array( + z.object({ + games: z.array(gameSchema), + firstCool: id.nullable(), + reason: z.string(), + at: z.string(), + }), + ), +}); +export type Match = z.infer; +export const cueSchema = z.object({ id, label, scene: z.enum(scenes), phase: z.enum(phases) }); +export type Cue = z.infer; +export const stateSchema = z.object({ + schemaVersion: z.literal(1), + tournamentId: id, + revision: z.number().int().nonnegative(), + updatedAt: z.string(), + demo: z.boolean(), + ruleVersion: z.literal('2026-09-18.1').default('2026-09-18.1'), + boardPage: z.number().int().nonnegative().default(0), + title: label, + subtitle: z.string().max(120), + phase: z.enum(phases), + scene: z.enum(scenes), + profile: z.enum(['kushiro', 'asahikawa']), + advancePerGroup: z.number().int().min(1).max(8), + players: z.array(playerSchema).max(128), + matches: z.array(matchSchema), + currentMatchId: id.nullable(), + logo: z.string().max(1_500_000).nullable(), + cues: z.array(cueSchema).max(40), + cueId: id.nullable(), + audit: z.array(z.object({ id, at: z.string(), action: z.string(), detail: z.string() })), +}); +export type ControlState = z.infer; +export type Profile = ControlState['profile']; +export const commandSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('show'), scene: z.enum(scenes), phase: z.enum(phases) }), + z.object({ type: z.literal('board-page'), page: z.number().int().min(0).max(255) }), + z.object({ type: z.literal('select-match'), matchId: id }), + z.object({ type: z.literal('start-match'), matchId: id, firstCool: id }), + z.object({ + type: z.literal('save-game'), + matchId: id, + game: gameSchema, + correction: z.string().trim().max(300), + }), + z.object({ type: z.literal('replay'), matchId: id, reason: z.string().trim().min(1).max(300) }), + z.object({ type: z.literal('save-player'), player: playerSchema }), + z.object({ type: z.literal('remove-player'), playerId: id }), + z.object({ type: z.literal('generate-qualifying') }), + z.object({ + type: z.literal('generate-finals'), + qualifiers: z.array(id).min(2).max(128), + justification: z.string().trim().max(300), + }), + z.object({ + type: z.literal('settings'), + title: label, + subtitle: z.string().max(120), + profile: z.enum(['kushiro', 'asahikawa']), + advancePerGroup: z.number().int().min(1).max(8), + }), + z.object({ + type: z.literal('logo'), + data: z + .string() + .max(1_500_000) + .regex(/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/) + .nullable(), + }), + z.object({ type: z.literal('save-cues'), cues: z.array(cueSchema).min(1).max(40) }), + z.object({ type: z.literal('play-cue'), cueId: id }), + z.object({ type: z.literal('reset'), demo: z.boolean(), confirmation: z.literal('RESET') }), +]); +export type Command = z.infer; +export const requestSchema = z.object({ + expectedRevision: z.number().int().nonnegative(), + command: commandSchema, +}); diff --git a/packages/shared/src/domain.ts b/packages/shared/src/domain.ts deleted file mode 100644 index 510bc04..0000000 --- a/packages/shared/src/domain.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { z } from 'zod'; - -/** 配信用 Viewer が表示しうる画面。§8.1 */ -export const screenIdSchema = z.enum([ - 'next-game', - 'in-game', - 'result', - 'standings', - 'bracket', - 'intermission', -]); -export type ScreenId = z.infer; - -/** 大会形式。予選総当たり → 決勝トーナメントの接続方法は §11.2 で未決。 */ -export const formatSchema = z.enum(['round-robin', 'single-elimination']); -export type Format = z.infer; - -export const sideSchema = z.enum(['COOL', 'HOT']); -export type Side = z.infer; - -export const halfSchema = z.enum(['FIRST', 'SECOND']); -export type Half = z.infer; - -export const matchStateSchema = z.enum(['pending', 'running', 'finished', 'replay']); -export type MatchState = z.infer; - -/** - * 出力スロットの定義。§8.2 - * Viewer はスコアの意味を知らず、この定義に従って描画する。 - * 計算規則(§5, トラックB)が確定するまでは `{ name: 'score' }` 1つのダミーを流す。 - */ -export const outputSlotSchema = z.object({ - name: z.string(), - label: z.string(), -}); -export type OutputSlot = z.infer; - -export const participantViewSchema = z.object({ - id: z.number().int(), - name: z.string(), -}); -export type ParticipantView = z.infer; - -/** スロット名 → 値。中身の意味は ScoreRule 側が決める。 */ -export const slotValuesSchema = z.record(z.string(), z.number()); -export type SlotValues = z.infer; - -export const matchViewSchema = z.object({ - id: z.number().int(), - name: z.string().nullable(), - state: matchStateSchema, - agent1: participantViewSchema, - agent2: participantViewSchema, - winnerId: z.number().int().nullable(), - /** 確定済みハーフの集計結果。参加者 id → スロット値 */ - slotValues: z.record(z.string(), slotValuesSchema).default({}), -}); -export type MatchView = z.infer; - -export const standingsSchema = z.object({ - rows: z.array( - z.object({ - rank: z.number().int(), - participant: participantViewSchema, - slotValues: slotValuesSchema, - }), - ), -}); -export type Standings = z.infer; diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts deleted file mode 100644 index a76e410..0000000 --- a/packages/shared/src/events.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { z } from 'zod'; -import { - formatSchema, - halfSchema, - matchViewSchema, - outputSlotSchema, - participantViewSchema, - screenIdSchema, - sideSchema, - slotValuesSchema, - standingsSchema, -} from './domain'; - -/** - * 接続直後に1件送る全状態。§6.2 - * Viewer が全画面を描くのに必要なものを漏れなく含む(§8.4)。 - */ -export const snapshotSchema = z.object({ - screen: screenIdSchema, - tournament: z.object({ - id: z.number().int(), - name: z.string(), - format: formatSchema, - }), - slots: z.array(outputSlotSchema), - participants: z.array(participantViewSchema), - matches: z.array(matchViewSchema), - currentMatchId: z.number().int().nullable(), - standings: standingsSchema.nullable(), -}); -export type Snapshot = z.infer; - -export const scorePayloadSchema = z.object({ - matchId: z.number().int(), - half: halfSchema, - cool: z.object({ participantId: z.number().int(), slotValues: slotValuesSchema }), - hot: z.object({ participantId: z.number().int(), slotValues: slotValuesSchema }), - put: sideSchema.nullable(), - lostConnect: sideSchema.nullable(), -}); -export type ScorePayload = z.infer; - -export const matchResultPayloadSchema = z.object({ - matchId: z.number().int(), - /** 仕切り直しに終わった場合は winnerId が null かつ replay が true。§5.5 */ - winnerId: z.number().int().nullable(), - replay: z.boolean(), - slotValues: z.record(z.string(), slotValuesSchema), -}); -export type MatchResultPayload = z.infer; - -export const chrosEventSchema = z.discriminatedUnion('type', [ - z.object({ type: z.literal('snapshot'), payload: snapshotSchema }), - z.object({ type: z.literal('screen.changed'), payload: z.object({ screen: screenIdSchema }) }), - z.object({ type: z.literal('match.started'), payload: z.object({ matchId: z.number().int() }) }), - z.object({ type: z.literal('match.scored'), payload: scorePayloadSchema }), - z.object({ type: z.literal('match.finished'), payload: matchResultPayloadSchema }), - z.object({ type: z.literal('standings.updated'), payload: standingsSchema }), -]); -export type ChrosEvent = z.infer; - -export type ChrosEventType = ChrosEvent['type']; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9eb8145..9242eb2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1 @@ -export * from './domain'; -export * from './events'; -export * from './api'; +export * from './control'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4b5d14..5270d4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + prettier: + specifier: 3.9.8 + version: 3.9.8 typescript: specifier: ^5.6.0 version: 5.9.3 @@ -1173,6 +1176,11 @@ packages: resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} + prettier@3.9.8: + resolution: {integrity: sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==} + engines: {node: '>=14'} + hasBin: true + prisma@6.19.3: resolution: {integrity: sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==} engines: {node: '>=18.18'} @@ -2188,6 +2196,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prettier@3.9.8: {} + prisma@6.19.3(typescript@5.9.3): dependencies: '@prisma/config': 6.19.3 diff --git a/scripts/smoke-prototype.mjs b/scripts/smoke-prototype.mjs new file mode 100644 index 0000000..ae63276 --- /dev/null +++ b/scripts/smoke-prototype.mjs @@ -0,0 +1,139 @@ +// Use a separate server with CHROS_DATA_DIR pointing to a disposable directory. +// node scripts/smoke-prototype.mjs http://127.0.0.1:3101 --reset-test-data +import assert from 'node:assert/strict'; +const url = process.argv[2]; +if (!url || process.argv[3] !== '--reset-test-data') + throw new Error( + '専用のテスト用サーバーURLと --reset-test-data を指定してください。対象大会を切り替えます。', + ); +let state = await (await fetch(`${url}/api/state`)).json(); +const send = async (command) => { + const response = await fetch(`${url}/api/state`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ expectedRevision: state.revision, command }), + }); + const body = await response.json(); + assert.equal(response.status, 200, JSON.stringify(body)); + state = body; + return body; +}; +await send({ type: 'reset', demo: false, confirmation: 'RESET' }); +await send({ + type: 'settings', + title: 'API検証大会', + subtitle: '', + profile: 'kushiro', + advancePerGroup: 2, +}); +for (let i = 0; i < 6; i++) + await send({ + type: 'save-player', + player: { + id: `smoke-${i}`, + name: i === 0 ? '=検証選手0' : `検証選手${i}`, + affiliation: '', + group: i < 3 ? 'A' : 'B', + }, + }); +await send({ type: 'generate-qualifying' }); +assert.equal(state.matches.length, 6); +const abort = new AbortController(); +const stream = await fetch(`${url}/api/stream`, { signal: abort.signal }); +assert.match(stream.headers.get('content-type'), /text\/event-stream/); +const reader = stream.body.getReader(), + decoder = new TextDecoder(); +const readSnapshot = async () => { + let text = ''; + while (!text.includes('\n\n')) { + const { value, done } = await reader.read(); + assert.equal(done, false); + text += decoder.decode(value, { stream: true }); + } + return JSON.parse( + text + .split('\n') + .find((line) => line.startsWith('data: ')) + .slice(6), + ); +}; +assert.equal((await readSnapshot()).revision, state.revision); +const revision = state.revision; +await send({ type: 'show', scene: 'match', phase: 'qualifying' }); +assert.equal((await readSnapshot()).scene, 'match'); +abort.abort(); +const conflict = await fetch(`${url}/api/state`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + expectedRevision: revision, + command: { type: 'show', scene: 'logo', phase: 'break' }, + }), +}); +assert.equal(conflict.status, 409); +assert.equal((await conflict.json()).state.revision, state.revision); +const crossOrigin = await fetch(`${url}/api/state`, { + method: 'POST', + headers: { Origin: 'https://unrelated.example', 'Content-Type': 'application/json' }, + body: '{}', +}); +assert.equal(crossOrigin.status, 403); +const score = (number) => ({ + number, + scoreA: 20, + scoreB: 10, + reason: 'points', + specialWinner: null, + remainingTurns: 0, +}); +const complete = async (id) => { + const match = state.matches.find((m) => m.id === id); + await send({ type: 'start-match', matchId: id, firstCool: match.b }); + for (const number of [1, 2]) + await send({ type: 'save-game', matchId: id, game: score(number), correction: '' }); +}; +for (const match of [...state.matches]) await complete(match.id); +await send({ + type: 'generate-finals', + qualifiers: ['smoke-0', 'smoke-3', 'smoke-1', 'smoke-4'], + justification: '', +}); +assert.equal(state.matches.filter((m) => m.stage === 'finals').length, 3); +for (const match of state.matches.filter((m) => m.round === 1)) await complete(match.id); +let final = state.matches.find((m) => m.label === '決勝'); +assert.equal(final.a, 'smoke-0'); +assert.equal(final.b, 'smoke-3'); +await complete(final.id); +await send({ type: 'select-match', matchId: final.id }); +await send({ type: 'show', scene: 'result', phase: 'finals' }); +const csv = await (await fetch(`${url}/api/export?format=csv`)).text(); +assert.ok(csv.includes('"\'=検証選手0"')); +assert.ok(csv.includes('"決勝"')); +assert.equal(csv.split('\r\n').length, 19); +const exported = await (await fetch(`${url}/api/export`)).json(); +assert.equal(exported.revision, state.revision); +assert.equal(exported.audit.length, state.audit.length); +assert.equal((await (await fetch(`${url}/api/state`)).json()).revision, state.revision); +console.log( + JSON.stringify( + { + result: 'PASS', + checks: [ + '参加者登録', + '総当たり', + '2戦記録', + 'SSE初期状態・更新', + '同時更新409', + '別サイト書込403', + '本戦進出・決勝', + 'CSV式注入対策', + 'JSON履歴出力', + ], + revision: state.revision, + matches: state.matches.length, + finalId: final.id, + }, + null, + 2, + ), +);