diff --git a/chros-score/.gitignore b/chros-score/.gitignore index af6ace7..b65ed64 100644 --- a/chros-score/.gitignore +++ b/chros-score/.gitignore @@ -1,2 +1,3 @@ .python-version -__pychache__/ +__pycache__/ +.pytest_cache/ diff --git a/chros-score/README.md b/chros-score/README.md new file mode 100644 index 0000000..8967b79 --- /dev/null +++ b/chros-score/README.md @@ -0,0 +1,151 @@ +# chros-score + +CHroS のスコア計算サービス。対戦結果から順位を算出する。 + +## 順位計算モジュール + +[src/score_api/ranking/](src/score_api/ranking/) に実装。 + +- [models.py](src/score_api/ranking/models.py) : 入出力のデータモデルと勝因の定義 +- [calculator.py](src/score_api/ranking/calculator.py) : 試合判定と順位表の組み立て + +```python +from src.score_api.ranking import BoutResult, calculate_ranking + +result = calculate_ranking([ + BoutResult(hot="player1", cool="player2", winner="hot", reason="put"), + BoutResult(hot="player2", cool="player1", winner="cool", reason="item"), +]) +``` + +### API + +`POST /ranking` に対戦結果の配列を送ると順位表が返る。 + +```jsonc +// リクエスト +[ + { "hot": "player1", "cool": "player2", "winner": "hot", "reason": "put" }, + { "hot": "player2", "cool": "player1", "winner": "cool", "reason": "item" }, + { "hot": "player1", "cool": "player3", "winner": "draw" }, + { "hot": "player3", "cool": "player1", "winner": "hot", "reason": "self_destruct" } +] +``` + +| フィールド | 内容 | +| --- | --- | +| `hot` | Hot側のユーザー名 (リクエスト内で一意なID) | +| `cool` | Cool側のユーザー名 (リクエスト内で一意なID) | +| `winner` | `hot` / `cool` / `draw` | +| `reason` | `self_destruct` / `put` / `item`。`draw` のときは省略 (または `none`) | + +`reason` は表記ゆれを吸収する (`LostConnect`、`timeout`、`アイテム数優勢` など。 +対応表は [models.py](src/score_api/ranking/models.py) の `_REASON_ALIASES`)。 +勝敗が付いた対戦で `reason` が無い、Hot と Cool が同一、`draw` なのに `reason` がある、 +といった入力は 422 で弾く。 + +レスポンスは `standings` (順位表) と `matches` (試合ごとの判定結果) を含む。 + +```jsonc +{ + "standings": [ + { + "rank": 1, + "player": "player3", // 自滅勝ちを持つため、同じ1勝の player1 より上位 + "matches": 1, // 決着した試合数 + "wins": 1, "losses": 0, + "bout_wins": 1, "bout_losses": 0, "bout_draws": 1, + "win_reasons": { "self_destruct": 1, "put": 0, "item": 0 }, + "pending_rematches": 0 // 引き分けで再試合待ちの試合数 + }, + { "rank": 2, "player": "player1", "wins": 1, "losses": 1, /* ... */ }, + { "rank": 3, "player": "player2", "wins": 0, "losses": 1, /* ... */ } + ], + "matches": [ + { + "match_no": 1, + "players": ["player1", "player2"], + "bouts": [ /* 対戦ごとの勝敗 */ ], + "scores": { + "player1": { "self_destruct": 0, "put": 1, "item": 1 }, + "player2": { "self_destruct": 0, "put": 0, "item": 0 } + }, + "winner": "player1", "loser": "player2", + "is_draw": false, + "rematch_required": false, // ルール1-4により再試合が必要 (未消化) + "voided": false, // 再試合が行われたため無効 + "notes": [] // 判定に関する補足・警告 (コード形式、下表参照) + }, + { "match_no": 2, "players": ["player1", "player3"], "winner": "player3", /* ... */ } + ] +} +``` + +### notes のコード + +`notes` は表示用の文言ではなく、プログラムで分岐できるコードで返す +(定義は [models.py](src/score_api/ranking/models.py) の `MatchNoteCode` / `MatchNote`)。 +各要素は `code` / `rule` / `related_match_no` / `bout_count` / `expected_bout_count` を持ち、 +該当しないフィールドは `null` になる。 + +| `code` | `rule` | 意味 | 付随フィールド | +| --- | --- | --- | --- | +| `score_tied` | 1-3 | 2対戦の対戦スコアが並んだ | — | +| `rematch_held` | 1-4 | 再試合が行われたためこの試合結果は無効 | `related_match_no` (再試合の試合番号) | +| `bouts_missing` | 0 | 試合を構成する対戦が不足している | `bout_count`, `expected_bout_count` | +| `sides_not_swapped` | 0 | 2対戦で先攻・後攻が入れ替わっていない | — | + +```jsonc +"notes": [ + { "code": "score_tied", "rule": "1-3", "related_match_no": null, "bout_count": null, "expected_bout_count": null }, + { "code": "rematch_held", "rule": "1-4", "related_match_no": 2, "bout_count": null, "expected_bout_count": null } +] +``` + +Python からは `match.has_note(MatchNoteCode.SCORE_TIED)` で判定できる。 + +### 判定のルール + +典拠: [U-16プログラミングコンテスト 釧路大会 ルール細則](https://kpc-u16.github.io/CHaserRuleGuide-Pub/) + +1. **試合の組み立て** — 「試合」は先攻・後攻を入れ替えた2つの「対戦」から成る (0. 用語集)。 + 入力配列を先頭から走査し、同じ組み合わせの対戦が2つ揃った時点で1試合として確定する。 +2. **試合の勝敗** — 2対戦の勝ち方を、1-3 の重みづけ順 + (相手エージェントの自滅 > Put勝ち > アイテム数優勢) で辞書式に比較して決める。 + 例えば「Put勝ち1つ」は「アイテム数優勢2つ」に優先する。 +3. **引き分けと再試合** — 上記で並んだ場合は引き分けとし、1-4 に従い再試合が必要 + (`rematch_required`) とする。同じ組み合わせの試合が後続にあれば、それを再試合とみなして + 引き分け試合を無効 (`voided`) にし、順位計算から除外する (1-4「再試合を実施すべき基準を + 満たした試合の結果は無効とする」)。 +4. **順位** — 決着した試合のみを集計し、次の優先順位で比較する。ここまで並べば同順位 + (同着の分だけ次の順位を飛ばす 1, 1, 3 方式)。 + 1. 試合の勝利数が多い + 2. 勝因の重み順 (自滅 > Put勝ち > アイテム数優勢) の辞書式比較 + +#### ルール細則に無いため補った点 + +- ルール細則は1試合の勝敗までしか定めておらず、**大会全体の順位決定方法は規定していない**。 + 上記4の順位比較は、1-3 の対戦スコア算定をそのまま大会全体に持ち上げたもの。 +- 未消化の再試合 (`rematch_required`) がある試合は、勝敗・対戦数のいずれにも算入せず + `pending_rematches` として数える。 +- 再試合かどうかは配列の順序から判断する。同じ組み合わせの引き分け試合の後に現れた試合を + 再試合として扱うため、**入力配列は試合の実施順に並べる必要がある**。 +- 対戦が1つしか無い組み合わせ (前半戦のみ) も判明している範囲で判定するが、 + 試合として未成立である旨を `notes` に `bouts_missing` として記録する。2対戦で先攻・後攻が + 入れ替わっていない場合も同様に `sides_not_swapped` を出す。 + +## 開発 + +```bash +poetry install --no-root +poetry run uvicorn src.score_api.main:app --reload --port 3002 +``` + +### テスト + +pytest と httpx はランタイム依存に含めていないため、個別に入れる。 + +```bash +pip install pytest httpx +pytest +``` diff --git a/chros-score/pyproject.toml b/chros-score/pyproject.toml index e706a92..9350a22 100644 --- a/chros-score/pyproject.toml +++ b/chros-score/pyproject.toml @@ -16,3 +16,7 @@ dependencies = [ [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/chros-score/src/score_api/main.py b/chros-score/src/score_api/main.py index 247ac05..dd5887a 100644 --- a/chros-score/src/score_api/main.py +++ b/chros-score/src/score_api/main.py @@ -1,7 +1,23 @@ -from fastapi import FastAPI +from fastapi import Body, FastAPI + +from .ranking import BoutResult, RankingResult, calculate_ranking app = FastAPI() @app.get("/") def read_root(): return {"message": "Hello, FastAPI!"} + + +@app.post("/ranking", response_model=RankingResult) +def create_ranking(bouts: list[BoutResult] = Body(...)) -> RankingResult: + """対戦結果の配列を受け取り、順位表を返す。 + + リクエストボディは対戦 (先攻・後攻それぞれ1回ずつで1試合) の配列。 + 例: + [ + {"hot": "player1", "cool": "player2", "winner": "hot", "reason": "put"}, + {"hot": "player2", "cool": "player1", "winner": "cool", "reason": "item"} + ] + """ + return calculate_ranking(bouts) diff --git a/chros-score/src/score_api/ranking/__init__.py b/chros-score/src/score_api/ranking/__init__.py new file mode 100644 index 0000000..02a9740 --- /dev/null +++ b/chros-score/src/score_api/ranking/__init__.py @@ -0,0 +1,36 @@ +"""CHaser の対戦結果から順位を算出するモジュール。 + +ルール典拠: U-16プログラミングコンテスト 釧路大会 ルール細則 +https://kpc-u16.github.io/CHaserRuleGuide-Pub/ +""" + +from .calculator import BOUTS_PER_MATCH, calculate_ranking +from .models import ( + BoutOutcome, + BoutResult, + MatchNote, + MatchNoteCode, + MatchResult, + Outcome, + PlayerStanding, + RankingResult, + ReasonBreakdown, + Side, + WinReason, +) + +__all__ = [ + "BOUTS_PER_MATCH", + "BoutOutcome", + "BoutResult", + "MatchNote", + "MatchNoteCode", + "MatchResult", + "Outcome", + "PlayerStanding", + "RankingResult", + "ReasonBreakdown", + "Side", + "WinReason", + "calculate_ranking", +] diff --git a/chros-score/src/score_api/ranking/calculator.py b/chros-score/src/score_api/ranking/calculator.py new file mode 100644 index 0000000..a425428 --- /dev/null +++ b/chros-score/src/score_api/ranking/calculator.py @@ -0,0 +1,210 @@ +"""対戦結果の配列から試合の勝敗を判定し、順位表を組み立てる。 + +ルール典拠: U-16プログラミングコンテスト 釧路大会 ルール細則 +https://kpc-u16.github.io/CHaserRuleGuide-Pub/ + +判定の流れ: + 1. 入力の対戦 (bout) を、同じ組み合わせごとに入力順で2つずつまとめて1試合とする + (ルール 0. 用語集「試合: 先攻,後攻を入れ替えて、それぞれ1度ずつ対戦すること」)。 + 2. 試合内の2対戦について、勝因の重み (自滅 > Put勝ち > アイテム数優勢) 順の + 辞書式比較で勝敗を決める (ルール 1-3)。 + 3. 並んだ場合は引き分けとし、再試合が必要な試合として印を付ける (ルール 1-4)。 + 同じ組み合わせの試合が後続に存在する場合、その引き分け試合は再試合により + 無効となったものとして順位計算から除外する (ルール 1-4 の3-a)。 + 4. 決着した試合のみを集計して順位表を作る。 +""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Iterable + +from .models import ( + BoutOutcome, + BoutResult, + MatchNote, + MatchNoteCode, + MatchResult, + PlayerStanding, + RankingResult, + ReasonBreakdown, +) + +#: 1試合を構成する対戦数 (前半戦・後半戦)。 +BOUTS_PER_MATCH = 2 + +PairKey = tuple[str, str] + + +def calculate_ranking(bouts: Iterable[BoutResult]) -> RankingResult: + """対戦結果の並びから順位表を算出する。 + + Args: + bouts: 入力順に並んだ対戦結果。同じ組み合わせの対戦が2つ揃った時点で + 1試合として判定する。 + + Returns: + 順位表と、試合ごとの判定結果。 + """ + matches = _build_matches(bouts) + _apply_rematch_rule(matches) + standings = _build_standings(matches) + return RankingResult(standings=standings, matches=matches) + + +def _pair_key(hot: str, cool: str) -> PairKey: + first, second = sorted((hot, cool)) + return (first, second) + + +def _build_matches(bouts: Iterable[BoutResult]) -> list[MatchResult]: + """対戦を組み合わせごとに2つずつまとめ、試合として判定する。""" + buffers: dict[PairKey, list[BoutOutcome]] = defaultdict(list) + matches: list[MatchResult] = [] + + for index, bout in enumerate(bouts): + key = _pair_key(bout.hot, bout.cool) + outcome = BoutOutcome( + index=index, + hot=bout.hot, + cool=bout.cool, + winner=bout.winner_name, + loser=bout.loser_name, + reason=bout.reason, + ) + buffers[key].append(outcome) + if len(buffers[key]) == BOUTS_PER_MATCH: + matches.append(_judge_match(key, buffers.pop(key))) + + # 2対戦揃わなかった (前半戦のみの) 組み合わせも、判明している範囲で判定する。 + for key, outcomes in buffers.items(): + if outcomes: + matches.append(_judge_match(key, outcomes)) + + matches.sort(key=lambda match: match.bouts[0].index) + for match_no, match in enumerate(matches, start=1): + match.match_no = match_no + return matches + + +def _judge_match(players: PairKey, outcomes: list[BoutOutcome]) -> MatchResult: + """1試合分の対戦から勝敗を判定する (ルール 1-3)。""" + scores = {player: ReasonBreakdown() for player in players} + for outcome in outcomes: + if outcome.winner is not None: + scores[outcome.winner].add(outcome.reason) + + first, second = players + first_key = scores[first].comparison_key + second_key = scores[second].comparison_key + + match = MatchResult(players=players, bouts=outcomes, scores=scores) + if first_key > second_key: + match.winner, match.loser = first, second + elif second_key > first_key: + match.winner, match.loser = second, first + else: + match.is_draw = True + match.rematch_required = True + match.notes.append(MatchNote(code=MatchNoteCode.SCORE_TIED, rule="1-3")) + + if len(outcomes) < BOUTS_PER_MATCH: + match.notes.append( + MatchNote( + code=MatchNoteCode.BOUTS_MISSING, + rule="0", + bout_count=len(outcomes), + expected_bout_count=BOUTS_PER_MATCH, + ) + ) + elif outcomes[0].hot == outcomes[1].hot: + match.notes.append(MatchNote(code=MatchNoteCode.SIDES_NOT_SWAPPED, rule="0")) + + return match + + +def _apply_rematch_rule(matches: list[MatchResult]) -> None: + """引き分け後に同じ組み合わせの試合があれば、それを再試合とみなし前の結果を無効にする。 + + ルール 1-4「再試合を実施すべき基準を満たした試合の結果は無効とする」に対応する。 + 後続の試合が無い引き分けは、未消化の再試合として rematch_required のまま残る。 + """ + by_pair: dict[PairKey, list[MatchResult]] = defaultdict(list) + for match in matches: + by_pair[match.players].append(match) + + for pair_matches in by_pair.values(): + for match, following in zip(pair_matches, pair_matches[1:]): + if match.is_draw: + match.voided = True + match.rematch_required = False + match.notes.append( + MatchNote( + code=MatchNoteCode.REMATCH_HELD, + rule="1-4", + related_match_no=following.match_no, + ) + ) + + +def _build_standings(matches: list[MatchResult]) -> list[PlayerStanding]: + """有効な試合結果を集計し、順位を付けた順位表を返す。""" + standings: dict[str, PlayerStanding] = {} + + def entry(player: str) -> PlayerStanding: + if player not in standings: + standings[player] = PlayerStanding(rank=0, player=player) + return standings[player] + + for match in matches: + for player in match.players: + entry(player) + if match.voided: + continue + if match.rematch_required: + for player in match.players: + entry(player).pending_rematches += 1 + continue + + for player in match.players: + standing = entry(player) + standing.matches += 1 + standing.win_reasons.merge(match.scores[player]) + if match.winner is not None and match.loser is not None: + entry(match.winner).wins += 1 + entry(match.loser).losses += 1 + for bout in match.bouts: + if bout.winner is None or bout.loser is None: + entry(bout.hot).bout_draws += 1 + entry(bout.cool).bout_draws += 1 + else: + entry(bout.winner).bout_wins += 1 + entry(bout.loser).bout_losses += 1 + + ordered = sorted(standings.values(), key=lambda s: (_standing_key(s), s.player)) + previous_key: tuple[int, ...] | None = None + previous_rank = 0 + for position, standing in enumerate(ordered, start=1): + key = _standing_key(standing) + if key == previous_key: + standing.rank = previous_rank + else: + standing.rank = position + previous_key = key + previous_rank = position + return ordered + + +def _standing_key(standing: PlayerStanding) -> tuple[int, ...]: + """順位比較キー (小さいほど上位)。 + + ルール細則は大会全体の順位決定方法を定めていないため、細則の対戦スコア算定 + (ルール 1-3) をそのまま持ち上げた次の優先順位で比較する。 + 1. 試合の勝利数が多い + 2. 勝因の重み順 (自滅 > Put勝ち > アイテム数優勢) の辞書式比較 + ここまで並んだ場合は同順位とする。 + """ + return ( + -standing.wins, + *(-count for count in standing.win_reasons.comparison_key), + ) diff --git a/chros-score/src/score_api/ranking/models.py b/chros-score/src/score_api/ranking/models.py new file mode 100644 index 0000000..c9acd94 --- /dev/null +++ b/chros-score/src/score_api/ranking/models.py @@ -0,0 +1,333 @@ +"""順位計算で用いるデータモデル。 + +ルール典拠: U-16プログラミングコンテスト 釧路大会 ルール細則 +https://kpc-u16.github.io/CHaserRuleGuide-Pub/ +""" + +from __future__ import annotations + +from enum import Enum +from typing import Any + +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator, model_validator + + +class Side(str, Enum): + """対戦におけるエージェントの側。""" + + HOT = "hot" + COOL = "cool" + + +class Outcome(str, Enum): + """1対戦の決着。""" + + HOT = "hot" + COOL = "cool" + DRAW = "draw" + + +class WinReason(str, Enum): + """勝因の区分。 + + ルール 1-3 「対戦スコア算定方法」の重みづけの順に定義する。 + 1. SELF_DESTRUCT : 相手エージェントの自滅 (1-2 の条件4のいずれか) + 2. PUT : Put勝ち (1-2 の条件1及び2) + 3. ITEM : アイテム数優勢 (1-2 の条件3) + NONE は引き分け (勝因なし) を表す。 + """ + + SELF_DESTRUCT = "self_destruct" + PUT = "put" + ITEM = "item" + NONE = "none" + + @property + def weight(self) -> int: + """重み。大きいほど価値の高い勝ち方。""" + return _REASON_WEIGHT[self] + + +_REASON_WEIGHT: dict[WinReason, int] = { + WinReason.SELF_DESTRUCT: 3, + WinReason.PUT: 2, + WinReason.ITEM: 1, + WinReason.NONE: 0, +} + +#: 重みの高い順に並べた勝因。対戦スコアの比較はこの順の辞書式で行う (ルール 1-3)。 +REASON_ORDER: tuple[WinReason, ...] = ( + WinReason.SELF_DESTRUCT, + WinReason.PUT, + WinReason.ITEM, +) + +#: 外部から渡されうる勝因の表記ゆれ。正規化 (小文字化・区切り文字統一) 済みのキーで引く。 +_REASON_ALIASES: dict[str, WinReason] = { + # 自滅・試合続行不可 (1-2 条件4) + "self_destruct": WinReason.SELF_DESTRUCT, + "selfdestruct": WinReason.SELF_DESTRUCT, + "self_destruction": WinReason.SELF_DESTRUCT, + "suicide": WinReason.SELF_DESTRUCT, + "lost_connect": WinReason.SELF_DESTRUCT, + "lostconnect": WinReason.SELF_DESTRUCT, + "disconnect": WinReason.SELF_DESTRUCT, + "disconnected": WinReason.SELF_DESTRUCT, + "timeout": WinReason.SELF_DESTRUCT, + "time_out": WinReason.SELF_DESTRUCT, + "abort": WinReason.SELF_DESTRUCT, + "error": WinReason.SELF_DESTRUCT, + "自滅": WinReason.SELF_DESTRUCT, + "相手の自滅": WinReason.SELF_DESTRUCT, + "続行不可": WinReason.SELF_DESTRUCT, + # Put勝ち (1-2 条件1・2) + "put": WinReason.PUT, + "put_win": WinReason.PUT, + "block": WinReason.PUT, + "surrounded": WinReason.PUT, + "put勝ち": WinReason.PUT, + "封鎖": WinReason.PUT, + # アイテム数優勢 (1-2 条件3) + "item": WinReason.ITEM, + "items": WinReason.ITEM, + "item_advantage": WinReason.ITEM, + "item_count": WinReason.ITEM, + "score": WinReason.ITEM, + "アイテム": WinReason.ITEM, + "アイテム数": WinReason.ITEM, + "アイテム数優勢": WinReason.ITEM, + # 引き分け (勝因なし) + "none": WinReason.NONE, + "null": WinReason.NONE, + "draw": WinReason.NONE, + "引き分け": WinReason.NONE, + "なし": WinReason.NONE, +} + +_OUTCOME_ALIASES: dict[str, Outcome] = { + "hot": Outcome.HOT, + "hot_win": Outcome.HOT, + "cool": Outcome.COOL, + "cool_win": Outcome.COOL, + "draw": Outcome.DRAW, + "drawn": Outcome.DRAW, + "tie": Outcome.DRAW, + "引き分け": Outcome.DRAW, +} + + +def _normalize(value: str) -> str: + return value.strip().lower().replace("-", "_").replace(" ", "_") + + +class BoutResult(BaseModel): + """1対戦分の入力。 + + ルール上「試合」は先攻・後攻を入れ替えた2つの「対戦」から成るため、 + この型は試合の半分 (前半戦もしくは後半戦) に相当する。 + """ + + model_config = ConfigDict(populate_by_name=True) + + hot: str = Field( + description="Hot側のユーザー名 (リクエスト内で一意なID)", + validation_alias=AliasChoices("hot", "hot_user", "hotUser", "hot_name", "hotName"), + ) + cool: str = Field( + description="Cool側のユーザー名 (リクエスト内で一意なID)", + validation_alias=AliasChoices("cool", "cool_user", "coolUser", "cool_name", "coolName"), + ) + winner: Outcome = Field( + description="どちらが勝ったか (hot / cool / draw)", + validation_alias=AliasChoices("winner", "result", "win", "winner_side"), + ) + reason: WinReason = Field( + default=WinReason.NONE, + description="勝因 (self_destruct / put / item)。引き分けの場合は none", + validation_alias=AliasChoices("reason", "win_reason", "winReason", "cause", "勝因"), + ) + + @field_validator("hot", "cool", mode="after") + @classmethod + def _require_name(cls, value: str) -> str: + name = value.strip() + if not name: + raise ValueError("ユーザー名が空です") + return name + + @field_validator("winner", mode="before") + @classmethod + def _coerce_winner(cls, value: Any) -> Any: + if isinstance(value, str): + return _OUTCOME_ALIASES.get(_normalize(value), value) + return value + + @field_validator("reason", mode="before") + @classmethod + def _coerce_reason(cls, value: Any) -> Any: + if value is None: + return WinReason.NONE + if isinstance(value, str): + return _REASON_ALIASES.get(_normalize(value), value) + return value + + @model_validator(mode="after") + def _check_consistency(self) -> BoutResult: + if self.hot == self.cool: + raise ValueError("HotとCoolに同一のユーザーを指定できません") + if self.winner is Outcome.DRAW and self.reason is not WinReason.NONE: + raise ValueError("引き分けの対戦に勝因は指定できません") + if self.winner is not Outcome.DRAW and self.reason is WinReason.NONE: + raise ValueError("勝敗が付いた対戦には勝因が必要です") + return self + + @property + def winner_name(self) -> str | None: + """勝者のユーザー名。引き分けなら None。""" + if self.winner is Outcome.HOT: + return self.hot + if self.winner is Outcome.COOL: + return self.cool + return None + + @property + def loser_name(self) -> str | None: + """敗者のユーザー名。引き分けなら None。""" + if self.winner is Outcome.HOT: + return self.cool + if self.winner is Outcome.COOL: + return self.hot + return None + + +class ReasonBreakdown(BaseModel): + """勝因別の勝利数。""" + + self_destruct: int = Field(default=0, description="相手エージェントの自滅による勝利数") + put: int = Field(default=0, description="Put勝ちの数") + item: int = Field(default=0, description="アイテム数優勢による勝利数") + + def add(self, reason: WinReason) -> None: + if reason is WinReason.SELF_DESTRUCT: + self.self_destruct += 1 + elif reason is WinReason.PUT: + self.put += 1 + elif reason is WinReason.ITEM: + self.item += 1 + + def merge(self, other: ReasonBreakdown) -> None: + self.self_destruct += other.self_destruct + self.put += other.put + self.item += other.item + + @property + def total(self) -> int: + return self.self_destruct + self.put + self.item + + @property + def comparison_key(self) -> tuple[int, ...]: + """ルール 1-3 の重みづけ順に並べた比較キー (辞書式比較用)。""" + return tuple(getattr(self, reason.value) for reason in REASON_ORDER) + + +class BoutOutcome(BaseModel): + """判定済みの1対戦。""" + + index: int = Field(description="入力配列における位置 (0始まり)") + hot: str + cool: str + winner: str | None = Field(default=None, description="勝者のユーザー名。引き分けなら null") + loser: str | None = Field(default=None, description="敗者のユーザー名。引き分けなら null") + reason: WinReason = WinReason.NONE + + +class MatchNoteCode(str, Enum): + """試合の判定に関する注記コード。 + + 自然言語のメッセージではなくこのコードで判定理由を表現する。 + 表示用の文言が必要な場合は、利用側でコードから引く。 + """ + + #: 2対戦の対戦スコアが並んだ (ルール 1-3)。 + SCORE_TIED = "score_tied" + #: 再試合が行われたため、この試合結果は無効 (ルール 1-4)。related_match_no に再試合の試合番号が入る。 + REMATCH_HELD = "rematch_held" + #: 試合を構成する対戦が不足している (ルール 0. 用語集「試合」)。bout_count / expected_bout_count 参照。 + BOUTS_MISSING = "bouts_missing" + #: 2対戦で先攻・後攻が入れ替わっていない (ルール 0. 用語集「試合」)。 + SIDES_NOT_SWAPPED = "sides_not_swapped" + + +class MatchNote(BaseModel): + """試合の判定に関する注記。""" + + code: MatchNoteCode = Field(description="注記コード") + rule: str | None = Field(default=None, description="典拠となるルール細則の項番") + related_match_no: int | None = Field( + default=None, description="関連する試合番号 (REMATCH_HELD の再試合など)" + ) + bout_count: int | None = Field(default=None, description="実際の対戦数 (BOUTS_MISSING)") + expected_bout_count: int | None = Field( + default=None, description="必要な対戦数 (BOUTS_MISSING)" + ) + + +class MatchResult(BaseModel): + """判定済みの1試合 (先攻・後攻を入れ替えた2対戦)。""" + + match_no: int = Field(default=0, description="試合番号 (1始まり)") + players: tuple[str, str] = Field(description="対戦した2名 (ユーザー名の昇順)") + bouts: list[BoutOutcome] = Field(default_factory=list) + scores: dict[str, ReasonBreakdown] = Field( + default_factory=dict, description="ユーザーごとの対戦スコア内訳" + ) + winner: str | None = Field(default=None, description="試合の勝者。未決着なら null") + loser: str | None = Field(default=None, description="試合の敗者。未決着なら null") + is_draw: bool = Field(default=False, description="対戦スコアが並び引き分けとなったか") + rematch_required: bool = Field( + default=False, description="ルール 1-4 により再試合が必要 (未消化) か" + ) + voided: bool = Field( + default=False, description="再試合が行われたため結果が無効となったか (ルール 1-4)" + ) + notes: list[MatchNote] = Field( + default_factory=list, description="判定に関する補足・警告 (コード形式)" + ) + + def has_note(self, code: MatchNoteCode) -> bool: + return any(note.code is code for note in self.notes) + + @property + def is_decided(self) -> bool: + """順位計算に算入できる (有効かつ決着済みの) 試合か。""" + return not self.voided and self.winner is not None + + +class PlayerStanding(BaseModel): + """1ユーザー分の順位表エントリ。""" + + rank: int = Field(description="順位 (同着は同順位、その分だけ次の順位を飛ばす)") + player: str + matches: int = Field(default=0, description="決着した試合数") + wins: int = Field(default=0, description="試合の勝利数") + losses: int = Field(default=0, description="試合の敗北数") + bout_wins: int = Field(default=0, description="対戦の勝利数") + bout_losses: int = Field(default=0, description="対戦の敗北数") + bout_draws: int = Field(default=0, description="対戦の引き分け数") + win_reasons: ReasonBreakdown = Field( + default_factory=ReasonBreakdown, description="勝因別の対戦勝利数" + ) + pending_rematches: int = Field( + default=0, description="引き分けにより再試合待ちとなっている試合数" + ) + + +class RankingResult(BaseModel): + """順位計算の結果。""" + + standings: list[PlayerStanding] = Field(default_factory=list, description="順位表") + matches: list[MatchResult] = Field(default_factory=list, description="試合ごとの判定結果") + + @property + def rematches_required(self) -> list[MatchResult]: + return [match for match in self.matches if match.rematch_required] diff --git a/chros-score/tests/test_ranking.py b/chros-score/tests/test_ranking.py new file mode 100644 index 0000000..bdf15db --- /dev/null +++ b/chros-score/tests/test_ranking.py @@ -0,0 +1,212 @@ +import pytest +from fastapi.testclient import TestClient +from pydantic import TypeAdapter, ValidationError + +from src.score_api.main import app +from src.score_api.ranking import BoutResult, MatchNoteCode, WinReason, calculate_ranking + +bouts_adapter = TypeAdapter(list[BoutResult]) + + +def parse(raw: list[dict]) -> list[BoutResult]: + return bouts_adapter.validate_python(raw) + + +def bout(hot: str, cool: str, winner: str, reason: str) -> dict: + return {"hot": hot, "cool": cool, "winner": winner, "reason": reason} + + +def test_勝因の重みで試合の勝敗が決まる(): + # a はアイテム数優勢で1勝、b はPut勝ちで1勝 → Put勝ちの方が重い + result = calculate_ranking( + parse( + [ + bout("a", "b", "hot", "item"), + bout("b", "a", "hot", "put"), + ] + ) + ) + match = result.matches[0] + assert match.winner == "b" + assert match.loser == "a" + assert not match.rematch_required + assert [s.player for s in result.standings] == ["b", "a"] + assert [s.rank for s in result.standings] == [1, 2] + assert result.standings[0].wins == 1 + assert result.standings[0].bout_wins == 1 + assert result.standings[0].win_reasons.put == 1 + + +def test_自滅勝ちはPut勝ちより重い(): + result = calculate_ranking( + parse( + [ + bout("a", "b", "hot", "self_destruct"), + bout("b", "a", "hot", "put"), + ] + ) + ) + assert result.matches[0].winner == "a" + + +def test_同じ勝因で1勝ずつなら引き分けで再試合が必要(): + result = calculate_ranking( + parse( + [ + bout("a", "b", "hot", "put"), + bout("b", "a", "hot", "put"), + ] + ) + ) + match = result.matches[0] + assert match.is_draw + assert match.rematch_required + assert match.winner is None + assert [note.code for note in match.notes] == [MatchNoteCode.SCORE_TIED] + assert all(s.wins == 0 and s.matches == 0 for s in result.standings) + assert all(s.pending_rematches == 1 for s in result.standings) + assert [m.match_no for m in result.rematches_required] == [1] + + +def test_2対戦とも勝った側が試合の勝者(): + result = calculate_ranking( + parse( + [ + bout("a", "b", "hot", "item"), + bout("b", "a", "cool", "item"), + ] + ) + ) + assert result.matches[0].winner == "a" + assert result.standings[0].player == "a" + assert result.standings[0].bout_wins == 2 + assert result.standings[1].bout_losses == 2 + + +def test_引き分けの対戦を含む試合(): + # 前半は引き分け、後半は a が勝ち → 試合は a の勝ち + result = calculate_ranking( + parse( + [ + {"hot": "a", "cool": "b", "winner": "draw"}, + bout("b", "a", "cool", "put"), + ] + ) + ) + match = result.matches[0] + assert match.winner == "a" + standing = {s.player: s for s in result.standings} + assert standing["a"].bout_draws == 1 + assert standing["b"].bout_draws == 1 + assert standing["a"].bout_wins == 1 + + +def test_再試合が行われると前の引き分け試合は無効になる(): + result = calculate_ranking( + parse( + [ + bout("a", "b", "hot", "put"), + bout("b", "a", "hot", "put"), + # 再試合 + bout("a", "b", "hot", "put"), + bout("b", "a", "cool", "item"), + ] + ) + ) + first, second = result.matches + assert first.voided + assert not first.rematch_required + assert [note.code for note in first.notes] == [ + MatchNoteCode.SCORE_TIED, + MatchNoteCode.REMATCH_HELD, + ] + assert first.notes[1].related_match_no == second.match_no + assert second.winner == "a" + standing = {s.player: s for s in result.standings} + assert standing["a"].wins == 1 + assert standing["a"].matches == 1 + assert standing["a"].pending_rematches == 0 + assert standing["b"].losses == 1 + + +def test_総当たりで試合数が並ぶと勝因の重みで順位が決まる(): + result = calculate_ranking( + parse( + [ + # a > b (自滅勝ち) + bout("a", "b", "hot", "self_destruct"), + bout("b", "a", "cool", "self_destruct"), + # b > c (アイテム数優勢) + bout("b", "c", "hot", "item"), + bout("c", "b", "cool", "item"), + # c > a (アイテム数優勢) + bout("c", "a", "hot", "item"), + bout("a", "c", "cool", "item"), + ] + ) + ) + # 3者とも1勝1敗。a のみ自滅勝ちを持つので a が1位、b と c は完全に並ぶ + assert [(s.player, s.rank) for s in result.standings] == [("a", 1), ("b", 2), ("c", 2)] + assert all(s.wins == 1 and s.losses == 1 for s in result.standings) + + +def test_対戦が1件しかない組み合わせは注記付きで判定する(): + result = calculate_ranking(parse([bout("a", "b", "hot", "put")])) + match = result.matches[0] + assert match.winner == "a" + assert match.has_note(MatchNoteCode.BOUTS_MISSING) + note = match.notes[0] + assert (note.bout_count, note.expected_bout_count) == (1, 2) + + +def test_先攻後攻が入れ替わっていないと注記が付く(): + result = calculate_ranking( + parse( + [ + bout("a", "b", "hot", "put"), + bout("a", "b", "hot", "item"), + ] + ) + ) + assert result.matches[0].has_note(MatchNoteCode.SIDES_NOT_SWAPPED) + + +def test_勝因の表記ゆれを吸収する(): + parsed = parse( + [ + {"hot": "a", "cool": "b", "winner": "hot", "reason": "LostConnect"}, + {"hot": "b", "cool": "a", "winner": "cool", "win_reason": "アイテム数優勢"}, + ] + ) + assert parsed[0].reason is WinReason.SELF_DESTRUCT + assert parsed[1].reason is WinReason.ITEM + + +def test_不正な入力を弾く(): + with pytest.raises(ValidationError): + parse([bout("a", "a", "hot", "put")]) + with pytest.raises(ValidationError): + parse([{"hot": "a", "cool": "b", "winner": "hot"}]) + with pytest.raises(ValidationError): + parse([{"hot": "a", "cool": "b", "winner": "draw", "reason": "put"}]) + + +def test_APIエンドポイントが順位表を返す(): + client = TestClient(app) + response = client.post( + "/ranking", + json=[ + bout("player1", "player2", "hot", "put"), + bout("player2", "player1", "cool", "item"), + ], + ) + assert response.status_code == 200 + body = response.json() + assert [(s["player"], s["rank"]) for s in body["standings"]] == [("player1", 1), ("player2", 2)] + assert body["matches"][0]["winner"] == "player1" + + +def test_APIエンドポイントが不正な入力に422を返す(): + client = TestClient(app) + response = client.post("/ranking", json=[bout("a", "b", "hot", "unknown_reason")]) + assert response.status_code == 422