Skip to content

Latest commit

 

History

History
970 lines (757 loc) · 57 KB

File metadata and controls

970 lines (757 loc) · 57 KB

CHroS 技術要件

対象読者: CHroS の実装者。要件そのものは requirements.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/sharedpackages/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. ドメインモデル

既存スキーマを土台に、進行状態を一級市民として持たせる。

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*OutputJson にする理由: 何を得点として外に出すかは規則が決める(§5.3)。 score 1つの大会もあれば scorespecialPoint を出す大会もあるため、固定カラムにできない。 表示側は出力スロットの定義(名前・表示名・型)を規則から引いて描画する。
  • ScoreMatchResult の分離: 入力された事実と、規則を適用した結果を混ぜない。 規則が変わったとき、事実はそのままに結果だけ再計算できる。可搬性を掲げる以上ここは譲れない。

勝敗判定は 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点多ければ」のような比較は構造上ここに書けず、必ず勝敗判定ブロックに寄る。 この制約が、規則の置き場所の曖昧さを消す。
  • 勝敗判定ブロックの出力は勝者と勝因のみ。得点をここで書き換えることはできない。
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.scoreb.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つ出す
  • 特殊ポイントを得点とは別に判定へ渡す大会 → scorespecialPoint の2つを出す(§5.5 大会A)
  • アイテム数と時間を別々に見せたい大会 → itemstimeBonus を分けて出す

出力スロットは名前と型を持ち、勝敗判定ブロックの各ノードが名前で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 として作る

model Match {
  // ...
  attempt      Int   @default(1)   // 何回目の対戦か
  rematchOfId  Int?                // 仕切り直しの元になった Match
  rematchOf    Match?  @relation("Rematch", fields: [rematchOfId], references: [id])
  rematches    Match[] @relation("Rematch")
}
  • 元の MatchMatchResult に「仕切り直し」の結果を残したまま確定する。 winnerIdnull になる。
  • 順位計算は最終的に勝敗が付いた 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 順位表 順位の再計算結果
// 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;
};

Snapshotslots を含めるのは §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 画面の種別と表示内容

ScreenIdpackages/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.statePENDING / 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.tsxrouter.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 — スコア計算(並行して設計を継続)

  1. ノード語彙と ScoreRuleGraph 保存形式の確定(§5.8 の未決を解消)
  2. Score / ScoreRule / MatchResult のスキーマ確定とマイグレーション
  3. packages/scoring の評価器・検証器の実装とテスト(純粋関数なので単独で固められる)
  4. ノードエディタ 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)