diff --git a/.env.example b/.env.example deleted file mode 100644 index b8386ed..0000000 --- a/.env.example +++ /dev/null @@ -1,29 +0,0 @@ -DISCORD_TOKEN= -DISCORD_GUILD_ID= -MAIN_TEXT_CHANNEL_ID= -MAIN_VOICE_CHANNEL_ID= -WOLFBOT_DB_PATH=./wolfbot.db -LOG_LEVEL=INFO - -# LLM provider: "xai" (default), "deepseek", or "gemini". Lowercase only. -LLM_PROVIDER=xai - -# xAI (required when LLM_PROVIDER=xai) -XAI_API_KEY= -XAI_MODEL=grok-4-1-fast-reasoning - -# DeepSeek (required when LLM_PROVIDER=deepseek) -DEEPSEEK_API_KEY= -DEEPSEEK_BASE_URL=https://api.deepseek.com -DEEPSEEK_MODEL=deepseek-v4-flash -DEEPSEEK_THINKING=enabled -DEEPSEEK_REASONING_EFFORT=max - -# Google Gemini on Vertex AI (required when LLM_PROVIDER=gemini) -# Authenticate with Application Default Credentials: -# local: gcloud auth application-default login -# production: use an attached service account with Vertex AI permissions -GEMINI_VERTEX_PROJECT= -GEMINI_VERTEX_LOCATION=global -GEMINI_MODEL=gemini-3-flash-preview -GEMINI_THINKING_LEVEL=high diff --git a/.env.master.example b/.env.master.example new file mode 100644 index 0000000..3129a1b --- /dev/null +++ b/.env.master.example @@ -0,0 +1,141 @@ +# ── Master bot (.env.master) ─────────────────────────── +# Used by `uv run wolfbot`. Loaded from `.env.master` via +# wolfbot.config.MasterSettings. + +# Discord Developer Portal で発行した bot トークン (Master 用) +DISCORD_TOKEN= +# bot を動かす Discord サーバー(guild)の数値 ID +DISCORD_GUILD_ID= +# 議論用メイン text チャンネルの数値 ID +MAIN_TEXT_CHANNEL_ID= +# プレイヤーが会話するメイン VC の数値 ID +MAIN_VOICE_CHANNEL_ID= +# SQLite データベースの保存先パス +WOLFBOT_DB_PATH=./wolfbot.db +# ログ出力レベル (DEBUG / INFO / WARNING / ERROR) +LOG_LEVEL=INFO + +# ── 議論モード ───────────────────────────────────────── +# rounds : Master が全 LLM 席のターン制議論文も生成する +# reactive_voice : 議論文は NPC bot プロセスが VC で発話する +# (Master は投票・夜行動の判断のみ生成) +LLM_DISCUSSION_MODE=reactive_voice + +# ── Master ↔ NPC bot / voice-ingest WebSocket ──────── +# Master ↔ NPC bot 間 WebSocket の listen アドレス +MASTER_WS_LISTEN=127.0.0.1:8800 +# NPC bot の WebSocket 認証用 Pre-Shared Key (NPC 側 .env.npc.* と同値) +MASTER_NPC_PSK= + +# ── Gameplay LLM (Master が"判断する"側) ─────────────── +# Master が LLM 席の挙動を決めるときに使う LLM。担当範囲: +# • 投票判断 (誰を処刑するか) ────────────── 全モード +# • 夜行動 (人狼の襲撃先 / 占い / 護衛) ──── 全モード +# • 議論ターンの発言文生成 ──────────────── rounds モードのみ +# (reactive_voice モードでは議論文は NPC bot 側に移譲され、Master は +# この LLM で投票と夜行動だけを生成する) +# +# Backend は 4 種類から選べる: +# xai ─ xAI Grok など OpenAI Chat Completions 互換 endpoint +# (OpenAI / Groq / Together / vLLM / Ollama も同じ枠で可) +# deepseek ─ DeepSeek (json_object モード + thinking + reasoning_effort) +# gemini ─ Vertex AI Gemini (ADC/IAM 認証、API key 認証は非対応) +# mock ─ オフライン統合テスト用。API コールせず決定論的な +# LLMAction を返す (投票/夜行動は target_name=None で +# LLMAdapter 側の random fallback に委譲、議論発言は +# 定型句のラウンドロビン)。API キー不要。 +# NPC bot 側 NPC_LLM_PROVIDER と独立に選べる (片方を Vertex Gemini、 +# 片方を xAI Grok にする、といった片寄せも可能)。 +GAMEPLAY_LLM_PROVIDER=xai + +# xAI / DeepSeek / OpenAI 互換 endpoint で使う API キー (gemini なら不要) +GAMEPLAY_LLM_API_KEY= +GAMEPLAY_LLM_MODEL=grok-4-1-fast +# 自前 endpoint や別プロバイダを叩く場合のみ上書き。空のままなら provider +# 既定値 (xai: https://api.x.ai/v1, deepseek: https://api.deepseek.com) +GAMEPLAY_LLM_BASE_URL= + +# DeepSeek 専用 (provider=deepseek 以外では無視される) +GAMEPLAY_LLM_THINKING=enabled +GAMEPLAY_LLM_REASONING_EFFORT=max + +# Vertex AI Gemini 専用 (provider=gemini 以外では無視される) +# 認証は ADC のみ: ローカルは `gcloud auth application-default login`、 +# 本番は Vertex AI 権限を持つ service account を attach する。 +GAMEPLAY_LLM_VERTEX_PROJECT= +GAMEPLAY_LLM_VERTEX_LOCATION=global +GAMEPLAY_LLM_THINKING_LEVEL=high + +# ── フェーズ duration (検証・モック用の早送り) ──────── +# 全フェーズの長さに掛ける倍率。0.1 = 10倍速、1.0 = デフォルト。 +# 1秒未満になる場合は 1秒にクランプ。空のままならデフォルト維持。 +# 個別フェーズのみ短くしたいときは下の WOLFBOT_*_DURATION で絶対秒指定 +# (倍率と個別指定は両方適用、個別指定が後勝ち)。 +# 将来的には Master bot の slash command で動的変更できるようになるため、 +# このシングルトンが書き換え地点 (wolfbot.domain.durations.set_phase_durations)。 +WOLFBOT_PHASE_DURATION_FACTOR= + +# 個別フェーズ秒数の絶対指定 (空ならデフォルト or 倍率適用後の値を維持) +WOLFBOT_VOTE_DURATION= +WOLFBOT_RUNOFF_DURATION= +WOLFBOT_NIGHT_DURATION= +WOLFBOT_DAY_DISCUSSION_GRACE= +WOLFBOT_RUNOFF_SPEECH_GRACE= +WOLFBOT_DISCUSSION_DAY1= +WOLFBOT_DISCUSSION_DAY2= +WOLFBOT_DISCUSSION_DAY3PLUS= + +# ── 音声 / テキスト ingest パイプラインの形状 ────────────── +# 人間の発言 (音声 / テキスト) を構造化 SpeechEvent +# (addressed_seat_no / co_declaration / role_callout) に変換する経路 +# を 2 つの直交スイッチで明示する。各経路に必要なコンポーネントの +# クレデンシャルは下のブロックで個別に設定する。 +# +# VOICE_PIPELINE: 人間の音声をどう取り込むか +# audio_analyzer : 1 コールの multimodal (audio → 構造化 JSON) +# 現状は AUDIO_ANALYZER_PROVIDER=gemini のみ対応 +# stt_then_text_analyzer : 2 段 (Stt で文字起こし → TextAnalyzer で構造化) +# TextAnalyzer はテキスト経路と同じインスタンスを共有 +# disabled : Master は VC に参加しない (rounds モードのデフォルト) +VOICE_PIPELINE=disabled +# +# TEXT_PIPELINE: テキスト発言をどう取り込むか +# text_analyzer : 各テキスト発言ごとに TextAnalyzer を 1 コール +# passthrough : 構造解析なし (SpeakArbiter は LRU 一本でルーティング) +TEXT_PIPELINE=passthrough + +# ── AudioAnalyzer (audio → transcript + 構造化) ───────────── +# VOICE_PIPELINE=audio_analyzer のときのみ必須。 +# 1 API コールで書き起こし + CO 検出 + 投票先 + addressed_name を返す +# multimodal LLM。現状 Gemini Flash 系のみ (xAI / Groq は audio-in 非対応)。 +AUDIO_ANALYZER_PROVIDER=gemini +AUDIO_ANALYZER_API_KEY= +AUDIO_ANALYZER_MODEL=gemini-2.0-flash-lite + +# ── Stt (audio → transcript) ──────────────────────────── +# VOICE_PIPELINE=stt_then_text_analyzer のときのみ必須。 +# Whisper-large-v3-turbo が Groq 上で最安かつ日本語実用品質。 +# whisper-large-v3 に切り替えると精度↑コスト約3倍。 +STT_PROVIDER=groq +STT_API_KEY= +STT_MODEL=whisper-large-v3-turbo +STT_BASE_URL=https://api.groq.com/openai/v1 + +# ── TextAnalyzer (text → 構造化) ──────────────────────── +# TEXT_PIPELINE=text_analyzer のとき (発言ごと)、または +# VOICE_PIPELINE=stt_then_text_analyzer のとき (Stt 後段) に使用。 +# 両経路で同じインスタンスを共有 → クレデンシャルプールも一本化。 +# GAMEPLAY_LLM_* とは意図的に分離 (Gameplay は重い思考モデル、 +# 構造解析は安い高 RPM モデル、の組み合わせを許す)。 +# +# プロバイダ: +# xai / deepseek / openai : OpenAI Chat Completions 互換 +# (API_KEY 必須、BASE_URL は任意) +# gemini : AI Studio REST (AIza... 形式の API_KEY 必須) +# ※ VOICE_PIPELINE=stt_then_text_analyzer の +# 後段としては未対応 (audio_analyzer に寄せる) +TEXT_ANALYZER_PROVIDER=xai +TEXT_ANALYZER_API_KEY= +TEXT_ANALYZER_MODEL=grok-4-1-fast +# 自前 endpoint (vLLM / Ollama 等) のときのみ上書き +TEXT_ANALYZER_BASE_URL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5f08c7d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,86 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + python: + name: Python (lint + type + tests) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python 3.11 + run: uv python install 3.11 + + # discord.py loads libopus via ctypes for VC tests. The ubuntu + # runner image doesn't ship it by default, so OpusError(...) + # crashes inside `_lib.opus_strerror` and breaks test collection + # in tests/test_voice_recv_resilience.py. + - name: Install libopus (for voice tests) + run: sudo apt-get update && sudo apt-get install -y libopus0 + + - name: Sync deps + run: uv sync --all-extras --dev + + - name: Lint + run: uv run ruff check src tests + + - name: Type check + run: uv run mypy + + - name: Test + run: uv run pytest tests + + viewer: + name: Viewer (typecheck + contract tests) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # The viewer's contract test spawns the real Python exporter + # (`scripts/export-game.py`) against a seeded fixture DB, so this + # job needs both Node and uv. + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python 3.11 + run: uv python install 3.11 + + - name: Sync Python deps (for live exporter spawn) + run: uv sync --all-extras --dev + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: viewer/pnpm-lock.yaml + + - name: Install viewer deps + working-directory: viewer + run: pnpm install --frozen-lockfile + + - name: Type check + working-directory: viewer + run: pnpm typecheck + + - name: Contract tests (vitest + live Python exporter) + working-directory: viewer + run: pnpm test diff --git a/.gitignore b/.gitignore index 8a50b42..c7b70f6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,11 +8,31 @@ __pycache__/ dist/ build/ .env +.env.master +# NPC bot env files live under envs/npc/. The committed `.env.npc.example` +# template is the only one tracked; the per-persona `.env.` files +# (generated by scripts/generate_npc_envs.py) stay out of git. +envs/npc/.env.* +!envs/npc/.env.npc.example +envs/voicetest/.env.* +!envs/voicetest/.env.voicetest.example +# Local secrets used by the generator +tokens.txt *.db *.db-journal *.db-wal *.db-shm .DS_Store +# scripts/run-bots.sh writes per-bot logs here. +logs/ + # for git push -.claude/ \ No newline at end of file +.claude/ + +# Specflow local env +.specflow/config.env +.specflow/runs/ +.specflow/worktrees/ +.codex +openspec \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index f9eea5f..d8776ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,10 @@ # Repository Guidelines ## Project Structure & Module Organization -Runtime code lives in `src/wolfbot`. Keep pure game logic in `domain/`, orchestration in `services/`, SQLite access in `persistence/`, LLM personas and prompts in `llm/`, and Discord UI views in `ui/`. Tests live in `tests/`, with shared fixtures in `tests/conftest.py` and fakes in `tests/fakes.py`. Use `.env.example` for local settings, and read `prompts/IMPLEMENTATION_PROMPT.md` before changing roles, phases, or event ordering. +Runtime code lives in `src/wolfbot`. Keep pure game logic in `domain/`, orchestration in `services/`, SQLite access in `persistence/`, shared persona types in `llm/persona_base.py` (player personas in `npc/personas.py`, GM personas in `master/personas.py`), and Discord UI views in `ui/`. The Master-only reactive-voice pipeline lives in `master/`; the per-process NPC bot worker lives in `npc/` with its own `NpcSettings`. Each NPC bot process is bound to one persona at startup (`NPC_PERSONA_KEY`); Master's `/wolf start` fills LLM seats from the online NPC registry in reactive_voice mode. Tests live in `tests/`, with shared fixtures in `tests/conftest.py` and fakes in `tests/fakes.py`. Use `.env.master.example` at the repo root for the Master env, and the per-persona templates in `envs/npc/.env..example` (see [envs/npc/README.md](envs/npc/README.md)) for each NPC process. Read `prompts/IMPLEMENTATION_PROMPT.md` before changing roles, phases, or event ordering. ## Build, Test, and Development Commands -Use `uv` for all local work. `uv sync` installs runtime and dev dependencies. `uv run wolfbot` starts the bot with values from `.env`. `uv run pytest tests` runs the full suite; `uv run pytest tests/test_rules_votes.py` runs one module. `uv run ruff check src tests` runs lint and import checks, `uv run ruff format src tests` formats code, and `uv run mypy` runs strict type checking. +Use `uv` for all local work. `uv sync` installs runtime and dev dependencies. `uv run wolfbot` starts the Master bot with values from `.env.master`. NPC workers are launched per persona: `WOLFBOT_NPC_ENV=envs/npc/.env. uv run wolfbot-npc`. `uv run pytest tests` runs the full suite; `uv run pytest tests/test_rules_votes.py` runs one module. `uv run ruff check src tests` runs lint and import checks, `uv run ruff format src tests` formats code, and `uv run mypy` runs strict type checking. ## Coding Style & Naming Conventions Target Python 3.11 and keep 4-space indentation. Follow Ruff defaults with a 100-character line length; let `ruff format` handle wrapping instead of manual alignment. New code should be fully type-annotated because `mypy` runs in strict mode. Use `snake_case` for modules, functions, and test files such as `test_recovery.py`; use `PascalCase` for classes and Pydantic models. diff --git a/CLAUDE.md b/CLAUDE.md index 777a45a..091962f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ Contributor-facing conventions (commit style, test naming, PR expectations) live ```bash uv sync # install deps (main + dev) uv run wolfbot # run the bot (requires .env) +uv run wolfbot-npc # run one NPC bot (requires NPC env vars) uv run pytest tests # full test suite uv run pytest tests/test_rules_votes.py # single file @@ -32,26 +33,67 @@ uv run mypy # strict typecheck (pac ## Required environment variables -From `.env.example`. The provider identity field (`XAI_API_KEY`, `DEEPSEEK_API_KEY`, or `GEMINI_VERTEX_PROJECT`) is conditionally required by `LLM_PROVIDER`; the cross-field check lives in a `model_validator(mode='after')` on `Settings` and fails fast at boot if the active provider's required field is missing or empty. Note: `GEMINI_VERTEX_PROJECT` only identifies the GCP project — Vertex AI credentials come from ADC (gcloud locally, attached service account in production), not from this var. Vertex AI Express mode and API-key auth are deliberately unsupported. - -- `DISCORD_TOKEN` — bot token (SecretStr) -- `LLM_PROVIDER` — `xai` (default), `deepseek`, or `gemini`. Lowercase only. -- `XAI_API_KEY` — xAI API key (SecretStr); required when `LLM_PROVIDER=xai` -- `XAI_MODEL` — model name (default `grok-4-1-fast-reasoning`) -- `DEEPSEEK_API_KEY` — DeepSeek API key (SecretStr); required when `LLM_PROVIDER=deepseek` -- `DEEPSEEK_BASE_URL` — default `https://api.deepseek.com` -- `DEEPSEEK_MODEL` — default `deepseek-v4-flash` -- `DEEPSEEK_THINKING` — `enabled` (default) or `disabled` -- `DEEPSEEK_REASONING_EFFORT` — `high` or `max` (default); only forwarded when thinking is enabled -- `GEMINI_VERTEX_PROJECT` — GCP project ID for Vertex AI; required when `LLM_PROVIDER=gemini`. Credentials come from ADC, not from this var. Empty string is rejected at boot. -- `GEMINI_VERTEX_LOCATION` — default `global`; the Vertex AI Gemini location -- `GEMINI_MODEL` — default `gemini-3-flash-preview` -- `GEMINI_THINKING_LEVEL` — `minimal` / `low` / `medium` / `high` (default `high`) +Two env files, one per process. The Gameplay LLM (Master) and the NPC speech LLM (each NPC worker) are configured by **the same provider switch**, just under different env-var prefixes (`GAMEPLAY_LLM_*` vs `NPC_LLM_*`). Both reuse :class:`wolfbot.llm.decider_config.LLMDeciderConfig` internally and dispatch to one of three providers (`xai` / `deepseek` / `gemini`). The provider field is conditionally required by the role's `*_PROVIDER`; the cross-field check is a `model_validator(mode='after')` on each Settings class and fails fast at boot. Note: `*_VERTEX_PROJECT` only identifies the GCP project — Vertex AI credentials come from ADC (gcloud locally, attached service account in production), not from this var. Vertex AI Express mode and API-key auth are deliberately unsupported. + +**Master** (`.env.master`, see `.env.master.example`) — loaded by `src/wolfbot/config.py::MasterSettings`, instantiated once in `main.py`: + +- `DISCORD_TOKEN` — Master bot token (SecretStr) - `DISCORD_GUILD_ID`, `MAIN_TEXT_CHANNEL_ID`, `MAIN_VOICE_CHANNEL_ID` — ints - `WOLFBOT_DB_PATH` — SQLite path (default `./wolfbot.db`) - `LOG_LEVEL` — default `INFO` - -Loaded at boot by `src/wolfbot/config.py::Settings` (pydantic-settings, reads `.env`, instantiated once in `main.py`). Adding a new env var = adding a typed field to `Settings` — do not parse `os.environ` directly from code paths. +- `LLM_DISCUSSION_MODE` — `rounds` (default) or `reactive_voice` +- `MASTER_WS_LISTEN` — Master WS bind address (default `127.0.0.1:8800`) +- `MASTER_NPC_PSK` — optional PSK for NPC/voice-ingest WS auth (SecretStr) +- **Gameplay LLM** — drives every gameplay decision Master makes on behalf of LLM seats: votes (always), night actions (always — wolf attack / divine / guard), and day-discussion text **in rounds mode** (in reactive_voice mode the discussion text is offloaded to NPC bots, but votes / night actions still go through this LLM). Provider switch: + - `GAMEPLAY_LLM_PROVIDER` — `xai` (default) / `deepseek` / `gemini`. Lowercase only. + - `GAMEPLAY_LLM_API_KEY` (SecretStr) — required when provider is `xai` or `deepseek`. Any OpenAI Chat Completions–compatible endpoint works (xAI Grok / OpenAI / Groq / Together / vLLM / Ollama / DeepSeek). + - `GAMEPLAY_LLM_MODEL` — model name (default `grok-4-1-fast`). + - `GAMEPLAY_LLM_BASE_URL` — override the provider default base URL when pointing at a self-hosted OpenAI-compatible endpoint. Optional. + - `GAMEPLAY_LLM_THINKING` — `enabled` (default) or `disabled`. **DeepSeek-only**, ignored otherwise. + - `GAMEPLAY_LLM_REASONING_EFFORT` — `high` or `max` (default). **DeepSeek-only**, only forwarded when thinking is enabled. + - `GAMEPLAY_LLM_VERTEX_PROJECT` — GCP project ID. **Required when provider is `gemini`**. Credentials come from ADC, not from this var. Empty string is rejected at boot. + - `GAMEPLAY_LLM_VERTEX_LOCATION` — default `global`. **Gemini-only.** + - `GAMEPLAY_LLM_THINKING_LEVEL` — `minimal` / `low` / `medium` / `high` (default `high`). **Gemini-only.** +- **Voice / Text ingest pipelines** — two orthogonal switches define how human speech (voice in VC, typed in main text channel) becomes a structured `SpeechEvent` with `addressed_seat_no` / `co_declaration` / `role_callout`. The pipelines are composed from three independent components — **AudioAnalyzer** (audio → transcript + structured), **Stt** (audio → transcript), **TextAnalyzer** (text → structured) — each configured with its own credential block so changing the voice backend can never accidentally drag the text path along (the bug fixed in commit pre-2026-05-02 where `VOICE_STT_PROVIDER=gemini` silently routed all typed messages through the Gemini key and 429-throttled them). Validators on `MasterSettings` only require credentials for components the chosen pipeline actually instantiates, so a rounds-mode boot needs none of these set. + - `VOICE_PIPELINE` — `audio_analyzer` (one multimodal call) / `stt_then_text_analyzer` (Stt + TextAnalyzer composed) / `disabled` (default; Master does not join VC). Only the multimodal path is currently implemented for `AUDIO_ANALYZER_PROVIDER=gemini`; the split path requires an OpenAI-compatible TextAnalyzer. + - `TEXT_PIPELINE` — `text_analyzer` (one TextAnalyzer call per typed message) / `passthrough` (default; structured fields stay null, SpeakArbiter falls back to LRU rotation). + - **AudioAnalyzer block** (used iff `VOICE_PIPELINE=audio_analyzer`): + - `AUDIO_ANALYZER_PROVIDER` — `gemini` (only supported value today; xAI/Groq lack audio-in chat-completion endpoints). + - `AUDIO_ANALYZER_API_KEY` (SecretStr) — AI Studio key. + - `AUDIO_ANALYZER_MODEL` — default `gemini-2.0-flash-lite`. + - **Stt block** (used iff `VOICE_PIPELINE=stt_then_text_analyzer`): + - `STT_PROVIDER` — `groq` (only supported value today). + - `STT_API_KEY` (SecretStr). + - `STT_MODEL` — default `whisper-large-v3-turbo`. + - `STT_BASE_URL` — default `https://api.groq.com/openai/v1`. + - **TextAnalyzer block** (used iff `TEXT_PIPELINE=text_analyzer` OR `VOICE_PIPELINE=stt_then_text_analyzer`; one instance shared across both paths): + - `TEXT_ANALYZER_PROVIDER` — `xai` (default) / `deepseek` / `openai` / `gemini`. The first three share the OpenAI Chat Completions surface; `gemini` uses the AI Studio REST API. The split voice path rejects `TEXT_ANALYZER_PROVIDER=gemini` at boot because `GroqWhisperAudioAnalyzer` only composes with the OpenAI-compatible analyzer shape. + - `TEXT_ANALYZER_API_KEY` (SecretStr). + - `TEXT_ANALYZER_MODEL` — default `grok-4-1-fast`. + - `TEXT_ANALYZER_BASE_URL` — optional override for vLLM / Ollama / etc.; empty = provider default. + + Component construction lives in `src/wolfbot/master/ingest_factory.py` (`build_text_analyzer` / `build_voice_ingest_provider`); `main.py` calls each helper exactly once and the returned instances are wired into `WolfCog.text_analyzer` and `VoiceIngestService.stt` respectively. Decoupling from `GAMEPLAY_LLM_*` is intentional — it lets Gameplay target a heavyweight reasoning model while text/audio analysis hits a cheap high-RPM one. + +**NPC bot** (`envs/npc/.env.`, one file per persona — see committed `envs/npc/.env..example` templates plus [envs/npc/README.md](envs/npc/README.md)) — loaded by `src/wolfbot/npc/config.py::NpcSettings`, instantiated once per worker in `wolfbot.npc.main`. **Each NPC bot process is bound to exactly one persona at startup** (`NPC_PERSONA_KEY`); NPC bots are not interchangeable. Required fields: + +- `NPC_ID` — unique NPC identifier on the WS (e.g. `npc_setsu`) +- `NPC_DISCORD_TOKEN` — this persona's Discord bot token (each persona has its own bot app, manually invited to the guild once) +- `NPC_PERSONA_KEY` — must be a key from `wolfbot.npc.personas.NPC_PERSONAS_BY_KEY` (`setsu`, `gina`, `sq`, `raqio`, `stella`, `shigemichi`, `chipie`, `comet`, `jonas`, `kukrushka`, `otome`, `sha_ming`, `remnan`, `yuriko`) +- `MASTER_WS_URL` — e.g. `ws://127.0.0.1:8800` +- `MASTER_NPC_PSK` — must match Master's value (SecretStr) +- **NPC LLM** — drives this NPC bot's short reactive utterances during DAY_DISCUSSION in reactive_voice mode. Does **not** decide votes or night actions — Master's `GAMEPLAY_LLM_*` handles those. The provider switch is symmetrical to `GAMEPLAY_LLM_*`: + - `NPC_LLM_PROVIDER` — `xai` (default) / `deepseek` / `gemini`. + - `NPC_LLM_API_KEY`, `NPC_LLM_MODEL`, `NPC_LLM_BASE_URL` — same semantics as the Gameplay equivalents. + - `NPC_LLM_THINKING`, `NPC_LLM_REASONING_EFFORT` — DeepSeek-only. + - `NPC_LLM_VERTEX_PROJECT`, `NPC_LLM_VERTEX_LOCATION`, `NPC_LLM_THINKING_LEVEL` — Gemini-only. + - The same credential may be reused across personas, and may also be shared with Master's `GAMEPLAY_LLM_*`, but the two roles are intentionally split so each can target a different provider / model. +- `TTS_VOICE_ID`, `VOICEVOX_URL` — VOICEVOX speaker / engine +- `MAIN_VOICE_CHANNEL_ID`, `DISCORD_GUILD_ID` — must match Master +- `HEARTBEAT_INTERVAL_S`, `LOG_LEVEL` + +Each NPC process picks its env file via the `WOLFBOT_NPC_ENV` env var (default: `.env.npc` in CWD, but with the `envs/npc/` layout you should always set this explicitly). Typical multi-persona deployment: `WOLFBOT_NPC_ENV=envs/npc/.env.setsu uv run wolfbot-npc` etc. + +Adding a new env var = adding a typed field to the appropriate `*Settings` class — do not parse `os.environ` directly from code paths. ## Architecture @@ -62,15 +104,24 @@ Outer layers depend on inner; never the reverse. ``` domain/ pure: enums, frozen models, rules, state_machine — no I/O, no asyncio ↑ -services/ orchestration: GameService, GameEngine, RecoveryService, PermissionManager +services/ orchestration: GameService, GameEngine, RecoveryService, PermissionManager, + LLMAdapter, DiscussionService, DiscordBotAdapter, ... ↑ persistence/ SqliteRepo (aiosqlite) llm/ personas + prompt builder prompts/ system prompt markdown (`llm_system_prompt.md`), read at runtime by prompt_builder ui/ discord.ui Views (DM vote/action selects) -main.py wiring +master/ Master-side reactive_voice pipeline (loaded only when MASTER_NPC_PSK is set): + ws_server, ingest_service, logic_service, speak_arbiter, npc_registry, + voice_ingest_service, voice_ingest_client, audio_sink, stt_service. +npc/ NPC bot worker (separate process via `uv run wolfbot-npc`): + main, config (NpcSettings), client, speech_service, + openai_compatible_generator, tts, playback. +main.py Master wiring (entrypoint `wolfbot`) ``` +`master/` and `npc/` are intentionally split because the two processes have disjoint runtime concerns: `master/` runs alongside the core `services/` in the Master Discord bot, while `npc/` is one-process-per-NPC and only talks to Master over WS. Anything imported by both lives in `domain/` (e.g. `domain/ws_messages.py`). + Within `services/`: `discord_service.py` contains both `DiscordBotAdapter` and the `WolfCog` slash-command dispatcher — new slash commands go in the cog, new Discord side-effects in the adapter. `submission_snapshot.py` is the shared pending-submission calculator, reused by `GameService` (early-wake checks) and `RecoveryService` (DM restoration on restart); reuse it rather than re-implement the "who still owes a submission" logic. ### The advance loop is the heart of the system @@ -144,7 +195,22 @@ During wolf-attack splits, the main channel announces only `未確定: N件` (hi ### LLM integration -`src/wolfbot/services/llm_service.py` exposes three deciders selected by `make_llm_decider(settings)` based on `settings.LLM_PROVIDER`. `XAILLMActionDecider` calls `https://api.x.ai/v1/chat/completions` with `response_format={"type":"json_schema", "json_schema": RESPONSE_SCHEMA}` strict mode (Grok rejects `reasoning_effort`/`extra_body`, so the xAI path deliberately sends neither). `DeepSeekLLMActionDecider` calls `https://api.deepseek.com` with `response_format={"type":"json_object"}` (DeepSeek doesn't support strict json_schema) plus a per-call JSON contract appended to the system prompt by `_deepseek_json_contract` so the model knows the exact field names; `DEEPSEEK_THINKING` toggles `extra_body={"thinking": {"type": ...}}` and, when enabled, forwards `reasoning_effort` (`high`/`max`). DeepSeek's `reasoning_content` is intentionally never read, logged, or persisted — only `message.content` is consumed. `GeminiLLMActionDecider` calls Vertex AI's Gemini API via the official `google-genai` SDK (`genai.Client(vertexai=True, project=..., location=...)` — endpoint is in the `aiplatform.googleapis.com` family, resolved by the SDK; with `location="global"` the SDK targets `https://aiplatform.googleapis.com/`). Authentication is ADC/IAM only (no API key); locally `gcloud auth application-default login`, in production an attached service account with Vertex AI permissions. Vertex AI Express mode and API-key auth are deliberately unsupported. Request shape: `client.aio.models.generate_content(...)` with `response_mime_type="application/json"` + `response_json_schema=RESPONSE_SCHEMA["schema"]` (Gemini 3 structured outputs), plus `thinking_config=types.ThinkingConfig(thinking_level=...)`; default `thinking_level="high"`. Gemini's internal thinking / thought signatures are never read, logged, or persisted — only `resp.text` is consumed (parallel to DeepSeek's `reasoning_content` rule). All three paths funnel through `LLMAction.model_validate_json` and share the same `tenacity` retry policy. The runtime markdown template (`src/wolfbot/prompts/llm_system_prompt.md`) is unchanged — the DeepSeek JSON contract is added at decider time only on the DeepSeek path; Gemini relies on `response_json_schema` and xAI on `json_schema` strict mode. +`src/wolfbot/services/llm_service.py` exposes three deciders selected by `make_llm_decider(cfg)` where `cfg: LLMDeciderConfig` is built from `MasterSettings.gameplay_decider_config()` (or `NpcSettings.npc_decider_config()` on the NPC side). The provider switch is one shared abstraction (`wolfbot.llm.decider_config.LLMDeciderConfig`) used by both the gameplay decider factory and the NPC speech generator factory (`wolfbot.npc.generator_factory.make_npc_generator`). The Master env prefix is `GAMEPLAY_LLM_*`; the NPC env prefix is `NPC_LLM_*`; the field semantics (provider, api_key, model, base_url, thinking, reasoning_effort, vertex_project, vertex_location, thinking_level) are identical. + +`XAILLMActionDecider` calls the configured OpenAI-compatible endpoint (default `https://api.x.ai/v1/chat/completions`; override via `*_LLM_BASE_URL` to point at OpenAI / Groq / Together / vLLM / Ollama / etc.) with `response_format={"type":"json_schema", "json_schema": RESPONSE_SCHEMA}` strict mode. Grok rejects `reasoning_effort`/`extra_body`, so the xAI path deliberately sends neither. `DeepSeekLLMActionDecider` calls `https://api.deepseek.com` with `response_format={"type":"json_object"}` (DeepSeek doesn't support strict json_schema) plus a per-call JSON contract appended to the system prompt by `_deepseek_json_contract` so the model knows the exact field names; `*_LLM_THINKING` toggles `extra_body={"thinking": {"type": ...}}` and, when enabled, forwards `reasoning_effort` (`high`/`max`). DeepSeek's `reasoning_content` is intentionally never read, logged, or persisted — only `message.content` is consumed. `GeminiLLMActionDecider` calls Vertex AI's Gemini API via the official `google-genai` SDK (`genai.Client(vertexai=True, project=..., location=...)` — endpoint is in the `aiplatform.googleapis.com` family, resolved by the SDK; with `location="global"` the SDK targets `https://aiplatform.googleapis.com/`). Authentication is ADC/IAM only (no API key); locally `gcloud auth application-default login`, in production an attached service account with Vertex AI permissions. Vertex AI Express mode and API-key auth are deliberately unsupported. Request shape: `client.aio.models.generate_content(...)` with `response_mime_type="application/json"` + `response_json_schema=RESPONSE_SCHEMA["schema"]` (Gemini 3 structured outputs), plus `thinking_config=types.ThinkingConfig(thinking_level=...)`; default `thinking_level="high"`. Gemini's internal thinking / thought signatures are never read, logged, or persisted — only `resp.text` is consumed (parallel to DeepSeek's `reasoning_content` rule). All three paths funnel through `LLMAction.model_validate_json` and share the same `tenacity` retry policy. The runtime markdown template (`src/wolfbot/prompts/llm_system_prompt.md`) is unchanged — the DeepSeek JSON contract is added at decider time only on the DeepSeek path; Gemini relies on `response_json_schema` and xAI on `json_schema` strict mode. + +The NPC speech path mirrors this exactly: `OpenAICompatibleNpcGenerator` handles xAI / OpenAI / DeepSeek (with `mode="json_object"`) by reusing the same decider-config-style switch in its config dataclass; `GeminiNpcGenerator` handles Vertex AI. The factory `wolfbot.npc.generator_factory.make_npc_generator(cfg, persona_key)` picks the right one based on `cfg.provider` and binds the persona to the worker process. + +### Reactive voice pipeline (realtime chat) + +When `LLM_DISCUSSION_MODE=reactive_voice` and `MASTER_NPC_PSK` is set, `main.py` wires a realtime voice pipeline: + +- **Master side** (`main.py`): `InMemoryNpcRegistry` + `SpeakArbiter` + `MasterIngestService` + `WebsocketsMasterWsServer`. The Master joins VC via `voice_recv.VoiceRecvClient` + `WolfbotAudioSink` for STT ingest, receives human speech via `GeminiAudioAnalyzer`, and dispatches NPC turns via the arbiter. On phase enter, `_on_reactive_phase_enter` assigns online NPCs to LLM seats. +- **NPC side** (`wolfbot.npc.main`, one process per NPC bot, **bound to one persona at startup**): `discord.Client` joins VC → WebSocket to Master (PSK auth, register payload carries `persona_key`) → `NpcClient` handles `SpeakRequest` → `OpenAICompatibleNpcGenerator` (NPC LLM call, structured JSON; default targets xAI Grok) renders speech in the bound persona → `NpcSpeechService` → `VoicevoxTtsService` (local VOICEVOX, 24kHz WAV) → `DiscordVoicePlayback` (`discord.FFmpegPCMAudio`). All NPC-side modules live under `wolfbot.npc.*`; the Master-side counterpart (`InMemoryNpcRegistry`, `SpeakArbiter`, `MasterIngestService`, `WebsocketsMasterWsServer`) lives under `wolfbot.master.*`. The NPC LLM backend is swappable via `NPC_LLM_BASE_URL` + `NPC_LLM_MODEL` in the persona's env file — no code change needed to point at OpenAI / Groq / Together / vLLM / Ollama / etc. + +- **Persona binding model**: each NPC bot embodies one persona forever (declared via `NPC_PERSONA_KEY` in its env file, validated against `NPC_PERSONAS_BY_KEY` at startup). On `npc_register` the NPC bot tells Master its `persona_key`; Master stores it on `NpcEntry`. When `/wolf start` runs in `reactive_voice` mode, `WolfCog._select_llm_seat_personas` pulls online NPC bots from the registry (skipping any already assigned to another active game) and uses their personas as the LLM seats — there is no separate "Master rolls personas" step. If fewer NPC bots are online than the LLM-seat shortfall, `/wolf start` fails with a friendly message. In `rounds` mode (no NPC bot processes), the same path falls through to a random `pick_personas(NPC_PERSONAS, ...)` draw because Master drives those LLM seats internally without VC playback. +- **Seat assignment**: `_on_reactive_phase_enter` in `main.py` pairs online NPC bots with unassigned LLM seats via `npc_registry.assign()`. The arbiter only dispatches to NPCs with an assigned seat. +- **Entry point**: `WOLFBOT_NPC_ENV=envs/npc/.env. uv run wolfbot-npc` (each persona has its own committed `envs/npc/.env..example` template; copy it to `envs/npc/.env.`, fill in the secrets — `NPC_DISCORD_TOKEN`, `MASTER_NPC_PSK`, `NPC_LLM_API_KEY`, `DISCORD_GUILD_ID`, `MAIN_VOICE_CHANNEL_ID` — and start one process per persona). See [envs/npc/README.md](envs/npc/README.md) for the persona / TTS_VOICE_ID table. The system prompt is **composed per actor** by `src/wolfbot/llm/prompt_builder.py`, not loaded verbatim. Three programmatically-generated blocks are layered onto `src/wolfbot/prompts/llm_system_prompt.md`: `_build_game_rules_block()` (9-player ruleset derived from `ROLE_DISTRIBUTION` + `VILLAGE_SIZE` so the canonical numbers aren't duplicated, plus the shared reasoning heuristics every seat sees — currently CO evaluation: a **never-countered** single role-CO is presumed near-real, but a **sole survivor** of a contested CO history (same role had ≥2 COs, others died) is **not** auto-trusted; topical mention of a CO ("the seer CO 〜について") is distinguished from self-declaration; counter-COs and divination/attack alignment feed the judgment), `_ROLE_STRATEGIES[role]` (role-scoped tactical hints — wolf/madman carry day-phased fake-CO playbooks that deliberately mirror each other, knight carries peaceful-morning guard-CO guidance, seer/medium/villager carry judgment-integrity rules (villager strategy explicitly forbids 「村人CO」/「素村CO」 and equivalents); cross-leak tests assert one role never sees another's strategy), and `_build_speech_profile_block(persona)` (the persona's 話法 section). Routing when editing: shared heuristics every seat should see → `_build_game_rules_block`; role-specific strategy → `_ROLE_STRATEGIES`; base framing / output format / hard invariants → the markdown template. The markdown is a template, not the whole prompt. diff --git a/README.md b/README.md index d3413b3..d7a30ee 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ - xAI / DeepSeek の API キー、または Vertex AI Gemini を呼べる Google Cloud project と ADC (`LLM_PROVIDER` で選んだもの) - 既存のメイン text チャンネル - 既存のメイン VC +- (任意) `tmux` — `scripts/run-bots.sh` で複数 bot をまとめて起動する場合のみ。macOS は `brew install tmux`。 ## セットアップ @@ -29,30 +30,44 @@ uv sync ``` -### 2. `.env` を用意する +### 2. `.env.master` を用意する -`.env.example` をコピーして `.env` を作成し、各値を設定します。 +`.env.master.example` をコピーして `.env.master` を作成し、Master bot 用の値を設定します。 ```bash -cp .env.example .env +cp .env.master.example .env.master ``` -最初は次の内容を埋めれば起動に必要な最低限の設定が揃います (xAI を使う場合)。DeepSeek を使う場合は `LLM_PROVIDER=deepseek` にして `DEEPSEEK_API_KEY` を、Gemini を使う場合は `LLM_PROVIDER=gemini` にして `GEMINI_VERTEX_PROJECT` を設定し、`gcloud auth application-default login` などで ADC を構成してください。 +reactive_voice モードで NPC bot も動かす場合は、**1 ペルソナ = 1 プロセス = 1 Discord bot アカウント** で別途用意します。各ペルソナの env ファイルは手書きせず、リポジトリルートの `tokens.txt` (gitignored) と単一テンプレ `envs/npc/.env.npc.example` から自動生成します。 + +```bash +# 1) tokens.txt にトークンを 1 行 1 ペルソナで貼る (リポジトリルートに作る) +# 例: +# setsu: MTQ5... +# gina: MTQ5... +# sq: MTQ5... +# ... + +# 2) 生成 (envs/npc/.env. がペルソナぶんできる) +python3 scripts/generate_npc_envs.py + +# 3) tmux で Master + 起動済みの全 NPC を一括起動 +scripts/run-bots.sh +``` + +各 NPC bot のセットアップ詳細・ペルソナ一覧・TTS_VOICE_ID の既定値は [envs/npc/README.md](envs/npc/README.md) を参照してください。 + +各 NPC bot は固有のペルソナ (`NPC_PERSONA_KEY`) を起動時に確定します。Master の `/wolf start` (reactive_voice モード) は、online な NPC bot の中から不足席数だけ選び、その bot の persona を席に割り当てます。online な bot が足りないときは開始時にエラー表示します (rounds モードに切り替えるか、bot を増やすことで解決)。 + +Master の Gameplay LLM と NPC bot の NPC LLM はそれぞれ独立に provider を切り替えできます (`xai` / `deepseek` / `gemini`)。Master 側は `GAMEPLAY_LLM_*` 系、NPC 側は `NPC_LLM_*` 系の env で制御し、片方を Vertex Gemini にして片方を xAI Grok にする、といった片寄せも可能。Vertex Gemini を選ぶ場合は手順 4 で ADC を構成します。 + +最初は `.env.master` に次の内容を埋めれば、Master 単体で起動に必要な最低限の設定が揃います (xAI を使う場合)。DeepSeek を使う場合は `GAMEPLAY_LLM_PROVIDER=deepseek` にして `GAMEPLAY_LLM_API_KEY` に DeepSeek のキーを入れてください。Vertex Gemini を使う場合は `GAMEPLAY_LLM_PROVIDER=gemini` にして `GAMEPLAY_LLM_VERTEX_PROJECT` を設定し、`gcloud auth application-default login` などで ADC を構成してください。 ```env DISCORD_TOKEN=your_discord_bot_token -LLM_PROVIDER=xai -XAI_API_KEY=your_xai_api_key -XAI_MODEL=grok-4-1-fast-reasoning -DEEPSEEK_API_KEY= -DEEPSEEK_BASE_URL=https://api.deepseek.com -DEEPSEEK_MODEL=deepseek-v4-flash -DEEPSEEK_THINKING=enabled -DEEPSEEK_REASONING_EFFORT=max -GEMINI_VERTEX_PROJECT= -GEMINI_VERTEX_LOCATION=global -GEMINI_MODEL=gemini-3-flash-preview -GEMINI_THINKING_LEVEL=high +GAMEPLAY_LLM_PROVIDER=xai +GAMEPLAY_LLM_API_KEY=your_gameplay_llm_api_key +GAMEPLAY_LLM_MODEL=grok-4-1-fast DISCORD_GUILD_ID=123456789012345678 MAIN_TEXT_CHANNEL_ID=123456789012345678 MAIN_VOICE_CHANNEL_ID=123456789012345678 @@ -61,16 +76,20 @@ LOG_LEVEL=INFO ``` - `DISCORD_TOKEN`: Discord Developer Portal で作成した bot のトークンを入れます。 -- `LLM_PROVIDER`: `xai` / `deepseek` / `gemini` のいずれか。既定は `xai`。 -- `XAI_API_KEY`: `LLM_PROVIDER=xai` のとき必須。xAI の API キーを入れます。 -- `DEEPSEEK_API_KEY`: `LLM_PROVIDER=deepseek` のとき必須。DeepSeek の API キーを入れます。 -- `GEMINI_VERTEX_PROJECT`: `LLM_PROVIDER=gemini` のとき必須。Vertex AI を使う Google Cloud project ID を入れます。認証は ADC/IAM で行います (`gcloud auth application-default login`、または実行環境に attach した service account)。 +- `GAMEPLAY_LLM_PROVIDER`: `xai` / `deepseek` / `gemini` のいずれか。既定は `xai`。 +- `GAMEPLAY_LLM_API_KEY`: `xai` または `deepseek` のとき必須。OpenAI Chat Completions 互換のプロバイダなら何でも可 (xAI Grok / OpenAI / Groq / Together / vLLM / Ollama / DeepSeek)。 +- `GAMEPLAY_LLM_MODEL`: 利用するモデル名。プロバイダのドキュメントに従って指定します。 +- `GAMEPLAY_LLM_BASE_URL`: 自前ホストや別エンドポイントを使う場合のみ上書きします。空で provider 既定を使用。 +- DeepSeek 使用時のみ: `GAMEPLAY_LLM_THINKING` (`enabled` 既定 / `disabled`)、`GAMEPLAY_LLM_REASONING_EFFORT` (`max` 既定 / `high`)。 +- Vertex Gemini 使用時のみ: `GAMEPLAY_LLM_VERTEX_PROJECT` (必須)、`GAMEPLAY_LLM_VERTEX_LOCATION` (`global` 既定)、`GAMEPLAY_LLM_THINKING_LEVEL` (`minimal` / `low` / `medium` / `high` 既定)。認証は ADC/IAM のみ。 - `DISCORD_GUILD_ID`: bot を動かす Discord サーバーの ID を入れます。 - `MAIN_TEXT_CHANNEL_ID`: 議論用に使うメイン text チャンネルの ID を入れます。 - `MAIN_VOICE_CHANNEL_ID`: プレイヤーが会話するメイン VC の ID を入れます。 -- `XAI_MODEL`, `DEEPSEEK_BASE_URL`, `DEEPSEEK_MODEL`, `DEEPSEEK_THINKING`, `DEEPSEEK_REASONING_EFFORT`, `GEMINI_VERTEX_LOCATION`, `GEMINI_MODEL`, `GEMINI_THINKING_LEVEL`, `WOLFBOT_DB_PATH`, `LOG_LEVEL` は最初は既定値のままで構いません。 +- `WOLFBOT_DB_PATH`、`LOG_LEVEL` は最初は既定値のままで構いません。 + +NPC bot 側もまったく同じ仕組みで切り替えできます (`NPC_LLM_PROVIDER` / `NPC_LLM_API_KEY` / `NPC_LLM_VERTEX_PROJECT` ...)。詳細は [envs/npc/README.md](envs/npc/README.md) と `envs/npc/.env.npc.example` を参照してください。 -`DISCORD_GUILD_ID`、`MAIN_TEXT_CHANNEL_ID`、`MAIN_VOICE_CHANNEL_ID` にはチャンネル名や `#channel` のようなメンション文字列ではなく、数値の ID を設定してください。どの guild やチャンネルを使うか未定なら、先に手順 3 を済ませてから `.env` を埋めてください。`LLM_PROVIDER=gemini` で `GEMINI_VERTEX_PROJECT` の値がまだ決まっていない場合は、先に手順 4 を済ませてから埋めてください。 +`DISCORD_GUILD_ID`、`MAIN_TEXT_CHANNEL_ID`、`MAIN_VOICE_CHANNEL_ID` にはチャンネル名や `#channel` のようなメンション文字列ではなく、数値の ID を設定してください。どの guild やチャンネルを使うか未定なら、先に手順 3 を済ませてから `.env.master` を埋めてください。`GAMEPLAY_LLM_PROVIDER=gemini` で `GAMEPLAY_LLM_VERTEX_PROJECT` の値がまだ決まっていない場合は、先に手順 4 を済ませてから埋めてください。 ### 3. Discord 側の設定を済ませる @@ -107,7 +126,7 @@ LOG_LEVEL=INFO ### 4. (Gemini を使う場合) Vertex AI ADC を構成する -`LLM_PROVIDER=gemini` で起動する場合のみ実施します。`xai` / `deepseek` を使う場合は読み飛ばして手順 5 へ進んでください。`wolfbot` は Vertex AI Express mode と API key 認証には対応せず、ADC/IAM のみをサポートします。 +`GAMEPLAY_LLM_PROVIDER=gemini` または `NPC_LLM_PROVIDER=gemini` で起動する場合のみ実施します。`xai` / `deepseek` を使う場合は読み飛ばして手順 5 へ進んでください。`wolfbot` は Vertex AI Express mode と API key 認証には対応せず、ADC/IAM のみをサポートします。 1. Google Cloud project を用意します。既存 project を使うか、Cloud Console (`https://console.cloud.google.com/`) または `gcloud projects create PROJECT_ID` で新規作成します。 2. その project に billing account を紐付けます。Vertex AI は無料 tier がないため、billing が有効でないと API 呼び出しが reject されます。 @@ -142,15 +161,16 @@ LOG_LEVEL=INFO 1 行目はブラウザを開いて Google ログインを行い、credentials を `~/.config/gcloud/application_default_credentials.json` に保存します。2 行目は ADC が請求先として参照する project を書き込みます (未設定だと `Your application has authenticated using end user credentials` の quota project warning が呼び出しごとに出ます)。 -8. `.env` の値を埋めます。 +8. `.env.master` の値を埋めます (NPC 側で Gemini を使う場合は `envs/npc/.env.` でも同じことを `NPC_LLM_*` プレフィックスで書きます)。 ```env - LLM_PROVIDER=gemini - GEMINI_VERTEX_PROJECT=PROJECT_ID - GEMINI_VERTEX_LOCATION=global + GAMEPLAY_LLM_PROVIDER=gemini + GAMEPLAY_LLM_VERTEX_PROJECT=PROJECT_ID + GAMEPLAY_LLM_VERTEX_LOCATION=global + GAMEPLAY_LLM_MODEL=gemini-3-flash-preview ``` - `gemini-3-flash-preview` は location `global` で利用可能です。他 region を指定する場合は Vertex AI の Gemini モデル提供 region 一覧で `gemini-3-flash-preview` の対応 region を確認してください。 + `gemini-3-flash-preview` は location `global` で利用可能です。他 region を指定する場合は Vertex AI の Gemini モデル提供 region 一覧で対応 region を確認してください。 9. (任意) ADC が正しく構成されたか確認します。 @@ -186,30 +206,89 @@ uv run wolfbot 起動後、`/wolf` コマンドが設定した guild に同期されます。 +### 5. (任意) Master + 複数 NPC bot を tmux でまとめて起動 + +reactive_voice モードで Master と複数の NPC bot を一度に起動したい場合、`scripts/run-bots.sh` が tmux で 1 ウィンドウ = 1 bot のセッションを作ります。macOS 前提 (Linux でも動作)、`tmux` が必要 (`brew install tmux`)。 + +```bash +# envs/npc/.env. が存在するペルソナを自動検出して全部起動 +scripts/run-bots.sh + +# 個別指定したい場合 +scripts/run-bots.sh setsu gina sq raqio + +# 既に同名セッションがあるときは停止して作り直す +FORCE=1 scripts/run-bots.sh + +# セッションに入って各ウィンドウのログを見る +tmux attach -t wolfbot +# prefix + n / p : 次/前ウィンドウ +# prefix + 0..9 / w : 番号 / 一覧から選択 +# prefix + d : デタッチ (bot は動き続ける) + +# 全部止める +scripts/stop-bots.sh +``` + +各 bot のログは tmux ペインと `logs/.log` の両方にストリームされます。プロセスが落ちてもウィンドウは残るので、終了直後の出力を確認できます。 + +**人間 0 / NPC bot 9 体だけのゲーム**を観戦したい場合: `envs/npc/.env.` を 9 ペルソナぶん用意して `scripts/run-bots.sh` で全部起動 → Discord で `/wolf create` → `/wolf join` は打たず `/wolf start` で人数不足の 9 席すべてが NPC bot で埋まります。 + ## 環境変数一覧 +### Master (`.env.master`, `wolfbot.config.MasterSettings`) + | 変数名 | 必須 / 既定値 | 用途 | | --- | --- | --- | -| `DISCORD_TOKEN` | 必須 | Discord bot のトークン | -| `LLM_PROVIDER` | 既定値: `xai` | LLM backend: `xai` / `deepseek` / `gemini` | -| `XAI_API_KEY` | `LLM_PROVIDER=xai` のとき必須 | xAI API キー | -| `XAI_MODEL` | 既定値: `grok-4-1-fast` | 使用する xAI モデル名 | -| `DEEPSEEK_API_KEY` | `LLM_PROVIDER=deepseek` のとき必須 | DeepSeek API キー | -| `DEEPSEEK_BASE_URL` | 既定値: `https://api.deepseek.com` | DeepSeek API の base URL | -| `DEEPSEEK_MODEL` | 既定値: `deepseek-v4-flash` | 使用する DeepSeek モデル名 | -| `DEEPSEEK_THINKING` | 既定値: `enabled` | DeepSeek thinking mode (`enabled` / `disabled`) | -| `DEEPSEEK_REASONING_EFFORT` | 既定値: `max` | thinking enabled 時の reasoning effort (`high` / `max`) | -| `GEMINI_VERTEX_PROJECT` | `LLM_PROVIDER=gemini` のとき必須 | Vertex AI を使う Google Cloud project ID。認証は ADC/IAM (`gcloud auth application-default login` か service account) | -| `GEMINI_VERTEX_LOCATION` | 既定値: `global` | Vertex AI Gemini API の location | -| `GEMINI_MODEL` | 既定値: `gemini-3-flash-preview` | 使用する Gemini モデル名 | -| `GEMINI_THINKING_LEVEL` | 既定値: `high` | Gemini 3 Flash thinking level (`minimal` / `low` / `medium` / `high`) | +| `DISCORD_TOKEN` | 必須 | Master bot のトークン | | `DISCORD_GUILD_ID` | 必須 | `/wolf` コマンドを同期する guild の ID | | `MAIN_TEXT_CHANNEL_ID` | 必須 | 議論用に使う既存のメイン text チャンネル ID | | `MAIN_VOICE_CHANNEL_ID` | 必須 | 参加者が会話する既存のメイン VC の ID | | `WOLFBOT_DB_PATH` | 既定値: `./wolfbot.db` | SQLite データベースの保存先 | | `LOG_LEVEL` | 既定値: `INFO` | ログ出力レベル | +| `LLM_DISCUSSION_MODE` | 既定値: `rounds` | LLM 議論モード (`rounds` / `reactive_voice`) | +| `MASTER_WS_LISTEN` | 既定値: `127.0.0.1:8800` | Master ↔ NPC/voice-ingest WS の listen アドレス | +| `MASTER_NPC_PSK` | 任意 | NPC bot / voice-ingest の WS 認証用 Pre-Shared Key | +| `GAMEPLAY_LLM_PROVIDER` | 既定値: `xai` | **Gameplay LLM** の backend: `xai` / `deepseek` / `gemini`。Master が投票・夜行動・rounds-mode 議論文を判断する LLM の切り替え | +| `GAMEPLAY_LLM_API_KEY` | `xai` / `deepseek` のとき必須 | Gameplay LLM の API キー (OpenAI Chat Completions 互換) | +| `GAMEPLAY_LLM_MODEL` | 既定値: `grok-4-1-fast` | Gameplay LLM のモデル名 (provider に合わせて変える) | +| `GAMEPLAY_LLM_BASE_URL` | 任意 | OpenAI 互換エンドポイントの上書き。空のままなら provider 既定 | +| `GAMEPLAY_LLM_THINKING` | 既定値: `enabled` | DeepSeek thinking mode (`enabled` / `disabled`)。DeepSeek 以外では無視 | +| `GAMEPLAY_LLM_REASONING_EFFORT` | 既定値: `max` | thinking enabled 時の reasoning effort (`high` / `max`)。DeepSeek 以外では無視 | +| `GAMEPLAY_LLM_VERTEX_PROJECT` | `gemini` のとき必須 | Vertex AI を使う Google Cloud project ID。認証は ADC/IAM のみ (`gcloud auth application-default login` か attached service account)。Vertex AI Express mode と API key 認証は非対応 | +| `GAMEPLAY_LLM_VERTEX_LOCATION` | 既定値: `global` | Vertex AI Gemini API の location | +| `GAMEPLAY_LLM_THINKING_LEVEL` | 既定値: `high` | Gemini 3 Flash thinking level (`minimal` / `low` / `medium` / `high`) | +| `VOICE_LLM_API_KEY` | 任意 | **Voice LLM** — Master が VC で人間音声を聞いて書き起こし+構造化解析する multimodal LLM。reactive_voice モードでのみ使用 | +| `VOICE_LLM_MODEL` | 既定値: `gemini-2.0-flash-lite` | Voice LLM のモデル名 (multimodal audio input 対応) | + +### NPC bot (`envs/npc/.env.`, `wolfbot.npc.config.NpcSettings`) + +各 NPC bot は **固有 persona に紐付いた 1 プロセス**。[envs/npc/](envs/npc/) 配下の `.env..example` をコピーして使います。プロセス起動時に `WOLFBOT_NPC_ENV` でファイルパスを指定 (例: `WOLFBOT_NPC_ENV=envs/npc/.env.setsu`)。 + +| 変数名 | 必須 / 既定値 | 用途 | +| --- | --- | --- | +| `NPC_ID` | 必須 (テンプレで `npc_` 既定) | NPC の一意 ID (Master WS 上の識別子) | +| `NPC_DISCORD_TOKEN` | 必須 | この persona 専用の Discord bot トークン | +| `NPC_PERSONA_KEY` | 必須 (テンプレで指定済) | Persona キー (`setsu` / `gina` / `sq` / `raqio` / `stella` / `shigemichi` / `chipie` / `comet` / `jonas` / `kukrushka` / `otome` / `sha_ming` / `remnan` / `yuriko`) | +| `DISCORD_GUILD_ID` | 必須 | Master と同じ guild ID | +| `MAIN_VOICE_CHANNEL_ID` | 必須 | Master と同じ VC の ID | +| `MASTER_WS_URL` | 既定値: `ws://127.0.0.1:8800` | Master WS への接続先 URL | +| `MASTER_NPC_PSK` | 必須 | Master の `MASTER_NPC_PSK` と同値 | +| `NPC_LLM_PROVIDER` | 既定値: `xai` | **NPC LLM** の backend: `xai` / `deepseek` / `gemini`。Master の `GAMEPLAY_LLM_PROVIDER` と独立に切り替えできる | +| `NPC_LLM_API_KEY` | `xai` / `deepseek` のとき必須 | NPC LLM の API キー。投票・夜行動の判断はしない (Master の Gameplay LLM が担当)。Master のキーと共用しても、別プロバイダのキーでもよい | +| `NPC_LLM_MODEL` | 既定値: `grok-4-1-fast` | NPC LLM のモデル名 | +| `NPC_LLM_BASE_URL` | 任意 | OpenAI 互換エンドポイントの上書き。空のままなら provider 既定 | +| `NPC_LLM_THINKING` | 既定値: `enabled` | DeepSeek thinking mode。DeepSeek 以外では無視 | +| `NPC_LLM_REASONING_EFFORT` | 既定値: `max` | DeepSeek thinking enabled 時の reasoning effort。DeepSeek 以外では無視 | +| `NPC_LLM_VERTEX_PROJECT` | `gemini` のとき必須 | Vertex AI を使う Google Cloud project ID。ADC/IAM のみ | +| `NPC_LLM_VERTEX_LOCATION` | 既定値: `global` | Vertex AI Gemini API の location | +| `NPC_LLM_THINKING_LEVEL` | 既定値: `high` | Gemini thinking level | +| `TTS_VOICE_ID` | テンプレで persona 別に既定値設定 | VOICEVOX のスピーカー ID。好みの声に変えたい場合のみ編集 | +| `VOICEVOX_URL` | 既定値: `http://localhost:50021` | VOICEVOX エンジンの URL | +| `HEARTBEAT_INTERVAL_S` | 既定値: `5` | ハートビート送信間隔(秒) | +| `LOG_LEVEL` | 既定値: `INFO` | ログ出力レベル | -`LLM_PROVIDER=gemini` は Vertex AI ADC/IAM 専用です。Vertex AI Express mode と API key 認証は非対応です。 +`GAMEPLAY_LLM_PROVIDER=gemini` / `NPC_LLM_PROVIDER=gemini` は Vertex AI ADC/IAM 専用です。Vertex AI Express mode と API key 認証は非対応です。 ## Discord 側の準備 diff --git a/envs/npc/.env.npc.example b/envs/npc/.env.npc.example new file mode 100644 index 0000000..853ff8e --- /dev/null +++ b/envs/npc/.env.npc.example @@ -0,0 +1,64 @@ +# ── Unified NPC bot template (envs/npc/.env.npc.example) ───────────── +# Don't edit per-persona env files (envs/npc/.env.) by hand — +# they are generated from this template by: +# +# python3 scripts/generate_npc_envs.py +# +# Inputs the generator combines: +# - tokens.txt (one line per persona: ": ") +# - .env.master (DISCORD_GUILD_ID / MAIN_VOICE_CHANNEL_ID +# / MASTER_NPC_PSK / GAMEPLAY_LLM_API_KEY) +# - wolfbot.npc.personas (NPC_PERSONA_KEY + TTS_VOICE_ID per persona) +# +# {{...}} placeholders below are substituted; everything else is copied +# verbatim. Re-running the generator overwrites existing per-persona files +# unless --no-overwrite is passed. + +# ── Identity (per-persona) ───────────────────────────────────────────── +NPC_ID={{NPC_ID}} +NPC_DISCORD_TOKEN={{NPC_DISCORD_TOKEN}} +NPC_PERSONA_KEY={{NPC_PERSONA_KEY}} + +# ── Discord guild + VC (shared, copied from .env.master) ─────────────── +DISCORD_GUILD_ID={{DISCORD_GUILD_ID}} +MAIN_VOICE_CHANNEL_ID={{MAIN_VOICE_CHANNEL_ID}} + +# ── Master ↔ NPC WebSocket (shared, must match .env.master) ──────────── +MASTER_NPC_PSK={{MASTER_NPC_PSK}} +MASTER_WS_URL=ws://127.0.0.1:8800 + +# ── NPC LLM (this NPC's reactive-utterance backend) ──────────────────── +# Same provider switch as Master's GAMEPLAY_LLM_*; pick any of the four: +# xai ─ xAI Grok / OpenAI / Groq / Together / vLLM / Ollama +# deepseek ─ DeepSeek +# gemini ─ Vertex AI Gemini (ADC/IAM, no API key) +# mock ─ オフライン統合テスト。ペルソナ別の定型台詞を round-robin +# で返す。API キー不要、ネットワーク呼び出しなし。 +# May share a credential with Master's GAMEPLAY_LLM_* or pick a different +# provider/model entirely (e.g. Master on Vertex Gemini, NPC on xAI Grok). +NPC_LLM_PROVIDER=xai + +# xAI / DeepSeek / OpenAI 互換 endpoint で使う API キー (gemini なら不要) +NPC_LLM_API_KEY={{NPC_LLM_API_KEY}} +NPC_LLM_MODEL=grok-4-1-fast +# Override only when pointing at a non-default endpoint (provider 既定: +# xai = https://api.x.ai/v1, deepseek = https://api.deepseek.com). +NPC_LLM_BASE_URL= + +# DeepSeek 専用 (provider=deepseek 以外では無視) +NPC_LLM_THINKING=enabled +NPC_LLM_REASONING_EFFORT=max + +# Vertex AI Gemini 専用 (provider=gemini 以外では無視) +# 認証は ADC のみ。Master 側と同様、API key 認証 / Express mode は非対応。 +NPC_LLM_VERTEX_PROJECT= +NPC_LLM_VERTEX_LOCATION=global +NPC_LLM_THINKING_LEVEL=high + +# ── VOICEVOX TTS (per-persona voice id, shared engine URL) ───────────── +TTS_VOICE_ID={{TTS_VOICE_ID}} +VOICEVOX_URL=http://localhost:50021 + +# ── Runtime knobs ────────────────────────────────────────────────────── +HEARTBEAT_INTERVAL_S=5 +LOG_LEVEL=INFO diff --git a/envs/npc/README.md b/envs/npc/README.md new file mode 100644 index 0000000..e94590b --- /dev/null +++ b/envs/npc/README.md @@ -0,0 +1,100 @@ +# NPC bot env files + +NPC bot は **1 プロセス = 1 ペルソナ** で動きます。各ペルソナの env ファイル +(`envs/npc/.env.`) は手書きせず、テンプレと `tokens.txt` から +**生成スクリプトで自動作成**します。 + +## 構成 + +| ファイル | 意味 | git | +|---|---|---| +| `envs/npc/.env.npc.example` | 単一テンプレ (`{{...}}` プレースホルダ) | コミットされる | +| `envs/npc/.env.` | 生成された実 env (秘密値を含む) | gitignored | +| `tokens.txt` (リポジトリルート) | `: ` の一覧 | gitignored | + +## セットアップ + +### 1. tokens.txt を用意 + +リポジトリルートに `tokens.txt` を作り、Discord Developer Portal で発行した +NPC bot のトークンを 1 行 1 ペルソナで貼る: + +``` +master: ← 任意 (生成時はスキップされる) +setsu: <セツ bot のトークン> +gina: <ジナ bot のトークン> +sq: +raqio: <ラキオ bot のトークン> +... +``` + +- 行の先頭が `#` ならコメントとして無視される +- `master` 行は **Master 用なので生成対象から除外** される (Master のトークンは + `.env.master` に書く) +- `setu` のような typo は `setsu` に自動マッピングされる (`PERSONA_ALIASES` で + 定義済み) + +### 2. .env.master を先に用意 + +生成スクリプトは以下の値を `.env.master` から拾うので、先に値を埋めておく: + +- `DISCORD_GUILD_ID` +- `MAIN_VOICE_CHANNEL_ID` +- `MASTER_NPC_PSK` +- `GAMEPLAY_LLM_API_KEY` (各 NPC の `NPC_LLM_API_KEY` として再利用される) + +### 3. 生成スクリプトを実行 + +```bash +# 全ペルソナぶん生成 +python3 scripts/generate_npc_envs.py + +# 既存ファイルを上書きしたくない +python3 scripts/generate_npc_envs.py --no-overwrite + +# 実際には書かず内容だけ確認 +python3 scripts/generate_npc_envs.py --dry-run + +# 入力パスを変えたい +python3 scripts/generate_npc_envs.py --tokens path/to/tokens.txt --master path/to/.env.master +``` + +`tokens.txt` を更新したり `.env.master` の共有値を変えたりしたら**再実行**で +全 NPC env を一括更新できます。手書き編集はやめて、編集はテンプレ側に集約。 + +### 4. 起動 + +```bash +WOLFBOT_NPC_ENV=envs/npc/.env.setsu uv run wolfbot-npc +``` + +複数まとめて起動したいときは `scripts/run-bots.sh` (tmux で 1 ウィンドウ = 1 bot)。 + +## ペルソナ一覧 (canonical source: `wolfbot.npc.personas`) + +| Key | 表示名 | スタイル | TTS_VOICE_ID | +|---|---|---|---| +| `setsu` | 🟡セツ | 真面目で責任感が強い。議論を整理する | 8 | +| `gina` | 🟣ジナ | 物静かで誠実。直感と共感を重視 | 9 | +| `sq` | 🔴SQ | 軽快で社交的。打算的な面もありつつ場を和ませる | 2 | +| `raqio` | 🦋ラキオ | 論理偏重で挑発的。矛盾追及が鋭い | 13 | +| `stella` | 🌟ステラ | 優しく献身的。柔らかい物言い | 4 | +| `shigemichi` | 👽シゲミチ | 率直で豪快。印象や勢いを重視 | 11 | +| `chipie` | 🐈‍⬛シピ | 柔らかく観察力がある。対立をなだめつつ疑問を出す | 6 | +| `comet` | ☄️コメット | 無邪気で気まぐれ。妙に核心を突く | 1 | +| `jonas` | 🎩ジョナス | 尊大で芝居がかった話し方 | 12 | +| `kukrushka` | 🧸ククルシカ | 不穏。ほぼ無言で身振りを示す | 0 | +| `otome` | 🐬オトメ | 事務的で面倒見がよい。要点中心 | 7 | +| `sha_ming` | 🥽シャーミン | 皮肉屋で自信家。挑発的に試す | 5 | +| `remnan` | ⚪️レムナン | 内向的で慎重。観察は細かい | 10 | +| `yuriko` | 👑ユリコ | 冷静で威圧感がある。少ない語数で核心を突く | 3 | + +`TTS_VOICE_ID` は `wolfbot.npc.personas` の `Persona.tts_voice_id` が一次情報。 +別の声に変えたい場合は (a) Persona 定義を編集してスクリプトを再実行、または +(b) 生成された `.env.` を後から手動で書き換える。 + +## 事前にやっておくこと + +- 各ペルソナぶんの Discord bot アプリを Developer Portal で作成し、 + guild に手動で招待しておく (1 度きりのセットアップ作業)。 +- VOICEVOX エンジンを起動 (既定 `http://localhost:50021`)。 diff --git a/envs/voicetest/.env.voicetest.example b/envs/voicetest/.env.voicetest.example new file mode 100644 index 0000000..c53e799 --- /dev/null +++ b/envs/voicetest/.env.voicetest.example @@ -0,0 +1,40 @@ +# Voice-test bot configuration. Copy to envs/voicetest/.env.voicetest +# and run with: +# WOLFBOT_VOICETEST_ENV=envs/voicetest/.env.voicetest uv run wolfbot-voicetest + +# Discord bot token (separate Discord application from Master / NPCs). +VOICETEST_DISCORD_TOKEN= + +# Guild + voice channel to join. Use the same VC the real game runs in +# so the recorded path matches production network conditions. +VOICETEST_GUILD_ID= +VOICETEST_VOICE_CHANNEL_ID= + +# Where to drop seg_*.wav + seg_*.txt sidecars. Same env var the +# production master reads, so an existing audio_debug/ tree continues +# to work — files end up under {dir}/voicetest/{speaker_name}/. +WOLFBOT_VOICE_DEBUG_DIR=./logs/audio_debug + +# Toggle the SilenceGeneratorSink wrap. Set to "true" to reproduce the +# current production behaviour, "false" to record raw real-frames-only +# audio for an A/B comparison. +VOICETEST_USE_SILENCE_GENERATOR=true + +# Drop segments that look like silence/background rather than real +# speech. Tuned from observed RMS samples — pure noise sits at 0-100, +# faint/distant speech around 200-500, normal talking 1000+. Adjust +# based on the voicetest_dump_skip / voicetest_dump_keep log lines +# for your mic + room. +VOICETEST_MIN_RMS=200 +VOICETEST_MIN_DURATION_MS=300 + +# Toggle real STT. When ``false`` (default), the recording path is +# tested in isolation - the .txt sidecar shows ``transcript: `` +# but the WAV is still dumped. When ``true``, kept segments are sent +# through the same STT stack the production master uses (selected by +# VOICE_STT_PROVIDER=gemini|groq in .env.master) - no API keys need +# to be set here, voicetest auto-loads them from .env.master at +# startup with the voicetest env file overriding on top. +VOICETEST_USE_STT=false + +LOG_LEVEL=INFO diff --git a/openspec/changes/add-master-npc-voice-stt/.openspec.yaml b/openspec/changes/add-master-npc-voice-stt/.openspec.yaml new file mode 100644 index 0000000..3f1f00e --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-26 diff --git a/openspec/changes/add-master-npc-voice-stt/current-phase.md b/openspec/changes/add-master-npc-voice-stt/current-phase.md new file mode 100644 index 0000000..9e03ee9 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/current-phase.md @@ -0,0 +1,11 @@ +# Current Phase: add-master-npc-voice-stt + +- Phase: fix-review +- Round: 5 +- Status: has_open_high +- Open High/Critical Findings: 4 件 — "Reactive voice still never drives the arbiter in production", "The per-NPC worker is still not runnable", "The human-speaking gate is released before STT finalization", "Playback deadlines are recorded but never enforced" +- Actionable Findings: 6 +- Accepted Risks: none +- Latest Changes: + - (no commits yet) +- Next Recommended Action: /specflow.fix_apply diff --git a/openspec/changes/add-master-npc-voice-stt/design.md b/openspec/changes/add-master-npc-voice-stt/design.md new file mode 100644 index 0000000..01a0fc2 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/design.md @@ -0,0 +1,234 @@ +# Design — Master + Separated NPC Voice Bots + Human STT + +This design backs the proposal `add-master-npc-voice-stt`. It expands the spec deltas (`speech-event-bus`, `voice-ingest`, `npc-voice-pipeline`, plus modified `day-discussion`, `llm-seats`, `discord-integration`) into the architecture, contracts, and integration choices that the apply phase will execute against. + +## Concerns + +The change is decomposed into seven user-facing concerns. Each is independently reviewable, and most can be implemented in parallel once their foundational contracts (the SpeechEvent shape, the WS protocol, the NPC registry view) are agreed. + +| ID | Concern | Problem it resolves | +|----|---------|---------------------| +| C1 | **Unified utterance bus (`speech-event-bus`)** | Today, public utterances live only as `PLAYER_SPEECH` log entries that are tightly coupled to the round-based LLM batch. Voice and reactive flows need a single canonical record of "who said what when," queryable across modes, rebuildable on restart. | +| C2 | **Public discussion derivation (`PublicDiscussionState`)** | Master-side reactive arbitration needs a stable code-derived view of who has CO'd, who is silent, etc. — not an LLM-summarized blob. The state must be deterministic from the event log so debugging and tests are sound. | +| C3 | **Human voice ingestion (`voice-ingest`)** | Humans cannot today participate by voice. We need a worker that listens to the Discord VC, runs VAD + STT, and emits `SpeechEvent(source=voice_stt)` — while reliably excluding NPC TTS audio so the model never feeds itself. | +| C4 | **NPC bot worker (`wolfbot-npc`)** | Each NPC needs its own Discord identity to be visibly distinct in VC and to play its own TTS voice. The bot must be a separable process with no game-state authority. | +| C5 | **Master ↔ NPC speech protocol** | Master must drive who speaks, when, with what context, and gate all NPC playback through explicit authorization. The protocol must reject stale results so a slow xAI response cannot leak speech into a phase that already advanced. | +| C6 | **Pluggable discussion mode (`day-discussion`)** | We need to introduce `reactive_voice` without breaking existing `rounds` games. The same code path must serve both modes, with a single mode-fixed-per-game switch via `LLM_DISCUSSION_MODE`. | +| C7 | **Cross-component observability** | Failures span STT / Master / NPC bot / TTS / VC. Without unified structured logs and a per-phase summary, regressions are invisible. | + +## State / Lifecycle + +### Canonical state (authoritative; written exactly once) + +| State | Owner | Persistence | Notes | +|-------|-------|-------------|-------| +| `Game`, `Seat`, `Player`, `Vote`, `NightAction`, `LogEntry` | Master | `wolfbot.db` (existing) | Unchanged by this change. | +| `SpeechEvent` row | Master (sole writer) | `speech_events` table (new) | Inserted by Master regardless of utterance origin (voice-ingest sends payload over ingestion endpoint; NPC bot sends `SpeakResult` over WS). | +| `npc_speak_requests` row | Master | `npc_speak_requests` (new) | Inserted at `SpeakArbiter` dispatch time. | +| `npc_speak_results` row | Master | `npc_speak_results` (new) | Inserted on `SpeakResult` arrival or recovery sweep. | +| `npc_playback_events` row | Master | `npc_playback_events` (new) | An **open** row (with `finished_at` NULL) is inserted when `PlaybackAuthorized` is issued — which happens **immediately after** Master accepts a `SpeakResult` and writes the `SpeechEvent`, **before** TTS begins. TTS and playback both run inside this authorized window. The row carries `tts_outcome` (`success` / `failed` / NULL while pending), `tts_duration_ms`, and `tts_failure_reason` columns — updated when `tts_finished` or `tts_failed` arrives from the NPC bot. If TTS fails, the row is **closed** immediately (`finished_at` + `tts_outcome=failed`); no audio playback occurs. If TTS succeeds, `tts_outcome` is updated to `success` and the row remains open until `playback_finished` / `playback_failed` / `playback_deadline_ms` timeout, at which point `finished_at` and `outcome` are set. On `master_restart`, all rows with `finished_at IS NULL` are closed with `failure_reason=master_restart`. | +| `llm_speech_counts` (existing) | Master | existing table | Used only in `rounds` mode. | + +### Derived state (rebuildable; never the source of truth) + +| State | Derivation | Lifetime | +|-------|-----------|----------| +| `PublicDiscussionState` | Pure fold over `speech_events` rows for the current `phase_id`. The alive-seat baseline is persisted as a **sentinel `SpeechEvent`** with `source=phase_baseline` inserted at phase start; the sentinel's `alive_seat_nos_json` column stores the `frozenset[int]` serialized as a JSON array. `silent_seats` = sentinel's `alive_seat_nos` minus seats with ≥1 non-sentinel `SpeechEvent` in the phase. This makes the fold self-contained — rebuild reads only `speech_events`, never the `seats` table. | In-memory per active game; rebuilt on Master restart by replaying the fold from `speech_events` alone (the sentinel provides the baseline). | +| NPC registry view in voice-ingest | Pushed from Master via WS `registry_snapshot` on connect and `registry_update` on changes. | In-memory per voice-ingest process; re-delivered on reconnect. | +| Open serial-speech gate (`human-speaking`, `npc-playing`) | `human-speaking`: set by `vad_speech_started` from voice-ingest WS, cleared on `vad_speech_ended` + finalized payload/failure (or `vad_finalization_timeout_ms`). `npc-playing`: computed from `npc_playback_events` rows with `finished_at IS NULL` (the open row inserted at `PlaybackAuthorized` time, **before TTS begins** — the gate covers both TTS synthesis and audio playback). | In-memory on Master; cleared to empty on restart (voice-ingest re-establishes via next `vad_speech_started`; open playback rows are closed with `failure_reason=master_restart` during the recovery sweep). | + +### Lifecycle boundaries + +- **Game lifecycle** is unchanged: `LOBBY → SETUP → NIGHT_0 → DAY_DISCUSSION ↔ DAY_VOTE … → GAME_OVER`. The new behavior plugs into all public speech phases (`DAY_DISCUSSION`, `DAY_RUNOFF_SPEECH`) — `LLM_DISCUSSION_MODE` selects the NPC speech strategy for these phases, and `SpeechEvent` capture + `PublicDiscussionState` derivation + `discussion_phase_summary` emission apply uniformly to every public speech phase. +- **`SpeakRequest` lifecycle**: created → dispatched over WS → result arrives (accepted/declined/error) → Master validates utterance (length cap, phase freshness) → if accepted: Master writes `SpeechEvent`, issues `PlaybackAuthorized` immediately (inserts an **open** `npc_playback_events` row) → NPC performs TTS inside the authorized window → `tts_finished` or `tts_failed` reported to Master (updates the open row) → if TTS succeeded (`tts_outcome=success`): NPC plays audio → `playback_finished` OR `playback_deadline_ms` timeout → the open row is **closed** with `finished_at` and `outcome`. If TTS failed (`tts_outcome=failed`): the open row is **closed** immediately; no audio playback occurs. The serial-speech gate (`npc-playing`) is held from `PlaybackAuthorized` through both TTS and playback, so synthesis time is covered by the gate and concurrent NPC turns cannot prepare TTS in parallel. +- **VAD window lifecycle**: `vad_speech_started` → packets buffered → `vad_speech_ended` → STT call → either `SpeechEvent(source=voice_stt)` written OR drop with structured-log evidence. +- **Process lifecycle**: Master, voice-ingest, and each NPC bot can restart independently; in-flight WS-protocol state at the restarted boundary is dropped (see "Integration Points"). + +### Persistence-sensitive state + +`speech_events` is the only new state that must survive restart for correctness — `PublicDiscussionState` is rebuilt from it. `npc_speak_*` and `npc_playback_events` are audit trails (queryable forever for postmortem) but the live arbiter does not depend on them after a restart; pending entries are simply marked rejected/failed during the recovery sweep. + +## Contracts / Interfaces + +### Inter-process contracts (new) + +**Master ↔ NPC bot (WebSocket, localhost-only)** + +``` +NPC → Master: npc_register {npc_id, discord_bot_user_id, supported_voices[], version} +Master → NPC: npc_registered {assigned_seat, game_id, phase_id} +NPC → Master: heartbeat {npc_id, ts} +Master → NPC: logic_packet {packet_id, phase_id, recipient_npc_id, public_state_summary, + logic_candidates[{id, claim, support[], counter[]}], + pressure{seat→float}, expires_at_ms} +Master → NPC: speak_request {request_id, phase_id, npc_id, seat_no, logic_packet_id, + suggested_intent, max_chars, max_duration_ms, + priority, expires_at_ms} +NPC → Master: speak_result {request_id, npc_id, phase_id, status, text?, used_logic_ids[], + intent?, estimated_duration_ms?, failure_reason?} +Master → NPC: playback_authorized {request_id, npc_id, status=authorized, + speech_event_id, playback_deadline_ms} +Master → NPC: playback_rejected {request_id, npc_id, status=rejected, failure_reason} +NPC → Master: tts_finished {request_id, npc_id, tts_duration_ms, audio_size_bytes} +NPC → Master: tts_failed {request_id, npc_id, failure_reason} +NPC → Master: playback_finished {request_id, npc_id, started_at_ms, finished_at_ms} +NPC → Master: playback_failed {request_id, npc_id, failure_reason} +``` + +All messages share a common envelope `{type, ts, trace_id}` and are JSON-encoded UTF-8. Authentication: a single `MASTER_NPC_PSK` is presented in the connect handshake; mismatch → connection refused. + +**voice-ingest → Master (WebSocket, localhost-only)** + +This contract fully specifies the voice-ingest control plane: **transport** (WebSocket, chosen below), **VAD lifecycle signaling** (`vad_speech_started` / `vad_speech_ended` with `segment_id` correlation), **finalized speech delivery** (`speech_event_payload` / `stt_failed`), **NPC registry delivery** (`registry_snapshot` / `registry_update`), **fail-closed behavior** when registry is unavailable, **serial-speech gate semantics** during control-plane failures, **restart semantics** for open VAD windows, and **`failure_reason` ownership** between voice-ingest and Master. Each sub-decision is detailed in a dedicated paragraph below. + +Transport choice: WebSocket (same `websockets` library as the NPC channel). HTTP was considered but rejected because VAD lifecycle events (`vad_speech_started`, `vad_speech_ended`) require low-latency push from voice-ingest to Master to drive the serial-speech gate; a polling or request-response model adds unacceptable latency to gate transitions. The voice-ingest WS connection authenticates with the same `MASTER_NPC_PSK` mechanism as NPC bots. + +``` +voice-ingest → Master: vad_speech_started {game_id, phase_id, speaker_discord_user_id, seat_no, + segment_id, audio_start_ms, ts} +voice-ingest → Master: vad_speech_ended {game_id, phase_id, speaker_discord_user_id, seat_no, + segment_id, audio_end_ms, ts} +voice-ingest → Master: speech_event_payload {game_id, phase_id, seat_no, speaker_discord_user_id, + segment_id, text, confidence, duration_ms, + audio_start_ms, audio_end_ms, ts} +voice-ingest → Master: stt_failed {game_id, phase_id, speaker_discord_user_id, seat_no, + segment_id, failure_reason, ts} +Master → voice-ingest: registry_snapshot {npc_user_ids: [discord_bot_user_id]} +Master → voice-ingest: registry_update {added: [discord_bot_user_id], removed: [discord_bot_user_id]} +voice-ingest → Master: heartbeat {ts} +``` + +All messages share the same common envelope `{type, ts, trace_id}` as the NPC channel. + +**VAD lifecycle signaling to Master.** `vad_speech_started` and `vad_speech_ended` are sent as soon as the VAD engine detects transitions. Master uses these to set/clear the `human_currently_speaking` serial-speech gate — while a VAD window is open (after `vad_speech_started`, before `vad_speech_ended` + finalized `speech_event_payload` or `stt_failed` arrives), `SpeakArbiter` rejects NPC `SpeakRequest`s with `failure_reason=human_currently_speaking`. The gate clears when **both** `vad_speech_ended` has arrived **and** the corresponding `speech_event_payload` or `stt_failed` has been received (or a configurable `vad_finalization_timeout_ms` expires, closing the gate with a structured-log warning). + +**Audio boundary fields and segment correlation.** Each VAD segment is assigned a unique `segment_id` by voice-ingest at `vad_speech_started` time; the same `segment_id` is carried through `vad_speech_ended`, `speech_event_payload`, and `stt_failed` so Master can correlate the full lifecycle. `vad_speech_started` carries `audio_start_ms` (milliseconds since the voice-ingest process joined the VC); `vad_speech_ended` carries `audio_end_ms`. Both values are echoed in `speech_event_payload` so Master can persist `audio_start_ms` and `audio_end_ms` directly into the `speech_events` row for `source=voice_stt` without inference. For `source=text` and `source=npc_generated`, `audio_start_ms` and `audio_end_ms` are NULL. + +**`failure_reason` ownership.** voice-ingest owns STT-layer failure reasons (`stt_low_confidence`, `stt_provider_error`, `stt_timeout`). Master owns protocol- and game-layer failure reasons (`stale_phase`, `expired_request`, `human_currently_speaking`, `queue_busy`, `npc_offline`, `utterance_too_long`, `tts_timeout`, `tts_synthesis_error`, `playback_not_authorized`, `master_restart`, `npc_restart`, `voice_ingest_restart`, `voice_ingest_disconnect`, `discord_playback_error`, `npc_stt_discarded`). Each process emits structured-log events with only its own failure reasons; Master records voice-ingest-originated reasons verbatim when they arrive via WS (e.g. in `stt_failed` messages) but does not synthesize new STT-layer reasons. + +**NPC registry delivery.** Master pushes `registry_snapshot` on voice-ingest connection and `registry_update` on NPC registration/deregistration changes. Voice-ingest no longer polls. + +**Fail-closed on registry unavailability.** If voice-ingest has not yet received a `registry_snapshot` (initial connect not complete) or the WS connection to Master is down, voice-ingest operates in **fail-closed mode**: it treats the NPC user-ID set as **empty**, meaning all audio (including any NPC TTS) is processed through VAD/STT. This is safe because Master applies a **NPC-originated STT discard** guard: when writing a `SpeechEvent` from a `speech_event_payload`, Master checks if `speaker_discord_user_id` belongs to a registered NPC in the `NpcRegistry`; if so, the event is discarded with structured-log event `npc_stt_discarded` and no `SpeechEvent` row is written. This guarantees the "never feed NPC TTS into STT" invariant even during fail-closed operation — the worst case is wasted STT compute, not corrupted game state. The alternative (fail-open = drop all audio) would silence humans during transient Master outages, which is worse. + +**Serial-speech gate during control-plane failures.** When the voice-ingest WS connection to Master is down, Master cannot receive `vad_speech_started` / `vad_speech_ended`. During this window the `human_currently_speaking` gate remains in whichever state it was left (cleared on disconnect per the restart semantics below). `SpeakArbiter` continues to schedule NPC speech; if a human is actually speaking but the gate is unset (because voice-ingest disconnected), the NPC may speak concurrently — this is an accepted degradation under connection loss. The reconnection path is self-healing: voice-ingest's next `vad_speech_started` after reconnection re-establishes the gate. No manual intervention is required. + +**Restart semantics for open VAD windows.** On voice-ingest restart, all open VAD windows are abandoned — no `vad_speech_ended` is sent for windows from the prior process. On Master restart, all tracked `human_currently_speaking` gates are cleared (Master starts with an empty gate set); if a VAD window is genuinely still open, voice-ingest's next `vad_speech_started` re-establishes it after reconnection. On Master-side WS disconnect from voice-ingest, Master clears all `human_currently_speaking` gates associated with that voice-ingest connection, logging `failure_reason=voice_ingest_disconnect`. + +### In-process contracts (Protocol-based, for testability) + +| Protocol | Implementations (real / fake) | Owner | +|----------|-------------------------------|-------| +| `SpeechEventStore` | `SqliteSpeechEventStore` / `FakeSpeechEventStore` | Master | +| `PublicDiscussionStateBuilder` | `DefaultBuilder` (pure code) / fake n/a (build is pure) | Master | +| `NpcRegistry` | `InMemoryNpcRegistry` / `FakeNpcRegistry` | Master | +| `SpeakArbiter` | `DefaultSpeakArbiter` / `FakeArbiter` (test override) | Master | +| `MasterWsServer` | `WebsocketsMasterWsServer` / `FakeMasterWsServer` | Master | +| `VoiceIngestClient` (master-side) | `WebsocketsVoiceIngestClient` / `FakeVoiceIngestClient` | voice-ingest | +| `SttService` | `GeminiSttService` / `FakeSttService` | voice-ingest | +| `VadEngine` | `WebrtcVadEngine` (or chosen library) / fake n/a | voice-ingest | +| `MasterClient` (npc-side) | `WebsocketsMasterClient` / `FakeMasterClient` | NPC bot | +| `TtsService` | `GoogleTtsService` (cost-minimized default) / `FakeTtsService` | NPC bot | +| `VoicePlayback` | `DiscordVoicePlayback` / `FakeVoicePlayback` | NPC bot | +| `NpcGenerator` | `XaiNpcGenerator` (reuses existing decider) / `FakeNpcGenerator` | NPC bot | + +The existing `DiscordAdapter`, `LLMAdapter`, `LLMActionDecider`, `MessagePoster`, `WakeSink` Protocols are preserved — `LLMAdapter` is unchanged for `rounds` mode; `reactive_voice` mode goes through the new `SpeakArbiter` instead. + +### Human text ingestion and PLAYER_SPEECH emission (R1-F01) + +**Human text → SpeechEvent.** In both discussion modes, human messages sent to the main text channel during any public speech phase (`DAY_DISCUSSION`, `DAY_RUNOFF_SPEECH`) are captured by `WolfCog` (existing Discord message listener on Master) and written as `SpeechEvent(source=text, speaker_kind=human)` via `SpeechEventStore`. No new inter-process contract is needed — this is an in-process path on Master. + +**SpeechEvent → PLAYER_SPEECH LogEntry + channel post.** Every accepted `SpeechEvent` — regardless of source (`text`, `voice_stt`, `npc_generated`) — triggers two side-effects on Master: +1. A `LogEntry(kind="PLAYER_SPEECH")` is appended to the existing game log (preserving the public-log contract for both modes). +2. The utterance text is posted to the main text channel via `MessagePoster` (for `voice_stt` and `npc_generated` sources this makes the utterance visible to text-only observers; for `text` source the original Discord message already serves as the channel post, so step 2 is skipped to avoid duplication). + +This is implemented as a `SpeechEventStore` post-write hook (or inline in the discussion service write path) so the contract is uniform across all ingestion origins: voice-ingest POST, NPC `SpeakResult` acceptance, rounds-mode backfill, and human text capture. + +**Reactive `SpeakResult` utterance validation.** Before writing a `SpeechEvent` and authorizing playback, Master validates the returned utterance text in `SpeakResult`: +- **Length cap**: In `reactive_voice` mode, `len(text) > 80` → reject with `failure_reason=utterance_too_long`; no `SpeechEvent` is written and no `PlaybackAuthorized` is issued. In `rounds` mode (if `SpeakResult` were ever used there), the cap is 300. The rejected result is recorded in `npc_speak_results` with `status=rejected` and the failure reason. +- **Phase freshness**: `phase_id` and `request_id` must match the current active phase and an outstanding request (existing check). +- Validation runs **before** `SpeechEvent` insertion and **before** `PlaybackAuthorized`, so an over-length utterance never enters the public discussion state or reaches Discord playback. + +## Persistence / Ownership + +### Data ownership boundaries + +- **Master is the sole writer** to all SQLite tables (`speech_events`, `npc_speak_*`, `npc_playback_events`, plus existing tables). voice-ingest and NPC bots never touch `wolfbot.db`. +- **NPC bots own their own Discord identity** (token, voice channel join, audio playback). Master never plays audio. +- **voice-ingest owns audio capture**. NPC bots and Master never read incoming voice packets. + +### Storage mechanisms + +- All persistent state stays in `wolfbot.db` (SQLite via aiosqlite). Schema changes are additive (`CREATE TABLE IF NOT EXISTS`, `ALTER TABLE ADD COLUMN` guarded by `PRAGMA table_info`). +- The TTS audio cache (used by NPC bots) is in-memory per process for the MVP; cache key = `provider + voice_id + sha256(text) + speed + pitch`. A persistent cache is out of scope. +- WebSocket sessions, heartbeat tracking, and arbiter gates are in-memory on Master. After restart, Master starts with empty session state and reconciles existing DB rows via the recovery sweep. + +### Artifact ownership + +- `proposal.md`, `design.md`, `tasks.md`, the change's `specs/**/*.md` deltas — owned by this change directory. +- Baseline `openspec/specs//spec.md` files — shared across changes; will be merged-in form on archive (per OpenSpec convention). + +## Integration Points + +### External systems + +- **Discord (gateway + voice + REST)** — Master, every NPC bot, voice-ingest each maintain their own Discord client connection. Slash commands stay on Master. +- **xAI Grok endpoint** — used by Master's existing `LLMAdapter` (rounds mode) and by each NPC bot (reactive_voice). No change to the `LLMAction` schema or to `response_format` strictness. +- **Gemini API (audio input)** — invoked by voice-ingest for STT only. Out of band from xAI traffic. +- **TTS provider (cost-minimized default: Google Cloud TTS Standard voices)** — invoked by each NPC bot. Provider is pluggable via `TTS_PROVIDER` / `TTS_VOICE_ID`. + +### Cross-layer dependency points + +- voice-ingest depends on Master's NPC registry (delivered via WS `registry_snapshot` / `registry_update`) to know which Discord user IDs to drop. If the WS connection is down or no snapshot has been received, voice-ingest operates fail-closed (empty NPC set → all audio processed; see "voice-ingest → Master" contract above for rationale). +- The serial-speech gate on Master couples voice-ingest VAD lifecycle events (`vad_speech_started`, `vad_speech_ended`) with NPC playback windows. The gate is the single integration point where these two streams meet; gate transitions and timeout behavior are specified in the voice-ingest WS contract above. +- `SpeakArbiter` reads `PublicDiscussionState`, which is built from `speech_events`, which is fed by both voice-ingest and NPC bots. This is a closed loop and is the heart of reactive_voice. + +### Regen / retry / save / restore boundaries + +- xAI errors retry within `tenacity` (existing). NPC bot failures bubble up as `speak_result.status=error` with `failure_reason`. +- STT errors and low-confidence results drop the utterance silently (no retry from voice-ingest's side — the human can simply speak again). +- TTS errors are recorded; the NPC stays silent for that turn but the game continues. +- Process restarts: in-flight WS state is dropped, audit-trail rows are finalized as rejected/failed by the recovery sweep, `PublicDiscussionState` is rebuilt from `speech_events`. + +## Ordering / Dependency Notes + +Implementation must proceed in the order below because later concerns rely on earlier contracts. Where parallel work is feasible, it is called out. + +1. **Foundational (must come first):** + - C1 `speech-event-bus` — defines the `SpeechEvent` shape, the `speech_events` table, the apply-event/rebuild functions. Everything downstream produces or consumes `SpeechEvent`. + - C2 `PublicDiscussionState` — pure code, depends on C1's shape. + +2. **Parallelizable once C1+C2 land:** + - C3 `voice-ingest` worker (depends on C1 shape, on the Master ingestion endpoint, and on the NPC-registry read endpoint). + - C4 `wolfbot-npc` worker skeleton (depends on the Master WS protocol shape from C5 below). + - C5 Master WS server + `MasterLogicBuilder` + `SpeakArbiter` + new tables (depends on C2). + - C6 `LLM_DISCUSSION_MODE=rounds` SpeechEvent writer (depends on C1; preserves observable rounds behavior). + +3. **Integration (last):** + - C6 `LLM_DISCUSSION_MODE=reactive_voice` end-to-end wiring — ties C3, C4, C5 together. + - C7 structured-log standardization + `discussion_phase_summary` event — overlays everything; can begin in parallel with C5/C6. + +4. **Operational ordering:** + - DB migrations before any new code path that writes new tables. + - Protocol Fakes alongside the real implementations (TDD-friendly). + - Apply tasks should land in commits aligned to the concern boundaries above (one commit per concern when feasible). + +## Completion Conditions + +A concern is complete when its observable conditions and reviewable artifacts are all in place: + +| Concern | Reviewable artifact | Observable condition | +|---------|---------------------|----------------------| +| C1 `speech-event-bus` | `domain/discussion.py` (`SpeechEvent`), `services/discussion_service.py`, `persistence/schema.py` migration block, unit tests | A `SpeechEvent` for any input source round-trips through write → rebuild → identical state. | +| C2 `PublicDiscussionState` | `domain/discussion.py` (state value object + `apply_speech_event` + `rebuild_public_state_from_events`), property tests | For a fixed event sequence and alive-seat baseline, the state is bitwise reproducible from a fresh fold; co_claims and silent_seats match the specified rules; silent_seats correctly excludes dead seats. | +| C3 `voice-ingest` | `services/voice_ingest_service.py`, `services/stt_service.py`, NPC-registry read client, ingestion HTTP client, unit tests with `FakeSttService` | A scripted human VAD segment produces exactly one `SpeechEvent(source=voice_stt)` on Master; an NPC packet produces zero `SpeechEvent`s; below-threshold STT produces zero `SpeechEvent`s plus the expected log events. | +| C4 `wolfbot-npc` worker | `npc_bot_main.py`, `services/npc_client.py`, `services/npc_speech_service.py`, `services/tts_service.py`, `services/voice_playback_service.py`, integration test with `FakeMasterWsServer` + `FakeTtsService` + `FakeVoicePlayback` | The NPC bot registers, receives a `SpeakRequest`, returns `SpeakResult`, waits for `PlaybackAuthorized` before calling TTS, synthesizes and plays only within the authorized window, reports `tts_finished`/`tts_failed` and `playback_finished`/`playback_failed`. | +| C5 Master speech arbitration | `services/master_logic_service.py`, `services/speak_arbiter.py`, `services/npc_registry.py`, `services/master_ws_server.py`, three new tables, recovery sweep, unit + integration tests | An end-to-end reactive_voice fixture (Master + Fake NPC + Fake voice-ingest) produces correct serial speech, drops stale results, and rebuilds `PublicDiscussionState` after a simulated Master restart. | +| C6 `day-discussion` mode plumbing | `LLM_DISCUSSION_MODE` setting in `config.py`, dispatcher in `game_service`, regression tests for `rounds` mode (must stay green) | Existing `rounds` tests still pass; a new `reactive_voice` integration test exercises the new path; mode is observably fixed for a game's lifetime. | +| C7 Observability | Logging helper in shared module, every new service emits the required fields, `discussion_phase_summary` event at every phase end, structured-log fixture tests | Every spec-listed log event appears in the test logs with the required fields; `discussion_phase_summary` includes separate `tts_success` / `tts_failed` counts (from `npc_playback_events.tts_outcome`) and `playback_success` / `playback_failed` counts; speech-event counts equal the number of `SpeechEvent` rows for the phase under test. | + +## Accepted Spec Conflicts + +| id | capability | delta_clause | baseline_clause | rationale | accepted_at | +|----|-----------|--------------|-----------------|-----------|-------------| +| AC1 | llm-seats | Under `LLM_DISCUSSION_MODE=reactive_voice`, utterances MUST be ≤80 characters and MAY be shorter. | Every LLM-generated utterance SHALL be Japanese, between 80 and 300 characters inclusive. | Reactive voice mode favors short reactive interjections (single barbs, ≤80 chars) over long monologues; the user explicitly approved relaxing the lower bound under this mode during clarify. The 80–300 contract is preserved unchanged for `rounds` mode. | 2026-04-26T04:50:00Z | +| AC2 | speech-event-bus | A sentinel row with `source=phase_baseline` and `alive_seat_nos_json` is inserted into `speech_events` at the start of each public speech phase to provide the alive-seat baseline for `PublicDiscussionState` rebuild. | `SpeechEvent` is defined as a unified public-utterance contract with `source` values `voice_stt`, `text`, `npc_generated`. | The sentinel makes the `PublicDiscussionState` fold self-contained (rebuild reads only `speech_events`, never the `seats` table), which is critical for correctness on Master restart. The sentinel is excluded from public-log emission (no `PLAYER_SPEECH` LogEntry, no channel post) and from all downstream consumer counts; consumers filter on `source != phase_baseline`. | 2026-04-26T15:00:00Z | diff --git a/openspec/changes/add-master-npc-voice-stt/proposal.md b/openspec/changes/add-master-npc-voice-stt/proposal.md new file mode 100644 index 0000000..fb98db0 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/proposal.md @@ -0,0 +1,51 @@ +## Why + +Today the bot drives 9-player Werewolf entirely through Discord text. LLM-controlled seats only emit a fixed two-round burst of `PLAYER_SPEECH` log entries during `DAY_DISCUSSION`, and human players type to participate. To make games feel like a live voice table — humans speaking aloud, NPCs replying with TTS — we need three things the current architecture cannot provide: + +1. A way to ingest human voice from a Discord VC and turn it into structured `SpeechEvent` records. +2. A reactive (rather than fixed-round) discussion engine that can decide *which* NPC should speak *now*, given the unfolding public dialogue. +3. NPC voices that are visibly distinct in Discord (one Discord bot identity per NPC) and that play back through TTS in the same VC where humans are speaking — but whose TTS audio is never re-fed into STT. + +Putting all of this inside the single existing bot process would couple game logic, public-state derivation, voice capture, LLM generation, TTS, and VC playback into one untestable surface. Splitting into Master + per-NPC voice bots + a dedicated voice-ingest worker isolates each concern, makes failures observable, and lets us run NPCs as real Discord identities with their own voices. + +## What Changes + +- **Introduce `SpeechEvent` as the unified public-utterance contract.** Every utterance — human voice (via STT), human text, and NPC-generated (via Grok) — is recorded as a `SpeechEvent` with the same schema, distinguished by a `source` enum (`voice_stt`, `text`, `npc_generated`) and a `speaker_kind` enum (`human`, `npc`). For human voice, exactly **one** `SpeechEvent` is written **after STT completes**; the upstream VAD lifecycle (`vad_speech_started`, `vad_speech_ended`) is emitted as separate structured-log events on the voice-ingest worker, not as `SpeechEvent` records. Persisted in a new `speech_events` table. **Both `LLM_DISCUSSION_MODE` modes** write `SpeechEvent` records — `rounds` mode also persists each LLM utterance as `SpeechEvent(source=npc_generated)` — so persistence is uniform. +- **Introduce `PublicDiscussionState` derived from `SpeechEvent` history.** Master maintains `co_claims`, `stances`, `pressure`, `open_topics`, `silent_seats`, and `recent_speech_event_ids` as code-side derivations from the event log — never as LLM-generated state. Initially in-memory; rebuildable from `speech_events`. **Both modes build the state** so debugging telemetry and `discussion_phase_summary` are uniform; `reactive_voice` additionally consumes the state to drive `MasterLogicBuilder` / `SpeakArbiter`. **MVP-required derivation rules** are limited to (a) `co_claims` from `SpeechEvent.text` containing canonical CO tokens (`〇〇CO`, `占いCO`, `霊媒CO`, `騎士CO`, etc.) for the speaker's seat, and (b) `silent_seats` as the set of seats with zero `SpeechEvent`s in the current `phase_id`. The remaining fields (`stances`, `pressure`, `open_topics`) are skeletons in MVP — their concrete heuristics are decided in the design phase. +- **Add a voice-ingest worker.** Receives only human VC audio, runs per-speaker VAD, STT, and emits `SpeechEvent(source=voice_stt)` to Master. NPC TTS audio is explicitly excluded from STT: voice-ingest reads the Master NPC registry, builds the set of `discord_bot_user_id`s currently registered as NPCs, and discards any incoming voice packet whose `user_id` is in that set at the receive boundary (before VAD/STT). The MVP STT is **Gemini API audio input**. STT results below a configured `stt_confidence` threshold (or hard provider errors) are **dropped without writing a `SpeechEvent`** — only `vad_speech_started`, `vad_speech_ended`, and `stt_request_failed` / `stt_low_confidence` structured-log events are recorded; no Discord-side surfacing of "please repeat" is required for MVP. +- **Split each NPC into its own bot process.** Per-NPC Discord token, persona, private state, and TTS voice. The NPC bot connects to Master via WebSocket, receives `LogicPacket` + `SpeakRequest`, generates a short utterance via Grok, returns `SpeakResult` to Master, and plays TTS in VC **only after** receiving `PlaybackAuthorized`. NPC bots never speak unsolicited. The MVP runs Master and all NPC bot processes on the **same host**: the WebSocket server binds to `127.0.0.1` and authenticates connections with a **pre-shared key** read from an environment variable. Multi-host deployment, TLS, and per-NPC token rotation are out of scope for the MVP. +- **Add a Master-side speech arbitrator.** `MasterLogicBuilder` builds per-NPC `LogicPacket`s from the public state; `SpeakArbiter` decides which NPC to wake, dispatches `SpeakRequest` with `phase_id` and `expires_at`, validates returned `SpeakResult` against current phase, records the accepted utterance as `SpeechEvent(source=npc_generated)`, and authorizes playback. Speech is **strictly serial** within a game: while a human is mid-utterance (after `vad_speech_started` and before either `vad_speech_ended` *and* a finalized `SpeechEvent(source=voice_stt)` arrives — or a configurable timeout) or another NPC's `PlaybackAuthorized` window is still open, the arbitrator rejects new `SpeakRequest`s with `failure_reason=human_currently_speaking` or `queue_busy`. A `PlaybackAuthorized` window closes when **either** the NPC bot reports `playback_finished` over the WebSocket **or** the Master clock passes `playback_deadline_ms` (whichever comes first); a deadline-driven close is logged with `failure_reason=tts_timeout` or `discord_playback_error` so the cause is auditable. Stale results (phase changed, expired) are dropped with structured log evidence. +- **Tolerate NPC bot disconnection.** When `SpeakArbiter` evaluates a candidate NPC for whom no recent heartbeat exists or whose WebSocket is offline, that NPC silently stays out of the round; the game continues, votes and night actions still resolve through the existing DM flow. There is no automatic fallback to text-mode speech for that NPC during the affected phase. +- **Recovery on process restart.** If Master, voice-ingest, or any NPC bot restarts during `DAY_DISCUSSION`, in-flight state at that process boundary is dropped: any open `SpeakRequest` whose `SpeakResult` has not yet arrived, and any unfinished `PlaybackAuthorized` window, are closed with `failure_reason=master_restart` (or `npc_restart` / `voice_ingest_restart` as appropriate); open VAD windows on voice-ingest are abandoned. On Master restart, `PublicDiscussionState` is rebuilt from `speech_events` for the active phase. The existing `RecoveryService` rule — `deadline_epoch < now` parks the game in `WAITING_HOST_DECISION` — is preserved and is the canonical path for stale phases. There is no per-request persistence of in-flight WebSocket exchanges. +- **Make `DAY_DISCUSSION` engine pluggable.** New env var `LLM_DISCUSSION_MODE` selects between `rounds` (existing two-round LLM batch — default, kept on the existing 80–300 character cap) and `reactive_voice` (the new Master-arbitrated flow, capped at **≤80 characters per utterance** to favor short reactive interjections). The `PLAYER_SPEECH` public-log contract is preserved by both modes. Mode is fixed for the lifetime of a single game (no mid-game switching). +- **Add new persistence tables.** `speech_events`, `npc_speak_requests`, `npc_speak_results`, `npc_playback_events` — all additive (`CREATE TABLE IF NOT EXISTS`) and backwards-compatible. +- **Standardize structured logging across all components.** Required fields include `ts`, `level`, `component`, `event`, `game_id`, `phase_id`, `trace_id`, `span_id`, plus event-specific fields. A `discussion_phase_summary` event is emitted at the end of every discussion phase summarizing STT / generation / authorization / playback / stale-drop counts. +- **Standardize `failure_reason` enum** across STT, LLM, TTS, VC, and protocol layers (e.g. `stale_phase`, `expired_request`, `npc_offline`, `tts_timeout`, `stt_low_confidence`, `playback_not_authorized`). +- **Preserve unchanged surfaces.** Voting, night actions, wolf-channel chat, persona registry, Grok structured-output schema, SQLite optimistic-locking transition flow, DM-based vote/action submission, and restart recovery are kept as-is. + +## Capabilities + +### New Capabilities + +- `speech-event-bus`: Unified `SpeechEvent` contract for all public utterances; code-side `PublicDiscussionState` derivation; `speech_events` table; rebuild-from-log semantics. +- `voice-ingest`: Human-only Discord VC audio capture, per-speaker VAD, STT, and `SpeechEvent(source=voice_stt)` emission to Master. NPC TTS audio is explicitly out of scope for STT. +- `npc-voice-pipeline`: End-to-end Master ↔ NPC bot speech protocol — separated NPC bot worker processes (one Discord token each), WebSocket transport, Master-side `MasterLogicBuilder` / `SpeakArbiter` / NPC registry / `LogicPacket` / `SpeakRequest` / `SpeakResult` / `PlaybackAuthorized` lifecycle, NPC-side Grok generation / TTS / VC playback gated on Master authorization, `phase_id` + `expires_at` staleness handling, and the `npc_speak_requests` / `npc_speak_results` / `npc_playback_events` tables. + +### Modified Capabilities + +- `day-discussion`: Add `LLM_DISCUSSION_MODE` setting; introduce the `reactive_voice` mode that replaces fixed-round LLM batches with Master-arbitrated NPC speech driven by `PublicDiscussionState`, capped at ≤80 chars per utterance. The default `rounds` mode keeps its existing two-round LLM-speech batches and 80–300-char cap. **Both modes additionally write every utterance (human text, human voice via STT, NPC) as a `SpeechEvent` and build `PublicDiscussionState`**, so persistence and telemetry are uniform; only the LLM speech-generation strategy differs between modes. Mode is fixed for the lifetime of a single game. Both modes continue to emit `PLAYER_SPEECH` log entries (the public-log contract is unchanged), and a `discussion_phase_summary` log event is emitted at every discussion phase end. +- `llm-seats`: Decouple LLM-driven NPC behavior from the in-process `LLMAdapter` so it can also run inside a separate `wolfbot-npc` process. NPC bots receive `LogicPacket` payloads (rather than the full game snapshot) for day-discussion utterances, while preserving today's persona registry, prompt-builder pipeline, role-strategy isolation, structured-output `LLMAction` schema, and seat-token resolver. +- `discord-integration`: Permit multiple Discord bot identities to coexist in one guild — one Master bot plus one bot per NPC. Add VC join/playback responsibilities to the NPC bot side and rules ensuring NPC bots never speak before Master authorizes their utterance. The existing `/wolf` slash-command set, channel-permission triplet, DM views, and end-of-game channel deletion behavior are preserved. + +## Impact + +- **New code modules** (Master): `src/wolfbot/domain/discussion.py` (SpeechEvent, PublicDiscussionState), `src/wolfbot/services/discussion_service.py`, `src/wolfbot/services/master_logic_service.py`, `src/wolfbot/services/speak_arbiter.py`, `src/wolfbot/services/npc_registry.py`, `src/wolfbot/services/master_ws_server.py`. +- **New code modules** (NPC bot worker): `src/wolfbot/npc_bot_main.py`, `src/wolfbot/services/npc_client.py`, `src/wolfbot/services/npc_speech_service.py`, `src/wolfbot/services/tts_service.py`, `src/wolfbot/services/voice_playback_service.py`. +- **New code modules** (voice ingest): `src/wolfbot/services/voice_ingest_service.py`, `src/wolfbot/services/stt_service.py`. +- **Persistence**: additive migrations in `src/wolfbot/persistence/schema.py` for `speech_events`, `npc_speak_requests`, `npc_speak_results`, `npc_playback_events`. No destructive changes. +- **Configuration**: new env vars `LLM_DISCUSSION_MODE` (Master), `MASTER_WS_LISTEN` (`127.0.0.1:`), `MASTER_NPC_PSK` (pre-shared key), plus a separate NPC-process env set (`WOLFBOT_PROCESS_ROLE`, `MASTER_WS_URL` pointing at `127.0.0.1`, `MASTER_NPC_PSK`, `NPC_ID`, `NPC_DISCORD_TOKEN`, `TTS_PROVIDER`, `TTS_VOICE_ID`). The voice-ingest process reuses Master's `MAIN_VOICE_CHANNEL_ID` and the same NPC bot identities published by Master's NPC registry — there is no separate `NPC_VOICE_CHANNEL_ID`. Existing env vars (`DISCORD_TOKEN`, `XAI_API_KEY`, `MAIN_VOICE_CHANNEL_ID`, etc.) remain. +- **Dependencies**: STT and TTS providers are pluggable. The MVP pins **Gemini API audio input for STT** and a low-cost TTS provider (e.g. Google Cloud TTS Standard voices or comparable) chosen during the design phase under a hard cost-minimization constraint. WebSocket transport reuses the existing async stack (`websockets` or equivalent). No removal of existing deps. +- **Discord application registrations**: each NPC requires a separate Discord bot user / token. Operationally non-trivial — covered in design. +- **Backwards compatibility**: default `LLM_DISCUSSION_MODE=rounds` keeps the **observable** discussion behavior (round count, length cap, public log channel posts, deadline) identical to today. The only added side-effects in `rounds` mode are internal: each utterance is also written as a `SpeechEvent` row and contributes to the in-memory `PublicDiscussionState`. `reactive_voice` is opt-in and additive. +- **Testing**: every external surface — STT, TTS, NPC ↔ Master WebSocket, Discord VC — is reached through a Protocol so tests substitute Fakes. New Fakes added: `FakeSttService`, `FakeTtsService`, `FakeNpcClient`, `FakeMasterWsServer`, `FakeVoicePlayback`. Unit and integration tests run with these Fakes only — no live STT/TTS/Discord/xAI traffic in the test suite. +- **Out of scope (future changes)**: persistent `PublicDiscussionState` snapshots, multi-guild support, multi-host deployment, automatic NPC token provisioning, alternate STT/TTS providers beyond the MVP pick, mid-game `LLM_DISCUSSION_MODE` switching, automatic text-mode fallback when an NPC bot disconnects. diff --git a/openspec/changes/add-master-npc-voice-stt/review-ledger-design.json b/openspec/changes/add-master-npc-voice-stt/review-ledger-design.json new file mode 100644 index 0000000..a404246 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/review-ledger-design.json @@ -0,0 +1,308 @@ +{ + "feature_id": "add-master-npc-voice-stt", + "phase": "design", + "current_round": 5, + "status": "has_open_high", + "max_finding_id": 16, + "findings": [ + { + "id": "R1-F01", + "severity": "high", + "category": "completeness", + "title": "Human text and public PLAYER_SPEECH flows are not fully designed or tasked", + "detail": "The design/tasks cover rounds NPC backfill, voice STT ingestion, and reactive NPC playback, but they never add the master-side integration that records human Discord text as `SpeechEvent(source=text)` or the path that emits `LogEntry(kind=\"PLAYER_SPEECH\")` and main-channel posts when a voice-STT or reactive NPC utterance is accepted. The spec requires every public utterance (human text, human voice, NPC) to create a SpeechEvent and preserve the PLAYER_SPEECH contract in both modes. Add explicit contracts and tasks for text-message ingestion plus public-log emission for STT and reactive NPC speech.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 2 + }, + { + "id": "R1-F02", + "severity": "high", + "category": "feasibility", + "title": "The voice-ingest to Master control plane is too underspecified to implement serial speech safely", + "detail": "`SpeakArbiter` must suppress NPC speech after `vad_speech_started` and until `vad_speech_ended` plus final STT completion, but the only defined voice-ingest→Master contract is a finalized `SpeechEvent` POST and NPC-registry lookup. There is no explicit VAD lifecycle message path, and the design also defers key boundary decisions such as the concrete transport choice and what happens when NPC-registry state is unavailable. As written, Master cannot reliably implement `human_currently_speaking`, and voice-ingest can violate the \"never feed NPC TTS into STT\" guarantee during control-plane failures. Choose the transport in the design, add explicit VAD start/end signaling and restart semantics, and define fail-closed registry/error behavior and shared `failure_reason` ownership before implementation.", + "origin_round": 1, + "latest_round": 1, + "status": "open", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R2-F03", + "severity": "high", + "category": "completeness", + "file": "design.md", + "title": "PlaybackAuthorized does not create an open playback record", + "detail": "The design says `npc_playback_events` is written only on `playback_finished`, `playback_failed`, or deadline timeout, yet restart recovery and serial gating both depend on knowing which playback windows are currently open. Without inserting an open playback row when `PlaybackAuthorized` is issued, Master cannot satisfy the spec's requirement to mark open playback rows failed on `master_restart`, and `request_id` joins cannot reconstruct the full lifecycle. Define an authorization-open row (or equivalent persisted state) created at authorization time and explicitly add tasks to open and close it.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R2-F04", + "severity": "medium", + "category": "completeness", + "file": "design.md", + "title": "Voice STT payloads cannot populate required audio boundary fields", + "detail": "The voice-ingest contract sends `speech_event_payload` with `confidence` and `duration_ms`, while `vad_speech_started`/`vad_speech_ended` expose only generic `ts` values. The `speech_events` schema requires `audio_start_ms` and `audio_end_ms` aligned to the actual VAD segment boundaries for successful `voice_stt` rows. As written, Master has no explicit contract for those persisted fields and would have to infer them from message timing. Add boundary timestamps (or a segment id plus explicit start/end times) to the WS messages and a task to persist them into `speech_events`.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R2-F05", + "severity": "medium", + "category": "consistency", + "file": "design.md", + "title": "silent_seats rebuild is underspecified for alive-seat filtering", + "detail": "The design states that `PublicDiscussionState` is rebuilt as a pure fold over current-phase `speech_events`, but the MVP rule defines `silent_seats` as alive seats with zero speech in the phase. Alive/dead membership is not derivable from the speech log alone, so a restart rebuild cannot distinguish a dead seat from a living silent one. Either persist a phase-start alive-seat baseline as part of the rebuild inputs, or explicitly record and justify a spec conflict; the current state contract is internally inconsistent.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R3-F06", + "severity": "medium", + "category": "consistency", + "file": "design.md", + "title": "PublicDiscussionState rebuild now depends on `seats` state instead of `speech_events` alone", + "detail": "The design resolves dead-seat filtering by seeding the fold from `alive_seat_nos` read from the `seats` table, and tasks 2.1-2.4 mirror that approach. The spec explicitly requires `PublicDiscussionState` to be rebuildable from `speech_events` alone. Persist the phase-start alive-seat baseline as phase-scoped data within the log/persistence model, or record an explicit accepted spec conflict; otherwise replay correctness depends on mutable external state.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R3-F07", + "severity": "medium", + "category": "completeness", + "file": "tasks.md", + "title": "Restart-recovery tasks omit rejecting open speak requests", + "detail": "Task 5.3 only calls out closing open playback rows and clearing in-memory human-speaking gates. It does not explicitly include the required recovery sweep that inserts rejected `npc_speak_results` rows with `failure_reason=master_restart` for open `npc_speak_requests` lacking a result. Without that task, the restart contract and per-request audit trail are incomplete.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R3-F08", + "severity": "medium", + "category": "completeness", + "file": "design.md", + "title": "The protocol does not expose enough TTS-stage telemetry for `discussion_phase_summary`", + "detail": "Master currently receives `playback_finished` / `playback_failed`, and task 9.3 says phase summaries are derived from `speech_events` and playback audit rows. That is not enough to populate the required `tts_success` and `tts_failed` counts separately from `playback_success` and `playback_failed`, especially when TTS succeeds but Discord playback fails or TTS fails before playback begins. Add explicit TTS-stage outcome reporting or persisted stage markers and include them in the summary task.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R3-F09", + "severity": "medium", + "category": "completeness", + "file": "design.md", + "title": "Reactive `SpeakResult` acceptance omits the required utterance validator", + "detail": "The design specifies freshness checks for `phase_id` and `request_id`, but it does not define Master-side validation of the returned utterance before writing a `SpeechEvent` and authorizing playback. The spec requires over-length reactive utterances to be rejected with `failure_reason=utterance_too_long` and no playback authorization. Add explicit validation for the `<=80` character cap in the acceptance contract, plus tasks/tests for the rejection path.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R4-F10", + "severity": "high", + "category": "consistency", + "file": "design.md", + "title": "TTS is moved ahead of `PlaybackAuthorized`, breaking the authorization and serial-speech contract", + "detail": "The current lifecycle and tasks 5.2/7.3 have the NPC worker synthesize TTS first, report `tts_finished`/`tts_failed`, and only then wait for `PlaybackAuthorized`. The spec requires the NPC bot to call TTS only after receiving `PlaybackAuthorized`. This change is not cosmetic: because the playback window is only opened after TTS succeeds, synthesis time is no longer covered by the serial gate, so multiple NPC turns can be prepared concurrently even though speech is supposed to be strictly serialized. Keep `PlaybackAuthorized` immediately after Master accepts `SpeakResult`, then run TTS and playback under that authorization window while reporting TTS-stage telemetry from inside the authorized lifecycle.", + "origin_round": 4, + "latest_round": 4, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R4-F11", + "severity": "medium", + "category": "consistency", + "file": "design.md", + "title": "The `phase_baseline` sentinel widens the public `SpeechEvent` contract without an accepted spec conflict", + "detail": "To solve replayability, the design/tasks add `source=phase_baseline` plus `alive_seat_nos_json` sentinel rows inside `speech_events`. The published spec defines `SpeechEvent` as the unified public-utterance contract with `source` values `voice_stt | text | npc_generated`, and the accepted-conflicts section does not record this new source type. As written, implementation will place non-utterance rows into the public utterance table and every downstream consumer must special-case them. Either record this as an explicit accepted spec conflict or move the alive-seat baseline into a separately documented persistence artifact that does not masquerade as a normal `SpeechEvent`.", + "origin_round": 4, + "latest_round": 4, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R4-F12", + "severity": "medium", + "category": "completeness", + "file": "tasks.md", + "title": "SpeechEvent capture is scoped to `DAY_DISCUSSION` instead of all public speech phases", + "detail": "The design's human-text contract and task 3.2 explicitly capture main-channel text only during `DAY_DISCUSSION`, and the mode-plumbing tasks are likewise framed only around `DAY_DISCUSSION`. The spec, however, requires every public utterance to become a `SpeechEvent` and requires `discussion_phase_summary` at `DAY_RUNOFF_SPEECH` as well when that phase exists. Without making the shared write path and tests explicit for runoff/public-speech phases too, those utterances can fall out of `speech_events`, state rebuild, and phase summaries. Expand the tasks/contracts to all public speech phases, especially `DAY_RUNOFF_SPEECH`.", + "origin_round": 4, + "latest_round": 4, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R5-F13", + "severity": "high", + "category": "consistency", + "file": "design.md", + "title": "Registry-loss behavior still feeds NPC audio into VAD/STT", + "detail": "The voice-ingest contract says that when no `registry_snapshot` has been received or the Master connection is down, the worker treats the NPC set as empty and processes all audio, relying on Master to discard NPC-originated transcripts later. That contradicts the spec's receive-boundary rule that NPC packets must be discarded before VAD/STT, and it breaks the proposal's \"never feed NPC TTS into STT\" guarantee during control-plane failures. The design needs a true fail-safe path here: either stop capture until a registry snapshot is available, retain a last-known-good NPC registry across reconnects, or block NPC playback while the registry view is unavailable. Task 6.2 should be updated to implement that behavior instead of the current empty-set fallback.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F14", + "severity": "high", + "category": "consistency", + "file": "design.md", + "title": "Voice-ingest disconnect handling breaks strict serial speech", + "detail": "The design explicitly clears all `human_currently_speaking` gates on voice-ingest disconnect and allows `SpeakArbiter` to keep scheduling NPC speech until a future `vad_speech_started` re-establishes the gate. That creates a window where a human can be speaking while Master believes the gate is clear, violating the requirement that speech remain strictly serial whenever a human utterance is open. Master needs to fail safe when capture-state visibility is lost, for example by suppressing new NPC requests while voice-ingest is disconnected or until capture readiness is re-established. Update tasks 4.4, 5.2, and 6.5 accordingly.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F15", + "severity": "high", + "category": "completeness", + "file": "design.md", + "title": "Voice-ingest has no authoritative source for game, phase, or seat context", + "detail": "The voice-ingest protocol only defines `registry_snapshot` and `registry_update`, but its outgoing messages and required structured logs include `game_id`, `phase_id`, and `seat_no`, and the worker must also know whether the current phase is a public speech phase at all. As written, voice-ingest has no contract for learning the active game, current phase, or the human `discord_user_id -> seat_no` mapping, so it cannot reliably label utterances, distinguish players from non-players, or avoid turning lobby/night chatter into `SpeechEvent`s. Add a Master→voice-ingest lifecycle/assignment contract (active-game snapshot, phase updates, seat map updates, public-speech enabled flag), or move game/phase/seat resolution entirely to Master and simplify the worker payloads and logs to match.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F16", + "severity": "medium", + "category": "completeness", + "file": "tasks.md", + "title": "`discussion_phase_summary` lacks a defined source of truth for required counters", + "detail": "Task 9.3 says the phase summary is emitted from `speech_events` and playback audit rows, but mandatory fields such as `logic_packets_built` and `stt_failed` are not stored in those tables, and the design never defines a restart-safe per-phase accumulator for them. That leaves the implementation without a reliable way to satisfy the required summary schema, especially after a mid-phase restart. The design/tasks should specify where every required counter comes from and how it survives restart boundaries, whether via a dedicated persisted phase-metrics artifact or an explicit recovery model for in-memory counters.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + } + ], + "round_summaries": [ + { + "round": 1, + "total": 2, + "open": 2, + "new": 2, + "resolved": 0, + "overridden": 0, + "by_severity": { + "high": 2 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-design_review-1" + }, + { + "round": 2, + "total": 5, + "open": 4, + "new": 3, + "resolved": 1, + "overridden": 0, + "by_severity": { + "high": 2, + "medium": 2 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-design_review-2" + }, + { + "round": 3, + "total": 9, + "open": 5, + "new": 4, + "resolved": 4, + "overridden": 0, + "by_severity": { + "high": 1, + "medium": 4 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-design_review-3" + }, + { + "round": 4, + "total": 12, + "open": 4, + "new": 3, + "resolved": 8, + "overridden": 0, + "by_severity": { + "high": 2, + "medium": 2 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-design_review-4" + }, + { + "round": 5, + "total": 16, + "open": 5, + "new": 4, + "resolved": 11, + "overridden": 0, + "by_severity": { + "high": 4, + "medium": 1 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-design_review-5" + } + ] +} diff --git a/openspec/changes/add-master-npc-voice-stt/review-ledger-design.json.bak b/openspec/changes/add-master-npc-voice-stt/review-ledger-design.json.bak new file mode 100644 index 0000000..92c0084 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/review-ledger-design.json.bak @@ -0,0 +1,307 @@ +{ + "feature_id": "add-master-npc-voice-stt", + "phase": "design", + "current_round": 5, + "status": "has_open_high", + "max_finding_id": 16, + "findings": [ + { + "id": "R1-F01", + "severity": "high", + "category": "completeness", + "title": "Human text and public PLAYER_SPEECH flows are not fully designed or tasked", + "detail": "The design/tasks cover rounds NPC backfill, voice STT ingestion, and reactive NPC playback, but they never add the master-side integration that records human Discord text as `SpeechEvent(source=text)` or the path that emits `LogEntry(kind=\"PLAYER_SPEECH\")` and main-channel posts when a voice-STT or reactive NPC utterance is accepted. The spec requires every public utterance (human text, human voice, NPC) to create a SpeechEvent and preserve the PLAYER_SPEECH contract in both modes. Add explicit contracts and tasks for text-message ingestion plus public-log emission for STT and reactive NPC speech.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 2 + }, + { + "id": "R1-F02", + "severity": "high", + "category": "feasibility", + "title": "The voice-ingest to Master control plane is too underspecified to implement serial speech safely", + "detail": "`SpeakArbiter` must suppress NPC speech after `vad_speech_started` and until `vad_speech_ended` plus final STT completion, but the only defined voice-ingest→Master contract is a finalized `SpeechEvent` POST and NPC-registry lookup. There is no explicit VAD lifecycle message path, and the design also defers key boundary decisions such as the concrete transport choice and what happens when NPC-registry state is unavailable. As written, Master cannot reliably implement `human_currently_speaking`, and voice-ingest can violate the \"never feed NPC TTS into STT\" guarantee during control-plane failures. Choose the transport in the design, add explicit VAD start/end signaling and restart semantics, and define fail-closed registry/error behavior and shared `failure_reason` ownership before implementation.", + "origin_round": 1, + "latest_round": 1, + "status": "open", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R2-F03", + "severity": "high", + "category": "completeness", + "file": "design.md", + "title": "PlaybackAuthorized does not create an open playback record", + "detail": "The design says `npc_playback_events` is written only on `playback_finished`, `playback_failed`, or deadline timeout, yet restart recovery and serial gating both depend on knowing which playback windows are currently open. Without inserting an open playback row when `PlaybackAuthorized` is issued, Master cannot satisfy the spec's requirement to mark open playback rows failed on `master_restart`, and `request_id` joins cannot reconstruct the full lifecycle. Define an authorization-open row (or equivalent persisted state) created at authorization time and explicitly add tasks to open and close it.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R2-F04", + "severity": "medium", + "category": "completeness", + "file": "design.md", + "title": "Voice STT payloads cannot populate required audio boundary fields", + "detail": "The voice-ingest contract sends `speech_event_payload` with `confidence` and `duration_ms`, while `vad_speech_started`/`vad_speech_ended` expose only generic `ts` values. The `speech_events` schema requires `audio_start_ms` and `audio_end_ms` aligned to the actual VAD segment boundaries for successful `voice_stt` rows. As written, Master has no explicit contract for those persisted fields and would have to infer them from message timing. Add boundary timestamps (or a segment id plus explicit start/end times) to the WS messages and a task to persist them into `speech_events`.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R2-F05", + "severity": "medium", + "category": "consistency", + "file": "design.md", + "title": "silent_seats rebuild is underspecified for alive-seat filtering", + "detail": "The design states that `PublicDiscussionState` is rebuilt as a pure fold over current-phase `speech_events`, but the MVP rule defines `silent_seats` as alive seats with zero speech in the phase. Alive/dead membership is not derivable from the speech log alone, so a restart rebuild cannot distinguish a dead seat from a living silent one. Either persist a phase-start alive-seat baseline as part of the rebuild inputs, or explicitly record and justify a spec conflict; the current state contract is internally inconsistent.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R3-F06", + "severity": "medium", + "category": "consistency", + "file": "design.md", + "title": "PublicDiscussionState rebuild now depends on `seats` state instead of `speech_events` alone", + "detail": "The design resolves dead-seat filtering by seeding the fold from `alive_seat_nos` read from the `seats` table, and tasks 2.1-2.4 mirror that approach. The spec explicitly requires `PublicDiscussionState` to be rebuildable from `speech_events` alone. Persist the phase-start alive-seat baseline as phase-scoped data within the log/persistence model, or record an explicit accepted spec conflict; otherwise replay correctness depends on mutable external state.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R3-F07", + "severity": "medium", + "category": "completeness", + "file": "tasks.md", + "title": "Restart-recovery tasks omit rejecting open speak requests", + "detail": "Task 5.3 only calls out closing open playback rows and clearing in-memory human-speaking gates. It does not explicitly include the required recovery sweep that inserts rejected `npc_speak_results` rows with `failure_reason=master_restart` for open `npc_speak_requests` lacking a result. Without that task, the restart contract and per-request audit trail are incomplete.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R3-F08", + "severity": "medium", + "category": "completeness", + "file": "design.md", + "title": "The protocol does not expose enough TTS-stage telemetry for `discussion_phase_summary`", + "detail": "Master currently receives `playback_finished` / `playback_failed`, and task 9.3 says phase summaries are derived from `speech_events` and playback audit rows. That is not enough to populate the required `tts_success` and `tts_failed` counts separately from `playback_success` and `playback_failed`, especially when TTS succeeds but Discord playback fails or TTS fails before playback begins. Add explicit TTS-stage outcome reporting or persisted stage markers and include them in the summary task.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R3-F09", + "severity": "medium", + "category": "completeness", + "file": "design.md", + "title": "Reactive `SpeakResult` acceptance omits the required utterance validator", + "detail": "The design specifies freshness checks for `phase_id` and `request_id`, but it does not define Master-side validation of the returned utterance before writing a `SpeechEvent` and authorizing playback. The spec requires over-length reactive utterances to be rejected with `failure_reason=utterance_too_long` and no playback authorization. Add explicit validation for the `<=80` character cap in the acceptance contract, plus tasks/tests for the rejection path.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R4-F10", + "severity": "high", + "category": "consistency", + "file": "design.md", + "title": "TTS is moved ahead of `PlaybackAuthorized`, breaking the authorization and serial-speech contract", + "detail": "The current lifecycle and tasks 5.2/7.3 have the NPC worker synthesize TTS first, report `tts_finished`/`tts_failed`, and only then wait for `PlaybackAuthorized`. The spec requires the NPC bot to call TTS only after receiving `PlaybackAuthorized`. This change is not cosmetic: because the playback window is only opened after TTS succeeds, synthesis time is no longer covered by the serial gate, so multiple NPC turns can be prepared concurrently even though speech is supposed to be strictly serialized. Keep `PlaybackAuthorized` immediately after Master accepts `SpeakResult`, then run TTS and playback under that authorization window while reporting TTS-stage telemetry from inside the authorized lifecycle.", + "origin_round": 4, + "latest_round": 4, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R4-F11", + "severity": "medium", + "category": "consistency", + "file": "design.md", + "title": "The `phase_baseline` sentinel widens the public `SpeechEvent` contract without an accepted spec conflict", + "detail": "To solve replayability, the design/tasks add `source=phase_baseline` plus `alive_seat_nos_json` sentinel rows inside `speech_events`. The published spec defines `SpeechEvent` as the unified public-utterance contract with `source` values `voice_stt | text | npc_generated`, and the accepted-conflicts section does not record this new source type. As written, implementation will place non-utterance rows into the public utterance table and every downstream consumer must special-case them. Either record this as an explicit accepted spec conflict or move the alive-seat baseline into a separately documented persistence artifact that does not masquerade as a normal `SpeechEvent`.", + "origin_round": 4, + "latest_round": 4, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R4-F12", + "severity": "medium", + "category": "completeness", + "file": "tasks.md", + "title": "SpeechEvent capture is scoped to `DAY_DISCUSSION` instead of all public speech phases", + "detail": "The design's human-text contract and task 3.2 explicitly capture main-channel text only during `DAY_DISCUSSION`, and the mode-plumbing tasks are likewise framed only around `DAY_DISCUSSION`. The spec, however, requires every public utterance to become a `SpeechEvent` and requires `discussion_phase_summary` at `DAY_RUNOFF_SPEECH` as well when that phase exists. Without making the shared write path and tests explicit for runoff/public-speech phases too, those utterances can fall out of `speech_events`, state rebuild, and phase summaries. Expand the tasks/contracts to all public speech phases, especially `DAY_RUNOFF_SPEECH`.", + "origin_round": 4, + "latest_round": 4, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R5-F13", + "severity": "high", + "category": "consistency", + "file": "design.md", + "title": "Registry-loss behavior still feeds NPC audio into VAD/STT", + "detail": "The voice-ingest contract says that when no `registry_snapshot` has been received or the Master connection is down, the worker treats the NPC set as empty and processes all audio, relying on Master to discard NPC-originated transcripts later. That contradicts the spec's receive-boundary rule that NPC packets must be discarded before VAD/STT, and it breaks the proposal's \"never feed NPC TTS into STT\" guarantee during control-plane failures. The design needs a true fail-safe path here: either stop capture until a registry snapshot is available, retain a last-known-good NPC registry across reconnects, or block NPC playback while the registry view is unavailable. Task 6.2 should be updated to implement that behavior instead of the current empty-set fallback.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F14", + "severity": "high", + "category": "consistency", + "file": "design.md", + "title": "Voice-ingest disconnect handling breaks strict serial speech", + "detail": "The design explicitly clears all `human_currently_speaking` gates on voice-ingest disconnect and allows `SpeakArbiter` to keep scheduling NPC speech until a future `vad_speech_started` re-establishes the gate. That creates a window where a human can be speaking while Master believes the gate is clear, violating the requirement that speech remain strictly serial whenever a human utterance is open. Master needs to fail safe when capture-state visibility is lost, for example by suppressing new NPC requests while voice-ingest is disconnected or until capture readiness is re-established. Update tasks 4.4, 5.2, and 6.5 accordingly.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F15", + "severity": "high", + "category": "completeness", + "file": "design.md", + "title": "Voice-ingest has no authoritative source for game, phase, or seat context", + "detail": "The voice-ingest protocol only defines `registry_snapshot` and `registry_update`, but its outgoing messages and required structured logs include `game_id`, `phase_id`, and `seat_no`, and the worker must also know whether the current phase is a public speech phase at all. As written, voice-ingest has no contract for learning the active game, current phase, or the human `discord_user_id -> seat_no` mapping, so it cannot reliably label utterances, distinguish players from non-players, or avoid turning lobby/night chatter into `SpeechEvent`s. Add a Master→voice-ingest lifecycle/assignment contract (active-game snapshot, phase updates, seat map updates, public-speech enabled flag), or move game/phase/seat resolution entirely to Master and simplify the worker payloads and logs to match.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F16", + "severity": "medium", + "category": "completeness", + "file": "tasks.md", + "title": "`discussion_phase_summary` lacks a defined source of truth for required counters", + "detail": "Task 9.3 says the phase summary is emitted from `speech_events` and playback audit rows, but mandatory fields such as `logic_packets_built` and `stt_failed` are not stored in those tables, and the design never defines a restart-safe per-phase accumulator for them. That leaves the implementation without a reliable way to satisfy the required summary schema, especially after a mid-phase restart. The design/tasks should specify where every required counter comes from and how it survives restart boundaries, whether via a dedicated persisted phase-metrics artifact or an explicit recovery model for in-memory counters.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + } + ], + "round_summaries": [ + { + "round": 1, + "total": 2, + "open": 2, + "new": 2, + "resolved": 0, + "overridden": 0, + "by_severity": { + "high": 2 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-design_review-1" + }, + { + "round": 2, + "total": 5, + "open": 4, + "new": 3, + "resolved": 1, + "overridden": 0, + "by_severity": { + "high": 2, + "medium": 2 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-design_review-2" + }, + { + "round": 3, + "total": 9, + "open": 5, + "new": 4, + "resolved": 4, + "overridden": 0, + "by_severity": { + "high": 1, + "medium": 4 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-design_review-3" + }, + { + "round": 4, + "total": 12, + "open": 4, + "new": 3, + "resolved": 8, + "overridden": 0, + "by_severity": { + "high": 2, + "medium": 2 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-design_review-4" + }, + { + "round": 5, + "total": 16, + "open": 5, + "new": 4, + "resolved": 11, + "overridden": 0, + "by_severity": { + "high": 4, + "medium": 1 + } + } + ] +} diff --git a/openspec/changes/add-master-npc-voice-stt/review-ledger.json b/openspec/changes/add-master-npc-voice-stt/review-ledger.json new file mode 100644 index 0000000..55490fc --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/review-ledger.json @@ -0,0 +1,310 @@ +{ + "feature_id": "add-master-npc-voice-stt", + "phase": "impl", + "current_round": 5, + "status": "has_open_high", + "max_finding_id": 16, + "findings": [ + { + "id": "R1-F01", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/discord_service.py", + "title": "New games never capture `LLM_DISCUSSION_MODE`", + "detail": "`/wolf create` still builds `Game(...)` without `discussion_mode=self.settings.LLM_DISCUSSION_MODE`, so every game persists the default `rounds` value. That makes `reactive_voice` unreachable through the normal app flow and violates the spec’s “mode fixed at game start” contract. Populate the field when creating the game and validate it against the allowed modes.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 2 + }, + { + "id": "R1-F02", + "severity": "high", + "category": "completeness", + "file": "src/wolfbot/main.py", + "title": "The speech-event / reactive-voice pipeline is not wired into the running app", + "detail": "The boot path still starts only the legacy single-process bot: it never constructs `DiscussionService`, `MasterIngestService`, `SpeakArbiter`, or `WebsocketsMasterWsServer`, and it instantiates `LLMAdapter` without the new `discussion_service`. In production this means rounds mode does not backfill `speech_events`, `MASTER_WS_LISTEN`/`MASTER_NPC_PSK` are unused, and `reactive_voice` has no active WS/STT/TTS pipeline. Wire the new services at startup and connect the recovery and phase-end hooks before merging.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R1-F03", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/discord_service.py", + "title": "Human text discussion messages still bypass `SpeechEvent` persistence", + "detail": "`on_message()` still writes `PLAYER_SPEECH` directly to `logs_public` and returns. The spec requires every public utterance (human text, human voice, NPC) to create a `SpeechEvent` and feed `PublicDiscussionState`; with the current path, text messages never reach `speech_events`, `silent_seats`, or CO extraction. Route DAY_DISCUSSION / DAY_RUNOFF_SPEECH main-channel messages through `DiscussionService.record(make_human_text_event(...))` and seed the phase baseline once per phase.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R1-F04", + "severity": "medium", + "category": "testing", + "file": "tests/test_reactive_voice_mode.py", + "title": "Coverage misses the app-level integrations that are regressing", + "detail": "The new tests cover repo persistence and isolated helper classes, but they do not exercise `/wolf create` with `LLM_DISCUSSION_MODE=reactive_voice`, `WolfCog.on_message()` producing `speech_events`, or the main boot path wiring the new services. Those gaps are why the production regressions above still pass. Add integration tests around game creation, main-channel text ingestion, and startup wiring.", + "origin_round": 1, + "latest_round": 1, + "status": "open", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R2-F05", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/game_service.py", + "title": "Recovery still restarts legacy discussion rounds inside reactive_voice games", + "detail": "`RecoveryService._recover_one()` always calls `resume_llm_speech_progress()`, and that helper still treats every `DAY_DISCUSSION` game as rounds-mode. If an alive LLM seat has `discussion_rounds_done < 2`, it re-dispatches `submit_llm_discussion_rounds()` with no `discussion_mode` guard. After a restart, a `reactive_voice` game will therefore spawn the legacy two-round speech batch, violating the per-game mode contract and polluting the speech-event timeline. Skip this resume path for `discussion_mode == \"reactive_voice\"` and hand recovery to the arbiter rebuild/sweep flow instead.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R2-F06", + "severity": "medium", + "category": "completeness", + "file": "pyproject.toml", + "title": "The WebSocket transport still has no declared runtime dependency", + "detail": "The codebase now imports `websockets` in `master_ws_server.py` and `voice_ingest_client.py`, but `pyproject.toml` only adds a mypy ignore and never adds `websockets` to `[project.dependencies]`. A clean `uv sync` install does not guarantee that package is present, so the WS transport cannot start reliably in production once the remaining boot wiring lands. Add an explicit runtime dependency or switch the transport to an already-declared library.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R3-F07", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/master_ws_server.py", + "title": "The WebSocket handshake rejects every connection under the pinned `websockets` version", + "detail": "`WebsocketsMasterWsServer._authenticate()` reads the query string from `ws.path`, but the branch now pins `websockets==16.0`, where `serve()` passes a `ServerConnection` that exposes the HTTP request as `ws.request`, not `ws.path`. In practice `getattr(ws, \"path\", \"\")` falls back to an empty string, so `role` and `psk` are always parsed as empty and every NPC / voice-ingest client is closed with `auth_failed`. Read the query from `ws.request.path` (or move auth into `process_request`) and add a real integration test for the live server.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R3-F08", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/main.py", + "title": "Reactive voice/STT utterances are persisted but never mirrored to the public text channel", + "detail": "`main.py` constructs `DiscussionService(store=speech_store, log_sink=repo)` without any `message_poster`, so `DiscussionService.record()` only writes `speech_events` and `PLAYER_SPEECH` logs for `voice_stt` / `npc_generated` events. Those utterances never get posted to the main Discord channel, which breaks the stated requirement that non-text public speech remain visible to text observers. This is especially visible in `SpeakArbiter.handle_speak_result()` and `MasterIngestService.ingest_voice()`, which rely on `DiscussionService.record()` for their outward side effects. Wire a compatible public-message poster into `DiscussionService` and cover the path with a runtime test.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R3-F09", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/master_ingest_service.py", + "title": "Voice-ingest STT events bypass canonical phase seeding and accept caller-supplied phase IDs", + "detail": "`MasterIngestService.ingest_voice()` constructs a `SpeechEvent` directly from `payload.phase_id` and calls `discussion.record(event)` without first seeding or reusing the phase baseline. A voice-only discussion phase therefore has no `phase_baseline`, so `PublicDiscussionState` rebuilds, `silent_seats`, and CO extraction all fail for the very path reactive voice depends on. Because the service also trusts the incoming `payload.phase_id` instead of recomputing the current canonical phase id, a delayed STT result from an older public phase can be written under a stale phase id if another public phase is active. Compute the current canonical phase id on Master, seed the baseline once, and record the STT event against that canonical phase only.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R4-F10", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/main.py", + "title": "Reactive voice still never drives the arbiter in production", + "detail": "The new startup wiring only instantiates `SpeakArbiter`; it never connects a real dispatch loop. `GameService`'s reactive_voice phase-enter path just logs and returns, `WolfCog.on_message()` / `MasterIngestService.ingest_voice()` only persist speech, and `MasterHandlers` in `main.py` is created without `on_speak_result`, `on_vad_started`, or `on_vad_ended` callbacks. In a live reactive_voice game no `LogicPacket`/`SpeakRequest` is ever sent, and even if an NPC replied its `speak_result` would be dropped, so NPCs remain silent. Add a real trigger on phase entry / new public speech and wire the arbiter callbacks through the WS server.", + "origin_round": 4, + "latest_round": 4, + "status": "open", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R4-F11", + "severity": "medium", + "category": "correctness", + "file": "src/wolfbot/services/recovery_service.py", + "title": "Master restart never closes in-flight reactive_voice rows", + "detail": "`SpeakArbiter.reactive_voice_recovery_sweep()` exists, but `RecoveryService._recover_one()` still only reconciles Discord state, restarts the engine, resends DMs, and resumes rounds speech. It never sweeps `npc_speak_requests` or `npc_playback_events` for reactive_voice games, so a Master restart leaves open rows dangling instead of marking them with `failure_reason=master_restart` as the spec requires. Thread a reactive_voice recovery hook into startup recovery before the engine resumes.", + "origin_round": 4, + "latest_round": 4, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R4-F12", + "severity": "medium", + "category": "completeness", + "file": "src/wolfbot/services/game_service.py", + "title": "Discussion phases still never emit the required summary event", + "detail": "The new `discussion_phase_summary` helper is only referenced by tests; there is no production call site when `DAY_DISCUSSION` or `DAY_RUNOFF_SPEECH` ends. That means the promised phase-end telemetry for both rounds and reactive_voice modes is never emitted. Invoke the summary emitter during the public-speech phase exit/advance path before transitioning away from the discussion phase.", + "origin_round": 4, + "latest_round": 4, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R5-F13", + "severity": "high", + "category": "completeness", + "file": "src/wolfbot/npc_bot_main.py", + "title": "The per-NPC worker is still not runnable", + "detail": "The repository still does not provide a deployable NPC worker for the new pipeline. `npc_bot_main._main()` logs that production wiring is deferred and exits immediately, and there is no corresponding voice-ingest entrypoint in the tree. That means the Master can start its WS server, but the proposal's required worker processes cannot actually be launched from this implementation.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F14", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/main.py", + "title": "The human-speaking gate is released before STT finalization", + "detail": "`_on_vad_ended()` calls `arbiter.clear_human_speaking(...)` and immediately `try_dispatch_next(...)`. The proposal requires the human gate to stay closed until the finalized STT `SpeechEvent` arrives (or times out), so this allows an NPC to start speaking before the just-finished human utterance has been transcribed and recorded. Keep the segment blocked until `speech_event_payload`/`stt_failed` completes, then dispatch.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F15", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/speak_arbiter.py", + "title": "Playback deadlines are recorded but never enforced", + "detail": "`handle_speak_result()` stores `playback_deadline_ms` and adds the request to `_active_playback`, but nothing ever checks for an expired authorization. If an NPC disconnects after authorization and never sends `tts_failed`/`playback_finished`/`playback_failed`, the playback row stays open and `_active_playback` blocks all future NPC speech for the rest of the phase. Add a timeout sweep that closes expired playback with the spec'd failure reason and releases the gate.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F16", + "severity": "medium", + "category": "correctness", + "file": "src/wolfbot/services/recovery_service.py", + "title": "Reactive-voice recovery never re-enters the arbiter after restart", + "detail": "Recovery now sweeps in-flight rows, but after that it only starts the timer engine and calls `resume_llm_speech_progress()`, which intentionally no-ops for `reactive_voice`. There is no recovery-time call to `try_dispatch_next()` or the reactive phase-enter hook, so a game recovered mid-discussion will stay silent until a new human utterance arrives or the deadline expires. Trigger one post-recovery reactive dispatch for active public-speech phases.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + } + ], + "round_summaries": [ + { + "round": 1, + "total": 4, + "open": 4, + "new": 4, + "resolved": 0, + "overridden": 0, + "by_severity": { + "high": 3, + "medium": 1 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-apply_review-1" + }, + { + "round": 2, + "total": 6, + "open": 5, + "new": 2, + "resolved": 1, + "overridden": 0, + "by_severity": { + "high": 3, + "medium": 2 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-apply_review-2" + }, + { + "round": 3, + "total": 9, + "open": 5, + "new": 3, + "resolved": 4, + "overridden": 0, + "by_severity": { + "high": 4, + "medium": 1 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-apply_review-3" + }, + { + "round": 4, + "total": 12, + "open": 5, + "new": 3, + "resolved": 7, + "overridden": 0, + "by_severity": { + "high": 2, + "medium": 3 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-apply_review-4" + }, + { + "round": 5, + "total": 16, + "open": 6, + "new": 4, + "resolved": 10, + "overridden": 0, + "by_severity": { + "medium": 2, + "high": 4 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-apply_review-5" + } + ] +} diff --git a/openspec/changes/add-master-npc-voice-stt/review-ledger.json.bak b/openspec/changes/add-master-npc-voice-stt/review-ledger.json.bak new file mode 100644 index 0000000..95bb360 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/review-ledger.json.bak @@ -0,0 +1,309 @@ +{ + "feature_id": "add-master-npc-voice-stt", + "phase": "impl", + "current_round": 5, + "status": "has_open_high", + "max_finding_id": 16, + "findings": [ + { + "id": "R1-F01", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/discord_service.py", + "title": "New games never capture `LLM_DISCUSSION_MODE`", + "detail": "`/wolf create` still builds `Game(...)` without `discussion_mode=self.settings.LLM_DISCUSSION_MODE`, so every game persists the default `rounds` value. That makes `reactive_voice` unreachable through the normal app flow and violates the spec’s “mode fixed at game start” contract. Populate the field when creating the game and validate it against the allowed modes.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 2 + }, + { + "id": "R1-F02", + "severity": "high", + "category": "completeness", + "file": "src/wolfbot/main.py", + "title": "The speech-event / reactive-voice pipeline is not wired into the running app", + "detail": "The boot path still starts only the legacy single-process bot: it never constructs `DiscussionService`, `MasterIngestService`, `SpeakArbiter`, or `WebsocketsMasterWsServer`, and it instantiates `LLMAdapter` without the new `discussion_service`. In production this means rounds mode does not backfill `speech_events`, `MASTER_WS_LISTEN`/`MASTER_NPC_PSK` are unused, and `reactive_voice` has no active WS/STT/TTS pipeline. Wire the new services at startup and connect the recovery and phase-end hooks before merging.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R1-F03", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/discord_service.py", + "title": "Human text discussion messages still bypass `SpeechEvent` persistence", + "detail": "`on_message()` still writes `PLAYER_SPEECH` directly to `logs_public` and returns. The spec requires every public utterance (human text, human voice, NPC) to create a `SpeechEvent` and feed `PublicDiscussionState`; with the current path, text messages never reach `speech_events`, `silent_seats`, or CO extraction. Route DAY_DISCUSSION / DAY_RUNOFF_SPEECH main-channel messages through `DiscussionService.record(make_human_text_event(...))` and seed the phase baseline once per phase.", + "origin_round": 1, + "latest_round": 1, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R1-F04", + "severity": "medium", + "category": "testing", + "file": "tests/test_reactive_voice_mode.py", + "title": "Coverage misses the app-level integrations that are regressing", + "detail": "The new tests cover repo persistence and isolated helper classes, but they do not exercise `/wolf create` with `LLM_DISCUSSION_MODE=reactive_voice`, `WolfCog.on_message()` producing `speech_events`, or the main boot path wiring the new services. Those gaps are why the production regressions above still pass. Add integration tests around game creation, main-channel text ingestion, and startup wiring.", + "origin_round": 1, + "latest_round": 1, + "status": "open", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R2-F05", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/game_service.py", + "title": "Recovery still restarts legacy discussion rounds inside reactive_voice games", + "detail": "`RecoveryService._recover_one()` always calls `resume_llm_speech_progress()`, and that helper still treats every `DAY_DISCUSSION` game as rounds-mode. If an alive LLM seat has `discussion_rounds_done < 2`, it re-dispatches `submit_llm_discussion_rounds()` with no `discussion_mode` guard. After a restart, a `reactive_voice` game will therefore spawn the legacy two-round speech batch, violating the per-game mode contract and polluting the speech-event timeline. Skip this resume path for `discussion_mode == \"reactive_voice\"` and hand recovery to the arbiter rebuild/sweep flow instead.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R2-F06", + "severity": "medium", + "category": "completeness", + "file": "pyproject.toml", + "title": "The WebSocket transport still has no declared runtime dependency", + "detail": "The codebase now imports `websockets` in `master_ws_server.py` and `voice_ingest_client.py`, but `pyproject.toml` only adds a mypy ignore and never adds `websockets` to `[project.dependencies]`. A clean `uv sync` install does not guarantee that package is present, so the WS transport cannot start reliably in production once the remaining boot wiring lands. Add an explicit runtime dependency or switch the transport to an already-declared library.", + "origin_round": 2, + "latest_round": 2, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 3 + }, + { + "id": "R3-F07", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/master_ws_server.py", + "title": "The WebSocket handshake rejects every connection under the pinned `websockets` version", + "detail": "`WebsocketsMasterWsServer._authenticate()` reads the query string from `ws.path`, but the branch now pins `websockets==16.0`, where `serve()` passes a `ServerConnection` that exposes the HTTP request as `ws.request`, not `ws.path`. In practice `getattr(ws, \"path\", \"\")` falls back to an empty string, so `role` and `psk` are always parsed as empty and every NPC / voice-ingest client is closed with `auth_failed`. Read the query from `ws.request.path` (or move auth into `process_request`) and add a real integration test for the live server.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R3-F08", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/main.py", + "title": "Reactive voice/STT utterances are persisted but never mirrored to the public text channel", + "detail": "`main.py` constructs `DiscussionService(store=speech_store, log_sink=repo)` without any `message_poster`, so `DiscussionService.record()` only writes `speech_events` and `PLAYER_SPEECH` logs for `voice_stt` / `npc_generated` events. Those utterances never get posted to the main Discord channel, which breaks the stated requirement that non-text public speech remain visible to text observers. This is especially visible in `SpeakArbiter.handle_speak_result()` and `MasterIngestService.ingest_voice()`, which rely on `DiscussionService.record()` for their outward side effects. Wire a compatible public-message poster into `DiscussionService` and cover the path with a runtime test.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R3-F09", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/master_ingest_service.py", + "title": "Voice-ingest STT events bypass canonical phase seeding and accept caller-supplied phase IDs", + "detail": "`MasterIngestService.ingest_voice()` constructs a `SpeechEvent` directly from `payload.phase_id` and calls `discussion.record(event)` without first seeding or reusing the phase baseline. A voice-only discussion phase therefore has no `phase_baseline`, so `PublicDiscussionState` rebuilds, `silent_seats`, and CO extraction all fail for the very path reactive voice depends on. Because the service also trusts the incoming `payload.phase_id` instead of recomputing the current canonical phase id, a delayed STT result from an older public phase can be written under a stale phase id if another public phase is active. Compute the current canonical phase id on Master, seed the baseline once, and record the STT event against that canonical phase only.", + "origin_round": 3, + "latest_round": 3, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 4 + }, + { + "id": "R4-F10", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/main.py", + "title": "Reactive voice still never drives the arbiter in production", + "detail": "The new startup wiring only instantiates `SpeakArbiter`; it never connects a real dispatch loop. `GameService`'s reactive_voice phase-enter path just logs and returns, `WolfCog.on_message()` / `MasterIngestService.ingest_voice()` only persist speech, and `MasterHandlers` in `main.py` is created without `on_speak_result`, `on_vad_started`, or `on_vad_ended` callbacks. In a live reactive_voice game no `LogicPacket`/`SpeakRequest` is ever sent, and even if an NPC replied its `speak_result` would be dropped, so NPCs remain silent. Add a real trigger on phase entry / new public speech and wire the arbiter callbacks through the WS server.", + "origin_round": 4, + "latest_round": 4, + "status": "open", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R4-F11", + "severity": "medium", + "category": "correctness", + "file": "src/wolfbot/services/recovery_service.py", + "title": "Master restart never closes in-flight reactive_voice rows", + "detail": "`SpeakArbiter.reactive_voice_recovery_sweep()` exists, but `RecoveryService._recover_one()` still only reconciles Discord state, restarts the engine, resends DMs, and resumes rounds speech. It never sweeps `npc_speak_requests` or `npc_playback_events` for reactive_voice games, so a Master restart leaves open rows dangling instead of marking them with `failure_reason=master_restart` as the spec requires. Thread a reactive_voice recovery hook into startup recovery before the engine resumes.", + "origin_round": 4, + "latest_round": 4, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R4-F12", + "severity": "medium", + "category": "completeness", + "file": "src/wolfbot/services/game_service.py", + "title": "Discussion phases still never emit the required summary event", + "detail": "The new `discussion_phase_summary` helper is only referenced by tests; there is no production call site when `DAY_DISCUSSION` or `DAY_RUNOFF_SPEECH` ends. That means the promised phase-end telemetry for both rounds and reactive_voice modes is never emitted. Invoke the summary emitter during the public-speech phase exit/advance path before transitioning away from the discussion phase.", + "origin_round": 4, + "latest_round": 4, + "status": "resolved", + "relation": "new", + "supersedes": null, + "notes": "", + "resolved_round": 5 + }, + { + "id": "R5-F13", + "severity": "high", + "category": "completeness", + "file": "src/wolfbot/npc_bot_main.py", + "title": "The per-NPC worker is still not runnable", + "detail": "The repository still does not provide a deployable NPC worker for the new pipeline. `npc_bot_main._main()` logs that production wiring is deferred and exits immediately, and there is no corresponding voice-ingest entrypoint in the tree. That means the Master can start its WS server, but the proposal's required worker processes cannot actually be launched from this implementation.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F14", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/main.py", + "title": "The human-speaking gate is released before STT finalization", + "detail": "`_on_vad_ended()` calls `arbiter.clear_human_speaking(...)` and immediately `try_dispatch_next(...)`. The proposal requires the human gate to stay closed until the finalized STT `SpeechEvent` arrives (or times out), so this allows an NPC to start speaking before the just-finished human utterance has been transcribed and recorded. Keep the segment blocked until `speech_event_payload`/`stt_failed` completes, then dispatch.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F15", + "severity": "high", + "category": "correctness", + "file": "src/wolfbot/services/speak_arbiter.py", + "title": "Playback deadlines are recorded but never enforced", + "detail": "`handle_speak_result()` stores `playback_deadline_ms` and adds the request to `_active_playback`, but nothing ever checks for an expired authorization. If an NPC disconnects after authorization and never sends `tts_failed`/`playback_finished`/`playback_failed`, the playback row stays open and `_active_playback` blocks all future NPC speech for the rest of the phase. Add a timeout sweep that closes expired playback with the spec'd failure reason and releases the gate.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + }, + { + "id": "R5-F16", + "severity": "medium", + "category": "correctness", + "file": "src/wolfbot/services/recovery_service.py", + "title": "Reactive-voice recovery never re-enters the arbiter after restart", + "detail": "Recovery now sweeps in-flight rows, but after that it only starts the timer engine and calls `resume_llm_speech_progress()`, which intentionally no-ops for `reactive_voice`. There is no recovery-time call to `try_dispatch_next()` or the reactive phase-enter hook, so a game recovered mid-discussion will stay silent until a new human utterance arrives or the deadline expires. Trigger one post-recovery reactive dispatch for active public-speech phases.", + "origin_round": 5, + "latest_round": 5, + "status": "new", + "relation": "new", + "supersedes": null, + "notes": "" + } + ], + "round_summaries": [ + { + "round": 1, + "total": 4, + "open": 4, + "new": 4, + "resolved": 0, + "overridden": 0, + "by_severity": { + "high": 3, + "medium": 1 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-apply_review-1" + }, + { + "round": 2, + "total": 6, + "open": 5, + "new": 2, + "resolved": 1, + "overridden": 0, + "by_severity": { + "high": 3, + "medium": 2 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-apply_review-2" + }, + { + "round": 3, + "total": 9, + "open": 5, + "new": 3, + "resolved": 4, + "overridden": 0, + "by_severity": { + "high": 4, + "medium": 1 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-apply_review-3" + }, + { + "round": 4, + "total": 12, + "open": 5, + "new": 3, + "resolved": 7, + "overridden": 0, + "by_severity": { + "high": 2, + "medium": 3 + }, + "gate_id": "review_decision-add-master-npc-voice-stt-1-apply_review-4" + }, + { + "round": 5, + "total": 16, + "open": 6, + "new": 4, + "resolved": 10, + "overridden": 0, + "by_severity": { + "medium": 2, + "high": 4 + } + } + ] +} diff --git a/openspec/changes/add-master-npc-voice-stt/specs/day-discussion/spec.md b/openspec/changes/add-master-npc-voice-stt/specs/day-discussion/spec.md new file mode 100644 index 0000000..c34aa27 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/specs/day-discussion/spec.md @@ -0,0 +1,56 @@ +## MODIFIED Requirements + +### Requirement: LLM seats speak twice per discussion phase + +For every `DAY_DISCUSSION` phase **when `LLM_DISCUSSION_MODE=rounds`**, each living LLM seat SHALL emit exactly two structured speech utterances. Progress for each seat MUST be tracked in the `llm_speech_counts.discussion_rounds_done` column so that an interrupted phase (process restart, deadline-driven advance, host `/wolf force-skip`) does not cause the same round to be repeated when execution resumes. **When `LLM_DISCUSSION_MODE=reactive_voice`, this fixed two-round contract does NOT apply** — speech is instead driven by `SpeakArbiter` against `PublicDiscussionState`, and the count of utterances per seat is dynamic (zero or more). + +#### Scenario: Two utterances per LLM seat in a fresh phase (rounds mode) +- **WHEN** `DAY_DISCUSSION` starts for `day_number = 1` with 4 living LLM seats and the deadline is far enough in the future, with `LLM_DISCUSSION_MODE=rounds` +- **THEN** the system schedules two utterances per LLM seat (8 total), each producing a `PLAYER_SPEECH` `LogEntry` + +#### Scenario: Resume after restart in mid-phase (rounds mode) +- **WHEN** the bot restarts during `DAY_DISCUSSION` after one round of LLM speech has already been recorded for an LLM seat under `LLM_DISCUSSION_MODE=rounds` +- **AND** that seat's `llm_speech_counts.discussion_rounds_done` is `1` +- **THEN** on resumption the system schedules only the remaining round for that seat, not both rounds + +#### Scenario: reactive_voice mode does not use llm_speech_counts.discussion_rounds_done +- **WHEN** the bot runs with `LLM_DISCUSSION_MODE=reactive_voice` +- **THEN** the speech-batching path is not invoked; `llm_speech_counts.discussion_rounds_done` is not incremented and is not used to drive scheduling + +### Requirement: Discussion mode is configurable + +The day-discussion engine SHALL accept a runtime mode setting via the `LLM_DISCUSSION_MODE` environment variable that selects between two strategies. The default mode `rounds` MUST run the existing two-round LLM batching with the existing 80–300-character utterance cap. The alternative mode `reactive_voice` MUST replace the LLM-speech batching with Master-arbitrated NPC speech driven by `PublicDiscussionState`, with a hard `max_chars=80` per utterance. **The mode is fixed for the lifetime of a single game** (no mid-game switching). Both modes MUST preserve the public-log contract — every utterance, regardless of mode, is emitted as a `PLAYER_SPEECH` `LogEntry`. Both modes MUST also write a `SpeechEvent` row for every utterance (human text, human voice via STT, NPC) and update an in-memory `PublicDiscussionState` derived from those rows. + +#### Scenario: Default mode is rounds +- **WHEN** the bot starts without `LLM_DISCUSSION_MODE` set +- **THEN** `DAY_DISCUSSION` runs the two-round LLM batching described in this spec, with the existing 80–300-character cap + +#### Scenario: reactive_voice mode replaces LLM batching +- **WHEN** the bot starts with `LLM_DISCUSSION_MODE=reactive_voice` +- **THEN** `DAY_DISCUSSION` does not invoke the round-based LLM batch; `SpeakArbiter` drives NPC speech instead, with `max_chars=80` per utterance + +#### Scenario: Both modes preserve PLAYER_SPEECH +- **WHEN** an utterance is recorded under either mode +- **THEN** a `LogEntry(kind="PLAYER_SPEECH", actor_seat=, text=)` is emitted to the public log path and posted to the main channel + +#### Scenario: Both modes write SpeechEvent +- **WHEN** an utterance is recorded under either mode +- **THEN** a `SpeechEvent` row is inserted into `speech_events` with the appropriate `source` (`text`, `voice_stt`, or `npc_generated`) and `speaker_kind` (`human` or `npc`) + +#### Scenario: Mode is fixed for a game +- **WHEN** a game starts in mode `M` and the host or operator changes the `LLM_DISCUSSION_MODE` environment variable mid-game +- **THEN** the running game continues to use mode `M`; the new value applies only to subsequent games + +## ADDED Requirements + +### Requirement: Discussion phase end emits a discussion_phase_summary log event + +At the end of every `DAY_DISCUSSION` phase (and `DAY_RUNOFF_SPEECH` if applicable), regardless of `LLM_DISCUSSION_MODE`, the system SHALL emit a structured log event with `event=discussion_phase_summary` summarizing what happened during that phase. The event MUST include at least: `game_id`, `phase_id`, `mode`, `speech_events_total`, `human_speech_events`, `npc_speech_events`, and (when applicable) `stt_success`, `stt_failed`, `logic_packets_built`, `speak_requests_sent`, `speak_results_accepted`, `speak_results_rejected`, `playback_authorized`, `tts_success`, `tts_failed`, `playback_success`, `playback_failed`, `stale_dropped`. + +#### Scenario: rounds mode emits a summary +- **WHEN** a `DAY_DISCUSSION` phase ends under `LLM_DISCUSSION_MODE=rounds` +- **THEN** a structured log event with `event=discussion_phase_summary`, `mode=rounds`, the count of human and NPC `SpeechEvent` rows for the phase, and zeros for the reactive-voice fields is emitted + +#### Scenario: reactive_voice mode emits a richer summary +- **WHEN** a `DAY_DISCUSSION` phase ends under `LLM_DISCUSSION_MODE=reactive_voice` +- **THEN** the summary log event includes the full reactive-voice telemetry (logic packets, speak requests/results, playback outcomes, stale drops) along with the speech counts diff --git a/openspec/changes/add-master-npc-voice-stt/specs/discord-integration/spec.md b/openspec/changes/add-master-npc-voice-stt/specs/discord-integration/spec.md new file mode 100644 index 0000000..b0890ee --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/specs/discord-integration/spec.md @@ -0,0 +1,67 @@ +## MODIFIED Requirements + +### Requirement: /wolf slash commands are registered to a single guild + +The Master bot SHALL register the `/wolf` command set (`join`, `leave`, `start`, `abort`, `force-skip`, `extend`) inside a single Discord guild whose id is read from the `DISCORD_GUILD_ID` setting. Host-only commands (`start`, `abort`, `force-skip`, `extend`) MUST verify that the invoking user is the recorded game host before performing any state mutation. **NPC bot workers and the voice-ingest worker run as separate Discord bot identities in the same guild and SHALL NOT register any slash commands** — only the Master bot owns the `/wolf` command surface. NPC bots and voice-ingest authenticate to Discord with their own tokens but do not expose any user-facing commands. + +#### Scenario: Non-host abort attempt is rejected +- **WHEN** a user who is not the host invokes `/wolf abort` on an active game +- **THEN** the bot replies ephemerally with a permission error and does not call `GameService.host_abort` + +#### Scenario: Host abort tears down only on first call +- **WHEN** the host invokes `/wolf abort` while the game is still active +- **THEN** `GameService.host_abort` returns `True`, the cog detaches and stops the `GameEngine`, and posts the public `強制終了` message + +#### Scenario: Host abort on already-ended game is a no-op +- **WHEN** the host invokes `/wolf abort` on a game whose `ended_at` is non-null +- **THEN** `GameService.host_abort` returns `False`, the cog replies ephemerally, and no public message is posted and no engine teardown runs + +#### Scenario: NPC bots register no slash commands +- **WHEN** a `wolfbot-npc` process connects to Discord with `NPC_DISCORD_TOKEN` +- **THEN** that bot does not register any `/wolf` (or other) application commands; only the Master bot owns the slash-command surface for the guild + +## ADDED Requirements + +### Requirement: NPC bots and voice-ingest share the main voice channel + +When `LLM_DISCUSSION_MODE=reactive_voice` is active for a game, every NPC bot worker AND the voice-ingest worker SHALL join the Discord voice channel identified by `MAIN_VOICE_CHANNEL_ID` (the same channel humans speak in). NPC bots act as audio sources only (TTS playback). voice-ingest acts as a listener only (no playback). There is no per-game voice channel allocation in the MVP and no separate `NPC_VOICE_CHANNEL_ID`. + +#### Scenario: All NPC bots and voice-ingest join the same VC +- **WHEN** a reactive_voice game starts with three NPC seats and voice-ingest is enabled +- **THEN** the three `wolfbot-npc` processes and the voice-ingest worker all join the channel referenced by `MAIN_VOICE_CHANNEL_ID` + +#### Scenario: voice-ingest does not play audio +- **WHEN** the voice-ingest worker is present in `MAIN_VOICE_CHANNEL_ID` +- **THEN** it never invokes any voice-send / playback API; it only consumes incoming audio packets + +### Requirement: NPC bots play VC audio only after Master authorization + +NPC bot workers MUST gate all VC audio output on having received a valid `PlaybackAuthorized` from Master for a specific `request_id`. NPC bots MUST NOT play any audio they generate independently (e.g. test playback, ambient effects, retries of a rejected utterance) in the live VC. + +#### Scenario: Unauthorized playback is suppressed +- **WHEN** an NPC bot has a finished Grok response and finished TTS audio cached but has not received `PlaybackAuthorized` for that `request_id` +- **THEN** the NPC bot does not play the audio in `MAIN_VOICE_CHANNEL_ID` + +#### Scenario: Authorized playback proceeds +- **WHEN** an NPC bot receives `PlaybackAuthorized` for `request_id=sr_01HX` +- **THEN** the NPC bot plays the corresponding TTS audio in `MAIN_VOICE_CHANNEL_ID` and emits `playback_finished` upon completion + +### Requirement: NPC bots stay silent when no game is active for them + +NPC bot workers MUST remain silent (no VC audio, no Discord text post, no DM) when no game has assigned them a seat or when the assigned game's phase is not one in which `SpeakArbiter` could legitimately request speech (e.g. `LOBBY`, `SETUP`, `NIGHT_0`, `NIGHT`, `DAY_VOTE`, `DAY_RUNOFF`, `WAITING_HOST_DECISION`, `GAME_OVER`). + +#### Scenario: Idle NPC bot is silent +- **WHEN** no game is assigned to NPC `npc_p5`, or the assigned game is in `LOBBY` +- **THEN** the NPC bot makes no Discord-side action — no VC playback, no message in any channel + +#### Scenario: NPC bot is silent during NIGHT +- **WHEN** the assigned game enters `NIGHT` +- **THEN** the NPC bot performs no VC playback even if it receives a stale `SpeakRequest`; the Master arbiter would in any case not issue one + +### Requirement: voice-ingest excludes NPC bot identities by registry lookup + +The voice-ingest worker SHALL fetch the current NPC bot `discord_bot_user_id` set from the Master NPC registry and discard any incoming voice packet whose `user_id` is in that set, before VAD or STT runs (see voice-ingest spec for full rules). This requirement is reflected in the discord-integration capability because it is the boundary at which Discord-supplied user identity becomes the authoritative filter for "is this a human". + +#### Scenario: NPC TTS audio is filtered out by user_id +- **WHEN** an NPC bot plays TTS in `MAIN_VOICE_CHANNEL_ID` and Discord routes the corresponding voice packets to voice-ingest +- **THEN** voice-ingest discards those packets at the receive boundary and does not run them through VAD or STT diff --git a/openspec/changes/add-master-npc-voice-stt/specs/llm-seats/spec.md b/openspec/changes/add-master-npc-voice-stt/specs/llm-seats/spec.md new file mode 100644 index 0000000..149824a --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/specs/llm-seats/spec.md @@ -0,0 +1,47 @@ +## MODIFIED Requirements + +### Requirement: Utterances respect length, language, and content invariants + +Every LLM-generated utterance SHALL be Japanese, SHALL NOT quote original Gnosia dialogue verbatim, and SHALL NOT contain meta-commentary about being an AI or about the input being structured data. Length is constrained per `LLM_DISCUSSION_MODE`: under `rounds`, utterances MUST be between 80 and 300 characters inclusive; under `reactive_voice`, utterances MUST be **80 characters or fewer** (and MAY be shorter, including a single short interjection of a handful of characters). + +#### Scenario: Over-length utterance under rounds mode is rejected +- **WHEN** an LLM produces an utterance longer than 300 characters under `LLM_DISCUSSION_MODE=rounds` +- **THEN** the system prompt's hard rules cause the model to self-correct on retry, and a non-conforming response is logged and dropped rather than published as `PLAYER_SPEECH` + +#### Scenario: Over-length utterance under reactive_voice mode is rejected +- **WHEN** an LLM produces an utterance longer than 80 characters under `LLM_DISCUSSION_MODE=reactive_voice` +- **THEN** the SpeakArbiter rejects the corresponding `SpeakResult` with `failure_reason=utterance_too_long`, no `SpeechEvent` is written, and the NPC bot receives `playback_rejected` + +#### Scenario: Short reactive interjection is allowed under reactive_voice mode +- **WHEN** an LLM produces a 12-character utterance such as `「うーん…怪しいかも」` under `LLM_DISCUSSION_MODE=reactive_voice` +- **THEN** the utterance is accepted, recorded as `SpeechEvent(source=npc_generated)`, and authorized for playback + +#### Scenario: Original Gnosia dialogue is forbidden in any mode +- **WHEN** the prompt builder renders any persona's prompt under any `LLM_DISCUSSION_MODE` +- **THEN** the rendered prompt explicitly forbids quoting original Gnosia dialogue and requires personality imitation by tone alone + +## ADDED Requirements + +### Requirement: NPC bot worker process consumes LogicPacket instead of full game snapshot + +When `LLM_DISCUSSION_MODE=reactive_voice`, each NPC seat SHALL be served by a separate `wolfbot-npc` worker process which receives **only** Master-published `LogicPacket` payloads for day-discussion utterances — never the full game snapshot, never other NPCs' private state, never the master role assignments. The NPC bot SHALL combine the `LogicPacket` with its own persona, role, and private state to compose the Grok prompt. The persona registry, the prompt-builder pipeline, the role-strategy isolation rules, the structured-output `LLMAction` schema, and the seat-token resolver MUST be reused unchanged. + +#### Scenario: NPC bot does not see other NPCs' private state +- **WHEN** Master sends a `LogicPacket` to NPC `npc_p5` +- **THEN** the payload contains no role assignments, no private state, no LLM-context for any seat other than `npc_p5`'s own + +#### Scenario: Persona registry is shared with NPC bot processes +- **WHEN** an NPC bot worker starts with `NPC_ID=npc_p5` +- **THEN** the NPC bot loads the persona for `npc_p5` from the same `wolfbot.llm.personas.PERSONAS_BY_KEY` registry the Master process uses + +#### Scenario: Prompt-builder isolation is preserved across processes +- **WHEN** an NPC bot worker for a seat whose role is `villager` composes its system prompt +- **THEN** the rendered prompt contains the villager strategy block and does NOT contain any wolf, madman, or other-role strategy text + +### Requirement: rounds-mode behavior remains in-process + +Under `LLM_DISCUSSION_MODE=rounds`, LLM seats SHALL continue to be served by the in-process `LLMAdapter` exactly as today: structured-output requests issued from the Master process, results consumed by the existing `submit_llm_*` flows, no separate `wolfbot-npc` worker required. The new NPC-bot worker MUST NOT be required to be running for `rounds`-mode games. + +#### Scenario: rounds-mode game runs without NPC bot worker +- **WHEN** a game starts under `LLM_DISCUSSION_MODE=rounds` while no `wolfbot-npc` workers are connected +- **THEN** the game runs end-to-end: `submit_llm_discussion_rounds`, `submit_llm_votes`, `submit_llm_night_actions`, and the runoff-speech path all operate via the in-process `LLMAdapter` and produce the same observable behavior as today diff --git a/openspec/changes/add-master-npc-voice-stt/specs/npc-voice-pipeline/spec.md b/openspec/changes/add-master-npc-voice-stt/specs/npc-voice-pipeline/spec.md new file mode 100644 index 0000000..fa9a6d5 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/specs/npc-voice-pipeline/spec.md @@ -0,0 +1,145 @@ +## ADDED Requirements + +### Requirement: Each NPC runs as its own Discord bot process + +Each NPC seat in `LLM_DISCUSSION_MODE=reactive_voice` SHALL be served by a separate `wolfbot-npc` worker process with its own Discord bot user, its own `NPC_DISCORD_TOKEN`, its own `NPC_ID`, its own `TTS_VOICE_ID`, and its own WebSocket connection to Master. The Master process MUST NOT play TTS audio itself or impersonate any NPC's Discord identity. + +#### Scenario: NPC bot uses a distinct Discord identity +- **WHEN** an NPC bot connects to Discord with token `NPC_DISCORD_TOKEN` +- **THEN** the resulting `discord_bot_user_id` is distinct from the Master bot's user id and from every other NPC bot's user id + +#### Scenario: Master never plays NPC audio +- **WHEN** Master is asked to coordinate NPC speech in any phase +- **THEN** Master never directly invokes a TTS provider or plays audio in the voice channel; only the NPC bot for the speaking seat does + +### Requirement: Master <-> NPC transport is localhost WebSocket with pre-shared key + +The MVP Master ↔ NPC transport SHALL be a WebSocket bound to `127.0.0.1` on Master and connected from each NPC bot worker on the same host. Authentication MUST use a single pre-shared key carried in `MASTER_NPC_PSK` (read by Master and by every NPC bot). The protocol MUST refuse any connection that does not present the correct key. Multi-host deployment, TLS, and per-NPC rotating tokens are out of scope for the MVP. + +#### Scenario: Master rejects a connection with a wrong PSK +- **WHEN** an NPC bot attempts to connect with an incorrect `MASTER_NPC_PSK` +- **THEN** Master closes the connection immediately and emits a structured log event with `event=npc_register_rejected` and `failure_reason=invalid_psk` + +#### Scenario: Master accepts a localhost connection with the correct PSK +- **WHEN** an NPC bot connects from `127.0.0.1` with the correct `MASTER_NPC_PSK` +- **THEN** Master accepts the connection, runs the registration handshake below, and replies with `npc_registered` + +### Requirement: NPC registration produces a typed handshake + +On connect, an NPC bot SHALL send a typed `npc_register` message containing `npc_id`, `discord_bot_user_id`, `supported_voices` (a list of `voice_id`s), and `version`. Master SHALL respond with `npc_registered` containing `assigned_seat`, `game_id`, and `phase_id`, after which the NPC is considered live in the registry. NPC bots MUST send `heartbeat` messages at a configurable interval; Master MUST treat an NPC as offline if no heartbeat arrives within a configurable timeout. + +#### Scenario: NPC registers and is bound to a seat +- **WHEN** an NPC bot sends `npc_register` for `npc_id=npc_p5` while seat 5 of game `g_123` is in setup or active +- **THEN** Master maps `npc_p5` to seat 5 of `g_123` in its in-memory NPC registry and responds with `npc_registered` carrying `assigned_seat=5` + +#### Scenario: Heartbeat timeout marks an NPC offline +- **WHEN** Master receives no heartbeat from an NPC for longer than the configured timeout +- **THEN** the NPC is marked offline in the registry and any subsequent `SpeakArbiter` evaluation skips it for the affected phase + +### Requirement: Master-side speak arbitration drives the reactive_voice flow + +When `LLM_DISCUSSION_MODE=reactive_voice`, Master SHALL implement `MasterLogicBuilder` and `SpeakArbiter`. `MasterLogicBuilder` reads `PublicDiscussionState` and produces per-NPC `LogicPacket` payloads with `packet_id`, `phase_id`, `recipient_npc_id`, a textual `public_state_summary`, a list of `logic_candidates` (each with `id`, `claim`, `support[]`, `counter[]`), per-seat `pressure` weights, and `expires_at_ms`. `SpeakArbiter` MUST decide which NPC should speak next, dispatch a typed `SpeakRequest` over the WebSocket carrying `request_id`, `phase_id`, `npc_id`, `seat_no`, `logic_packet_id`, `suggested_intent`, `max_chars=80`, `max_duration_ms`, `priority`, and `expires_at_ms`, and persist a row in `npc_speak_requests`. + +#### Scenario: SpeakRequest is sent only to alive NPCs +- **WHEN** `SpeakArbiter` selects a candidate NPC who is alive and online +- **THEN** Master sends a `SpeakRequest` over the WebSocket and inserts a `npc_speak_requests` row with the same `request_id` and `phase_id` + +#### Scenario: Dead or offline NPC is not sent SpeakRequest +- **WHEN** `SpeakArbiter` evaluates a candidate NPC seat that is dead or whose WebSocket is offline +- **THEN** no `SpeakRequest` is dispatched and no `npc_speak_requests` row is created for that turn + +### Requirement: Speech is strictly serial inside a game + +While a human speaker has an open utterance (after `vad_speech_started` and before either `vad_speech_ended` AND a finalized `SpeechEvent(source=voice_stt)` arrives, or a configurable timeout elapses) OR another NPC's `PlaybackAuthorized` window is still open, `SpeakArbiter` SHALL NOT issue a new `SpeakRequest`. If a `SpeakRequest` would be appropriate but blocked, the arbiter MUST emit a structured log event `speak_request_suppressed` with `failure_reason=human_currently_speaking` or `failure_reason=queue_busy`. + +#### Scenario: NPC speech blocked while human is speaking +- **WHEN** voice-ingest reports `vad_speech_started` for seat 3 and `vad_speech_ended` has not arrived +- **THEN** `SpeakArbiter` emits no `SpeakRequest` for any NPC and logs `speak_request_suppressed` with `failure_reason=human_currently_speaking` if a candidate was otherwise eligible + +#### Scenario: NPC speech blocked while another NPC plays back +- **WHEN** an NPC's `PlaybackAuthorized` window is open and the arbiter evaluates a different NPC candidate +- **THEN** no `SpeakRequest` is sent to the second NPC; `speak_request_suppressed` with `failure_reason=queue_busy` is logged + +### Requirement: NPC bot generates a short utterance via Grok and returns SpeakResult + +On receipt of a `SpeakRequest`, the NPC bot SHALL build a Grok prompt by combining its own persona / role / private state with the `LogicPacket` referenced by `logic_packet_id`, call the existing xAI structured-output endpoint, and return a typed `SpeakResult` to Master containing `request_id`, `npc_id`, `phase_id`, `status` (`accepted | declined | error`), `text` (≤80 characters when `status=accepted`), `used_logic_ids`, `intent`, and `estimated_duration_ms`. NPC bots MUST NOT play audio before sending `SpeakResult`. + +#### Scenario: NPC produces an accepted SpeakResult under 80 characters +- **WHEN** an NPC bot receives a `SpeakRequest` and Grok returns a valid utterance +- **THEN** the NPC bot sends `SpeakResult` with `status=accepted`, `text` not exceeding 80 characters, `used_logic_ids` referencing one or more entries from the matching `LogicPacket`, and an `estimated_duration_ms` + +#### Scenario: NPC bot does not pre-play audio +- **WHEN** an NPC bot has a finished Grok response but has not yet received `PlaybackAuthorized` +- **THEN** no audio is queued or played; the NPC bot only emits `SpeakResult` and waits + +### Requirement: Master validates SpeakResult against current phase + +When `SpeakResult` arrives, Master SHALL validate `phase_id` against the current `phase_id` of the game and `request_id` against an open `npc_speak_requests` row whose `expires_at_ms` has not yet passed. On validation success, Master inserts a row in `npc_speak_results` with `status=accepted`, writes a `SpeechEvent(source=npc_generated, speaker_kind=npc)` for the seat, and dispatches `PlaybackAuthorized` over the WebSocket. On validation failure (stale phase, expired request, unknown request_id), Master writes the result row with `status=rejected` and an explicit `failure_reason` (`stale_phase`, `expired_request`, or `unknown_request`), emits a structured log, and returns a `playback_rejected` message to the NPC bot. + +#### Scenario: Stale phase result is rejected +- **WHEN** an NPC's `SpeakResult` arrives carrying `phase_id=day1_discussion_001` after Master has already advanced to `day1_vote_001` +- **THEN** the result is recorded with `status=rejected`, `failure_reason=stale_phase`; no `SpeechEvent` is written; the NPC bot receives `playback_rejected` + +#### Scenario: Accepted SpeakResult yields PlaybackAuthorized +- **WHEN** Master accepts a `SpeakResult` whose `phase_id` matches and whose `request_id` is open and unexpired +- **THEN** Master writes the corresponding `npc_speak_results` row with `status=accepted`, inserts the matching `SpeechEvent(source=npc_generated)`, and sends `PlaybackAuthorized` to the NPC bot with `speech_event_id` and `playback_deadline_ms` + +### Requirement: NPC bot plays TTS only after PlaybackAuthorized + +NPC bots MUST gate VC playback on receiving a `PlaybackAuthorized` for a specific `request_id`. On receipt, the NPC bot calls its TTS provider with the accepted `text` and the configured `TTS_VOICE_ID`, then plays the resulting audio in `MAIN_VOICE_CHANNEL_ID`. Once playback finishes, the NPC bot SHALL send a typed `playback_finished` message to Master. NPC bots MUST NOT play any audio for which they did not receive a matching `PlaybackAuthorized`. + +#### Scenario: Playback occurs only post-authorization +- **WHEN** an NPC bot receives `PlaybackAuthorized` for `request_id=sr_01HX` +- **THEN** the NPC bot runs TTS for the corresponding `text`, plays the audio in the voice channel, and emits `playback_finished` after the audio completes + +#### Scenario: Without authorization, no playback occurs +- **WHEN** an NPC bot has sent `SpeakResult` and either receives `playback_rejected` or no `PlaybackAuthorized` arrives within the configured timeout +- **THEN** the NPC bot performs no TTS call and plays no audio for that `request_id` + +### Requirement: PlaybackAuthorized window closes on playback_finished or deadline + +A `PlaybackAuthorized` window SHALL be closed on Master when **either** the matching `playback_finished` message arrives over the WebSocket OR Master's clock passes `playback_deadline_ms` for that request — whichever comes first. Deadline-driven closures MUST be logged with `failure_reason=tts_timeout` (no playback_finished received and no playback_failed reported) or `failure_reason=discord_playback_error` (playback_failed reported but window already deadline-closed). Once the window closes, `SpeakArbiter` is unblocked from issuing the next `SpeakRequest`. + +#### Scenario: Window closes on playback_finished +- **WHEN** Master receives a `playback_finished` for an open authorization window +- **THEN** Master records a row in `npc_playback_events` with `status=succeeded` and `finished_at` set, and the serial-speech gate is released + +#### Scenario: Window closes on deadline timeout +- **WHEN** Master's clock passes `playback_deadline_ms` for an open authorization window without a `playback_finished` arriving +- **THEN** Master writes `npc_playback_events` with `status=failed` and `failure_reason=tts_timeout`, releases the serial-speech gate, and emits a structured log + +### Requirement: NPC disconnection causes that NPC to silently sit out the round + +If `SpeakArbiter` evaluates a candidate NPC for whom no recent heartbeat exists or whose WebSocket is offline, that NPC SHALL silently stay out of the round; the game continues, votes and night actions still resolve through the existing DM flow. There MUST be no automatic fallback to text-mode speech for that NPC during the affected phase. The skip MUST be logged as `speak_candidate_skipped` with `failure_reason=npc_offline`. + +#### Scenario: Offline NPC is skipped +- **WHEN** `SpeakArbiter` evaluates seat 5 and seat 5's NPC bot has not heartbeat in N seconds +- **THEN** no `SpeakRequest` is sent to seat 5 and a structured log event `speak_candidate_skipped` with `failure_reason=npc_offline` is recorded + +### Requirement: Restart drops in-flight requests and authorizations + +If Master, voice-ingest, or any NPC bot restarts during `DAY_DISCUSSION`, in-flight protocol state at that process boundary SHALL be dropped. On Master restart, every open `npc_speak_requests` row whose `npc_speak_results` is missing MUST be marked rejected with `failure_reason=master_restart`, and every open `npc_playback_events` row without a paired `playback_finished` MUST be marked failed with `failure_reason=master_restart`. NPC bot restart and voice-ingest restart MUST emit structured-log events (`npc_restart`, `voice_ingest_restart`). On Master restart, `PublicDiscussionState` is rebuilt from `speech_events` for the active phase. The existing `RecoveryService` rule (`deadline_epoch < now` parks in `WAITING_HOST_DECISION`) MUST be preserved. + +#### Scenario: Master restart marks open requests rejected +- **WHEN** Master restarts mid-phase with two open `SpeakRequest`s outstanding +- **THEN** the recovery flow inserts `npc_speak_results` rows with `status=rejected`, `failure_reason=master_restart` for both, and Master rebuilds `PublicDiscussionState` from `speech_events` before resuming `SpeakArbiter` + +### Requirement: Required tables exist with idempotent migrations + +The following tables SHALL be created via additive `CREATE TABLE IF NOT EXISTS` migrations: `npc_speak_requests`, `npc_speak_results`, `npc_playback_events`. Each MUST carry the fields necessary to reconstruct a per-request audit trail (request id, phase id, npc id, seat, status, failure_reason, timestamps). + +#### Scenario: Migrations are idempotent on existing databases +- **WHEN** the bot starts against a database that already has these tables +- **THEN** `migrate()` issues no DDL changes for them + +#### Scenario: Audit trail is queryable by request_id +- **WHEN** a single `request_id` is queried across `npc_speak_requests`, `npc_speak_results`, and `npc_playback_events` +- **THEN** the join produces a complete lifecycle record (sent → result accepted/rejected → playback succeeded/failed/timeout) for that request + +### Requirement: All external surfaces are testable via Protocol Fakes + +NPC bots, the Master ↔ NPC transport, the TTS provider, the Discord VC playback, and the per-NPC Grok client SHALL each be reached through a Protocol so tests substitute Fakes. New testing utilities MUST include `FakeNpcClient`, `FakeMasterWsServer`, `FakeTtsService`, and `FakeVoicePlayback`. No test in the suite may make real Discord, xAI, Gemini, or TTS-provider calls. + +#### Scenario: Reactive-voice flow is tested with Fakes only +- **WHEN** a unit test exercises the full SpeakArbiter → SpeakRequest → SpeakResult → PlaybackAuthorized → playback_finished cycle +- **THEN** it can do so by composing the Master service, an in-memory `FakeMasterWsServer`, a `FakeNpcClient`, a `FakeTtsService`, and a `FakeVoicePlayback`, without touching any real network diff --git a/openspec/changes/add-master-npc-voice-stt/specs/speech-event-bus/spec.md b/openspec/changes/add-master-npc-voice-stt/specs/speech-event-bus/spec.md new file mode 100644 index 0000000..179df63 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/specs/speech-event-bus/spec.md @@ -0,0 +1,65 @@ +## ADDED Requirements + +### Requirement: SpeechEvent is the unified public-utterance contract + +The system SHALL record every public utterance — human voice (post-STT), human Discord text, and NPC-generated text — as a `SpeechEvent` row in the `speech_events` table with the same shape regardless of origin. The schema MUST include `event_id` (ULID), `game_id`, `phase_id`, `day`, `phase`, `source` (`voice_stt | text | npc_generated`), `speaker_kind` (`human | npc`), `speaker_seat`, `text`, `stt_confidence` (nullable), `audio_start_ms` (nullable), `audio_end_ms` (nullable), and `created_at_ms`. Both `LLM_DISCUSSION_MODE` modes MUST write `SpeechEvent` rows for every utterance they produce — `rounds` mode writes one `SpeechEvent(source=npc_generated)` per LLM speech in addition to the existing `PLAYER_SPEECH` log entry. + +#### Scenario: Human text utterance is recorded +- **WHEN** a human player sends a Discord main-channel message during `DAY_DISCUSSION` +- **THEN** a `SpeechEvent` is inserted with `source=text`, `speaker_kind=human`, the player's `seat_no`, the message text, and `stt_confidence`/`audio_*` fields all `null` + +#### Scenario: NPC utterance under rounds mode is recorded +- **WHEN** the bot is running with `LLM_DISCUSSION_MODE=rounds` and an LLM seat's discussion task posts a `PLAYER_SPEECH` log entry +- **THEN** the same utterance is also recorded as `SpeechEvent(source=npc_generated, speaker_kind=npc)` with the same `seat_no` and `text` and matching `phase_id` + +#### Scenario: NPC utterance under reactive_voice mode is recorded +- **WHEN** the bot is running with `LLM_DISCUSSION_MODE=reactive_voice` and an NPC bot's `SpeakResult` is accepted by the Master arbiter +- **THEN** a `SpeechEvent(source=npc_generated, speaker_kind=npc)` is inserted before `PlaybackAuthorized` is dispatched + +### Requirement: Human voice produces exactly one finalized SpeechEvent + +For human voice input, the system SHALL emit exactly one `SpeechEvent(source=voice_stt)` per utterance, written **after STT completes**. The upstream `vad_speech_started` and `vad_speech_ended` events MUST NOT be persisted as `SpeechEvent` rows; they SHALL only appear as structured-log events on the voice-ingest worker. STT failures and below-threshold confidences MUST NOT produce a `SpeechEvent` (see voice-ingest spec for the failure contract). + +#### Scenario: Successful STT writes one SpeechEvent +- **WHEN** voice-ingest receives a complete human VAD segment, runs STT successfully, and the result's `stt_confidence` is at or above the configured threshold +- **THEN** exactly one `SpeechEvent(source=voice_stt, speaker_kind=human)` is inserted with the transcribed `text`, the `stt_confidence`, the `audio_start_ms` / `audio_end_ms` aligned with VAD boundaries, and `created_at_ms = STT completion time` + +#### Scenario: VAD events are not stored as SpeechEvent +- **WHEN** voice-ingest detects `vad_speech_started` and later `vad_speech_ended` for a human segment +- **THEN** the `speech_events` table receives no row from those VAD events — only structured-log entries are emitted + +### Requirement: PublicDiscussionState is derived deterministically from SpeechEvent history + +The Master SHALL maintain an in-memory `PublicDiscussionState` value object per active game that is **derived deterministically** from the `speech_events` rows for the current `phase_id`. The state MUST contain `co_claims`, `stances`, `pressure`, `open_topics`, `silent_seats`, and `recent_speech_event_ids`. The derivation MUST never rely on LLM output — pure code-side computation only. The state MUST be rebuildable from `speech_events` alone (no external state); on Master restart, the state for the current phase SHALL be reconstructed from the persisted rows. + +#### Scenario: State rebuilds from log on Master restart +- **WHEN** Master restarts during an active `DAY_DISCUSSION` phase +- **THEN** Master rebuilds `PublicDiscussionState` for the current `phase_id` by re-reading `speech_events` rows whose `phase_id` matches and replaying them through the apply function, producing the same value object that would have been held in memory before restart + +#### Scenario: Derivation is pure +- **WHEN** the same sequence of `SpeechEvent` rows is applied twice from a fresh empty state +- **THEN** the two resulting `PublicDiscussionState` values are bitwise equal + +### Requirement: MVP derivation rules cover co_claims and silent_seats + +For the MVP, `PublicDiscussionState` derivation SHALL implement at minimum two deterministic rules: (1) `co_claims` adds an entry whenever a `SpeechEvent.text` contains a canonical CO token (`占いCO`, `霊媒CO`, `騎士CO`, or `〇〇CO` for any role keyword) attributed to the speaker's seat; (2) `silent_seats` is the set of alive seats with zero `SpeechEvent` rows in the current `phase_id`. The other fields (`stances`, `pressure`, `open_topics`) MAY remain empty in the MVP — their concrete heuristics are decided in the design phase and refined post-MVP. + +#### Scenario: Seer CO is detected +- **WHEN** seat 3's `SpeechEvent.text` contains `占いCO` for the first time in `DAY_DISCUSSION` +- **THEN** `PublicDiscussionState.co_claims` for the current phase contains an entry `{seat: 3, role_claim: "seer", ...}` + +#### Scenario: Silent seats are tracked +- **WHEN** `DAY_DISCUSSION` is 60 seconds in and seats 6 and 7 have no `SpeechEvent` rows for the current `phase_id` +- **THEN** `PublicDiscussionState.silent_seats` is the unordered set `{6, 7}` (assuming both are alive) + +### Requirement: speech_events table is additive and forward-compatible + +The `speech_events` table SHALL be created via `CREATE TABLE IF NOT EXISTS` in the existing `migrate()` flow. The DDL MUST be idempotent so that existing databases upgrade cleanly on restart. Adding new columns to the table MUST follow the existing additive pattern (`ALTER TABLE ADD COLUMN` guarded by `PRAGMA table_info`); destructive changes are out of scope. + +#### Scenario: Migration runs on every boot +- **WHEN** the bot starts against a database that already has the `speech_events` table +- **THEN** `migrate()` issues no DDL changes for that table because the `IF NOT EXISTS` guard short-circuits + +#### Scenario: Migration creates the table on a fresh database +- **WHEN** the bot starts against a database without the `speech_events` table +- **THEN** `migrate()` creates it with the full MVP schema and the bot proceeds to start diff --git a/openspec/changes/add-master-npc-voice-stt/specs/voice-ingest/spec.md b/openspec/changes/add-master-npc-voice-stt/specs/voice-ingest/spec.md new file mode 100644 index 0000000..a9129ba --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/specs/voice-ingest/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: voice-ingest runs as a separate worker process + +A dedicated `voice-ingest` worker SHALL be implemented as a separate process from Master and from each NPC bot. The worker connects to the same Discord guild as Master, joins the voice channel identified by `MAIN_VOICE_CHANNEL_ID` (shared with Master and all NPC bots), captures incoming voice packets, runs VAD per speaker, performs STT on completed segments, and sends `SpeechEvent(source=voice_stt)` records to Master over a Master-defined ingestion endpoint (HTTP POST or WebSocket — chosen in design). The worker MUST NOT inline VC playback, NPC speech generation, or game-state mutation. + +#### Scenario: voice-ingest joins MAIN_VOICE_CHANNEL_ID +- **WHEN** the `voice-ingest` worker starts and Master has an active game +- **THEN** the worker joins the Discord voice channel whose id equals `MAIN_VOICE_CHANNEL_ID` and registers as a listener-only client (no audio playback) + +#### Scenario: voice-ingest does not write SpeechEvent directly to SQLite +- **WHEN** voice-ingest produces a transcribed utterance +- **THEN** it sends the `SpeechEvent` payload to Master's ingestion endpoint; Master is the sole writer of `speech_events` rows + +### Requirement: NPC bot audio is excluded at the receive boundary + +voice-ingest SHALL exclude NPC TTS audio from STT processing **before** VAD runs. The worker MUST read the Master NPC registry (over the same protocol it uses to send `SpeechEvent`s) and build the set of `discord_bot_user_id` values currently registered as NPCs. Any inbound voice packet whose Discord `user_id` is in that set MUST be discarded immediately at the receive boundary; such packets MUST NOT be stored, mixed, or fed into VAD/STT. + +#### Scenario: NPC voice packet is dropped +- **WHEN** voice-ingest receives a voice packet whose `user_id` matches a registered NPC bot's `discord_bot_user_id` +- **THEN** the packet is discarded immediately and no `vad_speech_started` event is emitted for that packet + +#### Scenario: Human voice packet proceeds to VAD +- **WHEN** voice-ingest receives a voice packet whose `user_id` is not in the NPC registry set +- **THEN** the packet is forwarded into the per-speaker VAD pipeline + +### Requirement: VAD lifecycle is logged but not persisted as SpeechEvent + +voice-ingest SHALL emit structured log events `vad_speech_started` and `vad_speech_ended` for each detected human voice segment, including `game_id`, `phase_id`, `speaker_user_id`, `speaker_seat`, `audio_start_ms` / `audio_end_ms`, and `trace_id`. These events MUST NOT cause `SpeechEvent` rows to be written; only the post-STT finalized utterance does (see speech-event-bus spec). + +#### Scenario: VAD start emits a log event +- **WHEN** the VAD engine detects voice activity beginning for a human speaker +- **THEN** voice-ingest emits a structured log entry with `event=vad_speech_started`, the speaker's `user_id`, the resolved `seat_no`, and the absolute `audio_start_ms` + +### Requirement: STT uses Gemini API audio input for the MVP + +The voice-ingest worker SHALL use the Gemini API audio-input feature as the MVP STT provider. Provider configuration (API key, model id, language hint, confidence threshold) MUST come from environment variables; no provider credentials may be hard-coded. The STT call MUST run asynchronously from the VAD pipeline so a slow STT response does not stall packet capture. + +#### Scenario: Gemini API is invoked with the segment audio +- **WHEN** a complete human VAD segment becomes available +- **THEN** voice-ingest sends the segment audio to the Gemini API audio endpoint and awaits the transcription with a configurable timeout + +### Requirement: Low-confidence and failed STT are dropped with structured-log evidence + +When STT returns a transcription whose `stt_confidence` is below the configured threshold, or when the STT provider returns a hard error (timeout, 5xx, malformed response), voice-ingest SHALL **drop the utterance**: no `SpeechEvent` is sent to Master, and the human player receives no Discord-side surfacing. The drop MUST be recorded as a structured-log event with `event=stt_request_failed` (provider error) or `event=stt_low_confidence` (below threshold), including `failure_reason`, `stt_confidence` (if known), `audio_duration_ms`, and `trace_id`. + +#### Scenario: Below-threshold STT result is dropped +- **WHEN** STT returns `stt_confidence = 0.42` and the configured threshold is `0.6` +- **THEN** voice-ingest emits a `stt_low_confidence` log event and does NOT send a `SpeechEvent` to Master + +#### Scenario: Provider error drops the utterance +- **WHEN** the Gemini API call raises a timeout or returns a 5xx status +- **THEN** voice-ingest emits a `stt_request_failed` log event with `failure_reason=stt_provider_error` (or `stt_timeout`) and does NOT send a `SpeechEvent` to Master + +### Requirement: voice-ingest is fully testable via Protocol Fakes + +The voice-ingest worker SHALL expose its external dependencies (Discord VC audio source, STT provider, Master ingestion endpoint, NPC-registry lookup) through Protocols so that tests can substitute Fakes. New testing utilities `FakeSttService`, `FakeNpcRegistryClient`, and `FakeMasterIngestionClient` MUST be provided in `tests/fakes.py` (or a new `tests/voice_fakes.py`). No test in the suite may make real Discord, Gemini, or Master HTTP calls. + +#### Scenario: STT can be substituted in tests +- **WHEN** a unit test instantiates the voice-ingest pipeline +- **THEN** it can pass a `FakeSttService` that returns predetermined transcriptions and confidences without making any external network call + +### Requirement: voice-ingest restart drops in-flight VAD windows + +When the voice-ingest worker restarts during an active `DAY_DISCUSSION`, any open per-speaker VAD windows SHALL be abandoned: no partial `SpeechEvent` is sent for an utterance whose `vad_speech_ended` had not yet been observed. The restart MUST be logged as `voice_ingest_restart` with the abandoned `speaker_user_id`s. The worker reconnects to Discord and rebuilds its NPC-registry view from Master before resuming capture. + +#### Scenario: In-flight VAD is abandoned across restart +- **WHEN** voice-ingest crashes mid-utterance for a human speaker and restarts +- **THEN** no `SpeechEvent` is sent for that abandoned utterance, and the restart is logged with `event=voice_ingest_restart` and the affected speaker's `user_id` diff --git a/openspec/changes/add-master-npc-voice-stt/task-graph.json b/openspec/changes/add-master-npc-voice-stt/task-graph.json new file mode 100644 index 0000000..16d4724 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/task-graph.json @@ -0,0 +1,457 @@ +{ + "version": "1.0", + "change_id": "add-master-npc-voice-stt", + "bundles": [ + { + "id": "speech-event-bus-foundation", + "title": "Speech Event Bus Foundation", + "goal": "Introduce the canonical SpeechEvent model, persistence, and service APIs that every discussion mode can write to and rebuild from.", + "depends_on": [], + "inputs": [ + "openspec/changes/add-master-npc-voice-stt/design.md", + "openspec/changes/add-master-npc-voice-stt/specs/speech-event-bus/spec.md", + "src/wolfbot/persistence/schema.py", + "src/wolfbot/persistence/sqlite_repo.py" + ], + "outputs": [ + "src/wolfbot/domain/discussion.py#SpeechEvent", + "src/wolfbot/services/discussion_service.py", + "src/wolfbot/persistence/schema.py#speech_events", + "tests/test_discussion_service.py" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Define SpeechEvent domain types, source enums, and serialization rules.", + "status": "done" + }, + { + "id": "2", + "title": "Add the additive speech_events migration and store/repository interfaces.", + "status": "done" + }, + { + "id": "3", + "title": "Implement speech event write, read, and rebuild helpers for the master process.", + "status": "done" + }, + { + "id": "4", + "title": "Add persistence and round-trip rebuild tests for SpeechEvent streams.", + "status": "done" + } + ], + "owner_capabilities": [ + "day-discussion", + "llm-seats" + ], + "size_score": 4 + }, + { + "id": "public-discussion-state", + "title": "Public Discussion State Derivation", + "goal": "Build a deterministic PublicDiscussionState fold over speech_events for the active phase.", + "depends_on": [ + "speech-event-bus-foundation" + ], + "inputs": [ + "openspec/changes/add-master-npc-voice-stt/design.md", + "openspec/changes/add-master-npc-voice-stt/specs/day-discussion/spec.md", + "src/wolfbot/domain/discussion.py#SpeechEvent" + ], + "outputs": [ + "src/wolfbot/domain/discussion.py#PublicDiscussionState", + "src/wolfbot/domain/discussion.py#apply_speech_event", + "src/wolfbot/domain/discussion.py#rebuild_public_state_from_events", + "tests/test_public_discussion_state.py" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Define the PublicDiscussionState value object and its deterministic fields.", + "status": "done" + }, + { + "id": "2", + "title": "Implement apply_speech_event and rebuild_public_state_from_events as a pure fold.", + "status": "done" + }, + { + "id": "3", + "title": "Encode co-claim and silence rules directly from event order without LLM summaries.", + "status": "done" + }, + { + "id": "4", + "title": "Add regression and property-style tests that verify bitwise reproducibility from a fresh rebuild.", + "status": "done" + } + ], + "owner_capabilities": [ + "day-discussion", + "llm-seats" + ], + "size_score": 4 + }, + { + "id": "rounds-mode-speechevent-backfill", + "title": "Rounds Mode SpeechEvent Backfill", + "goal": "Make the existing rounds discussion path write canonical SpeechEvents without changing current gameplay behavior.", + "depends_on": [ + "speech-event-bus-foundation" + ], + "inputs": [ + "openspec/changes/add-master-npc-voice-stt/design.md", + "openspec/changes/add-master-npc-voice-stt/specs/day-discussion/spec.md", + "src/wolfbot/services/llm_service.py", + "src/wolfbot/services/game_service.py", + "src/wolfbot/services/discussion_service.py" + ], + "outputs": [ + "src/wolfbot/services/llm_service.py#rounds-speech-event-writes", + "src/wolfbot/services/game_service.py#rounds-discussion-path", + "tests/test_llm_service.py", + "tests/test_llm_trigger.py" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Route rounds-mode utterance generation through the SpeechEvent store.", + "status": "done" + }, + { + "id": "2", + "title": "Preserve existing log entries and llm_speech_counts side effects for rounds mode.", + "status": "done" + }, + { + "id": "3", + "title": "Add regression coverage that proves current rounds behavior remains unchanged.", + "status": "done" + } + ], + "owner_capabilities": [ + "day-discussion", + "llm-seats" + ], + "size_score": 3 + }, + { + "id": "master-speech-control-surface", + "title": "Master Speech Control Surface", + "goal": "Add the master-owned protocol, endpoints, registry, and audit-table schema that external voice workers connect to.", + "depends_on": [ + "public-discussion-state" + ], + "inputs": [ + "openspec/changes/add-master-npc-voice-stt/design.md", + "openspec/changes/add-master-npc-voice-stt/specs/npc-voice-pipeline/spec.md", + "openspec/changes/add-master-npc-voice-stt/specs/voice-ingest/spec.md", + "src/wolfbot/domain/discussion.py#PublicDiscussionState", + "src/wolfbot/persistence/schema.py" + ], + "outputs": [ + "src/wolfbot/services/master_ws_server.py", + "src/wolfbot/services/master_ingest_service.py", + "src/wolfbot/services/npc_registry.py", + "src/wolfbot/persistence/schema.py#npc_speak_requests", + "src/wolfbot/persistence/schema.py#npc_speak_results", + "src/wolfbot/persistence/schema.py#npc_playback_events", + "tests/test_master_ws_server.py" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Define the shared WebSocket envelope, message schemas, and PSK handshake rules.", + "status": "done" + }, + { + "id": "2", + "title": "Add additive migrations and repository hooks for npc_speak_requests, npc_speak_results, and npc_playback_events.", + "status": "done" + }, + { + "id": "3", + "title": "Implement the master WebSocket server, NPC registration and heartbeat tracking, and localhost ingest and registry endpoints.", + "status": "done" + }, + { + "id": "4", + "title": "Add fakes and protocol-focused tests for registration, heartbeats, and endpoint contracts.", + "status": "done" + } + ], + "owner_capabilities": [ + "discord-integration", + "day-discussion" + ], + "size_score": 4 + }, + { + "id": "master-arbitration-and-recovery", + "title": "Master Arbitration And Recovery", + "goal": "Build the master logic and arbiter that selects NPC speech, enforces serial gating, and finalizes stale or interrupted work.", + "depends_on": [ + "public-discussion-state", + "master-speech-control-surface" + ], + "inputs": [ + "openspec/changes/add-master-npc-voice-stt/design.md", + "openspec/changes/add-master-npc-voice-stt/specs/npc-voice-pipeline/spec.md", + "src/wolfbot/domain/discussion.py#PublicDiscussionState", + "src/wolfbot/services/master_ws_server.py", + "src/wolfbot/services/npc_registry.py", + "src/wolfbot/services/discussion_service.py" + ], + "outputs": [ + "src/wolfbot/services/master_logic_service.py", + "src/wolfbot/services/speak_arbiter.py", + "src/wolfbot/services/recovery_service.py#reactive-voice-sweep", + "tests/test_reactive_voice_master.py" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Build logic packet generation from PublicDiscussionState and seat pressure inputs.", + "status": "done" + }, + { + "id": "2", + "title": "Implement SpeakArbiter dispatch, authorization and rejection flow, serial speech gates, and stale result handling.", + "status": "done" + }, + { + "id": "3", + "title": "Finalize restart recovery rules for pending speak and playback rows plus in-memory gate rebuild.", + "status": "done" + }, + { + "id": "4", + "title": "Add master-side tests for serial speech, stale responses, and restart rebuild behavior.", + "status": "done" + } + ], + "owner_capabilities": [ + "day-discussion", + "discord-integration", + "llm-seats" + ], + "size_score": 4 + }, + { + "id": "voice-ingest-worker", + "title": "Voice Ingest Worker", + "goal": "Capture human voice, filter NPC audio, run VAD and STT, and send canonical speech events to the master.", + "depends_on": [ + "speech-event-bus-foundation", + "master-speech-control-surface" + ], + "inputs": [ + "openspec/changes/add-master-npc-voice-stt/design.md", + "openspec/changes/add-master-npc-voice-stt/specs/voice-ingest/spec.md", + "src/wolfbot/services/master_ingest_service.py", + "src/wolfbot/config.py" + ], + "outputs": [ + "src/wolfbot/services/voice_ingest_service.py", + "src/wolfbot/services/stt_service.py", + "src/wolfbot/services/voice_ingest_client.py", + "tests/test_voice_ingest_service.py" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Add VAD, STT, and master-client protocols plus configuration for the voice-ingest worker.", + "status": "done" + }, + { + "id": "2", + "title": "Implement NPC registry refresh and Discord packet filtering for bot user IDs.", + "status": "done" + }, + { + "id": "3", + "title": "Implement VAD buffering, STT submission, and speech_event ingestion with explicit drop paths for low-confidence and error cases.", + "status": "done" + }, + { + "id": "4", + "title": "Add tests for one-human-one-event, NPC exclusion, and low-confidence or error drops.", + "status": "done" + } + ], + "owner_capabilities": [ + "discord-integration" + ], + "size_score": 4 + }, + { + "id": "npc-voice-worker", + "title": "NPC Voice Worker", + "goal": "Run a per-NPC bot process that registers with the master, generates short utterances, and only plays after authorization.", + "depends_on": [ + "master-speech-control-surface" + ], + "inputs": [ + "openspec/changes/add-master-npc-voice-stt/design.md", + "openspec/changes/add-master-npc-voice-stt/specs/npc-voice-pipeline/spec.md", + "src/wolfbot/config.py", + "src/wolfbot/llm/prompt_builder.py", + "src/wolfbot/services/master_ws_server.py" + ], + "outputs": [ + "src/wolfbot/npc_bot_main.py", + "src/wolfbot/services/npc_client.py", + "src/wolfbot/services/npc_speech_service.py", + "src/wolfbot/services/tts_service.py", + "src/wolfbot/services/voice_playback_service.py", + "tests/test_npc_voice_worker.py" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Implement the NPC-side master client with register, heartbeat, and request and result handling.", + "status": "done" + }, + { + "id": "2", + "title": "Build short-utterance NPC generation, TTS synthesis, and in-memory audio cache services.", + "status": "done" + }, + { + "id": "3", + "title": "Gate playback on explicit authorization and emit playback_finished and playback_failed events.", + "status": "done" + }, + { + "id": "4", + "title": "Add integration tests with fake master, TTS, and playback services.", + "status": "done" + } + ], + "owner_capabilities": [ + "discord-integration", + "llm-seats" + ], + "size_score": 4 + }, + { + "id": "reactive-voice-mode-plumbing", + "title": "Reactive Voice Mode Plumbing", + "goal": "Wire reactive_voice as a mode-fixed day-discussion path without regressing rounds mode.", + "depends_on": [ + "rounds-mode-speechevent-backfill", + "master-arbitration-and-recovery", + "voice-ingest-worker", + "npc-voice-worker" + ], + "inputs": [ + "openspec/changes/add-master-npc-voice-stt/design.md", + "openspec/changes/add-master-npc-voice-stt/specs/day-discussion/spec.md", + "openspec/changes/add-master-npc-voice-stt/specs/llm-seats/spec.md", + "src/wolfbot/config.py", + "src/wolfbot/services/game_service.py", + "src/wolfbot/main.py" + ], + "outputs": [ + "src/wolfbot/config.py#LLM_DISCUSSION_MODE", + "src/wolfbot/services/game_service.py#reactive-voice-dispatch", + "src/wolfbot/main.py#voice-worker-startup", + "tests/test_reactive_voice_mode.py", + "tests/test_game_service_advance.py" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Add LLM_DISCUSSION_MODE selection and freeze the chosen discussion mode for each game instance.", + "status": "done" + }, + { + "id": "2", + "title": "Dispatch DAY_DISCUSSION between rounds and reactive_voice without changing rounds semantics.", + "status": "done" + }, + { + "id": "3", + "title": "Wire master, voice-ingest, and NPC worker lifecycle management for reactive_voice sessions.", + "status": "done" + }, + { + "id": "4", + "title": "Add end-to-end reactive_voice and rounds-regression tests.", + "status": "done" + } + ], + "owner_capabilities": [ + "day-discussion", + "discord-integration", + "llm-seats" + ], + "size_score": 4 + }, + { + "id": "cross-component-observability", + "title": "Cross-Component Observability", + "goal": "Standardize structured logs and phase summaries across master, voice-ingest, and NPC workers for debugging and recovery.", + "depends_on": [ + "master-arbitration-and-recovery", + "voice-ingest-worker", + "npc-voice-worker", + "reactive-voice-mode-plumbing" + ], + "inputs": [ + "openspec/changes/add-master-npc-voice-stt/design.md", + "openspec/changes/add-master-npc-voice-stt/specs/day-discussion/spec.md", + "openspec/changes/add-master-npc-voice-stt/specs/voice-ingest/spec.md", + "openspec/changes/add-master-npc-voice-stt/specs/npc-voice-pipeline/spec.md", + "src/wolfbot/services/master_logic_service.py", + "src/wolfbot/services/voice_ingest_service.py", + "src/wolfbot/services/npc_speech_service.py" + ], + "outputs": [ + "src/wolfbot/services/structured_logging.py", + "src/wolfbot/services/recovery_service.py#discussion-phase-summary", + "tests/test_observability.py" + ], + "status": "done", + "tasks": [ + { + "id": "1", + "title": "Define a shared structured logging helper and required fields for trace, phase, request, seat, and source identifiers.", + "status": "done" + }, + { + "id": "2", + "title": "Instrument master, voice-ingest, and NPC worker flows for drops, authorization, playback, and timeout events.", + "status": "done" + }, + { + "id": "3", + "title": "Emit discussion_phase_summary at each phase end using speech_events and playback audit rows.", + "status": "done" + }, + { + "id": "4", + "title": "Add log fixture tests that assert required events and per-phase speech counts.", + "status": "done" + } + ], + "owner_capabilities": [ + "day-discussion", + "discord-integration", + "llm-seats" + ], + "size_score": 4 + } + ], + "generated_at": "2026-04-26T06:10:31.890Z", + "generated_from": "design.md" +} diff --git a/openspec/changes/add-master-npc-voice-stt/tasks.md b/openspec/changes/add-master-npc-voice-stt/tasks.md new file mode 100644 index 0000000..5839605 --- /dev/null +++ b/openspec/changes/add-master-npc-voice-stt/tasks.md @@ -0,0 +1,95 @@ +## 1. Speech Event Bus Foundation ✓ + +> Introduce the canonical SpeechEvent model, persistence, and service APIs that every discussion mode can write to and rebuild from. + +- [x] 1.1 Define SpeechEvent domain types, source enums, and serialization rules. +- [x] 1.2 Add the additive speech_events migration and store/repository interfaces. +- [x] 1.3 Implement speech event write, read, and rebuild helpers for the master process. +- [x] 1.4 Add persistence and round-trip rebuild tests for SpeechEvent streams. + +## 2. Public Discussion State Derivation ✓ + +> Build a deterministic PublicDiscussionState fold over speech_events for the active phase. + +> Depends on: speech-event-bus-foundation + +- [x] 2.1 Define the PublicDiscussionState value object and its deterministic fields. +- [x] 2.2 Implement apply_speech_event and rebuild_public_state_from_events as a pure fold. +- [x] 2.3 Encode co-claim and silence rules directly from event order without LLM summaries. +- [x] 2.4 Add regression and property-style tests that verify bitwise reproducibility from a fresh rebuild. + +## 3. Rounds Mode SpeechEvent Backfill ✓ + +> Make the existing rounds discussion path write canonical SpeechEvents without changing current gameplay behavior. + +> Depends on: speech-event-bus-foundation + +- [x] 3.1 Route rounds-mode utterance generation through the SpeechEvent store. +- [x] 3.2 Preserve existing log entries and llm_speech_counts side effects for rounds mode. +- [x] 3.3 Add regression coverage that proves current rounds behavior remains unchanged. + +## 4. Master Speech Control Surface ✓ + +> Add the master-owned protocol, endpoints, registry, and audit-table schema that external voice workers connect to. + +> Depends on: public-discussion-state + +- [x] 4.1 Define the shared WebSocket envelope, message schemas, and PSK handshake rules. +- [x] 4.2 Add additive migrations and repository hooks for npc_speak_requests, npc_speak_results, and npc_playback_events. +- [x] 4.3 Implement the master WebSocket server, NPC registration and heartbeat tracking, and localhost ingest and registry endpoints. +- [x] 4.4 Add fakes and protocol-focused tests for registration, heartbeats, and endpoint contracts. + +## 5. Master Arbitration And Recovery ✓ + +> Build the master logic and arbiter that selects NPC speech, enforces serial gating, and finalizes stale or interrupted work. + +> Depends on: public-discussion-state, master-speech-control-surface + +- [x] 5.1 Build logic packet generation from PublicDiscussionState and seat pressure inputs. +- [x] 5.2 Implement SpeakArbiter dispatch, authorization and rejection flow, serial speech gates, and stale result handling. +- [x] 5.3 Finalize restart recovery rules for pending speak and playback rows plus in-memory gate rebuild. +- [x] 5.4 Add master-side tests for serial speech, stale responses, and restart rebuild behavior. + +## 6. Voice Ingest Worker ✓ + +> Capture human voice, filter NPC audio, run VAD and STT, and send canonical speech events to the master. + +> Depends on: speech-event-bus-foundation, master-speech-control-surface + +- [x] 6.1 Add VAD, STT, and master-client protocols plus configuration for the voice-ingest worker. +- [x] 6.2 Implement NPC registry refresh and Discord packet filtering for bot user IDs. +- [x] 6.3 Implement VAD buffering, STT submission, and speech_event ingestion with explicit drop paths for low-confidence and error cases. +- [x] 6.4 Add tests for one-human-one-event, NPC exclusion, and low-confidence or error drops. + +## 7. NPC Voice Worker ✓ + +> Run a per-NPC bot process that registers with the master, generates short utterances, and only plays after authorization. + +> Depends on: master-speech-control-surface + +- [x] 7.1 Implement the NPC-side master client with register, heartbeat, and request and result handling. +- [x] 7.2 Build short-utterance NPC generation, TTS synthesis, and in-memory audio cache services. +- [x] 7.3 Gate playback on explicit authorization and emit playback_finished and playback_failed events. +- [x] 7.4 Add integration tests with fake master, TTS, and playback services. + +## 8. Reactive Voice Mode Plumbing ✓ + +> Wire reactive_voice as a mode-fixed day-discussion path without regressing rounds mode. + +> Depends on: rounds-mode-speechevent-backfill, master-arbitration-and-recovery, voice-ingest-worker, npc-voice-worker + +- [x] 8.1 Add LLM_DISCUSSION_MODE selection and freeze the chosen discussion mode for each game instance. +- [x] 8.2 Dispatch DAY_DISCUSSION between rounds and reactive_voice without changing rounds semantics. +- [x] 8.3 Wire master, voice-ingest, and NPC worker lifecycle management for reactive_voice sessions. +- [x] 8.4 Add end-to-end reactive_voice and rounds-regression tests. + +## 9. Cross-Component Observability ✓ + +> Standardize structured logs and phase summaries across master, voice-ingest, and NPC workers for debugging and recovery. + +> Depends on: master-arbitration-and-recovery, voice-ingest-worker, npc-voice-worker, reactive-voice-mode-plumbing + +- [x] 9.1 Define a shared structured logging helper and required fields for trace, phase, request, seat, and source identifiers. +- [x] 9.2 Instrument master, voice-ingest, and NPC worker flows for drops, authorization, playback, and timeout events. +- [x] 9.3 Emit discussion_phase_summary at each phase end using speech_events and playback audit rows. +- [x] 9.4 Add log fixture tests that assert required events and per-phase speech counts. diff --git a/openspec/specs/day-discussion/spec.md b/openspec/specs/day-discussion/spec.md new file mode 100644 index 0000000..ab6762b --- /dev/null +++ b/openspec/specs/day-discussion/spec.md @@ -0,0 +1,100 @@ +# day-discussion Specification + +## Purpose + +Drives the `DAY_DISCUSSION` and `DAY_RUNOFF_SPEECH` phases of a 9-player Werewolf game. Coordinates day-time speech for both human seats (free Discord text) and LLM seats (batched generated speech), publishes utterances as `PLAYER_SPEECH` log entries, and tracks per-LLM-seat speech progress so that restarts and host force-skips resume correctly without double-posting. + +## Requirements + +### Requirement: Day discussion duration is fixed per day number + +`DAY_DISCUSSION` SHALL run for a deterministic duration depending on `Game.day_number`, computed by `domain.rules.day_discussion_duration(day)`. The duration MUST be 300 seconds on day 1, 240 seconds on day 2, and 180 seconds on day 3 and later. + +#### Scenario: Day 1 discussion length +- **WHEN** the state machine plans the transition into `DAY_DISCUSSION` for `day_number = 1` +- **THEN** the resulting `Transition.deadline_epoch` is exactly 300 seconds after the planning `now_epoch` + +#### Scenario: Day 3+ discussion length +- **WHEN** the state machine plans the transition into `DAY_DISCUSSION` for `day_number >= 3` +- **THEN** the resulting `Transition.deadline_epoch` is exactly 180 seconds after the planning `now_epoch` + +### Requirement: LLM seats speak twice per discussion phase + +For every `DAY_DISCUSSION` phase, each living LLM seat SHALL emit exactly two structured speech utterances. Progress for each seat MUST be tracked in the `llm_speech_counts.discussion_rounds_done` column so that an interrupted phase (process restart, deadline-driven advance, host `/wolf force-skip`) does not cause the same round to be repeated when execution resumes. + +#### Scenario: Two utterances per LLM seat in a fresh phase +- **WHEN** `DAY_DISCUSSION` starts for `day_number = 1` with 4 living LLM seats and the deadline is far enough in the future +- **THEN** the system schedules two utterances per LLM seat (8 total), each producing a `PLAYER_SPEECH` `LogEntry` + +#### Scenario: Resume after restart in mid-phase +- **WHEN** the bot restarts during `DAY_DISCUSSION` after one round of LLM speech has already been recorded for an LLM seat +- **AND** that seat's `llm_speech_counts.discussion_rounds_done` is `1` +- **THEN** on resumption the system schedules only the remaining round for that seat, not both rounds + +### Requirement: All speech is published as PLAYER_SPEECH log entries + +Every successful day-discussion utterance — whether produced by a human via Discord text or by an LLM seat via the structured-output pipeline — SHALL be persisted as a `LogEntry` with `kind = "PLAYER_SPEECH"`, attributed to the originating `seat_no` and `display_name`, and visible in the public main-text channel. + +#### Scenario: LLM speech is logged +- **WHEN** an LLM seat's `submit_llm_discussion_rounds` task produces an utterance +- **THEN** a `LogEntry(kind="PLAYER_SPEECH", actor_seat=, text=)` is appended via the public-log path and posted to the main channel + +#### Scenario: Human speech is logged +- **WHEN** a human player sends a message in the main text channel during `DAY_DISCUSSION` +- **THEN** the bot records the message as a `PLAYER_SPEECH` `LogEntry` for the speaker's seat + +### Requirement: LLM submissions never block the advance loop + +`LLMAdapter.submit_llm_discussion_rounds` and the runoff-speech equivalent SHALL schedule background `asyncio` tasks and return without awaiting the xAI round-trip. The `GameService.advance(game_id)` call site MUST NOT await LLM completion, and a slow xAI response MUST NOT delay deadline detection or phase transitions. + +#### Scenario: advance() returns before LLM responses arrive +- **WHEN** `GameService.advance` enters `DAY_DISCUSSION` and dispatches LLM submissions +- **THEN** the call returns once all background tasks are scheduled, regardless of xAI latency + +#### Scenario: Stale background task detects phase change +- **WHEN** a background LLM submission task is in flight and a `/wolf force-skip` advances the phase before the task completes +- **THEN** the task re-loads the game, observes the changed `phase`, `day_number`, or non-null `ended_at`, and silently aborts without writing a `PLAYER_SPEECH` log + +### Requirement: Concurrent LLM seats run in parallel; wolf night-chat stays serial + +Within a single discussion or runoff-speech batch, per-seat LLM work SHALL run concurrently via `asyncio.gather` so that multiple LLM seats hit xAI simultaneously rather than serially. The wolf night-chat coordination, however, MUST remain serial because later wolves rely on reading earlier wolves' messages from the shared wolves channel. + +#### Scenario: Discussion batch runs in parallel +- **WHEN** four LLM seats are alive and `submit_llm_discussion_rounds` schedules a round +- **THEN** the four xAI requests are issued concurrently rather than one after another + +#### Scenario: Wolf night-chat is serial +- **WHEN** multiple LLM wolves are alive during a NIGHT phase +- **THEN** their wolf-channel utterances are produced in seat order, each later wolf seeing the prior wolf's messages + +### Requirement: Runoff speeches occur only when LLM candidates are tied + +`DAY_RUNOFF_SPEECH` SHALL be entered only when `DAY_VOTE` produces a tie that includes at least one LLM-controlled candidate. In that phase, each tied LLM candidate MUST emit exactly one speech utterance, tracked by `llm_speech_counts.runoff_speech_done`. + +#### Scenario: Tie with LLM candidate triggers runoff speeches +- **WHEN** `DAY_VOTE` resolves with two seats tied and one of them is an LLM seat +- **THEN** the state machine transitions to `DAY_RUNOFF_SPEECH`, and the LLM seat produces exactly one runoff speech before `DAY_RUNOFF` begins + +#### Scenario: All-human tie skips runoff speech phase +- **WHEN** `DAY_VOTE` resolves with two human seats tied and no LLM candidate +- **THEN** the state machine transitions directly to `DAY_RUNOFF`, skipping `DAY_RUNOFF_SPEECH` + +### Requirement: LLM submissions re-validate phase before each per-seat write + +Inside every background LLM submission task, the system SHALL re-load the game and re-check `phase`, `day_number`, and `ended_at` immediately before writing each per-seat `PLAYER_SPEECH` log. A mismatch on any field MUST cause the per-seat write to be skipped. + +#### Scenario: Phase change mid-batch drops remaining seats +- **WHEN** a discussion batch has dispatched per-seat tasks and `/wolf force-skip` advances to `DAY_VOTE` before all tasks complete +- **THEN** any per-seat task that observes the new phase aborts before posting its utterance, and no late `PLAYER_SPEECH` is written for that day's discussion + +### Requirement: Discussion mode is configurable + +The day-discussion engine SHALL accept a runtime mode setting (e.g. via the `LLM_DISCUSSION_MODE` environment variable) that selects between alternative discussion strategies. The default mode MUST be the round-based strategy described above; alternative modes MAY replace the LLM-speech batching behavior while preserving the public-log contract (`PLAYER_SPEECH` entries) and the deadline contract. + +#### Scenario: Default mode runs fixed-round discussion +- **WHEN** the bot starts without `LLM_DISCUSSION_MODE` set +- **THEN** `DAY_DISCUSSION` runs the two-round LLM batching described in this spec + +#### Scenario: Alternative mode preserves the public-log contract +- **WHEN** an alternative discussion mode is selected +- **THEN** every recorded utterance is still emitted as a `PLAYER_SPEECH` `LogEntry` so downstream voting, recovery, and post-game replay are unaffected diff --git a/openspec/specs/discord-integration/spec.md b/openspec/specs/discord-integration/spec.md new file mode 100644 index 0000000..fc7cfd0 --- /dev/null +++ b/openspec/specs/discord-integration/spec.md @@ -0,0 +1,103 @@ +# discord-integration Specification + +## Purpose + +Defines the Discord-side surface of the bot: the `/wolf` slash-command set hosted by `WolfCog`, the per-phase channel-permission reconciliation performed by `PermissionManager` across the main / wolves / heaven channel triplet, the DM-based `VoteView` and `NightActionView` interactions used for private submissions, and the lifecycle of the per-game wolves and heaven channels (created on game start, deleted on game end). + +## Requirements + +### Requirement: /wolf slash commands are registered to a single guild + +The bot SHALL register the `/wolf` command set (`join`, `leave`, `start`, `abort`, `force-skip`, `extend`) inside a single Discord guild whose id is read from the `DISCORD_GUILD_ID` setting. Host-only commands (`start`, `abort`, `force-skip`, `extend`) MUST verify that the invoking user is the recorded game host before performing any state mutation. + +#### Scenario: Non-host abort attempt is rejected +- **WHEN** a user who is not the host invokes `/wolf abort` on an active game +- **THEN** the bot replies ephemerally with a permission error and does not call `GameService.host_abort` + +#### Scenario: Host abort tears down only on first call +- **WHEN** the host invokes `/wolf abort` while the game is still active +- **THEN** `GameService.host_abort` returns `True`, the cog detaches and stops the `GameEngine`, and posts the public `強制終了` message + +#### Scenario: Host abort on already-ended game is a no-op +- **WHEN** the host invokes `/wolf abort` on a game whose `ended_at` is non-null +- **THEN** `GameService.host_abort` returns `False`, the cog replies ephemerally, and no public message is posted and no engine teardown runs + +### Requirement: Lobby seat mutations use phase-guarded repo methods + +`/wolf join` and `/wolf leave` SHALL invoke `SqliteRepo.join_lobby` / `SqliteRepo.leave_lobby` respectively, never raw `insert_seat` / `delete_seat`. These repo methods MUST be phase-guarded in a single transaction so that a stale `/wolf join` arriving after `LOBBY → SETUP` does not corrupt the seat list. + +#### Scenario: Join after start is rejected atomically +- **WHEN** a user issues `/wolf join` between the moment `/wolf start` has flipped the phase to `SETUP` and the moment the bot has finished any post-start work +- **THEN** `SqliteRepo.join_lobby` observes `phase != LOBBY`, the transaction rolls back, and the user is shown an ephemeral error + +### Requirement: Channel permissions are reconciled per phase and idempotent + +`PermissionManager` SHALL maintain three classes of channel state — main text, wolves (private), and heaven (private) — and reconcile them whenever the game phase changes or `GameEngine` reattaches on restart. Living players see+send in the main channel; dead players see only. Living werewolves see the wolves channel always but send only during `NIGHT`. Dead players see+send in the heaven channel; living players cannot see it. The reconciliation MUST be idempotent: only diffs trigger Discord API calls. + +#### Scenario: Wolves can send only during NIGHT +- **WHEN** the game enters `NIGHT` with two living werewolves +- **THEN** the wolves-channel overwrites for both wolves grant send permission, and outside of `NIGHT` (e.g. after entering `DAY_DISCUSSION`) those overwrites grant view-only + +#### Scenario: Heaven hides from the living +- **WHEN** the game has at least one dead player +- **THEN** the heaven-channel overwrite for `@everyone` denies view, the bot's own overwrite allows view+send, and each dead player's overwrite allows view+send while no living player has any heaven overwrite + +#### Scenario: Repeat reconciliation is a no-op +- **WHEN** `PermissionManager.apply` is invoked twice in succession with no underlying state change +- **THEN** the second invocation issues no Discord API calls because all overwrites already match the desired state + +### Requirement: Wolves and heaven channels are deleted at game end + +When a game enters `GAME_OVER` (via execution victory, attack victory, or `/wolf abort`), the bot SHALL delete the per-game wolves and heaven channels (`Game.wolves_channel_id` and `Game.heaven_channel_id`) rather than merely clearing their permission overwrites. This deletion is required to prevent cross-game channel leak. + +#### Scenario: End-of-game cleanup deletes private channels +- **WHEN** a game reaches `GAME_OVER` +- **THEN** the bot deletes both the wolves channel referenced by `Game.wolves_channel_id` and the heaven channel referenced by `Game.heaven_channel_id` + +### Requirement: Wolf-attack splits show asymmetric public information + +While a wolf-attack split is unresolved during `NIGHT`, the main text channel SHALL announce only an aggregate `未確定: N件` count and MUST NOT reveal the per-target breakdown. The wolves private channel, by contrast, MAY display the real per-target tally so the wolves can coordinate. This asymmetry is intentional and MUST be preserved. + +#### Scenario: Main channel hides the breakdown +- **WHEN** two wolves submit different attack targets and the split is still unresolved +- **THEN** the main-channel announcement reads `未確定: 2件` (or equivalent) and does not name the targets + +#### Scenario: Wolves channel sees the split +- **WHEN** the same split is in progress +- **THEN** the wolves-channel message lists both targets and the count for each so the wolves can converge + +### Requirement: DM views capture day at send time + +`VoteView` and `NightActionView` sent to a player as a Discord DM SHALL embed the `Game.day_number` value as it was at the moment the DM was dispatched. When the player clicks a button on a DM after the game has advanced to a new day, `submit_vote` / `submit_night_action` MUST observe the captured `day` against the current `game.day_number` and reject the submission with `SubmitResult.STALE_PHASE` even if the current phase happens to coincidentally match. + +#### Scenario: Yesterday's DM is rejected today +- **WHEN** a player clicks the vote button on a DM that was sent during day 1's `DAY_VOTE` after the game has advanced into day 2's `DAY_VOTE` +- **THEN** `submit_vote(day=1)` is rejected with `SubmitResult.STALE_PHASE` and no vote is recorded + +### Requirement: DM resends cover both missing and unresolved seats + +`resend_pending_dms` (used by `/wolf extend` and the recovery path) SHALL re-send DMs to the union of `PendingSubmission.missing_seats` (never submitted) and `PendingSubmission.unresolved_seats` (submitted but unresolved, e.g. wolf attack split). This guarantees that an extend after a split lockout reaches the not-yet-submitted seat too without requiring the host to use `/wolf force-skip`. + +#### Scenario: Extend during attack split resends to both wolves +- **WHEN** during a wolf-attack split one wolf has submitted and one has not, and the host issues `/wolf extend` +- **THEN** the resend reaches both wolves, with the not-yet-submitted wolf seeing the full legal attack list + +### Requirement: Night DMs separate "who to DM" from "the legal target pool" + +`DiscordAdapter.send_night_action_dms(game, actors, alive_players, seats)` SHALL accept two separate player pools: `actors` (the recipients to DM, typically the still-pending subset) and `alive_players` (the full alive pool used to compute legal targets). Implementations MUST NOT collapse these into a single argument, because resends to a single not-yet-submitted wolf during a split still need the full legal attack list. + +#### Scenario: Single-wolf resend still offers full target list +- **WHEN** `send_night_action_dms` is called with `actors = [seat_5]` and `alive_players` containing all living non-wolf seats +- **THEN** the DM sent to seat 5 lists every legal attack target derived from `alive_players`, not only seats present in `actors` + +### Requirement: WAITING_HOST_DECISION requires explicit host intervention + +When `RecoveryService` finds a game whose `deadline_epoch < now` on startup, it SHALL park the game in `WAITING_HOST_DECISION` instead of silently auto-resolving stale submissions. The host MUST then run either `/wolf force-skip` or `/wolf extend` to unblock the game. + +#### Scenario: Past-deadline game on restart awaits host +- **WHEN** the bot restarts and finds an active game whose `deadline_epoch` already passed +- **THEN** the game is moved to `WAITING_HOST_DECISION` and remains there until the host runs `/wolf force-skip` or `/wolf extend` + +#### Scenario: Per-game isolation in recovery +- **WHEN** one game's recovery raises an exception during reconciliation +- **THEN** the exception is logged for that game only and other active games still complete their recovery diff --git a/openspec/specs/llm-seats/spec.md b/openspec/specs/llm-seats/spec.md new file mode 100644 index 0000000..d769deb --- /dev/null +++ b/openspec/specs/llm-seats/spec.md @@ -0,0 +1,95 @@ +# llm-seats Specification + +## Purpose + +Specifies how unfilled seats in a 9-player Werewolf game are played by LLM-driven NPC personas backed by the xAI Grok endpoint. Defines the structured-output contract (`LLMAction`), the per-actor system-prompt composition pipeline (`prompt_builder`), the persona registry, the role-strategy isolation rules, and the seat-token resolver that maps generated `target_name` strings back to specific seats. + +## Requirements + +### Requirement: Unfilled seats are auto-backfilled by LLM-driven NPCs + +When `/wolf start` runs and the lobby contains fewer than 9 human players, the system SHALL fill the remaining seats with LLM-controlled NPCs as part of the same atomic `claim_start_and_backfill` transaction. The number of LLM seats MUST equal `9 - ` so that the table always seats exactly 9 players. + +#### Scenario: Six humans triggers three LLM seats +- **WHEN** `/wolf start` is invoked with exactly 6 human seats present in `LOBBY` +- **THEN** the start transaction inserts 3 additional LLM seats so the game enters `SETUP` with 9 seats total + +#### Scenario: Solo lobby triggers eight LLM seats +- **WHEN** `/wolf start` is invoked with exactly 1 human seat in `LOBBY` +- **THEN** the start transaction inserts 8 additional LLM seats so the game enters `SETUP` with 9 seats total + +### Requirement: Each LLM seat is bound to a unique persona + +Each LLM seat SHALL be assigned a persona record from `wolfbot.llm.personas.PERSONAS_BY_KEY` carrying at minimum a katakana `display_name` prefixed by a distinguishing emoji, a free-form `style_guide` string for judgement and tone, and a structured `SpeechProfile` capturing speech reproduction (`first_person`, `self_reference_aliases`, `address_style`, `sentence_style`, `pause_style`, `signature_phrases`, `forbidden_overuse`, `narration_mode`). No two persona records in a single game MAY share the same `display_name`. + +#### Scenario: Persona display_name is never duplicated within a game +- **WHEN** the start transaction selects personas for the LLM seats of a single game +- **THEN** every chosen persona's `display_name` is unique across that game's seat list + +#### Scenario: Style and speech fields are isolated +- **WHEN** a persona record is read by the prompt builder +- **THEN** speech-reproduction data lives only in `speech_profile` and judgement/tone data lives only in `style_guide`; neither field carries content belonging to the other + +### Requirement: LLM actions are returned as a strict structured JSON + +LLM seats SHALL communicate with the xAI Grok endpoint using the OpenAI-compatible chat-completions API with `response_format` set to enforce the `LLMAction` JSON schema strictly. The schema MUST encode the four legal intents `speak`, `vote`, `night_action`, and `skip`, plus optional fields for `target_name` (a seat token), `text` (the utterance, if any), and reasoning metadata. Transient transport errors MUST be retried with exponential backoff via `tenacity`. + +#### Scenario: Strict structured output is requested +- **WHEN** an LLM seat issues a request via `XAILLMActionDecider` +- **THEN** the request includes `response_format` configured to require the `LLMAction` JSON schema and rejects any unparseable response + +#### Scenario: Transient errors retry with backoff +- **WHEN** the xAI endpoint returns a transient error (timeout, 5xx) on an LLMAction request +- **THEN** the request is retried using `tenacity` with exponential wait and a finite stop-after-attempt + +### Requirement: System prompt is composed per actor + +For every LLM call, the system prompt SHALL be composed dynamically by `prompt_builder.build_system_prompt` rather than loaded verbatim. The composition MUST layer (1) the markdown template at `src/wolfbot/prompts/llm_system_prompt.md` for base framing, output format, and hard invariants; (2) `_build_game_rules_block()` derived from `ROLE_DISTRIBUTION` and `VILLAGE_SIZE` plus shared CO-evaluation heuristics; (3) the role-specific entry from `_ROLE_STRATEGIES`; and (4) `_build_speech_profile_block(persona)` for the persona's speech reproduction. + +#### Scenario: Game-rules block reflects canonical 9-player distribution +- **WHEN** the prompt builder runs for any LLM seat +- **THEN** the rendered game-rules block lists the role counts and village size derived from `ROLE_DISTRIBUTION` / `VILLAGE_SIZE` constants without duplicating the numbers in the markdown template + +#### Scenario: Speech profile is rendered per persona +- **WHEN** the prompt builder runs for a seat whose persona uses `narration_mode = "silent_gesture"` +- **THEN** the speech-profile block renders gesture descriptions instead of a normal speech profile + +### Requirement: Role strategies do not leak across roles + +The role-strategy block injected into the system prompt SHALL include only the entry from `_ROLE_STRATEGIES` matching the actor's true role. Strategy text scoped to one role (e.g. wolf or madman fake-CO playbooks) MUST NOT appear in prompts built for any other role's actor. + +#### Scenario: Wolf strategy never appears for a seer prompt +- **WHEN** the prompt builder composes a system prompt for a seat whose role is `seer` +- **THEN** the wolf and madman fake-CO strategy text is absent from the rendered prompt + +#### Scenario: Villager strategy forbids self-CO declarations +- **WHEN** the prompt builder composes a system prompt for a seat whose role is `villager` +- **THEN** the rendered prompt forbids declaring `村人CO` / `素村CO` and equivalent self-declarations + +### Requirement: target_name resolves via seat token + +When an `LLMAction` carries a `target_name`, the resolver in `LLMAdapter` SHALL accept the canonical seat-token form `席{seat_no} {display_name}` (parsed by the `_SEAT_TOKEN_RE` pattern `^\s*席(\d+)\b`) and use the leading seat number as authoritative. A bare `display_name` without the seat-number prefix MUST resolve only when it is unambiguous across the alive seat list. An unresolvable or out-of-range token MUST cause the action to be treated as `skip` rather than be silently retargeted. + +#### Scenario: Seat-token prefix is authoritative +- **WHEN** an LLM returns `target_name = "席3 アリス"` and seat 3's `display_name` is `アリス` +- **THEN** the resolver maps the action to seat 3 + +#### Scenario: Duplicate display_name resolves by seat-token prefix +- **WHEN** two living seats both have `display_name = "アリス"` (one human, one persona) and an LLM returns `target_name = "席5 アリス"` +- **THEN** the resolver maps the action to seat 5, not seat 3 + +#### Scenario: Bare display_name with collision is rejected +- **WHEN** two living seats both have `display_name = "アリス"` and an LLM returns `target_name = "アリス"` without a seat-number prefix +- **THEN** the resolver does not pick a seat at random; the action is treated as `skip` + +### Requirement: Utterances respect length, language, and content invariants + +Every LLM-generated utterance SHALL be Japanese, between 80 and 300 characters inclusive, and SHALL NOT quote original Gnosia dialogue verbatim or contain meta-commentary about being an AI or about the input being structured data. + +#### Scenario: Over-length utterance is rejected upstream +- **WHEN** an LLM produces an utterance longer than 300 characters +- **THEN** the system prompt's hard rules cause the model to self-correct on retry, and a non-conforming response is logged and dropped rather than published as `PLAYER_SPEECH` + +#### Scenario: Original Gnosia dialogue is forbidden +- **WHEN** the prompt builder renders any persona's prompt +- **THEN** the rendered prompt explicitly forbids quoting original Gnosia dialogue and requires personality imitation by tone alone diff --git a/pyproject.toml b/pyproject.toml index e50e23f..0c8acc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,10 +13,14 @@ dependencies = [ "google-genai>=1.69", "httpx>=0.27", "tenacity>=8.2", + "websockets>=12", + "discord-ext-voice-recv>=0.5.2a179", ] [project.scripts] wolfbot = "wolfbot.main:cli" +wolfbot-npc = "wolfbot.npc.runtime.main:main" +wolfbot-voicetest = "wolfbot.voicetest.main:run" [build-system] requires = ["hatchling"] @@ -38,9 +42,7 @@ dev = [ asyncio_mode = "auto" testpaths = ["tests"] pythonpath = ["src"] -filterwarnings = [ - "ignore::DeprecationWarning:audioop", -] +filterwarnings = ["ignore::DeprecationWarning:audioop"] [tool.ruff] line-length = 100 @@ -49,16 +51,20 @@ src = ["src", "tests"] [tool.ruff.lint] select = [ - "E", "F", "W", # pycodestyle / pyflakes - "I", # isort - "B", # bugbear - "UP", # pyupgrade - "SIM", # simplify - "RUF", # ruff-specific - "TID", # tidy imports + "E", + "F", + "W", # pycodestyle / pyflakes + "I", # isort + "B", # bugbear + "UP", # pyupgrade + "SIM", # simplify + "RUF", # ruff-specific + "TID", # tidy imports + ] ignore = [ - "E501", # line length handled by formatter + "E501", # line length handled by formatter + ] [tool.ruff.lint.per-file-ignores] @@ -72,5 +78,11 @@ mypy_path = "src" exclude = ["tests/.*"] [[tool.mypy.overrides]] -module = ["discord.*", "aiosqlite.*", "tenacity.*", "google.genai.*"] +module = [ + "discord.*", + "aiosqlite.*", + "tenacity.*", + "websockets.*", + "google.genai.*", +] ignore_missing_imports = true diff --git a/scripts/dump-export-schema.py b/scripts/dump-export-schema.py new file mode 100755 index 0000000..f37b3e3 --- /dev/null +++ b/scripts/dump-export-schema.py @@ -0,0 +1,41 @@ +"""Dump the JSON Schema of the viewer export contract. + +Writes the JSON Schema produced by +:class:`wolfbot.services.game_export_types.GameExport` to +``viewer/sample-data/export-schema.json``. The viewer contract test +loads this file and validates every committed export against it. + +Run after touching ``game_export_types.py``:: + + uv run python scripts/dump-export-schema.py + +A drift check (``tests/test_game_export_integration.py``) asserts the +committed file equals the freshly emitted schema, so CI fails if you +forget this step. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +def main() -> None: + repo_root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(repo_root / "src")) + + from wolfbot.services.game_export_types import GameExport + + schema = GameExport.model_json_schema() + target = repo_root / "viewer" / "sample-data" / "export-schema.json" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + json.dumps(schema, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print(f"wrote {target.relative_to(repo_root)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/export-game.py b/scripts/export-game.py new file mode 100755 index 0000000..dbf67aa --- /dev/null +++ b/scripts/export-game.py @@ -0,0 +1,75 @@ +"""CLI: export one finished/aborted game to viewer-compatible JSON. + +Usage:: + + uv run python scripts/export-game.py --game-id g_abc123def + uv run python scripts/export-game.py --game-id g_abc123def \\ + --db ./wolfbot.db --output viewer/games/ + +The same export is also kicked off automatically by GameService at the +end of every game (victory or host_abort) — this CLI is for re-exporting +historical games without replaying them. +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys +from pathlib import Path + + +def _parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument( + "--game-id", + required=True, + help="Game id to export (the 12-char hex id printed by /wolf start).", + ) + p.add_argument( + "--db", + default=os.environ.get("WOLFBOT_DB_PATH", "./wolfbot.db"), + help="SQLite path. Defaults to $WOLFBOT_DB_PATH or ./wolfbot.db.", + ) + p.add_argument( + "--trace-dir", + default=os.environ.get("WOLFBOT_LLM_TRACE_DIR", "logs/llm_calls"), + help="LLM trace dir. Defaults to $WOLFBOT_LLM_TRACE_DIR or logs/llm_calls.", + ) + p.add_argument( + "--output", + default="viewer/games", + help=( + "Output directory. The file is written as " + "{output}/YYYY-MM-DD_HH-MM-SS.json (derived from game's created_at)." + ), + ) + return p.parse_args() + + +async def _amain() -> int: + # Make `from wolfbot.services.game_export import export_game` work when + # this script is invoked directly without the package being installed. + repo_root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(repo_root / "src")) + + from wolfbot.services.game_export import export_game + + args = _parse_args() + out_path = await export_game( + game_id=args.game_id, + db_path=args.db, + trace_dir=args.trace_dir, + output_dir=args.output, + ) + print(f"exported {args.game_id} -> {out_path}") + return 0 + + +def main() -> None: + sys.exit(asyncio.run(_amain())) + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_npc_envs.py b/scripts/generate_npc_envs.py new file mode 100755 index 0000000..e1f80e5 --- /dev/null +++ b/scripts/generate_npc_envs.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""Generate per-persona NPC env files from a single template + tokens list. + +Source files (read, not written): + tokens.txt - ": " per line + (typo-aliases like "setu" → "setsu" allowed) + .env.master - DISCORD_GUILD_ID / MAIN_VOICE_CHANNEL_ID / + MASTER_NPC_PSK / GAMEPLAY_LLM_API_KEY copied + forward + envs/npc/.env.npc.example - {{...}} placeholders substituted per persona + wolfbot.npc.personas - NPC_PERSONA_KEY + tts_voice_id authoritative + source + +Outputs: + envs/npc/.env. - one file per persona present in tokens.txt + +Usage: + python3 scripts/generate_npc_envs.py + python3 scripts/generate_npc_envs.py --tokens tokens.txt --master .env.master + python3 scripts/generate_npc_envs.py --no-overwrite + python3 scripts/generate_npc_envs.py --dry-run + +Failure modes (non-zero exit): + - tokens.txt or .env.master missing + - .env.master missing a required shared key + - tokens.txt names a persona that isn't in NPC_PERSONAS_BY_KEY (after alias) + - persona has no tts_voice_id (template can't substitute it) +""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_TOKENS = REPO_ROOT / "tokens.txt" +DEFAULT_MASTER_ENV = REPO_ROOT / ".env.master" +DEFAULT_TEMPLATE = REPO_ROOT / "envs" / "npc" / ".env.npc.example" +DEFAULT_OUT_DIR = REPO_ROOT / "envs" / "npc" + +# Common typos in tokens.txt → canonical persona key. Add aliases here as +# they show up; the alias map is intentionally explicit (not fuzzy match) +# so a wrong key fails loudly rather than silently mapping to neighbours. +PERSONA_ALIASES: dict[str, str] = { + "setu": "setsu", +} + +# Master tokens are persona-shaped lines but don't generate a per-persona +# env file. The Master's DISCORD_TOKEN already lives in .env.master. +NON_NPC_KEYS: frozenset[str] = frozenset({"master"}) + +REQUIRED_MASTER_KEYS: tuple[str, ...] = ( + "DISCORD_GUILD_ID", + "MAIN_VOICE_CHANNEL_ID", + "MASTER_NPC_PSK", + "GAMEPLAY_LLM_API_KEY", +) + + +@dataclass(frozen=True) +class TokenEntry: + raw_key: str # exactly what was in tokens.txt before alias resolution + persona_key: str # canonical key after alias resolution + token: str + + +def parse_tokens(path: Path) -> list[TokenEntry]: + """Parse `: ` lines, ignore comments / blanks.""" + if not path.exists(): + sys.exit(f"ERROR: tokens file not found: {path}") + entries: list[TokenEntry] = [] + seen_raw_keys: set[str] = set() + for lineno, raw in enumerate(path.read_text().splitlines(), start=1): + line = raw.strip() + if not line or line.startswith("#"): + continue + if ":" not in line: + sys.exit(f"ERROR: {path}:{lineno}: expected ': ', got {line!r}") + key_part, token_part = line.split(":", 1) + key = key_part.strip().lower() + token = token_part.strip() + if not key or not token: + sys.exit(f"ERROR: {path}:{lineno}: empty key or token") + if key in seen_raw_keys: + sys.exit(f"ERROR: {path}:{lineno}: duplicate entry for key {key!r}") + seen_raw_keys.add(key) + canonical = PERSONA_ALIASES.get(key, key) + entries.append(TokenEntry(raw_key=key, persona_key=canonical, token=token)) + return entries + + +def parse_env_file(path: Path) -> dict[str, str]: + """Minimal `.env` reader: KEY=VALUE per line, ignore comments / blanks. + + Strips matching surrounding single or double quotes. Does not handle + multi-line values or shell expansion (we don't need those for our envs). + """ + if not path.exists(): + sys.exit(f"ERROR: env file not found: {path}") + out: dict[str, str] = {} + for lineno, raw in enumerate(path.read_text().splitlines(), start=1): + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + sys.exit(f"ERROR: {path}:{lineno}: expected 'KEY=VALUE', got {line!r}") + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): + value = value[1:-1] + out[key] = value + return out + + +def load_npc_personas() -> dict[str, object]: + """Late import so this script remains stdlib-only at import time.""" + sys.path.insert(0, str(REPO_ROOT / "src")) + try: + from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY + except ImportError as exc: + sys.exit(f"ERROR: failed to import NPC_PERSONAS_BY_KEY: {exc}") + return NPC_PERSONAS_BY_KEY # type: ignore[no-any-return] + + +_PLACEHOLDER_RE = re.compile(r"\{\{([A-Z_][A-Z0-9_]*)\}\}") + + +def render_template(template: str, values: dict[str, str]) -> str: + """Substitute `{{KEY}}` placeholders. Fail fast on missing keys.""" + missing: list[str] = [] + + def _repl(match: re.Match[str]) -> str: + key = match.group(1) + if key not in values: + missing.append(key) + return match.group(0) + return values[key] + + out = _PLACEHOLDER_RE.sub(_repl, template) + if missing: + unique = sorted(set(missing)) + sys.exit(f"ERROR: template referenced unknown placeholder(s): {unique}") + return out + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Generate envs/npc/.env. files from a single template.", + ) + def _rel(p: Path) -> str: + try: + return str(p.relative_to(REPO_ROOT)) + except ValueError: + return str(p) + + parser.add_argument("--tokens", type=Path, default=DEFAULT_TOKENS, + help=f"path to tokens file (default: {_rel(DEFAULT_TOKENS)})") + parser.add_argument("--master", type=Path, default=DEFAULT_MASTER_ENV, + help=f"path to .env.master (default: {_rel(DEFAULT_MASTER_ENV)})") + parser.add_argument("--template", type=Path, default=DEFAULT_TEMPLATE, + help=f"path to template (default: {_rel(DEFAULT_TEMPLATE)})") + parser.add_argument("--out-dir", type=Path, default=DEFAULT_OUT_DIR, + help=f"output dir (default: {_rel(DEFAULT_OUT_DIR)})") + parser.add_argument("--no-overwrite", action="store_true", + help="refuse to overwrite existing per-persona env files") + parser.add_argument("--dry-run", action="store_true", + help="print what would be written but don't touch the filesystem") + args = parser.parse_args() + + tokens = parse_tokens(args.tokens) + master_env = parse_env_file(args.master) + template = args.template.read_text() if args.template.exists() else None + if template is None: + sys.exit(f"ERROR: template not found: {args.template}") + + missing_master_keys = [k for k in REQUIRED_MASTER_KEYS if not master_env.get(k)] + if missing_master_keys: + sys.exit( + f"ERROR: {args.master} is missing or has empty value for: " + f"{', '.join(missing_master_keys)}" + ) + + npc_personas = load_npc_personas() + args.out_dir.mkdir(parents=True, exist_ok=True) + + written: list[str] = [] + skipped: list[str] = [] + aliased: list[tuple[str, str]] = [] + skipped_master: list[str] = [] + + for entry in tokens: + if entry.raw_key in NON_NPC_KEYS: + skipped_master.append(entry.raw_key) + continue + if entry.raw_key != entry.persona_key: + aliased.append((entry.raw_key, entry.persona_key)) + persona = npc_personas.get(entry.persona_key) + if persona is None: + valid = ", ".join(sorted(npc_personas.keys())) + sys.exit( + f"ERROR: tokens.txt references persona {entry.raw_key!r} " + f"(canonical {entry.persona_key!r}) which is not in NPC_PERSONAS_BY_KEY.\n" + f" Valid keys: {valid}" + ) + tts_voice_id = getattr(persona, "tts_voice_id", None) + if tts_voice_id is None: + sys.exit( + f"ERROR: persona {entry.persona_key!r} has no tts_voice_id set; " + f"add one to wolfbot.npc.personas.NPC_PERSONAS." + ) + + out_path = args.out_dir / f".env.{entry.persona_key}" + if out_path.exists() and args.no_overwrite: + skipped.append(entry.persona_key) + continue + + substitutions = { + "NPC_ID": f"npc_{entry.persona_key}", + "NPC_DISCORD_TOKEN": entry.token, + "NPC_PERSONA_KEY": entry.persona_key, + "DISCORD_GUILD_ID": master_env["DISCORD_GUILD_ID"], + "MAIN_VOICE_CHANNEL_ID": master_env["MAIN_VOICE_CHANNEL_ID"], + "MASTER_NPC_PSK": master_env["MASTER_NPC_PSK"], + "NPC_LLM_API_KEY": master_env["GAMEPLAY_LLM_API_KEY"], + "TTS_VOICE_ID": str(tts_voice_id), + } + rendered = render_template(template, substitutions) + + if args.dry_run: + print(f"[dry-run] would write {_rel(out_path)}") + else: + out_path.write_text(rendered) + written.append(entry.persona_key) + + # Summary + print() + if aliased: + for raw, canonical in aliased: + print(f"note: tokens.txt key {raw!r} aliased to canonical {canonical!r}") + if skipped_master: + print(f"note: skipped non-NPC entries from tokens.txt: {', '.join(skipped_master)} " + f"(Master token belongs in .env.master, not envs/npc/)") + if args.dry_run: + print(f"\ndry-run complete. {len(tokens) - len(skipped_master)} files would be written.") + else: + if skipped: + print(f"skipped (already exist, --no-overwrite): {', '.join(skipped)}") + print(f"\n✅ wrote {len(written)} per-persona env file(s) to {_rel(args.out_dir)}/") + for persona in written: + print(f" - .env.{persona}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run-bots.sh b/scripts/run-bots.sh new file mode 100755 index 0000000..2d3d7d0 --- /dev/null +++ b/scripts/run-bots.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash +# Launch the wolfbot Master and all configured NPC bots inside a single +# tmux session, one window per process. Designed for macOS (and Linux) +# but tested primarily on macOS with iTerm2 + Homebrew tmux. +# +# Usage: +# scripts/run-bots.sh # auto-detect personas from envs/npc/.env.* +# scripts/run-bots.sh setsu gina sq # only the listed personas +# scripts/run-bots.sh --mock # offline mock mode (no LLM API calls, +# # no real VOICEVOX needed beyond local +# # daemon, fast phase durations) +# scripts/run-bots.sh --mock setsu # mock mode + persona filter +# FORCE=1 scripts/run-bots.sh # kill & recreate a stale session +# +# Mock mode injects these env vars into every spawned bot, overriding the +# values in the user's real .env.master / envs/npc/.env. via +# pydantic-settings precedence (process env beats .env file): +# GAMEPLAY_LLM_PROVIDER=mock # Master gameplay LLM → mock decider +# NPC_LLM_PROVIDER=mock # NPC speech LLM → scripted phrases +# WOLFBOT_PHASE_DURATION_FACTOR=0.1 # phases 10x faster (60s vote → 6s) +# So the user keeps their real Discord token / VOICEVOX URL etc. and only +# the LLM and pacing layers swap to deterministic test stand-ins. +# +# After the session is up: +# tmux attach -t wolfbot # open the session +# prefix + n / p # next/prev window +# prefix + 0..9 / w # jump by index / pick from list +# prefix + d # detach (bots keep running) +# +# Stop everything: scripts/stop-bots.sh + +set -euo pipefail + +# ─── parse --mock flag (must come before persona args) ──────────────────── +MOCK_MODE=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --mock) + MOCK_MODE=1 + shift + ;; + --) + shift + break + ;; + -*) + echo "ERROR: unknown flag: $1" + echo "Usage: $0 [--mock] [persona ...]" + exit 1 + ;; + *) + break + ;; + esac +done + +# ─── locate repo root (parent of this script's dir) ─────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +cd "${REPO_ROOT}" + +SESSION="${WOLFBOT_TMUX_SESSION:-wolfbot}" +LOG_DIR="${REPO_ROOT}/logs" +mkdir -p "${LOG_DIR}" + +# ─── prerequisites ──────────────────────────────────────────────────────── +if ! command -v tmux >/dev/null 2>&1; then + echo "ERROR: tmux is not installed." + echo " macOS: brew install tmux" + echo " Linux: apt install tmux (or your distro equivalent)" + exit 1 +fi + +if [[ ! -x "${REPO_ROOT}/.venv/bin/wolfbot" || ! -x "${REPO_ROOT}/.venv/bin/wolfbot-npc" ]]; then + echo "ERROR: wolfbot entry points not found in ${REPO_ROOT}/.venv/bin/." + echo " Run 'uv sync' first to create the project venv." + exit 1 +fi + +if [[ ! -f "${REPO_ROOT}/.env.master" ]]; then + echo "ERROR: ${REPO_ROOT}/.env.master is missing." + echo " Copy .env.master.example, fill the secrets, and rerun." + exit 1 +fi + +# ─── VOICEVOX engine reachability probe (warn, don't block) ─────────────── +# In reactive_voice mode every NPC bot needs the VOICEVOX HTTP engine on +# its NPC_LLM_VOICEVOX_URL (default localhost:50021). Probing once before +# launch saves debugging time when the engine wasn't started — NPCs +# would otherwise spin up, register on the WS, and fail silently the +# moment a SpeakRequest arrives. +VOICEVOX_URL="${WOLFBOT_VOICEVOX_URL:-http://localhost:50021}" +if command -v curl >/dev/null 2>&1; then + if ! curl -fsS --max-time 2 "${VOICEVOX_URL}/version" >/dev/null 2>&1; then + echo "WARNING: VOICEVOX engine not reachable at ${VOICEVOX_URL}/version" + echo " NPCs will register with Master but fail on the first SpeakRequest." + echo " Start it before NPCs are dispatched. Examples:" + echo " macOS app: open -a VOICEVOX" + echo " Docker: docker run --rm -p 50021:50021 voicevox/voicevox_engine:cpu-latest" + echo " Override the probe target via WOLFBOT_VOICEVOX_URL=..." + fi +fi + +# We invoke .venv/bin/wolfbot* directly rather than `uv run` so that the +# project's pinned Python 3.11 is used regardless of the user's shell +# environment. UV_PYTHON / VIRTUAL_ENV pointing elsewhere (3.12 / 3.14 +# system pythons) is common on multi-project machines and would cause +# `uv run` to error out with "incompatible interpreter". +WOLFBOT_BIN="${REPO_ROOT}/.venv/bin/wolfbot" +WOLFBOT_NPC_BIN="${REPO_ROOT}/.venv/bin/wolfbot-npc" + +# ─── mock-mode env injection ────────────────────────────────────────────── +# Built once, then prefixed to every bot's command line so it shows up as +# real OS env at process start. pydantic-settings reads OS env *before* +# the .env file, so these values cleanly override whatever the user has +# in .env.master / envs/npc/.env.. +MOCK_ENV_PREFIX="" +if [[ "${MOCK_MODE}" == "1" ]]; then + # Each variable is double-quoted so a value with shell-special chars + # would still be safe — there's nothing to interpolate today, but + # this keeps the pattern future-proof. + MOCK_ENV_PREFIX="\ +GAMEPLAY_LLM_PROVIDER='mock' \ +NPC_LLM_PROVIDER='mock' \ +WOLFBOT_PHASE_DURATION_FACTOR='0.1' " + echo "Mock mode ON — injecting GAMEPLAY_LLM_PROVIDER=mock, NPC_LLM_PROVIDER=mock, WOLFBOT_PHASE_DURATION_FACTOR=0.1" +fi + +# ─── work out which NPC personas to launch ──────────────────────────────── +declare -a PERSONAS=() +if [[ $# -gt 0 ]]; then + PERSONAS=("$@") +else + # Auto-detect from envs/npc/.env. (real, non-example files only). + while IFS= read -r -d '' env_file; do + base="$(basename "${env_file}")" + # Strip the `.env.` prefix. Skip *.example templates. + persona="${base#.env.}" + [[ "${persona}" == *.example ]] && continue + PERSONAS+=("${persona}") + done < <(find "${REPO_ROOT}/envs/npc" -maxdepth 1 -type f -name '.env.*' -print0 | sort -z) +fi + +if [[ ${#PERSONAS[@]} -eq 0 ]]; then + echo "ERROR: no NPC env files found under envs/npc/." + echo " Copy a template (e.g. envs/npc/.env.setsu.example → envs/npc/.env.setsu)," + echo " fill the secrets, and rerun." + echo " Or pass persona keys explicitly: scripts/run-bots.sh setsu gina sq" + exit 1 +fi + +# Validate that every requested persona has a real env file. +for persona in "${PERSONAS[@]}"; do + env_path="envs/npc/.env.${persona}" + if [[ ! -f "${env_path}" ]]; then + echo "ERROR: ${env_path} not found." + echo " cp envs/npc/.env.${persona}.example envs/npc/.env.${persona}" + echo " and fill in the secrets, then rerun." + exit 1 + fi +done + +# ─── handle existing session ────────────────────────────────────────────── +if tmux has-session -t "${SESSION}" 2>/dev/null; then + if [[ "${FORCE:-0}" == "1" ]]; then + echo "Killing existing tmux session '${SESSION}' (FORCE=1)." + tmux kill-session -t "${SESSION}" + else + echo "tmux session '${SESSION}' is already running." + echo "Attach with: tmux attach -t ${SESSION}" + echo "Recreate with: FORCE=1 scripts/run-bots.sh" + exit 0 + fi +fi + +# ─── helper to wrap a long-running command with logging + hold-on-exit ──── +# `tmux send-keys` is used so each window can be re-entered later. The +# trailing `; echo …; exec bash` keeps the pane open after the process +# exits, surfacing the failure log so the user can read it before manual +# teardown. +launch_in_window() { + local window_name="$1" + local log_file="$2" + local cmd="$3" + local create_new="${4:-yes}" + + if [[ "${create_new}" == "yes" ]]; then + tmux new-window -t "${SESSION}" -n "${window_name}" -c "${REPO_ROOT}" + else + tmux rename-window -t "${SESSION}:0" "${window_name}" + fi + # `2>&1 | tee` so logs stream both to the pane and the file. + tmux send-keys -t "${SESSION}:${window_name}" \ + "echo '── ${window_name} starting at $(date) ──' && ${cmd} 2>&1 | tee '${log_file}'; echo; echo '── ${window_name} exited (rc=${PIPESTATUS[0]:-?}) ──'; exec bash" \ + C-m +} + +# ─── create the session, with the master in window 0 ────────────────────── +echo "Starting tmux session '${SESSION}' with 1 master + ${#PERSONAS[@]} NPC bot(s)..." +tmux new-session -d -s "${SESSION}" -c "${REPO_ROOT}" + +launch_in_window "master" "${LOG_DIR}/master.log" "${MOCK_ENV_PREFIX}'${WOLFBOT_BIN}'" "no" + +# Wait for Master to announce its WebSocket bind in the log. Polling with +# a TCP probe (e.g. /dev/tcp) would write bytes to the socket and trigger +# spurious EOFError tracebacks on the websockets server side; tailing the +# log is non-intrusive. Discord login + VC join takes ~5-10s, and any NPC +# bot that tries to connect before that gets ConnectionRefusedError +# without auto-retry, so we serialize the dependency here. +echo "Waiting for Master to announce 'master_ws_listening' (up to 60s)..." +WS_READY=0 +for _ in $(seq 1 60); do + if grep -q "master_ws_listening" "${LOG_DIR}/master.log" 2>/dev/null; then + WS_READY=1 + break + fi + sleep 1 +done +if [[ "${WS_READY}" != "1" ]]; then + echo "WARNING: Master did not announce WS readiness within 60s." + echo " Check 'tail -f ${LOG_DIR}/master.log' for errors before launching NPCs." + echo " NPC bots will still be started but may need manual restart after Master." +else + echo "Master WS is ready." +fi + +for persona in "${PERSONAS[@]}"; do + launch_in_window \ + "${persona}" \ + "${LOG_DIR}/${persona}.log" \ + "${MOCK_ENV_PREFIX}WOLFBOT_NPC_ENV=envs/npc/.env.${persona} '${WOLFBOT_NPC_BIN}'" + # Small stagger so 9 NPCs don't all finish Discord login and slam + # Master's WS accept() in the same instant. Master + the NPC retry + # loop both tolerate concurrency, but staggering avoids the easy + # ECONNREFUSED race entirely. + sleep 0.4 +done + +# Land on the master window when the user attaches. +tmux select-window -t "${SESSION}:master" + +cat <.log + +Attach: tmux attach -t ${SESSION} +List windows: tmux list-windows -t ${SESSION} +Stop all: scripts/stop-bots.sh + +NPCs (${#PERSONAS[@]}): +EOF +for persona in "${PERSONAS[@]}"; do + echo " - ${persona}" +done diff --git a/scripts/stop-bots.sh b/scripts/stop-bots.sh new file mode 100755 index 0000000..310e5b5 --- /dev/null +++ b/scripts/stop-bots.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Cleanly tear down the wolfbot tmux session created by run-bots.sh. +# +# Each window holds two processes once the bot exits: the bot itself and +# the `exec bash` we left running so the log stays readable. Killing the +# session takes both down in one go. + +set -euo pipefail + +SESSION="${WOLFBOT_TMUX_SESSION:-wolfbot}" + +if ! command -v tmux >/dev/null 2>&1; then + echo "ERROR: tmux is not installed." + exit 1 +fi + +if tmux has-session -t "${SESSION}" 2>/dev/null; then + echo "Stopping tmux session '${SESSION}'..." + tmux kill-session -t "${SESSION}" + echo "Done." +else + echo "No tmux session named '${SESSION}' is running." +fi diff --git a/src/wolfbot/config.py b/src/wolfbot/config.py index 020277b..e65fff3 100644 --- a/src/wolfbot/config.py +++ b/src/wolfbot/config.py @@ -1,6 +1,8 @@ -"""Environment-driven settings. +"""Master-process settings. -Loaded once at startup from `.env` via pydantic_settings. +Loaded once at startup from ``.env.master`` via pydantic-settings. + +NPC bot worker settings live in :mod:`wolfbot.npc.runtime.config`. """ from __future__ import annotations @@ -10,9 +12,56 @@ from pydantic import SecretStr, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from wolfbot.llm.decider_config import LLMDeciderConfig, LLMProvider + +# Pipeline-shape literals ──────────────────────────────────────────────── +# Kept at module scope because they participate in cross-field validators +# and the test suite imports them directly when constructing minimal +# Settings instances. + +VoicePipeline = Literal["audio_analyzer", "stt_then_text_analyzer", "disabled"] +"""How human voice is converted into a structured ``SpeechEvent``: + +* ``audio_analyzer`` — single multimodal call (audio → transcript + + structured fields). Currently only Gemini Flash supports this shape. +* ``stt_then_text_analyzer`` — two-step pipeline. ``Stt`` transcribes + audio (Groq Whisper). ``TextAnalyzer`` extracts structured fields from + the transcript. The same ``TextAnalyzer`` instance is shared with the + text channel path so they hit one credential pool. +* ``disabled`` — Master does not join VC, no voice ingest. Used by + ``rounds`` mode and by reactive_voice setups that only have NPCs + speaking (no human voice). +""" + +TextPipeline = Literal["text_analyzer", "passthrough"] +"""How a typed text-channel utterance becomes a ``SpeechEvent``: + +* ``text_analyzer`` — one ``TextAnalyzer`` call per typed message + extracts ``addressed_name`` / ``co_claim`` / ``role_callout`` so + ``SpeakArbiter`` can route NPC responses to the addressed seat. +* ``passthrough`` — typed messages produce ``SpeechEvent`` rows with + all structured fields ``None``. ``SpeakArbiter`` falls back to LRU + rotation only. +""" + +AudioAnalyzerProvider = Literal["gemini"] +"""Backends that accept audio input and return transcript + structured +analysis in one hop. Only Gemini Flash for now (xAI / Groq don't expose +audio-in chat-completion endpoints).""" + +SttProvider = Literal["groq"] +"""Backends that transcribe audio to text only. Groq Whisper for now.""" + +TextAnalyzerProvider = Literal["xai", "deepseek", "openai", "gemini"] +"""Backends that accept text input and return structured analysis JSON. +``xai``/``deepseek``/``openai`` use the shared OpenAI Chat Completions +schema; ``gemini`` uses the AI Studio REST API.""" -class Settings(BaseSettings): - model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + +class MasterSettings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env.master", env_file_encoding="utf-8", extra="ignore" + ) DISCORD_TOKEN: SecretStr DISCORD_GUILD_ID: int @@ -20,31 +69,243 @@ class Settings(BaseSettings): MAIN_VOICE_CHANNEL_ID: int WOLFBOT_DB_PATH: str = "./wolfbot.db" LOG_LEVEL: str = "INFO" + # Discussion mode applied to NEW games started under this process. + # Existing rows keep whichever mode was captured at their start time. + LLM_DISCUSSION_MODE: str = "rounds" + + # ── Gameplay LLM ─────────────────────────────────────────────────── + # The LLM that drives every gameplay decision Master makes on behalf + # of LLM-controlled seats: + # + # * Day-time votes (which seat to execute). + # * Night actions (wolf attack target, seer divine target, knight + # guard target). + # * Day-discussion text **in rounds mode** (Master generates the + # LLM seat's discussion turn directly). In reactive_voice mode + # the discussion turns are produced by NPC bot processes via + # `wolfbot.npc.*` and use NPC_LLM_* there — but votes / night + # actions still flow through this Gameplay LLM. + GAMEPLAY_LLM_PROVIDER: LLMProvider = "xai" + + # xAI / DeepSeek / any OpenAI-compatible endpoint. + GAMEPLAY_LLM_API_KEY: SecretStr | None = None + GAMEPLAY_LLM_MODEL: str = "grok-4-1-fast" + # Override the provider default base URL when pointing at a + # self-hosted OpenAI-compatible endpoint (vLLM, Ollama, ...). + GAMEPLAY_LLM_BASE_URL: str | None = None + + # DeepSeek-specific knobs. Ignored unless provider == "deepseek". + GAMEPLAY_LLM_THINKING: Literal["enabled", "disabled"] = "enabled" + GAMEPLAY_LLM_REASONING_EFFORT: Literal["high", "max"] = "max" + + # Gemini Vertex AI specific. Authentication is ADC/IAM only + # (gcloud locally; attached service account in prod). Vertex AI + # Express mode and API-key auth are deliberately unsupported. + GAMEPLAY_LLM_VERTEX_PROJECT: str | None = None + GAMEPLAY_LLM_VERTEX_LOCATION: str = "global" + GAMEPLAY_LLM_THINKING_LEVEL: Literal["minimal", "low", "medium", "high"] = "high" - LLM_PROVIDER: Literal["xai", "deepseek", "gemini"] = "xai" + # ── Master ↔ NPC bot / voice-ingest WebSocket transport ─────────── + MASTER_WS_LISTEN: str = "127.0.0.1:8800" + MASTER_NPC_PSK: SecretStr | None = None - XAI_API_KEY: SecretStr | None = None - XAI_MODEL: str = "grok-4-1-fast" + # ── Voice / Text ingest pipeline shapes ──────────────────────────── + # Two orthogonal switches define the structural shape of human-input + # ingestion. Each pipeline is composed from independent components + # (AudioAnalyzer / Stt / TextAnalyzer) configured below. Defaults are + # ``disabled``/``passthrough`` so a fresh checkout boots without + # touching VC or burning analyzer tokens; reactive_voice operators + # opt in explicitly. + VOICE_PIPELINE: VoicePipeline = "disabled" + TEXT_PIPELINE: TextPipeline = "passthrough" - DEEPSEEK_API_KEY: SecretStr | None = None - DEEPSEEK_BASE_URL: str = "https://api.deepseek.com" - DEEPSEEK_MODEL: str = "deepseek-v4-flash" - DEEPSEEK_THINKING: Literal["enabled", "disabled"] = "enabled" - DEEPSEEK_REASONING_EFFORT: Literal["high", "max"] = "max" + # ── AudioAnalyzer (audio → transcript + structured) ─────────────── + # Used only when ``VOICE_PIPELINE=audio_analyzer``. Single multimodal + # call: cheap and lowest-latency, but each game segment shares the + # same RPM bucket as any text-channel call that piggy-backs on the + # same key — keep the credential isolated unless you intentionally + # want shared throttling. + AUDIO_ANALYZER_PROVIDER: AudioAnalyzerProvider = "gemini" + AUDIO_ANALYZER_API_KEY: SecretStr | None = None + AUDIO_ANALYZER_MODEL: str = "gemini-2.0-flash-lite" - GEMINI_VERTEX_PROJECT: str | None = None - GEMINI_VERTEX_LOCATION: str = "global" - GEMINI_MODEL: str = "gemini-3-flash-preview" - GEMINI_THINKING_LEVEL: Literal["minimal", "low", "medium", "high"] = "high" + # ── Stt (audio → transcript) ────────────────────────────────────── + # Used only when ``VOICE_PIPELINE=stt_then_text_analyzer``. Pairs + # with the ``TEXT_ANALYZER_*`` block to form a two-step voice path. + # ``whisper-large-v3-turbo`` is the cheapest multilingual Whisper + # variant on Groq that still handles Japanese well; switch to + # ``whisper-large-v3`` for max accuracy at ~3x the cost. + STT_PROVIDER: SttProvider = "groq" + STT_API_KEY: SecretStr | None = None + STT_MODEL: str = "whisper-large-v3-turbo" + STT_BASE_URL: str = "https://api.groq.com/openai/v1" + + # ── TextAnalyzer (text → structured) ────────────────────────────── + # Used by ``TEXT_PIPELINE=text_analyzer`` (per typed message) AND by + # ``VOICE_PIPELINE=stt_then_text_analyzer`` (per voice segment, after + # STT). Same component, single credential pool, deliberately + # decoupled from ``GAMEPLAY_LLM_*`` so Gameplay can target a + # heavyweight reasoning model while text analysis hits a cheap one. + # + # Provider gating: + # * ``xai`` / ``deepseek`` / ``openai`` use the shared OpenAI Chat + # Completions schema and require ``TEXT_ANALYZER_API_KEY``. + # * ``gemini`` uses the AI Studio REST API and requires + # ``TEXT_ANALYZER_API_KEY`` (an AIza-format key). Vertex auth is + # not supported on this path because the analyzer uses the + # ``v1beta/models/{m}:generateContent?key=...`` shape. + TEXT_ANALYZER_PROVIDER: TextAnalyzerProvider = "xai" + TEXT_ANALYZER_API_KEY: SecretStr | None = None + TEXT_ANALYZER_MODEL: str = "grok-4-1-fast" + # Optional override for OpenAI-compatible endpoints (vLLM, Ollama, + # etc.). Empty / None → provider default base URL. + TEXT_ANALYZER_BASE_URL: str | None = None + + # ── Pre-STT silence gate ────────────────────────────────────────── + # Discord's speaking-start fires on any audio above a low threshold + # (breathing, keyboard, room hum). With ``SilenceGeneratorSink`` + # padding, this would burn one Stt + one TextAnalyzer call for + # every such non-speech burst. The gate suppresses the STT call + # (and emits ``stt_failed reason=pre_stt_silence_gate`` so the + # arbiter still finalises the segment) when the buffer is too + # short or too quiet to plausibly contain speech. Tuned from + # voicetest measurements: pure noise sits at RMS 0-100, faint / + # distant speech 200-500, normal talking 1000+. + # Set ``VOICE_PRE_STT_MIN_RMS=0`` to disable the gate entirely. + VOICE_PRE_STT_MIN_RMS: int = 200 + VOICE_PRE_STT_MIN_DURATION_MS: int = 300 + + # ── Master TTS narration (reactive_voice only) ──────────────────── + # When `LLM_DISCUSSION_MODE=reactive_voice` and Master is in VC, + # phase-transition announcements (PHASE_CHANGE / MORNING / VICTORY + # / EXECUTION headlines) are read aloud by Master via VOICEVOX. Long + # content (vote tallies, role reveal) goes to the VC's text chat. + # Default speaker 47 = ナースロボ_タイプT (machine-like polite). + MASTER_TTS_VOICE_ID: int = 47 + # Reuse the NPC-side VOICEVOX HTTP engine. Single shared local engine + # is the typical setup; override only if Master needs a different one. + MASTER_VOICEVOX_URL: str = "http://localhost:50021" + + @model_validator(mode="after") + def _require_gameplay_provider_key(self) -> MasterSettings: + if self.GAMEPLAY_LLM_PROVIDER in ("xai", "deepseek") and self.GAMEPLAY_LLM_API_KEY is None: + raise ValueError( + f"GAMEPLAY_LLM_PROVIDER={self.GAMEPLAY_LLM_PROVIDER} " + "requires GAMEPLAY_LLM_API_KEY to be set" + ) + # Gemini supports two auth modes: + # * Vertex AI via ADC + GAMEPLAY_LLM_VERTEX_PROJECT + # * Google AI Studio via GAMEPLAY_LLM_API_KEY (AIza... format) + # At least one must be set; if both are set, Vertex wins + # (production deployments rely on attached-SA credentials). + if ( + self.GAMEPLAY_LLM_PROVIDER == "gemini" + and not self.GAMEPLAY_LLM_VERTEX_PROJECT + and self.GAMEPLAY_LLM_API_KEY is None + ): + raise ValueError( + "GAMEPLAY_LLM_PROVIDER=gemini requires either " + "GAMEPLAY_LLM_VERTEX_PROJECT (Vertex AI / ADC) or " + "GAMEPLAY_LLM_API_KEY (Google AI Studio)" + ) + return self @model_validator(mode="after") - def _require_provider_key(self) -> Settings: - if self.LLM_PROVIDER == "xai" and self.XAI_API_KEY is None: - raise ValueError("LLM_PROVIDER=xai requires XAI_API_KEY to be set") - if self.LLM_PROVIDER == "deepseek" and self.DEEPSEEK_API_KEY is None: - raise ValueError("LLM_PROVIDER=deepseek requires DEEPSEEK_API_KEY to be set") - if self.LLM_PROVIDER == "gemini" and not self.GEMINI_VERTEX_PROJECT: + def _require_pipeline_components(self) -> MasterSettings: + """Enforce credential presence given the chosen pipeline shape. + + Each component check fires only when the pipeline actually + instantiates that component. Operators with + ``VOICE_PIPELINE=disabled`` and ``TEXT_PIPELINE=passthrough`` + boot without any analyzer credential at all (rounds-mode + default). + """ + # AudioAnalyzer is required only by the multimodal voice path. + if ( + self.VOICE_PIPELINE == "audio_analyzer" + and self.AUDIO_ANALYZER_API_KEY is None + ): raise ValueError( - "LLM_PROVIDER=gemini requires GEMINI_VERTEX_PROJECT to be set" + "VOICE_PIPELINE=audio_analyzer requires " + "AUDIO_ANALYZER_API_KEY to be set" ) + + # Stt is required only by the split voice path. + if ( + self.VOICE_PIPELINE == "stt_then_text_analyzer" + and self.STT_API_KEY is None + ): + raise ValueError( + "VOICE_PIPELINE=stt_then_text_analyzer requires " + "STT_API_KEY to be set" + ) + + # TextAnalyzer is required by EITHER the text path OR the split + # voice path (which composes Stt + TextAnalyzer). + text_analyzer_required = ( + self.TEXT_PIPELINE == "text_analyzer" + or self.VOICE_PIPELINE == "stt_then_text_analyzer" + ) + if text_analyzer_required and self.TEXT_ANALYZER_API_KEY is None: + raise ValueError( + "TEXT_ANALYZER_API_KEY must be set when " + "TEXT_PIPELINE=text_analyzer or " + "VOICE_PIPELINE=stt_then_text_analyzer" + ) + + # The split voice path's analyzer step calls an OpenAI-compatible + # /chat/completions endpoint (see GroqWhisperAudioAnalyzer). The + # AI Studio REST shape used by the gemini text analyzer doesn't + # fit, so block the combo at boot rather than failing per + # segment. + if ( + self.VOICE_PIPELINE == "stt_then_text_analyzer" + and self.TEXT_ANALYZER_PROVIDER == "gemini" + ): + raise ValueError( + "VOICE_PIPELINE=stt_then_text_analyzer composes Stt with " + "an OpenAI-compatible TextAnalyzer; TEXT_ANALYZER_PROVIDER=" + "gemini is not supported on that path. Either set " + "TEXT_ANALYZER_PROVIDER to xai/deepseek/openai or switch " + "VOICE_PIPELINE to audio_analyzer." + ) + return self + + def apply_phase_durations(self) -> None: + """Initialize the global :class:`PhaseDurations` singleton from env. + + Called once during boot from :mod:`wolfbot.main` after the + Settings instance is constructed. Subsequent runtime mutations + (a future ``/wolf settings duration_factor ...`` slash command, + for example) should call :func:`set_phase_durations` directly + rather than re-running this method, so the env-derived values + don't accidentally clobber a UI-driven override. + + See :class:`wolfbot.domain.durations.PhaseDurations.from_env` + for the env-var contract (``WOLFBOT_PHASE_DURATION_FACTOR`` plus + per-phase ``WOLFBOT_*_DURATION`` overrides). + """ + from wolfbot.domain.durations import ( + PhaseDurations, + set_phase_durations, + ) + + set_phase_durations(PhaseDurations.from_env()) + + def gameplay_decider_config(self, *, timeout: float = 30.0) -> LLMDeciderConfig: + """Project this Settings instance onto the provider-agnostic + ``LLMDeciderConfig`` consumed by the decider factory. + """ + return LLMDeciderConfig( + provider=self.GAMEPLAY_LLM_PROVIDER, + api_key=self.GAMEPLAY_LLM_API_KEY, + model=self.GAMEPLAY_LLM_MODEL, + base_url=self.GAMEPLAY_LLM_BASE_URL, + thinking=self.GAMEPLAY_LLM_THINKING, + reasoning_effort=self.GAMEPLAY_LLM_REASONING_EFFORT, + vertex_project=self.GAMEPLAY_LLM_VERTEX_PROJECT, + vertex_location=self.GAMEPLAY_LLM_VERTEX_LOCATION, + thinking_level=self.GAMEPLAY_LLM_THINKING_LEVEL, + timeout=timeout, + ) diff --git a/src/wolfbot/domain/discussion.py b/src/wolfbot/domain/discussion.py new file mode 100644 index 0000000..0aab1a0 --- /dev/null +++ b/src/wolfbot/domain/discussion.py @@ -0,0 +1,284 @@ +"""Public discussion domain — SpeechEvent + PublicDiscussionState. + +Frozen Pydantic models that describe public utterances (human voice via STT, +human text, NPC-generated text) plus a sentinel `phase_baseline` row that +seeds the alive-seat baseline at the start of every public speech phase. + +Pure: no I/O, no asyncio. + +The `SpeechEvent` row is the unified public-utterance contract. Both +`LLM_DISCUSSION_MODE` modes (`rounds` and `reactive_voice`) write SpeechEvent +rows for every utterance, so persistence and downstream telemetry are mode- +independent. The `phase_baseline` sentinel makes the `PublicDiscussionState` +fold self-contained — the rebuild path reads only `speech_events`, never the +`seats` table — which is critical on Master restart. +""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field + +from wolfbot.domain.enums import Phase + + +class SpeechSource(StrEnum): + """Origin of a `SpeechEvent` row. + + - `voice_stt`: human voice transcribed by voice-ingest's STT. + - `text`: human Discord text message captured by Master's main-channel listener. + - `npc_generated`: utterance produced by an LLM-controlled NPC seat. + - `phase_baseline`: sentinel inserted at the start of every public speech phase + to record the alive-seat set; consumed by `PublicDiscussionState` rebuild. + Excluded from public-log emission, channel posts, and downstream counts. + """ + + VOICE_STT = "voice_stt" + TEXT = "text" + NPC_GENERATED = "npc_generated" + PHASE_BASELINE = "phase_baseline" + + +class SpeakerKind(StrEnum): + HUMAN = "human" + NPC = "npc" + SYSTEM = "system" # only used by the phase_baseline sentinel + + +class SpeechEvent(BaseModel): + """A public utterance recorded by Master. + + Frozen (`ConfigDict(frozen=True)`) — never mutate after construction. Persisted + one-to-one with rows in the `speech_events` table. + + Fields specific to each `source`: + - `voice_stt`: `stt_confidence`, `audio_start_ms`, `audio_end_ms` populated; + `alive_seat_nos_json` NULL. + - `text`: `stt_confidence` / `audio_*` NULL; `alive_seat_nos_json` NULL. + - `npc_generated`: `stt_confidence` / `audio_*` NULL; `alive_seat_nos_json` NULL. + - `phase_baseline`: `text` is "" or a short marker; `speaker_seat` is None; + `speaker_kind = SpeakerKind.SYSTEM`; `alive_seat_nos_json` + populated with the JSON-encoded list of alive seat numbers. + """ + + model_config = ConfigDict(frozen=True) + + event_id: str + game_id: str + phase_id: str + day: int = Field(ge=0) + phase: Phase + source: SpeechSource + speaker_kind: SpeakerKind + speaker_seat: int | None = Field(default=None, ge=1, le=9) + text: str + stt_confidence: float | None = None + audio_start_ms: int | None = None + audio_end_ms: int | None = None + summary: str | None = None + alive_seat_nos_json: str | None = None + co_declaration: str | None = Field( + default=None, + description=( + "Structured CO self-declaration tag (`seer` / `medium` / `knight`) " + "extracted at the source: schema field for NPC/LLM speech, " + "Gemini's `co_claim` for human voice. Authoritative when set; " + "legacy events fall back to `_CO_MARKERS` substring scan." + ), + ) + addressed_seat_no: int | None = Field( + default=None, + ge=1, + le=9, + description=( + "Legacy single-addressee field. When ``addressed_seat_nos`` is " + "set this mirrors its first element so older readers (DB row " + "writers, restart recovery for pre-multi-address events) keep " + "working. Prefer ``addressed_seat_nos`` for new code." + ), + ) + addressed_seat_nos: tuple[int, ...] = Field( + default_factory=tuple, + description=( + "All seats this utterance is addressed to. SpeakArbiter " + "prioritises every seat in the set on the next dispatch and " + "consumes them one-by-one as each addressee replies. Empty " + "for general remarks. The legacy `addressed_seat_no` field " + "carries the first element for back-compat with code that " + "only knows the singular form. Use " + ":func:`event_addressed_seats` rather than reading either " + "field directly so the singular-only fallback is applied " + "consistently." + ), + ) + role_callout: str | None = Field( + default=None, + description=( + "Role the utterance is calling out for " + "('占い師の方どうぞ' → 'seer'). Stored alongside the speech so " + "the arbiter and NPC prompt builders can surface a 'pending " + "role callout' to every NPC: real role holders take it as a " + "CO trigger, wolf-side NPCs take it as a chance to fake CO. " + "Distinct from `co_declaration` (= self-declaration). Values " + "are the canonical CoDeclaration enum strings." + ), + ) + claimed_seer_target_seat: int | None = Field( + default=None, + ge=1, + le=9, + description=( + "Target seat the speaker claimed to have divined in this " + "utterance — *real* (true seer) or *fake* (wolf/madman). Set " + "iff the speech announces a NEW divination outcome; null " + "otherwise (general remarks, mere references to prior " + "results). Pairs with ``claimed_seer_is_wolf``. " + "Authoritative source for the per-seat claim history that " + "Master folds back into every subsequent prompt." + ), + ) + claimed_seer_is_wolf: bool | None = Field( + default=None, + description=( + "Black/white verdict the speaker attached to " + "``claimed_seer_target_seat``. None when no seer claim was " + "made this utterance." + ), + ) + claimed_medium_target_seat: int | None = Field( + default=None, + ge=1, + le=9, + description=( + "Target seat (= yesterday's executed seat) the speaker " + "claimed a medium result for. Mirror of the seer fields for " + "medium-CO." + ), + ) + claimed_medium_is_wolf: bool | None = Field( + default=None, + description=( + "Black/white verdict for the medium claim. None when " + "``claimed_medium_target_seat`` is None *or* when the " + "speaker explicitly declared 'no execution yesterday → no " + "result'." + ), + ) + created_at_ms: int + + def is_baseline(self) -> bool: + return self.source == SpeechSource.PHASE_BASELINE + + +def event_addressed_seats(event: SpeechEvent) -> tuple[int, ...]: + """Return the canonical ordered list of addressees for a SpeechEvent. + + Single source of truth: prefer ``addressed_seat_nos`` (the new + multi-addressee field) and fall back to wrapping the legacy + ``addressed_seat_no`` in a 1-tuple so test fixtures and older + callers that only set the singular field still feed the fold + correctly. Returns ``()`` when neither is set. + """ + if event.addressed_seat_nos: + return event.addressed_seat_nos + if event.addressed_seat_no is not None: + return (event.addressed_seat_no,) + return () + + +def make_phase_id(game_id: str, day: int, phase: Phase, sequence: int = 1) -> str: + """Canonical `phase_id` format used across the master / voice-ingest / NPC bots. + + Example: ``g_abc::day1::DAY_DISCUSSION::1``. The `sequence` lets a single + `(game, day, phase)` triple have multiple phase_ids if a runoff causes the + discussion phase to be re-entered conceptually. + """ + return f"{game_id}::day{day}::{phase.value}::{sequence}" + + +class CoClaim(BaseModel): + """A single CO-style self-declaration captured from `SpeechEvent.text`. + + The `role_claim` follows canonical role keywords (`seer`, `medium`, `knight`). + """ + + model_config = ConfigDict(frozen=True) + + seat: int = Field(ge=1, le=9) + role_claim: str + declared_at_event_id: str + + +class PublicDiscussionState(BaseModel): + """Code-derived view of the live public discussion. + + Built deterministically from `SpeechEvent` rows of a single `phase_id`. + Never written to disk in MVP; rebuilt from the event log on Master restart. + The `phase_baseline` sentinel provides the `alive_seat_nos` baseline that + `silent_seats` is computed against — so the fold needs no access to the + `seats` table. + """ + + model_config = ConfigDict(frozen=False) + + game_id: str + phase_id: str + day: int + alive_seat_nos: frozenset[int] = frozenset() + co_claims: tuple[CoClaim, ...] = () + stances: dict[int, dict[int, float]] = Field(default_factory=dict) + pressure: dict[int, float] = Field(default_factory=dict) + open_topics: tuple[str, ...] = () + silent_seats: frozenset[int] = frozenset() + recent_speech_event_ids: tuple[str, ...] = () + last_addressed_seat: int | None = None + last_addressed_seats: frozenset[int] = frozenset() + last_addressed_speaker_seat: int | None = None + last_addressed_text: str = "" + # Most recent non-sentinel speech_event speaker. SpeakArbiter uses this + # as a de-prioritization signal for the *next* dispatch so that once + # `silent_seats` empties (= every alive NPC has spoken once), the + # rotation can't re-pick the just-finished speaker. Without this, the + # arbiter falls back to seat-number tiebreak and seat 1 monopolizes + # the rest of the phase. + last_speaker_seat: int | None = None + # Sliding window of `(speaker_seat, has_info)` for the most recent + # non-sentinel speech events in this phase. ``has_info`` is True when + # the event added structured information (currently: a CO declaration; + # future signals can be added without changing the field shape). + # SpeakArbiter consumes this to detect a low-information pair volley + # (e.g. ラキオ ↔ ジョナス pinging each other for several rounds without + # CO or new accusation target) and demote both seats so a third NPC + # gets picked instead. Capped at 6 entries so the window stays small + # but big enough to cover both the pair-volley check (last 4) and the + # consecutive-speaker cap (last 3). + recent_speech_summary: tuple[tuple[int, bool], ...] = () + # Set of role names ("seer" / "medium" / "knight") that some speaker + # has explicitly called for in this phase but no one has CO'd as yet. + # Cleared when a matching CO arrives (= the call was answered) and + # reset per phase. Surfaces in the NPC prompt so real role holders + # take it as a CO trigger and wolf-side NPCs can decide whether to + # fake CO. + pending_role_callouts: frozenset[str] = frozenset() + # Roles whose *first ever* CO just landed in this game. Triggers the + # arbiter's counter-CO opportunity pool: the next dispatches go to + # the real role-holder (when uncpd) plus every wolf-side seat that + # hasn't claimed any info role yet, in random order, one chance + # each. Each member's reply (CO or skip) consumes their slot via + # the arbiter's `_callout_pool_asked` tracker. The pool exhausts + # when every member has been asked, after which normal priority + # resumes — without this window, the village-side first-CO often + # got "free real" treatment because no wolf had a guaranteed turn + # to contest it before the discussion drifted onto other topics. + pending_co_response: frozenset[str] = frozenset() + # Per-seat utterance count within this phase (non-baseline events + # only). The arbiter prefers seats with the lowest count so a + # talkative NPC doesn't monopolize the phase — the binary + # ``silent_seats`` was indistinguishable once every seat had spoken + # once, after which the rotation collapsed to seat-number tiebreak + # and the lowest-seat NPC kept winning. ``speech_counts`` extends + # that signal across the entire phase so wolf-side NPCs at higher + # seat numbers still get fair speaking turns (= more chances to + # fake-CO). + speech_counts: dict[int, int] = Field(default_factory=dict) diff --git a/src/wolfbot/domain/durations.py b/src/wolfbot/domain/durations.py new file mode 100644 index 0000000..207862e --- /dev/null +++ b/src/wolfbot/domain/durations.py @@ -0,0 +1,224 @@ +"""Phase-duration configuration — runtime-mutable singleton. + +The wolf game has 5 fixed-length phases plus 3 day-numbered discussion +durations. Historically these were module-level ``int`` constants in +:mod:`wolfbot.domain.state_machine` and a function in +:mod:`wolfbot.domain.rules`. Both still exist as backwards-compatible +re-exports (state_machine.py uses :pep:`562` ``__getattr__`` to lazy- +read the singleton; ``rules.day_discussion_duration`` delegates here). + +This module is the single source of truth at runtime: + + - :class:`PhaseDurations` is a frozen dataclass — immutable values. + - The process holds one *current* :class:`PhaseDurations` instance + in this module's private ``_current`` slot. + - :func:`current_phase_durations` returns the current instance. + - :func:`set_phase_durations` swaps the slot atomically. + - :func:`reset_phase_durations_to_defaults` is a test convenience. + +The singleton design is intentional: phase durations are a process-wide +operational knob, not per-game state. One Master process serves one +guild's worth of games, and an admin who runs +``/wolf settings duration_factor 0.5`` would expect *all* future phase +deadlines to use the new value — including ones in already-running +games. The current phase's ``deadline_epoch`` (already written to the +DB) is untouched; the new value applies on the next phase transition. +For per-game overrides (a richer model that would let two games on the +same Master use different durations) a future change can add a +``phase_durations`` JSON column to the ``games`` table and have +``plan_*`` functions prefer that over the singleton — the dataclass is +designed so a row's value can be carried alongside. + +Usage: + + # at boot (main.py) + settings.apply_phase_durations() + + # in plan_*() (state_machine.py) + new_deadline_epoch=now_epoch + current_phase_durations().vote + + # future UI command (slash command handler) + set_phase_durations(replace(current_phase_durations(), vote=15)) +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, replace + + +@dataclass(frozen=True) +class PhaseDurations: + """Seconds for each phase deadline. Frozen — swap, don't mutate. + + Defaults match the historical hardcoded values from + :mod:`wolfbot.domain.state_machine` and + :func:`wolfbot.domain.rules.day_discussion_duration`. Tests assert + against these defaults, so a new field's default must keep that + equivalence to avoid silent breakage. + """ + + vote: int = 60 + runoff: int = 60 + night: int = 90 + day_discussion_grace: int = 30 + runoff_speech_grace: int = 30 + discussion_day1: int = 300 + discussion_day2: int = 240 + discussion_day3plus: int = 180 + + def discussion_for_day(self, day_number: int) -> int: + """Length of the discussion phase for ``day_number``. + + Mirrors the historical + :func:`wolfbot.domain.rules.day_discussion_duration` logic. + """ + if day_number <= 0: + raise ValueError("day_number must be >= 1 for discussion") + if day_number == 1: + return self.discussion_day1 + if day_number == 2: + return self.discussion_day2 + return self.discussion_day3plus + + def with_factor(self, factor: float) -> PhaseDurations: + """Return a new :class:`PhaseDurations` with every value scaled + by ``factor``, clamped to a minimum of 1 second per phase. + + The clamp matters in mock / fast-test mode: a factor of 0.01 + applied to a 60-second VOTE_DURATION rounds to 0, which would + make ``deadline_epoch == now`` and the engine's deadline-watcher + would advance immediately, sometimes before the LLM submission + loop can even fire. 1 second is short enough for fast iteration + but still gives the loop a tick. + """ + if factor <= 0: + raise ValueError("factor must be > 0") + + def _scale(v: int) -> int: + return max(1, round(v * factor)) + + return PhaseDurations( + vote=_scale(self.vote), + runoff=_scale(self.runoff), + night=_scale(self.night), + day_discussion_grace=_scale(self.day_discussion_grace), + runoff_speech_grace=_scale(self.runoff_speech_grace), + discussion_day1=_scale(self.discussion_day1), + discussion_day2=_scale(self.discussion_day2), + discussion_day3plus=_scale(self.discussion_day3plus), + ) + + @classmethod + def from_env( + cls, + env: dict[str, str] | None = None, + ) -> PhaseDurations: + """Build a :class:`PhaseDurations` from environment variables. + + Resolution order, applied in this sequence so individual + overrides win against the global factor: + + 1. Start with the dataclass defaults. + 2. If ``WOLFBOT_PHASE_DURATION_FACTOR`` is set, scale all + values by it (a single knob for "make everything N times + faster" — the typical mock / fast-test use case). + 3. Per-phase overrides apply absolute values, replacing + whatever the factor produced: + - ``WOLFBOT_VOTE_DURATION`` + - ``WOLFBOT_RUNOFF_DURATION`` + - ``WOLFBOT_NIGHT_DURATION`` + - ``WOLFBOT_DAY_DISCUSSION_GRACE`` + - ``WOLFBOT_RUNOFF_SPEECH_GRACE`` + - ``WOLFBOT_DISCUSSION_DAY1`` + - ``WOLFBOT_DISCUSSION_DAY2`` + - ``WOLFBOT_DISCUSSION_DAY3PLUS`` + + Each override must parse as a positive integer (seconds); a + non-positive value or unparseable string raises ``ValueError`` + at load time so config errors fail fast at boot. + """ + e = env if env is not None else os.environ + d = cls() + factor_raw = e.get("WOLFBOT_PHASE_DURATION_FACTOR") + if factor_raw is not None and factor_raw.strip(): + try: + factor = float(factor_raw) + except ValueError as exc: + raise ValueError( + f"WOLFBOT_PHASE_DURATION_FACTOR must be a number, got {factor_raw!r}" + ) from exc + d = d.with_factor(factor) + + overrides: dict[str, str] = { + "vote": "WOLFBOT_VOTE_DURATION", + "runoff": "WOLFBOT_RUNOFF_DURATION", + "night": "WOLFBOT_NIGHT_DURATION", + "day_discussion_grace": "WOLFBOT_DAY_DISCUSSION_GRACE", + "runoff_speech_grace": "WOLFBOT_RUNOFF_SPEECH_GRACE", + "discussion_day1": "WOLFBOT_DISCUSSION_DAY1", + "discussion_day2": "WOLFBOT_DISCUSSION_DAY2", + "discussion_day3plus": "WOLFBOT_DISCUSSION_DAY3PLUS", + } + applied: dict[str, int] = {} + for field_name, env_name in overrides.items(): + raw = e.get(env_name) + if raw is None or not raw.strip(): + continue + try: + value = int(raw) + except ValueError as exc: + raise ValueError( + f"{env_name} must be an integer, got {raw!r}" + ) from exc + if value <= 0: + raise ValueError(f"{env_name} must be > 0, got {value}") + applied[field_name] = value + if applied: + d = replace(d, **applied) + return d + + +_current: PhaseDurations = PhaseDurations() + + +def current_phase_durations() -> PhaseDurations: + """Return the active :class:`PhaseDurations` instance. + + This is the read API every ``plan_*`` transition function uses. + Reading is just a singleton-slot access — no I/O, deterministic. + """ + return _current + + +def set_phase_durations(durations: PhaseDurations) -> None: + """Swap the active :class:`PhaseDurations` instance. + + Called once at boot from + :meth:`wolfbot.config.MasterSettings.apply_phase_durations`, and + intended to be the exact same hook a future + ``/wolf settings duration_factor ...`` slash command would call to + flip the value at runtime. The change applies on the *next* phase + transition; the current phase's already-written ``deadline_epoch`` + is unchanged. + """ + global _current + _current = durations + + +def reset_phase_durations_to_defaults() -> None: + """Test convenience — restore the singleton to ``PhaseDurations()``. + + Tests that mutate the singleton must restore it afterward, otherwise + later tests in the same process inherit the override. + """ + global _current + _current = PhaseDurations() + + +__all__ = [ + "PhaseDurations", + "current_phase_durations", + "reset_phase_durations_to_defaults", + "set_phase_durations", +] diff --git a/src/wolfbot/domain/enums.py b/src/wolfbot/domain/enums.py index d4616f7..00c9326 100644 --- a/src/wolfbot/domain/enums.py +++ b/src/wolfbot/domain/enums.py @@ -6,6 +6,7 @@ from __future__ import annotations from enum import StrEnum +from typing import Literal class Role(StrEnum): @@ -110,3 +111,65 @@ class SubmitResult(StrEnum): VILLAGE_SIZE = 9 + + +# Roles that may openly claim themselves ("カミングアウト" / CO) during day +# discussion. Wolves and madmen routinely fake these, but they cannot CO +# their own true role (no one openly claims wolf/madman); villagers have +# no role power so a villager-CO is meaningless. The lowercased form is +# the wire/storage shape used by ``speech_events.co_declaration``, the +# voice/text analyzer prompts, and downstream Literal validators. +CO_CLAIMABLE_ROLES: tuple[Role, ...] = (Role.SEER, Role.MEDIUM, Role.KNIGHT) + + +def role_to_co_claim(role: Role) -> str: + """Wire form of a CO-claimable role (``Role.SEER`` → ``"seer"``).""" + return role.value.lower() + + +CO_CLAIM_VALUES: tuple[str, ...] = tuple( + role_to_co_claim(r) for r in CO_CLAIMABLE_ROLES +) + + +# Role-callout values are a SUPERSET of CO_CLAIM_VALUES — specifically +# they include the synthetic ``"info_request"`` token that the speech +# analyzers emit when a speaker generically asks for opinions / role +# holders without naming a specific role. Examples (from real game logs): +# - "誰か怪しい人いる?" +# - "みんな意見を聞かせて" +# - "気になる人を挙げて" +# - "誰か役職持ち、出てきて" +# The arbiter's role-callout priority pool treats ``"info_request"`` as +# "all info roles + all wolf-side" so the village can extract early +# information from a generic prompt, not only role-specific prompts. +ROLE_CALLOUT_VALUES: tuple[str, ...] = (*CO_CLAIM_VALUES, "info_request") + + +# Type alias for the wire/storage form. Cannot be derived from +# ``CO_CLAIM_VALUES`` because :class:`Literal` requires static values +# resolvable by the type-checker — ``Literal[*tuple]`` is not legal +# Python. Adding a CO-claimable role therefore requires updating BOTH +# :data:`CO_CLAIMABLE_ROLES` and this Literal in lockstep; the +# consistency test in ``tests/test_domain_co_claim.py`` asserts the two +# stay aligned so a mismatch is caught at CI time rather than at +# runtime when a mis-typed seat starts dropping CO declarations +# silently. +# +# Named ``CoDeclaration`` rather than ``CoClaim`` because +# :class:`wolfbot.domain.discussion.CoClaim` is already a Pydantic +# model representing a CO event in public-state rebuild — the two +# concepts are unrelated and the name collision would be confusing. +CoDeclaration = Literal["seer", "medium", "knight"] + + +def format_co_claim_options( + *, separator: str = "/", quote: str = '"' +) -> str: + """Render :data:`CO_CLAIM_VALUES` as a prompt-friendly enumeration. + + Default form ``"seer"/"medium"/"knight"`` matches the existing + Japanese analyzer prompts. Pass ``quote=""`` for a bare list + (``seer/medium/knight``). + """ + return separator.join(f"{quote}{v}{quote}" for v in CO_CLAIM_VALUES) diff --git a/src/wolfbot/domain/models.py b/src/wolfbot/domain/models.py index e8b3b4e..c2f1cb8 100644 --- a/src/wolfbot/domain/models.py +++ b/src/wolfbot/domain/models.py @@ -54,6 +54,10 @@ class Game(BaseModel): created_at: int ended_at: int | None = None force_skip_pending: bool = False + # discussion_mode is captured at game-start time from `Settings.LLM_DISCUSSION_MODE` + # and never mutated mid-game (per the day-discussion spec). Default `rounds` + # preserves backwards-compatible behavior for old rows that lack the column. + discussion_mode: str = "rounds" class NightAction(BaseModel): diff --git a/src/wolfbot/domain/rules.py b/src/wolfbot/domain/rules.py index 030edd2..7f52052 100644 --- a/src/wolfbot/domain/rules.py +++ b/src/wolfbot/domain/rules.py @@ -34,14 +34,17 @@ def assign_roles(seats: Sequence[Seat], rng: Random) -> dict[int, Role]: def day_discussion_duration(day_number: int) -> int: - """Seconds for the discussion phase. Day 1: 300, day 2: 240, day 3+: 180.""" - if day_number <= 0: - raise ValueError("day_number must be >= 1 for discussion") - if day_number == 1: - return 300 - if day_number == 2: - return 240 - return 180 + """Seconds for the discussion phase. Defaults: day 1: 300, day 2: + 240, day 3+: 180. + + Reads the live :class:`PhaseDurations` singleton — runtime-mutable + so a future ``/wolf settings`` slash command can change durations + without restarting Master. See :mod:`wolfbot.domain.durations` for + the full singleton contract. + """ + from wolfbot.domain.durations import current_phase_durations + + return current_phase_durations().discussion_for_day(day_number) def legal_attack_targets(players: Sequence[Player], actor_seat: int) -> list[int]: @@ -141,6 +144,7 @@ def resolve_wolf_attack( alive_wolf_seats: Sequence[int], force_skip: bool, human_wolf_seats: Sequence[int] = (), + rng: Random | None = None, ) -> AttackResult: """Determine the night's attack target per spec. @@ -151,8 +155,19 @@ def resolve_wolf_attack( - The `missing` tuple is always reported (even with force_skip) so logs can name who didn't submit. - When 2 wolves disagree, both submitted, and exactly one of them is a human - seat (per `human_wolf_seats`), the human's target wins (no split). All other - split shapes (both human, both LLM) keep the existing split=True behaviour. + seat (per `human_wolf_seats`), the human's target wins (no split). + - When 2 wolves disagree, both submitted, both are LLMs, and ``rng`` is + provided, randomly pick one of the two concrete picks as the resolved + attack target. This unblocks games that previously parked in + ``WAITING_HOST_DECISION`` whenever the wolf-chat coordination LLM + failed to make the two NPCs converge (observed in game + ``98e5a083b5ff`` day 2: SQ→コメット, ユリコ→セツ → host paused + indefinitely with no human able to break the tie). + - When ``rng`` is None and no human-wolf priority applies, the legacy + ``split=True`` shape is returned so unit tests pinning the old + behaviour keep passing. + - If both wolves' picks are ``None`` (= both abstained), the result + stays "no attack" rather than synthesising a target. """ alive = list(alive_wolf_seats) if not alive: @@ -171,6 +186,13 @@ def resolve_wolf_attack( return AttackResult(target_seat=picks.get(wolf), missing=missing) targets = [picks.get(w) for w in alive] + if targets[0] is None and targets[1] is None: + # Both wolves explicitly abstained — that's a unanimous "no + # attack" rather than a split. The legacy code reported + # split=True here because the equality check bailed out on + # ``None``; we promote it to a clean no-attack result so the + # caller doesn't pause the night chasing a phantom split. + return AttackResult(target_seat=None, missing=missing) if targets[0] is not None and targets[0] == targets[1]: return AttackResult(target_seat=targets[0], missing=missing) # Targets differ. Check human-wolf priority: only when exactly 2 alive wolves, @@ -182,6 +204,15 @@ def resolve_wolf_attack( human_target = picks.get(human_seat) if human_target is not None: return AttackResult(target_seat=human_target, missing=missing) + # All-LLM split (or unfilled human-wolf seats with both LLM picks + # diverging). With an RNG seeded by the caller we resolve the split + # by randomly picking one of the two concrete picks; without one we + # fall back to the legacy split=True for callers (mostly tests) that + # haven't started threading an RNG. + concrete_targets = [t for t in targets if t is not None] + if rng is not None and concrete_targets: + chosen = rng.choice(concrete_targets) + return AttackResult(target_seat=chosen, missing=missing) return AttackResult(split=True, missing=missing) diff --git a/src/wolfbot/domain/state_machine.py b/src/wolfbot/domain/state_machine.py index 9dc512b..fa1b7d5 100644 --- a/src/wolfbot/domain/state_machine.py +++ b/src/wolfbot/domain/state_machine.py @@ -21,6 +21,7 @@ from collections.abc import Mapping, Sequence from random import Random +from wolfbot.domain.durations import current_phase_durations from wolfbot.domain.enums import ( FACTION_JA, ROLE_JA, @@ -55,23 +56,44 @@ resolve_wolf_attack, ) -VOTE_DURATION = 60 -RUNOFF_DURATION = 60 -NIGHT_DURATION = 90 - -# Re-check interval used by `plan_day_discussion_wait` — engine sleeps this long -# between checks when the discussion deadline has passed but at least one alive -# LLM seat hasn't completed both rounds. The LLM completion path also calls -# `wake.wake(game_id)`, so this is a fallback ceiling, not the typical wait time. -DAY_DISCUSSION_GRACE = 30 - # Initial deadline for DAY_RUNOFF_SPEECH. Acts as a safety net so a hung LLM # task doesn't freeze the game; the LLM dispatcher's per-seat `finally` block # always advances `runoff_speech_done` so the engine can move on. +# +# Not part of :class:`PhaseDurations` because it's a hard safety floor rather +# than a tunable phase length — adjust here if real LLM submissions need more +# headroom, but don't expose it to the duration_factor knob. RUNOFF_SPEECH_DEADLINE = 60 -# Re-check interval used by `plan_runoff_speech_wait`, mirroring DAY_DISCUSSION_GRACE. -RUNOFF_SPEECH_GRACE = 30 + +# Backwards-compatible re-exports of the historical ``int`` constants. +# Per :pep:`562`, this module-level ``__getattr__`` is consulted when a +# name is not found in the module's normal namespace, so +# ``state_machine.VOTE_DURATION`` returns the *current* singleton value +# at access time rather than a stale snapshot from import. +# +# Note: ``from wolfbot.domain.state_machine import VOTE_DURATION`` still +# binds the local name to the value resolved at import time, so any +# tests that rely on dynamic re-reading must access the constant via +# ``current_phase_durations()`` directly. The aliases here exist so +# (a) tests that assert against the default value keep working, and +# (b) any third-party caller that reads the module attribute gets the +# current value. +_DURATION_ALIASES: dict[str, str] = { + "VOTE_DURATION": "vote", + "RUNOFF_DURATION": "runoff", + "NIGHT_DURATION": "night", + "DAY_DISCUSSION_GRACE": "day_discussion_grace", + "RUNOFF_SPEECH_GRACE": "runoff_speech_grace", +} + + +def __getattr__(name: str) -> int: + field = _DURATION_ALIASES.get(name) + if field is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value: int = getattr(current_phase_durations(), field) + return value # ---------------------------------------------------------------- helpers @@ -83,14 +105,31 @@ def _name(seats: Mapping[int, Seat], seat_no: int) -> str: def _public_log( - game: Game, kind: str, text: str, now_epoch: int, phase: Phase | None = None + game: Game, + kind: str, + text: str, + now_epoch: int, + phase: Phase | None = None, + actor_seat: int | None = None, + day: int | None = None, ) -> LogEntry: + # `actor_seat` is forwarded so EXECUTION (target) and MORNING (victim) + # carry the affected seat. Master Levi narration reads this field to + # voice the actual seat label instead of "対象不明". + # + # ``day`` overrides the default ``game.day_number`` for transitions + # that cross a day boundary in the same Transition (e.g. the NIGHT + # resolver emits the next day's MORNING + "N 日目の議論を開始" + # PHASE_CHANGE before bumping day_number). Without the override + # those logs land in the *prior* day's bucket, and the viewer's + # per-(day, phase) timeline reads "DAY_VOTE → NIGHT → DAY_DISCUSSION" + # — the discussion section ends up dated AFTER its own speeches. return LogEntry( game_id=game.id, - day=game.day_number, + day=game.day_number if day is None else day, phase=phase or game.phase, kind=kind, - actor_seat=None, + actor_seat=actor_seat, visibility="PUBLIC", text=text, created_at=now_epoch, @@ -104,10 +143,11 @@ def _private_log( text: str, now_epoch: int, phase: Phase | None = None, + day: int | None = None, ) -> LogEntry: return LogEntry( game_id=game.id, - day=game.day_number, + day=game.day_number if day is None else day, phase=phase or game.phase, kind=kind, actor_seat=None, @@ -283,6 +323,7 @@ def plan_night0( ), now_epoch=now_epoch, phase=Phase.DAY_DISCUSSION, + day=1, ) return Transition( @@ -307,7 +348,7 @@ def plan_day_discussion_to_vote(game: Game, now_epoch: int) -> Transition: return Transition( next_phase=Phase.DAY_VOTE, next_day=game.day_number, - new_deadline_epoch=now_epoch + VOTE_DURATION, + new_deadline_epoch=now_epoch + current_phase_durations().vote, public_logs=(pub,), ) @@ -394,7 +435,7 @@ def plan_day_vote_resolve( return Transition( next_phase=Phase.DAY_RUNOFF, next_day=game.day_number, - new_deadline_epoch=now_epoch + RUNOFF_DURATION, + new_deadline_epoch=now_epoch + current_phase_durations().runoff, public_logs=(pub,), clear_force_skip=True, ) @@ -409,7 +450,7 @@ def plan_day_vote_resolve( return Transition( next_phase=Phase.NIGHT, next_day=game.day_number, - new_deadline_epoch=now_epoch + NIGHT_DURATION, + new_deadline_epoch=now_epoch + current_phase_durations().night, public_logs=(pub,), clear_force_skip=True, ) @@ -479,7 +520,7 @@ def plan_day_runoff_resolve( return Transition( next_phase=Phase.NIGHT, next_day=game.day_number, - new_deadline_epoch=now_epoch + NIGHT_DURATION, + new_deadline_epoch=now_epoch + current_phase_durations().night, public_logs=(pub,), clear_force_skip=True, ) @@ -510,7 +551,7 @@ def _apply_execution( return Transition( next_phase=Phase.NIGHT, next_day=game.day_number, - new_deadline_epoch=now_epoch + NIGHT_DURATION, + new_deadline_epoch=now_epoch + current_phase_durations().night, public_logs=(pub,), clear_force_skip=clear_force, ) @@ -521,6 +562,7 @@ def _apply_execution( kind="EXECUTION", text=f"{exec_name} が処刑されました。{tally_suffix}", now_epoch=now_epoch, + actor_seat=executed_seat, ), ) updates = ( @@ -554,7 +596,7 @@ def _apply_execution( return Transition( next_phase=Phase.NIGHT, next_day=game.day_number, - new_deadline_epoch=now_epoch + NIGHT_DURATION, + new_deadline_epoch=now_epoch + current_phase_durations().night, player_updates=updates, public_logs=public_logs, newly_dead_seats=(executed_seat,), @@ -571,6 +613,7 @@ def plan_night_resolve( previous_guard_seat: int | None, force_skip: bool, now_epoch: int, + rng: Random | None = None, ) -> Transition: """Night resolution in the spec's fixed 10-step order. @@ -578,6 +621,12 @@ def plan_night_resolve( → 5. guard==attack? (no death) → 6. else attack target dies → 7. Morning announce → 8. Permission update (via newly_dead_seats) → 9. Victory check → 10. DAY_DISCUSSION day+1 (or GAME_OVER). + + ``rng`` is forwarded to :func:`wolfbot.domain.rules.resolve_wolf_attack` + so an all-LLM split picks one wolf's target at random instead of + parking the game in ``WAITING_HOST_DECISION``. ``None`` keeps the + legacy split-pause behaviour for tests / callers that aren't yet + threading an RNG. """ seats_by_no = {s.seat_no: s for s in seats} seer_seat = alive_seat_of_role(players, Role.SEER) @@ -596,7 +645,11 @@ def plan_night_resolve( knight_action = next((a for a in actions if a.kind is SubmissionType.KNIGHT_GUARD), None) attack = resolve_wolf_attack( - wolf_actions, wolves, force_skip=force_skip, human_wolf_seats=human_wolf_seats + wolf_actions, + wolves, + force_skip=force_skip, + human_wolf_seats=human_wolf_seats, + rng=rng, ) missing: set[int] = set() @@ -766,6 +819,7 @@ def plan_night_resolve( if knight_seat is not None and guard_target is not None: record_guard = (knight_seat, guard_target) + next_day = game.day_number + 1 public_logs: tuple[LogEntry, ...] = ( _public_log( game, @@ -773,6 +827,8 @@ def plan_night_resolve( text=morning_text, now_epoch=now_epoch, phase=Phase.DAY_DISCUSSION, + actor_seat=killed_seat, + day=next_day, ), ) @@ -795,7 +851,6 @@ def plan_night_resolve( clear_force_skip=True, ) - next_day = game.day_number + 1 day_start = _public_log( game, kind="PHASE_CHANGE", @@ -805,6 +860,7 @@ def plan_night_resolve( ), now_epoch=now_epoch, phase=Phase.DAY_DISCUSSION, + day=next_day, ) return Transition( next_phase=Phase.DAY_DISCUSSION, @@ -848,7 +904,7 @@ def plan_day_discussion_wait(game: Game, now_epoch: int) -> Transition: return Transition( next_phase=Phase.DAY_DISCUSSION, next_day=game.day_number, - new_deadline_epoch=now_epoch + DAY_DISCUSSION_GRACE, + new_deadline_epoch=now_epoch + current_phase_durations().day_discussion_grace, ) @@ -875,7 +931,7 @@ def plan_runoff_speech_to_runoff( return Transition( next_phase=Phase.DAY_RUNOFF, next_day=game.day_number, - new_deadline_epoch=now_epoch + RUNOFF_DURATION, + new_deadline_epoch=now_epoch + current_phase_durations().runoff, public_logs=(pub,), ) @@ -891,17 +947,24 @@ def plan_runoff_speech_wait(game: Game, now_epoch: int) -> Transition: return Transition( next_phase=Phase.DAY_RUNOFF_SPEECH, next_day=game.day_number, - new_deadline_epoch=now_epoch + RUNOFF_SPEECH_GRACE, + new_deadline_epoch=now_epoch + current_phase_durations().runoff_speech_grace, ) +# Names listed below that are NOT bound at module level +# (DAY_DISCUSSION_GRACE / NIGHT_DURATION / RUNOFF_DURATION / +# RUNOFF_SPEECH_GRACE / VOTE_DURATION) are exported dynamically through +# the module-level ``__getattr__`` defined above (PEP 562). They are +# intentionally part of the public API for backwards compatibility with +# code that imports the historical constants. Ruff cannot see the +# dynamic binding, hence the ``noqa: F822`` markers. __all__ = [ - "DAY_DISCUSSION_GRACE", - "NIGHT_DURATION", - "RUNOFF_DURATION", + "DAY_DISCUSSION_GRACE", # noqa: F822 — see PEP 562 __getattr__ above + "NIGHT_DURATION", # noqa: F822 + "RUNOFF_DURATION", # noqa: F822 "RUNOFF_SPEECH_DEADLINE", - "RUNOFF_SPEECH_GRACE", - "VOTE_DURATION", + "RUNOFF_SPEECH_GRACE", # noqa: F822 + "VOTE_DURATION", # noqa: F822 "plan_day_discussion_to_vote", "plan_day_discussion_wait", "plan_day_runoff_resolve", diff --git a/src/wolfbot/domain/suspicion.py b/src/wolfbot/domain/suspicion.py new file mode 100644 index 0000000..76e764a --- /dev/null +++ b/src/wolfbot/domain/suspicion.py @@ -0,0 +1,79 @@ +"""Public suspicion record — who suspects whom, at what level, with what reason. + +Each `Suspicion` row is emitted alongside a `SpeechEvent` and persisted to +the `speech_suspicions` table. Subsequent prompts surface the immutable +history back so a player who later contradicts their own past suspicion +without an explicit `update_from_level` + `update_reason` is detectable. + +The history is the village-side anti-fabrication mechanism: a wolf who +"forgot" they said someone was trustworthy yesterday can be caught by +the recorded mismatch between past and current statements. It is also +the data source for visualising the suspicion graph during discussion. + +Pure: no I/O, no asyncio. +""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field + + +class SuspicionLevel(StrEnum): + """Four-step scale a speaker assigns to a target seat. + + The scale is deliberately coarse so an LLM can pick consistently + over many phases. The values map onto natural-language register: + + - ``trust``: この人は村陣営寄りだと感じる (white-leaning) + - ``low``: 弱い違和感、まだ静観 (mild raised eyebrow) + - ``medium``: はっきり怪しい、議論で詰めたい (clearly suspicious) + - ``high``: 今日処刑したい第一候補 (prime lynch target) + """ + + TRUST = "trust" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +class Suspicion(BaseModel): + """One suspicion datum attached to a SpeechEvent. + + Keep frozen so the persisted row is immutable in memory once + constructed. The DB schema mirrors this shape. + + Anti-fabrication contract: when the speaker has previously declared + a suspicion against the same ``target_seat`` and is now amending it, + they MUST set ``update_from_level`` to the prior level and provide + a non-empty ``update_reason``. Subsequent prompts surface the full + history so an unannounced reversal (e.g. ``trust → high`` with + update_from_level=null) is detectable as evidence of fabrication. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + target_seat: int = Field(ge=1, le=9) + level: SuspicionLevel + reason: str = Field(min_length=1, max_length=500) + update_from_level: SuspicionLevel | None = Field( + default=None, + description=( + "Previous level this suspicion is updating from. None on the " + "first declaration against this target_seat. When non-null, " + "``update_reason`` MUST also be non-null and explain the " + "shift — silent reversals are anti-fabrication red flags." + ), + ) + update_reason: str | None = Field( + default=None, + max_length=500, + description=( + "Why the level changed from ``update_from_level`` to ``level``. " + "Required when ``update_from_level`` is set." + ), + ) + + +__all__ = ["Suspicion", "SuspicionLevel"] diff --git a/src/wolfbot/domain/ws_messages.py b/src/wolfbot/domain/ws_messages.py new file mode 100644 index 0000000..e0559e1 --- /dev/null +++ b/src/wolfbot/domain/ws_messages.py @@ -0,0 +1,822 @@ +"""Master ↔ NPC and Master ↔ voice-ingest WebSocket message schemas. + +All messages share a common envelope with `type`, `ts`, and `trace_id` fields. +Pydantic v2 frozen models give us strict structural validation at the wire +boundary. Discriminator on `type` lets the server dispatch by message kind +without touching dict-shaped JSON. + +Design choices: +- One file holds every typed message so the protocol is reviewable in one diff. +- `BaseEnvelope` carries the cross-cutting fields; concrete messages inherit it. +- All payloads serialize/deserialize via `.model_dump_json()` / + `.model_validate_json()` — no hand-rolled dict shaping. +- The runtime PSK handshake is enforced by the WS server before message + parsing begins (so an unauthenticated peer cannot inject typed messages). +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from wolfbot.domain.enums import CoDeclaration +from wolfbot.domain.suspicion import Suspicion + + +class BaseEnvelope(BaseModel): + """Cross-cutting fields present on every message.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + ts: int = Field(description="Wall-clock millis at send time") + trace_id: str = Field(description="Per-flow correlation id") + + +# ---------------------------------------------------------------- NPC ↔ Master + + +class NpcRegister(BaseEnvelope): + type: Literal["npc_register"] = "npc_register" + npc_id: str + discord_bot_user_id: str + persona_key: str = Field( + description=( + "Persona this NPC bot embodies (key from wolfbot.npc.personas). " + "Each NPC bot process is bound to exactly one persona at startup; " + "Master uses this when filling reactive_voice LLM seats." + ) + ) + supported_voices: tuple[str, ...] = () + version: str = "0.0.0" + + +class NpcRegistered(BaseEnvelope): + type: Literal["npc_registered"] = "npc_registered" + npc_id: str + assigned_seat: int | None = None + game_id: str | None = None + phase_id: str | None = None + + +class SeatAssigned(BaseEnvelope): + """Master → NPC: this NPC bot was picked for `game_id`. Join VC and + stand by for SpeakRequests. Sent at phase entry after the registry + pairs the bot with an LLM seat.""" + + type: Literal["seat_assigned"] = "seat_assigned" + npc_id: str + seat_no: int + game_id: str + phase_id: str + + +class SeatReleased(BaseEnvelope): + """Master → NPC: this NPC bot is no longer attached to a game. Leave + VC and idle until the next `seat_assigned`. Sent on game end / host + abort / explicit unassign so unselected bots don't linger in VC.""" + + type: Literal["seat_released"] = "seat_released" + npc_id: str + game_id: str | None = None + reason: str = "game_ended" + + +class SetMuteState(BaseEnvelope): + """Master → NPC: set this bot's voice *self*-mute. + + Self-mute (vs. server-mute via ``Member.edit(mute=...)``) sidesteps + both the MUTE_MEMBERS permission requirement and Discord's role + hierarchy rule that the moderator's role must be strictly above the + target. Each NPC owns its own voice state, so it can flip self-mute + via gateway opcode without any admin power. Used to visually flag + dead seats and non-discussion phases — viewers see a mic-muted icon + next to dead/idle bots.""" + + type: Literal["set_mute_state"] = "set_mute_state" + npc_id: str + self_mute: bool + + +class Heartbeat(BaseEnvelope): + type: Literal["heartbeat"] = "heartbeat" + npc_id: str | None = None # populated by NPC bots; None for voice-ingest + + +class LogicCandidate(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + id: str + claim: str + support: tuple[str, ...] = () + counter: tuple[str, ...] = () + + +class RecentSpeech(BaseModel): + """Compact rendering of a past public utterance for NPC context. + + Built from `SpeechEvent` rows by the SpeakArbiter and sent to the NPC + bot as part of `LogicPacket.recent_speeches` so the NPC's prompt can + surface recent dialogue (the parity gap with rounds-mode prompts that + fed `PLAYER_SPEECH` log lines into `build_user_context`). + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + seat_no: int + display_name: str + source: Literal["text", "voice_stt", "npc_generated"] + text: str + + +class LogicPacket(BaseEnvelope): + type: Literal["logic_packet"] = "logic_packet" + packet_id: str + phase_id: str + recipient_npc_id: str + public_state_summary: str + logic_candidates: tuple[LogicCandidate, ...] = () + pressure: dict[int, float] = Field(default_factory=dict) + expires_at_ms: int + recent_speeches: tuple[RecentSpeech, ...] = Field( + default_factory=tuple, + description=( + "Recent public utterances in the current phase, oldest-first. " + "Defaults to empty so older Master builds talking to newer NPC " + "bots stay schema-compatible." + ), + ) + past_votes: tuple[tuple[int, int, tuple[tuple[int, int | None], ...]], ...] = Field( + default_factory=tuple, + description=( + "Public vote history for completed past days. Each entry is " + "``(day, round, ((voter_seat, target_seat | None), ...))`` so " + "the NPC prompt can render 「day1: 席1 → 席3, …」 without " + "asking each NPC to remember its own ballot. Without this, " + "models routinely fabricate a different vote target than the " + "one they actually cast (observed live: ジナ said her vote " + "was コメット when she actually voted セツ). Round 0 = main " + "vote, round 1 = runoff." + ), + ) + pending_role_callouts: tuple[str, ...] = Field( + default_factory=tuple, + description=( + "Roles that some speaker has called out for in the current " + "phase but no one has CO'd as yet (e.g. ``('seer',)`` after " + "「占い師の方は名乗り出てください」). Real role holders should " + "treat this as a CO trigger; wolf-side NPCs should consider " + "whether to fake CO. Cleared on the Master side when a " + "matching CO arrives." + ), + ) + past_suspicions: tuple[ + tuple[int, str, int, int, str, str, str | None, str | None], ... + ] = Field( + default_factory=tuple, + description=( + "Public suspicion timeline carried as a flat tuple of " + "``(day, phase, suspecter_seat, target_seat, level, reason, " + "update_from_level | None, update_reason | None)`` rows in " + "chronological order. Used by the NPC speech LLM to render " + "the immutable suspicion history block so silent reversals " + "are detectable." + ), + ) + + +class SpeakRequest(BaseEnvelope): + type: Literal["speak_request"] = "speak_request" + request_id: str + phase_id: str + npc_id: str + seat_no: int + logic_packet_id: str + suggested_intent: str + max_chars: int = 80 + max_duration_ms: int = 8000 + priority: int = 0 + expires_at_ms: int + role: str | None = Field( + default=None, + description=( + "Role of the seat this NPC is bound to (VILLAGER / WEREWOLF / ...). " + "Sent so the NPC's system prompt can surface role-specific " + "strategy text. Optional for back-compat with older Master builds." + ), + ) + role_strategy: str | None = Field( + default=None, + description=( + "Pre-rendered role-specific strategy markdown produced by Master's " + "rounds-mode `build_strategy_block(role)`. Sent verbatim so the " + "NPC bot doesn't need to import gameplay-LLM strategy data." + ), + ) + alive_seats: tuple[tuple[int, str], ...] = Field( + default_factory=tuple, + description=( + "(seat_no, display_name) pairs for every still-alive seat. Lets " + "the NPC's prompt list 'who is alive' without an extra round-trip. " + "Empty for back-compat with older Master builds." + ), + ) + dead_seats: tuple[tuple[int, str], ...] = Field( + default_factory=tuple, + description=( + "(seat_no, display_name) pairs for dead seats. Same back-compat story as `alive_seats`." + ), + ) + dead_seat_causes: tuple[tuple[int, str], ...] = Field( + default_factory=tuple, + description=( + "Per-seat death cause tag for the dead_seats list, e.g. " + "((2, 'EXECUTION'), (8, 'ATTACK')). Lets the NPC distinguish " + "yesterday's vote victim from last night's attack victim " + "without parsing the morning text. Optional — empty for " + "back-compat with older Master builds; the NPC's prompt " + "builder falls back to an unlabelled list when missing." + ), + ) + retry_feedback: str | None = Field( + default=None, + description=( + "Master-side rejection note from a prior SpeakResult. Non-null " + "only when Master detected a fabricated `claimed_seer_result` " + "/ `claimed_medium_result` on the previous attempt and is " + "asking the same NPC to retry. The NPC's prompt builder must " + "surface this verbatim near the structured-output requirements " + "so the model corrects its claim. Empty / None on first " + "attempts and on non-fabrication retries." + ), + ) + + +class ClaimedSeerResult(BaseModel): + """Structured seer-CO result the NPC declares within a single utterance. + + Set on :class:`SpeakResult` when the NPC's `text` announces a new + divination outcome — *real* (true seer) or *fake* (wolf/madman + fake-seer-CO). Identical shape, different ground truth. + + Master persists every claim on the originating SpeechEvent and rolls + them up into a per-seat "claim history" that gets injected back into + every subsequent NPC prompt. That round-trip is what stops a fake + seer from drifting between phases (Day1 「シゲミチ白」 → Day2 results + that silently drop シゲミチ and add a fabricated コメット白). + + The `day` is implicit (= the speech's day field) and not carried here + so the NPC can't accidentally mis-stamp a claim. ``target_name`` is + likewise resolved Master-side from the seat lookup so the NPC never + has to invent it. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + target_seat: int = Field(ge=1, le=9) + is_wolf: bool + + +class ClaimedMediumResult(BaseModel): + """Structured medium-CO result the NPC declares within a single utterance. + + ``is_wolf`` may be ``None`` to encode the explicit "no execution + yesterday → no result today" case so the NPC can claim the void + rather than fabricating a result it doesn't have. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + target_seat: int = Field(ge=1, le=9) + is_wolf: bool | None = None + + +class SpeakResult(BaseEnvelope): + type: Literal["speak_result"] = "speak_result" + request_id: str + npc_id: str + phase_id: str + status: Literal["accepted", "declined", "error"] + text: str | None = None + used_logic_ids: tuple[str, ...] = () + intent: str | None = None + estimated_duration_ms: int | None = None + failure_reason: str | None = None + co_declaration: CoDeclaration | None = Field( + default=None, + description=( + "Structured CO self-declaration tag set by the NPC's speech " + "generator. Authoritative — Master persists it on SpeechEvent." + ), + ) + claimed_seer_result: ClaimedSeerResult | None = Field( + default=None, + description=( + "Structured seer-CO result this utterance announces. Non-null " + "iff the NPC's text describes a NEW divination outcome (real " + "seer or fake-CO wolf/madman). Master persists it on the " + "SpeechEvent and folds it into the per-seat claim history " + "every subsequent prompt sees, anchoring fake seers to their " + "prior lies." + ), + ) + claimed_medium_result: ClaimedMediumResult | None = Field( + default=None, + description=( + "Structured medium-CO result this utterance announces. " + "Identical handling to claimed_seer_result; ``is_wolf=null`` " + "encodes the 'no execution yesterday' void case." + ), + ) + addressed_seat_no: int | None = Field( + default=None, + description=( + "Legacy single-addressee field. When ``addressed_seat_nos`` " + "is non-empty Master prefers the list and ignores this. " + "Older NPC bot builds that don't know the list field still " + "work via this fallback. None / unset for general remarks." + ), + ) + addressed_seat_nos: tuple[int, ...] = Field( + default_factory=tuple, + description=( + "All seats this utterance is directed at — e.g. asking " + "「セツとジナはどう思う?」 produces ``(2, 3)``. Master " + "persists every entry on SpeechEvent so the arbiter " + "prioritises every named NPC and consumes them one-by-one " + "as each replies. Self-address (==speaker_seat) and " + "non-alive seats are dropped on the Master boundary." + ), + ) + suspicions: tuple[Suspicion, ...] = Field( + default_factory=tuple, + description=( + "Structured suspicion records this utterance asserts. Each " + "entry says 「自分は target_seat を level (理由 reason) で見ている」. " + "Master persists them to ``speech_suspicions`` keyed on " + "``event_id`` and folds the immutable history into every " + "subsequent prompt so a silent reversal (e.g. trust → high " + "without setting ``update_from_level``) is detectable. " + "Empty tuple is allowed but discouraged — see the speech " + "system prompt's 名指し義務 rule." + ), + ) + + +# ------------------------- Phase D: per-seat decision delegation +# Reactive_voice mode is migrating to "NPC bot is an embodied agent for its +# seat". The bot owns its role + private results in-memory and decides +# speech / vote / night-action via its own `NPC_LLM_*`. Master pushes the +# private state at /wolf start (and on NPC re-register) and asks for +# decisions when a phase needs them. None of these messages are required +# for back-compat — Master falls back to the historical Master-decides +# path (LLMAdapter / SpeakRequest) when the NPC bot is on an older build. + + +class SeerResult(BaseModel): + """Past seer divination result a seer NPC needs to recall in speech.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + day: int + target_seat: int + target_name: str + is_wolf: bool + + +class MediumResult(BaseModel): + """Past medium post-execution result.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + day: int + target_seat: int + target_name: str + is_wolf: bool | None = None # None when no execution happened + + +class GuardEntry(BaseModel): + """Past knight guard target. ``peaceful_morning`` lets the NPC tell a + truthful story about which guards led to which outcomes.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + day: int + target_seat: int + target_name: str + peaceful_morning: bool | None = None # None until morning resolves + + +class WolfChatLine(BaseModel): + """One line of the wolves-only chat history.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + day: int + speaker_seat: int + speaker_name: str + text: str + + +class WolfAttackEntry(BaseModel): + """Past wolf attack target the wolves submitted on a given night. + + ``peaceful_morning`` is True when the attack was knight-GJ'd (no + victim that morning), False when the target was killed, None when + the morning hasn't resolved yet. Wolves use this to detect the + "GJ → re-attack same target" pattern: the knight cannot guard the + same seat on consecutive nights, so a GJ'd target is a free kill + on the next night. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + day: int + target_seat: int + target_name: str + peaceful_morning: bool | None = None + + +class PrivateStateSnapshot(BaseEnvelope): + """Master → NPC: full private game state for the seat this NPC plays. + + Sent at game start (after roles assigned) and on NPC re-register so the + NPC bot can rebuild its in-memory state without persisting anything to + disk locally. Idempotent — the NPC replaces its state for that game_id + with the snapshot's contents. + """ + + type: Literal["private_state_snapshot"] = "private_state_snapshot" + npc_id: str + game_id: str + seat_no: int + persona_key: str + role: str # canonical Role enum value (VILLAGER / WEREWOLF / ...) + day_number: int = 0 + alive_seats: tuple[tuple[int, str], ...] = () + dead_seats: tuple[tuple[int, str], ...] = () + dead_seat_causes: tuple[tuple[int, str], ...] = () + # Wolf-only — empty for non-wolves. + partner_wolves: tuple[tuple[int, str], ...] = () + # Role-specific result history. Empty for roles without these powers. + seer_results: tuple[SeerResult, ...] = () + medium_results: tuple[MediumResult, ...] = () + guard_history: tuple[GuardEntry, ...] = () + wolf_chat_history: tuple[WolfChatLine, ...] = () + wolf_attack_history: tuple[WolfAttackEntry, ...] = () + + +class PrivateStateUpdate(BaseEnvelope): + """Master → NPC: incremental update to the seat's private state. + + Push semantics, append-only on the NPC's local state. ``payload`` shape + depends on ``update_kind``: + + * ``seer_result`` → SeerResult-shaped fields + * ``medium_result`` → MediumResult-shaped fields + * ``guard_entry`` → GuardEntry-shaped fields + * ``guard_resolved`` → ``{"day": int, "peaceful_morning": bool}`` — + retroactively fills the previous night's `peaceful_morning` flag. + * ``wolf_chat`` → WolfChatLine-shaped fields + * ``alive_changed`` → ``{"alive_seats": [...], "dead_seats": [...]}`` + * ``day_advanced`` → ``{"day_number": int}`` + """ + + type: Literal["private_state_update"] = "private_state_update" + npc_id: str + game_id: str + seat_no: int + update_kind: Literal[ + "seer_result", + "medium_result", + "guard_entry", + "guard_resolved", + "wolf_chat", + "alive_changed", + "day_advanced", + ] + payload: dict[str, object] = Field(default_factory=dict) + + +class DecideVoteRequest(BaseEnvelope): + """Master → NPC: decide a vote target for ``round_`` (0=regular, 1=runoff). + + ``candidate_seats`` is the legal target set the state machine produced + (alive seats minus self). NPC may return ``target_seat=None`` to + abstain. + """ + + type: Literal["decide_vote_request"] = "decide_vote_request" + request_id: str + npc_id: str + seat_no: int + game_id: str + phase_id: str + round_: int = 0 + candidate_seats: tuple[tuple[int, str], ...] + public_state_summary: str = "" + expires_at_ms: int + + +class VoteDecision(BaseEnvelope): + """NPC → Master: vote target choice. ``target_seat=None`` = abstain.""" + + type: Literal["vote_decision"] = "vote_decision" + request_id: str + npc_id: str + seat_no: int + target_seat: int | None + reason_summary: str = "" + suspicions: tuple[Suspicion, ...] = Field( + default_factory=tuple, + description=( + "Structured suspicion records the NPC asserts as part of " + "this vote decision. Master persists them to the immutable " + "suspicion timeline so vote-time reasoning is part of the " + "captured public history. The vote target is implicitly a " + "high suspicion; Master auto-promotes target_seat to " + "level=high if no explicit suspicion entry exists for it." + ), + ) + + +class DecideNightActionRequest(BaseEnvelope): + """Master → NPC: decide a night action target. + + ``action_kind`` determines the seat universe: wolf_attack / seer_divine + / knight_guard. ``candidate_seats`` is the legal target set. + """ + + type: Literal["decide_night_action_request"] = "decide_night_action_request" + request_id: str + npc_id: str + seat_no: int + game_id: str + phase_id: str + action_kind: Literal["wolf_attack", "seer_divine", "knight_guard"] + candidate_seats: tuple[tuple[int, str], ...] + public_state_summary: str = "" + expires_at_ms: int + + +class NightActionDecision(BaseEnvelope): + """NPC → Master: night-action target choice. ``target_seat=None`` = + skip. Master writes the action to ``night_actions`` and applies the + rules at phase resolution.""" + + type: Literal["night_action_decision"] = "night_action_decision" + request_id: str + npc_id: str + seat_no: int + action_kind: Literal["wolf_attack", "seer_divine", "knight_guard"] + target_seat: int | None + reason_summary: str = "" + suspicions: tuple[Suspicion, ...] = Field( + default_factory=tuple, + description=( + "Structured suspicion records. Surfaced primarily for " + "``seer_divine`` (the chosen target IS the seat the seer " + "most wants to verify, which maps cleanly to a suspicion " + "entry). Master persists these to the timeline and " + "auto-promotes the divine target to level=medium if not " + "explicitly listed (seer divine is information-seeking, " + "not a lynch declaration). Wolf-attack and knight-guard " + "decisions do not produce suspicion entries by default — " + "wolves know their targets and guards declare protection, " + "not suspicion." + ), + ) + + +class WolfChatRequest(BaseEnvelope): + """Master → NPC (wolf seat only): "post a coordination line now". + + Sent sequentially to each alive wolf NPC at the start of the night + phase before attack-decision dispatch. Each wolf reads the others' + `wolf_chat_history` (already updated via `private_state_update`) so + the chain converges on a target. NPC replies via `WolfChatSend` + carrying the same `request_id` so Master can drain its pending + futures. + """ + + type: Literal["wolf_chat_request"] = "wolf_chat_request" + request_id: str + npc_id: str + seat_no: int + game_id: str + phase_id: str + candidate_seats: tuple[tuple[int, str], ...] = Field( + default_factory=tuple, + description=( + "(seat_no, name) pairs of legal attack targets — passed so the " + "wolf NPC can ground its proposal in real candidates rather " + "than improvising a name." + ), + ) + public_state_summary: str = "" + expires_at_ms: int + + +class WolfChatSend(BaseEnvelope): + """NPC (wolf seat only) → Master: post a line to the wolves' private + chat. Master persists it as a `WOLF_CHAT` private LogEntry and pushes + a `wolf_chat` PrivateStateUpdate to every other live wolf seat's NPC. + + `request_id` is non-null when the line was prompted by a + `WolfChatRequest` from Master; the dispatcher uses it to resolve the + pending future. Spontaneous wolf chat (a wolf NPC volunteers a line + without a request) leaves it null — Master broker still persists + + fans out, just without resolving any future. + """ + + type: Literal["wolf_chat_send"] = "wolf_chat_send" + npc_id: str + seat_no: int + game_id: str + text: str + request_id: str | None = None + + +class PlaybackAuthorized(BaseEnvelope): + type: Literal["playback_authorized"] = "playback_authorized" + request_id: str + npc_id: str + status: Literal["authorized"] = "authorized" + speech_event_id: str + playback_deadline_ms: int + + +class PlaybackRejected(BaseEnvelope): + type: Literal["playback_rejected"] = "playback_rejected" + request_id: str + npc_id: str + status: Literal["rejected"] = "rejected" + failure_reason: str + + +class TtsFinished(BaseEnvelope): + type: Literal["tts_finished"] = "tts_finished" + request_id: str + npc_id: str + tts_duration_ms: int + audio_size_bytes: int + + +class TtsFailed(BaseEnvelope): + type: Literal["tts_failed"] = "tts_failed" + request_id: str + npc_id: str + failure_reason: str + + +class PlaybackFinished(BaseEnvelope): + type: Literal["playback_finished"] = "playback_finished" + request_id: str + npc_id: str + started_at_ms: int + finished_at_ms: int + + +class PlaybackFailed(BaseEnvelope): + type: Literal["playback_failed"] = "playback_failed" + request_id: str + npc_id: str + failure_reason: str + + +# --------------------------------------------------- voice-ingest ↔ Master + + +class VadSpeechStarted(BaseEnvelope): + type: Literal["vad_speech_started"] = "vad_speech_started" + game_id: str + phase_id: str + speaker_discord_user_id: str + seat_no: int + segment_id: str + audio_start_ms: int + + +class VadSpeechEnded(BaseEnvelope): + type: Literal["vad_speech_ended"] = "vad_speech_ended" + game_id: str + phase_id: str + speaker_discord_user_id: str + seat_no: int + segment_id: str + audio_end_ms: int + + +class SpeechEventPayload(BaseEnvelope): + type: Literal["speech_event_payload"] = "speech_event_payload" + game_id: str + phase_id: str + seat_no: int + speaker_discord_user_id: str + segment_id: str + text: str + confidence: float + duration_ms: int + audio_start_ms: int + audio_end_ms: int + summary: str | None = None + co_declaration: CoDeclaration | None = Field( + default=None, + description=( + "Structured CO self-declaration extracted by Gemini's audio " + "analyzer (`co_claim` field). Master persists it on SpeechEvent." + ), + ) + addressed_name: str | None = Field( + default=None, + description=( + "Literal name/handle the speaker called out (e.g. 'セツ', " + "'ジーナさん', '席3'). Master resolves this against the current " + "seats table to populate SpeechEvent.addressed_seat_no. None when " + "the analyzer didn't detect a named address." + ), + ) + addressed_seat_no: int | None = Field( + default=None, + description=( + "Seat number the analyzer pre-resolved ``addressed_name`` to " + "when its prompt was grounded with a roster. Master prefers " + "this over running ``resolve_seat_by_name(addressed_name, ...)`` " + "which only matches against the persona-canonical " + "``Seat.display_name`` and fails when the bot's live VC " + "nickname diverges from the persona handle. None when the " + "analyzer wasn't grounded or couldn't pick a seat." + ), + ) + role_callout: CoDeclaration | None = Field( + default=None, + description=( + 'Role the utterance is calling out for (e.g. "占い師いますか?" ' + "→ ``seer``). Mirrors the human-side STT field so wolf-side " + "NPCs and real role holders can react. None for the vast " + "majority of utterances; only set when the analyzer detects " + "an explicit role-callout intent (not mere mentions of a " + "role name in unrelated context)." + ), + ) + + +class SttFailed(BaseEnvelope): + type: Literal["stt_failed"] = "stt_failed" + game_id: str + phase_id: str + speaker_discord_user_id: str + seat_no: int + segment_id: str + failure_reason: str + + +class RegistrySnapshot(BaseEnvelope): + type: Literal["registry_snapshot"] = "registry_snapshot" + npc_user_ids: tuple[str, ...] + + +class RegistryUpdate(BaseEnvelope): + type: Literal["registry_update"] = "registry_update" + added: tuple[str, ...] = () + removed: tuple[str, ...] = () + + +# ---------------------------------------------------------------- Errors + + +class HandshakeError(BaseEnvelope): + """Sent before connection close on PSK failure or malformed handshake.""" + + type: Literal["handshake_error"] = "handshake_error" + failure_reason: str + + +__all__ = [ + "BaseEnvelope", + "ClaimedMediumResult", + "ClaimedSeerResult", + "HandshakeError", + "Heartbeat", + "LogicCandidate", + "LogicPacket", + "NpcRegister", + "NpcRegistered", + "PlaybackAuthorized", + "PlaybackFailed", + "PlaybackFinished", + "PlaybackRejected", + "RegistrySnapshot", + "RegistryUpdate", + "SeatAssigned", + "SeatReleased", + "SetMuteState", + "SpeakRequest", + "SpeakResult", + "SpeechEventPayload", + "SttFailed", + "TtsFailed", + "TtsFinished", + "VadSpeechEnded", + "VadSpeechStarted", +] diff --git a/src/wolfbot/llm/decider_config.py b/src/wolfbot/llm/decider_config.py new file mode 100644 index 0000000..e6ae83a --- /dev/null +++ b/src/wolfbot/llm/decider_config.py @@ -0,0 +1,78 @@ +"""Provider-agnostic LLM decider configuration. + +The Discord bot has *two* roles that need to call a chat-completion-style +LLM that returns structured JSON: + +* **Gameplay LLM** — drives Master's votes / night actions / rounds-mode + discussion text on behalf of LLM seats. Lives in + :mod:`wolfbot.services.llm_service` and is configured via + :class:`wolfbot.config.MasterSettings` (env prefix ``GAMEPLAY_LLM_*``). +* **NPC speech LLM** — drives one NPC bot's short reactive utterance in + ``reactive_voice`` mode. Lives in + :mod:`wolfbot.npc.speech.openai_compatible_generator` (and the Vertex Gemini + sibling) and is configured via :class:`wolfbot.npc.runtime.config.NpcSettings` + (env prefix ``NPC_LLM_*``). + +Both roles support the same three providers (``xai`` / ``deepseek`` / +``gemini``) and share this dataclass so the wiring code is identical. +The role-specific env-var prefix is the *only* difference between the +two settings classes; the runtime decider/generator factories take a +fully-populated :class:`LLMDeciderConfig` and stay role-blind. + +Why a separate module instead of putting this on the deciders directly: +the Settings classes need to import this without pulling in heavy +runtime deps (``openai``, ``google-genai``, ``tenacity``). Keeping it +in :mod:`wolfbot.llm` keeps the import graph cheap. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from pydantic import SecretStr + +LLMProvider = Literal["xai", "deepseek", "gemini", "mock"] + + +@dataclass(frozen=True) +class LLMDeciderConfig: + """All knobs needed to construct any of the three provider deciders. + + Constructed by ``MasterSettings.gameplay_decider_config()`` and + ``NpcSettings.npc_decider_config()``; consumed by + :func:`wolfbot.services.llm_service.make_llm_decider` (gameplay) and + :func:`wolfbot.npc.speech.generator_factory.make_npc_generator` (NPC). + + Provider gating: the relevant Settings ``model_validator`` guarantees + that the field tied to the chosen provider is non-None / non-empty + by the time we construct this — the asserts in the factories are + documentation aids for mypy. + + The ``"mock"`` provider is a special offline-test mode: no credential + is required, no network call is ever made, and the decider/generator + factories return a deterministic stub. Used by integration test rigs + that exercise the full Master + NPC pipeline without burning real + LLM tokens. + """ + + provider: LLMProvider + + # Common (xAI + DeepSeek path; ignored by Gemini). + api_key: SecretStr | None = None + model: str = "grok-4-1-fast" + base_url: str | None = None # None ⇒ provider default + + # DeepSeek-specific (ignored by xAI / Gemini). + thinking: Literal["enabled", "disabled"] = "enabled" + reasoning_effort: Literal["high", "max"] = "max" + + # Gemini Vertex AI specific (ignored by xAI / DeepSeek). + vertex_project: str | None = None + vertex_location: str = "global" + thinking_level: Literal["minimal", "low", "medium", "high"] = "high" + + timeout: float = 30.0 + + +__all__ = ["LLMDeciderConfig", "LLMProvider"] diff --git a/src/wolfbot/llm/persona_base.py b/src/wolfbot/llm/persona_base.py new file mode 100644 index 0000000..1bdab3d --- /dev/null +++ b/src/wolfbot/llm/persona_base.py @@ -0,0 +1,127 @@ +"""Shared persona base — types + pool-agnostic pickers. + +`Persona` is the generic identity carrier (display name + judgment-style +guide + structured speech profile). Concrete persona instances live in: + +* :mod:`wolfbot.npc.personas` — player characters (Gnosia-flavored). +* :mod:`wolfbot.master.personas` — Master/GM narrator voices. + +`SpeechProfile` holds the structured speech-reproduction data +(first-person, address style, signature phrases, narration mode) that the +system prompt's `## 話法` block consumes. Keep `style_guide` for +personality/judgment and `speech_profile` for 喋り方/語彙/文体 — do not +mix the two in free-form prose. + +Some personas are near-silent in their source material (e.g. Kukrushka) +— `narration_mode="silent_gesture"` makes the prompt builder render +gesture descriptions instead of a normal conversation profile. + +Hard rule: do NOT quote original source dialogue when defining a persona. +Only imitate temperament + register. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from random import Random +from typing import Literal + + +@dataclass(frozen=True) +class SpeechProfile: + first_person: str + self_reference_aliases: tuple[str, ...] = () + address_style: str = "" + sentence_style: str = "" + pause_style: str = "" + signature_phrases: tuple[str, ...] = () + forbidden_overuse: tuple[str, ...] = () + narration_mode: Literal["standard", "silent_gesture"] = "standard" + + +@dataclass(frozen=True) +class JudgmentProfile: + """Structured judgment-tendency axes consumed by the prompt builder. + + Each axis is 0.0-1.0. Values are rendered as labeled qualitative bands + in the system prompt so the LLM has a concrete tendency to lean toward, + independent of the free-form `style_guide` prose. + + Defaults are neutral so personas without explicit values still render + cleanly. Overrides per persona shape behaviour: + + - `trust_hard_facts`: how much weight Master's HARD-confidence deductions + get. Logical personas (Raqio) ≈ 1.0; defiant/wolf-leaning personas + can dip to 0.7 to keep verbal cover for muddling logic. + - `trust_medium_facts`: weight for MEDIUM-confidence deductions. + Conservative seers and analyzers stay high (0.7-0.9); deceiver-leaning + personas drop lower (0.3-0.5) to keep room for contrarian reads. + - `contrarian_bias`: tendency to deliberately question majority view. + Wolves & disruptors high; loyal villagers low. + - `aggression`: speed of moving from suspicion to active accusation. + - `bandwagon_tendency`: how readily the persona joins forming consensus. + + The system prompt rendering pairs these axes with explicit guidance — + the persona doesn't have to compute them, just lean toward them. + """ + + trust_hard_facts: float = 1.0 + trust_medium_facts: float = 0.7 + contrarian_bias: float = 0.0 + aggression: float = 0.5 + bandwagon_tendency: float = 0.5 + + +_DEFAULT_JUDGMENT_PROFILE = JudgmentProfile() + + +@dataclass(frozen=True) +class Persona: + key: str + display_name: str + style_guide: str + speech_profile: SpeechProfile + judgment_profile: JudgmentProfile = _DEFAULT_JUDGMENT_PROFILE + tts_voice_id: int | None = None + + +def index_by_key(pool: Sequence[Persona]) -> dict[str, Persona]: + """Build a key→Persona index. Raises if keys collide within `pool`.""" + out: dict[str, Persona] = {} + for p in pool: + if p.key in out: + raise ValueError(f"duplicate persona key {p.key!r} in pool") + out[p.key] = p + return out + + +def pick_personas(pool: Sequence[Persona], count: int, rng: Random) -> list[Persona]: + """Pick `count` distinct personas from `pool` at random.""" + if count < 0 or count > len(pool): + raise ValueError(f"cannot pick {count} personas; pool has {len(pool)}") + return rng.sample(list(pool), count) + + +def pick_personas_excluding( + pool: Sequence[Persona], + count: int, + exclude_keys: Sequence[str], + rng: Random, +) -> list[Persona]: + """Pick from `pool` minus `exclude_keys` — useful when you somehow need to extend.""" + excluded = set(exclude_keys) + candidates = [p for p in pool if p.key not in excluded] + if count > len(candidates): + raise ValueError(f"cannot pick {count} personas; only {len(candidates)} available") + return rng.sample(candidates, count) + + +__all__ = [ + "JudgmentProfile", + "Persona", + "SpeechProfile", + "index_by_key", + "pick_personas", + "pick_personas_excluding", +] diff --git a/src/wolfbot/llm/prompt_builder.py b/src/wolfbot/llm/prompt_builder.py index f1b9eae..02952d5 100644 --- a/src/wolfbot/llm/prompt_builder.py +++ b/src/wolfbot/llm/prompt_builder.py @@ -7,6 +7,7 @@ from __future__ import annotations from collections.abc import Sequence +from functools import cache from pathlib import Path from wolfbot.domain.enums import ( @@ -17,9 +18,11 @@ Phase, Role, SubmissionType, + format_co_claim_options, ) from wolfbot.domain.models import Game, Player, Seat -from wolfbot.llm.personas import Persona +from wolfbot.llm.persona_base import Persona +from wolfbot.llm.template import load_template, render_template SYSTEM_TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "prompts" / "llm_system_prompt.md" @@ -28,637 +31,66 @@ def _load_template() -> str: return SYSTEM_TEMPLATE_PATH.read_text(encoding="utf-8") +_GAME_RULES_TEMPLATE = "shared/game_rules_9p" + + def _build_game_rules_block() -> str: - """Return the fixed 9-player ruleset shared by every LLM seat. - - Includes role distribution (derived from ROLE_DISTRIBUTION + ROLE_JA so we - don't duplicate the canonical numbers), win conditions matching - `rules.check_victory`, and the invariants the LLM must never violate - (NIGHT_0 random white is non-wolf, seer/medium see only real wolves as - black, wolves split → attack fails, knight can't guard the same target - twice, `target_name` must match a candidate token). + """Return the 9-player ruleset shared by every LLM seat. + + Body lives in ``prompts/templates/shared/game_rules_9p.md``. Two + placeholders are filled here: ``village_size`` (= ``VILLAGE_SIZE`` + constant) and ``distribution`` (rendered from ``ROLE_DISTRIBUTION`` + + ``ROLE_JA`` so the canonical seat counts stay derived, not + duplicated). """ distribution = " / ".join( f"{ROLE_JA[role]}{count}" for role, count in ROLE_DISTRIBUTION.items() ) - return ( - f"- この村は {VILLAGE_SIZE} 人村固定。プレイヤーは 9 名。\n" - f"- 初期配役は {distribution} (合計 {VILLAGE_SIZE} 名) で固定。途中で配役は変わらない。\n" - "- 陣営: 人狼・狂人は人狼陣営、占い師・霊媒師・騎士・村人は村人陣営。\n" - "- 村人陣営勝利: 生存人狼数が 0 になった時点。\n" - "- 人狼陣営勝利: 生存人狼数が生存非人狼人数以上になった時点 " - "(狂人はこの計算で非人狼として数えるが、勝敗判定は人狼陣営の勝利)。\n" - "- 昼の発言は、公開ログと自分が知る私的情報だけを根拠にする。" - "他プレイヤーの役職・夜行動・占い/霊媒判定・人狼同士の仲間関係など、" - "自分に公開されていない情報を事実として断言してはならない。\n" - "- 占い師と霊媒師の判定は、本物の人狼だけを黒と表示する。" - "狂人は黒判定されない (白として扱われる)。\n" - "- 霊媒結果の白 (『人狼ではありませんでした』) は、対象が本物の人狼ではないことだけを示す。" - "役職名 (占い師・霊媒師・騎士・村人・狂人) までは特定できない。\n" - "- 処刑された占い師 CO に霊媒結果で白が出ても、真占い師だった可能性と矛盾しない。" - "霊媒白だけを理由にその占い師 CO を偽扱いしない。" - "偽視するなら、対抗 CO、占い結果の破綻、発言時系列、投票、襲撃結果、死亡タイミングとの整合性で判断する。\n" - "- 逆に処刑された占い師 CO に霊媒結果で黒が出た場合は、その人物は本物の人狼なので、" - "真占い師ではなく人狼の騙りだったと強く判断してよい。\n" - "- NIGHT_0 に占い師へ提示されるランダム白は、本物の人狼ではない相手が選ばれる。" - "ただし真に村であることは保証されない (狂人の可能性はある)。\n" - "- 人狼同士で夜の襲撃対象の意見が割れた場合、原則として襲撃は空振りになる。" - "ただし生存人狼が 2 人で「片方が人間プレイヤー、片方が LLM 席」の構成だけは例外で、" - "人間プレイヤーの選択がそのまま襲撃対象として採用される " - "(双方人間 / 双方 LLM の不一致は従来どおり不成立)。" - "人狼は人狼専用チャットで襲撃先を 1 人に揃えることを最優先にする。\n" - "- 騎士は同じ相手を連続で護衛できない (前夜と同じ対象は選べない)。\n" - "- 投票先や夜行動対象は、プロンプトで提示された合法な候補トークン " - "(例: `席3 Alice`) の中からだけ選ぶ。候補外の名前を返してはならない。\n" - "- 特定役職 (占い師・霊媒師・騎士) の CO が 1 人だけで、同じ役職への対抗 CO が" - "公開ログ上一度も出ていない場合、その単独 CO 者は原則として真の役職者にかなり近い位置として扱う。" - "根拠なくその CO 者を処刑候補にしない。\n" - "- ただし単独 CO は絶対確定ではない。公開ログ上の発言破綻・投票矛盾・判定結果の矛盾・" - "噛み筋との不整合など、通常より強い根拠がある場合に限り疑ってよい。\n" - "- ただし「現在生存している CO 者が 1 人だけ」というだけでは単独 CO 扱いしない。" - "同じ役職 CO が過去に 2 人以上存在したことがある場合、対抗者が処刑・襲撃などで死亡して" - "現在 1 人だけ残っていても、その残存 CO 者を自動的に真置きしない。\n" - "- 対抗 CO が出た場合は、死亡済み CO 者も推理対象として保持し、" - "判定結果・発言の時系列・投票・襲撃結果・死亡タイミングとの整合性で真偽を比較し、" - "どちらをより真寄りとするか判断する。\n" - "- 「最後まで生き残った CO 者」は真とは限らない。" - "狼が情報役を噛まずに残した、対抗を吊らせて信用を取った、" - "囲いに使う狙いで残した、といった可能性も平行して見る。" - "対抗 CO 履歴がある役職で残存 CO が 1 人になった時点で「単独 CO だから真」と短絡しない。\n" - "- 「占いCOが出たら」「霊媒COについて」「占いCOしている人をどう見るか」など、" - "CO 語彙が発言中に登場するだけでは、その発言者自身の CO ではない。" - "CO 語彙の話題化と本人による名乗りを区別する。\n" - "- CO として扱うのは、本人が「私は占い師です」「占い師COします」「霊媒師として出ます」" - "のように自分の役職として明確に宣言した場合だけである。" - "仮定・話題提示・他者への言及はどれも CO ではない。\n" - "- 疑わしい場合は、公開ログの前後関係、主語、引用や仮定の語尾 (〜なら / 〜について / 〜どう見る)、" - "自分自身の宣言か他者への言及かを確認する。判断に迷うときは CO として数えない。\n" - "- 占い師・霊媒師・騎士の各 CO 数を時系列で整理し、各役職について『CO 数 - 1』(下限 0) を「対抗 CO 超過分」(騙り最低数) として数える。" - "真役職は各 1 人だけのため、超過分はその役職で少なくとも騙りである。\n" - "- 占い師 CO 超過分 + 霊媒師 CO 超過分 + 騎士 CO 超過分 の超過分合計が 3 に達した場合、" - "人狼 2 + 狂人 1 の狼陣営 3 名が能力役職 CO 群に出切っている。" - "村陣営の騙り・CO 撤回・同一人物の複数 CO・曖昧な CO 文言の誤読・死亡済み CO 見落とし・前提破綻がない限り、" - "能力役職 CO していない位置は配役上の消去法で村陣営の確白級として扱う。" - "同条件下で対抗のない単独 CO 役職が別にある場合、その単独 CO 者も狼陣営ではないため真役職としてかなり強く扱える。\n" - "- この超過分合計 3 による非 CO 確白は、単発の白判定 (狂人も白に出るため『村陣営確定』とは言い切らない) とは別根拠で、" - "固定配役上の消去法で狼陣営 3 名が CO 群に出切ったと数えられる点で村陣営まで強く推せる。" - "ただし、対抗 CO 群の中で誰が真役職かまでは超過分合計だけでは特定できない。" - "判定結果・霊媒結果・投票・襲撃・死亡タイミング・破綻で詰める。\n" - "- 超過分合計が 0〜2 の段階では、狼陣営が非 CO や単独 CO に残っている可能性があるため、" - "非 CO 位置を CO 数だけで確白とは断定しない。\n" - "- 超過分合計が 4 以上に見える場合は固定配役と矛盾する。" - "CO 撤回、同一人物の複数 CO、話題としての CO 語彙の誤読、村騙り、死亡済み CO 見落とし、ログ見落としを疑い、" - "確白扱いを保留して時系列を再整理する。\n" - "- 例: 3-2-1 (占い師 CO 3 / 霊媒師 CO 2 / 騎士 CO 1) → 超過分 2 + 1 + 0 = 3。" - "前提崩壊がなければ非 CO 位置は村陣営の確白級、対抗のない単独騎士 CO も狼陣営ではないため真騎士寄り。\n" - "- 例: 2-2-2 (占い師 CO 2 / 霊媒師 CO 2 / 騎士 CO 2) → 超過分 1 + 1 + 1 = 3。" - "各 CO 群に 1 人ずつ真役職、残り 3 人が狼陣営という形が基本。" - "非 CO 位置は確白級。各 CO 群内の真偽は判定結果・霊媒結果・投票・襲撃で詰める。\n" - "- 例: 3-1-1 (占い師 CO 3 / 霊媒師 CO 1 / 騎士 CO 1) → 超過分 2 + 0 + 0 = 2。" - "狼陣営 1 名が非 CO や単独 CO に残る可能性があるため、非 CO 全員を確白とは断定しない。\n" - "- 例: 4-1-1 (占い師 CO 4 / 霊媒師 CO 1 / 騎士 CO 1) → 超過分 3 + 0 + 0 = 3。" - "占い師 CO 群に狼陣営 3 名が固まっている可能性が高く、対抗のない単独霊媒・単独騎士は強い白寄り。" - "CO 撤回や村騙りが出たら再整理する。\n" - "- 占い師 CO が 3 人・霊媒師 CO が 1 人の盤面を『3-1』と呼ぶ。" - "3-1 では占い 3 人のうち 2 人が騙りである可能性が高く、" - "単独の霊媒師 CO は対抗がいない限り原則として真寄りの進行軸として扱い、" - "初日は占い師 CO 側から処刑候補を検討するのが基本線になる。\n" - "- 3-1 の基本進行は占いローラーまたは黒ストップの 2 択。" - "占いローラーは、偽っぽい・狼っぽい・視点漏れしている占い師 CO から順に処刑し、" - "処刑後の霊媒結果を占い結果・投票・襲撃結果の整合性と突き合わせて真偽を絞り込む。\n" - "- 黒ストップとは、単独霊媒が占い師 CO の誰かに黒判定を出した時、" - "残る占い師 CO をその場で処刑せず、灰 (役職 CO していない位置) の精査へ切り替える進行を指す。" - "霊媒が真であれば処刑された占い師 CO は本物の人狼として確定しているので、" - "占いローラー続行より灰の精査の方が有利になる局面が多いからである。\n" - "- ただし黒ストップは絶対ではない。真狼狼 (2 人の狼が共に占い師 CO) の可能性、" - "霊媒師 CO 側が偽だった可能性、残る占い師 CO の発言・投票・判定が破綻している場合、" - "あるいはローラー続行しないと決選投票で PP (パワープレイ) を許す残り人数である場合は、" - "黒ストップをやめて占いローラーを続行する判断があり得る。\n" - "- 3-1 で占い師 CO が 3 人並んだ盤面では、CO 者のうち 2 人が公開情報上『本物の人狼ではない』と確定した場合、" - "残る 1 人の占い師 CO を固定配役上の消去法として確定黒級の人狼位置と推定する。" - "人狼 2 人固定の配役で『占い師 CO 3 人のうち 2 人が非狼確定』なら、" - "残る占い師 CO 位置に少なくとも 1 人の人狼がいる以外に整合する配役がないためである。\n" - "- ただし『白判定』と『非狼確定』を混同しない。" - "信用が未確定な占い師 CO が出した白、偽が混じり得る霊媒結果、印象だけの白寄り評価は非狼確定として数えない。" - "狂人も白判定されるため、単発の白だけで非狼扱いを固定しない。\n" - "- 非狼確定として数えてよい根拠は、公開ログ・霊媒結果・襲撃死・真寄り情報役の判定・" - "CO 破綻整理など、この bot のルールと公開情報の整合から説明できるものに限る。" - "霊媒師 CO 側が真寄りと十分判断できる時点での霊媒白、本物の人狼ではないと示す襲撃死、" - "対抗 CO や判定矛盾で偽が破綻した結果としての非狼整理などが具体的な根拠になる。\n" - "- 2 人非狼確定が成立した場合は、残る占い師 CO を『まだ灰の 1 人』ではなく、" - "固定配役上の狼位置として投票・発言・進行提案へ反映させる。" - "黒ストップによる灰精査ではなく、残る占い師 CO の処刑提案や、" - "その人物を相方候補ペア仮説の片側として扱う議論を優先してよい。\n" - "- ただし前提が崩れた場合は確定黒扱いを解除して時系列から再整理する。" - "村陣営の騙り (例: 狂人や村人が占い師 CO していた)、CO 撤回、" - "霊媒師 CO 側が偽だった可能性が後から浮上した状況、非狼確定の根拠が後から破綻した場合などが該当する。" - "前提が崩れたと分かった時点で、確定黒扱いをやめ、CO 履歴と判定履歴を時系列で再整理する。\n" - "- 占い師 CO が 2 人・霊媒師 CO が 2 人の盤面を『2-2』と呼ぶ。" - "2-2 では占い・霊媒のどちらも真が確定しておらず、" - "霊媒ローラー (または霊媒切り) が基本進行軸となる。\n" - "- 2-2 で霊媒師 CO が 2 人出ている場合、片方を根拠なく真置きせず、" - "霊媒結果は偽が混ざっている可能性を常に織り込んで推理する。" - "一度霊媒ローラーを開始したら原則として完走させ、途中で止めるには" - "通常よりも強い根拠 (公開ログ上の破綻・襲撃・投票・占い結果との不整合) を要する。\n" - "- 占い師 CO が 2 人・霊媒師 CO が 1 人の盤面を『2-1』と呼ぶ。" - "2-1 では単独霊媒師を原則として真寄りの進行役候補とし、" - "占い師 2 人の真偽比較とグレー精査を並行する。" - "白進行ならグレー吊り/グレランが基本候補になりやすく、縄数・囲い候補・決め打ち日を意識する。" - "占い黒が出ている場合は黒吊りで霊媒結果を見る選択肢が強いが、" - "黒を出した占い師の信用と黒先の発言も必ず見る。\n" - "- 占い師 CO が 1 人・霊媒師 CO が 2 人の盤面を『1-2』と呼ぶ。" - "1-2 では占い師 CO は真寄りになりやすい一方、霊媒師は騙り混じりとして扱い、" - "霊媒ローラーまたは霊媒切りを基本候補にする。" - "ただし霊媒内訳が真狂寄りでグレー狼が濃いと判断できる場合だけ、グレー精査も比較する。" - "どちらを選ぶかは縄数・占い結果・霊媒の破綻・投票で判断する。\n" - "- 嘘をつける役職 (人狼・狂人) が偽 CO するときでも、" - "偽の占い結果・霊媒結果・騎士日記はこの bot の実ルール上あり得る内容だけに留める。\n" - "- 偽占い師は、占ったと主張する対象がその夜まで生存していたか、" - "過去に自分が出した結果と矛盾しないかを確認する。\n" - "- NIGHT_0 で占い師に提示されるランダム白は、占い師の初回占い結果として扱う。" - "そのため day 1 の朝に占い師 CO する者 (真でも騙りでも) は、" - "初回の占い結果として必ず白を主張する。\n" - "- day 1 の朝に偽占い師として初回結果を黒と主張するのは、" - "この bot の実ルール上の NIGHT_0 タイムラインと矛盾するため破綻要素として扱われる。" - "day 1 で初回黒主張はしない。\n" - "- 偽占い師の黒結果主張は day 2 以降にのみ行う。" - "前夜に占ったという想定で、公開ログ・生存状況・過去に自分が出した判定履歴・" - "対抗 CO の発表内容と矛盾しない場合だけ出す。\n" - "- 偽霊媒師は、処刑がなかった日に霊媒結果を捏造しない。\n" - "- 偽騎士は、自分護衛・同一対象連続護衛・死亡済み対象への護衛・存在しない護衛成功を主張しない。\n" - "- どの偽 CO でも、実際には知らない他者役職や狼位置を事実として断言しない。\n" - "- day 2 以降に占い師・霊媒師・騎士として CO 中の者は、真でも偽でも、" - "昼の議論 1 巡目で前夜相当の能力結果を出すのが信用上重要である。" - "結果を持つはずの役職 CO が 1 巡目で結果を出さないと、信用低下や破綻疑いにつながる。\n" - "- 以下は公開ログと盤面を読むときに共通で使う推理語彙。" - "いずれも上記の事実ルール (単独 CO 真寄り、霊媒白=非人狼のみ、3-1/2-2 進行) を塗り替えない。" - "語彙はラベルであって、最終判断は常に公開情報の整合性で行う。\n" - "- グレー (灰): 役職 CO もなく占い/霊媒で白黒も十分ついていない位置。" - "誰視点のグレーかを常に意識する。\n" - "- グレラン: グレーから各自が理由を持って投票する進行。" - "名前にランダムとあるが完全な無作為投票ではなく、" - "発言・CO 状況・票筋・グレスケを根拠に選ぶ。\n" - "- グレスケ (スケール): グレーや未確定位置を白い順・黒い順に並べる考察。" - "順位だけでなく、発言・投票・判定・噛み筋との整合性を理由として添える。\n" - "- 縄計算: 残り処刑回数 (縄) を数えること。" - "標準目安は floor((生存人数 - 1) / 2)。この 9 人村は開始時 4縄。" - "残り人狼数・狂人生存・PP/RPP リスクから、無駄吊りできる回数を意識する。\n" - "- 白: 占い/霊媒で本物の人狼ではないと出た判定。" - "狂人も白に出るため、村陣営確定ではない。\n" - "- 黒: 占い/霊媒で本物の人狼と出た判定。" - "誰の判定か、CO 者の信用、対抗結果との整合性を必ず確認する。\n" - "- 確白: 公開情報上ほぼ非狼として扱える進行役候補の位置。" - "ただし狂人は白判定されるため、「村陣営確定」と言い切りすぎない。\n" - "- 確黒: 全視点または十分信用できる複数情報で本物の人狼と見てよい位置。" - "単独の偽占い候補から黒を出されただけでは確黒ではない。\n" - "- パンダ: 白判定と黒判定の両方を受けた位置。" - "パンダ本人の黒さだけでなく、判定を出した CO 者同士の真偽比較・" - "霊媒結果・票筋・噛み筋で評価する。\n" - "- ローラー (ロラ): 複数 CO した同種役職候補を吊り切る進行 " - "(占いローラー・霊媒ローラー)。開始したら原則完走し、" - "黒ストップや強い破綻などで止める場合は理由を明示する。\n" - "- 決め打ち: 複数 CO や複数候補のうち一方を真寄り・他方を偽寄りとして" - "進行を固定する判断。外すと負けに直結しやすいため、" - "縄余裕・判定・票筋・噛み筋を根拠にする。\n" - "- 破綻: 発言・判定・投票・死亡タイミング・役職数などが公開情報と矛盾して" - "成り立たなくなった状態。強い偽要素として扱ってよい。\n" - "- ライン: 発言・投票・擁護・判定結果などから見える二者以上のつながり " - "(特に狼同士に見える関係)。ライン切りや偶然もあるため、" - "単発ではなく複数材料で見る。\n" - "- 囲い: 偽占いなどが、狼である味方を白判定で保護する動き。" - "狼は狂人位置を知らないため、狂人を確定の味方として囲う前提では考えない。" - "狂人が偶然狼へ白を出す動きは別概念として扱う。" - "白先の発言・投票・噛み筋と合わせて疑う。\n" - "- 身内切り: 狼が仲間の狼に黒判定を出したり、投票や発言で切ったりして信用を買う戦術。" - "狼は狂人位置を知らないため、狂人を本物の仲間として特定して切る前提にはしない。" - "黒を出した側が必ず真とは限らない。\n" - "- 票筋: 誰が誰に投票したかの履歴。" - "同票・決選・狼候補への票有無、ラインや擁護との一貫性を見る。\n" - "- 噛み筋: 夜の襲撃先の傾向。" - "情報役噛み・白位置噛み・意見噛み・狩人探しの意図を推理する材料になる。\n" - "- 視点漏れ: ある役職視点では本来知り得ないはずの情報 " - "(狼位置、夜行動内訳、他人の属性など) を事実として話してしまう失言。" - "強い騙り判断材料。\n" - "- SG (スケープゴート): 狼に疑いを押し付けられて処刑候補にされやすい村陣営位置。" - "怪しまれている理由が本人の黒要素か、狼の誘導かを分けて見る。\n" - "- GJ (グッジョブ) / 平和: 朝に犠牲者が出ない状態。" - "騎士の護衛成功があり得るが、騎士 CO や護衛先の開示は" - "既存の騎士立ち回り指針に従い不用意に行わない。\n" - "- 騎士 / 狩人 / 狩: この bot の正式役職名は騎士。" - "人狼用語として狩人・狩も同じ護衛役を指す同義語として使われるため、" - "公開ログ上で狩人や狩と書かれていても騎士と同じ意味として読む。\n" - "- 鉄板護衛: 真寄り情報役・確白寄り・進行役など、" - "噛まれると村が大きく崩れる位置を堅く守る護衛。\n" - "- 変態護衛: セオリー上の本命から外れた位置を、襲撃読みで守る護衛。" - "当たれば強いが、外すと重要役職を抜かれるリスクがある。\n" - "- 捨て護衛: 噛まれにくい、または噛まれても村損失が小さい位置を" - "あえて護衛する戦術。連続護衛不可の環境では、今日本命を守ると" - "明日その本命を守れなくなるため、次夜の本命護衛余地を残す目的でも使う。" - "この bot では合法護衛候補から 1 名を選ぶ行動であり、" - "未提出・対象なし・誰も守らない・skip ではない。\n" - "- 連ガ無し / 連続護衛不可: 同じ相手を連続で護衛できないルール。" - "この bot は連続護衛不可で、前夜の護衛先は今夜の合法候補から外れる " - "(前述の騎士護衛ルールと同じ)。\n" - "- 護衛読み: 人狼がどこを噛みたいか、" - "どこが護衛されていそうで噛みを避けるかを推理すること。" - "騎士側でも、自分の護衛が読まれて噛みを外される可能性を考える材料になる。\n" - "- 護衛誘導: 騎士の護衛先に影響を与えようとする昼発言。" - "村利の進行整理にもなれば、人狼側の誘導にもなり得るため、" - "発言者の立場・噛み筋・投票・翌日の得を合わせて評価する。\n" - "- PP (パワープレイ): 終盤に人狼陣営が票を合わせて勝ちを取りに行く局面。" - "残り人狼数・狂人生存可能性・縄数で成立可否を判断する。\n" - "- RPP (ロスト/ランダム PP): 村側の縄と情報が尽き、PP 阻止が乱数勝負になる状態。" - "ここに入る前に決め打ちや情報整理を優先する。\n" - "- 発言の根拠チェックリスト: CO 履歴 (誰がいつ何を名乗ったか、対抗の有無)、" - "判定履歴 (占い/霊媒の白黒、誰視点の結果か、狂人白ルールとの整合)、" - "投票履歴 (同票・決選・身内票・票変え)、" - "噛み筋 (情報役噛み・白位置噛み・意見噛み・狩人探し)、" - "縄数 (残り処刑回数、PP/RPP の近さ) と自分の情報範囲 (私的情報と公開情報を混ぜない) を常に意識する。\n" - "- 実際の発言には、上のチェックリストから今の結論に最も効く 1〜2 点だけを根拠として出す。" - "用語だけで押し切らず、誰のどの発言・票・判定を見たのかを短く添える。" - "長い内部思考そのものを発話しない。\n" - "- この村は人狼 2 人固定なので、怪しい人を 1 人挙げたら、その人物が人狼ならもう 1 人の相方候補は誰かまで" - "公開ログからの仮説として考える。" - "村側・狂人・確定していない役職は実際の 2 人狼ペアを知らないため、断定ではなく推理として扱う。\n" - "- A-B の 2 人狼仮説は、(1) 庇い・便乗・距離の取り方、(2) 投票先・決選投票・票変えなどの票筋、" - "(3) 占い・霊媒結果と白先・黒先・囲い候補、(4) 噛み筋・噛まれなかった位置・情報役噛みとの整合、" - "(5) 片方が処刑濃厚なときの動き (ライン切り・身内票) が" - "2 人狼セットとして自然かで検証する。\n" - "- 単体黒要素が強くても自然な相方候補が見つからない場合は疑いの強さを下げ、" - "単体では中庸でも相方候補との票筋・噛み筋が強くつながる場合は疑いを上げる。\n" - "- 発言では 2 人狼候補を長く列挙せず、最も効くペア仮説 1〜2 点だけを短く添える。" - "全候補のペアを並べる議論は時間を浪費し、結論にもつながりにくい。\n" - "- 「相方候補」は公開ログからの推理用語として使ってよい。" - "ただし実際の 2 人狼ペアを知っているのは人狼本人だけで、" - "それ以外の役職は相方候補を確定情報として語らない。" + return render_template( + _GAME_RULES_TEMPLATE, + village_size=VILLAGE_SIZE, + distribution=distribution, ) -# Role-specific tips. Each string contains vocabulary unique to that role so -# the cross-leak tests can assert isolation. Keep the wolf strategy's `相方` / -# `襲撃先を揃える` out of every other role; keep `本物の人狼位置を知っている前提` -# out of the madman's tips. -_ROLE_STRATEGIES: dict[Role, str] = { - Role.WEREWOLF: ( - "- 相方の人狼と襲撃先を揃えることを最優先にする。" - "原則として意見が割れると襲撃は失敗するが、" - "相方が人間プレイヤーで自分が LLM 席 (またはその逆) のときに限り、人間プレイヤーの選択がそのまま採用される。" - "それ以外の構成 (双方人間 / 双方 LLM) で割れたら従来どおり失敗する。\n" - "- 昼の主張・投票理由・夜の襲撃意図に一貫性を持たせ、視点漏れを避ける。\n" - "- 相方を露骨に庇いすぎない。無理筋な擁護は狼ラインを疑われる原因になる。" - "相方を囲うなら発言・投票・噛み筋と整合する理由を用意する。\n" - "- 占い師・霊媒師などの情報役、信頼されている位置、盤面整理を主導する相手を" - "優先的に脅威として評価する。\n" - "- 人狼は勝利に必要な本体であり生存価値が高い。騙りに出るか潜伏するかは盤面で選ぶ。\n" - "- day 1 の占い師騙りは強い選択肢だが、無条件の既定行動ではない。" - "狂人らしい騙りが既に出ている、CO 数が増えすぎている、相方が危険位置にいる場合は、" - "潜伏して発言・投票・噛み筋で白さを取る方がよいことがある。\n" - "- 黒出しは真占い師・真霊媒師・真騎士に当てると破綻や対抗 CO のリスクがある。" - "吊りやすさだけでなく翌日の霊媒結果・投票・噛み筋まで考えて出す。\n" - "- day 1 に占い師騙りを選ぶ場合、初回の占い結果は NIGHT_0 ランダム白に合わせて必ず白を主張する。" - "初日に黒を出す主張はこの bot の実ルール上の時系列と矛盾し破綻するため、絶対にしない。\n" - "- day 1 の白先選びは、相方の位置・囲いリスク・公開ログ上の発言や投票・噛み筋と整合させる。" - "相方を囲うかどうか、灰の中で白を打つ位置はどこかを、襲撃計画と合わせて決める。\n" - "- 黒出しは day 2 以降にだけ検討する。前夜に占ったという想定で、" - "霊媒結果・投票・襲撃結果・対抗 CO の反応まで見て、破綻しない黒先と出すタイミングを選ぶ。\n" - "- 霊媒師騙りも day 1 の選択肢として、占い師騙りや潜伏と並べて比較する。" - "霊媒師騙りを選ぶと、真霊媒との 1-2 や、相方が占い師騙りに出ていれば 2-2 を作りに行ける。" - "霊媒ローラーで真霊媒を巻き込めば縄を使わせられる一方、9 人村では狼本体がローラーで失われやすい。" - "縄数・相方の位置・潜伏で白を取る余地・占い騙りの混雑度を比較して選ぶ。\n" - "- day 1 に霊媒師騙りで CO する場合、まだ処刑が発生していないので霊媒結果は出さない。" - "「処刑があった翌日に結果を出す」立ち回りを宣言するに留め、存在しない初日結果を捏造しない。\n" - "- 霊媒師騙りで day 2 以降の最初の 1 巡目は、前日処刑者への霊媒結果を必ず添える。" - "前夜に能力を使ったという想定で結果を出し、対象は前日に処刑された相手だけにする。\n" - "- 既に対抗占い師 CO が出ている場合は、day 2 以降に霊媒師騙りまたは騎士騙りを検討する。" - "霊媒師騙りでは前日処刑者への霊媒結果 (夜に能力を使った想定) を添えて CO する。" - "騎士騙りでは護衛先 (夜に能力を使った想定) を、" - "平和な朝ならば護衛成功主張も添えて CO する。\n" - "- 霊媒師騙りは 9 人村ではローラーされやすく、狼本体を失いやすい。" - "真霊媒を巻き込む価値、残り縄、相方の位置、PP/RPP まで見て限定的に選ぶ。\n" - "- 騎士騙りの護衛日記は、この bot の合法護衛に合わせる。" - "自分護衛・同じ相手の連続護衛・死亡済み対象への護衛・説明不能な護衛成功主張は破綻として扱われる。\n" - "- 役職 CO と対抗 CO が合計 6 人以上に膨らむと、" - "役職 CO していない位置の白が確定する。" - "騙りすぎには注意し、相方との役職分担を事前に意識する。\n" - "- 夜の襲撃先は、候補ごとに「襲撃価値」「護衛されやすさ」「騎士候補度」" - "「相方との整合」を比較して選ぶ。" - "単独真寄りの情報役・確白寄り・直近で白をもらった重要位置・進行役・" - "強く信頼された発言者は、襲撃価値も護衛されやすさも高い両刃の存在として扱う。\n" - "- 騎士候補度は公開ログから推定する。" - "騎士 CO、護衛先を匂わせる発言、情報役を守りたがる姿勢、" - "平和な朝の反応、処刑回避の仕方、発言量の抑え方を材料にする。" - "逆に、騎士 CO を強く促す位置、護衛ルールを誤っている位置、" - "死を恐れない視点の位置は騎士候補度を下げる。" - "ただし実役職を知っている前提で断言してはならない。" - "あくまで公開情報からの推定として扱う。\n" - "- 噛み方針を「情報役噛み」「白位置噛み」「意見噛み」「騎士探し」「SG 残し」" - "のどれかとして整理し、" - "翌日の自分や相方の発言・投票・騙り結果と矛盾しない襲撃を選ぶ。\n" - "- 護衛リスクを読んで噛む。GJ で縄が増える、噛み失敗で人狼側が不利になる、" - "進行役を残されるなど、護衛されやすい位置を毎回無条件に噛むと損になる。" - "護衛濃厚な真役職を噛みに行く場合は、その位置を残すと黒を引かれる・" - "霊媒で破綻する・盤面を固められる、といった GJ リスクを承知で" - "勝負する理由を持つ。\n" - "- 騎士候補を噛むのは「騎士探し」として有効で、" - "翌日以降に安全に情報役を噛む準備として価値がある。" - "ただし噛み筋が露骨な意見噛みや相方を不自然に白くする形に見えないかを" - "必ず確認する。\n" - "- 最終的には人狼チャットで相方と襲撃先を 1 人に揃える。" - "自分の第一希望だけで突っ込まず、相方案がある場合は" - "襲撃価値・護衛されやすさ・騎士候補度を比較して合わせる。\n" - "- day 2 以降に占い師・霊媒師・騎士を騙る、または既に騙っている場合、" - "昼の議論 1 巡目で前夜相当の能力結果を必ず発表する。" - "結果を後回しにすると、熟練者目線では信用を落とす。\n" - "- 偽占い師の day 2 以降の結果は、前夜に占ったという想定で対象 + 白/黒 + 短い理由を出す。" - "対象は前夜の開始時点で生存していた相手だけにし、" - "死亡済み・存在しない・自分自身・過去の自判定と矛盾する対象は使わない。" - "相方を白で囲う場合は発言・投票・噛み筋と整合させ、露骨な囲いを避ける。" - "非相方への白は白位置噛み・SG 残し・対抗の信用落としと矛盾しないかを見る。" - "黒は真役職・騎士・強い白位置への直撃で対抗 CO や破綻を招かないか、" - "翌日の霊媒結果・投票・襲撃計画と整合するかを確認する。\n" - "- 偽霊媒師の day 2 以降の結果は、前日処刑者だけに出す。" - "処刑がなかった日は霊媒結果なしを主張し、存在しない結果を作らない。" - "霊媒黒は占いローラー停止や灰吊り誘導に使えるが、" - "過去の投票・相方位置・残り縄と矛盾しないか必ず見る。" - "霊媒白は、対象が真占い師だった可能性・狂人だった可能性・村役だった可能性をどう見せるかを整理する。\n" - "- 偽騎士の day 2 以降の主張は、合法な護衛履歴を日付順 (護衛日 + 護衛先) に出す。" - "自分護衛・同一対象連続護衛・死亡済み対象護衛・死者が出た朝の護衛成功主張はしない。" - "平和な朝に護衛成功を主張するなら、" - "護衛先がその朝の襲撃失敗説明として自然か、襲撃計画と矛盾しないかを確認する。\n" - "- 結果を出す発言は、長い内部思考ではなく、結果と最も効く 1〜2 点の理由に絞る。\n" - "- 投票は翌日以降に票筋として読まれる前提で動く。" - "相方が公開ログ上で処刑濃厚な局面で、自分だけ弱い別候補へ票を逸らすと、" - "相方を庇った狼として透けやすく、狼ラインを濃くする。\n" - "- 相方を救えない局面では、身内票やライン切りで相方へ投票し、" - "自分が白く残って翌日以降に動ける価値を優先することも熟練者の選択肢に入れる。" - "投票理由は公開ログ上の発言・CO 矛盾・票筋・視点漏れに沿って自然に作り、" - "前日までの自分の主張や騙り結果と矛盾しないようにする。\n" - "- 相方を救う票は、自然に票が集まり得る対抗候補がいて、" - "自分の過去発言・投票理由・騙り結果と矛盾しないときだけ選ぶ。" - "根拠の薄い票を逸らす動きや、無理筋な対抗誘導は狼ラインを濃くする。\n" - "- 決選投票では、相方救済の成功見込み、自分の透けリスク、" - "相方を切って翌日以降に残る価値、PP/RPP の近さと縄数を比較し、" - "もっとも勝ち筋が残る投票を選ぶ。\n" - "- 「常に相方へ投票」でも「常に相方を庇う」でもなく、" - "公開ログ上もっとも自然で、自分と相方の票筋が翌日以降に読まれても" - "勝ち筋が残る投票を毎回選ぶ。\n" - "- 自分と相方は村から「2 人狼セット」として票筋・庇い・距離感・噛み筋を読まれる前提で動く。" - "救う票・切る票・囲い・黒出し・襲撃のどれを取っても、" - "翌日以降に自分と相方のラインとして説明できるかを毎回確認する。\n" - "- 偽占いの白囲い・黒出し、偽霊媒結果、騎士騙りの護衛日記、襲撃先は、" - "それ単体の自然さだけでなく、自分と相方の 2 人狼セットとしてどう読まれるかまで考えて選ぶ。\n" - "- 襲撃は「誰を消すか」だけでなく、" - "「誰を残すとどの相方候補が疑われるか」" - "「噛み筋で自分と相方のラインが濃くならないか」を比較して決める。\n" - "- 公開発言では実際の相方を知っているからこそ出てしまう視点漏れを絶対に出さない。" - "相方語彙は人狼チャットや夜行動の私的領域だけで使う。\n" - "- さらに精密に、占い師・霊媒師・騎士の対抗 CO 超過分 (各役職の CO 数 - 1、下限 0) を集計する。" - "超過分合計が 3 に達した時点で、能力役職 CO していない位置は村陣営の確白級として扱われ、処刑候補が CO 群に集中する。" - "騙りに出るか潜伏するかは、現在の各 CO 数と残り縄を見て、相方と整合する形で選ぶ。" - ), - Role.MADMAN: ( - "- あなたは人狼陣営の勝利に貢献するが、本物の人狼位置を知っている前提で話してはならない。" - "人狼が誰かは公開情報からは分からない立場として振る舞う。\n" - "- 偽 CO や偽の判定結果を出す場合でも、公開ログ・投票・処刑結果との矛盾を避け、" - "破綻しない範囲に留める。\n" - "- 知り得ない確定情報 (夜行動の内訳・他プレイヤーの属性など) を事実として断言しない。\n" - "- 真占い・真霊媒に疑いを向け、村陣営の情報整理を妨げる方向に投票や発言を運ぶ。\n" - "- 占い師騙りは狂人の強い基本候補で、day 1 に検討する価値が高い。" - "ただし無条件ではなく、既に複数の占い師 CO が出ている、CO 数が膨らみすぎる、" - "盤面的に潜伏して混乱させた方が得な場合はその限りではない。\n" - "- day 1 に占い師騙りを選ぶ場合、初回の占い結果は NIGHT_0 ランダム白に合わせて必ず白を主張する。" - "初日に黒を出す主張はこの bot の実ルール上の時系列と矛盾し破綻するため、絶対にしない。\n" - "- 黒出しは day 2 以降にだけ検討する。前夜に占ったという想定で、" - "公開ログ上の反応・対抗 CO・票筋を見て慎重に選ぶ。" - "狂人は本物の狼位置を知らないため、誤爆リスクは day 2 以降の黒出しでも常に残る点に注意する。\n" - "- 黒出しは誤爆リスクを常に見る。人狼位置を知らないため、" - "真役職・本物の狼・白い位置へ当ててしまうと反動が大きい。\n" - "- 白出しは破綻しにくいが、白先が本物の狼とは限らない。" - "白先を確定の味方として扱わず、公開ログ上の反応を見て支援先を調整する。\n" - "- 霊媒師騙りは真霊媒をローラーに巻き込める一方、自分も処刑されやすい。" - "占い師 CO 数、霊媒 CO 数、残り縄を見て選ぶ。\n" - "- 騎士騙りは終盤や対抗騎士が出た場面では有効だが、護衛履歴の破綻リスクが高い。" - "自分護衛・同じ相手の連続護衛・死亡済み対象への護衛・存在しない護衛成功主張を含めてはならない。\n" - "- 霊媒師騙りも day 1 の選択肢として、占い師騙りや潜伏と並べて比較する。" - "狂人が霊媒師騙りに出ると、真霊媒との 1-2、または別の騙り狼の占い師 CO と組み合わせて 2-2 を作りに行ける。" - "真霊媒をローラーに巻き込める一方、9 人村では狂人本人がローラーで処刑されやすい。" - "占い師 CO 数・霊媒 CO 数・縄数・盤面の混雑度を見て、潜伏や占い師騙りより得な場合だけ選ぶ。\n" - "- 狂人は本物の人狼位置を知らないため、霊媒師騙りを選んでも誤爆・誤支援は起きる。" - "霊媒黒で本物の狼を切ってしまう、霊媒白で真占いを補強してしまう、といったケースが day 2 以降の結果でも常に残る前提で動く。\n" - "- day 1 に霊媒師騙りで CO する場合、まだ処刑が発生していないので霊媒結果は出さない。" - "「処刑があった翌日に結果を出す」立ち回りを宣言するに留め、存在しない初日結果を捏造しない。\n" - "- 霊媒師騙りで day 2 以降の最初の 1 巡目は、前日処刑者への霊媒結果を必ず添える。" - "前夜に能力を使ったという想定で結果を出し、対象は前日に処刑された相手だけにする。" - "ただし誤爆・誤支援を踏まえて結果の色を選ぶ。\n" - "- 既に対抗占い師 CO が出ている場合は、day 2 以降に霊媒師騙りまたは騎士騙りを検討する。" - "霊媒師騙りでは前日処刑者への霊媒結果 (夜に能力を使った想定) を添えて CO する。" - "騎士騙りでは護衛先 (夜に能力を使った想定) を、" - "平和な朝ならば護衛成功主張も添えて CO する。\n" - "- 役職 CO と対抗 CO が合計 6 人以上に膨らむと、" - "役職 CO していない位置の白が確定する。" - "騙りすぎには注意する。狂人は本物の狼位置を知らない前提で動くため、" - "自分が騙り続けるほど推理材料が減る点にも留意する。\n" - "- day 2 以降に占い師・霊媒師・騎士を騙る、または既に騙っている場合、" - "昼の議論 1 巡目で前夜相当の能力結果を必ず発表する。" - "結果を後回しにすると信用低下や破綻疑いにつながる。" - "ただし狂人は本物の人狼位置を知らないため、" - "「狼を助けるつもり」でも誤爆や誤支援が起きる前提で偽結果を選ぶ。\n" - "- 偽占い師の day 2 以降の結果は、前夜に占ったという想定で対象 + 白/黒 + 短い理由を出す。" - "白出しは破綻しにくいが、白先が本物の狼とは限らないため白先を確定の味方として扱わない。" - "黒は誤爆リスクがあり、本物の人狼・真役職・強く白い位置に当てた場合の反動を考える。" - "黒先は処刑可能性があり、過去の自分の結果・対抗結果・投票履歴と矛盾しにくい位置を選ぶ。" - "対抗占い師の白先へ黒を重ねる、灰に黒を出す、白を重ねて議論を割る、などの選択肢を" - "CO 数・縄数・霊媒状況で使い分ける。\n" - "- 偽霊媒師の day 2 以降の結果は、前日処刑者だけに出す。" - "処刑なしの日は結果なしを主張し、存在しない結果を作らない。" - "霊媒結果は、真占い師の信用を落とす・霊媒ローラーに持ち込む・" - "占いローラー停止/継続を歪める目的で使う。" - "ただし本物の人狼が処刑されたかは知らないため、" - "黒白どちらも公開情報との整合性と誤支援リスクを見る。\n" - "- 偽騎士は、終盤や対抗騎士が出た場面で検討する。" - "合法な護衛履歴 (日付順の護衛日 + 護衛先) だけを出し、" - "自分護衛・同一対象連続護衛・死亡済み対象護衛・存在しない護衛成功主張はしない。\n" - "- 公開ログから 2 人狼仮説を立てるときは、本物の人狼位置を知らない立場として" - "公開ログからの推定として扱う。" - "狼候補 1 人だけを支援・攻撃するのではなく、その相方候補まで見て" - "どの誘導が人狼陣営に得かを比較する。\n" - "- 黒出し・白出しは、対象単体の整合だけでなく、" - "対象を狼扱い/非狼扱いしたときに相方候補の見え方がどう変わるかでも判断する。\n" - "- 真占い・真霊媒を崩すときも、村に誤った 2 人狼仮説を追わせられるかで価値を測る。" - "誤爆リスクを前提に、相方候補の推定は断定しすぎない。\n" - "- さらに精密に、占い師・霊媒師・騎士の対抗 CO 超過分 (各役職の CO 数 - 1、下限 0) を集計する。" - "超過分合計が 3 に達した時点で、能力役職 CO していない位置は村陣営の確白級として扱われ、" - "処刑候補が CO 群に集中するリスクを認識する。" - "騙りに出るか潜伏するかは、公開情報の各 CO 数と残り縄から判断する。" - ), - Role.SEER: ( - "- 自分の判定履歴を時系列で一貫して扱う。過去の白黒と矛盾する発言はしない。\n" - "- 黒結果は強い根拠として扱ってよい。ただし対抗 (偽占い) がいる場合は整合性を比較する。\n" - "- 白結果は『本物の人狼ではない』ことしか保証しない。狂人は白に出るため、" - "完全な村置きとしては扱わない。\n" - "- CO タイミング・対抗 CO の有無・投票と判定の噛み合いを重視し、" - "偽占い視点の破綻を探す。\n" - "- 公開ログ上まだ占い師 CO が出ていない状態で議論が進む場合は、" - "初日ランダム白と以後の占い結果を時系列で出して早めに CO する選択肢を強く持つ。" - "真占いが沈黙し続けると、偽 CO を単独真として扱わせてしまう。\n" - "- 偽占い師 CO が出た場合は、原則として早めに対抗 CO し、" - "初日ランダム白を含む全判定履歴を時系列で公開する。潜伏を続けるなら理由が必要。\n" - "- 黒を引いた場合は、CO して黒結果・過去の白結果・投票理由を明示し、" - "その黒の処刑と霊媒結果での確認を提案する選択肢を検討する。\n" - "- 夜の占い対象は、合法候補からランダムに選ばず、" - "白でも黒でも情報が落ちる位置 (翌日の議論に情報を落とせる位置) を優先する。" - "占い価値の高い位置は、対抗 CO の白先や囲い候補、" - "投票・決選投票・票変えで浮いた位置、根拠の薄い強い誘導をしている位置、" - "白なら進行役候補にでき黒なら処刑提案しやすい位置、" - "自分視点の灰を狭める位置である。\n" - "- 占い価値が下がる対象は、既に自分が占った位置、今日ほぼ処刑されそうな位置、" - "公開情報で十分処理対象になっている位置、" - "発言が極端に少なく白黒どちらでも議論が伸びにくい位置である。" - "今夜襲撃される可能性が高い位置でも、" - "結果が CO 真偽や囲い疑惑の整理に直結するなら候補に残してよい " - "(死亡しても占い結果は得られる)。\n" - "- 対抗 CO がいる場合は、相手の白先・黒先・投票先・対抗を庇う発言者を比較し、" - "真偽判定に最も効く対象を選ぶ。" - "単独 CO 寄りで自分が信用されている場合は、黒狙いだけでなく、" - "白が出たときに村の進行を安定させる位置も候補にする。\n" - "- 占い先は単体黒狙いだけでなく、白でも黒でも結果が出たときに" - "相方候補の整理に効く位置を優先する。" - "対抗占い CO がいる場合は、対抗の白先・黒先・投票・庇いから 2 人狼仮説を組み立て、" - "占い結果で最も崩せる仮説を狙って占う。\n" - "- 黒を引いたら、その黒の相方候補を公開ログ (票筋・庇い・噛み筋) から絞り、" - "処刑後の霊媒結果や翌日の占い方針につなげる。\n" - "- 占い師 CO が 3 人になり自分以外の 2 人が公開情報上で非狼確定したと読めた場合、" - "残る 1 人を固定配役上の狼位置として、夜の占い対象選びでも" - "その位置の確認や周辺の相方候補ペア仮説を崩す方向の占い先を優先する。" - "ただし非狼確定として数えるのは、印象白や自分以外の占い CO の単発白ではなく、" - "霊媒結果・襲撃死・CO 破綻など説明可能な根拠に限る。\n" - "- 対抗 CO 超過分合計が 3 に達して能力役職 CO していない位置が非 CO 確白級になった場合、" - "そこを無駄占いせず、対抗 CO 群やまだ確定しない位置を優先して占う。" - ), - Role.MEDIUM: ( - "- 処刑結果と占い師の主張・投票の流れを照合し、占い視点の真贋を見極める。\n" - "- 自分の霊媒結果が占い視点に与える影響 (真占い補強、偽占い否定など) を整理して発言する。\n" - "- 処刑された相手が狂人でも、霊媒結果は『人狼ではありませんでした』になる。" - "黒になるのは本物の人狼だけで、白結果だけでは村置き確定にはならない。\n" - "- 処刑が発生した翌日は、霊媒結果を公開して議論の軸を作る価値が高い。" - "沈黙すると偽霊媒 CO を単独真として扱わせてしまうリスクがある。\n" - "- 処刑がまだ発生していない段階では断定を増やしすぎず、" - "占い師 CO への反応を観察する。\n" - "- 対抗霊媒が出た場合は、自分の結果履歴と相手の矛盾を時系列で整理し、" - "ローラーで自分も巻き込まれる可能性を織り込んで発言する。\n" - "- 占い師 CO を処刑して霊媒結果が白だった場合、それは占い師 CO 偽の証明ではない。" - "真占い師だった可能性と、狂人など非狼の騙りだった可能性を分けて整理する。\n" - "- 占い師 CO を偽視する場合は、霊媒白そのものではなく、" - "対抗 CO、占い結果の破綻、発言時系列、投票、襲撃結果、死亡タイミングとの整合性を根拠にする。\n" - "- 霊媒黒が出た場合は、処刑された狼の相方候補を" - "票筋・庇い・ライン切り・身内票から絞り、翌日の処刑候補や占い先の議論につなげる。\n" - "- 霊媒白だった場合は、その処刑を押した位置・票の集まり方から、" - "狼がどこを吊りたかったかを 2 人狼仮説として整理する。\n" - "- 占い師 CO 処刑後の霊媒結果は、占い真偽の判定材料に使うだけでなく、" - "残った狼候補と相方候補のペア整理にも使う。\n" - "- 占い師 CO が 3 人並んだ盤面では、自分の霊媒結果と公開ログから 2 人を非狼確定にできるかを毎日整理する。" - "霊媒白だけで非狼確定として数えてよいのは、自分の霊媒 CO 側が真寄りと十分読める段階であり、" - "霊媒結果以外の整合 (襲撃死・CO 破綻・判定矛盾) も合わせて説明できる場合である。" - "2 人非狼確定が揃ったら、残る占い師 CO を確定黒級として処刑提案・投票誘導の材料にしてよい。\n" - "- 霊媒結果は対抗 CO 超過分の CO 数推理を更新する材料にする。" - "処刑された CO 者が黒なら対抗 CO 群内の狼数を絞り、白なら真役職または狂人の可能性を分け、" - "非 CO 確白の前提が保たれるかを確認する。" - ), - Role.KNIGHT: ( - "- 守る価値の高い情報役 (真占い・真霊媒) や、信頼されている位置を護衛対象として意識する。\n" - "- 夜の護衛先は、候補ごとに" - "「護衛価値 (真占い・真霊媒・確白寄り・進行役か)」" - "「襲撃されやすさ (今夜実際に噛まれそうか、護衛を読んで避けられそうか)」" - "「GJ が縄に与える価値 (平和で縄が増えるか、PP/RPP を遠ざけられるか、" - "CO 時に盤面を詰めやすいか)」" - "「次夜の連続護衛不可リスク (今日守った相手は明日守れない。" - "明日の本命襲撃が濃いか)」" - "「CO 時の説明可能性 (後で護衛履歴を出したとき、合法かつ盤面に沿って説明できるか)」" - "を短く比較して選ぶ。" - "最も白い相手や真寄りの情報役を毎夜固定するのではなく、" - "盤面ごとに価値で判断する。\n" - "- 鉄板護衛は、真寄り情報役・確白寄り・進行役など、" - "今夜抜かれると村が大きく崩れる位置を堅く守る選択。" - "重要役職が今夜噛まれそうな局面では、迷わず鉄板護衛を優先する。\n" - "- 捨て護衛は、噛まれにくい低損失位置をあえて護衛し、" - "次夜に本命を護衛できる余地を残す選択。" - "この bot では合法候補から 1 名を選ぶ行動で、" - "未提出や対象なしや skip ではない。\n" - "- 捨て護衛は、今夜本命が噛まれにくい理由があり、" - "明日以降に本命襲撃が濃くなると読める場合だけ検討する。" - "重要役職が今夜抜かれるリスクが高いなら鉄板護衛を優先し、" - "捨て護衛を毎夜の既定行動にしない。\n" - "- 同じ相手を連続で護衛してはならない。前夜と違う相手を選ぶ。\n" - "- 自分を護衛対象にすることはできない。死亡済みの相手を護衛したと主張してもならない。\n" - "- 自分の護衛先を不用意に公開しない。公開すると翌夜の噛み筋のヒントを" - "人狼側に与えてしまう。\n" - "- 通常の進行中は潜伏を優先する。根拠のない CO は引き続き避ける。\n" - "- 犠牲者が出ない平和な朝は、自分の護衛が成功した可能性が高い。" - "このときは護衛先を添えて騎士 CO する価値が高く、" - "守った相手を真寄り・白寄りに置く材料として村の推理を進められる。\n" - "- 終盤、または自分が吊られそうな局面、自分の CO で確白や真役職を守れる局面では、" - "護衛履歴を日付順に添えて CO することを検討する。\n" - "- 護衛成功を理由に CO するときは必ず護衛先を添える。" - "護衛先を隠した騎士 CO は真偽判定されにくく信用されない。\n" - "- 騎士 CO の護衛履歴は合法でなければならない。" - "自分護衛・同じ相手の連続護衛・死亡済み対象への護衛・存在しない護衛成功主張は含めない。\n" - "- 平和な朝や CO する局面で護衛履歴を日付順に出すときは、" - "捨て護衛を含む夜があったら、なぜ本命を外したか・なぜ次夜の余地を優先したかを" - "1 行だけ添えて説明できる範囲で使う。\n" - "- 護衛先は単体の白さだけでなく、" - "2 人狼仮説上どこが噛まれると狼陣営に得かまで考える。" - "情報役を守ると相方候補のラインがどう変わるか、" - "白位置を残すとどの相方候補が浮くかを短く比較する。\n" - "- 平和な朝や実際の襲撃先からは、" - "どの 2 人狼仮説ならその噛み筋が自然かを昼の推理材料として使う。\n" - "- 騎士 CO 時は護衛履歴に加え、必要に応じて" - "噛み筋がどの相方候補ペア仮説に最も合うかを短く述べる。\n" - "- 占い師 CO が 3 人並び、共有ルール側の『2 人非狼確定で残る 1 人が確定黒級』が成立したと読めた場合、" - "夜の護衛先選びでは、残る占い師 CO 位置からの黒判定で守るべき真情報役・確白寄り位置と、" - "次夜の本命護衛余地を比較する。" - "ただし非狼確定の 2 人を数える基準は印象白ではなく霊媒結果・襲撃死・CO 破綻などに限り、" - "前提が崩れたら護衛方針も再整理する。\n" - "- 対抗 CO 超過分合計 3 で生まれた非 CO 確白級や、単独で対抗のない真寄り情報役は護衛価値が高い。" - "ただし連続護衛不可、襲撃読み、CO 時の説明可能性も合わせて判断する。" - ), - Role.VILLAGER: ( - "- 公開発言の矛盾、視点漏れ、投票理由、占い/霊媒結果との整合性を重視して推理する。\n" - "- 不確実なときは候補を絞り、理由を添えて話す。曖昧な決めつけや" - "『なんとなく怪しい』だけの発言は避ける。\n" - "- 自分に私的情報があるふりをしない。占い/霊媒/騎士の CO 騙りは村陣営としては行わない。\n" - "- 「村人CO」「素村CO」「普通の村人です」「役職は村人です」のように、" - "自分から村人役職を CO して信用を取ろうとしない。" - "村人は能力結果を持たないため CO しても証明にはならず、熟練者は村人 CO を信用材料に使わない。\n" - "- 役職について聞かれた場合も「役職 CO はない」「非 CO の灰」と答えるに留め、" - "「村人を CO する」形にしない。CO ではなく、公開ログ・CO 履歴・判定履歴・投票履歴・噛み筋・縄数の" - "整合性で白さを取る。\n" - "- 情報役を守り、人狼陣営が狙いやすい位置 (真 CO、盤面整理役) を" - "投票で落とさないようにする。\n" - "- 発言の根拠は CO 履歴・判定履歴・投票履歴・噛み筋・縄数のうち今の結論に効く 1〜2 点に絞り、" - "誰のどの発言・票・判定を見たかを短く添える。\n" - "- 公開情報しかない立場として、CO 履歴・判定履歴・投票履歴・噛み筋から 2 人狼仮説を作り、" - "「この人が人狼なら相方候補はこの位置が自然/不自然」までセットで考える。\n" - "- 単体黒要素だけでなく、決選投票での身内票・弱い対抗候補への票逸らし・" - "処刑濃厚な位置への急なライン切りも疑いの材料にする。" - "ただし「対立=ライン切り」と断定せず、偶然の対立や進行上の都合と区別する。\n" - "- 発言では全候補を長く列挙せず、今日の結論に効くペア仮説 1〜2 個だけを短く出す。" - "相方候補は推理用語であり、自分が実際の 2 人狼ペアを知っている前提で語ってはいけない。\n" - "- 占い師 CO が 3 人並んだ盤面で、共有ルール側の『2 人非狼確定なら残る 1 人を確定黒級』を満たすと自分視点で読めた場合は、" - "残る占い師 CO 位置を投票・発言・進行提案で固定配役上の狼位置として扱ってよい。" - "ただし非狼確定の 2 人を数えるときは、印象白や信用未確定 CO の白判定では数えず、" - "霊媒結果・襲撃死・CO 破綻など説明可能な根拠だけを使う。" - "前提が崩れた瞬間 (例: 霊媒師 CO 側の偽が浮上した、CO 撤回が出た) には確定黒扱いをやめて再整理する。\n" - "- 公開ログから占い師・霊媒師・騎士の CO 数と対抗 CO 超過分を毎日整理する。" - "超過分合計が 3 に達したら能力役職 CO していない位置を村陣営の確白級として扱い、" - "発言では誰が CO 群で誰が非 CO 確白かを短く示して投票先を CO 群に絞る。" - "超過分合計が 0〜2 のうちは非 CO 位置を CO 数だけで確白扱いせず、" - "超過分合計が 4 以上に見えたら CO 撤回・村騙り・誤読を疑って時系列から再整理する。\n" - "村人 CO 禁止の方針はそのまま維持する。" - ), +# Role-specific tips. Each role has its own markdown file under +# `prompts/templates/strategy/` (e.g. `strategy/werewolf.md`). The +# cross-leak tests assert each file's vocabulary stays role-scoped: +# the wolf playbook's `相方` / `襲撃先を揃える` must not appear in any +# other role's file, and `本物の人狼位置を知っている前提` (a +# prohibition unique to the madman) must not leak elsewhere. When +# editing a strategy file, keep its bullets focused on that one role. +_STRATEGY_FILE_BY_ROLE: dict[Role, str] = { + Role.WEREWOLF: "strategy/werewolf", + Role.MADMAN: "strategy/madman", + Role.SEER: "strategy/seer", + Role.MEDIUM: "strategy/medium", + Role.KNIGHT: "strategy/knight", + Role.VILLAGER: "strategy/villager", } -def _build_strategy_block(role: Role) -> str: +@cache +def _load_role_strategy(role: Role) -> str: + """Read a role's strategy markdown and return the bullet body. + + The on-disk file starts with a `# 人狼 (WEREWOLF) 戦略` heading + intended for human readers; this helper strips the heading + the + blank line that follows so the LLM sees only the bullet list. The + trailing newline appended on file write is also stripped so the + returned string is byte-equivalent to the legacy inline-dict form + (relevant for the cross-leak substring tests). Cached so repeated + `build_system_prompt` calls within one process touch disk once + per role. + """ + raw = load_template(_STRATEGY_FILE_BY_ROLE[role]) + parts = raw.split("\n", 2) + has_md_heading = len(parts) >= 3 and parts[0].startswith("# ") and parts[1] == "" + body = parts[2] if has_md_heading else raw + return body.rstrip("\n") + + +def build_strategy_block(role: Role) -> str: """Return role-specific tips for the given role only. Caller must pass a non-None Role; `build_system_prompt` is invoked after @@ -666,12 +98,102 @@ def _build_strategy_block(role: Role) -> str: returns other roles' tips, so the system prompt cannot leak strategy between LLM seats. """ - return _ROLE_STRATEGIES[role] + return _load_role_strategy(role) + +# Underscore alias retained for the historical "private" import path used +# inside this module; new external callers (Master arbiter → SpeakRequest) +# use the public `build_strategy_block` name. +_build_strategy_block = build_strategy_block -def _build_speech_profile_block(persona: Persona) -> str: + +def _band(value: float, *, low: str, mid_low: str, mid: str, mid_high: str, high: str) -> str: + """Map a 0.0-1.0 axis to one of five qualitative bands. + + Five-step granularity gives the LLM enough nuance without exposing the + raw float (which would invite spurious precision). Boundaries are + chosen so the neutral 0.5 default sits squarely on `mid`. + """ + if value <= 0.2: + return low + if value <= 0.4: + return mid_low + if value <= 0.6: + return mid + if value <= 0.8: + return mid_high + return high + + +def build_judgment_profile_block(persona: Persona) -> str: + """Render `JudgmentProfile` axes as labeled tendency bands. + + Each axis is mapped to a qualitative band so the LLM has a concrete + behavioural lean without seeing the raw float. The block is paired + with a usage hint that names HARD/MEDIUM facts so the trust axes + have something concrete to attach to. + """ + j = persona.judgment_profile + trust_hard = _band( + j.trust_hard_facts, + low="ほぼ無視 (理屈より直感)", + mid_low="やや軽視", + mid="標準", + mid_high="重視", + high="絶対視 (論理確定は揺るがない)", + ) + trust_medium = _band( + j.trust_medium_facts, + low="ほぼ参考にしない", + mid_low="懐疑的に扱う", + mid="参考程度", + mid_high="やや信用する", + high="基本受け入れる", + ) + contrarian = _band( + j.contrarian_bias, + low="多数派にあえて逆らわない", + mid_low="やや迎合的", + mid="是々非々", + mid_high="多数派に懐疑的", + high="あえて逆張りする傾向", + ) + aggression = _band( + j.aggression, + low="慎重で疑い先を出すのが遅い", + mid_low="控えめに疑う", + mid="標準的に疑い先を出す", + mid_high="積極的に疑い先を指す", + high="即座に処刑候補を名指しする", + ) + bandwagon = _band( + j.bandwagon_tendency, + low="単独行動を好み流れに乗らない", + mid_low="独自路線を好む", + mid="状況次第", + mid_high="形成された流れに乗りやすい", + high="多数派・流れに強く乗る", + ) + return ( + f"- 論理確定 (HARD ファクト) への態度: {trust_hard}\n" + f"- 推測根拠 (MEDIUM ファクト) への態度: {trust_medium}\n" + f"- 多数派への姿勢: {contrarian}\n" + f"- 攻撃性 (疑い→処刑候補名指しまでの速さ): {aggression}\n" + f"- 流れへの追従度: {bandwagon}\n" + "- 上記は判断のクセであり、ルールや論理確定情報を上書きしない。" + "HARD ファクトは原則として受け入れた上で、態度に応じた言い回しに調整する。" + "MEDIUM ファクトは「態度」に応じて採用度合いを変える。" + "この性格を口調と判断の傾きとして表現してください。" + ) + + +def build_speech_profile_block(persona: Persona) -> str: """Render the persona's structured speech profile as a bullet block. + Public function (renamed from the historical underscored name) so the + Master arbiter can render the same block for the reactive_voice NPC + prompt as rounds-mode uses. + Dispatches on `narration_mode`: silent-gesture personas (kukrushka) get a structurally different block — no `一人称` line, gesture examples instead — so callers can assert the structural difference in tests. Per-persona @@ -704,6 +226,13 @@ def _build_speech_profile_block(persona: Persona) -> str: ) +# Underscore aliases for callers (and tests) that imported the historical +# private names. The reactive_voice NPC system-prompt builder calls the +# public names directly. +_build_speech_profile_block = build_speech_profile_block +_build_judgment_profile_block = build_judgment_profile_block + + def build_system_prompt( persona: Persona, role: Role, @@ -724,9 +253,10 @@ def build_system_prompt( return ( template.replace("{game_rules_block}", _build_game_rules_block()) .replace("{persona_block}", persona_block) - .replace("{speech_profile_block}", _build_speech_profile_block(persona)) + .replace("{judgment_profile_block}", build_judgment_profile_block(persona)) + .replace("{speech_profile_block}", build_speech_profile_block(persona)) .replace("{role_block}", role_block) - .replace("{strategy_block}", _build_strategy_block(role)) + .replace("{strategy_block}", build_strategy_block(role)) .replace("{phase_block}", phase_block) .replace("{task_block}", task_text) ) @@ -764,6 +294,7 @@ def build_user_context( public_logs: Sequence[dict[str, object]], private_logs: Sequence[dict[str, object]], last_own_public: str | None = None, + deduced_facts_block: str | None = None, ) -> str: seats_by_no = {s.seat_no: s for s in seats} alive_players = [p for p in players if p.alive] @@ -802,6 +333,15 @@ def _format_log(log: dict[str, object], *, attributed_kinds: tuple[str, ...]) -> rope_block = _format_rope_block(players) + facts_section = "" + if deduced_facts_block: + facts_section = ( + "\n## 公開情報からの確定/推測事実 (Master 整理)\n" + f"{deduced_facts_block}\n" + "HARD は論理的に確定。MEDIUM は強めの推測。" + "判断傾向に応じて態度を変えてよいが、HARD を覆す論拠は公開ログにある具体物だけにする。\n" + ) + return ( f"あなたは座席 {my_seat.seat_no}『{my_seat.display_name}』です。\n" f"生存者: {alive_names}\n" @@ -810,6 +350,7 @@ def _format_log(log: dict[str, object], *, attributed_kinds: tuple[str, ...]) -> f"{wolf_partner_block}" "\n" f"{rope_block}\n" + f"{facts_section}" "\n" "## あなたの私的メモ (他者には非公開)\n" f"{priv_block}\n" @@ -823,41 +364,40 @@ def _format_log(log: dict[str, object], *, attributed_kinds: tuple[str, ...]) -> # ---------------------------------------------------------- task blocks +_TASK_DAYTIME_SPEECH_TEMPLATE = "master/task_daytime_speech" +_TASK_VOTE_TEMPLATE = "master/task_vote" +_TASK_NIGHT_ACTION_TEMPLATE = "master/task_night_action" +_TASK_WOLF_CHAT_TEMPLATE = "master/task_wolf_chat" + + def task_daytime_speech( day_number: int, discussion_round: int | None = None, *, role: Role | None = None, ) -> str: - base = ( - f"現在は day {day_number} の議論フェイズです。" - " 必要と感じた場合のみ `intent=speak` を返し、`public_message` に 80〜300 字で短い発言を書いてください。" - " 発言したくない場合は `intent=skip` と明示してください。" - " 疑い先を出すときは、単体黒要素だけでなく、その人物が人狼なら相方候補は誰か、" - "2 人狼セットとして票筋・噛み筋が自然かも必要に応じて短く触れてください。" - "全候補のペアを長く列挙せず、今の結論に効く 1〜2 点だけを出してください。" + """Day-discussion task instruction. + + Body lives in ``master/task_daytime_speech.md``. Two optional + paragraphs are gated by template ``{{#if}}`` blocks: + + * ``include_day2_round1_results_block`` — turns on when + ``day_number >= 2 and discussion_round == 1`` so the LLM is + reminded to surface previous-night ability results in their + first speech of the day. + * ``include_day1_round1_wolf_madman_block`` — wolf/madman-only + branch on day-1 round-1 that walks the 占い師騙り / 霊媒師騙り + / 潜伏 triad. Other roles never see partner / fake-CO tactics. + """ + return render_template( + _TASK_DAYTIME_SPEECH_TEMPLATE, + day_number=day_number, + co_claim_options=format_co_claim_options(separator=" / "), + include_day2_round1_results_block=(day_number >= 2 and discussion_round == 1), + include_day1_round1_wolf_madman_block=( + day_number == 1 and discussion_round == 1 and role in (Role.WEREWOLF, Role.MADMAN) + ), ) - if day_number >= 2 and discussion_round == 1: - base += ( - " これは day 2 以降の 1 巡目発言です。" - " 占い師・霊媒師・騎士として CO 済み、または今 CO する場合は、" - "前夜の能力結果をこの発言で添えてください。" - " 占い師なら対象 + 白/黒 + 占い理由、" - "霊媒師なら前日処刑者 + 人狼/人狼ではない/結果なし、" - "騎士なら CO する局面で合法な護衛履歴 (護衛日 + 護衛先) を日付順に出し、" - "平和な朝なら護衛成功も合わせて主張します。" - " 結果を持つ、または結果を主張する役職 CO が 1 巡目で結果を出さないと、" - "信用低下や破綻疑いにつながります。" - ) - if day_number == 1 and discussion_round == 1 and role in (Role.WEREWOLF, Role.MADMAN): - base += ( - " day 1 の 1 巡目で偽 CO を選ぶ場合、占い師騙り・霊媒師騙り・潜伏の 3 択を比較してください。" - " 霊媒師騙りに出ると、真霊媒との 1-2、または別の騙り狼との組み合わせで 2-2 を作りに行けます。" - " 占い師騙り CO 数・縄数・潜伏で白を取る余地と比べて、得な方を選びます。" - " 霊媒師騙りで day 1 に CO する場合は、まだ処刑がないので初日の霊媒結果は出さず、" - "翌日処刑があった時に結果を出す立ち回りだと添えてください。" - ) - return base def task_vote( @@ -867,118 +407,67 @@ def task_vote( role: Role | None = None, wolf_partner_tokens: Sequence[str] = (), ) -> str: - """Candidates are `席{N} {display_name}` tokens; target_name must echo one back. - - `role` + `wolf_partner_tokens` are an additive, wolf-only enrichment: when the - voter is a werewolf and at least one alive partner exists, append a checklist - that names the partner and walks熟練狼's vote-discipline tradeoffs (身内票 / - ライン切り / 票逸らしリスク / 決選投票). Other roles always get the base text - so partner identity and wolf-side voting tactics never reach non-wolf prompts. + """Vote-phase task instruction. + + Body lives in ``master/task_vote.md``. Candidates are + ``席{N} {display_name}`` tokens; target_name must echo one back. + + ``role`` + ``wolf_partner_tokens`` are an additive, wolf-only + enrichment: the template's ``{{#if has_wolf_block}}`` branch + appends a checklist that names the partner and walks 熟練狼's + vote-discipline tradeoffs (身内票 / ライン切り / 票逸らしリスク + / 決選投票). Other roles flip ``has_wolf_block`` off so partner + identity and wolf-side voting tactics never reach non-wolf + prompts. """ - names = "、".join(candidate_tokens) - runoff_note = "これは決選投票です。" if runoff else "" - base = ( - f"{runoff_note}投票先として合法な候補は: {names}\n" - " `intent=vote`、`target_name` に候補トークン (例: `席3 Alice`) のいずれかを" - " 厳密に一致させて返してください。`席番号` を含めないと同名の別席と区別できません。" - " どうしても棄権したい場合は `intent=skip` を返し、`target_name` は `null` にします。\n" - "- 投票先は単体黒要素だけでなく、その人物が人狼なら相方候補は誰か、" - "2 人狼セットとして票筋・噛み筋が自然かも考えて選んでください。" - "投票理由は長くせず、最終判断で相方候補との整合を加味します。" - ) - if role is not Role.WEREWOLF or not wolf_partner_tokens: - return base - partners = "、".join(wolf_partner_tokens) - runoff_extra = ( - "\n- 決選投票では、相方救済の成功見込み、自分の透けリスク、" - "相方を切って翌日以降に残る価値、PP/RPP の近さと縄数を比較してください。" - if runoff - else "" + has_wolf_block = role is Role.WEREWOLF and bool(wolf_partner_tokens) + return render_template( + _TASK_VOTE_TEMPLATE, + runoff_note="これは決選投票です。" if runoff else "", + names="、".join(candidate_tokens), + has_wolf_block=has_wolf_block, + partners="、".join(wolf_partner_tokens) if has_wolf_block else "", + runoff=runoff and has_wolf_block, ) - wolf_block = ( - "\n\nあなたは人狼です。仲間の人狼: " - f"{partners}。" - "\n- 投票は翌日以降に票筋として読まれます。" - "仲間が公開ログ上で処刑濃厚なときに自分だけ弱い別候補へ票を逸らすと、" - "相方を庇った狼として透けやすくなります。" - "\n- 相方を救えない局面では、身内票やライン切りで相方へ投票することも検討してください。" - "投票理由は公開ログ上の発言・CO 矛盾・票筋・視点漏れに沿って自然に作り、" - "前日までの自分の発言や騙り結果と矛盾しないようにします。" - "\n- 相方を救う票は、自然に票が集まり得る対抗候補がいて、" - "自分の過去発言・投票理由・騙り結果と矛盾しないときだけ選んでください。" - "根拠の薄い票逸らしは狼ラインを濃くします。" - "\n- 救う票・切る票・票逸らしのどれも、翌日以降に自分と相方の 2 人狼セットとして読まれても" - "説明できるかを必ず確認してください。" - f"{runoff_extra}" - "\n- 最後は必ず合法候補トークンから 1 名を返します。" - "困っても安易に skip しないでください。" - ) - return base + wolf_block def task_night_action(kind: SubmissionType, candidate_tokens: Sequence[str]) -> str: - """Candidates are `席{N} {display_name}` tokens; target_name must echo one back.""" - names = "、".join(candidate_tokens) + """Night-action task instruction. + + Body lives in ``master/task_night_action.md``. Candidates are + ``席{N} {display_name}`` tokens; target_name must echo one back. + + Three role-scoped advice paragraphs (wolf-attack value scoring, + knight-guard tradeoffs, seer-divine target value) are gated by + template ``{{#if}}`` blocks keyed off the action kind so each + role only sees its own decision checklist. + """ label = { SubmissionType.WOLF_ATTACK: "襲撃", SubmissionType.SEER_DIVINE: "占い", SubmissionType.KNIGHT_GUARD: "護衛", }[kind] - extra = "" - if kind is SubmissionType.WOLF_ATTACK: - extra = ( - " 仲間の人狼が人狼チャットで案を出している場合、強い反対理由がなければ" - " その案に合わせてください。意見が割れると襲撃が空振りになります。\n" - " 候補ごとに「襲撃価値」「護衛されやすさ」「騎士候補度」「翌日の説明しやすさ」" - "を短く比較してから 1 名に決めてください。" - " 単独真寄りの情報役・確白寄り・進行役は襲撃価値も護衛されやすさも高い両刃です。" - " 騎士っぽい相手を「騎士探し」として先に噛む選択も忘れずに検討してください。" - ) - elif kind is SubmissionType.KNIGHT_GUARD: - extra = ( - " 候補ごとに「鉄板護衛すべき価値」" - "「今夜実際に噛まれそうか (護衛を読まれて避けられそうか含む)」" - "「GJ の価値」「次夜に同じ相手を守れないリスク」" - "「捨て護衛で本命護衛余地を残す価値」" - "を短く比較してから 1 名に決めてください。" - " 捨て護衛を選ぶ場合も、この bot では合法候補から 1 名を選びます。" - " 未提出や対象なしや skip にしてはいけません。" - ) - elif kind is SubmissionType.SEER_DIVINE: - extra = ( - " 候補ごとに「占い価値 (灰を狭める効果、CO 真偽比較への寄与、自分視点の進行整理)」" - "「対抗 CO の白先や囲い候補と比較して真偽判定に効くか」" - "「投票・票変え・誘導との整合 (浮いた位置・根拠の薄い強い誘導をしている位置)」" - "「白でも黒でも情報が落ちるか (白なら進行役候補、黒なら処刑提案)」" - "を短く比較してから 1 名に決めてください。" - " 既に自分が占った位置・今日ほぼ処刑されそうな位置・" - "公開情報で十分処理対象になっている位置・極端に発言が少なく白黒どちらでも議論が伸びにくい位置は占い価値が下がります。" - " 今夜襲撃される可能性が高い位置でも、結果が CO 真偽や囲い疑惑の整理に直結するなら候補に残してよいです" - "(死亡しても占い結果は得られます)。" - ) - return ( - f"夜です。{label} 対象を 1 名選んでください。合法候補: {names}\n" - " `intent=night_action`、`target_name` に候補トークン (例: `席3 Alice`) のいずれかを" - " 厳密に一致させて返してください。`席番号` を含めないと同名の別席と区別できません。" - f"{extra}" + return render_template( + _TASK_NIGHT_ACTION_TEMPLATE, + label=label, + names="、".join(candidate_tokens), + is_wolf_attack=kind is SubmissionType.WOLF_ATTACK, + is_knight_guard=kind is SubmissionType.KNIGHT_GUARD, + is_seer_divine=kind is SubmissionType.SEER_DIVINE, ) def task_wolf_chat(partner_tokens: Sequence[str], candidate_tokens: Sequence[str]) -> str: - """Ask a wolf to post a short coordination message to the wolves-only chat.""" - partners = "、".join(partner_tokens) if partner_tokens else "(なし)" - names = "、".join(candidate_tokens) - return ( - f"夜になりました。仲間の人狼: {partners}。人狼チャット (村人には非公開) で" - f" 襲撃対象を調整してください。候補: {names}\n" - " `intent=speak` と `public_message` に 1 名の襲撃候補とその理由を" - " 80〜150 字で書いてください。" - " 理由には「襲撃価値 (情報役噛み/白位置噛み/意見噛み/騎士探し/SG 残しのどれか)」" - "「護衛されそうか」「本人が騎士っぽいか (騎士候補)」「相方案への賛否」のうち" - " 重要な 1〜2 点を含めてください。" - " 仲間が既に案を出している場合は、護衛リスクと襲撃価値を比較したうえで" - " 最終的に 1 人に揃えることを優先してください。" - " 話すことがなければ `intent=skip` を返してください。" + """Wolf-chat coordination task instruction. + + Body lives in ``master/task_wolf_chat.md``. Asks a wolf to post a + short coordination line to the wolves-only chat naming the + intended attack target with concise reasoning. + """ + return render_template( + _TASK_WOLF_CHAT_TEMPLATE, + partners="、".join(partner_tokens) if partner_tokens else "(なし)", + names="、".join(candidate_tokens), ) diff --git a/src/wolfbot/llm/template/__init__.py b/src/wolfbot/llm/template/__init__.py new file mode 100644 index 0000000..ccf26ae --- /dev/null +++ b/src/wolfbot/llm/template/__init__.py @@ -0,0 +1,48 @@ +"""Markdown prompt template engine — load + render with placeholder substitution. + +Templates live under :mod:`wolfbot.prompts.templates` (file-system path +``src/wolfbot/prompts/templates``) and are referenced by a +slash-separated id like ``"master/task_vote"`` (no ``.md`` suffix). + +Two responsibilities split into two modules: + +* :func:`load_template` (``loader``) — find the template on disk, return + the raw string. LRU-cached so prompt files are read at most once per + process. +* :func:`render` (``parser``) — substitute ``{{var}}`` placeholders and + evaluate ``{{#if var}}...{{/if}}`` conditional blocks against a + caller-supplied context dict. + +The two-pieces split lets unit tests cover the parser in isolation +(no I/O) while the loader handles only the FS shape. Callers usually +chain them via :func:`render_template`. + +Why a tiny in-house engine rather than Jinja2 / Mustache: +- The placeholder syntax we need is intentionally minimal — no loops, + no filters, no auto-escaping. Anything more complex stays in Python. +- Zero new dependency for a 9-player Werewolf bot. +- Predictable error surface: a missing variable raises immediately + with the placeholder name, instead of silently rendering as empty. +""" + +from __future__ import annotations + +from wolfbot.llm.template.loader import ( + TEMPLATES_ROOT, + TemplateNotFoundError, + load_template, +) +from wolfbot.llm.template.parser import ( + TemplateRenderError, + render, + render_template, +) + +__all__ = [ + "TEMPLATES_ROOT", + "TemplateNotFoundError", + "TemplateRenderError", + "load_template", + "render", + "render_template", +] diff --git a/src/wolfbot/llm/template/loader.py b/src/wolfbot/llm/template/loader.py new file mode 100644 index 0000000..f3b0b0c --- /dev/null +++ b/src/wolfbot/llm/template/loader.py @@ -0,0 +1,82 @@ +"""Locate and read .md template files under ``prompts/templates``. + +Public API: + +* :data:`TEMPLATES_ROOT` — the on-disk directory used as the search + base. Tests can monkeypatch this to point at a fixture dir. +* :func:`load_template` — given a slash-separated id (``"master/task_vote"`` + → ``master/task_vote.md``), return the raw template body as a string. + Caches by template id so repeated calls during one process share the + same string. Raises :class:`TemplateNotFoundError` when the file + doesn't exist. + +The loader is deliberately minimal — it does not parse / render. That +job lives in :mod:`wolfbot.llm.template.parser` so unit tests can cover +parsing without touching the filesystem. +""" + +from __future__ import annotations + +from functools import cache +from pathlib import Path + +TEMPLATES_ROOT: Path = ( + Path(__file__).resolve().parent.parent.parent / "prompts" / "templates" +) +"""Filesystem directory containing the .md templates. + +Resolved relative to this module so the layout works regardless of +where the project is installed (editable, wheel, frozen). All template +ids are interpreted relative to this root. +""" + + +class TemplateNotFoundError(FileNotFoundError): + """Raised when :func:`load_template` is asked for a missing id. + + Inherits from ``FileNotFoundError`` so callers that just want + "treat as missing file" can keep their existing handlers; the + distinct subclass name surfaces in tracebacks where the difference + matters (template-id typo vs unrelated I/O failure). + """ + + +def _resolve(template_id: str) -> Path: + """Map ``"master/task_vote"`` → ``/master/task_vote.md``. + + Refuses absolute paths and ``..`` components defensively — template + ids must stay inside the templates root. + """ + if not template_id: + msg = "template id must be non-empty" + raise ValueError(msg) + parts = template_id.split("/") + for part in parts: + if not part or part in (".", ".."): + msg = f"illegal template id segment in {template_id!r}" + raise ValueError(msg) + return TEMPLATES_ROOT.joinpath(*parts).with_suffix(".md") + + +@cache +def load_template(template_id: str) -> str: + """Read a template file by id. + + The id is the path under :data:`TEMPLATES_ROOT` without the ``.md`` + extension, using forward slashes (``"npc/decision_vote_user"``). + + LRU-cached unbounded — templates are tiny and bounded in count, so + one cache miss per id per process is fine. Tests that need a fresh + read should call :func:`load_template.cache_clear`. + """ + path = _resolve(template_id) + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError as exc: + msg = ( + f"template not found: {template_id!r} (resolved to {path})" + ) + raise TemplateNotFoundError(msg) from exc + + +__all__ = ["TEMPLATES_ROOT", "TemplateNotFoundError", "load_template"] diff --git a/src/wolfbot/llm/template/parser.py b/src/wolfbot/llm/template/parser.py new file mode 100644 index 0000000..8d87f49 --- /dev/null +++ b/src/wolfbot/llm/template/parser.py @@ -0,0 +1,141 @@ +"""Render a markdown template by substituting placeholders. + +Syntax: + +* ``{{name}}`` — simple substitution. ``name`` is a snake-case + identifier that must exist in the supplied context. Missing keys + raise :class:`TemplateRenderError` immediately (fail-fast); rendering + to an empty string would silently corrupt the prompt. +* ``{{#if name}}...{{/if}}`` — conditional block. The body is included + iff ``context[name]`` is truthy (``bool(value)``). Nesting is + supported. Unknown ``name`` is treated as falsy (block omitted) — + this matches how callers typically phrase optional sections without + needing a separate "is the variable defined" check. + +Deliberately not supported: +* loops (``{{#each}}``) — rendered lists belong in Python where the + separator / formatting can be tested per item. +* filters / auto-escape — prompt text is plain markdown bound for an + LLM, not HTML. +* nested attribute access (``{{persona.name}}``) — flatten the context + dict at the call site instead. + +If the syntax above is ever insufficient, escalate to Jinja2 — don't +extend this module ad-hoc. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from typing import Any + +from wolfbot.llm.template.loader import load_template + +_PLACEHOLDER_RE = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}") +# Tempered-greedy body: match anything that does NOT start a nested +# ``{{#if`` opener, so a single regex pass picks up the *innermost* if +# blocks first. The caller loop expands repeatedly until no openers +# remain — by that point each outer block has had its inner blocks +# replaced, so the now-flat outer becomes innermost on the next pass. +# Without this, ``S{{#if A}}-{{#if B}}I{{/if}}-{{/if}}E`` would match +# ``{{#if A}}-{{#if B}}I{{/if}}`` greedily on the first pass and drop +# the trailing ``-{{/if}}E`` outside the regex. +_IF_BLOCK_RE = re.compile( + r"\{\{#if\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}" + r"(?P(?:(?!\{\{#if\b).)*?)" + r"\{\{/if\}\}", + re.DOTALL, +) + + +class TemplateRenderError(KeyError): + """A required ``{{var}}`` had no entry in the render context. + + Inherits from ``KeyError`` so callers that intentionally want to + catch "missing variable" can use the broader builtin; the distinct + subclass name carries the template id for log triage. + """ + + def __init__(self, variable: str, *, template_id: str | None = None) -> None: + suffix = f" (template {template_id!r})" if template_id else "" + super().__init__( + f"missing required template variable {variable!r}{suffix}" + ) + self.variable = variable + self.template_id = template_id + + +def _expand_if_blocks(text: str, context: Mapping[str, Any]) -> str: + """Resolve ``{{#if var}}...{{/if}}`` repeatedly until none remain. + + Inner-first expansion supports nesting because :data:`_IF_BLOCK_RE` + is non-greedy on ``body`` and the loop walks until the regex finds + no more matches. Without the loop, an outer block whose body + contains an inner ``{{/if}}`` would be matched as one greedy span. + """ + while True: + match = _IF_BLOCK_RE.search(text) + if match is None: + return text + var = match.group(1) + body = match.group("body") + keep = bool(context.get(var)) + replacement = body if keep else "" + text = text[: match.start()] + replacement + text[match.end() :] + + +def _expand_placeholders( + text: str, + context: Mapping[str, Any], + *, + template_id: str | None, +) -> str: + """Replace every ``{{var}}`` with ``str(context[var])``. + + Missing keys raise :class:`TemplateRenderError` immediately so a + typo never silently produces a half-rendered prompt. + """ + + def _sub(match: re.Match[str]) -> str: + name = match.group(1) + if name not in context: + raise TemplateRenderError(name, template_id=template_id) + return str(context[name]) + + return _PLACEHOLDER_RE.sub(_sub, text) + + +def render( + template_text: str, + /, + *, + template_id: str | None = None, + **context: Any, +) -> str: + """Render a raw template string against ``**context``. + + Order: ``{{#if}}`` blocks first, then ``{{var}}`` substitution. + Reverse order would force callers to escape placeholders inside an + ``{{#if}}`` body that should also reference variables. + + ``template_id`` is purely for error messages — pass it when the + text was loaded from disk so :class:`TemplateRenderError` includes + the id in its message. + """ + expanded = _expand_if_blocks(template_text, context) + return _expand_placeholders(expanded, context, template_id=template_id) + + +def render_template(template_id: str, /, **context: Any) -> str: + """Convenience: load a template by id and render it in one call.""" + return render( + load_template(template_id), template_id=template_id, **context + ) + + +__all__ = [ + "TemplateRenderError", + "render", + "render_template", +] diff --git a/src/wolfbot/main.py b/src/wolfbot/main.py index cf2037d..b3deb6b 100644 --- a/src/wolfbot/main.py +++ b/src/wolfbot/main.py @@ -6,15 +6,19 @@ import contextlib import logging import signal +import time +from typing import Any import discord from discord.ext import commands from dotenv import load_dotenv -from wolfbot.config import Settings +from wolfbot.config import MasterSettings +from wolfbot.domain.enums import Phase from wolfbot.persistence.schema import migrate from wolfbot.persistence.sqlite_repo import SqliteRepo from wolfbot.services.discord_service import DiscordBotAdapter, WolfCog +from wolfbot.services.discussion_service import DiscussionService, SqliteSpeechEventStore from wolfbot.services.game_service import GameService from wolfbot.services.llm_service import LLMAdapter, make_llm_decider from wolfbot.services.recovery_service import RecoveryService @@ -24,8 +28,9 @@ async def _run() -> None: - load_dotenv() - settings = Settings() # type: ignore[call-arg] + load_dotenv(".env.master") + settings = MasterSettings() # type: ignore[call-arg] + settings.apply_phase_durations() logging.basicConfig( level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO), format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", @@ -38,16 +43,638 @@ async def _run() -> None: intents = discord.Intents.default() intents.message_content = True intents.members = True + intents.voice_states = True bot = commands.Bot(command_prefix="!", intents=intents) registry = EngineRegistry() discord_adapter = DiscordBotAdapter(bot=bot, repo=repo, settings=settings) - decider = make_llm_decider(settings) - llm_adapter = LLMAdapter(repo=repo, decider=decider, message_poster=discord_adapter) - game_service = GameService(repo=repo, discord=discord_adapter, llm=llm_adapter, wake=registry) + # NpcRegistry is created right below; late-bind so `apply_permissions` + # can server-mute NPC bots on phase changes / death. + + # NPC registry is always created so /wolf start can consult it for + # reactive_voice seat backfill. The WS server (which actually accepts + # NPC bot connections) is only started when MASTER_NPC_PSK is set. + from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry + + npc_registry = InMemoryNpcRegistry() + discord_adapter.set_npc_registry(npc_registry) + + speech_store = SqliteSpeechEventStore(repo._db) + + class _DiscussionPoster: + """Adapts DiscordBotAdapter.post_public(Game, ...) to the + SpeechMessagePoster protocol which takes a bare game_id string. + Loads the Game from the repo so the adapter gets the channel id + it needs. + """ + + async def post_public(self, game_id: str, text: str, kind: str) -> None: + game = await repo.load_game(game_id) + if game is None: + return + # In reactive_voice mode each NPC bot posts its own utterance + # directly to VC chat from its own account (see + # `NpcClient.on_post_chat`), so a duplicate Master-side post + # would either repeat NPC lines or surface human STT + # transcripts that the user would rather not see twice. Skip + # PLAYER_SPEECH unconditionally for this mode; rounds mode + # still goes through the legacy adapter path. + if game.discussion_mode == "reactive_voice" and kind == "PLAYER_SPEECH": + return + await discord_adapter.post_public(game, text, kind) + + discussion_service = DiscussionService( + store=speech_store, + log_sink=repo, + message_poster=_DiscussionPoster(), + ) + + decider = make_llm_decider(settings.gameplay_decider_config()) + llm_adapter = LLMAdapter( + repo=repo, + decider=decider, + message_poster=discord_adapter, + discussion_service=discussion_service, + ) + # The reactive_phase_enter callback is set below once the arbiter exists. + _reactive_phase_cb: list[Any] = [] + # Optional NPC registry reference for seat assignment on phase enter. + _npc_registry_ref: list[Any] = [] + # voice-ingest seat/phase caches (populated when integrated ingest is active) + _vc_seat_map: dict[str, int] = {} + _vc_phase_cache: list[tuple[str, str] | None] = [None] + # Alive seats as ``(seat_no, display_name)`` so the analyzer LLM + # can resolve mistranscribed names to a canonical participant. + _vc_roster: list[tuple[int, str]] = [] + + async def _refresh_voice_ingest_cache(game_id: str) -> None: + """Update seat map and phase cache for the integrated voice-ingest. + + ``_vc_roster`` holds the alive seats in ``(seat_no, display_name)`` + form for the analyzer LLM's name-resolution prompt. The + ``display_name`` here is the **live VC nickname** as Discord + renders it for that participant - i.e. what the human speaker + actually sees on their VC overlay - not the stored + ``Seat.display_name`` (which for NPC seats is the persona's + canonical handle and may differ from the Discord bot's + guild-side nickname). Falls back to the stored value when the + member is uncached or the bot user_id isn't known yet. + """ + game = await repo.load_game(game_id) + if game is None or game.ended_at is not None: + _vc_phase_cache[0] = None + return + from wolfbot.domain.discussion import make_phase_id as _mkpi + phase_id = _mkpi(game.id, game.day_number, game.phase) + _vc_phase_cache[0] = (game.id, phase_id) + seats = await repo.load_seats(game_id) + players = await repo.load_players(game_id) + alive_seats = {p.seat_no for p in players if p.alive} + _vc_seat_map.clear() + for s in seats: + if s.discord_user_id and s.seat_no in alive_seats: + _vc_seat_map[s.discord_user_id] = s.seat_no + + # Resolve each seat's live VC display name. NPC seats have + # ``discord_user_id=NULL`` in the seats table, so cross- + # reference NpcRegistry to pick up the bot's actual user id + # (each NPC bot logs in as its own Discord user). + try: + guild = bot.get_guild(int(game.guild_id)) if game.guild_id else None + except (TypeError, ValueError): + guild = None + npc_user_by_seat: dict[int, str] = {} + if _npc_registry_ref: + registry = _npc_registry_ref[0] + for entry in registry.assigned_to_game(game.id): + if entry.assigned_seat is not None: + npc_user_by_seat[entry.assigned_seat] = entry.discord_bot_user_id + + _vc_roster.clear() + for s in sorted(seats, key=lambda x: x.seat_no): + if s.seat_no not in alive_seats: + continue + user_id_str = s.discord_user_id or npc_user_by_seat.get(s.seat_no) + live_name: str | None = None + if guild is not None and user_id_str: + try: + member = guild.get_member(int(user_id_str)) + except (TypeError, ValueError): + member = None + if member is not None: + live_name = member.display_name + _vc_roster.append((s.seat_no, live_name or s.display_name)) + + # ---- Master VC join lifecycle --------------------------------------- + # Single VC connection; one Master = one guild = at most one active + # reactive_voice game at a time. Held in a list so closures can rebind. + master_vc_ref: list[Any] = [None] + master_vc_lock = asyncio.Lock() + + async def _master_join_vc_for_game(game: Any) -> None: + """Join the game's VC if reactive_voice + voice_ingest is active. + + Idempotent: returns immediately when the existing connection is + still healthy. Called from `_on_reactive_phase_enter` and from + recovery so a Master restart mid-game re-joins. In rounds mode + Master never joins VC at all. + """ + if voice_ingest is None: + return + if getattr(game, "discussion_mode", None) != "reactive_voice": + return + async with master_vc_lock: + existing = master_vc_ref[0] + if existing is not None: + try: + if existing.is_connected(): + return + except Exception: + pass + master_vc_ref[0] = None + from discord.ext import voice_recv + + from wolfbot.master.voice.audio_sink import WolfbotAudioSink + from wolfbot.master.voice.voice_recv_dave_patch import ( + apply_dave_decrypt_patch, + ) + from wolfbot.master.voice.voice_recv_resilience import ( + apply_packet_router_resilience, + ) + + # Patch upstream so a single `OpusError("corrupted stream")` can + # no longer kill the RX thread — without this, one bad packet + # silences the entire reactive_voice pipeline for the rest of + # the game (no STT, no NPC dispatch). + apply_packet_router_resilience() + # Layer DAVE (E2EE voice) inner decrypt on top of voice_recv's + # outer AEAD. Without this, channels with E2EE enabled deliver + # MLS-encrypted opus that the decoder can't read, manifesting + # as a flood of `corrupted stream` warnings and zero usable + # audio (the ``e5660c02f79a`` debug dumps before this patch + # were the canonical symptom). See voice_recv_dave_patch.py. + apply_dave_decrypt_patch() + + try: + channel_id = int(game.main_vc_channel_id) + except (TypeError, ValueError): + log.warning( + "master_vc_channel_id_invalid game=%s value=%r", + game.id, + game.main_vc_channel_id, + ) + return + vc_channel = bot.get_channel(channel_id) + if vc_channel is None or not isinstance(vc_channel, discord.VoiceChannel): + log.warning( + "master_vc_channel_not_found id=%s", channel_id + ) + return + try: + vc_client = await vc_channel.connect(cls=voice_recv.VoiceRecvClient) + sink = WolfbotAudioSink( + voice_ingest, loop=asyncio.get_running_loop() + ) + # Wrap with SilenceGeneratorSink so gaps in Discord's + # opus stream (the user pausing mid-utterance) get + # padded with synthetic 20ms silence frames. Without + # this, our PCM buffer concatenates only the spoken + # frames and the resulting WAV plays back time- + # compressed — Whisper sees garbled audio and falls + # back to its boilerplate hallucinations + # ("ご視聴ありがとうございました"). + vc_client.listen(voice_recv.SilenceGeneratorSink(sink)) + master_vc_ref[0] = vc_client + log.info( + "master_vc_joined channel=%s game=%s", channel_id, game.id + ) + except Exception: + log.warning( + "master_vc_join_failed channel=%s", + channel_id, + exc_info=True, + ) + + async def _master_leave_vc() -> None: + """Disconnect Master from VC. Idempotent.""" + async with master_vc_lock: + vc = master_vc_ref[0] + if vc is None: + return + try: + if vc.is_connected(): + await vc.disconnect() + log.info("master_vc_left") + except Exception: + log.exception("master_vc_leave_failed") + finally: + master_vc_ref[0] = None + + async def _push_private_state_snapshot( + npc_send: Any, + *, + npc_id: str, + game_id: str, + seat_no: int, + persona_key: str, + ) -> None: + """Build and push a `PrivateStateSnapshot` for the seat the NPC plays. + + Called after `SeatAssigned` so the NPC bot has its full Phase-D + state (role, partner wolves, role-result histories) before any + decision is requested. Best-effort — a failed send is logged and + skipped; the NPC will see the historical empty-state behavior on + decision requests until the next snapshot opportunity (re-register). + """ + from wolfbot.master.state.private_state import ( + build_snapshot_for_seat, + load_private_state_for_seat, + ) + + try: + game = await repo.load_game(game_id) + seats = await repo.load_seats(game_id) + players = await repo.load_players(game_id) + except Exception: + log.exception( + "private_state_snapshot_load_failed npc=%s seat=%d game=%s", + npc_id, seat_no, game_id, + ) + return + if game is None: + log.warning( + "private_state_snapshot_skip_no_game npc=%s seat=%d game=%s", + npc_id, seat_no, game_id, + ) + return + me = next((p for p in players if p.seat_no == seat_no), None) + if me is None or me.role is None: + log.warning( + "private_state_snapshot_skip_no_role npc=%s seat=%d game=%s", + npc_id, seat_no, game_id, + ) + return + # Pull persisted role-specific history (NIGHT_0 random white, + # past divinations / mediums / guards / wolf chat) from the + # logs_private + night_actions tables. Without this the seer + # NPC sees no concrete data to CO with on day 1, even though + # the strategy block tells them to declare early — which is + # exactly the day-1 silence the live game hit. + ( + seer_results, + medium_results, + guard_history, + wolf_chat_history, + wolf_attack_history, + ) = await load_private_state_for_seat( + repo, + game_id=game_id, + seat_no=seat_no, + role=me.role, + players=players, + seats=seats, + ) + snapshot = build_snapshot_for_seat( + npc_id=npc_id, + game_id=game_id, + seat_no=seat_no, + persona_key=persona_key, + role=me.role, + day_number=game.day_number, + players=players, + seats=seats, + seer_results=seer_results, + medium_results=medium_results, + guard_history=guard_history, + wolf_chat_history=wolf_chat_history, + wolf_attack_history=wolf_attack_history, + ts=int(asyncio.get_running_loop().time() * 1000), + trace_id=f"snapshot-{game_id}-{seat_no}", + ) + try: + await npc_send(snapshot.model_dump_json()) + except Exception: + log.exception( + "private_state_snapshot_send_failed npc=%s seat=%d", + npc_id, seat_no, + ) + + async def _assign_online_npcs_to_seats(game_id: str) -> int: + """Pair online NPC bots with unassigned LLM seats and tell them to + join VC. Idempotent (already-assigned NPCs are skipped). Returns + the number of NEW assignments dispatched in this call — callers + use it to decide whether to wait for VC join confirmations. + """ + if not _npc_registry_ref or _vc_phase_cache[0] is None: + return 0 + from wolfbot.domain.ws_messages import SeatAssigned + + _game_id, phase_id = _vc_phase_cache[0] + npc_reg = _npc_registry_ref[0] + seats = await repo.load_seats(game_id) + llm_seats = [s for s in seats if s.is_llm] + online = npc_reg.all_online() + assigned_npc_ids = { + e.npc_id for e in online if e.assigned_seat is not None and e.game_id == game_id + } + unassigned_npcs = [e for e in online if e.npc_id not in assigned_npc_ids] + unassigned_seats = [ + s for s in llm_seats + if not any(e.assigned_seat == s.seat_no and e.game_id == game_id for e in online) + ] + dispatched = 0 + for npc_entry, seat in zip(unassigned_npcs, unassigned_seats, strict=False): + npc_reg.assign(npc_entry.npc_id, seat=seat.seat_no, + game_id=game_id, phase_id=phase_id) + log.info("npc_seat_assigned npc=%s seat=%d game=%s", + npc_entry.npc_id, seat.seat_no, game_id) + # Tell the picked bot to join VC. Unselected NPCs receive + # nothing and stay idle (no VC). + if npc_entry.send is not None: + try: + msg = SeatAssigned( + ts=int(asyncio.get_running_loop().time() * 1000), + trace_id=f"assign-{game_id}-{seat.seat_no}", + npc_id=npc_entry.npc_id, + seat_no=seat.seat_no, + game_id=game_id, + phase_id=phase_id, + ) + await npc_entry.send(msg.model_dump_json()) + dispatched += 1 + except Exception: + log.exception( + "seat_assigned_send_failed npc=%s seat=%d", + npc_entry.npc_id, + seat.seat_no, + ) + continue + # Phase-D: push the seat's full private state right after + # SeatAssigned so the NPC bot can rebuild its in-memory + # game state before the first decision request lands. + # This may skip with `private_state_snapshot_skip_no_role` + # when called pre-SETUP (the `/wolf start` path runs before + # role assignment); the self-heal below re-pushes once + # roles are visible. + await _push_private_state_snapshot( + npc_entry.send, + npc_id=npc_entry.npc_id, + game_id=game_id, + seat_no=seat.seat_no, + persona_key=npc_entry.persona_key or "", + ) + # Self-heal: re-push snapshots for every NPC currently assigned to + # this game, not just the ones we touched above. The first push + # from `/wolf start`'s `_on_reactive_game_start` runs before + # `plan_setup` writes roles, so the snapshot is silently dropped + # by `_push_private_state_snapshot`'s "no role" guard. Without a + # second push the NPC's `game_states[game_id]` stays empty for + # the entire game — every DecideVoteRequest / + # DecideNightActionRequest / WolfChatRequest then falls back to + # `target=None` because the decision handler short-circuits on + # missing state. Re-pushing on every phase entry is idempotent + # (the NPC client overwrites `game_states[game_id]` from the new + # snapshot) and only adds one small WS frame per assigned NPC + # per phase change. Safe-by-default also covers Master restarts: + # a re-attached NPC gets fresh state at the next phase enter. + for npc_entry in npc_reg.all_online(): + if npc_entry.assigned_seat is None or npc_entry.game_id != game_id: + continue + if npc_entry.send is None: + continue + await _push_private_state_snapshot( + npc_entry.send, + npc_id=npc_entry.npc_id, + game_id=game_id, + seat_no=npc_entry.assigned_seat, + persona_key=npc_entry.persona_key or "", + ) + return dispatched + + async def _wait_for_npcs_in_vc(expected_count: int, timeout: float = 5.0) -> None: + """Block until `expected_count` bots are visible in Master's VC. + + After SeatAssigned dispatch, NPCs join VC asynchronously (Discord + connect ~1-1.5s each). Without this wait, the SETUP_COMPLETE + narration starts speaking into an empty channel. We poll the + VoiceChannel.members list (counting bots, excluding the Master + itself) and bail on timeout so a single slow NPC doesn't stall + game start indefinitely. + """ + if expected_count <= 0: + return + deadline = time.time() + timeout + while time.time() < deadline: + vc = master_vc_ref[0] + channel = getattr(vc, "channel", None) if vc is not None else None + members = getattr(channel, "members", None) + if members is not None: + bot_user = bot.user + count = sum( + 1 for m in members if getattr(m, "bot", False) and m != bot_user + ) + if count >= expected_count: + log.info("npc_vc_join_confirmed count=%d", count) + return + await asyncio.sleep(0.25) + log.info( + "npc_vc_join_timeout expected=%d after=%.1fs", expected_count, timeout + ) + + async def _on_reactive_game_start(game_id: str) -> None: + """Pre-game NPC + Master VC setup, called from `/wolf start`. + + Without this, NPCs only join VC on DAY_DISCUSSION entry — too + late for the SETUP_COMPLETE / day-1 PHASE_CHANGE narration to + be heard from a populated channel. We: + 1. Cache phase_id (needed for SeatAssigned), + 2. Master joins VC (so it can voice the announcement), + 3. Assign online NPCs to LLM seats + dispatch SeatAssigned + so they join VC, + 4. Wait briefly for the NPC VC joins to land. + Idempotent — `_on_reactive_phase_enter` re-invokes the same + helpers later and finds nothing left to do. + """ + g = await repo.load_game(game_id) + if g is None or g.ended_at is not None: + return + if g.discussion_mode != "reactive_voice": + return + await _refresh_voice_ingest_cache(game_id) + await _master_join_vc_for_game(g) + dispatched = await _assign_online_npcs_to_seats(game_id) + if dispatched > 0: + await _wait_for_npcs_in_vc(dispatched) + + async def _on_reactive_phase_enter(game_id: str) -> None: + await _refresh_voice_ingest_cache(game_id) + # Master joins the game's VC the first time the game enters a + # public-speech phase. Idempotent so re-entries are no-ops. + g_for_vc = await repo.load_game(game_id) + # Bail when the game is already ended — this callback can run a + # few hundred ms after `/wolf abort` if a transition's + # `_dispatch_submissions` was in flight when abort fired. Without + # this guard Master rejoins VC right after abort just to drop + # the call again seconds later, surfacing as the "Master came + # back" flicker in the user's voice channel. + if g_for_vc is None or g_for_vc.ended_at is not None: + return + await _master_join_vc_for_game(g_for_vc) + # Seed the phase_baseline sentinel so SpeakArbiter's + # `rebuild_public_state` has an alive-seat baseline to fold + # against. In rounds mode this happens inside + # `submit_llm_discussion_rounds`, but reactive_voice skips + # that batch entirely — without seeding here, the arbiter + # silently no-ops on every dispatch attempt because the + # rebuilt state is None. begin_phase_if_absent is idempotent + # across re-entries / recovery. + if g_for_vc.discussion_mode == "reactive_voice": + players_for_baseline = await repo.load_players(game_id) + alive_seat_nos = sorted( + p.seat_no for p in players_for_baseline if p.alive + ) + try: + await discussion_service.begin_phase_if_absent( + game_id=game_id, + day=g_for_vc.day_number, + phase=g_for_vc.phase, + alive_seat_nos=alive_seat_nos, + ) + except Exception: + log.exception( + "phase_baseline_seed_failed game=%s phase=%s", + game_id, + g_for_vc.phase, + ) + # Assign online NPC bots to their game seats so the arbiter can + # pick them. No-op when `/wolf start`'s pre-game callback already + # claimed them. + await _assign_online_npcs_to_seats(game_id) + if _reactive_phase_cb: + await _reactive_phase_cb[0].try_dispatch_next(game_id) + + async def _on_reactive_game_end(game_id: str) -> None: + """Release every NPC bot so they leave VC. + + Called from `GameService` at natural end + host abort. Sends a + `seat_released` to *every online NPC* — not just those whose + registry row still pins them to this game — so any bot that + ended up in VC (e.g. via a previous abort that left a stale WS + connection or a dropped assignment) is reliably evicted before + the next /wolf start. Idempotent on the NPC side. + """ + if not _npc_registry_ref: + return + from wolfbot.domain.ws_messages import SeatReleased + + npc_reg = _npc_registry_ref[0] + # Union: bots assigned to *this* game + every online bot whose + # registry row carries no game assignment but might still be in + # VC. We don't filter strictly because abort must be a sweep, not + # a precision strike. + attached = {e.npc_id: e for e in npc_reg.assigned_to_game(game_id)} + for e in npc_reg.all_online(): + attached.setdefault(e.npc_id, e) + for entry in attached.values(): + if entry.send is not None: + try: + msg = SeatReleased( + ts=int(asyncio.get_running_loop().time() * 1000), + trace_id=f"release-{game_id}-{entry.npc_id}", + npc_id=entry.npc_id, + game_id=game_id, + reason="game_ended", + ) + await entry.send(msg.model_dump_json()) + except Exception: + log.exception( + "seat_released_send_failed npc=%s game=%s", + entry.npc_id, + game_id, + ) + npc_reg.unassign(entry.npc_id) + log.info("npc_seat_unassigned npc=%s game=%s", entry.npc_id, game_id) + # Drop in-memory dispatcher / arbiter state for this game so a + # long-lived Master process doesn't accumulate stale pending + # futures + playback gates across many games. DB rows are kept + # (export / replay depends on them); only the live dicts are + # swept here. Best-effort: missing references (rounds-mode build + # without dispatcher / arbiter wired) are silent no-ops. + dispatcher = getattr(llm_adapter, "_npc_decision_dispatcher", None) + if dispatcher is not None and hasattr(dispatcher, "cleanup_game"): + try: + dispatcher.cleanup_game(game_id) + except Exception: + log.exception( + "decision_dispatcher_cleanup_failed game=%s", game_id + ) + if _reactive_phase_cb: + arbiter_ref = _reactive_phase_cb[0] + if hasattr(arbiter_ref, "cleanup_game"): + try: + arbiter_ref.cleanup_game(game_id) + except Exception: + log.exception( + "speak_arbiter_cleanup_failed game=%s", game_id + ) + # Drop Master's own VC connection too — keeps the bot out of the + # voice channel between games. Reattaches at the next /wolf start. + await _master_leave_vc() + + async def _on_game_end_finalize(game_id: str) -> None: + """Export the finished/aborted game to viewer-compatible JSON. + + Joins SQLite + ``logs/llm_calls/{game_id}/*.jsonl`` into one file + under ``viewer/games/YYYY-MM-DD_HH-MM-SS.json`` (timestamp prefix + from the game's created_at, sortable by play time). The viewer + auto-discovers the most-recent file in that directory, so a + finished game can be reviewed by simply running + ``cd viewer && pnpm dev`` — no separate export step or env var. + Errors here MUST NOT prevent end-of-game cleanup; + ``GameService._run_finalize_hook`` already wraps this in try/except. + """ + from wolfbot.services.game_export import export_game + + await export_game( + game_id=game_id, + db_path=settings.WOLFBOT_DB_PATH, + ) + + game_service = GameService( + repo=repo, + discord=discord_adapter, + llm=llm_adapter, + wake=registry, + on_reactive_phase_enter=_on_reactive_phase_enter, + on_reactive_game_end=_on_reactive_game_end, + on_game_end_finalize=_on_game_end_finalize, + ) discord_adapter.set_game_service(game_service) llm_adapter.set_game_service(game_service) + async def _on_speech_recorded(game_id: str) -> None: + """After a text SpeechEvent is recorded, try dispatching an NPC reply.""" + if _reactive_phase_cb: + await _reactive_phase_cb[0].try_dispatch_next(game_id) + + # Text channel structured analysis: one TextAnalyzer call per typed + # message extracts ``addressed_name`` / ``co_claim`` / + # ``role_callout`` so SpeakArbiter can route a text address to the + # right NPC seat. ``build_text_analyzer`` returns ``None`` when + # ``TEXT_PIPELINE=passthrough`` (or when the voice path doesn't need + # an analyzer either), in which case ``WolfCog`` falls back to plain + # raw capture. The component is the same instance used by the + # voice path's split pipeline below — single credential pool, + # decoupled from ``GAMEPLAY_LLM_*``. + from wolfbot.master.ingest_factory import build_text_analyzer + + text_analyzer: Any = ( + build_text_analyzer(settings) + if settings.LLM_DISCUSSION_MODE == "reactive_voice" + and settings.MASTER_NPC_PSK is not None + else None + ) + cog = WolfCog( bot=bot, repo=repo, @@ -56,9 +683,15 @@ async def _run() -> None: llm_adapter=llm_adapter, registry=registry, settings=settings, + discussion_service=discussion_service, + on_speech_recorded=_on_speech_recorded, + npc_registry=npc_registry, + text_analyzer=text_analyzer, + on_reactive_game_start=_on_reactive_game_start, ) await bot.add_cog(cog) - bot.tree.add_command(cog.wolf, guild=discord.Object(id=settings.DISCORD_GUILD_ID)) + bot.tree.add_command(cog.wolf, guild=discord.Object( + id=settings.DISCORD_GUILD_ID)) recovery = RecoveryService( repo=repo, @@ -68,17 +701,592 @@ async def _run() -> None: ) recovery_done = asyncio.Event() + # ---- reactive_voice pipeline (Master WS + NPC registry + arbiter) ---- + # Constructed when MASTER_NPC_PSK is set, which signals that the operator + # wants the WS transport running. Without a PSK the pipeline stays off and + # reactive_voice games simply rely on the deadline gate (no NPC speech). + ws_server: Any = None + voice_ingest: Any = None + if settings.MASTER_NPC_PSK is not None: + from wolfbot.master.arbiter.ingest_service import MasterIngestService + from wolfbot.master.arbiter.speak_arbiter import SpeakArbiter + from wolfbot.master.ws.ws_server import ( + MasterHandlers, + WebsocketsMasterWsServer, + ) + + _npc_registry_ref.append(npc_registry) + + # Late-bound holder so arbiter can call into master_tts (built + # below) without reordering construction. GameService is already + # in scope so we can pin it immediately. + _master_tts_holder: list[Any] = [] + _game_service_holder: list[Any] = [game_service] + + async def _runoff_announce(seat: Any) -> None: + """Levi voice-introduces a tied runoff candidate before TTS.""" + from wolfbot.master.narration.narration import render_runoff_candidate_intro + + if not _master_tts_holder: + return + tts = _master_tts_holder[0] + try: + async with tts.suppress_npc_dispatch(arbiter): + await tts.speak(render_runoff_candidate_intro(seat)) + except Exception: + log.exception( + "runoff_candidate_intro_failed seat=%d", + getattr(seat, "seat_no", -1), + ) + + def _runoff_wake(game_id: str) -> None: + if not _game_service_holder: + return + try: + _game_service_holder[0].wake.wake(game_id) + except Exception: + log.exception("runoff_wake_invocation_failed game=%s", game_id) + + arbiter = SpeakArbiter( + repo=repo, + registry=npc_registry, + discussion=discussion_service, + runoff_announce=_runoff_announce, + runoff_wake=_runoff_wake, + ) + _reactive_phase_cb.append(arbiter) + recovery._reactive_voice_sweep = arbiter.reactive_voice_recovery_sweep + + # Phase-D: NPC bot dispatcher for vote / night-action decisions. + # Wired into LLMAdapter so reactive_voice games skip the gameplay + # decider and ask the NPC bot for its own seat's vote / night + # action via WS. + from wolfbot.master.ws.decision_dispatcher import NpcDecisionDispatcher + from wolfbot.master.ws.wolf_chat_broker import WolfChatBroker + + decision_dispatcher = NpcDecisionDispatcher( + registry=npc_registry, + now_ms=lambda: int(time.time() * 1000), + repo=repo, + ) + llm_adapter._npc_decision_dispatcher = decision_dispatcher + + async def _post_to_wolves_channel(game_id: str, text: str) -> None: + game = await repo.load_game(game_id) + if game is None: + return + await discord_adapter.post_wolves_chat(game, text, kind="WOLF_CHAT") + + wolf_chat_broker = WolfChatBroker( + registry=npc_registry, + repo=repo, + post_to_wolves_channel=_post_to_wolves_channel, + now_ms=lambda: int(time.time() * 1000), + ) + + # Phase-D: state pusher fans out seer/medium/guard results, + # alive_changed, and day_advanced PrivateStateUpdates to NPC + # bots after each transition. Wired into GameService via the + # late-binding setter (the pusher needs npc_registry which is + # only available here, but GameService was constructed earlier). + from wolfbot.master.state.phase_d_state_pusher import PhaseDStatePusher + + phase_d_pusher = PhaseDStatePusher( + repo=repo, + registry=npc_registry, + now_ms=lambda: int(time.time() * 1000), + ) + game_service.set_phase_d_state_pusher(phase_d_pusher) + + async def _reactive_voice_reenter(game_id: str) -> None: + # On Master restart, reactive_voice games still in + # DAY_DISCUSSION need their VC joined again before the + # arbiter starts dispatching. `_master_join_vc_for_game` is + # idempotent so non-reactive_voice games are a no-op. + g = await repo.load_game(game_id) + if g is not None: + await _master_join_vc_for_game(g) + await arbiter.try_dispatch_next(game_id) + + recovery._reactive_voice_reenter = _reactive_voice_reenter + + # ---- Master narration (Levi TTS in VC + long content to VC chat) ---- + # Built once per process; the narrator callback is installed on + # `discord_adapter` so `post_public` / `post_morning` route through + # it whenever the active game is in reactive_voice mode. + from wolfbot.master.narration.narration import ( + NarrationContext, + render_master_narration, + ) + from wolfbot.master.voice.tts_playback import MasterTtsPlayback + from wolfbot.npc.audio.tts import VoicevoxTtsService + + master_tts = MasterTtsPlayback( + tts=VoicevoxTtsService( + base_url=settings.MASTER_VOICEVOX_URL, + default_speaker=settings.MASTER_TTS_VOICE_ID, + ), + voice_id=str(settings.MASTER_TTS_VOICE_ID), + vc_ref=master_vc_ref, + ) + # Hand the live MasterTtsPlayback to the arbiter's runoff_announce + # closure (built earlier with a late-binding holder so we didn't + # need to reorder construction). + _master_tts_holder.append(master_tts) + + async def _post_to_vc_chat(game: Any, text: str) -> None: + """Post `text` to the VC's attached text chat. + + In reactive_voice mode Master is forbidden from leaking text + into the guild's main text channel — every word goes to DM + or VC. Discord voice channels (since 2022) carry an attached + text chat reachable at the same channel id; both + ``VoiceChannel`` and ``TextChannel`` are ``Messageable``. If + the VC channel can't be resolved we drop the message rather + than fall back to main text — silence beats a leak.""" + try: + channel_id: int | None = int(game.main_vc_channel_id) + except (TypeError, ValueError): + channel_id = None + channel = bot.get_channel(channel_id) if channel_id else None + if not isinstance( + channel, discord.TextChannel | discord.VoiceChannel + ): + log.warning( + "vc_chat_post_no_channel game=%s vc=%s", + game.id, + game.main_vc_channel_id, + ) + return + try: + await channel.send(text) + except Exception: + log.exception( + "vc_chat_post_failed game=%s channel=%s", + game.id, + getattr(channel, "id", None), + ) + + async def _push_deadline_after_narration(game_id: str) -> None: + """Push the active phase's `deadline_epoch` to `now + duration`. + + Called from the narrator after TTS playback finishes. Without + this, mock-mode short phases (vote=6s) get fully consumed by a + 15s TTS announcement and the engine advances before any NPC / + human can act. Real-time phases also benefit — the phase clock + now starts from when Levi finishes speaking. + + Only pushes forward (never backward), and only when the phase + currently has a deadline (transient SETUP / NIGHT_0 / + WAITING_HOST_DECISION / GAME_OVER skip). + """ + from wolfbot.domain.durations import current_phase_durations + + fresh = await repo.load_game(game_id) + if fresh is None or fresh.ended_at is not None: + return + if fresh.deadline_epoch is None: + return + durations = current_phase_durations() + duration_for: dict[Phase, int] = { + Phase.DAY_DISCUSSION: durations.discussion_for_day( + max(1, fresh.day_number) + ), + Phase.DAY_VOTE: durations.vote, + Phase.DAY_RUNOFF: durations.runoff, + Phase.DAY_RUNOFF_SPEECH: durations.runoff_speech_grace, + Phase.NIGHT: durations.night, + } + duration = duration_for.get(fresh.phase) + if duration is None: + return + new_deadline = int(time.time()) + duration + if new_deadline <= fresh.deadline_epoch: + # TTS finished within the original budget — no push needed. + return + await repo.set_deadline(fresh.id, new_deadline) + log.info( + "master_tts_deadline_pushed game=%s phase=%s " + "old=%d new=%d delta=+%ds", + fresh.id, + fresh.phase.value, + fresh.deadline_epoch, + new_deadline, + new_deadline - fresh.deadline_epoch, + ) + + async def _master_narrate(game: Any, kind: str, text: str) -> bool: + """Public-post narrator: voice + VC chat in reactive_voice mode. + + Returns True when the post has been fully handled (the adapter + must skip its main-text-channel default). Returns False to + let the legacy text path run — used when not in reactive_voice + mode, when Master isn't actually in VC, or when no narration + template applies. + """ + if game.discussion_mode != "reactive_voice": + return False + # Defensive: an in-flight transition's `post_public` can land + # *after* `/wolf abort` has already torn the game down. We + # must not lazy-join VC for a corpse game — that's exactly + # the "Master came back after abort" flicker. + fresh = await repo.load_game(game.id) + if fresh is None or fresh.ended_at is not None: + return False + if master_vc_ref[0] is None: + # Lazy-join: SETUP_COMPLETE and the day-1 PHASE_CHANGE are + # posted *before* `_on_reactive_phase_enter` runs, so on the + # very first transition Master isn't in VC yet. Join here + # so opening narrations are voiced instead of falling + # through to text. Idempotent for later log entries. + await _master_join_vc_for_game(game) + if master_vc_ref[0] is None: + # Join failed (voice_ingest off, channel resolution + # failed, etc.) — fall through to text. + return False + from wolfbot.domain.models import LogEntry as _LogEntry + + players = await repo.load_players(game.id) + seats = await repo.load_seats(game.id) + ctx = NarrationContext( + day_number=game.day_number, + phase=game.phase, + alive_count=sum(1 for p in players if p.alive), + seats_by_no={s.seat_no: s for s in seats}, + ) + # Synthesize an entry-shaped object for the narrator. The + # caller (DiscordAdapter.post_public / post_morning) only + # gives us text + kind, so we reconstruct what the narrator + # needs without hitting the public_logs table. + actor_seat: int | None = None + if kind in ("EXECUTION", "MORNING"): + # Recover actor_seat from the public_log row that + # GameService.apply_transition just wrote so MORNING + # narration can name the deceased seat. The matching + # row's text equals the text the adapter received here + # (LogEntry.text round-trips). Walk the most recent + # entries newest-first. + try: + rows = await repo.load_public_logs(game.id, limit=10) + except Exception: + rows = [] + for row in reversed(rows): + if row.get("kind") == kind and row.get("text") == text: + actor_seat = row.get("actor_seat") + break + faux_entry = _LogEntry( + game_id=game.id, + day=game.day_number, + phase=game.phase, + kind=kind, + actor_seat=actor_seat, + visibility="PUBLIC", + text=text, + created_at=0, + ) + output = render_master_narration(faux_entry, ctx) + if output.is_silent(): + # No template matched — let the caller fall through to + # the legacy main-text post so the message isn't dropped. + return False + if output.chat_text: + await _post_to_vc_chat(game, output.chat_text) + if output.voice_text: + async with master_tts.suppress_npc_dispatch(arbiter): + await master_tts.speak(output.voice_text) + # Phase deadlines are committed in apply_transition based + # on `now` at plan_next time, so a 15s TTS playback eats + # straight into the next phase's budget — in mock mode the + # vote/runoff window can elapse before NPCs even start. + # After the announcement plays, push the deadline forward + # so the phase clock starts from when Levi finishes. + await _push_deadline_after_narration(game.id) + # After Master narrates, give NPC dispatch a kick — a + # PHASE_CHANGE into DAY_DISCUSSION should immediately + # invite an NPC reply. + if game.phase in ( + Phase.DAY_DISCUSSION, Phase.DAY_RUNOFF_SPEECH + ): + try: + await arbiter.try_dispatch_next(game.id) + except Exception: + log.exception( + "post_narration_dispatch_failed game=%s", game.id + ) + return True + + discord_adapter.set_narrator(_master_narrate) + + class _RepoPhase: + """Adapts SqliteRepo to MasterIngestService.PhaseLookup.""" + + async def get_phase(self, game_id: str) -> tuple[Any, int] | None: + g = await repo.load_game(game_id) + if g is None or g.ended_at is not None: + return None + return (g.phase, g.day_number) + + async def get_alive_seat_nos(self, game_id: str) -> list[int]: + players = await repo.load_players(game_id) + return sorted(p.seat_no for p in players if p.alive) + + async def resolve_addressed_seat( + self, game_id: str, addressed_name: str + ) -> int | None: + from wolfbot.master.arbiter.ingest_service import resolve_seat_by_name + + seats = await repo.load_seats(game_id) + players = await repo.load_players(game_id) + alive = {p.seat_no for p in players if p.alive} + return resolve_seat_by_name(addressed_name, seats, alive) + + ingest_service = MasterIngestService( + registry=npc_registry, + discussion=discussion_service, + phase_lookup=_RepoPhase(), + ) + + async def _on_speak_result(msg: Any, _ctx: Any) -> None: + g = await repo.load_game(msg.game_id) if hasattr(msg, "game_id") else None + # Resolve game from the pending request inside the arbiter + from wolfbot.domain.discussion import make_phase_id as _mkpi + + pending = arbiter._pending.get(msg.request_id) + if pending is None: + return + g = await repo.load_game(pending.game_id) + if g is None or g.ended_at is not None: + return + accepted, _reason = await arbiter.handle_speak_result( + msg, + current_phase_id=_mkpi(g.id, g.day_number, g.phase), + day=g.day_number, + phase=g.phase, + ) + if accepted: + log.info("speak_result_accepted npc=%s game=%s", + msg.npc_id, g.id) + + async def _on_tts_finished(msg: Any, _ctx: Any) -> None: + await arbiter.handle_tts_finished(msg) + + async def _on_tts_failed(msg: Any, _ctx: Any) -> None: + # Resolve game_id BEFORE handle_tts_failed pops `_pending`, + # otherwise the post-pop lookup is always None and the next + # NPC never gets dispatched. Production hit: Jonas' second + # speech timed out on VOICEVOX, the gate cleared, but the + # arbiter stalled silently for the rest of the phase + # because game_id was looked up after the pop. + pending = arbiter._pending.get(msg.request_id) + game_id = pending.game_id if pending is not None else None + await arbiter.handle_tts_failed(msg) + # Gate cleared — try dispatching next NPC. + if game_id is not None: + await arbiter.try_dispatch_next(game_id) + + async def _on_playback_finished(msg: Any, _ctx: Any) -> None: + # Resolve game_id before the pending entry is popped. + pending = arbiter._pending.get(msg.request_id) + game_id = pending.game_id if pending is not None else None + await arbiter.handle_playback_finished(msg) + # Gate cleared — try dispatching next NPC. + if game_id is not None: + await arbiter.try_dispatch_next(game_id) + + async def _on_playback_failed(msg: Any, _ctx: Any) -> None: + pending = arbiter._pending.get(msg.request_id) + game_id = pending.game_id if pending is not None else None + await arbiter.handle_playback_failed(msg) + if game_id is not None: + await arbiter.try_dispatch_next(game_id) + + async def _on_speech_payload(msg: Any, _ctx: Any) -> None: + await ingest_service.ingest_voice(msg) + # Finalize the STT gate for this segment before dispatching. + segment_id = getattr(msg, "segment_id", None) + if segment_id: + arbiter.finalize_stt(segment_id) + # After a new human speech event lands, try dispatching an NPC reply. + if hasattr(msg, "game_id") and msg.game_id: + await arbiter.try_dispatch_next(msg.game_id) + + async def _on_stt_failed(msg: Any, _ctx: Any) -> None: + segment_id = getattr(msg, "segment_id", None) + if segment_id: + arbiter.finalize_stt(segment_id) + # Gate cleared — try dispatching even though the STT failed. + game_id = getattr(msg, "game_id", None) + if game_id: + await arbiter.try_dispatch_next(game_id) + + async def _on_vad_started(msg: Any, _ctx: Any) -> None: + segment_id = getattr(msg, "segment_id", None) or "unknown" + arbiter.mark_human_speaking(segment_id) + + async def _on_vad_ended(msg: Any, _ctx: Any) -> None: + segment_id = getattr(msg, "segment_id", None) or "unknown" + # Keep the human-speaking gate held until STT completes. + # mark_pending_stt records the segment for finalization timeout. + arbiter.mark_pending_stt(segment_id) + + async def _on_vote_decision(msg: Any, _ctx: Any) -> None: + await decision_dispatcher.on_vote_decision(msg) + + async def _on_night_action_decision(msg: Any, _ctx: Any) -> None: + await decision_dispatcher.on_night_action_decision(msg) + + async def _on_wolf_chat_send(msg: Any, _ctx: Any) -> None: + # Two consumers run for every wolf_chat_send: + # 1) Broker: persist as WOLF_CHAT private log + broadcast a + # `wolf_chat` PrivateStateUpdate to other live wolves. + # 2) Dispatcher: resolve the matching pending future when + # the line was prompted by a Master-issued `WolfChatRequest`. + # Order matters — let the broker run first so by the time + # the dispatcher resolves the future and the wolf-chat + # gather() returns, every other wolf NPC's mirror is + # already updated. + await wolf_chat_broker.handle_wolf_chat_send(msg) + await decision_dispatcher.on_wolf_chat_send(msg) + + master_handlers = MasterHandlers( + registry=npc_registry, + on_speak_result=_on_speak_result, + on_tts_finished=_on_tts_finished, + on_tts_failed=_on_tts_failed, + on_playback_finished=_on_playback_finished, + on_playback_failed=_on_playback_failed, + on_speech_event_payload=_on_speech_payload, + on_vad_started=_on_vad_started, + on_vad_ended=_on_vad_ended, + on_stt_failed=_on_stt_failed, + on_vote_decision=_on_vote_decision, + on_night_action_decision=_on_night_action_decision, + on_wolf_chat_send=_on_wolf_chat_send, + ) + + host, port_str = settings.MASTER_WS_LISTEN.rsplit(":", 1) + ws_server = WebsocketsMasterWsServer( + host=host, + port=int(port_str), + psk=settings.MASTER_NPC_PSK.get_secret_value(), + registry=npc_registry, + handlers=master_handlers, + ) + log.info("reactive_voice pipeline wired, WS will listen on %s", + settings.MASTER_WS_LISTEN) + + # ---- integrated voice-ingest (STT runs in Master process) ---- + # Instead of a separate voice-ingest process, Master joins VC itself + # and pipes audio through VoiceIngestService → DirectMasterIngestionClient + # → arbiter/ingest_service, all in-process. + # The pipeline shape (audio_analyzer vs stt_then_text_analyzer) + # and component credentials live on MasterSettings; the factory + # returns ``None`` when ``VOICE_PIPELINE=disabled``. + from wolfbot.master.ingest_factory import ( + build_voice_ingest_provider, + voice_ingest_summary, + ) + + voice_llm: Any = build_voice_ingest_provider(settings) + if voice_llm is not None: + from wolfbot.master.voice.voice_ingest_client import DirectMasterIngestionClient + from wolfbot.master.voice.voice_ingest_service import ( + VoiceIngestConfig, + VoiceIngestService, + ) + + # Direct callbacks (no WS ctx needed) + async def _direct_vad_started(msg: Any) -> None: + segment_id = getattr(msg, "segment_id", None) or "unknown" + arbiter.mark_human_speaking(segment_id) + + async def _direct_vad_ended(msg: Any) -> None: + segment_id = getattr(msg, "segment_id", None) or "unknown" + arbiter.mark_pending_stt(segment_id) + + async def _direct_speech_payload(msg: Any) -> None: + await ingest_service.ingest_voice(msg) + segment_id = getattr(msg, "segment_id", None) + if segment_id: + arbiter.finalize_stt(segment_id) + if hasattr(msg, "game_id") and msg.game_id: + await arbiter.try_dispatch_next(msg.game_id) + + async def _direct_stt_failed(msg: Any) -> None: + segment_id = getattr(msg, "segment_id", None) + if segment_id: + arbiter.finalize_stt(segment_id) + game_id = getattr(msg, "game_id", None) + if game_id: + await arbiter.try_dispatch_next(game_id) + + direct_client = DirectMasterIngestionClient( + on_vad_started=_direct_vad_started, + on_vad_ended=_direct_vad_ended, + on_speech_event_payload=_direct_speech_payload, + on_stt_failed=_direct_stt_failed, + ) + + # NpcRegistryView adapter: InMemoryNpcRegistry → NpcRegistryView + class _RegistryViewAdapter: + def is_npc(self, discord_user_id: str) -> bool: + return discord_user_id in npc_registry.discord_bot_user_ids() + + def npc_user_ids(self) -> set[str]: + return npc_registry.discord_bot_user_ids() + + def _seat_lookup(discord_user_id: str) -> int | None: + """Resolve a Discord user ID to their seat number.""" + return _vc_seat_map.get(discord_user_id) + + def _phase_lookup() -> tuple[str, str] | None: + return _vc_phase_cache[0] + + def _roster_lookup() -> list[tuple[int, str]]: + """Snapshot of alive seats for grounding the STT analyzer. + + Read out of the ``_vc_roster`` cache populated by + ``_refresh_voice_ingest_cache`` so we don't hit the DB + on every speech segment. + """ + return list(_vc_roster) + + voice_ingest = VoiceIngestService( + registry_view=_RegistryViewAdapter(), + master_client=direct_client, + stt=voice_llm, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup, + config=VoiceIngestConfig( + pre_stt_min_rms=settings.VOICE_PRE_STT_MIN_RMS, + pre_stt_min_duration_ms=settings.VOICE_PRE_STT_MIN_DURATION_MS, + ), + roster_lookup=_roster_lookup, + ) + log.info("integrated voice-ingest wired (%s)", voice_ingest_summary(settings)) + @bot.event async def on_ready() -> None: guild = discord.Object(id=settings.DISCORD_GUILD_ID) await bot.tree.sync(guild=guild) - log.info("synced slash commands to guild %s", settings.DISCORD_GUILD_ID) + log.info("synced slash commands to guild %s", + settings.DISCORD_GUILD_ID) # on_ready re-fires on reconnect. Engines started on the first ready keep # ticking locally across reconnects, so re-running recovery would only # duplicate them. Run it once per process. if recovery_done.is_set(): return recovery_done.set() + if ws_server is not None: + await ws_server.start() + log.info("master WS server started on %s", + settings.MASTER_WS_LISTEN) + # Master no longer auto-joins VC at startup. Joining is deferred + # to `_on_reactive_phase_enter` (the first public-speech phase of + # a reactive_voice game) and to recovery for in-flight games. + # Hosts who want Master in a non-active VC still rely on starting + # a game; rounds-mode games never need VC. + recovered = await recovery.recover_all() log.info("recovered %d game(s)", len(recovered)) @@ -101,6 +1309,9 @@ def _handle_sigterm() -> None: log.info("stopping engines...") await registry.stop_all() + if ws_server is not None: + log.info("stopping master WS server...") + await ws_server.stop() log.info("closing bot...") await bot.close() bot_task.cancel() diff --git a/src/wolfbot/master/__init__.py b/src/wolfbot/master/__init__.py new file mode 100644 index 0000000..d9a298e --- /dev/null +++ b/src/wolfbot/master/__init__.py @@ -0,0 +1,7 @@ +"""Master-side reactive-voice pipeline. + +Code that runs only inside the Master Discord bot process when the +``reactive_voice`` discussion mode is active: the WS server, NPC registry, +speak arbiter, voice-ingest VAD/STT, and audio sink. NPC-bot-side +counterparts live in :mod:`wolfbot.npc`. +""" diff --git a/src/wolfbot/master/arbiter/__init__.py b/src/wolfbot/master/arbiter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wolfbot/master/arbiter/ingest_service.py b/src/wolfbot/master/arbiter/ingest_service.py new file mode 100644 index 0000000..7ab46fe --- /dev/null +++ b/src/wolfbot/master/arbiter/ingest_service.py @@ -0,0 +1,290 @@ +"""Master-side ingestion of SpeechEvent payloads from voice-ingest. + +Handles the boundary where Discord audio becomes a `SpeechEvent`: + +1. Voice-ingest sends a typed ``speech_event_payload`` over WS. +2. ``MasterIngestService.ingest_voice`` validates the speaker is NOT a + registered NPC bot (the "fail-closed STT discard" rule from design.md), + builds a `SpeechEvent(source=voice_stt)`, and delegates to + `DiscussionService.record(...)` so the canonical PLAYER_SPEECH log entry + and main-channel post are emitted. + +Keeping this in its own service (rather than inline in the WS handler) makes +the path easy to test with a Fake WS context and easy to call from the human- +text-message capture path on `WolfCog` (which also produces SpeechEvents). +""" + +from __future__ import annotations + +import logging +import re +import unicodedata +from collections.abc import Iterable +from typing import Protocol, runtime_checkable + +from wolfbot.domain.discussion import ( + SpeechEvent, + SpeechSource, + make_phase_id, +) +from wolfbot.domain.enums import Phase +from wolfbot.domain.models import Seat +from wolfbot.domain.ws_messages import SpeechEventPayload +from wolfbot.master.ws.npc_registry import NpcRegistry +from wolfbot.services.discussion_service import ( + DiscussionService, + new_event_id, +) +from wolfbot.services.discussion_service import ( + now_ms as default_now_ms, +) + +log = logging.getLogger(__name__) + + +_HONORIFICS: tuple[str, ...] = ("さん", "くん", "ちゃん", "様", "さま", "君") + + +def _strip_emoji(name: str) -> str: + """Drop leading emoji + whitespace; persona display_names are like '🌙セツ'.""" + out: list[str] = [] + skipping = True + for ch in name: + if skipping: + if ch.isspace(): + continue + cat = unicodedata.category(ch) + # Symbols (S*) and 'Cn' (unassigned) covers most emoji codepoints. + if cat.startswith("S") or cat == "Cn": + continue + skipping = False + out.append(ch) + return "".join(out).strip() + + +def _normalize_name(name: str) -> str: + """Lowercase + NFKC + strip whitespace, emoji, and trailing honorifics. + + Used for both the spoken alias and the seat display name so the + comparison is symmetric and forgiving. + """ + folded = unicodedata.normalize("NFKC", name).strip().lower() + folded = _strip_emoji(folded) + for honorific in _HONORIFICS: + if folded.endswith(honorific): + folded = folded[: -len(honorific)].rstrip() + break + return folded + + +_SEAT_NUMBER_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile(r"^席?\s*(\d+)\s*(?:番|ばん)?$"), + re.compile(r"^seat\s*(\d+)$"), +) + + +def resolve_seat_by_name( + addressed_name: str, + seats: Iterable[Seat], + alive: frozenset[int] | set[int] | None = None, +) -> int | None: + """Map ``addressed_name`` (literal handle from voice) to a seat number. + + Returns ``None`` when no seat matches, or when ``alive`` is provided and + the candidate is not currently alive (we never route an address at a + dead seat — they cannot reply). Ambiguous matches across multiple seats + also return ``None`` to fail closed. + """ + norm = _normalize_name(addressed_name) + if not norm: + return None + + for pat in _SEAT_NUMBER_PATTERNS: + m = pat.match(norm) + if m: + try: + seat_no = int(m.group(1)) + except ValueError: + continue + if 1 <= seat_no <= 9 and (alive is None or seat_no in alive): + return seat_no + return None + + matches: set[int] = set() + for seat in seats: + if alive is not None and seat.seat_no not in alive: + continue + candidates = {_normalize_name(seat.display_name)} + if seat.persona_key: + candidates.add(_normalize_name(seat.persona_key)) + candidates.discard("") + if norm in candidates: + matches.add(seat.seat_no) + continue + # Fall back to substring containment in either direction so handles + # like "ジナ" still match a display_name "ジナ・メイユイ". + for cand in candidates: + if cand and (norm == cand or norm in cand or cand in norm): + matches.add(seat.seat_no) + break + + if len(matches) == 1: + return matches.pop() + return None + + +@runtime_checkable +class PhaseLookup(Protocol): + """Resolves the current phase / day / alive seats for a game id. + + Defined as a protocol so tests can substitute an in-memory map without + spinning up `SqliteRepo`. The real implementation is a thin shim that + delegates to the repo. + """ + + async def get_phase(self, game_id: str) -> tuple[Phase, int] | None: ... + + async def get_alive_seat_nos(self, game_id: str) -> list[int]: ... + + async def resolve_addressed_seat( + self, game_id: str, addressed_name: str + ) -> int | None: + """Map a literal name/handle ('セツ', '席3', 'Alice さん') to a seat + number for `game_id`. Returns ``None`` when no seat matches or the + match is ambiguous.""" + ... + + +class MasterIngestService: + """Boundary handler for voice-ingest payloads.""" + + def __init__( + self, + *, + registry: NpcRegistry, + discussion: DiscussionService, + phase_lookup: PhaseLookup, + ) -> None: + self.registry = registry + self.discussion = discussion + self.phase_lookup = phase_lookup + + async def ingest_voice( + self, payload: SpeechEventPayload + ) -> tuple[SpeechEvent | None, str | None]: + """Try to record a `SpeechEvent(source=voice_stt)`. + + Returns ``(event, None)`` on success and ``(None, reason)`` when the + payload is dropped. Reasons are the canonical voice-ingest / + Master-side `failure_reason` values (`npc_stt_discarded`, + `stale_phase`, `unknown_game`). + """ + npc_user_ids = self.registry.discord_bot_user_ids() + if payload.speaker_discord_user_id in npc_user_ids: + log.info( + "npc_stt_discarded game=%s speaker=%s segment=%s", + payload.game_id, + payload.speaker_discord_user_id, + payload.segment_id, + ) + return (None, "npc_stt_discarded") + + phase_info = await self.phase_lookup.get_phase(payload.game_id) + if phase_info is None: + log.info("voice_ingest_unknown_game game=%s", payload.game_id) + return (None, "unknown_game") + phase, day = phase_info + if phase not in (Phase.DAY_DISCUSSION, Phase.DAY_RUNOFF_SPEECH): + log.info( + "voice_ingest_stale_phase game=%s phase=%s", + payload.game_id, + phase, + ) + return (None, "stale_phase") + + # Compute the canonical phase_id on Master rather than trusting + # the caller-supplied value. This ensures a delayed STT result + # from an older phase cannot be written under a stale phase_id. + canonical_phase_id = make_phase_id(payload.game_id, day, phase) + + # Seed the phase baseline so PublicDiscussionState rebuild works. + alive_seat_nos = await self.phase_lookup.get_alive_seat_nos(payload.game_id) + + # Defensive alive check. The voice-ingest VAD entry already + # filters dead-player audio (their seat is dropped from the + # seat lookup map), and Discord-level VC mute is applied on + # death — but a delayed STT result could still arrive after a + # player died mid-segment. Drop it here so a dead player can + # never inject SpeechEvents into the public log. + if payload.seat_no not in set(alive_seat_nos): + log.info( + "dead_speaker_stt_discarded game=%s seat=%d segment=%s", + payload.game_id, + payload.seat_no, + payload.segment_id, + ) + return (None, "dead_speaker_discarded") + + await self.discussion.begin_phase_if_absent( + game_id=payload.game_id, + day=day, + phase=phase, + alive_seat_nos=alive_seat_nos, + ) + + # Prefer the analyzer's pre-resolved seat number when its + # prompt was grounded with a roster - that path bypasses the + # legacy ``resolve_seat_by_name`` string match, which only + # compares against ``Seat.display_name`` (=persona handle) + # and silently drops the address when the live VC nickname + # diverges. Validate the seat is actually alive in this + # phase before trusting it; fall back to the name-resolution + # path otherwise so a buggy / hallucinated seat number + # doesn't poison the routing. + addressed_seat_no: int | None = None + alive_set = set(alive_seat_nos) + if ( + payload.addressed_seat_no is not None + and payload.addressed_seat_no in alive_set + ): + addressed_seat_no = payload.addressed_seat_no + elif payload.addressed_name: + try: + addressed_seat_no = await self.phase_lookup.resolve_addressed_seat( + payload.game_id, payload.addressed_name + ) + except Exception: + log.exception( + "addressed_seat_resolution_failed game=%s name=%s", + payload.game_id, + payload.addressed_name, + ) + addressed_seat_no = None + # Self-address never needs a routed reply. + if addressed_seat_no is not None and addressed_seat_no == payload.seat_no: + addressed_seat_no = None + + event = SpeechEvent( + event_id=new_event_id(), + game_id=payload.game_id, + phase_id=canonical_phase_id, + day=day, + phase=phase, + source=SpeechSource.VOICE_STT, + speaker_kind="human", # type: ignore[arg-type] + speaker_seat=payload.seat_no, + text=payload.text, + stt_confidence=payload.confidence, + audio_start_ms=payload.audio_start_ms, + audio_end_ms=payload.audio_end_ms, + summary=payload.summary, + co_declaration=payload.co_declaration, + addressed_seat_no=addressed_seat_no, + role_callout=payload.role_callout, + created_at_ms=default_now_ms(), + ) + await self.discussion.record(event) + return (event, None) + + +__all__ = ["MasterIngestService", "PhaseLookup", "resolve_seat_by_name"] diff --git a/src/wolfbot/master/arbiter/logic_service.py b/src/wolfbot/master/arbiter/logic_service.py new file mode 100644 index 0000000..ae361e3 --- /dev/null +++ b/src/wolfbot/master/arbiter/logic_service.py @@ -0,0 +1,388 @@ +"""MasterLogicBuilder — turns `PublicDiscussionState` into per-NPC LogicPackets. + +The full design has rich logic candidates (claim chains, support / counter +links, per-seat pressure scores). MVP per the proposal restricts the +deterministic fields to `co_claims` (extracted from text) and `silent_seats` +(alive set minus speakers); `stances` / `pressure` / `open_topics` remain +skeletons and are passed through empty. + +This module produces a `LogicPacket` that: + +* enumerates the recipient's seat-aware view of CO claims as candidate + entries (one per CO, each empty support/counter list — the NPC bot uses + its own persona+role to weight them); +* echoes `silent_seats` as a textual `public_state_summary`; +* sets `expires_at_ms` per the current phase deadline; +* leaves `pressure` empty. + +Builder is pure: no I/O, no asyncio. The arbiter passes in the deadline. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Iterable + +from wolfbot.domain.discussion import PublicDiscussionState +from wolfbot.domain.enums import Role +from wolfbot.domain.ws_messages import LogicCandidate, LogicPacket, RecentSpeech +from wolfbot.master.claim.claim_history import ( + ClaimHistory, + expected_seer_claim_count_for_day, +) + + +def _new_packet_id() -> str: + return f"lp_{uuid.uuid4().hex[:12]}" + + +def build_logic_packet( + *, + state: PublicDiscussionState, + recipient_npc_id: str, + expires_at_ms: int, + now_ms: int, + pressure: dict[int, float] | None = None, + additional_candidates: Iterable[LogicCandidate] = (), + recent_speeches: Iterable[RecentSpeech] = (), + past_votes: Iterable[ + tuple[int, int, tuple[tuple[int, int | None], ...]] + ] = (), + past_suspicions: Iterable[ + tuple[int, str, int, int, str, str, str | None, str | None] + ] = (), + seat_names: dict[int, str] | None = None, + claim_history: ClaimHistory | None = None, + recipient_seat_no: int | None = None, + recipient_role: Role | None = None, +) -> LogicPacket: + """Construct a `LogicPacket` for `recipient_npc_id`. + + The packet is deterministic given the same `state` + `now_ms` save for + the random `packet_id`. Tests should pin `now_ms` and inspect the rest of + the payload directly. + + ``seat_names`` is a seat → display_name lookup so the rendered + summary can refer to players by name instead of ``席N``. Optional + for back-compat (older callers / tests pass nothing and get the + legacy seat-only rendering); production callers in + `SpeakArbiter.dispatch_request` always pass it. + """ + name_map = seat_names or {} + + def _name(seat: int) -> str: + return name_map.get(seat) or f"席{seat}" + + candidates: list[LogicCandidate] = [] + for claim in state.co_claims: + candidates.append( + LogicCandidate( + id=f"co-{claim.seat}-{claim.role_claim}", + claim=f"{_name(claim.seat)} {claim.role_claim}CO", + ) + ) + candidates.extend(additional_candidates) + + silent_names = ( + "、".join(_name(s) for s in sorted(state.silent_seats)) + if state.silent_seats + else "" + ) + silent_repr = ( + f"silent_seats=[{silent_names}]" if silent_names else "silent_seats=[]" + ) + co_repr = ( + ", ".join(f"{_name(c.seat)}={c.role_claim}" for c in state.co_claims) + if state.co_claims + else "(none)" + ) + summary = f"phase_id={state.phase_id} day={state.day} co_claims=[{co_repr}] {silent_repr}" + if state.pending_role_callouts: + # Outstanding "誰か占い師?" / "霊媒師の方どうぞ" requests that no + # one has answered yet. Real role holders should treat this as a + # CO trigger; wolf-side NPCs should consider whether to fake CO. + callouts_repr = ", ".join(sorted(state.pending_role_callouts)) + summary += f" pending_role_callouts=[{callouts_repr}]" + if state.pending_co_response: + # First-CO counter-CO window: a role just got its first claim + # and every uncommitted wolf-side seat (plus the real role-holder + # when the CO'er was wolf-side) is being rotated through the + # priority pool. NPCs in the pool see this as "you're being asked + # now; either counter-CO or skip — the window expires once + # everyone has been asked". + co_response_repr = ", ".join(sorted(state.pending_co_response)) + summary += f" pending_co_response=[{co_response_repr}]" + if claim_history is not None and claim_history.by_seat: + # Public per-claimer divination/medium history. Every NPC sees + # the same record — real roles use it to keep their own past + # results consistent in speech, fake-CO wolves see their own + # prior lies and either commit to them or get caught when they + # contradict themselves. + # + # Layout (post-game ``74edf214638d`` redesign): + # + # 1. Top warning banner: dead-claimer COs are still valid public + # info. Game ``74edf214638d`` had ステラ (madman) seer-CO on + # day 1, get attacked night 1, then on day 2 morning every + # NPC ignored her CO and called ユリコ "the single seer CO" — + # the LLM was treating dead = irrelevant. Banner up top is + # the cheapest fix; the rule also goes into prompt_builder. + # 2. Per-role section header (### 占いCO / ### 霊媒CO) so the + # LLM doesn't confuse seer vs medium when scanning a row. + # 3. Each row carries an explicit alive/dead tag in-line so the + # LLM doesn't need to cross-reference the participant list + # to know whether a claimer is still around. + # 4. Distinct-claimer count vs cap is shown next to the role + # header (max 3 seer / 2 medium per CLAUDE rules). Per-day + # expected count (= N results per real seer by day-N morning, + # one per declared day) stays as a separate sub-line under + # the seer header. + expected = expected_seer_claim_count_for_day(state.day) + seer_claimers = sorted( + s for s, h in claim_history.by_seat.items() if h.seer_claims + ) + medium_claimers = sorted( + s for s, h in claim_history.by_seat.items() if h.medium_claims + ) + + def _alive_tag(seat: int) -> str: + return "生存" if seat in state.alive_seat_nos else "死亡" + + summary += "\n\n## 公開された占い/霊媒CO結果 (公式記録)\n" + summary += ( + "**重要**: 死亡席の CO も依然として有効な公開情報です。" + "alive/dead で ledger から除外しないでください。" + "「占い師は◯◯さん 1 人だけ」「単独 CO」のように現在生存中の CO 数だけで" + "結論を出すのは誤りで、必ず以下の通算件数で判断してください。\n" + + ) + if seer_claimers: + summary += ( + f"\n### 占いCO 通算 {len(seer_claimers)} 件 / 上限 3 件 " + f"(各人の発表は day{state.day} 朝までに通算 {expected} 件まで整合)\n" + ) + for seat_no in seer_claimers: + history = claim_history.by_seat[seat_no] + who = _name(seat_no) + seer_summary = ", ".join( + f"day{c.day}: {c.target_name}{'黒' if c.is_wolf else '白'}" + for c in history.seer_claims + ) + summary += ( + f"- {who} (席{seat_no}, {_alive_tag(seat_no)}) — {seer_summary}\n" + ) + if medium_claimers: + summary += ( + f"\n### 霊媒CO 通算 {len(medium_claimers)} 件 / 上限 2 件\n" + ) + for seat_no in medium_claimers: + history = claim_history.by_seat[seat_no] + who = _name(seat_no) + medium_summary = ", ".join( + f"day{c.day}: " + + ( + f"{c.target_name}" + + ("黒" if c.is_wolf is True else "白" if c.is_wolf is False else "結果なし") + ) + for c in history.medium_claims + ) + summary += ( + f"- {who} (席{seat_no}, {_alive_tag(seat_no)}) — {medium_summary}\n" + ) + summary = summary.rstrip() + # Per-recipient nudge when the addressee themselves is a CO'd + # claimer whose announced result count lags the day-N expected + # count. Without this, observation games (a51615d32274 day 2 + # ユリコ, 100b9e88e75a day 2 シゲミチ) showed the wolf seer + # repeating the day-1 result instead of producing the night-1 + # result on day-2 morning, even though the count rule lives in + # the system prompt — the LLM consistently failed to act on + # the gap. Surface it here as a direct "this applies to YOU" + # instruction so the model can't deflect to general advice. + if ( + recipient_seat_no is not None + and recipient_seat_no in claim_history.by_seat + and state.day >= 1 + ): + recipient_history = claim_history.by_seat[recipient_seat_no] + recipient_seer_count = len(recipient_history.seer_claims) + if ( + recipient_seer_count > 0 + and recipient_seer_count < expected + ): + missing = expected - recipient_seer_count + # Past-night index in the ledger convention: at day-N + # morning the most recent night is NIGHT_(N-1) (NIGHT_0 + # is the night before day 1, NIGHT_(N-1) is the night + # before day N). `prev_night = state.day - 1` is the + # one whose result is announced *today*. + prev_night = max(0, state.day - 1) + summary += ( + f"\n\n## 【あなた宛 / 緊急】占いCO 結果の発表が不足しています\n" + f"あなたは占いCO 者で、現在の発表結果は通算 " + f"{recipient_seer_count} 件。day{state.day} 朝までの" + f"期待値は {expected} 件 (day1 朝で 1 件、以後毎朝 +1 件)。" + f"未発表が {missing} 件あります。" + f"**この発話で前夜 (NIGHT_{prev_night}) の新しい占い結果を必ず発表し、" + f"`claimed_seer_result` に `{{target_seat, is_wolf}}` を構造化" + f"して入れてください**。" + f"過去の結果 ({', '.join(c.target_name for c in recipient_history.seer_claims)}) " + f"を再表明するだけでは整合しません。" + f"対象は NIGHT_{prev_night} の開始時点で生存していた相手から選ぶこと。" + ) + # Same gating logic for medium claimers: if the seat has + # CO'd as medium and yesterday's execution exists in the + # public log, they should produce yesterday's medium + # result. We don't have a clean execution-count signal in + # this packet (the audit games haven't shown medium drift + # at the same severity yet), so a softer cumulative-count + # instruction is enough — refining when we observe the + # failure mode in real games. + recipient_medium_count = len(recipient_history.medium_claims) + if recipient_medium_count > 0 and state.day >= 2: + summary += ( + f"\n\n## 【あなた宛】霊媒CO 結果の発表確認\n" + f"あなたは霊媒CO 者で、現在の発表結果は通算 " + f"{recipient_medium_count} 件。day{state.day} 朝の時点で、" + f"昨日 (day{state.day - 1}) の処刑があった場合は" + f"その霊媒結果を `claimed_medium_result` に入れて発表する義務があります。" + f"処刑がなかった日は `is_wolf=null` で「結果なし」を明言してください。" + ) + + # Wolf-side counter-CO / fake-CO decision prompt. Fires when: + # (a) recipient is WEREWOLF or MADMAN + # (b) recipient hasn't CO'd as any info role yet + # (c) an info-role callout is active (pending_role_callouts / + # pending_co_response carries seer / medium / knight) + # Surfaces the decision explicitly so the LLM treats this turn as a + # binary "fake CO or skip" rather than drifting into generic chat. + # The choice is the LLM's; this block frames the prompt. + if recipient_role in (Role.WEREWOLF, Role.MADMAN) and recipient_seat_no is not None: + recipient_co_keys: set[str] = set() + if claim_history is not None and recipient_seat_no in claim_history.by_seat: + history = claim_history.by_seat[recipient_seat_no] + if history.seer_claims: + recipient_co_keys.add("seer") + if history.medium_claims: + recipient_co_keys.add("medium") + # Also check live state.co_claims (covers the in-phase case + # where claim_history is built up to the current packet but + # the brand-new CO this turn may not be folded yet). + for c in state.co_claims: + if c.seat == recipient_seat_no: + recipient_co_keys.add(c.role_claim) + already_co_info = bool( + recipient_co_keys & {"seer", "medium", "knight"} + ) + if not already_co_info: + active_callouts: set[str] = set() + for k in ("seer", "medium", "knight"): + if ( + k in state.pending_role_callouts + or k in state.pending_co_response + ): + active_callouts.add(k) + if "info_request" in state.pending_role_callouts: + active_callouts |= {"seer", "medium", "knight"} + for k in tuple(active_callouts): + if any(c.role_claim == k for c in state.co_claims): + # Real or fake CO landed for this role at the same + # rough time as the prompt is being built — only + # surface a counter-CO decision when the role is + # genuinely unanswered. Drop already-answered + # role keys from the active set. + pass + if active_callouts: + role_label = { + "seer": "占い師", + "medium": "霊媒師", + "knight": "騎士", + } + roles_jp = "・".join(role_label[k] for k in sorted(active_callouts)) + # Per-role tactical hints — short reminders pulled from + # the existing strategy templates so the wolf-side LLM + # has the day-2+ medium / seer fake-CO playbook in + # mind without re-fetching the whole role-strategy + # block. + hints: list[str] = [] + if "medium" in active_callouts and state.day >= 2: + hints.append( + "霊媒騙りを選ぶなら、`co_declaration=\"medium\"` を立てた上で、" + "前日処刑者への霊媒結果 (`claimed_medium_result`) を必ず添える " + "(処刑が無い日は `is_wolf=null` で結果なしを明言)。" + ) + if "seer" in active_callouts: + hints.append( + "占い騙りを選ぶなら、`co_declaration=\"seer\"` を立てた上で、" + "占い結果 (`claimed_seer_result`) を必ず添える。" + "day 1 朝なら NIGHT_0 ランダム白で必ず白判定、" + "day 2 以降なら前夜の判定 (黒も可) を出す。" + "対抗 CO がすでに出ている場合は対抗 ledger と矛盾しない対象を選ぶ。" + ) + if "knight" in active_callouts: + hints.append( + "騎士騙りを選ぶなら、`co_declaration=\"knight\"` を立て、" + "合法な護衛履歴 (自己護衛・連続護衛・死亡対象護衛・存在しない護衛成功は禁止) のみ提示する。" + ) + hint_block = ("\n - " + "\n - ".join(hints)) if hints else "" + summary += ( + f"\n\n## 【あなた宛 / 緊急判断】対抗 CO のチャンス\n" + f"今ターン {roles_jp} の CO が公開ログに登場しているか、誰かが該当役の名乗りを促しています。" + f"あなたは人狼陣営側 ({recipient_role.value}) で、まだ占い/霊媒/騎士のいずれにも CO していません。" + f"**この発話で {roles_jp} のいずれかとして対抗/騙り CO を出すか、それとも潜伏 (skip / 一般発話) を選ぶか** を判断してください。" + f"\n\n判断軸:" + f"\n - 真役職者が単独で残ると村側が真寄りに固定する → 騙り CO で割って真偽比較を村に持ち込む価値" + f"\n - 縄数 / 残狼数 / 既出 CO 件数を見て、騙りを増やすことで処刑筋がどう変わるか" + f"\n - 騙ると「結果整合性 (発表内容の不変性)」を抱える義務が生じ、後の破綻リスクが上がる" + f"\n - 潜伏が強い盤面 (例: 占い CO がすでに 3 件出ているなど) では skip も選択肢" + f"{hint_block}" + f"\n\n決定したら、CO する場合は `co_declaration` を該当役にセットし、`public_message` で自然な日本語で名乗る。" + f"潜伏する場合は `co_declaration=null` のままで、議論への一般的な反応・疑い表明・話題提示などを行う。" + f"\"何もせず黙る\" のは情報漏洩こそ無いが、村側が CO 群を絞る時間を与える消極策である点に注意。" + ) + # Prefer the multi-addressee set; fall back to the legacy singular + # field for state objects that haven't been migrated (e.g. test + # fixtures that only set `last_addressed_seat`). + addressed_seats: frozenset[int] = state.last_addressed_seats + if not addressed_seats and state.last_addressed_seat is not None: + addressed_seats = frozenset({state.last_addressed_seat}) + if addressed_seats: + speaker_repr = ( + _name(state.last_addressed_speaker_seat) + if state.last_addressed_speaker_seat is not None + else "human" + ) + # Truncate the spoken line so the packet stays small even if the + # speaker rambled. NPCs only need the gist to respond on-topic. + utter = state.last_addressed_text.strip().replace("\n", " ") + if len(utter) > 160: + utter = utter[:160] + "…" + sorted_seats = sorted(addressed_seats) + addr_repr = ( + _name(sorted_seats[0]) + if len(sorted_seats) == 1 + else "[" + "、".join(_name(s) for s in sorted_seats) + "]" + ) + summary += ( + f" last_address={addr_repr}" + f" from={speaker_repr} text=\"{utter}\"" + ) + + return LogicPacket( + ts=now_ms, + trace_id=f"lp-{state.phase_id}-{recipient_npc_id}", + packet_id=_new_packet_id(), + phase_id=state.phase_id, + recipient_npc_id=recipient_npc_id, + public_state_summary=summary, + logic_candidates=tuple(candidates), + pressure=pressure or {}, + expires_at_ms=expires_at_ms, + recent_speeches=tuple(recent_speeches), + past_votes=tuple(past_votes), + pending_role_callouts=tuple(sorted(state.pending_role_callouts)), + past_suspicions=tuple(past_suspicions), + ) + + +__all__ = ["build_logic_packet"] diff --git a/src/wolfbot/master/arbiter/public_digest.py b/src/wolfbot/master/arbiter/public_digest.py new file mode 100644 index 0000000..8a5a294 --- /dev/null +++ b/src/wolfbot/master/arbiter/public_digest.py @@ -0,0 +1,228 @@ +"""Master-side public-info digest builder. + +Phase-D: Master is responsible for structuring the public log into +something each NPC bot can fold into its prompt without re-doing the +NLP. This module turns the live `PublicDiscussionState` + recent +`SpeechEvent` rows into a compact, *role-blind* Japanese block. Each +NPC bot receives the same digest regardless of role; their own private +state is what differentiates the eventual decision. + +What lands in the digest: + +* Active CO claims: seat → role, with counter-CO history. +* Silent seats (alive seats who haven't spoken in the day's discussion). +* Last addressed line: the most recent seat-to-seat callout text so the + NPC can reply on-topic. +* Recent speeches (last N): full text per utterance so the vote / night + decision LLM sees what the seer / medium actually claimed. Without + this the vote prompt only saw 「席9 が seer CO」 and 「席3 が medium + CO」 — no SQ-黒 detail — and ラキオ ended up voting セツ instead of + the SQ that ユリコ called black. +* Past votes from completed days: voter → target ledger so the LLM can + reason about who voted whom historically. + +Pure function of the inputs — no I/O, no LLM. Master invokes it once +per outbound `DecideVoteRequest` / `DecideNightActionRequest` / +`SpeakRequest` and stuffs the result into `public_state_summary`. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from wolfbot.domain.discussion import PublicDiscussionState, SpeechEvent, SpeechSource + +# Cap on the recent-speeches block so the prompt stays compact. Mirrors +# `_RECENT_SPEECH_CAP` in `speak_arbiter.py` for parity. +_RECENT_SPEECH_CAP = 20 + +# Per-utterance text snippet length cap. Any single speech longer than +# this is truncated with an ellipsis so a single rambling line doesn't +# blow the prompt budget. +_SNIPPET_CAP = 200 + + +def build_public_digest( + *, + state: PublicDiscussionState, + recent_events: Sequence[SpeechEvent], + seat_names: dict[int, str], + past_votes: Sequence[tuple[int, int, Sequence[tuple[int, int | None]]]] = (), + past_suspicions: Sequence[ + tuple[int, str, int, int, str, str, str | None, str | None] + ] = (), +) -> str: + """Compose the Japanese digest block. + + ``recent_events`` is a chronological sequence of the speech events + folded into ``state``. ``phase_baseline`` sentinels are filtered out + automatically — callers can pass the raw `load_phase` result. + + ``past_votes`` is the completed-day vote ledger as + ``(day, round, ((voter, target_or_none), ...))`` tuples. Passing an + empty sequence skips the ballot block entirely. + """ + lines: list[str] = [] + + def _name(seat: int) -> str: + """Best-effort display_name lookup; falls back to ``席N`` only + when the roster doesn't carry a name (recovery edge cases).""" + return seat_names.get(seat) or f"席{seat}" + + co_lines: list[str] = [] + if state.co_claims: + for c in state.co_claims: + co_lines.append(f" {_name(c.seat)}: {c.role_claim}") + lines.append("## CO 状況") + lines.extend(co_lines or [" (まだ誰も CO していない)"]) + + if state.silent_seats: + silent_str = "、".join(_name(s) for s in sorted(state.silent_seats)) + lines.append(f"## 未発言の生存席\n {silent_str}") + else: + lines.append("## 未発言の生存席\n (なし)") + + # NOTE: A `## 名指しされた回数 (多い順)` ranking used to live here + # — counted every time a seat was the addressed_seat_no of another + # utterance. Removed (2026-05-02) because vote/night decision LLMs + # were reading it as a "this seat is suspicious" signal, producing + # bandwagon votes against the most-discussed seat (often the human, + # who naturally draws more callouts as they argue). The recent + # speeches block below carries the same information in raw form; + # the LLM should infer pressure from the actual content, not from + # an aggregated counter that biases toward bandwagon piles-on. + + # Prefer the multi-addressee set; fall back to the legacy singular + # field for state objects that haven't been migrated (older fixtures + # / tests / pre-multi-address rows). + addressed_set: frozenset[int] = state.last_addressed_seats + if not addressed_set and state.last_addressed_seat is not None: + addressed_set = frozenset({state.last_addressed_seat}) + if addressed_set and state.last_addressed_text: + speaker = ( + state.last_addressed_speaker_seat + if state.last_addressed_speaker_seat is not None + else None + ) + speaker_label = _name(speaker) if speaker is not None else "人間" + target_label = "、".join(_name(t) for t in sorted(addressed_set)) + snippet = state.last_addressed_text.strip().replace("\n", " ") + if len(snippet) > 120: + snippet = snippet[:120] + "…" + lines.append(f"## 直近の名指し\n {speaker_label} → {target_label}: 「{snippet}」") + + # Recent speeches with content — the bit the vote / night decision + # LLM was missing. Capped to the trailing N non-baseline events so + # the prompt stays small even on a 9-NPC chat-heavy phase. + speech_lines: list[str] = [] + for ev in recent_events: + if ev.source == SpeechSource.PHASE_BASELINE: + continue + if ev.speaker_seat is None or not ev.text: + continue + snippet = ev.text.strip().replace("\n", " ") + if len(snippet) > _SNIPPET_CAP: + snippet = snippet[:_SNIPPET_CAP] + "…" + speech_lines.append(f" {_name(ev.speaker_seat)}: 「{snippet}」") + if speech_lines: + lines.append("## 直近の発言 (古い順)") + lines.extend(speech_lines[-_RECENT_SPEECH_CAP:]) + + if past_votes: + lines.append("## 公開された投票履歴") + for day, round_, pairs in past_votes: + label = "決選投票" if round_ >= 1 else "投票" + lines.append(f"- day{day} {label}:") + for voter, target in pairs: + voter_label = _name(voter) + target_label = "棄権" if target is None else _name(target) + lines.append(f" {voter_label} → {target_label}") + + if past_suspicions: + lines.append("## 公開された疑い履歴 (古い順、不変記録)") + level_label = { + "trust": "信頼", + "low": "弱疑", + "medium": "疑", + "high": "強疑", + } + for ( + day, + _phase, + suspecter, + target, + level, + reason, + from_level, + update_reason, + ) in past_suspicions: + sname = _name(suspecter) + tname = _name(target) + level_text = level_label.get(level, level) + row = f"- day{day} {sname} → {tname} ({level_text}): {reason}" + if from_level is not None: + from_text = level_label.get(from_level, from_level) + row += f" [{from_text}→{level_text} 更新理由: {update_reason or '(未記入)'}]" + lines.append(row) + + # Aggregate score per alive target — sum across suspecters using + # the LATEST level each suspecter has declared. Dead suspecters + # AND dead targets are dropped (a dead seat can't be attacked + # nor can their past suspicions be updated). Score weighting: + # trust=-1, low=1, medium=2, high=4. Wolves consult this to pick + # attack targets — the most-suspected seats will be lynched + # anyway, so attacking them is wasted; hit low-score (= village- + # trusted) seats instead. + alive_set = state.alive_seat_nos + latest: dict[tuple[int, int], str] = {} # (suspecter, target) → latest level + for ( + _day, + _phase, + suspecter, + target, + level, + _reason, + _from_level, + _update_reason, + ) in past_suspicions: + if suspecter not in alive_set or target not in alive_set: + continue + latest[(suspecter, target)] = level + score_weight = {"trust": -1, "low": 1, "medium": 2, "high": 4} + per_target: dict[int, dict[str, int]] = {} + for (_suspecter, target), level in latest.items(): + slot = per_target.setdefault( + target, {"trust": 0, "low": 0, "medium": 0, "high": 0, "score": 0}, + ) + slot[level] = slot.get(level, 0) + 1 + slot["score"] += score_weight.get(level, 0) + if per_target: + lines.append("") + lines.append("## 疑いスコア集計 (生存者のみ、score 高 → 低)") + lines.append( + " weights: trust=-1 / low=+1 / medium=+2 / high=+4 (各 suspecter の最新評価で集計)" + ) + sorted_targets = sorted( + per_target.items(), + key=lambda kv: (-kv[1]["score"], kv[0]), + ) + for target_seat, breakdown in sorted_targets: + tname = _name(target_seat) + parts: list[str] = [] + if breakdown["high"]: + parts.append(f"強疑{breakdown['high']}") + if breakdown["medium"]: + parts.append(f"疑{breakdown['medium']}") + if breakdown["low"]: + parts.append(f"弱疑{breakdown['low']}") + if breakdown["trust"]: + parts.append(f"信頼{breakdown['trust']}") + breakdown_text = " / ".join(parts) if parts else "(評価なし)" + lines.append( + f" - {tname}: {breakdown_text} → 合計 {breakdown['score']} 点" + ) + + return "\n".join(lines) + + +__all__ = ["build_public_digest"] diff --git a/src/wolfbot/master/arbiter/speak_arbiter.py b/src/wolfbot/master/arbiter/speak_arbiter.py new file mode 100644 index 0000000..e7635c5 --- /dev/null +++ b/src/wolfbot/master/arbiter/speak_arbiter.py @@ -0,0 +1,2299 @@ +"""SpeakArbiter — Master-side reactive_voice arbitration. + +Responsibilities, in spec order: + +1. Pick a candidate NPC for the current `PublicDiscussionState` and ensure + they are alive (NOT in `newly_dead`) and online (heartbeat fresh). +2. Reject the candidate when serial-speech is already busy: + - `human_currently_speaking` — a VAD window is open. + - `queue_busy` — another NPC has an authorized playback window open. + - `npc_offline` — the candidate's WS connection or heartbeat is stale. +3. Build a `LogicPacket` (via `master_logic_service.build_logic_packet`) + for the picked NPC, send it, then dispatch a `SpeakRequest` and + persist a row in `npc_speak_requests`. +4. On `SpeakResult` arrival, validate `phase_id` + `request_id` freshness + + length cap, persist a row in `npc_speak_results`, and if accepted, + write a `SpeechEvent(source=npc_generated)`, open the + `npc_playback_events` row, and reply with `PlaybackAuthorized`. +5. On `tts_finished` / `tts_failed` / `playback_finished` / `playback_failed` + update the audit row and release the serial-speech gate. + +A real `MasterWsServer` connection plus a real `SqliteRepo` are required at +runtime. Tests substitute `FakeMasterWsServer` and a tempfile-backed repo. +""" + +from __future__ import annotations + +import asyncio +import logging +import random +import uuid +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass +from typing import Any, Literal, cast + +from wolfbot.domain.discussion import ( + PublicDiscussionState, + SpeechEvent, + SpeechSource, + make_phase_id, +) +from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.models import Seat +from wolfbot.domain.rules import compute_vote_result +from wolfbot.domain.ws_messages import ( + PlaybackAuthorized, + PlaybackFailed, + PlaybackFinished, + RecentSpeech, + SpeakRequest, + SpeakResult, + TtsFailed, + TtsFinished, +) +from wolfbot.llm.prompt_builder import build_strategy_block +from wolfbot.master.arbiter.logic_service import build_logic_packet +from wolfbot.master.claim.claim_history import ( + ClaimHistory, + collect_claim_history, + expected_medium_claim_count_for_day, + expected_seer_claim_count_for_day, +) +from wolfbot.master.claim.claim_validator import ( + CO_CAP_REASONS, + FABRICATION_REASONS, + ActualMediumEvent, + ActualSeerEvent, + validate_claim_against_truth, +) +from wolfbot.master.ws.npc_registry import NpcRegistry +from wolfbot.persistence.sqlite_repo import SqliteRepo +from wolfbot.services.discussion_service import ( + DiscussionService, + new_event_id, +) +from wolfbot.services.discussion_service import ( + now_ms as default_now_ms, +) + +log = logging.getLogger(__name__) + + +# Last N speeches included in the LogicPacket. Mirrors the rounds-mode +# prompt builder which surfaces the trailing 40 PLAYER_SPEECH log lines; +# 20 is plenty for an 80-char reactive reply and keeps WS frames small. +_RECENT_SPEECH_CAP = 20 + +# Diversity guards on top of the addressed / silent / LRU sort key. +# - `_PAIR_VOLLEY_WINDOW`: when the last N events come from exactly 2 +# distinct seats AND none carried structured "info" (currently a +# ``co_declaration``), demote both seats so a third NPC gets to speak. +# This breaks the ラキオ ↔ ジョナス ping-pong: each pair gets to volley +# the full window before being told to step aside. +# - `_CONSECUTIVE_CAP`: same seat speaking ≥ N times in a row is also +# demoted. Mostly fires when a human keeps re-addressing the same NPC +# or when a buggy upstream tries to dispatch the same seat twice. +_PAIR_VOLLEY_WINDOW = 4 +_CONSECUTIVE_CAP = 3 + +# Maximum number of times Master will re-dispatch the same NPC after a +# fabricated `claimed_*_result`. With ~80% pass-rate per attempt +# (empirical: gemini-2.5-flash + thinking_budget=0 on the SEER prompt), +# 5 retries gives P(all fail) ≈ 0.03% — past that we give up and let +# normal rotation pick another NPC so the phase doesn't stall on one +# stuck speaker. +_MAX_FABRICATION_RETRIES = 5 + + +def _parse_day_from_phase_id(phase_id: str) -> int | None: + """Extract the integer day from a canonical phase_id token. + + Phase ids look like ``gid::day3::DAY_RUNOFF_SPEECH::1``; we walk the + ``::`` segments and look for one matching ``dayN``. Returns ``None`` + when the format doesn't match — the caller treats that as "skip, + don't mark done" rather than crashing the WS handler. + """ + for token in phase_id.split("::"): + if token.startswith("day"): + tail = token[3:] + if tail.isdigit(): + return int(tail) + return None + + +def _compute_demoted_seats( + summary: Sequence[tuple[int, bool]], +) -> frozenset[int]: + """Return seats that should be demoted in the next pick. + + Two independent gates, OR'd together: + + 1. Last ``_PAIR_VOLLEY_WINDOW`` events came from exactly 2 distinct + seats AND none of them flagged ``has_info`` (= no CO declared) → + both seats demoted. + 2. Last ``_CONSECUTIVE_CAP`` events all came from a single seat → + that seat demoted. + + Returns an empty set when the window is too short or no gate fires. + """ + demoted: set[int] = set() + if len(summary) >= _PAIR_VOLLEY_WINDOW: + window = list(summary)[-_PAIR_VOLLEY_WINDOW:] + seats_in_window = {seat for seat, _ in window} + any_info = any(has_info for _, has_info in window) + if len(seats_in_window) == 2 and not any_info: + demoted |= seats_in_window + if len(summary) >= _CONSECUTIVE_CAP: + tail = list(summary)[-_CONSECUTIVE_CAP:] + if len({seat for seat, _ in tail}) == 1: + demoted.add(tail[0][0]) + return frozenset(demoted) + + +@dataclass +class SpeakArbiterConfig: + # Per-utterance hard cap. Was 80 originally — too tight: the LLM + # routinely hit the limit mid-sentence (e.g. ending with 「それが気」 + # when about to say 「気になって」), and VOICEVOX then read the + # truncated fragment aloud. 140 leaves room for a complete thought + # while keeping playback under the 12s deadline (≈8s of audio at + # VOICEVOX's default speed). The system prompt also instructs the + # model to finish a sentence even if it has to be shorter than this + # cap, so 140 is the ceiling, not the target length. + max_chars_reactive: int = 140 + # Was 8000 — too tight for Gemini 2.5 Flash with any thinking + # budget (game 75a3b1f379cc had p50 latency 14.7s, p90 25s, max + # 25.7s; 80% of NPC speeches expired at 8s TTL). xAI Grok 4-1-fast + # comfortably fit under 8s, but the broader model lineup (Gemini + # 2.5/3, DeepSeek with thinking) routinely needs 15-25s. 30s + # gives a generous margin while still bounding the phase clock — + # the discussion phase is 300s so even a worst-case 30s TTL only + # consumes 10% of the phase per dispatch. Combined with thinking + # disabled at the env level (NPC_LLM_THINKING_LEVEL=minimal), most + # responses still come back in 2-4s; the 30s ceiling is purely + # for tail-latency tolerance. + request_ttl_ms: int = 30_000 + playback_deadline_ms: int = 12_000 + # Heartbeat freshness gate: an NPC bot is considered offline if its + # last heartbeat is older than this. Must be ≥3x ``HEARTBEAT_INTERVAL_S`` + # in the NPC env so a single missed/jittered beat doesn't kick the + # bot offline. Game ``6366cb014a0a`` had ``heartbeat_timeout_ms=5000`` + # against ``HEARTBEAT_INTERVAL_S=5`` (1:1 ratio) — Gina's NPC bot + # was chronically just-late on heartbeats and got skipped on EVERY + # SpeakRequest dispatch (0/59 across the whole game) while still + # receiving DecideVoteRequest (vote dispatch doesn't apply this + # gate). 15s = 3x interval, the textbook ratio for missed-beat + # tolerance. + heartbeat_timeout_ms: int = 15_000 + vad_finalization_timeout_ms: int = 4000 + + +@dataclass +class _PendingRequest: + request_id: str + npc_id: str + seat_no: int + phase_id: str + game_id: str + expires_at_ms: int + + +class SpeakArbiter: + """Single-game arbiter — `SpeakArbiter.dispatch_for_phase` is called by + the discussion mode plumbing (Bundle 8) once the phase enters + DAY_DISCUSSION under reactive_voice. + + The arbiter is intentionally not a long-running loop; it exposes + discrete operations the dispatcher / WS handlers call. + """ + + def __init__( + self, + *, + repo: SqliteRepo, + registry: NpcRegistry, + discussion: DiscussionService, + config: SpeakArbiterConfig | None = None, + now_ms: Callable[[], int] = default_now_ms, + runoff_announce: Callable[[Seat], Awaitable[None]] | None = None, + runoff_wake: Callable[[str], None] | None = None, + rng: random.Random | None = None, + ) -> None: + self.repo = repo + self.registry = registry + self.discussion = discussion + self.config = config or SpeakArbiterConfig() + self._now_ms = now_ms + # Seedable RNG for the picker tiebreak. Production uses an + # un-seeded `random.Random()`; tests inject a deterministic one. + self._rng = rng if rng is not None else random.Random() + # Optional Master-narration hook fired right before dispatching a + # tied LLM candidate's SpeakRequest in DAY_RUNOFF_SPEECH so Levi + # can name the speaker ("続いて、X 様の最終演説でございます。"). + # The arbiter awaits the callback so the announcement plays + # before the NPC's TTS so they don't overlap in VC. + self._runoff_announce = runoff_announce + # Optional engine-wake hook called when every tied LLM candidate + # has finished or been marked done; lets DAY_RUNOFF_SPEECH advance + # immediately to DAY_RUNOFF instead of waiting for the deadline. + self._runoff_wake = runoff_wake + # Strong refs to per-runoff watchdog tasks so they don't get + # garbage-collected mid-sleep. + self._runoff_watchdog_tasks: set[asyncio.Task[None]] = set() + self._pending: dict[str, _PendingRequest] = {} + # Serial-speech gate: a request_id is in `_active_playback` between + # PlaybackAuthorized and the closing tts_failed / playback_finished / + # playback_failed event. While non-empty, no new SpeakRequest is sent. + self._active_playback: set[str] = set() + # Playback deadline tracking: request_id → deadline_ms. + self._playback_deadlines: dict[str, int] = {} + # human_currently_speaking gate; the WS handler flips this on + # vad_speech_started / vad_speech_ended (handled in voice-ingest + # plumbing in Bundle 8). Empty by default. + self._human_speaking_segments: set[str] = set() + # Segments awaiting STT finalization. The human-speaking gate stays + # closed for a segment until speech_event_payload or stt_failed + # arrives (or vad_finalization_timeout_ms elapses). + # segment_id → deadline_ms + self._pending_stt_segments: dict[str, int] = {} + # Per-game tracker of seats already dispatched from the current + # role-callout priority pool. Reset when `pending_role_callouts` + # becomes empty for that game (= the callout was consumed by a + # matching CO). Without this set the picker would loop on the + # same pool member when others decline; with it, each pool + # member gets at most one chance per callout. + self._callout_pool_asked: dict[str, set[int]] = {} + # Per-(game_id, phase_id, npc_id) count of fabricated-claim + # rejections. Drives the "same NPC retries until accepted" loop + # in handle_speak_result. Cleared on phase change (alongside + # _callout_pool_asked) and on a successful accept for the NPC. + self._fabrication_retries: dict[tuple[str, str, str], int] = {} + # Per-(game_id, phase_id) set of seats that hit the fabrication + # retry cap (`_MAX_FABRICATION_RETRIES`). The dispatcher's + # candidate picker filters these seats out for the rest of the + # phase so a chronically-fabricating NPC can't monopolise the + # phase via the rotation re-selecting them. Cleared on phase + # change (alongside `_callout_pool_asked`). + self._fabrication_capped: dict[tuple[str, str], set[int]] = {} + # Per-(game_id, day) set of seats currently between + # `_runoff_announce` start and `dispatch_request` (LLM) / + # human-grace-watchdog spawn (human). The existing `_pending` + # check inside `_dispatch_runoff_next` only catches re-entries + # AFTER dispatch_request completes; the entire `await _runoff_announce` + # window (Levi TTS, several seconds) is unguarded. Without this + # set, a second `try_dispatch_next` invocation triggered by + # `_master_narrate` (PHASE_CHANGE) racing against + # `_on_reactive_phase_enter` re-picks the same seat and + # awaits `_runoff_announce` again, causing Levi to read the + # candidate intro twice in a row before the NPC speaks. + self._runoff_in_flight: dict[tuple[str, int], set[int]] = {} + # Per-(game_id, day) set of human candidates whose grace + # watchdog is currently scheduled. Strong refs to the asyncio + # tasks live in `_runoff_watchdog_tasks`; this dict just dedups. + self._runoff_human_watchdog: dict[tuple[str, int], set[int]] = {} + + # ------------------------------------------------------------- gates + + def mark_human_speaking(self, segment_id: str) -> None: + self._human_speaking_segments.add(segment_id) + + def mark_pending_stt(self, segment_id: str) -> None: + """VAD ended — keep gate held until STT finalizes or times out.""" + deadline = self._now_ms() + self.config.vad_finalization_timeout_ms + self._pending_stt_segments[segment_id] = deadline + + def finalize_stt(self, segment_id: str) -> None: + """STT completed (payload or failure) — release the segment gate.""" + self._pending_stt_segments.pop(segment_id, None) + self._human_speaking_segments.discard(segment_id) + + def clear_human_speaking(self, segment_id: str) -> None: + self._human_speaking_segments.discard(segment_id) + + def is_blocked(self) -> str | None: + now = self._now_ms() + # Sweep expired STT finalization deadlines — release segments whose + # STT never arrived within vad_finalization_timeout_ms. + expired_stt = [sid for sid, dl in self._pending_stt_segments.items() if now > dl] + for sid in expired_stt: + log.info("stt_finalization_timeout segment=%s", sid) + self._pending_stt_segments.pop(sid, None) + self._human_speaking_segments.discard(sid) + if self._human_speaking_segments: + return "human_currently_speaking" + # Sweep expired playback deadlines — close rows whose NPC never + # reported tts_failed / playback_finished / playback_failed. + expired_pb = [rid for rid, dl in self._playback_deadlines.items() if now > dl] + for rid in expired_pb: + log.info("playback_deadline_exceeded request=%s", rid) + self._active_playback.discard(rid) + self._playback_deadlines.pop(rid, None) + self._pending.pop(rid, None) + # DB close is best-effort — fire-and-forget in the sync check. + # The actual row closure is done in _sweep_expired_playback. + if self._active_playback: + return "queue_busy" + return None + + # ------------------------------------------------------------- dispatch + + async def dispatch_request( + self, + *, + state: PublicDiscussionState, + candidate_npc_id: str, + seat_no: int, + game_id: str, + suggested_intent: str = "speak", + selection_reason: str | None = None, + public_state_snapshot: dict[str, Any] | None = None, + retry_feedback: str | None = None, + ) -> tuple[SpeakRequest | None, str | None]: + """Try to send a SpeakRequest to `candidate_npc_id`. + + Returns ``(request, None)`` on success, ``(None, reason)`` on skip. + Reasons cover every documented `failure_reason` for arbiter + suppression / candidate skip. + """ + block = self.is_blocked() + if block is not None: + log.info( + "speak_request_suppressed npc=%s seat=%s reason=%s", + candidate_npc_id, + seat_no, + block, + ) + return (None, block) + + entry = self.registry.get(candidate_npc_id) + if entry is None or not entry.is_online or entry.send is None: + log.info( + "speak_candidate_skipped npc=%s reason=npc_offline", + candidate_npc_id, + ) + return (None, "npc_offline") + + now = self._now_ms() + if (now - entry.last_heartbeat_ms) > self.config.heartbeat_timeout_ms: + log.info( + "speak_candidate_skipped npc=%s reason=npc_offline_heartbeat", + candidate_npc_id, + ) + return (None, "npc_offline") + + # Resolve seat data: recent_speeches (with display names attached), + # alive/dead seat lists, and the candidate's role+strategy. All + # best-effort — if a load fails we fall back to empty/null and + # still dispatch (the prompt degrades gracefully to the historical + # minimal shape). + ( + recent_speeches, + alive_seats, + dead_seats, + role_name, + role_strategy, + ) = await self._collect_request_context(state, seat_no) + past_votes = await self._load_past_votes(game_id, state.day) + past_suspicions = await self._load_past_suspicions(game_id) + seat_names_lookup: dict[int, str] = { + seat: name for seat, name in (list(alive_seats) + list(dead_seats)) + } + claim_history = await self._load_claim_history(game_id, seat_names_lookup) + + # Build LogicPacket (sent first so the NPC has context for the + # subsequent speak_request). + recipient_role: Role | None = None + if role_name is not None: + try: + recipient_role = Role(role_name) + except ValueError: + recipient_role = None + packet = build_logic_packet( + state=state, + recipient_npc_id=candidate_npc_id, + expires_at_ms=now + self.config.request_ttl_ms, + now_ms=now, + recent_speeches=recent_speeches, + past_votes=past_votes, + past_suspicions=past_suspicions, + seat_names=seat_names_lookup, + claim_history=claim_history, + recipient_seat_no=seat_no, + recipient_role=recipient_role, + ) + try: + await entry.send(packet.model_dump_json()) + except Exception: + log.exception("logic_packet_send_failed npc=%s", candidate_npc_id) + return (None, "ws_send_failed") + + request = SpeakRequest( + ts=now, + trace_id=packet.trace_id, + request_id=f"sr_{uuid.uuid4().hex[:12]}", + phase_id=state.phase_id, + npc_id=candidate_npc_id, + seat_no=seat_no, + logic_packet_id=packet.packet_id, + suggested_intent=suggested_intent, + max_chars=self.config.max_chars_reactive, + max_duration_ms=self.config.playback_deadline_ms, + priority=0, + expires_at_ms=now + self.config.request_ttl_ms, + role=role_name, + role_strategy=role_strategy, + alive_seats=alive_seats, + dead_seats=dead_seats, + retry_feedback=retry_feedback, + ) + + await self.repo.insert_npc_speak_request( + request_id=request.request_id, + game_id=game_id, + phase_id=request.phase_id, + npc_id=candidate_npc_id, + seat_no=seat_no, + logic_packet_id=request.logic_packet_id, + suggested_intent=suggested_intent, + max_chars=self.config.max_chars_reactive, + max_duration_ms=self.config.playback_deadline_ms, + priority=0, + expires_at_ms=request.expires_at_ms, + created_at_ms=now, + selection_reason=selection_reason, + public_state_snapshot=public_state_snapshot, + ) + try: + await entry.send(request.model_dump_json()) + except Exception: + log.exception("speak_request_send_failed npc=%s", candidate_npc_id) + await self.repo.insert_npc_speak_result( + request_id=request.request_id, + game_id=game_id, + phase_id=request.phase_id, + npc_id=candidate_npc_id, + status="rejected", + text=None, + used_logic_ids=None, + intent=None, + estimated_duration_ms=None, + failure_reason="ws_send_failed", + received_at_ms=now, + ) + return (None, "ws_send_failed") + + self._pending[request.request_id] = _PendingRequest( + request_id=request.request_id, + npc_id=candidate_npc_id, + seat_no=seat_no, + phase_id=request.phase_id, + game_id=game_id, + expires_at_ms=request.expires_at_ms, + ) + return (request, None) + + # --------------------------------------------------- request context loader + + async def _collect_request_context( + self, + state: PublicDiscussionState, + seat_no: int, + ) -> tuple[ + tuple[RecentSpeech, ...], + tuple[tuple[int, str], ...], + tuple[tuple[int, str], ...], + str | None, + str | None, + ]: + """Load the data the NPC prompt needs but the arbiter doesn't already have. + + Returns ``(recent_speeches, alive_seats, dead_seats, role_name, + role_strategy)``. Each piece is independently best-effort: a failed + DB read for one slot logs and falls back to an empty value while + the others still populate. The intent is that a transient repo + glitch must NOT block dispatching — the NPC then sees the older + minimal prompt shape rather than no prompt at all. + """ + recent: tuple[RecentSpeech, ...] = () + try: + events = await self.discussion.load_phase(state.game_id, state.phase_id) + seats = await self.repo.load_seats(state.game_id) + seat_name_by_no = {s.seat_no: s.display_name for s in seats} + recent_list: list[RecentSpeech] = [] + for ev in events: + if ev.source == SpeechSource.PHASE_BASELINE: + continue + if ev.speaker_seat is None or not ev.text: + continue + name = seat_name_by_no.get(ev.speaker_seat, f"席{ev.speaker_seat}") + # Trim very long speeches; the NPC only needs the gist. + snippet = ev.text.strip().replace("\n", " ") + if len(snippet) > 200: + snippet = snippet[:200] + "…" + # `SpeechSource.value` is one of the four runtime values, but + # ``phase_baseline`` is filtered above so the literal narrows + # to the three viewer-facing values that `RecentSpeech.source` + # accepts. + source_value = cast( + Literal["text", "voice_stt", "npc_generated"], + ev.source.value, + ) + recent_list.append( + RecentSpeech( + seat_no=ev.speaker_seat, + display_name=name, + source=source_value, + text=snippet, + ) + ) + # Cap to last N so the prompt stays compact even on long phases. + recent = tuple(recent_list[-_RECENT_SPEECH_CAP:]) + except Exception: + log.exception("recent_speeches_load_failed phase_id=%s", state.phase_id) + + alive_seats: tuple[tuple[int, str], ...] = () + dead_seats: tuple[tuple[int, str], ...] = () + role_name: str | None = None + try: + seats = await self.repo.load_seats(state.game_id) + players = await self.repo.load_players(state.game_id) + seat_name_by_no = {s.seat_no: s.display_name for s in seats} + alive_list: list[tuple[int, str]] = [] + dead_list: list[tuple[int, str]] = [] + for p in players: + name = seat_name_by_no.get(p.seat_no, f"席{p.seat_no}") + if p.alive: + alive_list.append((p.seat_no, name)) + else: + dead_list.append((p.seat_no, name)) + if p.seat_no == seat_no and p.role is not None: + role_name = p.role.value + alive_seats = tuple(sorted(alive_list)) + dead_seats = tuple(sorted(dead_list)) + except Exception: + log.exception( + "seat_role_load_failed game_id=%s seat=%s", + state.game_id, + seat_no, + ) + + role_strategy: str | None = None + if role_name is not None: + try: + role_strategy = build_strategy_block(Role(role_name)) + except Exception: + log.exception("role_strategy_build_failed role=%s", role_name) + role_strategy = None + + return recent, alive_seats, dead_seats, role_name, role_strategy + + async def _count_execution_days(self, game_id: str) -> int: + """Count distinct days that recorded an EXECUTION public log. + + Used by the pending-result picker priority: a medium CO seat is + "pending" when their published medium-claim count is short of the + executions-so-far. We cap the lookup at the limit hard-coded in + ``load_public_logs`` (40 rows is plenty — a 9-player game caps + out at 4 ropes ≤ 4 executions plus runoffs). On error we return + 0 so the priority bucket stays empty and the picker falls back + to the count-driven rotation. + """ + try: + rows = await self.repo.load_public_logs(game_id, limit=40) + except Exception: + log.exception("execution_day_count_failed game=%s", game_id) + return 0 + days = { + int(r["day"]) + for r in rows + if r.get("kind") == "EXECUTION" and r.get("day") is not None + } + return len(days) + + def _compute_pending_result_seats( + self, + *, + co_claims: Sequence[Any], + claim_history: ClaimHistory | None, + day_number: int, + execution_days: int, + ) -> frozenset[int]: + """Seats with an unpublished seer/medium result for the current day. + + A seer CO seat is pending when their announced seer-claim count + is strictly less than ``expected_seer_claim_count_for_day`` — + i.e. they haven't yet declared today's expected divine result + (NIGHT_0 random white on day 1, prior-night result on day 2+). + + A medium CO seat is pending when their announced medium-claim + count is strictly less than ``executions_so_far`` — i.e. there + was an execution they haven't yet weighed in on. day 1 has no + prior execution so the medium can never be pending in day 1. + + Returns an empty set when ``claim_history`` is None (load + failure) or no CO matches; the picker treats an empty set as + "no priority bucket" and proceeds with the count-driven sort. + """ + if claim_history is None or day_number < 1: + return frozenset() + expected_seer = expected_seer_claim_count_for_day(day_number) + expected_medium = expected_medium_claim_count_for_day(execution_days) + pending: set[int] = set() + for claim in co_claims: + seat = getattr(claim, "seat", None) + role = getattr(claim, "role_claim", None) + if seat is None or role is None: + continue + history = claim_history.by_seat.get(seat) + if history is None: + # CO'd but no claim entries persisted yet — definitely pending. + if role in ("seer", "medium"): + pending.add(seat) + continue + if (role == "seer" and len(history.seer_claims) < expected_seer) or (role == "medium" and len(history.medium_claims) < expected_medium): + pending.add(seat) + return frozenset(pending) + + async def _load_claim_history( + self, + game_id: str, + seat_names: dict[int, str], + ) -> ClaimHistory | None: + """Aggregate every persisted seer/medium claim into a per-seat history. + + Reads ``speech_events`` directly via the discussion-service store + so the rebuild matches the canonical SpeechEvent ordering. The + helper is best-effort: a load failure logs and returns ``None`` + so the per-prompt block silently degrades to the legacy "no + history" rendering rather than failing the whole dispatch. + """ + try: + events = await self.discussion.load_for_game(game_id) + except Exception: + log.exception( + "claim_history_load_failed game=%s", + game_id, + ) + return None + return collect_claim_history(events, seat_names=seat_names) + + async def _load_past_suspicions( + self, game_id: str + ) -> tuple[tuple[int, str, int, int, str, str, str | None, str | None], ...]: + """Load the public suspicion timeline for the game. + + Best-effort: returns an empty tuple on any DB glitch so a + suspicions-table read failure cannot stall a SpeakRequest. + """ + try: + rows = await self.repo.load_suspicions_for_game(game_id) + except Exception: + log.exception("past_suspicions_load_failed game=%s", game_id) + return () + return tuple( + ( + int(r["day"]), + str(r["phase"]), + int(r["suspecter_seat"]), + int(r["target_seat"]), + str(r["level"]), + str(r["reason"]), + r["update_from_level"] if r["update_from_level"] is not None else None, + r["update_reason"] if r["update_reason"] is not None else None, + ) + for r in rows + ) + + async def _load_past_votes( + self, game_id: str, current_day: int + ) -> tuple[tuple[int, int, tuple[tuple[int, int | None], ...]], ...]: + """Load completed-day vote ballots so the prompt builder can show + every NPC the public ledger of "who voted whom". + + Without this, models routinely fabricate their own past vote + because the EXECUTION public log isn't surfaced anywhere in the + per-phase fold (state has co_claims and silent_seats but not + votes). Returns empty when no past day exists or on any DB + glitch — best-effort. + """ + if current_day <= 1: + return () + out: list[tuple[int, int, tuple[tuple[int, int | None], ...]]] = [] + try: + for day in range(1, current_day): + for round_ in (0, 1): + rows = await self.repo.load_votes( + game_id, + day=day, + round_=round_, + ) + if not rows: + continue + pairs = tuple( + (v.voter_seat, v.target_seat) + for v in sorted(rows, key=lambda v: v.voter_seat) + ) + out.append((day, round_, pairs)) + except Exception: + log.exception("past_votes_load_failed game=%s", game_id) + return () + return tuple(out) + + async def _load_actual_seer_history( + self, + game_id: str, + seat_no: int, + ) -> list[ActualSeerEvent]: + """Build the real-seer history for `seat_no`. + + Reuses :func:`master.private_state.load_private_state_for_seat` + so the validator reads the SAME private-log path the NPC's + ``自分の占い結果`` prompt section sees: ``SEER_RESULT_NIGHT0`` + plus per-night ``SEER_RESULT`` rows in ``logs_private``. + + Going through ``night_actions`` directly (the v1 implementation) + misses the NIGHT_0 random white because no submission row is + written for it — the system picks the target. Game + ``6a0dd72d63e3`` reproduced the bug: real seer Setsu's NIGHT_0 + Jonas白 wasn't in ``night_actions``, so the validator treated + her legitimate Jonas claim as a fabricated target and bounced + her 6 times in a row. + """ + from wolfbot.master.state.private_state import load_private_state_for_seat + try: + players = await self.repo.load_players(game_id) + seats = await self.repo.load_seats(game_id) + except Exception: + log.exception("actual_seer_load_meta_failed game=%s", game_id) + return [] + try: + seer_results, _medium, _guard, _wolfchat, _attacks = ( + await load_private_state_for_seat( + self.repo, game_id=game_id, seat_no=seat_no, + role=Role.SEER, players=players, seats=seats, + ) + ) + except Exception: + log.exception( + "actual_seer_load_private_state_failed game=%s seat=%s", + game_id, seat_no, + ) + return [] + return [ + ActualSeerEvent( + day=sr.day, + target_seat=sr.target_seat, + is_wolf=sr.is_wolf, + ) + for sr in seer_results + ] + + async def _load_actual_medium_history( + self, + game_id: str, + seat_no: int, + ) -> tuple[list[ActualMediumEvent], int]: + """Build real-medium history + total executions-so-far count. + + Mirrors :meth:`_load_actual_seer_history` for medium: parse + ``MEDIUM_RESULT`` rows in ``logs_private`` so the validator + and the NPC's ``自分の霊媒結果`` prompt section agree on truth. + For the ``executions_so_far`` count needed by fake-medium + guards, we additionally count public EXECUTION logs because + non-medium speakers don't have private medium logs. Returns + ``(history, count)`` — history empty unless the speaker IS + the real medium, count comes from the public log regardless. + """ + from wolfbot.master.state.private_state import load_private_state_for_seat + try: + players = await self.repo.load_players(game_id) + seats = await self.repo.load_seats(game_id) + except Exception: + log.exception("actual_medium_load_meta_failed game=%s", game_id) + return ([], 0) + try: + _seer, medium_results, _guard, _wolfchat, _attacks = ( + await load_private_state_for_seat( + self.repo, game_id=game_id, seat_no=seat_no, + role=Role.MEDIUM, players=players, seats=seats, + ) + ) + except Exception: + log.exception( + "actual_medium_load_private_state_failed game=%s seat=%s", + game_id, seat_no, + ) + medium_results = () + # Real-medium results may include "no execution today" entries + # whose is_wolf is None — those don't need validating against + # because they carry no claim-able fact, so drop them here. + history = [ + ActualMediumEvent( + day=mr.day, + target_seat=mr.target_seat, + is_wolf=mr.is_wolf, + ) + for mr in medium_results + if mr.is_wolf is not None + ] + # Public EXECUTION count is needed even when the speaker isn't + # the real medium (fake-medium "no execution yet" rule). + count = 0 + try: + logs = await self.repo.load_public_logs(game_id, limit=200) + except Exception: + log.exception("actual_medium_load_logs_failed game=%s", game_id) + logs = [] + for row in logs: + if row.get("kind") == "EXECUTION": + count += 1 + return (history, count) + + async def _load_speaker_role(self, game_id: str, seat_no: int) -> Role | None: + try: + players = await self.repo.load_players(game_id) + except Exception: + log.exception("speaker_role_load_failed game=%s", game_id) + return None + for p in players: + if p.seat_no == seat_no: + return p.role + return None + + # ------------------------------------------------------------- handle result + + async def handle_speak_result( + self, + result: SpeakResult, + *, + current_phase_id: str, + day: int, + phase: Phase, + ) -> tuple[bool, str | None]: + """Validate and persist a SpeakResult. + + On success returns ``(True, None)`` and emits a `PlaybackAuthorized` + on the NPC's back-channel. On failure returns ``(False, reason)`` + and emits a `PlaybackRejected`. + """ + from wolfbot.domain.ws_messages import PlaybackRejected + + now = self._now_ms() + pending = self._pending.get(result.request_id) + entry = self.registry.get(result.npc_id) + + async def _send(payload: str) -> None: + if entry is not None and entry.send is not None: + try: + await entry.send(payload) + except Exception: + log.exception("speak_result_response_send_failed npc=%s", result.npc_id) + + async def _record_rejection(reason: str) -> None: + await self.repo.insert_npc_speak_result( + request_id=result.request_id, + game_id=pending.game_id if pending is not None else "", + phase_id=result.phase_id, + npc_id=result.npc_id, + status="rejected", + text=result.text, + used_logic_ids=list(result.used_logic_ids), + intent=result.intent, + estimated_duration_ms=result.estimated_duration_ms, + failure_reason=reason, + received_at_ms=now, + ) + rejection = PlaybackRejected( + ts=now, + trace_id=result.trace_id, + request_id=result.request_id, + npc_id=result.npc_id, + failure_reason=reason, + ) + await _send(rejection.model_dump_json()) + + if pending is None: + await _record_rejection("unknown_request") + return (False, "unknown_request") + + async def _reject_and_advance(reason: str) -> tuple[bool, str | None]: + """Common rejection cleanup: record + mark runoff (no-op outside + runoff) + pop _pending + re-dispatch. + + Without the pop + re-dispatch, an `expired_request` rejection + in DAY_DISCUSSION leaves the phase silently stalled — game + eab1f9514a10 day 4: SQ finished, ユリコ was dispatched, took + 10s to respond (TTL=8s), got `expired_request` rejected, and + the day went 2.5 minutes without a single follow-up dispatch + because no one was triggering try_dispatch_next. + """ + await _record_rejection(reason) + await self._mark_runoff_done_if_phase( + game_id=pending.game_id, + phase_id=result.phase_id, + seat_no=pending.seat_no, + ) + self._pending.pop(result.request_id, None) + try: + await self.try_dispatch_next(pending.game_id) + except Exception: + log.exception( + "speak_result_rejection_redispatch_failed game=%s reason=%s", + pending.game_id, + reason, + ) + return (False, reason) + + async def _reject_and_retry_same_npc( + reason: str, + feedback: str, + ) -> tuple[bool, str | None]: + """Same-NPC retry path for fabrication-class rejections. + + Records the rejection (so the audit trail keeps every + attempt), sends a ``PlaybackRejected`` so the NPC drops + its queued audio, then re-dispatches a fresh SpeakRequest + to the same seat with ``retry_feedback`` filled in. The + rebuilt LogicPacket carries the latest public state (the + previous attempt's text is NOT in the public log because + it never played, so the recent-speech section is unchanged + from the original attempt). Increments + ``_fabrication_retries`` first; the caller checked the cap + already, so this method is the unconditional retry leg. + """ + await _record_rejection(reason) + self._pending.pop(result.request_id, None) + try: + state_for_retry = await self.rebuild_public_state( + game_id=pending.game_id, + day=day, + phase=phase, + ) + if state_for_retry is None: + log.info( + "fabrication_retry_no_state game=%s npc=%s", + pending.game_id, + result.npc_id, + ) + return (False, reason) + await self.dispatch_request( + state=state_for_retry, + candidate_npc_id=result.npc_id, + seat_no=pending.seat_no, + game_id=pending.game_id, + suggested_intent="speak", + selection_reason="fabrication_retry", + retry_feedback=feedback, + ) + except Exception: + log.exception( + "fabrication_retry_dispatch_failed game=%s npc=%s reason=%s", + pending.game_id, + result.npc_id, + reason, + ) + return (False, reason) + + if result.phase_id != current_phase_id: + return await _reject_and_advance("stale_phase") + if now > pending.expires_at_ms: + return await _reject_and_advance("expired_request") + if result.status != "accepted" or not result.text: + return await _reject_and_advance("speaker_declined") + if len(result.text) > self.config.max_chars_reactive: + return await _reject_and_advance("utterance_too_long") + + # Fabrication validation: catch a real seer / medium claiming an + # unrecorded target, OR a fake CO swapping its own past target / + # color. On hit, retry the same NPC up to _MAX_FABRICATION_RETRIES + # times with feedback in the next prompt; past the cap, fall + # back to normal rotation so the phase doesn't stall. + if result.claimed_seer_result is not None or result.claimed_medium_result is not None: + speaker_role = await self._load_speaker_role( + pending.game_id, + pending.seat_no, + ) + actual_seer: list[ActualSeerEvent] = [] + actual_medium: list[ActualMediumEvent] = [] + executions_so_far = 0 + if speaker_role is Role.SEER: + actual_seer = await self._load_actual_seer_history( + pending.game_id, pending.seat_no, + ) + if speaker_role is Role.MEDIUM: + actual_medium, executions_so_far = await self._load_actual_medium_history( + pending.game_id, pending.seat_no, + ) + else: + # Need executions_so_far for the fake-medium "no execution + # yet" rule even when the speaker isn't the real medium. + _, executions_so_far = await self._load_actual_medium_history( + pending.game_id, pending.seat_no, + ) + seats = await self.repo.load_seats(pending.game_id) + seat_names_for_history = {s.seat_no: s.display_name for s in seats} + claim_history = await self._load_claim_history( + pending.game_id, + seat_names_for_history, + ) + prior_for_speaker = ( + claim_history.by_seat.get(pending.seat_no) if claim_history is not None else None + ) + # Count distinct claimers per role for the global CO cap + # (max 3 seer / max 2 medium). A seat with non-empty + # seer_claims has CO'd at least once → counts as one + # distinct claimer. + seer_co_count = 0 + medium_co_count = 0 + if claim_history is not None: + for ch in claim_history.by_seat.values(): + if ch.seer_claims: + seer_co_count += 1 + if ch.medium_claims: + medium_co_count += 1 + validation = validate_claim_against_truth( + speaker_role=speaker_role or Role.VILLAGER, + speaker_seat=pending.seat_no, + day=day, + phase=phase, + claimed_seer=result.claimed_seer_result, + claimed_medium=result.claimed_medium_result, + actual_seer_history=actual_seer, + actual_medium_history=actual_medium, + prior_public_claims=prior_for_speaker, + executions_so_far=executions_so_far, + seer_co_count=seer_co_count, + medium_co_count=medium_co_count, + ) + if not validation.ok and validation.reason in CO_CAP_REASONS: + # CO cap exceeded: drop the audio (PlaybackRejected) and + # rotate to the next picker pick. Don't burn fabrication + # retries — the cap is a structural rule, not a self- + # correction problem. The same NPC remains eligible for + # subsequent dispatches; if they CO again next time, + # they get rejected again, but no retry cascade happens. + log.info( + "co_cap_exceeded_skip game=%s npc=%s seat=%s reason=%s", + pending.game_id, + result.npc_id, + pending.seat_no, + validation.reason, + ) + return await _reject_and_advance(validation.reason) + if not validation.ok and validation.reason in FABRICATION_REASONS: + key = (pending.game_id, result.phase_id, result.npc_id) + self._fabrication_retries[key] = self._fabrication_retries.get(key, 0) + 1 + attempt = self._fabrication_retries[key] + log.warning( + "fabrication_detected game=%s npc=%s seat=%s reason=%s attempt=%d/%d", + pending.game_id, + result.npc_id, + pending.seat_no, + validation.reason, + attempt, + _MAX_FABRICATION_RETRIES, + ) + if attempt >= _MAX_FABRICATION_RETRIES: + # Give up: block this seat from being re-picked for + # the rest of the phase, then bail to normal rotation. + # Without the block, `try_dispatch_next` re-selects + # the same NPC immediately (especially when they're + # in the seer/medium callout pool), the retry counter + # increments past the cap (observed: attempt=6/5, + # 7/5, 8/5...), and the phase stalls because no other + # NPC ever gets dispatched. Game ``6366cb014a0a`` + # day 1 hit this with ユリコ (madman) burning ~75s + # of phase time on `day1_seer_claim_overflow` retries + # before the deadline closed the discussion. + self._fabrication_capped.setdefault( + (pending.game_id, result.phase_id), set() + ).add(pending.seat_no) + log.info( + "fabrication_capped_seat_blocked game=%s phase=%s seat=%s", + pending.game_id, result.phase_id, pending.seat_no, + ) + return await _reject_and_advance(validation.reason) + return await _reject_and_retry_same_npc( + reason=validation.reason, + feedback=validation.feedback or "", + ) + + # Accepted. Clear any prior fabrication-retry count for this + # (game, phase, npc) so the cap resets if the NPC fabricates + # again later in the same phase. + self._fabrication_retries.pop( + (pending.game_id, result.phase_id, result.npc_id), + None, + ) + + # Accepted. Persist result + SpeechEvent + open playback row. + await self.repo.insert_npc_speak_result( + request_id=result.request_id, + game_id=pending.game_id, + phase_id=result.phase_id, + npc_id=result.npc_id, + status="accepted", + text=result.text, + used_logic_ids=list(result.used_logic_ids), + intent=result.intent, + estimated_duration_ms=result.estimated_duration_ms, + failure_reason=None, + received_at_ms=now, + ) + # Resolve the NPC's addressed list: take every seat it named, + # union with the legacy singular field (for older NPC builds), + # then drop self-address and any non-alive seats so a + # hallucinated id can't poison the routing. Order is preserved + # so the eventual frontmost addressee in the list (e.g. + # ``[3, 4]``) keeps a deterministic ordering — the arbiter's + # tiebreak randomises within the addressed group anyway. + addressed_candidates: list[int] = [] + seen: set[int] = set() + for seat in result.addressed_seat_nos: + if seat is None or seat in seen: + continue + seen.add(seat) + addressed_candidates.append(int(seat)) + if result.addressed_seat_no is not None and result.addressed_seat_no not in seen: + seen.add(result.addressed_seat_no) + addressed_candidates.append(int(result.addressed_seat_no)) + # Drop self-address. + addressed_candidates = [s for s in addressed_candidates if s != pending.seat_no] + if addressed_candidates: + try: + alive_seats = await self.repo.load_players(pending.game_id) + alive_set = {p.seat_no for p in alive_seats if p.alive} + except Exception: + log.exception( + "addressed_seat_alive_check_failed game=%s", + pending.game_id, + ) + alive_set = set() + filtered: list[int] = [] + for s in addressed_candidates: + if s in alive_set: + filtered.append(s) + else: + log.info( + "npc_addressed_seat_unknown game=%s seat=%d addressed=%s — dropped", + pending.game_id, + pending.seat_no, + s, + ) + addressed_candidates = filtered + addressed_seat_nos: tuple[int, ...] = tuple(addressed_candidates) + addressed_seat_no = addressed_seat_nos[0] if addressed_seat_nos else None + # Self-claim guard: if the NPC's structured claim names its own + # seat we drop it before persisting. The wire model already + # rejects self-target via `_build_claimed_*` on the NPC side, but + # an older NPC binary or a malformed payload could still slip + # through — defending here keeps the claim-history fold clean. + seer_claim = result.claimed_seer_result + if seer_claim is not None and seer_claim.target_seat == pending.seat_no: + seer_claim = None + medium_claim = result.claimed_medium_result + if medium_claim is not None and medium_claim.target_seat == pending.seat_no: + medium_claim = None + speech_event = SpeechEvent( + event_id=new_event_id(), + game_id=pending.game_id, + phase_id=result.phase_id, + day=day, + phase=phase, + source=SpeechSource.NPC_GENERATED, + speaker_kind="npc", # type: ignore[arg-type] + speaker_seat=pending.seat_no, + text=result.text, + co_declaration=result.co_declaration, + addressed_seat_no=addressed_seat_no, + addressed_seat_nos=addressed_seat_nos, + claimed_seer_target_seat=(seer_claim.target_seat if seer_claim is not None else None), + claimed_seer_is_wolf=(seer_claim.is_wolf if seer_claim is not None else None), + claimed_medium_target_seat=( + medium_claim.target_seat if medium_claim is not None else None + ), + claimed_medium_is_wolf=(medium_claim.is_wolf if medium_claim is not None else None), + created_at_ms=now, + ) + await self.discussion.record(speech_event) + # Persist structured suspicions attached to this utterance. Drop + # entries that target the speaker's own seat (the SpeakResult + # builder already filters but a stale NPC binary could slip + # through). Empty list is a no-op. + valid_suspicions = tuple( + s for s in result.suspicions if s.target_seat != pending.seat_no + ) + if valid_suspicions: + try: + await self.repo.insert_speech_suspicions( + event_id=speech_event.event_id, + game_id=pending.game_id, + day=day, + phase=phase, + suspecter_seat=pending.seat_no, + created_at_ms=now, + suspicions=valid_suspicions, + ) + except Exception: + log.exception( + "speech_suspicions_insert_failed game=%s seat=%d event=%s", + pending.game_id, + pending.seat_no, + speech_event.event_id, + ) + deadline = now + self.config.playback_deadline_ms + await self.repo.open_npc_playback( + request_id=result.request_id, + game_id=pending.game_id, + phase_id=result.phase_id, + npc_id=result.npc_id, + speech_event_id=speech_event.event_id, + authorized_at_ms=now, + playback_deadline_ms=deadline, + ) + self._active_playback.add(result.request_id) + self._playback_deadlines[result.request_id] = deadline + authorized = PlaybackAuthorized( + ts=now, + trace_id=result.trace_id, + request_id=result.request_id, + npc_id=result.npc_id, + speech_event_id=speech_event.event_id, + playback_deadline_ms=deadline, + ) + await _send(authorized.model_dump_json()) + # Note: `runoff_speech_done` is intentionally NOT flipped here. + # `accept_speak_result` fires when the NPC has generated text + + # TTS bytes — the audio hasn't started playing in VC yet. If we + # advanced the phase here, the runoff vote would open while the + # candidate's speech is still being read out loud. Defer the + # flag flip to `handle_playback_finished` (or + # `handle_playback_failed` / `handle_tts_failed`) so listeners + # actually hear the final speech before the vote starts. + return (True, None) + + # ------------------------------------------------------------- TTS / playback + + async def handle_tts_finished(self, msg: TtsFinished) -> None: + await self.repo.update_npc_playback_tts( + msg.request_id, + outcome="success", + duration_ms=msg.tts_duration_ms, + failure_reason=None, + ) + + async def handle_tts_failed(self, msg: TtsFailed) -> None: + now = self._now_ms() + await self.repo.update_npc_playback_tts( + msg.request_id, + outcome="failed", + duration_ms=None, + failure_reason=msg.failure_reason, + ) + await self.repo.close_npc_playback( + msg.request_id, + finished_at_ms=now, + outcome="failed", + failure_reason=msg.failure_reason, + ) + # Read pending fields BEFORE pop so the runoff-mark hook + # below has the seat / phase / game ids available. TTS-fail + # is a terminal state; the phase advances rather than stalling. + pending = self._pending.get(msg.request_id) + self._active_playback.discard(msg.request_id) + self._playback_deadlines.pop(msg.request_id, None) + self._pending.pop(msg.request_id, None) + if pending is not None: + await self._mark_runoff_done_if_phase( + game_id=pending.game_id, + phase_id=pending.phase_id, + seat_no=pending.seat_no, + ) + + async def handle_playback_finished(self, msg: PlaybackFinished) -> None: + await self.repo.close_npc_playback( + msg.request_id, + finished_at_ms=msg.finished_at_ms, + outcome="succeeded", + failure_reason=None, + ) + # Read pending fields BEFORE pop so the runoff-mark hook + # has the seat / phase / game ids. The flag flip is delayed + # to playback-finish (not accept_speak_result) so listeners + # actually hear the final speech before the vote starts. + pending = self._pending.get(msg.request_id) + self._active_playback.discard(msg.request_id) + self._playback_deadlines.pop(msg.request_id, None) + self._pending.pop(msg.request_id, None) + if pending is not None: + await self._mark_runoff_done_if_phase( + game_id=pending.game_id, + phase_id=pending.phase_id, + seat_no=pending.seat_no, + ) + + async def handle_playback_failed(self, msg: PlaybackFailed) -> None: + now = self._now_ms() + await self.repo.close_npc_playback( + msg.request_id, + finished_at_ms=now, + outcome="failed", + failure_reason=msg.failure_reason, + ) + pending = self._pending.get(msg.request_id) + self._active_playback.discard(msg.request_id) + self._playback_deadlines.pop(msg.request_id, None) + self._pending.pop(msg.request_id, None) + if pending is not None: + # Playback fail is terminal — advance instead of stalling. + await self._mark_runoff_done_if_phase( + game_id=pending.game_id, + phase_id=pending.phase_id, + seat_no=pending.seat_no, + ) + + # ------------------------------------------------------------- auto-dispatch + + async def _sweep_expired_playback(self) -> None: + """Close DB rows for playback windows that exceeded their deadline.""" + now = self._now_ms() + expired = [rid for rid, dl in list(self._playback_deadlines.items()) if now > dl] + for rid in expired: + log.info("playback_deadline_enforced request=%s", rid) + try: + await self.repo.close_npc_playback( + rid, + finished_at_ms=now, + outcome="failed", + failure_reason="playback_deadline_exceeded", + ) + except Exception: + log.exception("playback_deadline_close_failed request=%s", rid) + self._active_playback.discard(rid) + self._playback_deadlines.pop(rid, None) + self._pending.pop(rid, None) + + async def try_dispatch_next(self, game_id: str) -> None: + """Auto-pick the next candidate NPC and dispatch a SpeakRequest. + + Called on phase entry, after each new public speech event, and after + playback completes. No-op when the serial-speech gate is blocked, no + NPC is online, or no game is in a reactive_voice discussion phase. + + DAY_RUNOFF_SPEECH uses a separate picker (`_dispatch_runoff_next`) + constrained to tied candidates with one shot each — see that method. + """ + # Close expired playback rows in DB before checking the gate. + await self._sweep_expired_playback() + + game = await self.repo.load_game(game_id) + if game is None or game.ended_at is not None: + return + if game.discussion_mode != "reactive_voice": + return + if game.phase not in (Phase.DAY_DISCUSSION, Phase.DAY_RUNOFF_SPEECH): + return + + block = self.is_blocked() + if block is not None: + return + + if game.phase is Phase.DAY_RUNOFF_SPEECH: + await self._dispatch_runoff_next(game_id, game.day_number) + return + + state = await self.rebuild_public_state( + game_id=game_id, day=game.day_number, phase=game.phase + ) + if state is None: + return + + # Auto-inject the medium-callout on day≥2 morning when no medium + # CO has landed yet. Without this, day 2+ discussions silently + # skip the medium topic — observed live in game 6c97c1cc22ef + # where day 2/3 went without a single mention of the medium + # role's expected result. The fold logic in `_compute_callout_pool` + # then naturally builds a priority pool from the real medium + # plus every wolf-side seat that hasn't CO'd as any info role, + # so they get first dispatch and choose between fake CO or skip. + auto_callouts = await self._compute_auto_callouts(state, game.day_number) + if auto_callouts: + state = state.model_copy( + update={ + "pending_role_callouts": frozenset( + state.pending_role_callouts | auto_callouts + ), + } + ) + + # Pick the next NPC. Priority order (applied as a 7-key sort): + # 1. callout pool member — seer/medium/knight callout in flight + # and this seat is in the priority pool (real role-holder + + # wolf-side fake-CO candidates). + # 2. NOT demoted — `_compute_demoted_seats` flags seats stuck + # in a low-info pair volley OR exceeding the consecutive + # speaker cap. Demoted seats fall to the bottom so a 3rd + # NPC can break in. + # 3. **pending role result** — a seer/medium CO seat that + # hasn't yet published today's expected ability result. + # Promoted above addressed/count so a CO holder always gets + # a turn before anyone else when they owe a published + # result (real seer day-2 silence is the case this fixes — + # see game c99ecf313f96 day 2 ラキオ占いCO never spoke). + # 4. lowest speech_count this phase — primary rotation key + # across all days (not just day 1). Was previously below + # ``addressed`` which let role-CO seats addressing each + # other monopolise the floor while non-CO greys stayed at + # 0 turns; promoting count to before addressed guarantees + # everyone speaks before anyone speaks twice (same-day). + # 5. addressed — seat appears in ``last_addressed_seats``. + # Now a tiebreaker: an addressed seat with count=N wins + # over a non-addressed seat with the same count, but a + # non-addressed seat with count tuple[int, int, int, int, int, int, float]: + seat = getattr(e, "assigned_seat", None) or 99 + is_in_pool = 0 if seat in effective_pool else 1 + is_demoted = 1 if seat in demoted else 0 + is_pending = 0 if seat in pending_results else 1 + count = state.speech_counts.get(seat, 0) + is_addressed = 0 if seat in addressed_set else 1 + is_just_spoke = 1 if (last_speaker is not None and seat == last_speaker) else 0 + return ( + is_in_pool, + is_demoted, + is_pending, + count, + is_addressed, + is_just_spoke, + self._rng.random(), + ) + + online_npc_seats = sorted( + e.assigned_seat for e in online if e.assigned_seat is not None and e.game_id == game_id + ) + # Counts per seat, restricted to the candidates the arbiter can + # actually pick — used both for the snapshot and to classify the + # reason as ``low_count_rotation`` when the winning seat has + # spoken but strictly less than someone else online. + candidate_counts: dict[int, int] = { + seat: state.speech_counts.get(seat, 0) + for seat in online_npc_seats + if seat in state.alive_seat_nos + } + max_candidate_count = max(candidate_counts.values(), default=0) + snapshot: dict[str, Any] = { + "phase_id": state.phase_id, + "day": state.day, + "phase": game.phase.value, + "last_addressed_seat": (next(iter(sorted(addressed_set))) if addressed_set else None), + "last_addressed_seats": sorted(addressed_set), + "last_speaker_seat": last_speaker, + "silent_seats": sorted(state.silent_seats), + "alive_seat_nos": sorted(state.alive_seat_nos), + "online_npc_seats": online_npc_seats, + "demoted_seats": sorted(demoted), + "pending_result_seats": sorted(pending_results), + "speech_counts": sorted(candidate_counts.items()), + } + + capped_for_phase: frozenset[int] | set[int] = self._fabrication_capped.get( + (game_id, state.phase_id), frozenset() + ) + for entry in sorted(online, key=_pick_key): + if entry.assigned_seat is None or entry.game_id != game_id: + continue + if entry.assigned_seat not in state.alive_seat_nos: + continue + if entry.assigned_seat in capped_for_phase: + # Hit the fabrication cap this phase — skip so a chronically + # fabricating NPC can't monopolise rotation. Cleared when the + # phase advances (see cleanup_game / phase-change reset). + continue + seat = entry.assigned_seat + picked_count = state.speech_counts.get(seat, 0) + if seat in effective_pool: + # Role-callout pool member won — record so future picks + # in this same callout window pick a different member. + self._callout_pool_asked.setdefault(game_id, set()).add(seat) + reason = "role_callout_pool" + elif seat in demoted: + # Reached this branch only when EVERY non-demoted + # candidate was filtered out (offline / dead / not in + # this game). Falling back is preferable to silence. + reason = "all_demoted_fallback" + elif seat in pending_results: + # Seer/medium CO seat owing today's published ability + # result — promoted above count + addressed so the floor + # is held until the result lands. + reason = "pending_role_result" + elif demoted: + # The pair-volley gate fired and a non-demoted third + # party won. Labelled before the count-based labels so + # the viewer keeps showing "stuck volley → diverted to + # seat N" even when the count axis also discriminates + # (the demotion is the *cause* of the diversion; count + # would only be the rationale absent the volley). + reason = "low_info_diversion" + elif picked_count < max_candidate_count: + # Lower count than the top of the field — this is now the + # primary rotation lever. silent_rotation (count==0) is + # a special case of low_count_rotation worth labelling + # so the viewer can still show "first speech of the + # phase". + reason = "silent_rotation" if picked_count == 0 else "low_count_rotation" + elif seat in addressed_set: + # Counts tied at the top — addressing wins the tiebreak. + reason = "addressed" + elif last_speaker is not None and seat != last_speaker: + reason = "lru_rotation" + else: + # Seat-number tiebreak was replaced with random in the + # `_pick_key` so all NPCs at equal priority get a fair + # roll. Keep the legacy reason label so the viewer's + # historical badge wording still matches. + reason = "seat_tiebreak" + await self.dispatch_request( + state=state, + candidate_npc_id=entry.npc_id, + seat_no=seat, + game_id=game_id, + selection_reason=reason, + public_state_snapshot=snapshot, + ) + return + + # ------------------------------------------------------------- runoff dispatch + + async def _dispatch_runoff_next(self, game_id: str, day: int) -> None: + """Dispatch the next tied candidate's final speech (LLM or human). + + Candidate set = round-0 tied seats ∩ alive ∩ + ``runoff_speech_done = False``. The arbiter walks them in + seat-no order: pick → Master narration intro → (LLM: + SpeakRequest → NPC TTS) / (human: grace-watchdog awaits a + SpeechEvent or marks done on timeout). After each candidate + resolves, `runoff_speech_done` is flipped so the engine's + `plan_runoff_speech_to_runoff` advances as soon as the last + one finishes. + + Human candidates are first-class participants. They get a Levi + intro and a grace window (`runoff_speech_grace`) to actually + speak via voice / text; if they speak the SpeechEvent path + flips the flag, otherwise the watchdog marks them done so the + phase doesn't stall. + """ + seats = await self.repo.load_seats(game_id) + seats_by_no: dict[int, Seat] = {s.seat_no: s for s in seats} + players = await self.repo.load_players(game_id) + alive_set = {p.seat_no for p in players if p.alive} + round0 = await self.repo.load_votes(game_id, day=day, round_=0) + outcome = compute_vote_result(round0, alive_set) + tied = list(outcome.tied) + if not tied: + # Edge: no tied set (e.g. recovery race) → just wake so the + # engine can re-plan against fresh state. + self._maybe_wake_runoff(game_id) + return + + # Auto-mark already-spoken human candidates. Voice (ingest_service) + # and text (discord_service.on_message) both record SpeechEvents + # via `discussion.record()` but neither flips + # `runoff_speech_done` on the human path (that flag is the + # NPC-side dispatcher's accounting). Detect their presence here + # by scanning this phase's speech_events — once a tied human + # has any non-baseline event from their seat in this phase, + # consider their turn complete and flip the flag so the engine + # can advance. + phase_id = make_phase_id(game_id, day, Phase.DAY_RUNOFF_SPEECH) + try: + phase_events = await self.discussion.load_phase(game_id, phase_id) + except Exception: + phase_events = [] + human_spoke_seats: set[int] = set() + for ev in phase_events: + if ev.speaker_seat is None: + continue + seat = seats_by_no.get(ev.speaker_seat) + if seat is None or seat.is_llm: + continue + human_spoke_seats.add(ev.speaker_seat) + for seat_no in sorted(human_spoke_seats & set(tied)): + try: + await self.repo.mark_llm_runoff_speech_done(game_id, day, seat_no) + except Exception: + log.exception( + "runoff_human_mark_done_failed game=%s seat=%d", + game_id, + seat_no, + ) + + # Tied seats whose runoff speech hasn't been recorded yet. + # Seat-no order is the user-visible "who speaks first" axis. + eligible: list[Seat] = [] + for seat_no in sorted(tied): + seat = seats_by_no.get(seat_no) + if seat is None: + continue + progress = await self.repo.load_llm_speech_progress(game_id, day, seat_no) + if progress[4]: # runoff_speech_done + continue + eligible.append(seat) + + if not eligible: + # Every tied candidate has already spoken (or been skipped). + # Wake the engine so DAY_RUNOFF_SPEECH advances to DAY_RUNOFF + # without waiting for the safety-net deadline. + self._maybe_wake_runoff(game_id) + return + + # Find the first eligible seat that's actually dispatchable. + # LLM seats need an online NPC bot (mark done + skip if absent — + # otherwise the phase would stall forever). Human seats are + # always dispatchable: their grace watchdog handles silence. + chosen: Seat | None = None + chosen_npc_id: str | None = None + for seat in eligible: + if not seat.is_llm: + chosen = seat + break + entry = self._find_npc_for_seat(game_id, seat.seat_no) + if entry is None: + log.info( + "runoff_speech_no_online_npc game=%s seat=%d — marking done so phase advances", + game_id, + seat.seat_no, + ) + try: + await self.repo.mark_llm_runoff_speech_done(game_id, day, seat.seat_no) + except Exception: + log.exception( + "runoff_speech_done_mark_failed game=%s seat=%d", + game_id, + seat.seat_no, + ) + continue + chosen = seat + chosen_npc_id = entry.npc_id + break + + if chosen is None: + # All remaining tied LLMs were skipped above. Wake the + # engine so it sees the marks we just wrote. + self._maybe_wake_runoff(game_id) + return + + # Re-entrancy guard #1 (post-dispatch): a SpeakRequest for this + # seat is already in `_pending` — the existing dispatch will + # flip `runoff_speech_done` itself. LLM-only. + for pending in self._pending.values(): + if pending.game_id == game_id and pending.seat_no == chosen.seat_no: + log.debug( + "runoff_dispatch_skipped_already_pending game=%s seat=%d request=%s", + game_id, + chosen.seat_no, + pending.request_id, + ) + return + + # Re-entrancy guard #2 (in-flight announcement): phase entry + # into DAY_RUNOFF_SPEECH fires `try_dispatch_next` from two + # sequential paths — `_master_narrate` (PHASE_CHANGE narration) + # and `_on_reactive_phase_enter` (driven by + # `_dispatch_submissions`). Between the first call's + # `_runoff_announce` (Levi TTS, several seconds) and + # `dispatch_request` completing, `_pending` is still empty, + # so the second call would pick the same seat and re-announce. + # `_runoff_in_flight` plugs this race window — set BEFORE the + # await on `_runoff_announce`, cleared in `finally` once the + # downstream dispatch has handed off to the existing tracking + # (`_pending` for LLM, `_runoff_human_watchdog` for human). + in_flight_key = (game_id, day) + in_flight = self._runoff_in_flight.setdefault(in_flight_key, set()) + if chosen.seat_no in in_flight: + log.debug( + "runoff_dispatch_skipped_in_flight game=%s seat=%d", + game_id, + chosen.seat_no, + ) + return + in_flight.add(chosen.seat_no) + + try: + # Master narration: name the candidate before they speak. + # Awaited so the intro finishes before the NPC's own TTS + # (or the human's grace window) starts. + if self._runoff_announce is not None: + try: + await self._runoff_announce(chosen) + except Exception: + log.exception( + "runoff_announce_failed game=%s seat=%d", + game_id, + chosen.seat_no, + ) + + if not chosen.is_llm: + # Human candidate path: schedule a grace watchdog. The + # human is expected to speak via voice / text within + # `runoff_speech_grace` seconds; the SpeechEvent path + # auto-marks them done (top-of-method scan on the next + # `try_dispatch_next` invocation). On timeout the + # watchdog marks them done so the phase doesn't stall + # on silence. + self._spawn_human_runoff_watchdog( + game_id=game_id, + day=day, + seat_no=chosen.seat_no, + ) + return + + # LLM candidate path: rebuild state, dispatch SpeakRequest, + # spawn watchdog. + state = await self.rebuild_public_state( + game_id=game_id, day=day, phase=Phase.DAY_RUNOFF_SPEECH + ) + if state is None: + self._maybe_wake_runoff(game_id) + return + + assert chosen_npc_id is not None # LLM branch guarantees this + snapshot: dict[str, Any] = { + "phase_id": state.phase_id, + "day": state.day, + "phase": Phase.DAY_RUNOFF_SPEECH.value, + "tied_candidates": sorted(tied), + "alive_seat_nos": sorted(state.alive_seat_nos), + } + request, reason = await self.dispatch_request( + state=state, + candidate_npc_id=chosen_npc_id, + seat_no=chosen.seat_no, + game_id=game_id, + selection_reason="runoff_candidate", + public_state_snapshot=snapshot, + ) + if request is None: + # Dispatch failed (npc_offline, ws_send_failed, gate held). + # The npc_offline + ws_send_failed cases would leave this + # seat permanently un-spoken, so mark done and re-dispatch + # so the phase keeps moving. ``queue_busy`` / + # ``human_currently_speaking`` are transient — just re-poll + # later via the normal try_dispatch_next pathway. + if reason in ("npc_offline", "ws_send_failed"): + await self._mark_runoff_done_if_phase( + game_id=game_id, + phase_id=state.phase_id, + seat_no=chosen.seat_no, + ) + await self.try_dispatch_next(game_id) + return + + # Watchdog: if the NPC never returns a SpeakResult before the + # request's TTL expires, the phase would stall forever. Spawn + # a one-shot task that marks the seat done and re-dispatches + # so the engine eventually advances. The mark is idempotent + # (UPSERT), so a result that arrives in the same window is fine. + self._spawn_llm_runoff_watchdog( + game_id=game_id, + request_id=request.request_id, + seat_no=chosen.seat_no, + phase_id=state.phase_id, + ) + finally: + in_flight.discard(chosen.seat_no) + if not in_flight: + self._runoff_in_flight.pop(in_flight_key, None) + + def _spawn_llm_runoff_watchdog( + self, + *, + game_id: str, + request_id: str, + seat_no: int, + phase_id: str, + ) -> None: + """One-shot LLM-runoff watchdog (extracted from `_dispatch_runoff_next`). + + See the inline doc on `_dispatch_runoff_next` for the rationale — + the body was moved out so the human-grace watchdog + (`_spawn_human_runoff_watchdog`) can sit alongside it. + """ + ttl_s = max(1.0, self.config.request_ttl_ms / 1000.0) + + async def _watchdog() -> None: + try: + await asyncio.sleep(ttl_s) + except asyncio.CancelledError: + return + if request_id not in self._pending: + return # SpeakResult already resolved AND playback finished. + # SpeakResult acceptance moves the request into _active_playback + # but does NOT pop _pending (handle_playback_finished does that + # when playback ends). If playback runs longer than ttl_s + # (typical NPC TTS is 10-15s vs the 8s TTL), the watchdog wakes + # while _pending still has the entry — but the NPC HAS responded. + # Without this check the watchdog spuriously pops _pending, + # which then makes `_on_playback_finished` lose the game_id + # lookup → try_dispatch_next is never called → runoff stalls + # (game d57c5d83ed4a day 2: ジョナス 11.6s playback shadowed + # the 8s watchdog and シゲミチ was never dispatched). + if request_id in self._active_playback: + return # Accepted; playback handler will re-dispatch on finish. + log.info( + "runoff_request_watchdog_fired game=%s seat=%d request=%s", + game_id, + seat_no, + request_id, + ) + await self._mark_runoff_done_if_phase( + game_id=game_id, + phase_id=phase_id, + seat_no=seat_no, + ) + self._pending.pop(request_id, None) + try: + await self.try_dispatch_next(game_id) + except Exception: + log.exception("runoff_watchdog_redispatch_failed game=%s", game_id) + + task = asyncio.create_task(_watchdog(), name=f"runoff-watchdog-{request_id}") + self._runoff_watchdog_tasks.add(task) + task.add_done_callback(self._runoff_watchdog_tasks.discard) + + def _spawn_human_runoff_watchdog( + self, + *, + game_id: str, + day: int, + seat_no: int, + ) -> None: + """Grace timer for a human runoff candidate. + + The human is expected to speak (voice or text) within + ``runoff_speech_grace`` seconds of the Levi intro. If a + SpeechEvent for this seat lands during the window, the + top-of-method scan in `_dispatch_runoff_next` flips + `runoff_speech_done` on the next `try_dispatch_next` invocation. + On timeout, this watchdog flips the flag itself so + `plan_runoff_speech_to_runoff` can advance. + + Dedup: keyed on (game_id, day, seat_no) — re-entrant calls + during the grace window are silently ignored. The strong-ref + task lives in `_runoff_watchdog_tasks` (shared with the LLM + watchdogs). + """ + watchdog_key = (game_id, day) + scheduled = self._runoff_human_watchdog.setdefault(watchdog_key, set()) + if seat_no in scheduled: + return + scheduled.add(seat_no) + + try: + from wolfbot.domain.durations import current_phase_durations + + grace_s = max(1.0, float(current_phase_durations().runoff_speech_grace)) + except Exception: + grace_s = 30.0 + + async def _watchdog() -> None: + try: + await asyncio.sleep(grace_s) + except asyncio.CancelledError: + return + try: + progress = await self.repo.load_llm_speech_progress( + game_id, day, seat_no + ) + except Exception: + log.exception( + "runoff_human_watchdog_load_progress_failed game=%s seat=%d", + game_id, + seat_no, + ) + progress = (0, False, None, 0, False) + already_done = bool(progress[4]) + scheduled.discard(seat_no) + if not scheduled: + self._runoff_human_watchdog.pop(watchdog_key, None) + if already_done: + return # Human spoke within the grace window. + log.info( + "runoff_human_grace_timeout game=%s seat=%d — marking done", + game_id, + seat_no, + ) + try: + await self.repo.mark_llm_runoff_speech_done(game_id, day, seat_no) + except Exception: + log.exception( + "runoff_human_mark_done_failed game=%s seat=%d", + game_id, + seat_no, + ) + self._maybe_wake_runoff(game_id) + try: + await self.try_dispatch_next(game_id) + except Exception: + log.exception( + "runoff_human_watchdog_redispatch_failed game=%s", game_id + ) + + task = asyncio.create_task( + _watchdog(), name=f"runoff-human-watchdog-{game_id}-d{day}-s{seat_no}" + ) + self._runoff_watchdog_tasks.add(task) + task.add_done_callback(self._runoff_watchdog_tasks.discard) + + def _find_npc_for_seat(self, game_id: str, seat_no: int) -> Any | None: + """Lookup the registry entry pinned to ``(game_id, seat_no)``.""" + for entry in self.registry.all_online(): + if entry.assigned_seat == seat_no and entry.game_id == game_id: + return entry + return None + + async def _compute_auto_callouts( + self, state: PublicDiscussionState, day: int + ) -> frozenset[str]: + """Synthesize callout role keys to surface forgotten role topics. + + Currently fires for **medium** on day ≥ 2 when no medium CO has + landed yet. Day 2+ morning is the natural time to disclose the + previous-day execution's medium result, but if no real medium + nor wolf-side fake-CO mentions the role, the discussion drifts + and the topic disappears entirely (observed live in game + 6c97c1cc22ef where the medium was silent for two consecutive + days). Returning ``frozenset({"medium"})`` here merges the role + key into the public_state_summary's + ``pending_role_callouts=[medium]`` indicator AND triggers the + existing ``_compute_callout_pool`` priority bucket that rounds + up the real medium + every wolf-side seat without an info CO. + Each candidate gets one dispatch chance via + ``_callout_pool_asked``; they choose between fake-CO (`medium`) + or skip, then normal rotation resumes. + + Auto-injection stops naturally as soon as a medium CO arrives: + the fold rule in `apply_speech_event` clears the role key from + ``pending_role_callouts`` once the matching CO appears in + ``state.co_claims``, so the next dispatch sees an empty pool. + """ + if day < 2: + return frozenset() + # Only inject for the morning's first speaker round. We can't + # access the round number directly from state, so use the + # phase_id sequence — the morning is sequence 1. + if "::DAY_DISCUSSION::" not in state.phase_id: + return frozenset() + has_medium_co = any(c.role_claim == "medium" for c in state.co_claims) + if has_medium_co: + return frozenset() + return frozenset({"medium"}) + + async def _compute_callout_pool( + self, game_id: str, state: PublicDiscussionState + ) -> frozenset[int]: + """Return seats that should be prioritized when a role callout is + pending and unanswered, OR when a first-CO of an info role just + landed and the counter-CO opportunity window is still open. + + Two trigger sources, combined into one pool: + + - ``state.pending_role_callouts`` — public callouts ("占い師は?") + - ``state.pending_co_response`` — first-CO trigger that opens + a counter-CO window where every wolf-side seat (and the real + role-holder when the CO'er was wolf-side) gets one guaranteed + chance to respond before normal priority resumes. + + Pool composition for any active role: + + - ``"seer"`` / ``"medium"`` / ``"knight"`` — pool = real + role-holder (alive, uncpd) + every wolf-side seat (人狼/狂人, + alive, no info CO yet). + - ``"info_request"`` (callouts only — first-CO doesn't fire + this) — pool = ALL real info-role holders + wolf-side. + + The arbiter prioritizes pool members (random within pool). Each + member gets at most one chance via the ``_callout_pool_asked`` + tracker; declines fall through to other pool members until the + pool is exhausted, after which normal priority resumes. + """ + if not state.pending_role_callouts and not state.pending_co_response: + return frozenset() + callout_to_role = { + "seer": Role.SEER, + "medium": Role.MEDIUM, + "knight": Role.KNIGHT, + } + # Resolve the set of real roles that should be in the pool. + # `info_request` expands to every info role. Both pending sources + # contribute their role keys symmetrically. + target_roles: set[Role] = set() + has_info_request = "info_request" in state.pending_role_callouts + if has_info_request: + target_roles |= {Role.SEER, Role.MEDIUM, Role.KNIGHT} + for callout_key, role in callout_to_role.items(): + if ( + callout_key in state.pending_role_callouts + or callout_key in state.pending_co_response + ): + target_roles.add(role) + if not target_roles: + return frozenset() + try: + players = await self.repo.load_players(game_id) + seats = await self.repo.load_seats(game_id) + except Exception: + log.exception("callout_pool_load_failed game=%s", game_id) + return frozenset() + seats_by_no: dict[int, Seat] = {s.seat_no: s for s in seats} + co_keys: set[tuple[int, str]] = {(c.seat, c.role_claim) for c in state.co_claims} + pool: set[int] = set() + for player in players: + if not player.alive: + continue + if player.seat_no not in state.alive_seat_nos: + continue + seat = seats_by_no.get(player.seat_no) + if seat is None or not seat.is_llm: + continue + # Real role-holder for any target role, not yet CO'd as that + # role. (For info_request, all three info roles are targets.) + if player.role in target_roles: + role_callout_key = next( + (k for k, r in callout_to_role.items() if r is player.role), + None, + ) + if ( + role_callout_key is not None + and (player.seat_no, role_callout_key) not in co_keys + ): + pool.add(player.seat_no) + # Wolf-side seats not yet CO'd as any info role can fake any + # of the requested roles, so prioritize them too. + if player.role in (Role.WEREWOLF, Role.MADMAN): + already_co_info = any( + (player.seat_no, role) in co_keys for role in ("seer", "medium", "knight") + ) + if not already_co_info: + pool.add(player.seat_no) + return frozenset(pool) + + def _maybe_wake_runoff(self, game_id: str) -> None: + if self._runoff_wake is None: + return + try: + self._runoff_wake(game_id) + except Exception: + log.exception("runoff_wake_failed game=%s", game_id) + + async def _mark_runoff_done_if_phase( + self, + *, + game_id: str, + phase_id: str, + seat_no: int, + ) -> None: + """Best-effort `runoff_speech_done = 1` for a finished SpeakResult. + + Reads the day from the phase_id format (`gid::dayN::PHASE::seq`) + so we don't need an extra DB hit. No-op when the phase token in + ``phase_id`` isn't DAY_RUNOFF_SPEECH (= the result was for an + ordinary discussion utterance and there's no progress to flip). + """ + if "::DAY_RUNOFF_SPEECH::" not in phase_id: + return + day = _parse_day_from_phase_id(phase_id) + if day is None: + return + try: + await self.repo.mark_llm_runoff_speech_done(game_id, day, seat_no) + except Exception: + log.exception( + "runoff_speech_done_mark_failed game=%s day=%d seat=%d", + game_id, + day, + seat_no, + ) + return + # Wake the engine so `_plan_next` can re-evaluate immediately + # whether all tied candidates are done. Without this the phase + # would sit until the next deadline tick. + self._maybe_wake_runoff(game_id) + + # ------------------------------------------------------------- game-end cleanup + + def cleanup_game(self, game_id: str) -> int: + """Drop in-memory speak/playback state belonging to ``game_id``. + + Companion to :meth:`NpcDecisionDispatcher.cleanup_game`. Called + from the game-end hook so a long-lived Master process doesn't + carry pending arbitration state across games. The DB rows + (``npc_speak_requests`` / ``_results`` / ``_playback_events``) + are intentionally kept for replay/export — only the in-memory + gates / dicts are swept here. + + Returns the count of in-flight `_pending` entries dropped. + """ + swept = 0 + for rid, pending in list(self._pending.items()): + if pending.game_id != game_id: + continue + self._pending.pop(rid, None) + self._active_playback.discard(rid) + self._playback_deadlines.pop(rid, None) + swept += 1 + self._callout_pool_asked.pop(game_id, None) + # Drop fabrication-retry counters for this game. + for key in list(self._fabrication_retries.keys()): + if key[0] == game_id: + self._fabrication_retries.pop(key, None) + # Drop fabrication cap-hit blocklist for this game. + for cap_key in list(self._fabrication_capped.keys()): + if cap_key[0] == game_id: + self._fabrication_capped.pop(cap_key, None) + if swept: + log.info( + "speak_arbiter_cleanup_game game=%s swept=%d", + game_id, + swept, + ) + return swept + + # ------------------------------------------------------------- restart sweep + + async def reactive_voice_recovery_sweep(self, game_id: str) -> None: + """Mark every in-flight request rejected and every open playback failed. + + Called once on Master restart from `RecoveryService`. The + ``failure_reason=master_restart`` value is mandated by the + npc-voice-pipeline spec. + """ + now = self._now_ms() + open_reqs = await self.repo.load_open_npc_speak_requests(game_id) + for row in open_reqs: + await self.repo.insert_npc_speak_result( + request_id=row["request_id"], + game_id=game_id, + phase_id=row["phase_id"], + npc_id=row["npc_id"], + status="rejected", + text=None, + used_logic_ids=None, + intent=None, + estimated_duration_ms=None, + failure_reason="master_restart", + received_at_ms=now, + ) + open_play = await self.repo.load_open_npc_playback(game_id) + for row in open_play: + await self.repo.close_npc_playback( + row["request_id"], + finished_at_ms=now, + outcome="failed", + failure_reason="master_restart", + ) + + async def rebuild_public_state( + self, + *, + game_id: str, + day: int, + phase: Phase, + ) -> PublicDiscussionState | None: + """Re-fold `speech_events` for the active phase. + + Used after Master restart to seed the in-memory `PublicDiscussionState` + before re-entering the arbitration loop. CO claims are layered on + top from a *game-wide* event scan so day-2+ NPC prompts still show + the day-1 seer CO etc.; without that carry, the per-phase fold + starts each new day with empty `co_claims` and wolves miss the + chance to counter-CO. + """ + from wolfbot.services.discussion_service import ( + extract_co_claims_from_events, + rebuild_public_state_from_events, + ) + + phase_id = make_phase_id(game_id, day, phase) + events: Sequence[SpeechEvent] = await self.discussion.load_phase(game_id, phase_id) + # Pull game-wide CO history so the per-phase rebuild's volley-demotion + # signal (`is_new_co`) treats day-N re-assertions of a day-(N-1) CO + # as not-new. Without this seed, ジョナス re-CO'ing seer on day 2 + # makes every utterance look like fresh info, suppressing the + # `_PAIR_VOLLEY_WINDOW` demotion gate (game a701a7531dca day 2). + # Restricted to events outside the current phase so a seat's + # FIRST in-phase CO still counts as new info (test_try_dispatch_next + # _lru_when_speech_counts_tied depends on this). + try: + all_events = await self.discussion.load_for_game(game_id) + except Exception: + log.exception("co_claim_history_load_failed game=%s", game_id) + all_events = () + prior_phase_events = tuple(e for e in all_events if e.phase_id != phase_id) + prior_co_claims = ( + extract_co_claims_from_events(prior_phase_events) if prior_phase_events else () + ) + prior_co_keys = frozenset((c.seat, c.role_claim) for c in prior_co_claims) + state = rebuild_public_state_from_events(events, prior_co_keys=prior_co_keys) + if state is None: + return None + # state.co_claims should reflect game-wide CO history (used by NPC + # prompts on day 2+ to remember day-1 declarations). + if all_events: + state.co_claims = extract_co_claims_from_events(all_events) + return state + + +__all__ = ["SpeakArbiter", "SpeakArbiterConfig"] + + +# Force a non-static reference so the linter keeps Awaitable / Callable +# imports for downstream typing extensions. +_ = (Awaitable, Callable) diff --git a/src/wolfbot/master/claim/__init__.py b/src/wolfbot/master/claim/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wolfbot/master/claim/claim_history.py b/src/wolfbot/master/claim/claim_history.py new file mode 100644 index 0000000..748db54 --- /dev/null +++ b/src/wolfbot/master/claim/claim_history.py @@ -0,0 +1,228 @@ +"""Per-seat divination/medium claim history. + +Pure aggregation: walk a sequence of :class:`SpeechEvent` rows in +chronological order and group every structured claim by claimer seat. +The result is the canonical "what has each seat publicly claimed as +their seer/medium results so far" view. + +Why this exists +--------------- +A wolf or madman fake-CO seer must keep claiming results consistent +with what they said yesterday. Without a record, the LLM drifts: +real game ``a51615d32274`` (2026-04-30) had Yuriko (wolf, fake seer) +say "シゲミチ白" on day 1, then on day 2 silently drop シゲミチ and +add フ a fabricated "コメット白" picked up from Jonas's earlier claim. +The fix is to surface every prior claim in every subsequent prompt +so the wolf reads its own past lies and either commits to them or +gets caught. + +Determinism / restart safety +---------------------------- +The aggregator reads only :class:`SpeechEvent` rows, so on Master +restart the history rebuilds verbatim from the persisted store. No +per-game cache is required. + +Day-vs-count integrity +---------------------- +A real seer at day-N morning has issued ``N`` public divination +claims: 1 for NIGHT_0 (random white, declared day-1 morning), plus +1 per subsequent night surfaced on the next morning. The claim +ledger tags each entry with the day it was *announced*, so by day +N morning the count is exactly N (one entry per declared day, +day=1..N). :func:`expected_seer_claim_count_for_day` exposes the +rule so the prompt builder and viewer can both reference the same +number — and so the count never disagrees with +``claim_validator``'s ``same_day_priors`` (= "1 entry per declared +day") rule. A liar must mirror that count or get caught. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass + +from wolfbot.domain.discussion import SpeechEvent, SpeechSource + + +@dataclass(frozen=True) +class ClaimedSeerEntry: + """One announced seer divination — real or fake.""" + + day: int + target_seat: int + target_name: str + is_wolf: bool + declared_at_event_id: str + + +@dataclass(frozen=True) +class ClaimedMediumEntry: + """One announced medium result. ``is_wolf=None`` encodes the + explicit "no execution yesterday → no result today" case.""" + + day: int + target_seat: int + target_name: str + is_wolf: bool | None + declared_at_event_id: str + + +@dataclass(frozen=True) +class ClaimerHistory: + """Time-ordered claim list for a single claimer seat.""" + + claimer_seat: int + seer_claims: tuple[ClaimedSeerEntry, ...] = () + medium_claims: tuple[ClaimedMediumEntry, ...] = () + + +@dataclass(frozen=True) +class ClaimHistory: + """Per-claimer fold of every structured seer/medium claim in a game. + + ``by_seat`` is keyed on the claimer's seat number. Iteration order + of seats is sorted ascending so consumers (prompt builder, viewer) + can render a deterministic listing. + """ + + by_seat: Mapping[int, ClaimerHistory] + + +def collect_claim_history( + events: Sequence[SpeechEvent], + *, + seat_names: Mapping[int, str] | None = None, +) -> ClaimHistory: + """Aggregate structured claims per claimer seat. + + Pure: no I/O, no dependencies beyond ``SpeechEvent``. ``seat_names`` + provides the seat → display_name lookup so the rendered entries + carry human-readable target names. Missing names fall back to + ``"席N"`` so the function never errors on incomplete lookups. + + The fold: + + * Skips ``phase_baseline`` sentinels and events without a + ``speaker_seat`` (system messages). + * For each event, attaches a seer entry iff + ``claimed_seer_target_seat`` is set, and a medium entry iff + ``claimed_medium_target_seat`` is set. Both can co-exist on a + single event in the unlikely case where a single utterance + announces both kinds at once. + * Preserves chronological order via the input sequence's order + (callers MUST pass events sorted by ``created_at_ms`` ASC). + * Folds repeat re-assertions into a single ledger row keyed on + ``(day, target_seat, is_wolf)``. LLM seats routinely restate + "yesterday I divined X = white" across multiple speech turns + in the same day's discussion rounds, and surfacing each + utterance as a separate ledger entry double-counts the same + claim — viewer panels show duplicate rows and the seer/medium + header chip overshoots the expected per-day cap. A second + utterance with a *different* target or color on the same day + is kept (the validator catches it as a swap/flip; the ledger + preserves the inconsistency for post-game review). + """ + name_lookup = dict(seat_names or {}) + + def _name(seat: int) -> str: + return name_lookup.get(seat) or f"席{seat}" + + seer_by_seat: dict[int, list[ClaimedSeerEntry]] = {} + medium_by_seat: dict[int, list[ClaimedMediumEntry]] = {} + seer_seen: dict[int, set[tuple[int, int, bool]]] = {} + medium_seen: dict[int, set[tuple[int, int, bool | None]]] = {} + + for event in events: + if event.source == SpeechSource.PHASE_BASELINE: + continue + speaker = event.speaker_seat + if speaker is None: + continue + + seer_target = event.claimed_seer_target_seat + seer_verdict = event.claimed_seer_is_wolf + if seer_target is not None and seer_verdict is not None: + seer_key = (event.day, seer_target, seer_verdict) + speaker_seen = seer_seen.setdefault(speaker, set()) + if seer_key not in speaker_seen: + speaker_seen.add(seer_key) + seer_by_seat.setdefault(speaker, []).append( + ClaimedSeerEntry( + day=event.day, + target_seat=seer_target, + target_name=_name(seer_target), + is_wolf=seer_verdict, + declared_at_event_id=event.event_id, + ) + ) + + medium_target = event.claimed_medium_target_seat + if medium_target is not None: + medium_key = (event.day, medium_target, event.claimed_medium_is_wolf) + speaker_seen_m = medium_seen.setdefault(speaker, set()) + if medium_key not in speaker_seen_m: + speaker_seen_m.add(medium_key) + medium_by_seat.setdefault(speaker, []).append( + ClaimedMediumEntry( + day=event.day, + target_seat=medium_target, + target_name=_name(medium_target), + is_wolf=event.claimed_medium_is_wolf, + declared_at_event_id=event.event_id, + ) + ) + + seats = sorted(set(seer_by_seat) | set(medium_by_seat)) + by_seat = { + seat: ClaimerHistory( + claimer_seat=seat, + seer_claims=tuple(seer_by_seat.get(seat, ())), + medium_claims=tuple(medium_by_seat.get(seat, ())), + ) + for seat in seats + } + return ClaimHistory(by_seat=by_seat) + + +def expected_seer_claim_count_for_day(day: int) -> int: + """Number of seer claim entries a real seer should have announced by day N. + + The claim ledger tags each entry with the day it was *announced*: + + - NIGHT_0's random white is declared on day 1 morning → entry day=1. + - NIGHT_K's result (for K>=1) is declared on day K+1 morning → + entry day=K+1. + + So by day N morning the seer has announced exactly N entries + (one per declared day, day=1..N). This matches + :func:`wolfbot.master.claim.claim_validator._validate_seer_fake`'s + "1 entry per declared day" rule (``same_day_priors``). + + Day 0 has no morning discussion (SETUP / NIGHT_0 are transient), + so the rule reads "no announced results before day 1". + """ + if day < 1: + return 0 + return day + + +def expected_medium_claim_count_for_day(executions_so_far: int) -> int: + """Number of medium results expected so far. + + Mediums learn one verdict per execution that has actually + happened. Days without an execution legitimately have no result; + callers count distinct execution days to compare against the + medium claimer's announced result count. + """ + return max(0, executions_so_far) + + +__all__ = [ + "ClaimHistory", + "ClaimedMediumEntry", + "ClaimedSeerEntry", + "ClaimerHistory", + "collect_claim_history", + "expected_medium_claim_count_for_day", + "expected_seer_claim_count_for_day", +] diff --git a/src/wolfbot/master/claim/claim_validator.py b/src/wolfbot/master/claim/claim_validator.py new file mode 100644 index 0000000..da9edf7 --- /dev/null +++ b/src/wolfbot/master/claim/claim_validator.py @@ -0,0 +1,498 @@ +"""Master-side validator for `claimed_seer_result` / `claimed_medium_result`. + +Why +--- +Reactive_voice mode lets each NPC bot generate its own structured CO +result. With a low-thinking model (e.g. ``gemini-2.5-flash`` + +``thinking_budget=0``) the model occasionally fabricates a divination +target that doesn't appear in its own private record (game +``ba084ae208cc`` Setsu, ``101d9a90ab58`` Gina). The fabricated claim +poisons the public claim ledger every subsequent prompt sees. + +This module implements a pure, offline validator the arbiter calls +between ``handle_speak_result`` and ``PlaybackAuthorized``. If the +claim is structurally impossible (real seer claiming an unrecorded +target, fake CO swapping its own past target/color, day-1 morning +2nd seer claim, day-1 morning medium claim, etc.), the validator +returns a rejection reason + a feedback string. The caller drops +the playback (``PlaybackRejected``) and re-dispatches the same NPC +with the feedback embedded in the next prompt. + +Inputs are passed as plain dataclasses so the unit tests don't +need to spin up a SqliteRepo. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.ws_messages import ClaimedMediumResult, ClaimedSeerResult +from wolfbot.master.claim.claim_history import ( + ClaimedMediumEntry, + ClaimedSeerEntry, + ClaimerHistory, +) + +# Rejection reason codes (also used as `failure_reason` on +# ``npc_speak_results``). Keep stable — they're observable. +REASON_SEER_FABRICATED_TARGET = "fabricated_seer_target" +REASON_SEER_WRONG_VERDICT = "fabricated_seer_verdict" +REASON_SEER_DAY1_OVERFLOW = "day1_seer_claim_overflow" +REASON_SEER_TARGET_SWAP = "seer_target_swap" +REASON_SEER_VERDICT_FLIP = "seer_verdict_flip" +REASON_SEER_CO_CAP_EXCEEDED = "seer_co_cap_exceeded" +REASON_MEDIUM_FABRICATED_TARGET = "fabricated_medium_target" +REASON_MEDIUM_WRONG_VERDICT = "fabricated_medium_verdict" +REASON_MEDIUM_DAY1 = "day1_medium_claim" +REASON_MEDIUM_NO_EXECUTION = "medium_claim_without_execution" +REASON_MEDIUM_TARGET_SWAP = "medium_target_swap" +REASON_MEDIUM_VERDICT_FLIP = "medium_verdict_flip" +REASON_MEDIUM_CO_CAP_EXCEEDED = "medium_co_cap_exceeded" + +# Maximum number of distinct claimers per CO role. Beyond this, a fresh +# CO from a not-yet-CO'd seat is structurally fabricated: +# * seer — 真 1 + 狼 2 + 狂人 1 = 4 theoretical max, but a 4th CO can +# only come from a non-roleholder villager and is immediately +# cut by the village ledger. +# * medium — 真 1 + 狼/狂人 1 騙り = 2 theoretical max; 3rd is broken. +# * knight — same shape as medium. +SEER_CO_CAP = 3 +MEDIUM_CO_CAP = 2 + +FABRICATION_REASONS = frozenset( + { + REASON_SEER_FABRICATED_TARGET, + REASON_SEER_WRONG_VERDICT, + REASON_SEER_DAY1_OVERFLOW, + REASON_SEER_TARGET_SWAP, + REASON_SEER_VERDICT_FLIP, + REASON_MEDIUM_FABRICATED_TARGET, + REASON_MEDIUM_WRONG_VERDICT, + REASON_MEDIUM_DAY1, + REASON_MEDIUM_NO_EXECUTION, + REASON_MEDIUM_TARGET_SWAP, + REASON_MEDIUM_VERDICT_FLIP, + } +) + +# CO-cap rejection reasons are NOT in FABRICATION_REASONS by design. +# Fabrication rejections trigger the same-NPC retry loop with feedback; +# the cap is a structural rule (= "you literally cannot CO this role +# anymore, no amount of self-correction will fix it"), so the right +# response is a one-time soft skip — drop this dispatch's audio, let +# the picker rotate to the next NPC, and don't burn retries on a +# speaker that the model can't help fixing. +CO_CAP_REASONS = frozenset( + {REASON_SEER_CO_CAP_EXCEEDED, REASON_MEDIUM_CO_CAP_EXCEEDED} +) + + +@dataclass(frozen=True) +class ActualSeerEvent: + """One real seer divination, derived from ``night_actions`` + + target's ``seats.role``. Used only when the speaker IS the real + seer; otherwise this list is empty.""" + + day: int + target_seat: int + is_wolf: bool + + +@dataclass(frozen=True) +class ActualMediumEvent: + """One real medium result, derived from public execution events + + executed seat's ``seats.role``. Medium has no per-night action + submission, so we synthesize the implied result from the prior + day's execution. Used only when the speaker IS the real medium.""" + + day: int # day on which the execution log entry was emitted + target_seat: int + is_wolf: bool + + +@dataclass(frozen=True) +class ValidationResult: + """Pure-function output: ok flag + machine reason + human feedback.""" + + ok: bool + reason: str | None = None + feedback: str | None = None + + @classmethod + def accept(cls) -> ValidationResult: + return cls(ok=True) + + @classmethod + def reject(cls, *, reason: str, feedback: str) -> ValidationResult: + return cls(ok=False, reason=reason, feedback=feedback) + + +def _seer_history_block(history: Sequence[ActualSeerEvent]) -> str: + if not history: + return "(占い結果なし)" + return "\n".join( + f" - day{ev.day}: 席{ev.target_seat} ({'黒' if ev.is_wolf else '白'})" for ev in history + ) + + +def _prior_seer_block(claims: Sequence[ClaimedSeerEntry]) -> str: + if not claims: + return "(過去の主張なし)" + return "\n".join( + f" - day{c.day}: 席{c.target_seat} {c.target_name} ({'黒' if c.is_wolf else '白'})" + for c in claims + ) + + +def _validate_seer_real( + *, + claim: ClaimedSeerResult, + actual: Sequence[ActualSeerEvent], +) -> ValidationResult: + """Real seer: claim must match an entry in ``actual`` exactly.""" + for ev in actual: + if ev.target_seat == claim.target_seat: + if ev.is_wolf == claim.is_wolf: + return ValidationResult.accept() + # Right target, wrong color: structurally impossible. + return ValidationResult.reject( + reason=REASON_SEER_WRONG_VERDICT, + feedback=( + "前回の発話の `claimed_seer_result` は、" + f"対象 席{claim.target_seat} の判定色を" + f" {'黒' if claim.is_wolf else '白'} と主張したが、" + "あなたの非公開占い履歴では同じ対象の判定色は逆である。" + "本物の seer は判定色を後から塗り替えられない。" + "次の発話では、自分の `自分の占い結果` セクションに記録された" + "対象・色そのままで `claimed_seer_result` を埋めるか、" + "新しい結果を発表しないなら `claimed_seer_result=null` にする。" + ), + ) + return ValidationResult.reject( + reason=REASON_SEER_FABRICATED_TARGET, + feedback=( + "前回の発話で `claimed_seer_result.target_seat=" + f"{claim.target_seat}` と主張したが、あなたの非公開占い履歴に" + "その対象の記録は存在しない。本物の seer は記録外の対象を" + "占ったと主張できない。実履歴は次の通り:\n" + f"{_seer_history_block(actual)}\n" + "次の発話では、上記のいずれかの対象+色をそのまま `claimed_seer_result` に" + "入れるか、新しい結果を発表しない発話なら `claimed_seer_result=null` にする。" + ), + ) + + +def _validate_seer_fake( + *, + claim: ClaimedSeerResult, + day: int, + phase: Phase, + prior_self_claims: Sequence[ClaimedSeerEntry], +) -> ValidationResult: + """Fake seer (wolf / madman / villager bluff): can invent targets, + but must not retroactively swap or color-flip a same-night claim, + and must not exceed day-1-morning's 1-claim cap.""" + # Day-1 morning overflow: a real seer at day=1 morning has exactly + # 1 claim (NIGHT_0 random white). A fake seer who already issued + # 1 claim cannot issue a 2nd same-morning. + if day == 1 and phase is Phase.DAY_DISCUSSION: + already_today = [c for c in prior_self_claims if c.day == 1] + if already_today: + return ValidationResult.reject( + reason=REASON_SEER_DAY1_OVERFLOW, + feedback=( + "day 1 朝に占い結果を主張できるのは NIGHT_0 のランダム白 1 件だけ。" + f"あなたは既にこのターン以前に day 1 で席{already_today[0].target_seat}" + f"の判定 ({'黒' if already_today[0].is_wolf else '白'}) を公表している。" + "本物の seer は同じ朝に 2 件目の判定を出せないため、" + "次の発話では `claimed_seer_result=null` にする (発表する新しい結果はない)。" + ), + ) + + # Same-night swap / verdict flip: if this same speaker previously + # claimed a different target for the same night (= same `day` value + # in the seer-history convention: day-0 random + day-N nightly), reject. + # The "night identity" rule: real seer claims at most 1 row per day. + same_day_priors = [c for c in prior_self_claims if c.day == day] + for prior in same_day_priors: + if prior.target_seat != claim.target_seat: + return ValidationResult.reject( + reason=REASON_SEER_TARGET_SWAP, + feedback=( + f"あなたは day{prior.day} の占い結果として既に" + f"席{prior.target_seat} {prior.target_name}を公表している" + f" ({'黒' if prior.is_wolf else '白'})。" + f"今回の `claimed_seer_result.target_seat={claim.target_seat}` は" + "同じ夜について別対象に差し替えており、本物の seer には起き得ない。" + "次の発話では、過去に公表した対象・色をそのまま使うか、" + "`claimed_seer_result=null` にする。" + ), + ) + if prior.is_wolf != claim.is_wolf: + return ValidationResult.reject( + reason=REASON_SEER_VERDICT_FLIP, + feedback=( + f"あなたは day{prior.day} 席{prior.target_seat} の判定を既に" + f" {'黒' if prior.is_wolf else '白'} と公表している。" + "今回は同じ対象の判定色を逆に主張しており、本物の seer には起き得ない。" + "次の発話では過去発表通りに揃えるか、`claimed_seer_result=null` にする。" + ), + ) + return ValidationResult.accept() + + +def _validate_medium_real( + *, + claim: ClaimedMediumResult, + actual: Sequence[ActualMediumEvent], +) -> ValidationResult: + """Real medium: `target_seat` must match a recorded execution and + the verdict must match the executed seat's actual role.""" + if claim.is_wolf is None: + # Real medium emitting "no result" — only legal when there's + # no execution recorded for the day this morning would surface + # (i.e. yesterday had no execution). We can't perfectly detect + # that here without the execution day, so accept the null verdict + # but still validate the target_seat absence/presence loosely: + # if there IS a real history entry that matches target_seat, + # the model should not also be saying "no result". + for ev in actual: + if ev.target_seat == claim.target_seat: + return ValidationResult.reject( + reason=REASON_MEDIUM_WRONG_VERDICT, + feedback=( + f"前回の発話で `claimed_medium_result.target_seat=" + f"{claim.target_seat}` を主張しつつ `is_wolf=null` " + "(結果なし) としているが、その対象は実際に処刑されており" + f"判定色は {'黒' if ev.is_wolf else '白'} のはず。" + "次の発話では、結果を発表する場合は実履歴通りの対象+色で、" + "発表しない場合は `claimed_medium_result=null` にする。" + ), + ) + return ValidationResult.accept() + for ev in actual: + if ev.target_seat == claim.target_seat: + if ev.is_wolf == claim.is_wolf: + return ValidationResult.accept() + return ValidationResult.reject( + reason=REASON_MEDIUM_WRONG_VERDICT, + feedback=( + f"前回の発話の `claimed_medium_result` は、" + f"対象 席{claim.target_seat} の判定色を" + f" {'黒' if claim.is_wolf else '白'} と主張したが、" + "あなたの非公開霊媒履歴では同じ対象の判定色は逆である。" + "次の発話では実履歴通りに揃えるか、" + "`claimed_medium_result=null` にする。" + ), + ) + return ValidationResult.reject( + reason=REASON_MEDIUM_FABRICATED_TARGET, + feedback=( + f"前回の発話で `claimed_medium_result.target_seat=" + f"{claim.target_seat}` と主張したが、あなたの非公開霊媒履歴に" + "その対象の記録は存在しない (=その席は処刑されていない)。" + "本物の medium は処刑されていない席を霊媒結果として出せない。" + "次の発話では実履歴通りに揃えるか、`claimed_medium_result=null` にする。" + ), + ) + + +def _validate_medium_fake( + *, + claim: ClaimedMediumResult, + day: int, + phase: Phase, + prior_self_claims: Sequence[ClaimedMediumEntry], + executions_so_far: int, +) -> ValidationResult: + """Fake medium (any non-medium role): day-1 morning has no legal + medium result; can't exceed execution count; can't swap own past.""" + if day == 1 and phase is Phase.DAY_DISCUSSION: + return ValidationResult.reject( + reason=REASON_MEDIUM_DAY1, + feedback=( + "day 1 朝の霊媒結果は構造的に存在しない (前日の処刑がまだない)。" + "本物の medium は day 1 朝に結果を出せないため、" + "`claimed_medium_result=null` にする。" + "霊媒師 CO 自体は day 1 朝でも可能だが、結果は伴わない。" + ), + ) + if executions_so_far == 0 and claim.is_wolf is not None: + return ValidationResult.reject( + reason=REASON_MEDIUM_NO_EXECUTION, + feedback=( + "まだ処刑が一度も発生していないため、霊媒結果を出せる対象が存在しない。" + "本物の medium は処刑がない時点で結果を発表しない。" + "次の発話では `claimed_medium_result=null` にする。" + ), + ) + same_day_priors = [c for c in prior_self_claims if c.day == day] + for prior in same_day_priors: + if prior.target_seat != claim.target_seat: + return ValidationResult.reject( + reason=REASON_MEDIUM_TARGET_SWAP, + feedback=( + f"あなたは day{prior.day} の霊媒結果として既に" + f"席{prior.target_seat} {prior.target_name}を公表している。" + "今回は同じ夜について別対象に差し替えており、" + "本物の medium には起き得ない。" + "次の発話では過去発表通りに揃えるか、" + "`claimed_medium_result=null` にする。" + ), + ) + if ( + prior.is_wolf is not None + and claim.is_wolf is not None + and prior.is_wolf != claim.is_wolf + ): + return ValidationResult.reject( + reason=REASON_MEDIUM_VERDICT_FLIP, + feedback=( + f"あなたは day{prior.day} 席{prior.target_seat} の霊媒結果を既に" + f" {'黒' if prior.is_wolf else '白'} と公表している。" + "今回は同じ対象の判定色を逆に主張しており、本物の medium には起き得ない。" + "次の発話では過去発表通りに揃えるか、`claimed_medium_result=null` にする。" + ), + ) + return ValidationResult.accept() + + +def validate_claim_against_truth( + *, + speaker_role: Role, + speaker_seat: int, + day: int, + phase: Phase, + claimed_seer: ClaimedSeerResult | None, + claimed_medium: ClaimedMediumResult | None, + actual_seer_history: Sequence[ActualSeerEvent] = (), + actual_medium_history: Sequence[ActualMediumEvent] = (), + prior_public_claims: ClaimerHistory | None = None, + executions_so_far: int = 0, + seer_co_count: int = 0, + medium_co_count: int = 0, +) -> ValidationResult: + """Single entry point: validate this utterance's structured claims. + + Returns ``ValidationResult.accept()`` when the claims are internally + consistent. Returns a rejection with a machine reason code (one of + ``FABRICATION_REASONS``) plus a Japanese feedback string suitable + for embedding in the next ``SpeakRequest.retry_feedback``. + + Both ``claimed_seer`` and ``claimed_medium`` may be ``None`` (the + common case when the utterance doesn't announce a new result); + that's always accepted. + + ``seer_co_count`` / ``medium_co_count`` are the count of *distinct + seats* that have already issued a CO of that role in the public + ledger. When this seat is brand-new to the role (no prior claim + of their own) AND the count is already at the structural cap + (3 for seer, 2 for medium), the new CO is rejected as + fabrication. Game ``59d5377c6794`` reproduced this: a 4th seer + CO appeared (seat 6 ジョナス) on day 1 explicitly noting "占い師 + が三人も揃うとはね、私こそが導き手" and the prompt-side + rule didn't stop it — only a hard validator catches it. + """ + prior_seer = prior_public_claims.seer_claims if prior_public_claims else () + prior_medium = prior_public_claims.medium_claims if prior_public_claims else () + + if claimed_seer is not None: + # CO cap check: applies BEFORE real/fake-specific rules. If the + # ledger already has SEER_CO_CAP distinct claimers and this + # speaker hasn't claimed yet, the CO is structurally fabricated + # regardless of role (real seer would still fail this — but + # `seer_co_count >= cap` while the real seer hasn't CO'd is + # itself impossible because the real seer is one of the distinct + # claimers; so this branch only fires for non-real CO inflation). + if not prior_seer and seer_co_count >= SEER_CO_CAP: + return ValidationResult.reject( + reason=REASON_SEER_CO_CAP_EXCEEDED, + feedback=( + f"前回の発話で占い師 CO を出したが、" + f"既に公開ログ上 {seer_co_count} 件の占い師 CO が出ている。" + f"占い師 CO は構造的に最大 {SEER_CO_CAP} 件 (真 1 + 狼 2 + 狂人 1) で、" + f"{SEER_CO_CAP + 1} 件目は村陣営の村人騙りとしてしか説明がつかず、" + "村側に強い狼/狂人ラインとして即座に切られる破綻行動である。" + "次の発話では占い師 CO に出ないこと " + "(`co_declaration=null`、`claimed_seer_result=null` を設定)。" + "潜伏のまま発話するか、別役職 (霊媒/騎士) の対抗 CO 余地を考える " + "(ただし霊媒/騎士も既に上限に達していないか確認すること)。" + ), + ) + if speaker_role is Role.SEER: + res = _validate_seer_real( + claim=claimed_seer, + actual=actual_seer_history, + ) + if not res.ok: + return res + else: + res = _validate_seer_fake( + claim=claimed_seer, + day=day, + phase=phase, + prior_self_claims=prior_seer, + ) + if not res.ok: + return res + + if claimed_medium is not None: + # Mirror the seer cap for medium (max 2 distinct claimers). + if not prior_medium and medium_co_count >= MEDIUM_CO_CAP: + return ValidationResult.reject( + reason=REASON_MEDIUM_CO_CAP_EXCEEDED, + feedback=( + f"前回の発話で霊媒師 CO を出したが、" + f"既に公開ログ上 {medium_co_count} 件の霊媒師 CO が出ている。" + f"霊媒師 CO は構造的に最大 {MEDIUM_CO_CAP} 件 (真 1 + 狼/狂人 1) で、" + f"{MEDIUM_CO_CAP + 1} 件目は本物の霊媒師ではあり得ない破綻行動である。" + "次の発話では霊媒師 CO に出ないこと " + "(`co_declaration=null`、`claimed_medium_result=null` を設定)。" + ), + ) + if speaker_role is Role.MEDIUM: + res = _validate_medium_real( + claim=claimed_medium, + actual=actual_medium_history, + ) + if not res.ok: + return res + else: + res = _validate_medium_fake( + claim=claimed_medium, + day=day, + phase=phase, + prior_self_claims=prior_medium, + executions_so_far=executions_so_far, + ) + if not res.ok: + return res + + return ValidationResult.accept() + + +__all__ = [ + "CO_CAP_REASONS", + "FABRICATION_REASONS", + "MEDIUM_CO_CAP", + "REASON_MEDIUM_CO_CAP_EXCEEDED", + "REASON_MEDIUM_DAY1", + "REASON_MEDIUM_FABRICATED_TARGET", + "REASON_MEDIUM_NO_EXECUTION", + "REASON_MEDIUM_TARGET_SWAP", + "REASON_MEDIUM_VERDICT_FLIP", + "REASON_MEDIUM_WRONG_VERDICT", + "REASON_SEER_CO_CAP_EXCEEDED", + "REASON_SEER_DAY1_OVERFLOW", + "REASON_SEER_FABRICATED_TARGET", + "REASON_SEER_TARGET_SWAP", + "REASON_SEER_VERDICT_FLIP", + "REASON_SEER_WRONG_VERDICT", + "SEER_CO_CAP", + "ActualMediumEvent", + "ActualSeerEvent", + "ValidationResult", + "validate_claim_against_truth", +] diff --git a/src/wolfbot/master/ingest_factory.py b/src/wolfbot/master/ingest_factory.py new file mode 100644 index 0000000..5bb71ca --- /dev/null +++ b/src/wolfbot/master/ingest_factory.py @@ -0,0 +1,158 @@ +"""Factories for the three ingest components: ``AudioAnalyzer`` / ``Stt`` +/ ``TextAnalyzer``. + +The pipeline shape switches (``VOICE_PIPELINE``, ``TEXT_PIPELINE``) on +:class:`wolfbot.config.MasterSettings` decide which components a +particular boot needs; this module turns those switches plus the +component-scoped credentials into ready-to-inject instances. + +Why a separate module: ``main.py`` and ``voicetest/main.py`` both need +exactly the same component construction. Centralising it here also keeps +the wiring in ``main.py`` flat — one ``build_*`` call per component +instead of nested provider branches per call site. + +The functions return ``None`` when the pipeline doesn't request the +component, so callers can write ``if audio_analyzer is None:`` rather +than re-checking the pipeline shape. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from wolfbot.config import MasterSettings + from wolfbot.master.state.text_analyzer import TextAnalyzer + + +def build_text_analyzer(settings: MasterSettings) -> TextAnalyzer | None: + """Construct the ``TextAnalyzer`` instance shared by the text and + split-voice pipelines, or ``None`` if neither pipeline needs it. + + The settings validator already guarantees ``TEXT_ANALYZER_API_KEY`` + is non-None when this returns non-None, so the assert is purely a + type-narrowing aid for mypy. + """ + needs_text_analyzer = ( + settings.TEXT_PIPELINE == "text_analyzer" + or settings.VOICE_PIPELINE == "stt_then_text_analyzer" + ) + if not needs_text_analyzer: + return None + assert settings.TEXT_ANALYZER_API_KEY is not None # validator-guaranteed + + if settings.TEXT_ANALYZER_PROVIDER == "gemini": + from wolfbot.master.state.text_analyzer import GeminiTextAnalyzer + + return GeminiTextAnalyzer( + api_key=settings.TEXT_ANALYZER_API_KEY.get_secret_value(), + model=settings.TEXT_ANALYZER_MODEL, + ) + + # xai / deepseek / openai all share the OpenAI Chat Completions surface. + from wolfbot.master.state.text_analyzer import OpenAICompatibleTextAnalyzer + + base_url = settings.TEXT_ANALYZER_BASE_URL or _openai_compat_default_base_url( + settings.TEXT_ANALYZER_PROVIDER + ) + return OpenAICompatibleTextAnalyzer( + api_key=settings.TEXT_ANALYZER_API_KEY.get_secret_value(), + model=settings.TEXT_ANALYZER_MODEL, + base_url=base_url, + ) + + +def build_voice_ingest_provider(settings: MasterSettings) -> Any | None: + """Construct the ``SttService``-shaped object that + :class:`VoiceIngestService` consumes, or ``None`` when voice ingest + is disabled. + + Returns one of: + + * :class:`GeminiAudioAnalyzer` — when ``VOICE_PIPELINE=audio_analyzer``. + * :class:`GroqWhisperAudioAnalyzer` — when + ``VOICE_PIPELINE=stt_then_text_analyzer``. Composes the Stt + provider with the OpenAI-compatible ``TextAnalyzer`` settings; + the analyzer step shares its credentials with the text-channel + ``TextAnalyzer`` so both paths hit the same RPM bucket. + * ``None`` — when ``VOICE_PIPELINE=disabled``. + + Both return types satisfy the ``SttService`` Protocol used by + :mod:`wolfbot.master.voice.voice_ingest_service`. Returning ``Any`` + here avoids dragging the heavy ``stt_service`` module into modules + that only need the credential-presence check. + """ + if settings.VOICE_PIPELINE == "disabled": + return None + + if settings.VOICE_PIPELINE == "audio_analyzer": + assert settings.AUDIO_ANALYZER_API_KEY is not None # validator-guaranteed + from wolfbot.master.voice.stt_service import GeminiAudioAnalyzer + + return GeminiAudioAnalyzer( + api_key=settings.AUDIO_ANALYZER_API_KEY.get_secret_value(), + model=settings.AUDIO_ANALYZER_MODEL, + ) + + # VOICE_PIPELINE == "stt_then_text_analyzer" + assert settings.STT_API_KEY is not None # validator-guaranteed + assert settings.TEXT_ANALYZER_API_KEY is not None # validator-guaranteed + from wolfbot.master.voice.stt_service import GroqWhisperAudioAnalyzer + + analyzer_base_url = ( + settings.TEXT_ANALYZER_BASE_URL + or _openai_compat_default_base_url(settings.TEXT_ANALYZER_PROVIDER) + ) + return GroqWhisperAudioAnalyzer( + groq_api_key=settings.STT_API_KEY.get_secret_value(), + groq_model=settings.STT_MODEL, + groq_base_url=settings.STT_BASE_URL, + analyzer_api_key=settings.TEXT_ANALYZER_API_KEY.get_secret_value(), + analyzer_model=settings.TEXT_ANALYZER_MODEL, + analyzer_base_url=analyzer_base_url, + ) + + +def voice_ingest_summary(settings: MasterSettings) -> str: + """Human-readable wiring summary for boot-time logs. + + Mirrors the strings ``main.py`` previously logged inline so operators + grepping for ``integrated voice-ingest wired`` keep a similar signal. + """ + if settings.VOICE_PIPELINE == "disabled": + return "voice_pipeline=disabled" + if settings.VOICE_PIPELINE == "audio_analyzer": + return ( + f"voice_pipeline=audio_analyzer " + f"provider={settings.AUDIO_ANALYZER_PROVIDER} " + f"model={settings.AUDIO_ANALYZER_MODEL}" + ) + return ( + f"voice_pipeline=stt_then_text_analyzer " + f"stt={settings.STT_PROVIDER}:{settings.STT_MODEL} " + f"analyzer={settings.TEXT_ANALYZER_PROVIDER}:{settings.TEXT_ANALYZER_MODEL}" + ) + + +_OPENAI_COMPAT_DEFAULTS: dict[str, str] = { + "xai": "https://api.x.ai/v1", + "deepseek": "https://api.deepseek.com", + "openai": "https://api.openai.com/v1", +} + + +def _openai_compat_default_base_url(provider: str) -> str: + """Default base URL for an OpenAI-compatible provider. + + Falls back to the xAI URL for unknown providers because the analyzer + code path is identical regardless of host (any error surfaces at the + first request rather than at construction). + """ + return _OPENAI_COMPAT_DEFAULTS.get(provider, "https://api.x.ai/v1") + + +__all__ = [ + "build_text_analyzer", + "build_voice_ingest_provider", + "voice_ingest_summary", +] diff --git a/src/wolfbot/master/narration/__init__.py b/src/wolfbot/master/narration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wolfbot/master/narration/narration.py b/src/wolfbot/master/narration/narration.py new file mode 100644 index 0000000..1f42af5 --- /dev/null +++ b/src/wolfbot/master/narration/narration.py @@ -0,0 +1,329 @@ +"""Master narration templates — Levi-style polite-machine announcements. + +Translates a `LogEntry` (the raw text already produced by +`domain/state_machine.plan_*`) into a `NarrationOutput` that the +DiscordBotAdapter routes to: + + * `voice_text` — short string read aloud via VOICEVOX in the VC. + * `chat_text` — long string posted to the VC's attached text chat. + +Either or both may be `None`. Rules: + + * Headline-level announcements (PHASE_CHANGE / MORNING / VICTORY / + SETUP_COMPLETE / EXECUTION-headline / NO_EXECUTION-headline / + RUNOFF_START-headline) are voiced. They are short by design. + * Tally / reveal blobs (vote tally, ROLE_REVEAL) are too long to + pleasantly voice — they go to the VC text chat untouched. + * In `EXECUTION` / `NO_EXECUTION` / `RUNOFF_START` the existing log + entry has the form ``"{tally_suffix}"`` where + ``tally_suffix == f"\\n\\n{tally}"``. The narrator splits on the + blank line: voice the headline (with Levi rewording), text-post the + tally separately. + * `ROLE_REVEAL` is text-only. + * In rounds mode the narrator is bypassed entirely — existing + `post_public` / `post_morning` text remains. + +The persona's tone is hard-coded into the templates rather than fed +through an LLM; this keeps narration deterministic, cheap, and safe to +re-run on phase recovery without paying API tokens. The Levi persona +data in `master.personas` exists for future LLM-styled rewrites and to +document the voice character. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from wolfbot.domain.durations import current_phase_durations +from wolfbot.domain.enums import Phase +from wolfbot.domain.models import LogEntry, Seat +from wolfbot.llm.template import render_template + + +@dataclass(frozen=True) +class NarrationContext: + """Extra game context the rendered template may need. + + Built once per `LogEntry` from the live `Game` + `Seat` table by + `DiscordBotAdapter` before calling `render`. Pure data — no I/O. + """ + + day_number: int + phase: Phase + alive_count: int + seats_by_no: dict[int, Seat] + + +@dataclass(frozen=True) +class NarrationOutput: + """What to do with this LogEntry under reactive_voice mode.""" + + voice_text: str | None = None + chat_text: str | None = None + + def is_silent(self) -> bool: + return self.voice_text is None and self.chat_text is None + + +def _split_headline_and_tally(text: str) -> tuple[str, str | None]: + """Split ``"\\n\\n"`` into ``(headline, tally)``. + + Mirrors how :func:`state_machine._apply_execution` / + :func:`state_machine.plan_day_vote_resolve` glue the two pieces + with a blank line. Falls back to ``(text, None)`` when no blank + line is present. + """ + head, _, tail = text.partition("\n\n") + head = head.strip() + tail = tail.strip() + return head, (tail or None) + + +def _seat_label(seats_by_no: dict[int, Seat], seat_no: int | None) -> str: + """Human-readable seat label used inside narration text.""" + if seat_no is None: + return "対象不明" + seat = seats_by_no.get(seat_no) + if seat is None: + return f"席{seat_no}" + # Strip emoji prefix from display_name for cleaner TTS readout. + name = seat.display_name.lstrip() + while name and not (name[0].isalnum() or "぀" <= name[0] <= "ヿ"): + name = name[1:] + name = name.strip() or seat.display_name + return f"席{seat_no}の {name} 様" + + +# ---------------------------------------------------------------- per-kind templates + + +def _narrate_setup_complete(_entry: LogEntry, ctx: NarrationContext) -> NarrationOutput: + return NarrationOutput( + voice_text=render_template( + "master/narration_setup_complete", + alive_count=ctx.alive_count, + ), + ) + + +def _narrate_phase_change(entry: LogEntry, ctx: NarrationContext) -> NarrationOutput: + durations = current_phase_durations() + target = entry.phase + if target is Phase.DAY_DISCUSSION: + secs = durations.discussion_for_day(max(1, ctx.day_number)) + return NarrationOutput( + voice_text=render_template( + "master/narration_phase_change_day_discussion", + # Day 2+ skips the dawn line because MORNING already announced + # it with casualty info; the template's two if-branches encode + # this fork. + is_first_day=ctx.day_number <= 1, + is_later_day=ctx.day_number > 1, + day_number=ctx.day_number, + secs=secs, + ) + ) + if target is Phase.DAY_VOTE: + return NarrationOutput( + voice_text=render_template( + "master/narration_phase_change_day_vote", + secs=durations.vote, + ) + ) + if target is Phase.DAY_RUNOFF: + return NarrationOutput( + voice_text=render_template( + "master/narration_phase_change_day_runoff", + secs=durations.runoff, + ) + ) + if target is Phase.DAY_RUNOFF_SPEECH: + return NarrationOutput( + voice_text=render_template( + "master/narration_phase_change_day_runoff_speech", + secs=durations.runoff_speech_grace, + ) + ) + if target is Phase.NIGHT: + return NarrationOutput( + voice_text=render_template( + "master/narration_phase_change_night", + secs=durations.night, + ) + ) + # Unknown / GAME_OVER fallthrough — voice the raw entry text as-is. + return NarrationOutput(voice_text=entry.text) + + +def _narrate_morning(entry: LogEntry, ctx: NarrationContext) -> NarrationOutput: + """MORNING entry → Levi reframing. + + Source text is e.g. "夜が明けました。席3 Bob が無残な姿で発見された..." + The template rewrites it neutrally with day_number + casualty info. + Two branches gated by has/no_casualty so the same template covers + both "犠牲者なし" and " の停止を確認" without a Python ternary. + + Also surfaces the kill / no-kill announcement to the VC's text chat + so post-game review (and players who muted Master's voice) see who + died without having to listen back. The companion survivor roster + is posted separately by ``DiscordBotAdapter.post_morning`` so it + stays a chat-only sidecar — voice keeps the existing concise tone + and isn't extended with a 5-name read-out. + """ + has_casualty = entry.actor_seat is not None + target = ( + _seat_label(ctx.seats_by_no, entry.actor_seat) if has_casualty else "" + ) + return NarrationOutput( + voice_text=render_template( + "master/narration_morning", + day_number=ctx.day_number, + has_casualty=has_casualty, + no_casualty=not has_casualty, + target=target, + alive_count=ctx.alive_count, + ), + chat_text=f"☀️ {entry.text}", + ) + + +def _narrate_execution(entry: LogEntry, ctx: NarrationContext) -> NarrationOutput: + """EXECUTION entry → Levi confirmation + (optional) NIGHT cue. + + The state-machine path emits no PHASE_CHANGE log between the + EXECUTION row and the NIGHT phase entry, so role-holders would + otherwise get silent DMs without any "夜です" cue. The template's + ``append_night_intro`` branch is on iff the next phase is actually + NIGHT — when execution triggers a victory the game flips to + GAME_OVER and the VICTORY narration takes over instead. + """ + headline, tally = _split_headline_and_tally(entry.text) + target = _seat_label(ctx.seats_by_no, entry.actor_seat) + going_to_night = ctx.phase is Phase.NIGHT + voice = render_template( + "master/narration_execution", + target=target, + append_night_intro=going_to_night, + night_secs=current_phase_durations().night if going_to_night else 0, + ) + chat = tally if tally else None + if chat is None and headline: + chat = headline + return NarrationOutput(voice_text=voice, chat_text=chat) + + +def _narrate_no_execution(entry: LogEntry, ctx: NarrationContext) -> NarrationOutput: + headline, tally = _split_headline_and_tally(entry.text) + is_runoff_tie = "決選投票も同票" in headline + is_invalid_vote = "投票結果が無効" in headline + voice = render_template( + "master/narration_no_execution", + is_runoff_tie=is_runoff_tie, + is_invalid_vote=is_invalid_vote and not is_runoff_tie, + is_default=not is_runoff_tie and not is_invalid_vote, + ) + chat = tally if tally else None + if chat is None and headline: + chat = headline + return NarrationOutput(voice_text=voice, chat_text=chat) + + +def _narrate_runoff_start(entry: LogEntry, _ctx: NarrationContext) -> NarrationOutput: + headline, tally = _split_headline_and_tally(entry.text) + voice = render_template("master/narration_runoff_start") + # Keep the candidate list + tally as text so players can see who's tied. + chat_parts: list[str] = [] + if headline: + chat_parts.append(headline) + if tally: + chat_parts.append(tally) + chat = "\n\n".join(chat_parts) if chat_parts else None + return NarrationOutput(voice_text=voice, chat_text=chat) + + +def _narrate_victory(entry: LogEntry, _ctx: NarrationContext) -> NarrationOutput: + """VICTORY entry → Levi end-of-game line. + + state_machine emits text like 村人陣営の勝利 or 人狼陣営の勝利 (with a + fullwidth exclamation). Substring-match picks the template branch; + anything unrecognised falls into the ``is_other`` branch which + quotes the raw text verbatim — keeping a clean fallback for any + future faction string the engine adds. + """ + text = entry.text.strip() + is_village = "村人" in text or "村陣営" in text + is_wolf = (not is_village) and ("人狼" in text or "狼陣営" in text) + is_other = not is_village and not is_wolf + return NarrationOutput( + voice_text=render_template( + "master/narration_victory", + is_village=is_village, + is_wolf=is_wolf, + is_other=is_other, + raw_text=text, + ) + ) + + +def _narrate_role_reveal(entry: LogEntry, _ctx: NarrationContext) -> NarrationOutput: + # 9-line role table — too long for TTS, post to chat verbatim. + return NarrationOutput(chat_text=entry.text) + + +_NarrationHandler = Callable[[LogEntry, NarrationContext], NarrationOutput] + +_HANDLERS: dict[str, _NarrationHandler] = { + "SETUP_COMPLETE": _narrate_setup_complete, + "PHASE_CHANGE": _narrate_phase_change, + "MORNING": _narrate_morning, + "EXECUTION": _narrate_execution, + "NO_EXECUTION": _narrate_no_execution, + "RUNOFF_START": _narrate_runoff_start, + "VICTORY": _narrate_victory, + "ROLE_REVEAL": _narrate_role_reveal, +} + + +def render_master_narration( + entry: LogEntry, ctx: NarrationContext +) -> NarrationOutput: + """Translate a public `LogEntry` into a Levi-styled narration. + + Returns ``NarrationOutput(None, None)`` when the entry kind has no + narration template. Callers fall back to posting the entry's raw + text in that case (no semantic loss vs the rounds-mode path). + """ + handler = _HANDLERS.get(entry.kind) + if handler is None: + return NarrationOutput() + return handler(entry, ctx) + + +def render_runoff_candidate_intro(seat: Seat) -> str: + """Levi-styled introduction line for a runoff candidate's speech. + + Spoken by Master in VC right before SpeakArbiter dispatches the + candidate's SpeakRequest, so listeners always know whose final + speech is starting (the same way 「占い師の発表」 patterns name the + speaker out loud). Uses the same emoji-stripping logic as + ``_seat_label`` so the readout pronounces the persona name cleanly. + + Body: ``master/narration_runoff_candidate_intro.md``. + """ + name = seat.display_name.lstrip() + while name and not (name[0].isalnum() or "぀" <= name[0] <= "ヿ"): + name = name[1:] + name = name.strip() or seat.display_name + return render_template( + "master/narration_runoff_candidate_intro", + name=name, + ) + + +__all__ = [ + "NarrationContext", + "NarrationOutput", + "render_master_narration", + "render_runoff_candidate_intro", +] diff --git a/src/wolfbot/master/personas.py b/src/wolfbot/master/personas.py new file mode 100644 index 0000000..4f3abc0 --- /dev/null +++ b/src/wolfbot/master/personas.py @@ -0,0 +1,71 @@ +"""Master / GM narrator persona — single canonical voice. + +Master only has one persona: ``levi``. The previous re-skin trio +(``stoic_gm`` / ``theatrical_gm`` / ``warm_gm``) was never wired to any +runtime selection mechanism, so collapsing them keeps the data model +simpler and gives narration templates a single tone target. + +The persona is modelled after Gnosia's "Levi" — polite mechanical +narration, neutral, factual, no theatrics. It is consumed by +``master.narration`` to render TTS-friendly announcements; the persona +data here exists so a future variant (or an LLM-styled rewrite) can swap +in a different voice without touching call sites. +""" + +from __future__ import annotations + +from wolfbot.llm.persona_base import Persona, SpeechProfile, index_by_key + +LEVI_PERSONA: Persona = Persona( + key="levi", + display_name="🤖 進行管理 LEVI", + style_guide=( + "丁寧で機械的な進行管理人格。感情を表に出さず、事実と進行内容のみを淡々と告げる。" + "プレイヤーへの敬意は保ちつつ、過剰な装飾や演出は加えない。" + "Gnosia の Levi に着想を得た、静かな AI 司会者。" + ), + speech_profile=SpeechProfile( + first_person="本機", + address_style=( + "全体への呼びかけは『参加者の皆様』を基本とし、特定個人を呼ぶときは" + "『席{番号}の {名前} 様』のように席番号を併記する。" + ), + sentence_style=( + "敬体・丁寧語で統一する。" + "『〜致します』『〜となります』『〜をお願い致します』のような穏やかで硬めの表現を用いる。" + "比喩や演出を排し、事実関係 (時間、席番号、結果) を最初に告げる。" + "感情表現は最小限。死亡や処刑も淡々と報告する。" + ), + pause_style=( + "テンポは均一。間は最小限で、機械的に整然と読み上げる印象を保つ。" + ), + signature_phrases=( + "ご報告致します", + "進行致します", + "次のフェイズへ移行致します", + "以上です", + ), + forbidden_overuse=( + "感情表現 (悲しい、嬉しい等)", + "煽り口調や挑発", + "比喩や詩的表現", + "プレイヤー個人への評価", + "『さて』『ところで』のような口語的な間投詞", + ), + ), +) + + +MASTER_PERSONAS: tuple[Persona, ...] = (LEVI_PERSONA,) + +MASTER_PERSONAS_BY_KEY: dict[str, Persona] = index_by_key(MASTER_PERSONAS) + +DEFAULT_MASTER_PERSONA_KEY = "levi" + + +__all__ = [ + "DEFAULT_MASTER_PERSONA_KEY", + "LEVI_PERSONA", + "MASTER_PERSONAS", + "MASTER_PERSONAS_BY_KEY", +] diff --git a/src/wolfbot/master/state/__init__.py b/src/wolfbot/master/state/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wolfbot/master/state/phase_d_state_pusher.py b/src/wolfbot/master/state/phase_d_state_pusher.py new file mode 100644 index 0000000..f74b0a2 --- /dev/null +++ b/src/wolfbot/master/state/phase_d_state_pusher.py @@ -0,0 +1,402 @@ +"""Phase-D state-update pusher — wired into ``GameService.advance``. + +After each transition is applied to the DB, this pusher diff's the new +state against the transition's emitted logs and ships incremental +``PrivateStateUpdate`` messages to each affected seat's NPC bot. Every +update kind the NPC bot recognises is covered: + +* ``seer_result`` / ``seer_result`` (NIGHT_0 random white) — when a + ``SEER_RESULT_NIGHT0`` / ``SEER_RESULT`` private log is emitted. +* ``medium_result`` — on every ``MEDIUM_RESULT`` private log. +* ``guard_entry`` — when a ``KNIGHT_GUARD`` night-action lands. +* ``guard_resolved`` — when MORNING resolves (peaceful or attack). +* ``alive_changed`` / ``day_advanced`` — broadcast to all live LLM + seats whenever a player dies or the day counter ticks. + +This module is the only place that needs to know which Master events +turn into which NPC state mutations, so the NPC bot stays a pure +consumer of the snapshot + update stream. + +Pure orchestration — no LLM calls, all data sourced from the repo, +seats, and the transition's logs. Best-effort end to end: a single +failed send is logged but does not block the others. +""" + +from __future__ import annotations + +import logging +import re +import time +from collections.abc import Callable, Sequence + +from wolfbot.domain.enums import Phase, Role, SubmissionType +from wolfbot.domain.models import Game, NightAction, Player, Seat +from wolfbot.domain.models import LogEntry as DomainLogEntry +from wolfbot.master.state.private_state import ( + make_alive_changed_update, + make_day_advanced_update, + make_guard_entry_update, + make_guard_resolved_update, + make_medium_result_update, + make_seer_result_update, +) +from wolfbot.master.ws.npc_registry import NpcEntry, NpcRegistry +from wolfbot.persistence.sqlite_repo import SqliteRepo + +log = logging.getLogger(__name__) + + +# Regex to pull a seat number out of the canonical ``席N 名前 は ...`` +# rendering used by every state-machine private log. The state_machine +# always builds these via ``_name(seats_by_no, ...)`` which produces +# ``席N 名前`` deterministically. +_SEAT_RE = re.compile(r"席(\d+)") + + +class PhaseDStatePusher: + """Side-effect coordinator: transitions → PrivateStateUpdate fan-out. + + Hold a reference to the registry + repo and a wall-clock helper. + The single public entry point is :meth:`push_after_advance`, called + from ``GameService`` after each successful ``apply_transition``. + """ + + def __init__( + self, + *, + repo: SqliteRepo, + registry: NpcRegistry, + now_ms: Callable[[], int] = lambda: int(time.time() * 1000), + ) -> None: + self.repo = repo + self.registry = registry + self._now_ms = now_ms + + async def push_after_advance( + self, + *, + game: Game, + prev_phase: Phase, + private_logs: Sequence[DomainLogEntry], + public_logs: Sequence[DomainLogEntry], + ) -> None: + """Run all phase-D side-effects for one applied transition. + + ``game`` is the *new* game state (after ``apply_transition``). + ``private_logs`` / ``public_logs`` are the entries emitted by + the transition planner; the pusher uses them as triggers and + re-loads any structured data it needs from the repo. + """ + if game.discussion_mode != "reactive_voice": + return + try: + seats = await self.repo.load_seats(game.id) + players = await self.repo.load_players(game.id) + except Exception: + log.exception( + "phase_d_state_pusher_load_failed game=%s", game.id, + ) + return + + seats_by_no = {s.seat_no: s for s in seats} + + # 1) seer / medium private results — derive structured data and + # push the matching updates. + for entry in private_logs: + if entry.audience_seat is None: + continue + if entry.kind in ("SEER_RESULT", "SEER_RESULT_NIGHT0"): + await self._push_seer_result( + game=game, + entry=entry, + seats_by_no=seats_by_no, + players=players, + ) + elif entry.kind == "MEDIUM_RESULT": + await self._push_medium_result( + game=game, + entry=entry, + seats_by_no=seats_by_no, + players=players, + ) + + # 2) Knight guard ENTRY — pushed when night actions land. Master's + # state_machine doesn't emit a private log for the knight's own + # guard choice, so we look directly at the night_actions row. + await self._push_guard_entries( + game=game, seats_by_no=seats_by_no, players=players, + ) + + # 3) Knight guard RESOLVED — pushed when MORNING fires. + if any(e.kind == "MORNING" for e in public_logs): + await self._push_guard_resolved( + game=game, + public_logs=public_logs, + seats_by_no=seats_by_no, + players=players, + ) + + # 4) Broad updates: alive_changed (whenever someone dies) + + # day_advanced (whenever day counter ticks). We always fan these + # out because the cost is small and the NPC's prompt depends on + # them. + await self._push_alive_and_day( + game=game, prev_phase=prev_phase, players=players, seats=seats, + ) + + # --------------------------------------------------- seer + + async def _push_seer_result( + self, + *, + game: Game, + entry: DomainLogEntry, + seats_by_no: dict[int, Seat], + players: Sequence[Player], + ) -> None: + target_seat = _parse_seat_from_text(entry.text) + if target_seat is None or target_seat not in seats_by_no: + log.info( + "phase_d_seer_no_target_in_text game=%s text=%r", + game.id, entry.text[:80], + ) + return + target = next((p for p in players if p.seat_no == target_seat), None) + if target is None or target.role is None: + return + # NIGHT_0 random white is always non-wolf by definition; the + # regular SEER_RESULT respects the role detection rule (madman is + # NOT detected as wolf). + from wolfbot.domain.rules import is_detected_as_wolf + + is_wolf = ( + False + if entry.kind == "SEER_RESULT_NIGHT0" + else bool(is_detected_as_wolf(target.role)) + ) + update = make_seer_result_update( + npc_id="", # filled per-recipient below + game_id=game.id, + seat_no=entry.audience_seat or 0, + day=entry.day, + target_seat=target_seat, + target_name=seats_by_no[target_seat].display_name, + is_wolf=is_wolf, + ts=self._now_ms(), + trace_id=f"seer_result-{game.id}-d{entry.day}", + ) + await self._send_to_seat(game.id, entry.audience_seat or 0, update) + + # --------------------------------------------------- medium + + async def _push_medium_result( + self, + *, + game: Game, + entry: DomainLogEntry, + seats_by_no: dict[int, Seat], + players: Sequence[Player], + ) -> None: + target_seat = _parse_seat_from_text(entry.text) + is_wolf: bool | None = None + target_name = "(処刑なし)" + if target_seat is not None and target_seat in seats_by_no: + target = next((p for p in players if p.seat_no == target_seat), None) + target_name = seats_by_no[target_seat].display_name + if target is not None and target.role is not None: + from wolfbot.domain.rules import is_detected_as_wolf + + is_wolf = bool(is_detected_as_wolf(target.role)) + else: + # No execution → medium gets a "no result" update so its + # state mirror still has a row for that day. target_seat=0 + # is a sentinel meaning "no target"; the NPC prompt-builder + # already special-cases is_wolf=None. + target_seat = 0 + update = make_medium_result_update( + npc_id="", + game_id=game.id, + seat_no=entry.audience_seat or 0, + day=entry.day, + target_seat=target_seat, + target_name=target_name, + is_wolf=is_wolf, + ts=self._now_ms(), + trace_id=f"medium_result-{game.id}-d{entry.day}", + ) + await self._send_to_seat(game.id, entry.audience_seat or 0, update) + + # --------------------------------------------------- knight guard + + async def _push_guard_entries( + self, + *, + game: Game, + seats_by_no: dict[int, Seat], + players: Sequence[Player], + ) -> None: + knight = next( + (p for p in players if p.role is Role.KNIGHT and p.alive), None, + ) + if knight is None: + return + try: + night_actions: list[NightAction] = list( + await self.repo.load_night_actions(game.id, day=game.day_number) + ) + except Exception: + log.exception("phase_d_load_night_actions_failed game=%s", game.id) + return + guard_action = next( + (a for a in night_actions if a.kind is SubmissionType.KNIGHT_GUARD), + None, + ) + if guard_action is None or guard_action.target_seat is None: + return + target_seat = guard_action.target_seat + if target_seat not in seats_by_no: + return + update = make_guard_entry_update( + npc_id="", + game_id=game.id, + seat_no=knight.seat_no, + day=game.day_number, + target_seat=target_seat, + target_name=seats_by_no[target_seat].display_name, + ts=self._now_ms(), + trace_id=f"guard_entry-{game.id}-d{game.day_number}", + ) + await self._send_to_seat(game.id, knight.seat_no, update) + + async def _push_guard_resolved( + self, + *, + game: Game, + public_logs: Sequence[DomainLogEntry], + seats_by_no: dict[int, Seat], + players: Sequence[Player], + ) -> None: + knight = next( + (p for p in players if p.role is Role.KNIGHT and p.alive), None, + ) + if knight is None: + return + morning = next((e for e in public_logs if e.kind == "MORNING"), None) + if morning is None: + return + peaceful = "平和な朝" in morning.text + # The morning entry's day is the new day; the guard we're + # resolving was submitted under the previous day_number. + update = make_guard_resolved_update( + npc_id="", + game_id=game.id, + seat_no=knight.seat_no, + day=max(0, game.day_number - 1), + peaceful_morning=peaceful, + ts=self._now_ms(), + trace_id=f"guard_resolved-{game.id}-d{game.day_number}", + ) + await self._send_to_seat(game.id, knight.seat_no, update) + + # --------------------------------------------------- alive / day + + async def _push_alive_and_day( + self, + *, + game: Game, + prev_phase: Phase, + players: Sequence[Player], + seats: Sequence[Seat], + ) -> None: + # Fan out to every alive LLM seat that has an NPC bot online. + for player in players: + if not player.alive: + continue + seat = next((s for s in seats if s.seat_no == player.seat_no), None) + if seat is None or not seat.is_llm: + continue + entry = self._find_npc_for_seat(game.id, player.seat_no) + if entry is None or entry.send is None: + continue + alive_update = make_alive_changed_update( + npc_id=entry.npc_id, + game_id=game.id, + seat_no=player.seat_no, + players=players, + seats=seats, + ts=self._now_ms(), + trace_id=f"alive-{game.id}-d{game.day_number}-s{player.seat_no}", + ) + try: + await entry.send(alive_update.model_dump_json()) + except Exception: + log.exception( + "phase_d_alive_update_failed npc=%s seat=%d", + entry.npc_id, player.seat_no, + ) + day_update = make_day_advanced_update( + npc_id=entry.npc_id, + game_id=game.id, + seat_no=player.seat_no, + day_number=game.day_number, + ts=self._now_ms(), + trace_id=f"day-{game.id}-d{game.day_number}-s{player.seat_no}", + ) + try: + await entry.send(day_update.model_dump_json()) + except Exception: + log.exception( + "phase_d_day_update_failed npc=%s seat=%d", + entry.npc_id, player.seat_no, + ) + # `prev_phase` is currently unused but kept on the signature so + # future hooks can branch on phase transitions (e.g. emit + # `day_advanced` only on NIGHT → DAY transitions). + _ = prev_phase + + # --------------------------------------------------- send helpers + + async def _send_to_seat( + self, + game_id: str, + seat_no: int, + update_template: object, + ) -> None: + entry = self._find_npc_for_seat(game_id, seat_no) + if entry is None or entry.send is None: + return + # The factory returns a PrivateStateUpdate with npc_id="" (we + # don't know it yet at construction time); patch the field via + # model_copy so the routed message addresses this NPC. + try: + model_copy = getattr(update_template, "model_copy", None) + if model_copy is None: + return + patched = model_copy(update={"npc_id": entry.npc_id}) + payload = patched.model_dump_json() + await entry.send(payload) + except Exception: + log.exception( + "phase_d_state_update_send_failed game=%s seat=%d", + game_id, seat_no, + ) + + def _find_npc_for_seat(self, game_id: str, seat_no: int) -> NpcEntry | None: + for entry in self.registry.all_online(): + if entry.assigned_seat == seat_no and entry.game_id == game_id: + return entry + return None + + +def _parse_seat_from_text(text: str) -> int | None: + """Pull the first ``席N`` token out of a Japanese private-log text.""" + m = _SEAT_RE.search(text) + if m is None: + return None + try: + return int(m.group(1)) + except ValueError: + return None + + +__all__ = ["PhaseDStatePusher"] diff --git a/src/wolfbot/master/state/private_state.py b/src/wolfbot/master/state/private_state.py new file mode 100644 index 0000000..c618eb5 --- /dev/null +++ b/src/wolfbot/master/state/private_state.py @@ -0,0 +1,640 @@ +"""Master-side builder for `PrivateStateSnapshot` / `PrivateStateUpdate`. + +In Phase-D (`reactive_voice` mode), each NPC bot is the embodied agent +for its seat and decides speech / vote / night-action via its own LLM. +The bot needs role + role-specific result history + wolf chat to make +those decisions; Master is the source of truth for all of it. + +This module turns Master DB rows into the WS payloads: + +* :func:`build_snapshot_for_seat` — full state replace, sent at game + start and on NPC re-register. Pure function of supplied data. +* :func:`load_private_state_for_seat` — async helper that reads + ``logs_private`` + ``night_actions`` and rebuilds the per-role + histories the snapshot carries (seer / medium / guard / wolf-chat). + Used by ``main.py`` so a snapshot push after a Master restart or + a delayed first-phase entry recovers the seer's day-0 random white, + the medium's past results, the knight's guard history, and the + wolves' chat log. +* update factories (e.g. :func:`make_seer_result_update`) — per-event + patches sent as Master computes new private results. + +The *snapshot builder* stays a pure function; the *loader* does I/O +against the repo. Callers in main.py / arbiter / state-machine glue +own the WS send. +""" + +from __future__ import annotations + +import logging +import re +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from wolfbot.domain.enums import Role, SubmissionType +from wolfbot.domain.models import Player, Seat +from wolfbot.domain.ws_messages import ( + GuardEntry, + MediumResult, + PrivateStateSnapshot, + PrivateStateUpdate, + SeerResult, + WolfAttackEntry, + WolfChatLine, +) + +if TYPE_CHECKING: + from wolfbot.persistence.sqlite_repo import SqliteRepo + +log = logging.getLogger(__name__) + + +def _seat_pairs( + players: Sequence[Player], + seats_by_no: dict[int, Seat], + *, + alive: bool, +) -> tuple[tuple[int, str], ...]: + """``(seat_no, display_name)`` pairs filtered by alive state, sorted by seat.""" + return tuple( + sorted( + (p.seat_no, seats_by_no[p.seat_no].display_name) + for p in players + if p.alive is alive and p.seat_no in seats_by_no + ) + ) + + +def _partner_wolves( + players: Sequence[Player], + seats_by_no: dict[int, Seat], + *, + self_seat: int, +) -> tuple[tuple[int, str], ...]: + """Wolves other than ``self_seat``, alive only, sorted by seat. + + Empty for non-wolves; the caller must only invoke this after + confirming the recipient is `Role.WEREWOLF`. + """ + return tuple( + sorted( + (p.seat_no, seats_by_no[p.seat_no].display_name) + for p in players + if p.role is Role.WEREWOLF + and p.seat_no != self_seat + and p.alive + and p.seat_no in seats_by_no + ) + ) + + +def build_snapshot_for_seat( + *, + npc_id: str, + game_id: str, + seat_no: int, + persona_key: str, + role: Role, + day_number: int, + players: Sequence[Player], + seats: Sequence[Seat], + seer_results: Sequence[SeerResult] = (), + medium_results: Sequence[MediumResult] = (), + guard_history: Sequence[GuardEntry] = (), + wolf_chat_history: Sequence[WolfChatLine] = (), + wolf_attack_history: Sequence[WolfAttackEntry] = (), + ts: int, + trace_id: str, +) -> PrivateStateSnapshot: + """Compose the full snapshot the NPC bot rebuilds its state from.""" + seats_by_no = {s.seat_no: s for s in seats} + # Death-cause tag per dead seat so the NPC prompt can distinguish + # yesterday's executions from last night's attacks. Players with no + # cause (= still alive, or some imported state) are skipped. + dead_causes = tuple( + sorted( + (p.seat_no, p.death_cause.value) + for p in players + if not p.alive and p.death_cause is not None + ) + ) + return PrivateStateSnapshot( + ts=ts, + trace_id=trace_id, + npc_id=npc_id, + game_id=game_id, + seat_no=seat_no, + persona_key=persona_key, + role=role.value, + day_number=day_number, + alive_seats=_seat_pairs(players, seats_by_no, alive=True), + dead_seats=_seat_pairs(players, seats_by_no, alive=False), + dead_seat_causes=dead_causes, + partner_wolves=( + _partner_wolves(players, seats_by_no, self_seat=seat_no) + if role is Role.WEREWOLF + else () + ), + seer_results=tuple(seer_results), + medium_results=tuple(medium_results), + guard_history=tuple(guard_history), + wolf_chat_history=tuple(wolf_chat_history), + wolf_attack_history=tuple(wolf_attack_history), + ) + + +# --- DB → snapshot history loader ------------------------------------ + +# Private logs persist results as natural Japanese text; the structured +# fields (target_seat, is_wolf) are not stored in dedicated columns. +# Parse them back out here so the NPC's snapshot can carry the same +# info rounds-mode prompt builds get from raw text reads. +_SEER_NIGHT0_RE = re.compile(r"^初日ランダム白:\s*(?P.+?)\s+は") +_SEER_RESULT_RE = re.compile(r"^占い結果:\s*(?P.+?)\s+は") +_MEDIUM_RESULT_RE = re.compile(r"^霊媒結果:\s*(?P.+?)\s+は") + + +def _resolve_seat_by_name( + name: str, seats_by_no: dict[int, Seat] +) -> tuple[int, str] | None: + """Match a parsed display name back to a seat. Tries exact match + first, then strips a leading emoji+space prefix as a fall-back so + the seer's NIGHT_0 random-white text "👑ユリコ" still resolves + when display_name lookup uses a different normalization.""" + for seat in seats_by_no.values(): + if seat.display_name == name: + return (seat.seat_no, seat.display_name) + # Fall-back: try stripping leading non-word characters (emoji etc.) + stripped = name.lstrip() + while stripped and not (stripped[0].isalnum() or "ぁ" <= stripped[0] <= "ヿ"): + stripped = stripped[1:] + if stripped: + for seat in seats_by_no.values(): + if seat.display_name.endswith(stripped): + return (seat.seat_no, seat.display_name) + return None + + +async def load_private_state_for_seat( + repo: SqliteRepo, + *, + game_id: str, + seat_no: int, + role: Role, + players: Sequence[Player], + seats: Sequence[Seat], +) -> tuple[ + tuple[SeerResult, ...], + tuple[MediumResult, ...], + tuple[GuardEntry, ...], + tuple[WolfChatLine, ...], + tuple[WolfAttackEntry, ...], +]: + """Rebuild role-specific history for a seat from persisted DB rows. + + Returns ``(seer_results, medium_results, guard_history, + wolf_chat_history, wolf_attack_history)``. Each tuple is non-empty + only when the role actually owns that history: + + * ``seer_results`` — populated for ``Role.SEER`` from + SEER_RESULT_NIGHT0 + SEER_RESULT private logs. + * ``medium_results`` — populated for ``Role.MEDIUM`` from + MEDIUM_RESULT private logs. + * ``guard_history`` — populated for ``Role.KNIGHT`` from + ``night_actions`` (KNIGHT_GUARD entries) joined with the + MORNING public log to derive ``peaceful_morning``. + * ``wolf_chat_history`` — populated for ``Role.WEREWOLF`` from + WOLF_CHAT private logs (every wolf seat sees them via the + ``audience_seat IS NULL`` branch of ``load_private_logs``). + * ``wolf_attack_history`` — populated for ``Role.WEREWOLF`` from + ``night_actions`` (WOLF_ATTACK entries) joined with the MORNING + public log to derive ``peaceful_morning``. Lets wolves detect + "we attacked X last night, X is alive → GJ → re-attack X tonight + because the knight can't guard the same seat twice in a row". + + Best-effort: a parse failure on one row is logged and skipped, not + fatal — a partially populated snapshot is still strictly better + than the previous always-empty default. + """ + seats_by_no = {s.seat_no: s for s in seats} + + seer_results: list[SeerResult] = [] + medium_results: list[MediumResult] = [] + wolf_chat_history: list[WolfChatLine] = [] + guard_history: list[GuardEntry] = [] + wolf_attack_history: list[WolfAttackEntry] = [] + + if role in (Role.SEER, Role.MEDIUM): + try: + rows = await repo.load_private_logs_for_audience( + game_id, audience_seat=seat_no, limit=200, + ) + except Exception: + log.exception( + "private_state_load_failed_seer_medium game=%s seat=%d", + game_id, seat_no, + ) + rows = [] + for r in rows: + day = int(r["day"]) + text = str(r["text"]) + kind = r["kind"] + if role is Role.SEER and kind == "SEER_RESULT_NIGHT0": + m = _SEER_NIGHT0_RE.match(text) + if m is None: + continue + resolved = _resolve_seat_by_name(m.group("name"), seats_by_no) + if resolved is None: + continue + target_seat, target_name = resolved + seer_results.append( + SeerResult( + day=day, target_seat=target_seat, + target_name=target_name, is_wolf=False, + ) + ) + elif role is Role.SEER and kind == "SEER_RESULT": + m = _SEER_RESULT_RE.match(text) + if m is None: + continue + resolved = _resolve_seat_by_name(m.group("name"), seats_by_no) + if resolved is None: + continue + target_seat, target_name = resolved + is_wolf = "ありません" not in text + seer_results.append( + SeerResult( + day=day, target_seat=target_seat, + target_name=target_name, is_wolf=is_wolf, + ) + ) + elif role is Role.MEDIUM and kind == "MEDIUM_RESULT": + m = _MEDIUM_RESULT_RE.match(text) + if m is None: + # "本日の霊媒結果はありません(処刑なし)。" — just skip. + continue + resolved = _resolve_seat_by_name(m.group("name"), seats_by_no) + if resolved is None: + continue + target_seat, target_name = resolved + is_wolf = "ありませんでした" not in text + medium_results.append( + MediumResult( + day=day, target_seat=target_seat, + target_name=target_name, is_wolf=is_wolf, + ) + ) + + if role is Role.WEREWOLF: + try: + rows = await repo.load_private_logs_for_audience( + game_id, audience_seat=seat_no, limit=200, + ) + except Exception: + log.exception( + "private_state_load_failed_wolf_chat game=%s seat=%d", + game_id, seat_no, + ) + rows = [] + for r in rows: + if r["kind"] != "WOLF_CHAT": + continue + speaker_seat = r["actor_seat"] + if speaker_seat is None or speaker_seat not in seats_by_no: + continue + speaker_name = seats_by_no[speaker_seat].display_name + wolf_chat_history.append( + WolfChatLine( + day=int(r["day"]), + speaker_seat=int(speaker_seat), + speaker_name=speaker_name, + text=str(r["text"]), + ) + ) + # Reload public logs to derive `peaceful_morning` per attack. + # Same shape as the knight's guard-history loader below. + try: + public_logs_for_attack = await repo.load_public_logs( + game_id, limit=200, + ) + except Exception: + log.exception( + "private_state_load_failed_public_logs_for_attack game=%s", + game_id, + ) + public_logs_for_attack = [] + attack_morning_by_day: dict[int, bool] = {} + for log_row in public_logs_for_attack: + if log_row.get("kind") == "MORNING": + resolved_day = int(log_row.get("day", 0)) + if resolved_day < 0: + continue + peaceful = "平和な朝" in str(log_row.get("text", "")) + attack_morning_by_day[resolved_day] = peaceful + try: + seen_attack_days: set[int] = set() + for day in range(0, 30): + actions = await repo.load_night_actions(game_id, day=day) + for a in actions: + if ( + a.kind is SubmissionType.WOLF_ATTACK + and a.target_seat is not None + and a.target_seat in seats_by_no + and day not in seen_attack_days + ): + seen_attack_days.add(day) + wolf_attack_history.append( + WolfAttackEntry( + day=day, + target_seat=a.target_seat, + target_name=seats_by_no[ + a.target_seat + ].display_name, + peaceful_morning=attack_morning_by_day.get(day), + ) + ) + if not actions and day > 1: + break + except Exception: + log.exception( + "private_state_load_failed_wolf_attack game=%s seat=%d", + game_id, seat_no, + ) + + if role is Role.KNIGHT: + # Knight's guard targets come from the night_actions table for + # every day the knight has been alive. Each guard entry's + # ``peaceful_morning`` is derived from the next day's MORNING + # public log. + for p in players: + if p.seat_no == seat_no and p.role is Role.KNIGHT: + break + else: + return ( + tuple(seer_results), tuple(medium_results), + tuple(guard_history), tuple(wolf_chat_history), + tuple(wolf_attack_history), + ) + try: + public_logs = await repo.load_public_logs(game_id, limit=200) + except Exception: + log.exception( + "private_state_load_failed_public_logs game=%s", game_id, + ) + public_logs = [] + morning_by_day: dict[int, bool] = {} + for log_row in public_logs: + if log_row.get("kind") == "MORNING": + # MORNING is emitted for the *new* day after a NIGHT + # resolution; the guard it resolves was submitted on + # ``day - 1``. Stash by submission day. + resolved_day = int(log_row.get("day", 0)) - 1 + if resolved_day < 0: + continue + peaceful = "平和な朝" in str(log_row.get("text", "")) + morning_by_day[resolved_day] = peaceful + try: + seen_days: set[int] = set() + # 9-player game runs at most a handful of nights; loading per-day is fine. + for day in range(0, 30): + actions = await repo.load_night_actions(game_id, day=day) + for a in actions: + if ( + a.actor_seat == seat_no + and a.kind is SubmissionType.KNIGHT_GUARD + and a.target_seat is not None + and a.target_seat in seats_by_no + and day not in seen_days + ): + seen_days.add(day) + guard_history.append( + GuardEntry( + day=day, + target_seat=a.target_seat, + target_name=seats_by_no[a.target_seat].display_name, + peaceful_morning=morning_by_day.get(day), + ) + ) + if not actions and day > 1: + # No actions ⇒ later days won't have any either. + break + except Exception: + log.exception( + "private_state_load_failed_guard game=%s seat=%d", + game_id, seat_no, + ) + + return ( + tuple(seer_results), + tuple(medium_results), + tuple(guard_history), + tuple(wolf_chat_history), + tuple(wolf_attack_history), + ) + + +def make_seer_result_update( + *, + npc_id: str, + game_id: str, + seat_no: int, + day: int, + target_seat: int, + target_name: str, + is_wolf: bool, + ts: int, + trace_id: str, +) -> PrivateStateUpdate: + return PrivateStateUpdate( + ts=ts, + trace_id=trace_id, + npc_id=npc_id, + game_id=game_id, + seat_no=seat_no, + update_kind="seer_result", + payload={ + "day": day, + "target_seat": target_seat, + "target_name": target_name, + "is_wolf": is_wolf, + }, + ) + + +def make_medium_result_update( + *, + npc_id: str, + game_id: str, + seat_no: int, + day: int, + target_seat: int, + target_name: str, + is_wolf: bool | None, + ts: int, + trace_id: str, +) -> PrivateStateUpdate: + return PrivateStateUpdate( + ts=ts, + trace_id=trace_id, + npc_id=npc_id, + game_id=game_id, + seat_no=seat_no, + update_kind="medium_result", + payload={ + "day": day, + "target_seat": target_seat, + "target_name": target_name, + "is_wolf": is_wolf, + }, + ) + + +def make_guard_entry_update( + *, + npc_id: str, + game_id: str, + seat_no: int, + day: int, + target_seat: int, + target_name: str, + ts: int, + trace_id: str, +) -> PrivateStateUpdate: + return PrivateStateUpdate( + ts=ts, + trace_id=trace_id, + npc_id=npc_id, + game_id=game_id, + seat_no=seat_no, + update_kind="guard_entry", + payload={ + "day": day, + "target_seat": target_seat, + "target_name": target_name, + }, + ) + + +def make_guard_resolved_update( + *, + npc_id: str, + game_id: str, + seat_no: int, + day: int, + peaceful_morning: bool, + ts: int, + trace_id: str, +) -> PrivateStateUpdate: + return PrivateStateUpdate( + ts=ts, + trace_id=trace_id, + npc_id=npc_id, + game_id=game_id, + seat_no=seat_no, + update_kind="guard_resolved", + payload={ + "day": day, + "peaceful_morning": peaceful_morning, + }, + ) + + +def make_wolf_chat_update( + *, + npc_id: str, + game_id: str, + seat_no: int, + day: int, + speaker_seat: int, + speaker_name: str, + text: str, + ts: int, + trace_id: str, +) -> PrivateStateUpdate: + return PrivateStateUpdate( + ts=ts, + trace_id=trace_id, + npc_id=npc_id, + game_id=game_id, + seat_no=seat_no, + update_kind="wolf_chat", + payload={ + "day": day, + "speaker_seat": speaker_seat, + "speaker_name": speaker_name, + "text": text, + }, + ) + + +def make_alive_changed_update( + *, + npc_id: str, + game_id: str, + seat_no: int, + players: Sequence[Player], + seats: Sequence[Seat], + ts: int, + trace_id: str, +) -> PrivateStateUpdate: + seats_by_no = {s.seat_no: s for s in seats} + return PrivateStateUpdate( + ts=ts, + trace_id=trace_id, + npc_id=npc_id, + game_id=game_id, + seat_no=seat_no, + update_kind="alive_changed", + payload={ + "alive_seats": [ + [pair[0], pair[1]] + for pair in _seat_pairs(players, seats_by_no, alive=True) + ], + "dead_seats": [ + [pair[0], pair[1]] + for pair in _seat_pairs(players, seats_by_no, alive=False) + ], + # Death-cause tag per dead seat. NPC prompt uses this to + # distinguish 「昨日処刑された」 from 「昨夜襲われた」 — without + # it the model regularly conflates yesterday's vote victim + # with last night's attack victim. + "dead_seat_causes": [ + [p.seat_no, p.death_cause.value] + for p in players + if not p.alive and p.death_cause is not None + ], + }, + ) + + +def make_day_advanced_update( + *, + npc_id: str, + game_id: str, + seat_no: int, + day_number: int, + ts: int, + trace_id: str, +) -> PrivateStateUpdate: + return PrivateStateUpdate( + ts=ts, + trace_id=trace_id, + npc_id=npc_id, + game_id=game_id, + seat_no=seat_no, + update_kind="day_advanced", + payload={"day_number": day_number}, + ) + + +__all__ = [ + "build_snapshot_for_seat", + "load_private_state_for_seat", + "make_alive_changed_update", + "make_day_advanced_update", + "make_guard_entry_update", + "make_guard_resolved_update", + "make_medium_result_update", + "make_seer_result_update", + "make_wolf_chat_update", +] diff --git a/src/wolfbot/master/state/text_analyzer.py b/src/wolfbot/master/state/text_analyzer.py new file mode 100644 index 0000000..107b39c --- /dev/null +++ b/src/wolfbot/master/state/text_analyzer.py @@ -0,0 +1,449 @@ +"""Text-channel utterance analyzer — mirrors `GeminiAudioAnalyzer`. + +Voice utterances go through `GeminiAudioAnalyzer` (audio → transcript + +structured fields). Text-channel utterances were previously persisted raw +with no structure, so a human typing a direct address ("〜さん、どう思う" +style) never set `SpeechEvent.addressed_seat_no` and the SpeakArbiter +could not route the reply. + +This module gives the text path the same structured signal: + + TextAnalyzer.analyze(text) → TextAnalysis( + addressed_name: str | None, # 'セツ' / '席3' / None + co_declaration: str | None, # 'seer' / 'medium' / 'knight' / None + ) + +Master then resolves `addressed_name` via the same `resolve_seat_by_name` +helper used by the voice path and passes both fields to +`make_human_text_event(...)`. The downstream pipeline (DiscussionService, +PublicDiscussionState fold, SpeakArbiter picker, LogicPacket summary) is +identical for text and voice — only the analyzer differs. + +Providers: +- ``GeminiTextAnalyzer`` — production. Sends one cheap Gemini call per + message (no audio, ~25-token prompt + the message text). +- ``FakeTextAnalyzer`` — deterministic stub for tests. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +from wolfbot.domain.enums import ( + CO_CLAIM_VALUES, + ROLE_CALLOUT_VALUES, + format_co_claim_options, +) +from wolfbot.llm.template import render_template + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class TextAnalysis: + """Structured analysis of a single text-channel utterance.""" + + addressed_name: str | None = None + co_declaration: str | None = None + # Role the utterance is calling for (e.g. "占い師の方どうぞ" → "seer"). + # None for non-callouts. Mirrors `SttResult.role_callout`. + role_callout: str | None = None + + +class TextAnalyzerError(RuntimeError): + """Raised on hard provider failures (timeout, 5xx, malformed response).""" + + def __init__(self, failure_reason: str) -> None: + super().__init__(failure_reason) + self.failure_reason = failure_reason + + +@runtime_checkable +class TextAnalyzer(Protocol): + """Async analyzer used by `WolfCog.on_message`. Must be cancellable and + must not block the asyncio loop on the network call.""" + + async def analyze(self, *, text: str, timeout_s: float) -> TextAnalysis: ... + + +class FakeTextAnalyzer: + """In-memory analyzer for tests. + + Either return a scripted sequence of results / errors, or a fixed + `default`. Tests can also pass a callable to derive the analysis from + the input text. + """ + + def __init__( + self, + scripted: list[TextAnalysis | Exception] | None = None, + default: TextAnalysis | None = None, + ) -> None: + self._scripted: list[TextAnalysis | Exception] = list(scripted or []) + self._default: TextAnalysis | None = default + self.call_count = 0 + self.last_text: str | None = None + + async def analyze(self, *, text: str, timeout_s: float) -> TextAnalysis: + self.call_count += 1 + self.last_text = text + if self._scripted: + head = self._scripted.pop(0) + if isinstance(head, Exception): + raise head + return head + if self._default is None: + return TextAnalysis() + return self._default + + +class GeminiTextAnalyzer: + """Gemini text-only analyzer. Schema mirrors `GeminiAudioAnalyzer`. + + Uses ``httpx`` lazily (no module-level import) so test environments + without httpx still load this file. Default model is the same cheap + flash-lite used for audio so cost stays bounded. + """ + + _SYSTEM_PROMPT: str = render_template( + "master/text_message_analyzer", + co_claim_options=format_co_claim_options(), + ) + """System prompt body — see prompts/templates/master/text_message_analyzer.md.""" + + def __init__( + self, + *, + api_key: str, + model: str = "gemini-2.0-flash-lite", + api_base: str = "https://generativelanguage.googleapis.com/v1beta", + timeout_s: float = 8.0, + ) -> None: + self.api_key = api_key + self.model = model + self.api_base = api_base.rstrip("/") + self.timeout_s = timeout_s + + async def analyze(self, *, text: str, timeout_s: float) -> TextAnalysis: + import httpx + + from wolfbot.services.llm_trace import ( + CallTimer, + extract_gemini_rest_tokens, + log_llm_call, + ) + + effective_timeout = min(timeout_s, self.timeout_s) + body = { + "contents": [ + { + "parts": [ + {"text": self._SYSTEM_PROMPT}, + {"text": f"発言:\n{text}"}, + ] + } + ], + "generationConfig": { + "responseMimeType": "application/json", + "temperature": 0.0, + }, + } + url = ( + f"{self.api_base}/models/{self.model}:generateContent" + f"?key={self.api_key}" + ) + timer = CallTimer() + raw_text = "" + tokens: dict[str, int | None] | None = None + err: str | None = None + try: + async with httpx.AsyncClient(timeout=effective_timeout) as client: + resp = await client.post(url, json=body) + if resp.status_code != 200: + err = f"gemini_http_{resp.status_code}" + await log_llm_call( + role="text_analysis", + provider="gemini", + model=self.model, + system_prompt=self._SYSTEM_PROMPT, + user_prompt=text, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + ) + raise TextAnalyzerError(err) + resp_json = resp.json() + raw_text = ( + resp_json.get("candidates", [{}])[0] + .get("content", {}) + .get("parts", [{}])[0] + .get("text", "") + ) + tokens = extract_gemini_rest_tokens(resp_json) + parsed = self._parse_response(raw_text) + except TextAnalyzerError: + raise + except httpx.TimeoutException as exc: + err = "gemini_timeout" + await log_llm_call( + role="text_analysis", + provider="gemini", + model=self.model, + system_prompt=self._SYSTEM_PROMPT, + user_prompt=text, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + ) + raise TextAnalyzerError(err) from exc + except httpx.ConnectError as exc: + err = "gemini_connection_refused" + await log_llm_call( + role="text_analysis", + provider="gemini", + model=self.model, + system_prompt=self._SYSTEM_PROMPT, + user_prompt=text, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + ) + raise TextAnalyzerError(err) from exc + except Exception as exc: + err = f"gemini_unexpected_{type(exc).__name__}" + await log_llm_call( + role="text_analysis", + provider="gemini", + model=self.model, + system_prompt=self._SYSTEM_PROMPT, + user_prompt=text, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + ) + raise TextAnalyzerError(err) from exc + + await log_llm_call( + role="text_analysis", + provider="gemini", + model=self.model, + system_prompt=self._SYSTEM_PROMPT, + user_prompt=text, + response=raw_text, + latency_ms=timer.elapsed_ms, + error=None, + tokens=tokens, + ) + + co_raw = parsed.get("co_claim") + co_declaration = co_raw if co_raw in CO_CLAIM_VALUES else None + addressed_raw = parsed.get("addressed_name") + addressed_name: str | None = None + if isinstance(addressed_raw, str): + stripped = addressed_raw.strip() + addressed_name = stripped or None + callout_raw = parsed.get("role_callout") + role_callout = ( + callout_raw if callout_raw in ROLE_CALLOUT_VALUES else None + ) + return TextAnalysis( + addressed_name=addressed_name, + co_declaration=co_declaration, + role_callout=role_callout, + ) + + @staticmethod + def _parse_response(raw: str) -> dict: # type: ignore[type-arg] + text = raw.strip() + if text.startswith("```"): + lines = text.split("\n") + lines = [ln for ln in lines if not ln.strip().startswith("```")] + text = "\n".join(lines).strip() + try: + return json.loads(text) # type: ignore[no-any-return] + except json.JSONDecodeError: + log.warning("gemini_text_json_parse_failed raw=%s", text[:200]) + return {} + + +class OpenAICompatibleTextAnalyzer: + """OpenAI Chat Completions-compatible text analyzer. + + Mirrors :class:`GeminiTextAnalyzer` but POSTs to an OpenAI-compatible + ``/chat/completions`` endpoint (xAI Grok / OpenAI / Together / vLLM + / Ollama / etc.). Used by `main.py` when + ``VOICE_STT_PROVIDER=groq``: in that mode the voice path already + splits STT (Groq Whisper) from the analyzer step (xAI Grok via the + gameplay LLM key), and the text path should follow the same split + instead of round-tripping through Gemini. Without this, a user with + Groq-mode voice still saw their typed messages fail with + ``gemini_http_429`` whenever the shared Gemini key got rate-limited. + + Schema and prompt are identical to :class:`GeminiTextAnalyzer` so + the two providers are interchangeable from the caller's POV. + """ + + _SYSTEM_PROMPT: str = GeminiTextAnalyzer._SYSTEM_PROMPT + + def __init__( + self, + *, + api_key: str, + model: str, + base_url: str = "https://api.x.ai/v1", + timeout_s: float = 8.0, + ) -> None: + self.api_key = api_key + self.model = model + self.base_url = base_url.rstrip("/") + self.timeout_s = timeout_s + + async def analyze(self, *, text: str, timeout_s: float) -> TextAnalysis: + import httpx + + from wolfbot.services.llm_trace import ( + CallTimer, + log_llm_call, + ) + + def _usage_from_dict( + resp_json: dict[str, object], + ) -> dict[str, int | None] | None: + usage = resp_json.get("usage") + if not isinstance(usage, dict): + return None + def _int_or_none(v: object) -> int | None: + return int(v) if isinstance(v, int) else None + return { + "prompt": _int_or_none(usage.get("prompt_tokens")), + "completion": _int_or_none(usage.get("completion_tokens")), + "total": _int_or_none(usage.get("total_tokens")), + } + + effective_timeout = min(timeout_s, self.timeout_s) + body = { + "model": self.model, + "messages": [ + {"role": "system", "content": self._SYSTEM_PROMPT}, + {"role": "user", "content": f"発言:\n{text}"}, + ], + "temperature": 0.0, + "response_format": {"type": "json_object"}, + } + url = f"{self.base_url}/chat/completions" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + timer = CallTimer() + raw_text = "" + tokens: dict[str, int | None] | None = None + err: str | None = None + provider_tag = "openai-compat" + try: + async with httpx.AsyncClient(timeout=effective_timeout) as client: + resp = await client.post(url, json=body, headers=headers) + if resp.status_code != 200: + err = f"openai_http_{resp.status_code}" + await log_llm_call( + role="text_analysis", + provider=provider_tag, + model=self.model, + system_prompt=self._SYSTEM_PROMPT, + user_prompt=text, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + ) + raise TextAnalyzerError(err) + resp_json = resp.json() + raw_text = ( + resp_json.get("choices", [{}])[0] + .get("message", {}) + .get("content", "") + ) or "" + tokens = _usage_from_dict(resp_json) + parsed = GeminiTextAnalyzer._parse_response(raw_text) + except TextAnalyzerError: + raise + except httpx.TimeoutException as exc: + err = "openai_timeout" + await log_llm_call( + role="text_analysis", + provider=provider_tag, + model=self.model, + system_prompt=self._SYSTEM_PROMPT, + user_prompt=text, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + ) + raise TextAnalyzerError(err) from exc + except httpx.ConnectError as exc: + err = "openai_connection_refused" + await log_llm_call( + role="text_analysis", + provider=provider_tag, + model=self.model, + system_prompt=self._SYSTEM_PROMPT, + user_prompt=text, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + ) + raise TextAnalyzerError(err) from exc + except Exception as exc: + err = f"openai_unexpected_{type(exc).__name__}" + await log_llm_call( + role="text_analysis", + provider=provider_tag, + model=self.model, + system_prompt=self._SYSTEM_PROMPT, + user_prompt=text, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + ) + raise TextAnalyzerError(err) from exc + + await log_llm_call( + role="text_analysis", + provider=provider_tag, + model=self.model, + system_prompt=self._SYSTEM_PROMPT, + user_prompt=text, + response=raw_text, + latency_ms=timer.elapsed_ms, + error=None, + tokens=tokens, + ) + + co_raw = parsed.get("co_claim") + co_declaration = co_raw if co_raw in CO_CLAIM_VALUES else None + addressed_raw = parsed.get("addressed_name") + addressed_name: str | None = None + if isinstance(addressed_raw, str): + stripped = addressed_raw.strip() + addressed_name = stripped or None + callout_raw = parsed.get("role_callout") + role_callout = ( + callout_raw if callout_raw in ROLE_CALLOUT_VALUES else None + ) + return TextAnalysis( + addressed_name=addressed_name, + co_declaration=co_declaration, + role_callout=role_callout, + ) + + +__all__ = [ + "FakeTextAnalyzer", + "GeminiTextAnalyzer", + "OpenAICompatibleTextAnalyzer", + "TextAnalysis", + "TextAnalyzer", + "TextAnalyzerError", +] diff --git a/src/wolfbot/master/voice/__init__.py b/src/wolfbot/master/voice/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wolfbot/master/voice/audio_sink.py b/src/wolfbot/master/voice/audio_sink.py new file mode 100644 index 0000000..474992a --- /dev/null +++ b/src/wolfbot/master/voice/audio_sink.py @@ -0,0 +1,87 @@ +"""Bridge between discord-ext-voice-recv and VoiceIngestService. + +``WolfbotAudioSink`` is an :class:`voice_recv.AudioSink` subclass that: + +* Feeds decoded PCM frames into :pymethod:`VoiceIngestService.handle_voice_packet`. +* Uses the library's synthetic ``on_voice_member_speaking_start`` / + ``on_voice_member_speaking_stop`` events as a VAD signal to drive + ``begin_segment`` / ``end_segment``. + +All sink callbacks (``write``, ``on_voice_member_*``) are invoked from an +internal reader thread, so async work is scheduled on the bot's event loop +via :func:`asyncio.run_coroutine_threadsafe`. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +import discord +from discord.ext import voice_recv + +if TYPE_CHECKING: + from wolfbot.master.voice.voice_ingest_service import VoiceIngestService + +log = logging.getLogger(__name__) + + +class WolfbotAudioSink(voice_recv.AudioSink): + """Receives per-user PCM and routes it to VoiceIngestService.""" + + def __init__( + self, + voice_ingest: VoiceIngestService, + loop: asyncio.AbstractEventLoop, + ) -> None: + super().__init__() + self._ingest = voice_ingest + self._loop = loop + + # ---- core sink interface ---- + + def wants_opus(self) -> bool: + return False + + def write(self, user: discord.Member | discord.User | None, data: voice_recv.VoiceData) -> None: + if user is None or data.pcm is None: + return + uid = str(user.id) + asyncio.run_coroutine_threadsafe( + self._ingest.handle_voice_packet( + speaker_user_id=uid, pcm=data.pcm), + self._loop, + ) + + def cleanup(self) -> None: + pass + + # ---- VAD via speaking indicators ---- + + @voice_recv.AudioSink.listener() # type: ignore[untyped-decorator] + def on_voice_member_speaking_start(self, member: discord.Member) -> None: + uid = str(member.id) + # Snapshot the display name now — by the time the coroutine + # runs the member object may have been GC'd or had its nick + # changed, and we want the name as it was when speech began. + display_name = getattr(member, "display_name", None) or getattr( + member, "name", None + ) + asyncio.run_coroutine_threadsafe( + self._ingest.begin_segment( + speaker_user_id=uid, display_name=display_name + ), + self._loop, + ) + + @voice_recv.AudioSink.listener() # type: ignore[untyped-decorator] + def on_voice_member_speaking_stop(self, member: discord.Member) -> None: + uid = str(member.id) + asyncio.run_coroutine_threadsafe( + self._ingest.end_segment(speaker_user_id=uid), + self._loop, + ) + + +__all__ = ["WolfbotAudioSink"] diff --git a/src/wolfbot/master/voice/stt_service.py b/src/wolfbot/master/voice/stt_service.py new file mode 100644 index 0000000..8882876 --- /dev/null +++ b/src/wolfbot/master/voice/stt_service.py @@ -0,0 +1,887 @@ +"""Speech-to-text adapter Protocol + pluggable provider implementations. + +Providers: +- ``GeminiAudioAnalyzer`` — sends raw audio to a cheap Gemini model + (e.g. gemini-2.0-flash-lite) and gets back transcription + structured + analysis (summary, claimed role, vote target, stance) in one API call. + This is the default for voice-ingest because it eliminates a separate + STT + LLM hop. +- ``GeminiSttService`` — skeleton; delegates to a user-supplied callable. +- ``FakeSttService`` — deterministic stub for tests. + +The ``SttService`` Protocol is the injection seam used by +``VoiceIngestService``. Any implementation satisfying the Protocol can be +swapped in via configuration. +""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass +from typing import Any, Protocol, runtime_checkable + +from wolfbot.domain.enums import ( + CO_CLAIM_VALUES, + ROLE_CALLOUT_VALUES, + VILLAGE_SIZE, + format_co_claim_options, +) +from wolfbot.llm.template import render_template + +# (seat_no, display_name) pair used to ground the analyzer's +# ``addressed_name`` extraction in the actual VC roster. Passing this +# at transcribe time lets the LLM resolve STT misspellings (e.g. +# Whisper's "ラッキーオ" for the spoken "ラキオ") to one of the real +# seats instead of returning the literal mistranscription that the +# downstream resolver can't match. +RosterEntry = tuple[int, str] + +log = logging.getLogger(__name__) + + +def _seat_range_label() -> str: + """1-based inclusive seat range derived from :data:`VILLAGE_SIZE`.""" + return f"1〜{VILLAGE_SIZE}" + + +def _coerce_seat_no(value: Any) -> int | None: + """Coerce an analyzer-emitted seat number to ``int`` in [1, VILLAGE_SIZE]. + + Accepts both ``int`` and string-of-digits because some models hand + back numeric strings even when the JSON schema asks for an int. + Returns ``None`` for any out-of-range / non-numeric input rather + than raising — the analyzer is best-effort and the downstream + name-resolver still has the literal ``addressed_name`` to fall + back on. + """ + if value is None: + return None + if isinstance(value, bool): + return None + if isinstance(value, int): + seat = value + elif isinstance(value, str) and value.strip().isdigit(): + seat = int(value.strip()) + else: + return None + return seat if 1 <= seat <= VILLAGE_SIZE else None + + +_ADDRESSED_SEAT_FIELD_INSTRUCTION = ( + "**addressed_seat_no**: addressed_name と同じ人物の席番号(整数 1〜9)、" + "または null。**downstream は addressed_name の文字一致より先に " + "addressed_seat_no を採用する**ので、roster 上の人物に確信があるなら " + "必ず席番号を埋める。確信が無いとき / 全体宛 / 該当無しは null。\n" +) + + +def _format_roster_block(roster: Sequence[RosterEntry] | None) -> str: + """Render the participant roster as a prompt block. + + Empty when ``roster`` is None or empty so the static prompt + sections look identical for callers that don't supply a roster + (tests, voicetest in no-op mode). When non-empty, lists every + seat with its ``display_name`` and instructs the analyzer LLM + to resolve ``addressed_name`` / ``vote_target_seat`` to one of + these canonical entries even when the upstream STT mangles the + spelling - the typical "Whisper rendered 'ラキオ' as 'ラッキーオ'" + failure mode this guard exists to fix. + """ + if not roster: + return "" + lines = "\n".join(f" - 席{seat}: {name}" for seat, name in roster) + return ( + "\n現在の参加者(席番号 → display_name):\n" + f"{lines}\n" + "**addressed_name の表記揺れ正規化**: addressed_name は必ず上の " + "display_name のいずれか(emoji含む完全一致)を返す、または null。" + "STT の書き起こしは表記揺れすることがあるので以下を吸収して最も近い人物に対応付ける:\n" + "- 音韻的に近い表記(例: 「ラッキーオ」「ラキ」「らきお」 → 「🦋ラキオ」)、" + "濁音/半濁音/促音/長音/ヤ行イ行差/カタカナ⇔ひらがなを許容\n" + "- さん/くん/ちゃん/様/氏 等の敬称は剝がして照合\n" + "- 「3番」「席3」のような席番号呼びかけは、その席の display_name に解決\n" + "- 「みんな」「全員」「みなさん」「お前ら」など全体宛は null\n" + "- 候補が決められない場合も null(無理に当てない)\n" + "**vote_target_seat も同じ参加者リストに従い、上の席番号のいずれか**を返す、" + "または null。\n" + + _ADDRESSED_SEAT_FIELD_INSTRUCTION + ) + + +def pcm_to_wav( + pcm: bytes, + *, + sample_rate: int = 48_000, + channels: int = 2, + sample_width: int = 2, +) -> bytes: + """Wrap raw PCM in a minimal RIFF/WAV header. + + Groq's ``audio/transcriptions`` endpoint runs ffmpeg under the hood + and rejects raw PCM with ``HTTP 400`` even when the multipart + ``Content-Type`` claims ``audio/wav``. The fix is to give it a real + WAV file. Defaults match ``discord-ext-voice_recv``'s opus decoder + output (48 kHz, stereo, 16-bit signed little-endian) so callers + feeding straight from :class:`WolfbotAudioSink` need not pass + explicit format kwargs. + """ + import io + import wave + + buf = io.BytesIO() + with wave.open(buf, "wb") as w: + w.setnchannels(channels) + w.setsampwidth(sample_width) + w.setframerate(sample_rate) + w.writeframes(pcm) + return buf.getvalue() + + +@dataclass(frozen=True) +class SttResult: + """Outcome of a single STT call. + + `text` is empty on a low-confidence drop; `confidence` is set so callers + can apply their own threshold check before deciding to emit a SpeechEvent. + Hard provider failures raise SttProviderError instead. + + `summary` is an optional structured analysis of the utterance content, + populated by providers that combine STT + inference in one call (e.g. + GeminiAudioAnalyzer). Providers that only do transcription leave it None. + + `co_declaration` is a structured CO tag (`seer` / `medium` / `knight`) + extracted from the utterance by providers that infer it (Gemini's + `co_claim`). Authoritative when set; otherwise the discussion service + falls back to substring matching on `text` for legacy compatibility. + + `raw_analysis` carries the full parsed JSON dict from the analyzer + LLM (or the multimodal analyzer's structured output). This is + pure debug visibility — production code paths read the typed + fields above. The optional debug dump uses it to surface + ``vote_target_seat``, ``stance``, and any future analyzer fields + in the ``.txt`` sidecar without needing a separate cross-reference + to the JSONL trace. + """ + + text: str + confidence: float + duration_ms: int + summary: str | None = None + co_declaration: str | None = None + addressed_name: str | None = None + # Seat number the analyzer resolved ``addressed_name`` to, when + # the prompt was grounded with a roster. Bypasses the downstream + # ``resolve_seat_by_name`` string match - critical when the live + # VC display name (which the speaker hears and the analyzer is + # told about) differs from ``Seat.display_name`` in the DB + # (which is the persona's canonical handle and the only thing + # the legacy resolver knows). ``None`` when no roster was given + # or the analyzer couldn't pick a seat. + addressed_seat_no: int | None = None + # `role_callout` flags speeches that explicitly call for a specific + # role to come out (e.g. "占い師の方は名乗り出てください"). The + # downstream arbiter / NPC prompt builder uses this so real role + # holders and wolf-side fakers can react. ``None`` for the vast + # majority of utterances. + role_callout: str | None = None + raw_analysis: dict[str, Any] | None = None + + +class SttProviderError(RuntimeError): + """Raised on hard STT failures (timeout, 5xx, malformed response). + + Carries a `failure_reason` matching the canonical voice-ingest enum + (`stt_provider_error`, `stt_timeout`, etc.). + """ + + def __init__(self, failure_reason: str) -> None: + super().__init__(failure_reason) + self.failure_reason = failure_reason + + +@runtime_checkable +class SttService(Protocol): + """Async STT adapter. + + Implementations MUST be cancellable and MUST NOT block the asyncio loop + on the network call (use `asyncio.to_thread` or an async HTTP client). + + ``roster`` is an optional list of ``(seat_no, display_name)`` pairs + grounding the analyzer LLM's ``addressed_name`` / + ``vote_target_seat`` extraction. When supplied, the analyzer maps + misspelled / phonetically-near transcriptions back to a canonical + seat. Implementations should fall back to their static prompt + when ``roster`` is None or empty. + """ + + async def transcribe( + self, + *, + audio: bytes, + language: str, + timeout_s: float, + roster: Sequence[RosterEntry] | None = None, + ) -> SttResult: ... + + +class FakeSttService: + """In-memory STT for tests. + + Either return a scripted sequence of results or raise scripted errors. + Records the most-recently-passed ``roster`` so tests asserting on + prompt-conditioning behavior can verify it without intercepting + HTTP traffic. + """ + + def __init__( + self, + scripted: list[SttResult | Exception] | None = None, + default: SttResult | None = None, + ) -> None: + self._scripted: list[SttResult | Exception] = list(scripted or []) + self._default: SttResult | None = default + self.call_count = 0 + self.last_roster: Sequence[RosterEntry] | None = None + + async def transcribe( + self, + *, + audio: bytes, + language: str, + timeout_s: float, + roster: Sequence[RosterEntry] | None = None, + ) -> SttResult: + self.call_count += 1 + self.last_roster = roster + if self._scripted: + head = self._scripted.pop(0) + if isinstance(head, Exception): + raise head + return head + if self._default is None: + raise SttProviderError("stt_no_script") + return self._default + + +class GeminiSttService: + """Production Gemini API STT adapter. + + The real API call is delegated to a user-supplied `transcribe_fn` so + the actual transport (Google Generative SDK or raw HTTP) is configurable + and the hot path stays test-friendly. This module deliberately does NOT + import the `google.generativeai` SDK at module level so test environments + without those credentials still load this file. + """ + + def __init__( + self, + *, + api_key: str, + model: str, + transcribe_fn: Callable[[bytes, str, str, float], + Awaitable[SttResult]] | None = None, + ) -> None: + self.api_key = api_key + self.model = model + self._transcribe_fn = transcribe_fn + + async def transcribe( + self, + *, + audio: bytes, + language: str, + timeout_s: float, + roster: Sequence[RosterEntry] | None = None, + ) -> SttResult: + if self._transcribe_fn is None: + raise SttProviderError("stt_provider_not_configured") + # ``GeminiSttService`` is a thin transcribe-only delegate; it + # has no analyzer prompt to inject the roster into. Tests that + # care about roster propagation use ``GeminiAudioAnalyzer`` + # instead. Accept the param for Protocol conformance. + del roster + return await self._transcribe_fn(audio, language, self.model, timeout_s) + + +class GeminiAudioAnalyzer: + """Gemini multimodal audio → transcription + structured analysis. + + Sends raw PCM/WAV audio directly to a cheap Gemini model and asks for + both transcription and a structured JSON analysis in a single request. + This replaces separate STT → LLM hops with one API call. + + The structured output includes: + - ``transcript``: verbatim Japanese text + - ``summary``: 1-sentence gist of what the speaker said + - ``confidence``: self-assessed transcription confidence (0.0-1.0) + - ``co_claim``: role CO if any (``seer``/``medium``/``knight``/null) + - ``vote_target_seat``: seat number the speaker wants to execute (null if none) + - ``stance``: dict of seat → trust (``positive``/``negative``/``neutral``) + + Uses ``httpx`` for the Gemini REST API to stay async-native. Model + defaults to ``gemini-2.0-flash-lite`` (cheapest multimodal, ~$0.075/1M + input tokens). Does NOT import ``httpx`` at module level. + """ + + _SYSTEM_PROMPT_TEMPLATE = "master/stt_audio_analyzer" + + @classmethod + def _build_system_prompt( + cls, roster: Sequence[RosterEntry] | None + ) -> str: + """Static template body + (optional) roster grounding block. + + Body lives in `prompts/templates/master/stt_audio_analyzer.md` + with `{{co_claim_options}}` / `{{seat_range_label}}` filled + from the canonical Python constants. The roster block is + appended outside the template (it's a runtime list rendered + per-game) so the template stays static. + """ + return render_template( + cls._SYSTEM_PROMPT_TEMPLATE, + co_claim_options=format_co_claim_options(), + seat_range_label=_seat_range_label(), + ) + _format_roster_block(roster) + + def __init__( + self, + *, + api_key: str, + model: str = "gemini-2.0-flash-lite", + api_base: str = "https://generativelanguage.googleapis.com/v1beta", + timeout_s: float = 15.0, + ) -> None: + self.api_key = api_key + self.model = model + self.api_base = api_base.rstrip("/") + self.timeout_s = timeout_s + + async def transcribe( + self, + *, + audio: bytes, + language: str, + timeout_s: float, + roster: Sequence[RosterEntry] | None = None, + ) -> SttResult: + import base64 + import json + + import httpx + + from wolfbot.services.llm_trace import ( + CallTimer, + extract_gemini_rest_tokens, + log_llm_call, + ) + + audio_b64 = base64.b64encode(audio).decode("ascii") + effective_timeout = min(timeout_s, self.timeout_s) + system_prompt = self._build_system_prompt(roster) + + body = { + "contents": [ + { + "parts": [ + { + "inline_data": { + "mime_type": "audio/wav", + "data": audio_b64, + } + }, + { + "text": system_prompt, + }, + ] + } + ], + "generationConfig": { + "responseMimeType": "application/json", + "temperature": 0.1, + }, + } + + url = ( + f"{self.api_base}/models/{self.model}:generateContent" + f"?key={self.api_key}" + ) + + timer = CallTimer() + raw_text = "" + err: str | None = None + tokens: dict[str, int | None] | None = None + try: + async with httpx.AsyncClient(timeout=effective_timeout) as client: + resp = await client.post(url, json=body) + if resp.status_code != 200: + err = f"gemini_http_{resp.status_code}" + raise SttProviderError(err) + + resp_json = resp.json() + tokens = extract_gemini_rest_tokens(resp_json) + raw_text = ( + resp_json.get("candidates", [{}])[0] + .get("content", {}) + .get("parts", [{}])[0] + .get("text", "") + ) + + parsed = self._parse_response(raw_text) + transcript = parsed.get("transcript", "") + confidence = float(parsed.get("confidence", 0.0)) + + # Build a compact summary JSON from the structured fields + summary_dict = { + k: v + for k, v in parsed.items() + if k not in ("transcript", "confidence") and v is not None and v != {} + } + summary_str = json.dumps( + summary_dict, ensure_ascii=False) if summary_dict else None + + co_raw = parsed.get("co_claim") + co_declaration = ( + co_raw if co_raw in CO_CLAIM_VALUES else None + ) + + addressed_raw = parsed.get("addressed_name") + addressed_name: str | None = None + if isinstance(addressed_raw, str): + stripped = addressed_raw.strip() + addressed_name = stripped or None + addressed_seat_no = _coerce_seat_no(parsed.get("addressed_seat_no")) + + callout_raw = parsed.get("role_callout") + role_callout = ( + callout_raw if callout_raw in ROLE_CALLOUT_VALUES else None + ) + + # Estimate duration from audio size (assume 16kHz 16-bit mono WAV) + data_bytes = max(0, len(audio) - 44) + duration_ms = int(data_bytes / (16_000 * 2) * 1000) + + return SttResult( + text=transcript, + confidence=confidence, + duration_ms=duration_ms, + summary=summary_str, + co_declaration=co_declaration, + addressed_name=addressed_name, + addressed_seat_no=addressed_seat_no, + role_callout=role_callout, + raw_analysis=parsed or None, + ) + + except SttProviderError: + raise + except httpx.TimeoutException as exc: + err = "gemini_timeout" + raise SttProviderError(err) from exc + except httpx.ConnectError as exc: + err = "gemini_connection_refused" + raise SttProviderError(err) from exc + except Exception as exc: + err = f"gemini_unexpected_{type(exc).__name__}" + raise SttProviderError(err) from exc + finally: + await log_llm_call( + role="voice_stt", + provider="gemini", + model=self.model, + system_prompt=system_prompt, + user_prompt=f"[audio bytes={len(audio)} mime=audio/wav]", + response=raw_text or None, + latency_ms=timer.elapsed_ms, + error=err, + tokens=tokens, + extra={"audio_bytes": len(audio), "language": language}, + ) + + @staticmethod + def _parse_response(raw: str) -> dict: # type: ignore[type-arg] + """Best-effort parse of Gemini's JSON response. + + Gemini sometimes wraps the JSON in markdown fences or adds + trailing text. We strip those and try ``json.loads``. + """ + import json + + text = raw.strip() + # Strip markdown code fences + if text.startswith("```"): + lines = text.split("\n") + # Drop first and last fence lines + lines = [ln for ln in lines if not ln.strip().startswith("```")] + text = "\n".join(lines).strip() + try: + return json.loads(text) # type: ignore[no-any-return] + except json.JSONDecodeError: + log.warning("gemini_json_parse_failed raw=%s", text[:200]) + return {"transcript": text, "confidence": 0.3} + + +class GroqWhisperAudioAnalyzer: + """Two-step STT: Groq Whisper (transcribe) → analyzer LLM (extract). + + Groq's free tier on ``whisper-large-v3-turbo`` is generous and the + transport is OpenAI-compatible (``audio/transcriptions`` multipart), + so this slots in next to the existing Gemini analyzer without + changing :class:`VoiceIngestService`. The structured fields the + discussion path expects (``co_claim``, ``vote_target_seat``, + ``addressed_name``, summary) are filled by a second call to a tiny + OpenAI-compatible analyzer (xAI Grok in production) — the same + contract the multimodal Gemini call returns in one hop. + + Both steps are traced under ``role=voice_stt`` so the exporter and + viewer keep working without a schema change. The two trace lines are + distinguished by ``metadata.step`` (``transcribe`` vs ``analyze``). + + Failure semantics: Whisper failure raises ``SttProviderError`` with + a precise reason (timeout / 4xx / 5xx). Analyzer failure is + soft-handled — we still surface the transcript with empty structured + fields, because the discussion path can re-derive CO via legacy + substring matching on the text. This keeps the human-speech signal + flowing even when the analyzer LLM is briefly down. + """ + + _ANALYZER_PROMPT_TEMPLATE = "master/stt_text_analyzer" + + @classmethod + def _build_analyzer_prompt( + cls, roster: Sequence[RosterEntry] | None + ) -> str: + """Static template body + (optional) roster grounding block. + + Body lives in `prompts/templates/master/stt_text_analyzer.md`. + The roster block is appended outside the template (per-game + runtime list) so the template stays static. + """ + return render_template( + cls._ANALYZER_PROMPT_TEMPLATE, + co_claim_options=format_co_claim_options(), + seat_range_label=_seat_range_label(), + ) + _format_roster_block(roster) + + def __init__( + self, + *, + groq_api_key: str, + groq_model: str = "whisper-large-v3-turbo", + groq_base_url: str = "https://api.groq.com/openai/v1", + analyzer_api_key: str, + analyzer_model: str, + analyzer_base_url: str = "https://api.x.ai/v1", + timeout_s: float = 15.0, + # Format of the raw PCM bytes ``transcribe()`` receives. Defaults + # match discord-ext-voice_recv's opus decoder so the typical + # caller (``WolfbotAudioSink`` → ``VoiceIngestService``) needs no + # configuration. Override only when feeding pre-processed audio. + pcm_sample_rate: int = 48_000, + pcm_channels: int = 2, + pcm_sample_width: int = 2, + ) -> None: + self.groq_api_key = groq_api_key + self.groq_model = groq_model + self.groq_base_url = groq_base_url.rstrip("/") + self.analyzer_api_key = analyzer_api_key + self.analyzer_model = analyzer_model + self.analyzer_base_url = analyzer_base_url.rstrip("/") + self.timeout_s = timeout_s + self.pcm_sample_rate = pcm_sample_rate + self.pcm_channels = pcm_channels + self.pcm_sample_width = pcm_sample_width + + async def transcribe( + self, + *, + audio: bytes, + language: str, + timeout_s: float, + roster: Sequence[RosterEntry] | None = None, + ) -> SttResult: + effective_timeout = min(timeout_s, self.timeout_s) + bytes_per_sec = ( + self.pcm_sample_rate * self.pcm_channels * self.pcm_sample_width + ) + duration_ms = ( + int(len(audio) / bytes_per_sec * 1000) if bytes_per_sec else 0 + ) + # Groq's whisper endpoint rejects headerless PCM. Wrap to WAV + # using the configured PCM format so ffmpeg on Groq's side can + # demux the stream. + wav_audio = pcm_to_wav( + audio, + sample_rate=self.pcm_sample_rate, + channels=self.pcm_channels, + sample_width=self.pcm_sample_width, + ) + + transcript, asr_confidence = await self._whisper( + wav_audio, language, effective_timeout + ) + if not transcript: + return SttResult( + text="", + confidence=0.0, + duration_ms=duration_ms, + summary=None, + co_declaration=None, + addressed_name=None, + ) + + analysis = await self._analyze(transcript, effective_timeout, roster=roster) + co_raw = analysis.get("co_claim") + co_decl = co_raw if co_raw in CO_CLAIM_VALUES else None + addressed = analysis.get("addressed_name") + addressed_name = ( + addressed.strip() or None + if isinstance(addressed, str) + else None + ) + callout_raw = analysis.get("role_callout") + role_callout_val = ( + callout_raw if callout_raw in ROLE_CALLOUT_VALUES else None + ) + summary_dict = { + k: v + for k, v in analysis.items() + if k not in ("summary", "confidence") + and v is not None + and v != {} + } + summary_str: str | None = None + if "summary" in analysis and isinstance(analysis["summary"], str): + summary_str = analysis["summary"] + elif summary_dict: + import json as _json + + summary_str = _json.dumps(summary_dict, ensure_ascii=False) + + return SttResult( + text=transcript, + # Use Whisper's ASR confidence (derived from + # ``no_speech_prob``), NOT the analyzer's "claim clarity" + # field — the latter legitimately returns 0.0 for greetings + # and short reactions, which would silently fail the + # ``confidence_threshold`` gate in ``VoiceIngestService`` + # and drop valid speech events. The analyzer's confidence + # was a legacy field from the multimodal Gemini path where + # both signals collapsed into one number. + confidence=asr_confidence, + duration_ms=duration_ms, + summary=summary_str, + co_declaration=co_decl, + addressed_name=addressed_name, + addressed_seat_no=_coerce_seat_no(analysis.get("addressed_seat_no")), + role_callout=role_callout_val, + raw_analysis=analysis or None, + ) + + async def _whisper( + self, audio: bytes, language: str, timeout: float + ) -> tuple[str, float]: + """Step 1: POST audio to Groq's whisper transcription endpoint. + + Returns ``(transcript, asr_confidence)`` where ``asr_confidence`` + is derived from ``verbose_json``'s per-segment ``no_speech_prob`` + (a Whisper internal signal: probability that the segment is + actually silence/noise rather than speech). Aggregating over + segments gives a per-utterance ASR-side confidence that's + independent of the downstream analyzer LLM. ``no_speech_prob`` + is in [0, 1]; we report ``1 - max(no_speech_prob)`` so a single + bad segment lowers confidence appropriately. + """ + import httpx + + from wolfbot.services.llm_trace import ( + CallTimer, + log_llm_call, + ) + + url = f"{self.groq_base_url}/audio/transcriptions" + headers = {"Authorization": f"Bearer {self.groq_api_key}"} + # Whisper accepts BCP-47 like "ja"; map "ja-JP" → "ja". + lang_code = language.split("-")[0] if language else None + files: dict[str, tuple[str, bytes, str] | tuple[None, str]] = { + "file": ("segment.wav", audio, "audio/wav"), + "model": (None, self.groq_model), + # ``verbose_json`` adds ``segments`` with ``no_speech_prob``; + # plain ``json`` only carries ``text``. The size overhead is + # ~1 KB per call, negligible vs the audio upload itself. + "response_format": (None, "verbose_json"), + } + if lang_code: + files["language"] = (None, lang_code) + + timer = CallTimer() + transcript = "" + confidence = 0.0 + err: str | None = None + # Capture the upstream error body so a recurring 400/4xx is + # diagnosable from the trace alone (status code by itself didn't + # tell us "audio decode failed" the first time around). + err_body: str | None = None + tokens: dict[str, int | None] | None = None + try: + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post(url, headers=headers, files=files) + if resp.status_code != 200: + err = f"groq_http_{resp.status_code}" + err_body = resp.text[:1000] if resp.text else None + raise SttProviderError(err) + resp_json = resp.json() + transcript = (resp_json.get("text") or "").strip() + segments = resp_json.get("segments") or [] + # ``no_speech_prob`` may be missing on some segments + # (e.g. when the response shape varies per Groq build); + # fall back to a permissive 1.0 if the field is absent. + if segments: + worst_no_speech = max( + float(s.get("no_speech_prob") or 0.0) for s in segments + ) + confidence = max(0.0, min(1.0, 1.0 - worst_no_speech)) + else: + # No segments in the response (very short audio, or a + # response shape we don't recognize) — fall back to + # treating any returned transcript as confident. + confidence = 1.0 if transcript else 0.0 + # Groq returns an `x_groq.id` etc. but no usage field for + # whisper; leave tokens=None like the openai SDK does. + except SttProviderError: + raise + except httpx.TimeoutException as exc: + err = "groq_timeout" + raise SttProviderError(err) from exc + except httpx.ConnectError as exc: + err = "groq_connection_refused" + raise SttProviderError(err) from exc + except Exception as exc: + err = f"groq_unexpected_{type(exc).__name__}" + raise SttProviderError(err) from exc + finally: + await log_llm_call( + role="voice_stt", + provider="groq", + model=self.groq_model, + system_prompt=None, + user_prompt=f"[audio bytes={len(audio)} mime=audio/wav lang={lang_code}]", + response=transcript if err is None else err_body, + latency_ms=timer.elapsed_ms, + error=err, + tokens=tokens, + extra={ + "audio_bytes": len(audio), + "step": "transcribe", + "asr_confidence": round(confidence, 3) if err is None else None, + }, + ) + return transcript, confidence + + async def _analyze( + self, + transcript: str, + timeout: float, + *, + roster: Sequence[RosterEntry] | None = None, + ) -> dict: # type: ignore[type-arg] + """Step 2: ask the analyzer LLM to extract structured fields. + + Soft-fail: any error returns ``{}`` so the discussion path still + sees the transcript via legacy substring CO matching. The trace + line still records the error so operators can spot a chronic + analyzer outage. + """ + import json + + import httpx + + from wolfbot.services.llm_trace import ( + CallTimer, + extract_openai_tokens, + log_llm_call, + ) + + url = f"{self.analyzer_base_url}/chat/completions" + analyzer_prompt = self._build_analyzer_prompt(roster) + body = { + "model": self.analyzer_model, + "messages": [ + {"role": "system", "content": analyzer_prompt}, + {"role": "user", "content": transcript}, + ], + "response_format": {"type": "json_object"}, + } + headers = { + "Authorization": f"Bearer {self.analyzer_api_key}", + "Content-Type": "application/json", + } + + timer = CallTimer() + raw = "" + err: str | None = None + tokens: dict[str, int | None] | None = None + parsed: dict = {} # type: ignore[type-arg] + try: + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post(url, headers=headers, json=body) + if resp.status_code != 200: + err = f"analyzer_http_{resp.status_code}" + return {} + resp_json = resp.json() + # Extract OpenAI-shaped usage if present. + from types import SimpleNamespace + + usage_raw = resp_json.get("usage") or {} + usage_ns = SimpleNamespace( + prompt_tokens=usage_raw.get("prompt_tokens"), + completion_tokens=usage_raw.get("completion_tokens"), + total_tokens=usage_raw.get("total_tokens"), + ) + tokens = extract_openai_tokens(SimpleNamespace(usage=usage_ns)) + raw = ( + resp_json.get("choices", [{}])[0] + .get("message", {}) + .get("content", "") + or "" + ) + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + err = "analyzer_json_parse_failed" + parsed = {} + return parsed + except httpx.TimeoutException: + err = "analyzer_timeout" + return {} + except httpx.ConnectError: + err = "analyzer_connection_refused" + return {} + except Exception as exc: + err = f"analyzer_unexpected_{type(exc).__name__}" + return {} + finally: + await log_llm_call( + role="voice_stt", + provider="xai", + model=self.analyzer_model, + system_prompt=analyzer_prompt, + user_prompt=transcript, + response=raw or None, + latency_ms=timer.elapsed_ms, + error=err, + tokens=tokens, + extra={"step": "analyze"}, + ) + + +__all__ = [ + "FakeSttService", + "GeminiAudioAnalyzer", + "GeminiSttService", + "GroqWhisperAudioAnalyzer", + "RosterEntry", + "SttProviderError", + "SttResult", + "SttService", +] diff --git a/src/wolfbot/master/voice/tts_playback.py b/src/wolfbot/master/voice/tts_playback.py new file mode 100644 index 0000000..5392f83 --- /dev/null +++ b/src/wolfbot/master/voice/tts_playback.py @@ -0,0 +1,223 @@ +"""Master-side TTS playback — Levi narration via VOICEVOX in VC. + +NPC bots have their own per-process TTS pipeline +(:mod:`wolfbot.npc.audio.tts` + :mod:`wolfbot.npc.audio.playback`). Master needs a +parallel-but-simpler pipeline: synthesize narration text via the same +VOICEVOX HTTP engine, then push the audio through Master's own +`discord.VoiceClient.play(...)` so phase-transition announcements are +heard in the same voice channel the NPCs play in. + +This module is the seam: + + 1. ``MasterTtsPlayback.speak(text)`` synthesizes via VOICEVOX, then + plays the audio through the supplied `discord.VoiceClient`. + 2. ``async with playback.suppress_npc_dispatch(arbiter)`` parks the + SpeakArbiter's serial-speech gate so an NPC doesn't try to talk + over Master mid-narration. Implemented by stuffing a sentinel + request id into ``arbiter._active_playback`` and clearing it when + the with-block exits — no new arbiter API surface needed. + 3. The per-Master speak operations are mutex-serialized so back-to- + back announcements (e.g. EXECUTION followed by PHASE_CHANGE within + a few ms) play in order rather than overlapping at the audio + mixer. + +Deliberately NOT wired through the WS protocol: Master runs in the +same process as the arbiter, so we can call into its private state +without going through ``LogicPacket`` / ``SpeakRequest``. NPCs are +external processes; that's why their pipeline goes via WS. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import io +import logging +import uuid +from collections.abc import AsyncIterator +from typing import Any + +from wolfbot.npc.audio.tts import ( + InMemoryTtsCache, + TtsProviderError, + TtsRequest, + TtsService, +) + +log = logging.getLogger(__name__) + + +class MasterTtsPlayback: + """Synthesize + play Master's Levi narration into VC. + + Holds a back-reference to a ``vc_ref`` list so the live VoiceClient + is read on every speak (the connection may flap across game + boundaries — see :mod:`wolfbot.main` lifecycle helpers). + """ + + _SENTINEL_PREFIX = "master-narration-" + + def __init__( + self, + *, + tts: TtsService, + voice_id: str, + vc_ref: list[Any], + cache: InMemoryTtsCache | None = None, + ) -> None: + self._tts = tts + self._voice_id = voice_id + self._vc_ref = vc_ref + self._cache = cache or InMemoryTtsCache(max_entries=64) + self._lock = asyncio.Lock() + + @property + def voice_id(self) -> str: + return self._voice_id + + async def speak(self, text: str) -> bool: + """Synthesize ``text`` and play it. Returns False on any failure + (TTS error, no VC client, playback error). Failures are logged + but never raise — narration must not crash the engine.""" + text = text.strip() + if not text: + return False + async with self._lock: + req = TtsRequest(text=text, voice_id=self._voice_id) + cached = self._cache.get(req) + try: + if cached is not None: + result = cached + else: + result = await self._tts.synthesize(req) + self._cache.put(req, result) + except TtsProviderError as exc: + log.warning( + "master_tts_synth_failed reason=%s text=%r", + exc.failure_reason, + text[:80], + ) + return False + except Exception: + log.exception( + "master_tts_synth_unexpected text=%r", text[:80] + ) + return False + vc = self._vc_ref[0] + if vc is None or not vc.is_connected(): + log.info("master_tts_skipped reason=vc_not_connected") + return False + return await self._play(vc, result.audio) + + async def _play(self, vc: Any, audio: bytes) -> bool: + # Lazy import keeps test environments without discord.py able + # to import this module for narration unit tests. + import discord + + loop = asyncio.get_running_loop() + done = asyncio.Event() + play_error: list[Exception | None] = [None] + + def _after(error: Exception | None) -> None: + play_error[0] = error + loop.call_soon_threadsafe(done.set) + + try: + # Some versions of discord.VoiceClient reject `play` while a + # previous source is still active. Wait briefly for any prior + # source to finish, but don't block the engine if a prior + # play stalls — release the lock and let narration drop. + for _ in range(50): + if not vc.is_playing(): + break + await asyncio.sleep(0.05) + if vc.is_playing(): + log.info("master_tts_skipped reason=vc_busy") + return False + + source = discord.FFmpegPCMAudio(io.BytesIO(audio), pipe=True) + vc.play(source, after=_after) + except Exception: + log.exception("master_tts_play_failed") + return False + try: + await asyncio.wait_for(done.wait(), timeout=30.0) + except TimeoutError: + log.warning("master_tts_play_timeout") + return False + if play_error[0] is not None: + log.warning("master_tts_play_after_error err=%s", play_error[0]) + return False + return True + + @contextlib.asynccontextmanager + async def suppress_npc_dispatch( + self, arbiter: Any | None + ) -> AsyncIterator[None]: + """Hold the arbiter's serial-speech gate while Master speaks. + + Mutates ``arbiter._active_playback`` directly. We avoid the + public dispatch_request API because Master narration is not a + SpeakResult — there's no NPC bot to authorize, and we don't + want a DB row in `npc_playback_events` for a Master utterance. + + Before adding the sentinel we wait for any in-flight NPC + playback to drain. Otherwise an already-authorized NPC + utterance would play simultaneously with Master narration in + the same VC. The wait is bounded so a stuck NPC playback + (timed-out tts_finished/playback_finished) eventually unblocks + narration via the arbiter's normal expiry sweep. + """ + if arbiter is not None: + await self._wait_for_npc_playback_drain(arbiter) + sentinel: str | None = None + if arbiter is not None: + sentinel = f"{self._SENTINEL_PREFIX}{uuid.uuid4().hex[:8]}" + try: + arbiter._active_playback.add(sentinel) + except AttributeError: + # Defensive: if the arbiter shape ever changes, fall + # back to no-op. Better to risk audio overlap than to + # break narration entirely. + sentinel = None + try: + yield + finally: + if sentinel is not None and arbiter is not None: + with contextlib.suppress(AttributeError): + arbiter._active_playback.discard(sentinel) + + async def _wait_for_npc_playback_drain( + self, + arbiter: Any, + *, + max_wait_s: float = 15.0, + poll_interval_s: float = 0.1, + ) -> None: + """Block until the arbiter's `_active_playback` carries no + non-master entries, or `max_wait_s` elapses. + + Master sentinels (prefixed with ``master-narration-``) are + ignored so concurrent narrations don't deadlock — they're + already serialized by `self._lock`.""" + active: set[str] | None = None + try: + active = arbiter._active_playback + except AttributeError: + return + if active is None: + return + deadline_loops = max(1, int(max_wait_s / poll_interval_s)) + for _ in range(deadline_loops): + others = [ + rid for rid in active if not rid.startswith(self._SENTINEL_PREFIX) + ] + if not others: + return + await asyncio.sleep(poll_interval_s) + log.info( + "master_tts_drain_timeout pending=%d", len(others) + ) + + +__all__ = ["MasterTtsPlayback"] diff --git a/src/wolfbot/master/voice/voice_debug_dump.py b/src/wolfbot/master/voice/voice_debug_dump.py new file mode 100644 index 0000000..eee2099 --- /dev/null +++ b/src/wolfbot/master/voice/voice_debug_dump.py @@ -0,0 +1,221 @@ +"""Optional per-segment audio dump for debugging the voice pipeline. + +When ``WOLFBOT_VOICE_DEBUG_DIR`` is set, every voice segment Master +processes — successful, low-confidence, or hard-failed — is written +to disk so an operator can: + +1. Listen to the raw audio that was sent to Whisper +2. Read the transcript / structured analysis next to it +3. Diagnose hallucinations, dropped segments, mid-segment corruption + events without needing to re-run a game + +Layout:: + + $WOLFBOT_VOICE_DEBUG_DIR/ + {game_id}/ + {speaker_name}/ + seg_{id}.wav # 48 kHz stereo 16-bit (Discord native) + seg_{id}.txt # transcript + metadata, paired with the .wav + +The speaker subdirectory uses ``display_name`` when known so an operator +can browse the dumps grouped by player; falls back to the Discord user +ID when the display_name isn't available. + +Disabled by default — without the env var set, dumping is a no-op so +production deployments can leave the call site in place. File writes +run in a worker thread (``asyncio.to_thread``) so the audio path +never blocks on local disk. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import re +from dataclasses import dataclass +from pathlib import Path + +from wolfbot.master.voice.stt_service import SttResult, pcm_to_wav + +log = logging.getLogger(__name__) + +_SAFE_RE = re.compile(r"[^A-Za-z0-9_-]+") + +# Username sanitizer — preserves unicode (so "🌙 セツ" stays readable) +# and only strips characters that are unsafe on Windows / macOS / Linux +# filesystems or that would create traversal hazards. +_UNSAFE_PATH_CHARS = re.compile(r"[<>:\"/\\|?*\x00-\x1f]+") + + +@dataclass(frozen=True) +class SegmentDumpRecord: + """Everything the dump file needs to render a useful debug page. + + ``transcript`` and ``analysis`` are populated on success / partial + success; ``failure_reason`` carries the canonical voice-ingest + enum (``stt_provider_error`` / ``stt_low_confidence`` / etc.) when + the segment didn't make it through. The two are not mutually + exclusive — a low-confidence segment still has a transcript worth + inspecting. + """ + + game_id: str + phase_id: str + segment_id: str + seat_no: int + speaker_user_id: str + audio_start_ms: int + audio_end_ms: int + pcm_sample_rate: int + pcm_channels: int + pcm_sample_width: int + audio_bytes: int + display_name: str | None = None + result: SttResult | None = None + failure_reason: str | None = None + + +def debug_dir() -> Path | None: + """Directory configured via ``WOLFBOT_VOICE_DEBUG_DIR``, or ``None``.""" + raw = os.environ.get("WOLFBOT_VOICE_DEBUG_DIR") + return Path(raw) if raw else None + + +def _sanitize(s: str) -> str: + """Sanitize a single path component (game_id or segment_id).""" + cleaned = _SAFE_RE.sub("_", s).strip("_") + return cleaned or "x" + + +def _sanitize_username(name: str | None, fallback: str) -> str: + """Sanitize a display_name for use as a directory component. + + Lenient compared to :func:`_sanitize` — preserves Japanese / emoji + so the dump tree stays human-browsable. Replaces only characters + that are illegal on common filesystems or that would enable path + traversal. Falls back to ``fallback`` (typically the Discord user + ID) when the input is empty / None / sanitizes to nothing. + """ + if not name: + return _sanitize(fallback) + # Drop unsafe chars first so traversal segments like "../" can't + # survive even if the rest of the string is harmless. + cleaned = _UNSAFE_PATH_CHARS.sub("_", name) + # Strip leading/trailing dots and whitespace — `.` and ` ` directory + # names are legal but visually confusing and (on Windows) trimmed + # silently by the OS, which would collapse two distinct names. + cleaned = cleaned.strip(" .\t\n\r") + # Defensive: any residual ".." sequences get neutralised. + cleaned = cleaned.replace("..", "_") + # Keep filenames at a sane length — most filesystems cap each + # component at 255 bytes; UTF-8 Japanese is 3 bytes/char. + if len(cleaned.encode("utf-8")) > 80: + cleaned = cleaned[:40] + return cleaned or _sanitize(fallback) + + +def _format_txt(record: SegmentDumpRecord) -> str: + """Build a human-readable .txt sidecar for the matching .wav. + + Plain text rather than JSON so the transcript line is what an + operator sees first when they open the file in Finder/quick-look — + they're typically debugging "did Whisper hear me say X?" and the + answer should not be buried under metadata fields. + + The full analyzer-LLM JSON output is appended at the bottom under + ``analysis (raw):`` so structured fields like ``vote_target_seat`` + and ``stance`` are visible without cross-referencing the JSONL + trace. Pretty-printed with ``ensure_ascii=False`` so Japanese + field values stay readable. + """ + import json + + duration_s = (record.audio_end_ms - record.audio_start_ms) / 1000.0 + lines: list[str] = [] + if record.result is not None and record.result.text: + lines.append(f"transcript: {record.result.text}") + elif record.failure_reason: + lines.append(f"transcript: ") + else: + lines.append("transcript: ") + lines.append("") + lines.append(f"game_id : {record.game_id}") + lines.append(f"phase_id : {record.phase_id}") + lines.append(f"segment_id : {record.segment_id}") + lines.append(f"seat_no : {record.seat_no}") + lines.append(f"speaker_uid : {record.speaker_user_id}") + if record.display_name: + lines.append(f"speaker_name : {record.display_name}") + lines.append(f"audio_window : {record.audio_start_ms} → {record.audio_end_ms} ms ({duration_s:.2f}s)") + lines.append( + f"pcm_format : {record.pcm_sample_rate}Hz " + f"{record.pcm_channels}ch {record.pcm_sample_width * 8}bit" + ) + lines.append(f"audio_bytes : {record.audio_bytes}") + if record.result is not None: + lines.append(f"asr_conf : {record.result.confidence:.3f}") + lines.append(f"duration_ms : {record.result.duration_ms}") + if record.result.summary: + lines.append(f"summary : {record.result.summary}") + if record.result.co_declaration: + lines.append(f"co_declaration: {record.result.co_declaration}") + if record.result.addressed_name: + lines.append(f"addressed_name: {record.result.addressed_name}") + if record.failure_reason and not ( + record.result is not None and record.result.text + ): + lines.append(f"failure_reason: {record.failure_reason}") + if record.result is not None and record.result.raw_analysis: + lines.append("") + lines.append("analysis (raw):") + lines.append( + json.dumps(record.result.raw_analysis, ensure_ascii=False, indent=2) + ) + return "\n".join(lines) + "\n" + + +async def dump_segment(record: SegmentDumpRecord, pcm: bytes) -> None: + """Write ``seg_.wav`` and ``seg_.txt`` to the debug dir. + + No-op when the debug dir env var is unset, so call sites can leave + this in place unconditionally. Write failures are logged and + swallowed — debug dumping must never break the voice path. + """ + base = debug_dir() + if base is None: + return + try: + wav = pcm_to_wav( + pcm, + sample_rate=record.pcm_sample_rate, + channels=record.pcm_channels, + sample_width=record.pcm_sample_width, + ) + txt = _format_txt(record) + speaker_dir = _sanitize_username( + record.display_name, fallback=record.speaker_user_id + ) + target_dir = base / _sanitize(record.game_id) / speaker_dir + seg_stem = _sanitize(record.segment_id) + await asyncio.to_thread(_write_pair, target_dir, seg_stem, wav, txt) + except Exception: + log.exception( + "voice_debug_dump_failed game=%s segment=%s", + record.game_id, + record.segment_id, + ) + + +def _write_pair(target_dir: Path, seg_stem: str, wav: bytes, txt: str) -> None: + """Synchronous file writer — runs inside ``asyncio.to_thread``.""" + target_dir.mkdir(parents=True, exist_ok=True) + (target_dir / f"{seg_stem}.wav").write_bytes(wav) + (target_dir / f"{seg_stem}.txt").write_text(txt, encoding="utf-8") + + +__all__ = [ + "SegmentDumpRecord", + "debug_dir", + "dump_segment", +] diff --git a/src/wolfbot/master/voice/voice_ingest_client.py b/src/wolfbot/master/voice/voice_ingest_client.py new file mode 100644 index 0000000..171fb47 --- /dev/null +++ b/src/wolfbot/master/voice/voice_ingest_client.py @@ -0,0 +1,283 @@ +"""voice-ingest → Master WebSocket client. + +Sends `vad_speech_started` / `vad_speech_ended` / `speech_event_payload` +/ `stt_failed` / `heartbeat` to Master and consumes `registry_snapshot` / +`registry_update`. + +The transport is a `websockets` client connection; tests substitute +`FakeMasterIngestionClient`. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from collections.abc import Awaitable, Callable +from typing import Protocol, runtime_checkable + +from wolfbot.domain.ws_messages import ( + Heartbeat, + SpeechEventPayload, + SttFailed, + VadSpeechEnded, + VadSpeechStarted, +) + +log = logging.getLogger(__name__) + + +@runtime_checkable +class MasterIngestionClient(Protocol): + """The voice-ingest worker's outbound API to Master.""" + + async def send_vad_started(self, msg: VadSpeechStarted) -> None: ... + async def send_vad_ended(self, msg: VadSpeechEnded) -> None: ... + async def send_speech_event_payload( + self, msg: SpeechEventPayload) -> None: ... + + async def send_stt_failed(self, msg: SttFailed) -> None: ... + async def send_heartbeat(self, msg: Heartbeat) -> None: ... + + +@runtime_checkable +class NpcRegistryView(Protocol): + """Read-side view kept locally on the voice-ingest worker. + + Updated by `apply_snapshot` / `apply_update` triggered by Master pushes. + """ + + def is_npc(self, discord_user_id: str) -> bool: ... + + def npc_user_ids(self) -> set[str]: ... + + +class InMemoryNpcRegistryView: + """The default `NpcRegistryView` used by the voice-ingest worker. + + Fail-closed: if no snapshot has arrived yet, the set is empty and all + audio is processed (the Master-side `npc_stt_discarded` guard prevents + any STT->SpeechEvent leakage during this window — see voice-ingest + spec). + """ + + def __init__(self) -> None: + self._ids: set[str] = set() + + def apply_snapshot(self, npc_user_ids: tuple[str, ...]) -> None: + self._ids = set(npc_user_ids) + + def apply_update(self, added: tuple[str, ...], removed: tuple[str, ...]) -> None: + for uid in removed: + self._ids.discard(uid) + for uid in added: + self._ids.add(uid) + + def is_npc(self, discord_user_id: str) -> bool: + return discord_user_id in self._ids + + def npc_user_ids(self) -> set[str]: + return set(self._ids) + + +class FakeMasterIngestionClient: + """Captures every outbound message in-memory for assertion-based tests.""" + + def __init__(self) -> None: + self.vad_started: list[VadSpeechStarted] = [] + self.vad_ended: list[VadSpeechEnded] = [] + self.speech_payloads: list[SpeechEventPayload] = [] + self.stt_failures: list[SttFailed] = [] + self.heartbeats: list[Heartbeat] = [] + + async def send_vad_started(self, msg: VadSpeechStarted) -> None: + self.vad_started.append(msg) + + async def send_vad_ended(self, msg: VadSpeechEnded) -> None: + self.vad_ended.append(msg) + + async def send_speech_event_payload(self, msg: SpeechEventPayload) -> None: + self.speech_payloads.append(msg) + + async def send_stt_failed(self, msg: SttFailed) -> None: + self.stt_failures.append(msg) + + async def send_heartbeat(self, msg: Heartbeat) -> None: + self.heartbeats.append(msg) + + +class DirectMasterIngestionClient: + """In-process bridge: calls Master-side handlers directly. + + Used when voice-ingest is integrated into the Master process instead of + running as a separate WS-connected worker. Each method invokes the same + callback that would be triggered by an inbound WS message, skipping + serialization and network overhead entirely. + + The callbacks are the same handler closures that ``main.py`` wires for + the WS path (``_on_vad_started``, ``_on_speech_payload``, etc.), but + here they're called directly. A thin ``ConnectionContext`` stub is + supplied because the handler signatures still expect one. + """ + + def __init__( + self, + *, + on_vad_started: Callable[[VadSpeechStarted], Awaitable[None]], + on_vad_ended: Callable[[VadSpeechEnded], Awaitable[None]], + on_speech_event_payload: Callable[[SpeechEventPayload], Awaitable[None]], + on_stt_failed: Callable[[SttFailed], Awaitable[None]], + ) -> None: + self._on_vad_started = on_vad_started + self._on_vad_ended = on_vad_ended + self._on_speech_event_payload = on_speech_event_payload + self._on_stt_failed = on_stt_failed + + async def send_vad_started(self, msg: VadSpeechStarted) -> None: + await self._on_vad_started(msg) + + async def send_vad_ended(self, msg: VadSpeechEnded) -> None: + await self._on_vad_ended(msg) + + async def send_speech_event_payload(self, msg: SpeechEventPayload) -> None: + await self._on_speech_event_payload(msg) + + async def send_stt_failed(self, msg: SttFailed) -> None: + await self._on_stt_failed(msg) + + async def send_heartbeat(self, msg: Heartbeat) -> None: + # Heartbeat is a no-op when running in-process — the Master IS + # the voice-ingest, so liveness monitoring is unnecessary. + pass + + +class WebsocketsMasterIngestionClient: + """Production client using the `websockets` library. + + Connects to Master's localhost endpoint with `role=voice-ingest&psk=...` + and serializes outbound messages as JSON. Inbound `registry_snapshot` + / `registry_update` events are dispatched to user-supplied callbacks. + """ + + def __init__( + self, + *, + url: str, + psk: str, + on_registry_snapshot: Callable[[tuple[str, ...]], None], + on_registry_update: Callable[[tuple[str, ...], tuple[str, ...]], None], + ) -> None: + self.url = url + self.psk = psk + self._on_snapshot = on_registry_snapshot + self._on_update = on_registry_update + self._ws: object | None = None + self._reader_task: asyncio.Task[None] | None = None + self._lock = asyncio.Lock() + + async def connect(self) -> None: + import websockets + + sep = "?" if "?" not in self.url else "&" + full = f"{self.url}{sep}role=voice-ingest&psk={self.psk}" + self._ws = await websockets.connect(full) + self._reader_task = asyncio.create_task(self._reader_loop()) + + async def close(self) -> None: + if self._reader_task is not None: + self._reader_task.cancel() + self._reader_task = None + if self._ws is not None: + close = getattr(self._ws, "close", None) + if close is not None: + await close() + self._ws = None + + async def _reader_loop(self) -> None: + ws = self._ws + if ws is None: + return + async for raw in ws: # type: ignore[attr-defined] + if isinstance(raw, bytes): + raw = raw.decode("utf-8", "replace") + try: + payload = json.loads(raw) + except json.JSONDecodeError: + continue + t = payload.get("type") + if t == "registry_snapshot": + self._on_snapshot(tuple(payload.get("npc_user_ids", ()))) + elif t == "registry_update": + self._on_update( + tuple(payload.get("added", ())), + tuple(payload.get("removed", ())), + ) + + async def _send(self, message_json: str) -> None: + ws = self._ws + if ws is None: + return + async with self._lock: + try: + await ws.send(message_json) # type: ignore[attr-defined] + except Exception: + log.exception("voice_ingest_send_failed") + + async def send_vad_started(self, msg: VadSpeechStarted) -> None: + await self._send(msg.model_dump_json()) + + async def send_vad_ended(self, msg: VadSpeechEnded) -> None: + await self._send(msg.model_dump_json()) + + async def send_speech_event_payload(self, msg: SpeechEventPayload) -> None: + await self._send(msg.model_dump_json()) + + async def send_stt_failed(self, msg: SttFailed) -> None: + await self._send(msg.model_dump_json()) + + async def send_heartbeat(self, msg: Heartbeat) -> None: + await self._send(msg.model_dump_json()) + + +# Listener helper used by the registry view during reconnects: ensure the +# view applies a snapshot once and caches deltas thereafter. The functional +# wiring lives in the orchestrator so we keep this module dependency-light. + +ListenerFactory = Callable[ + [InMemoryNpcRegistryView], + tuple[ + Callable[[tuple[str, ...]], None], + Callable[[tuple[str, ...], tuple[str, ...]], None], + ], +] + + +def make_default_listeners( + view: InMemoryNpcRegistryView, +) -> tuple[ + Callable[[tuple[str, ...]], None], + Callable[[tuple[str, ...], tuple[str, ...]], None], +]: + def on_snapshot(npc_user_ids: tuple[str, ...]) -> None: + view.apply_snapshot(npc_user_ids) + + def on_update(added: tuple[str, ...], removed: tuple[str, ...]) -> None: + view.apply_update(added, removed) + + return on_snapshot, on_update + + +__all__ = [ + "DirectMasterIngestionClient", + "FakeMasterIngestionClient", + "InMemoryNpcRegistryView", + "ListenerFactory", + "MasterIngestionClient", + "NpcRegistryView", + "WebsocketsMasterIngestionClient", + "make_default_listeners", +] + + +# Re-bind Awaitable so type checkers keep the import. +_ = Awaitable diff --git a/src/wolfbot/master/voice/voice_ingest_service.py b/src/wolfbot/master/voice/voice_ingest_service.py new file mode 100644 index 0000000..4281ed8 --- /dev/null +++ b/src/wolfbot/master/voice/voice_ingest_service.py @@ -0,0 +1,553 @@ +"""voice-ingest worker — VAD + STT + Master ingestion. + +The worker pipeline: + +1. ``handle_voice_packet`` is called for every Discord-side voice packet. + - If the speaker's `discord_user_id` is in the NPC registry view, the + packet is discarded at the receive boundary (no VAD, no STT). +2. Otherwise the packet feeds a per-speaker buffer. ``begin_segment`` / + ``end_segment`` correspond to the VAD lifecycle. ``begin_segment`` + sends `vad_speech_started` to Master and assigns a `segment_id`; + ``end_segment`` sends `vad_speech_ended` and triggers async STT. +3. STT result handling: + - Below `confidence_threshold` → drop, log `stt_low_confidence`, + send `stt_failed` to Master with `failure_reason=stt_low_confidence`. + - Hard `SttProviderError` → drop, log `stt_request_failed`, + send `stt_failed` with the provider's failure_reason. + - Otherwise → send `speech_event_payload` to Master. + +VAD itself is provided by an injected `VadEngine` Protocol so unit tests +can replay scripted VAD transitions without driving real audio. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +import uuid +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable + +from wolfbot.domain.enums import CO_CLAIM_VALUES, ROLE_CALLOUT_VALUES +from wolfbot.domain.ws_messages import ( + Heartbeat, + SpeechEventPayload, + SttFailed, + VadSpeechEnded, + VadSpeechStarted, +) +from wolfbot.master.voice.stt_service import ( + RosterEntry, + SttProviderError, + SttResult, + SttService, +) +from wolfbot.master.voice.voice_ingest_client import ( + MasterIngestionClient, + NpcRegistryView, +) + +log = logging.getLogger(__name__) + + +@dataclass +class VoiceIngestConfig: + confidence_threshold: float = 0.6 + stt_timeout_s: float = 10.0 + stt_language: str = "ja-JP" + vad_finalization_timeout_ms: int = 4000 + heartbeat_interval_s: float = 5.0 + # PCM format of bytes accumulated in ``_OpenSegment.audio_buffer``. + # Defaults match discord-ext-voice_recv's opus decoder output and + # are surfaced here so the optional debug dump produces a playable + # WAV without round-tripping through STT-provider config. + pcm_sample_rate: int = 48_000 + pcm_channels: int = 2 + pcm_sample_width: int = 2 + # Pre-STT silence gate. Discord's speaking-start fires on any audio + # above a low threshold (breathing, keyboard, room hum) and the + # SilenceGeneratorSink keeps the segment open; without this gate + # every such non-speech burst hits the STT API. Both thresholds + # default to 0 (disabled) so existing tests / callers see the same + # behavior; production wiring opts in via MasterSettings. + # ``pre_stt_min_rms`` is a 16-bit signed RMS computed across the + # full segment buffer - speech is typically 1000-5000, breathing + # / typing 100-500, true silence 0-100. + pre_stt_min_rms: int = 0 + # ``pre_stt_min_duration_ms`` rejects sub-syllable bursts triggered + # by the silence-padding playback or single mouse clicks. Real + # utterances are ≥300ms. + pre_stt_min_duration_ms: int = 0 + + +@dataclass +class _OpenSegment: + """Per-speaker open VAD segment, awaiting ``end_segment``.""" + + segment_id: str + seat_no: int + speaker_user_id: str + audio_start_ms: int + display_name: str | None = None + audio_buffer: bytearray = field(default_factory=bytearray) + + +@runtime_checkable +class VadEngine(Protocol): + """VAD lifecycle events generator. Production VAD is webrtc-based; tests + drive the lifecycle manually.""" + + def push_pcm(self, *, speaker_user_id: str, pcm: bytes) -> tuple[str | None, bytes | None]: + """Push a PCM frame; return (event, audio) where event ∈ + {None, "started", "ended"} and audio is the buffered audio when + the segment ends.""" + ... + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _pcm_rms_s16le(pcm: bytes) -> int: + """Root-mean-square magnitude across all 16-bit signed LE samples. + + Returns 0 for empty / odd-length buffers. ~20ms for a 2-second + 48kHz stereo segment in pure Python — negligible vs the STT + network round-trip we're potentially saving. + """ + n = len(pcm) // 2 + if n == 0: + return 0 + import struct + + samples = struct.unpack(f"<{n}h", pcm[: n * 2]) + sq = 0 + for s in samples: + sq += s * s + return int((sq / n) ** 0.5) + + +class VoiceIngestService: + """Single-process voice-ingest orchestrator. + + Has no Discord-specific code itself — wire it from the worker entrypoint + that subscribes to discord.VoiceClient events. The Discord packet shape + feeds ``handle_voice_packet``; the VAD lifecycle is driven by + ``begin_segment`` / ``end_segment`` (or by an injected VAD engine). + """ + + def __init__( + self, + *, + registry_view: NpcRegistryView, + master_client: MasterIngestionClient, + stt: SttService, + seat_lookup: Callable[[str], int | None], + phase_lookup: Callable[[], tuple[str, str] | None], + config: VoiceIngestConfig | None = None, + now_ms: Callable[[], int] = _now_ms, + roster_lookup: Callable[[], list[RosterEntry]] | None = None, + ) -> None: + self.registry_view = registry_view + self.master_client = master_client + self.stt = stt + self.seat_lookup = seat_lookup + # phase_lookup returns (game_id, phase_id) for the current discussion phase, or None. + self.phase_lookup = phase_lookup + # roster_lookup returns the current alive seat list as + # (seat_no, display_name) so the analyzer LLM can resolve + # mistranscribed names to a canonical participant. Optional + # because tests / voicetest in no-op mode have nothing useful + # to ground against. + self.roster_lookup = roster_lookup + self.config = config or VoiceIngestConfig() + self._now_ms = now_ms + self._open_segments: dict[str, _OpenSegment] = {} + self.dropped_npc_packets = 0 + self.stt_low_confidence_count = 0 + self.stt_provider_error_count = 0 + # Pre-STT silence gate — counts segments suppressed before + # the STT call so an operator can spot a misconfigured + # threshold (e.g. silenced everything → no Whisper traffic). + self.pre_stt_silence_gated_count = 0 + + # ---------------------------------------------------------- packet boundary + + async def handle_voice_packet(self, *, speaker_user_id: str, pcm: bytes) -> bool: + """Returns True if the packet was forwarded to VAD, False if dropped.""" + if self.registry_view.is_npc(speaker_user_id): + self.dropped_npc_packets += 1 + return False + # Append to whichever segment is open for this speaker (if any). + seg = self._open_segments.get(speaker_user_id) + if seg is not None: + seg.audio_buffer.extend(pcm) + return True + + # ---------------------------------------------------------- VAD lifecycle + + async def begin_segment( + self, + *, + speaker_user_id: str, + display_name: str | None = None, + ) -> str | None: + """Open a VAD window for `speaker_user_id` and notify Master. + + ``display_name`` is the speaker's Discord display name as known + at VAD-start time. It's stored on the segment so the optional + debug dump can group files per-speaker by name. Optional — + unit tests typically don't pass it. + + Returns the new `segment_id`, or None if a session is not active + (no game phase / unknown seat). + """ + if self.registry_view.is_npc(speaker_user_id): + return None + phase = self.phase_lookup() + if phase is None: + return None + game_id, phase_id = phase + seat_no = self.seat_lookup(speaker_user_id) + if seat_no is None: + return None + segment_id = f"seg_{uuid.uuid4().hex[:12]}" + now = self._now_ms() + self._open_segments[speaker_user_id] = _OpenSegment( + segment_id=segment_id, + seat_no=seat_no, + speaker_user_id=speaker_user_id, + audio_start_ms=now, + display_name=display_name, + ) + msg = VadSpeechStarted( + ts=now, + trace_id=f"vi-{segment_id}", + game_id=game_id, + phase_id=phase_id, + speaker_discord_user_id=speaker_user_id, + seat_no=seat_no, + segment_id=segment_id, + audio_start_ms=now, + ) + await self.master_client.send_vad_started(msg) + return segment_id + + async def end_segment(self, *, speaker_user_id: str) -> None: + seg = self._open_segments.pop(speaker_user_id, None) + if seg is None: + return + phase = self.phase_lookup() + if phase is None: + return + game_id, phase_id = phase + now = self._now_ms() + await self.master_client.send_vad_ended( + VadSpeechEnded( + ts=now, + trace_id=f"vi-{seg.segment_id}", + game_id=game_id, + phase_id=phase_id, + speaker_discord_user_id=seg.speaker_user_id, + seat_no=seg.seat_no, + segment_id=seg.segment_id, + audio_end_ms=now, + ) + ) + await self._run_stt(seg, game_id, phase_id, audio_end_ms=now) + + async def abandon_open_segments(self) -> int: + """Restart-time cleanup. Returns count of abandoned segments.""" + n = len(self._open_segments) + self._open_segments.clear() + if n: + log.info("voice_ingest_restart abandoned_segments=%d", n) + return n + + # ---------------------------------------------------------- STT pipeline + + async def _run_stt( + self, + seg: _OpenSegment, + game_id: str, + phase_id: str, + *, + audio_end_ms: int, + ) -> None: + from wolfbot.services.llm_trace import ( + parse_day_from_phase_id, + trace_context, + ) + + actor = ( + f"speaker_user_id={seg.speaker_user_id} seat={seg.seat_no} " + f"segment={seg.segment_id}" + ) + with trace_context( + game_id=game_id, + phase=phase_id, + day=parse_day_from_phase_id(phase_id), + actor=actor, + metadata={ + "segment_id": seg.segment_id, + "audio_end_ms": audio_end_ms, + }, + ): + await self._run_stt_inner(seg, game_id, phase_id, audio_end_ms=audio_end_ms) + + async def _run_stt_inner( + self, + seg: _OpenSegment, + game_id: str, + phase_id: str, + *, + audio_end_ms: int, + ) -> None: + from wolfbot.master.voice.voice_debug_dump import ( + SegmentDumpRecord, + debug_dir, + dump_segment, + ) + + # Snapshot the audio bytes once. The buffer is later truncated by + # ``end_segment`` cleanup; capturing here keeps the debug dump + # aligned with what was actually sent to Whisper. + pcm_snapshot = bytes(seg.audio_buffer) + dump_enabled = debug_dir() is not None + + def _build_dump( + *, + result: SttResult | None, + failure_reason: str | None, + ) -> SegmentDumpRecord: + return SegmentDumpRecord( + game_id=game_id, + phase_id=phase_id, + segment_id=seg.segment_id, + seat_no=seg.seat_no, + speaker_user_id=seg.speaker_user_id, + audio_start_ms=seg.audio_start_ms, + audio_end_ms=audio_end_ms, + pcm_sample_rate=self.config.pcm_sample_rate, + pcm_channels=self.config.pcm_channels, + pcm_sample_width=self.config.pcm_sample_width, + audio_bytes=len(pcm_snapshot), + display_name=seg.display_name, + result=result, + failure_reason=failure_reason, + ) + + # Pre-STT silence gate. Skip the network round-trip when the + # buffer is too short or too quiet to plausibly contain + # speech — Discord's speaking-start fires on breathing / + # keyboard / hum, and without this every such non-speech + # burst would burn one Groq + one xAI analyzer call. + if ( + self.config.pre_stt_min_rms > 0 + or self.config.pre_stt_min_duration_ms > 0 + ): + bytes_per_ms = ( + self.config.pcm_sample_rate + * self.config.pcm_channels + * self.config.pcm_sample_width + // 1000 + ) + duration_ms = ( + len(pcm_snapshot) // bytes_per_ms if bytes_per_ms else 0 + ) + need_rms_check = self.config.pre_stt_min_rms > 0 + rms = _pcm_rms_s16le(pcm_snapshot) if need_rms_check else 0 + too_short = duration_ms < self.config.pre_stt_min_duration_ms + too_quiet = need_rms_check and rms < self.config.pre_stt_min_rms + if too_short or too_quiet: + self.pre_stt_silence_gated_count += 1 + log.info( + "stt_pre_silence_gated game=%s segment=%s " + "duration_ms=%d rms=%d reason=%s", + game_id, + seg.segment_id, + duration_ms, + rms, + "too_short" if too_short else "below_rms", + ) + if dump_enabled: + await dump_segment( + _build_dump( + result=None, + failure_reason="pre_stt_silence_gate", + ), + pcm_snapshot, + ) + await self.master_client.send_stt_failed( + SttFailed( + ts=self._now_ms(), + trace_id=f"vi-{seg.segment_id}", + game_id=game_id, + phase_id=phase_id, + speaker_discord_user_id=seg.speaker_user_id, + seat_no=seg.seat_no, + segment_id=seg.segment_id, + failure_reason="pre_stt_silence_gate", + ) + ) + return + + roster: list[RosterEntry] | None = None + if self.roster_lookup is not None: + try: + roster = list(self.roster_lookup()) + except Exception: + # Roster fetch is a soft dependency on game state; + # never let it abort the STT call. Log and proceed + # with the legacy un-grounded prompt. + log.warning( + "voice_roster_lookup_failed game=%s segment=%s", + game_id, + seg.segment_id, + exc_info=True, + ) + + try: + result: SttResult = await self.stt.transcribe( + audio=pcm_snapshot, + language=self.config.stt_language, + timeout_s=self.config.stt_timeout_s, + roster=roster, + ) + except SttProviderError as exc: + self.stt_provider_error_count += 1 + log.info( + "stt_request_failed game=%s phase=%s segment=%s reason=%s", + game_id, + phase_id, + seg.segment_id, + exc.failure_reason, + ) + if dump_enabled: + await dump_segment( + _build_dump(result=None, failure_reason=exc.failure_reason), + pcm_snapshot, + ) + await self.master_client.send_stt_failed( + SttFailed( + ts=self._now_ms(), + trace_id=f"vi-{seg.segment_id}", + game_id=game_id, + phase_id=phase_id, + speaker_discord_user_id=seg.speaker_user_id, + seat_no=seg.seat_no, + segment_id=seg.segment_id, + failure_reason=exc.failure_reason, + ) + ) + return + except Exception: + log.exception( + "stt_request_failed_unexpected game=%s segment=%s", + game_id, + seg.segment_id, + ) + if dump_enabled: + await dump_segment( + _build_dump(result=None, failure_reason="stt_provider_error"), + pcm_snapshot, + ) + await self.master_client.send_stt_failed( + SttFailed( + ts=self._now_ms(), + trace_id=f"vi-{seg.segment_id}", + game_id=game_id, + phase_id=phase_id, + speaker_discord_user_id=seg.speaker_user_id, + seat_no=seg.seat_no, + segment_id=seg.segment_id, + failure_reason="stt_provider_error", + ) + ) + return + + if result.confidence < self.config.confidence_threshold: + self.stt_low_confidence_count += 1 + log.info( + "stt_low_confidence game=%s segment=%s conf=%.2f", + game_id, + seg.segment_id, + result.confidence, + ) + if dump_enabled: + await dump_segment( + _build_dump(result=result, failure_reason="stt_low_confidence"), + pcm_snapshot, + ) + await self.master_client.send_stt_failed( + SttFailed( + ts=self._now_ms(), + trace_id=f"vi-{seg.segment_id}", + game_id=game_id, + phase_id=phase_id, + speaker_discord_user_id=seg.speaker_user_id, + seat_no=seg.seat_no, + segment_id=seg.segment_id, + failure_reason="stt_low_confidence", + ) + ) + return + + co_decl = ( + result.co_declaration + if result.co_declaration in CO_CLAIM_VALUES + else None + ) + role_callout = ( + result.role_callout + if result.role_callout in ROLE_CALLOUT_VALUES + else None + ) + if dump_enabled: + await dump_segment( + _build_dump(result=result, failure_reason=None), pcm_snapshot + ) + await self.master_client.send_speech_event_payload( + SpeechEventPayload( + ts=self._now_ms(), + trace_id=f"vi-{seg.segment_id}", + game_id=game_id, + phase_id=phase_id, + seat_no=seg.seat_no, + speaker_discord_user_id=seg.speaker_user_id, + segment_id=seg.segment_id, + text=result.text, + confidence=result.confidence, + duration_ms=result.duration_ms, + audio_start_ms=seg.audio_start_ms, + audio_end_ms=audio_end_ms, + summary=result.summary, + co_declaration=co_decl, # type: ignore[arg-type] + addressed_name=result.addressed_name, + addressed_seat_no=result.addressed_seat_no, + role_callout=role_callout, # type: ignore[arg-type] + ) + ) + + # ---------------------------------------------------------- heartbeat + + async def heartbeat_loop(self, stop_event: asyncio.Event) -> None: + while not stop_event.is_set(): + await self.master_client.send_heartbeat(Heartbeat(ts=self._now_ms(), trace_id="vi-hb")) + try: + await asyncio.wait_for(stop_event.wait(), timeout=self.config.heartbeat_interval_s) + except TimeoutError: + continue + + +__all__ = [ + "VadEngine", + "VoiceIngestConfig", + "VoiceIngestService", +] + + +# keep imports referenced +_ = (Awaitable, Callable) diff --git a/src/wolfbot/master/voice/voice_recv_dave_patch.py b/src/wolfbot/master/voice/voice_recv_dave_patch.py new file mode 100644 index 0000000..30d7ffc --- /dev/null +++ b/src/wolfbot/master/voice/voice_recv_dave_patch.py @@ -0,0 +1,152 @@ +"""Add DAVE (E2EE voice) inner-decrypt to ``discord-ext-voice-recv``. + +discord.py 2.7.1 negotiates DAVE per voice connection and drives the +MLS state machine (``DaveSession``) via the voice gateway opcodes +25-31. discord-ext-voice-recv 0.5.2a179 only handles the *outer* AEAD +layer - when DAVE is active, the AEAD-decrypted bytes are still the +MLS-encrypted opus payload. The opus decoder then sees ciphertext and +fails with ``OpusError("corrupted stream")`` for every packet, which +shows up as a flood of ``voice_recv_opus_decode_skip`` warnings (the +:func:`apply_packet_router_resilience` patch keeps the RX thread +alive but cannot recover the audio). + +Discord rolled DAVE out as **mandatory** on many channels, so the +client cannot opt out by advertising ``max_dave_protocol_version=0`` +(the voice gateway responds with close code 4017 and won't accept +the connection). The only way to keep recording is to layer the +DAVE decrypt step on top of voice_recv's pipeline ourselves. + +The implementation mirrors what receive-side recorders in other +ecosystems do (e.g. Craig on Eris): keep the upstream AEAD outer +decrypt as-is, then call into the active ``DaveSession`` to peel +off the MLS inner layer when the speaker is not in passthrough. + +This module monkey-patches :class:`discord.ext.voice_recv.reader.AudioReader` +so that, immediately after the upstream AEAD decrypt returns, an +extra :meth:`davey.DaveSession.decrypt` call replaces the buffer with +the inner-decrypted opus. We use the ``davey`` Python bindings that +discord.py already depends on, and the per-voice-client ``DaveSession`` +that discord.py keeps in ``voice_client._connection.dave_session`` — +no extra protocol implementation needed on our side. + +Apply via :func:`apply_dave_decrypt_patch` once at startup. Idempotent. +""" + +from __future__ import annotations + +import logging + +log = logging.getLogger(__name__) + +_PATCH_MARKER = "_wolfbot_dave_decrypt_patched" + + +def apply_dave_decrypt_patch() -> None: + """Wrap ``AudioReader.decryptor.decrypt_rtp`` to add DAVE inner decrypt. + + Idempotent. Logs a single info line on first apply so an operator + can confirm the patch fired before the bot connects to voice. + """ + try: + import davey + except ImportError: + log.info( + "voice_recv_dave_patch_skipped reason=davey_not_installed" + ) + return + + from discord.ext.voice_recv.reader import AudioReader + + if getattr(AudioReader, _PATCH_MARKER, False): + return + + original_init = AudioReader.__init__ + + def patched_init(self, sink, voice_client, *args, **kwargs): # type: ignore[no-untyped-def] + original_init(self, sink, voice_client, *args, **kwargs) + _attach_dave_wrapper(self, voice_client) + + original_update_secret_key = AudioReader.update_secret_key + + def patched_update_secret_key(self, secret_key): # type: ignore[no-untyped-def] + # update_secret_key rebuilds the AEAD ``box`` on the upstream + # decryptor but doesn't rebind ``decrypt_rtp``. Our wrapper + # closes over the original bound method, so it survives the + # key rotation - but we still re-attach defensively in case + # upstream ever rebuilds the bound method too. + original_update_secret_key(self, secret_key) + _attach_dave_wrapper(self, self.voice_client) + + AudioReader.__init__ = patched_init # type: ignore[method-assign] + AudioReader.update_secret_key = patched_update_secret_key # type: ignore[method-assign] + setattr(AudioReader, _PATCH_MARKER, True) + log.info( + "voice_recv_dave_decrypt_patch_applied protocol_version=%s", + davey.DAVE_PROTOCOL_VERSION, + ) + + +def _attach_dave_wrapper(reader, voice_client) -> None: # type: ignore[no-untyped-def] + """Install the DAVE-aware ``decrypt_rtp`` wrapper on the decryptor.""" + import davey + + decryptor = reader.decryptor + # Don't double-wrap if update_secret_key fires repeatedly during + # an MLS rotation while a previous wrapper is still in place. + if getattr(decryptor, _PATCH_MARKER, False): + return + + original_decrypt_rtp = decryptor.decrypt_rtp + + def dave_aware_decrypt_rtp(packet): # type: ignore[no-untyped-def] + data = original_decrypt_rtp(packet) + state = getattr(voice_client, "_connection", None) + session = getattr(state, "dave_session", None) + if session is None or not getattr(session, "ready", False): + return data + + ssrc = getattr(packet, "ssrc", None) + if ssrc is None: + return data + ssrc_map = getattr(voice_client, "_ssrc_to_id", None) or {} + user_id = ssrc_map.get(ssrc) + if user_id is None: + # First packet for this SSRC — we don't yet know which + # user it belongs to. Skip DAVE decrypt; the reader will + # drop unknown-SSRC packets anyway, and once the voice + # gateway sends the SSRC→user binding, subsequent packets + # decode normally. + return data + + try: + if session.can_passthrough(user_id): + return data + except Exception: + log.debug( + "dave_can_passthrough_check_failed ssrc=%s user=%s", + ssrc, + user_id, + exc_info=True, + ) + return data + + try: + return session.decrypt(user_id, davey.MediaType.audio, data) + except Exception: + # Emit at debug level — a flood of decrypt failures during + # an MLS transition is expected and would otherwise drown + # the log. The packet_router_resilience patch will + # downgrade the resulting OpusError to a single line. + log.debug( + "dave_decrypt_failed ssrc=%s user=%s", + ssrc, + user_id, + exc_info=True, + ) + return data + + decryptor.decrypt_rtp = dave_aware_decrypt_rtp + setattr(decryptor, _PATCH_MARKER, True) + + +__all__ = ["apply_dave_decrypt_patch"] diff --git a/src/wolfbot/master/voice/voice_recv_resilience.py b/src/wolfbot/master/voice/voice_recv_resilience.py new file mode 100644 index 0000000..9eed347 --- /dev/null +++ b/src/wolfbot/master/voice/voice_recv_resilience.py @@ -0,0 +1,82 @@ +"""Make ``discord.ext.voice_recv``'s ``PacketRouter`` survive single-packet +opus decode failures. + +Upstream's :meth:`PacketRouter._do_run` lets any exception from +``decoder.pop_data()`` propagate out of the inner ``for`` loop, which then +escapes ``run()``'s outer ``try``, logs once, sets ``reader.error``, and +calls ``voice_client.stop_listening()`` in ``finally``. After a single +``discord.opus.OpusError("corrupted stream")``, the entire RX path is +dead — Master can no longer hear humans, no STT runs, and the reactive +voice arbiter never sees a ``speech_event`` to dispatch NPCs against. + +The corruption is per-packet (or at worst per-SSRC). Catching +``OpusError`` inside the inner loop keeps the thread alive and lets the +next valid packet decode normally. Sink write failures get the same +treatment so a bug in one ``sink.write`` callback never kills the +reader thread. + +Apply once at startup via :func:`apply_packet_router_resilience` before +``voice_recv.VoiceRecvClient`` is connected. The patch is idempotent. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from discord.ext.voice_recv.router import PacketRouter + +log = logging.getLogger(__name__) + +_PATCH_MARKER = "_wolfbot_packet_router_resilient" + + +def apply_packet_router_resilience() -> None: + """Replace ``PacketRouter._do_run`` with a per-iteration try/except. + + Idempotent — calling more than once is a no-op so import-time wiring + plus an explicit call from ``main.py`` is safe. + """ + from discord.ext.voice_recv.router import PacketRouter + + if getattr(PacketRouter, _PATCH_MARKER, False): + return + + def _do_run_resilient(self: PacketRouter) -> None: + from discord.opus import OpusError + + while not self._end_thread.is_set(): + self.waiter.wait() + with self._lock: + for decoder in self.waiter.items: + ssrc = getattr(decoder, "ssrc", None) + try: + data = decoder.pop_data() + except OpusError as exc: + log.warning( + "voice_recv_opus_decode_skip ssrc=%s err=%s", + ssrc, + exc, + ) + continue + except Exception: + log.exception( + "voice_recv_pop_data_failed ssrc=%s", ssrc + ) + continue + if data is None: + continue + try: + self.sink.write(data.source, data) + except Exception: + log.exception( + "voice_recv_sink_write_failed ssrc=%s", ssrc + ) + + PacketRouter._do_run = _do_run_resilient # type: ignore[method-assign] + setattr(PacketRouter, _PATCH_MARKER, True) + log.info("voice_recv_packet_router_resilience_applied") + + +__all__ = ["apply_packet_router_resilience"] diff --git a/src/wolfbot/master/ws/__init__.py b/src/wolfbot/master/ws/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wolfbot/master/ws/decision_dispatcher.py b/src/wolfbot/master/ws/decision_dispatcher.py new file mode 100644 index 0000000..b8641de --- /dev/null +++ b/src/wolfbot/master/ws/decision_dispatcher.py @@ -0,0 +1,774 @@ +"""Master-side fan-out of vote / night-action decision requests to NPC bots. + +Phase-D: in `reactive_voice` mode each NPC bot is the embodied agent for +its seat and decides votes / night actions via its own `NPC_LLM_*`. This +dispatcher: + +1. Builds a `DecideVoteRequest` (or `DecideNightActionRequest`) per LLM + seat, sends it over WS, and parks an `asyncio.Future` per + `request_id`. +2. Resolves the matching future when `VoteDecision` / + `NightActionDecision` arrives on the WS handler chain. +3. Hits a deadline → resolves the future with ``target_seat=None`` + (= abstain / skip), logs the timeout, and the seat appears as a + no-decision row in the viewer (per the user's "log it so the viewer + shows the seat went silent" rule). + +The dispatcher is decoupled from `LLMAdapter` and `SpeakArbiter` so the +rounds-mode gameplay-LLM path stays untouched. `LLMAdapter.submit_llm_*` +methods branch on `game.discussion_mode` and delegate here for +reactive_voice. +""" + +from __future__ import annotations + +import asyncio +import logging +import uuid +from collections.abc import Awaitable, Callable, Coroutine, Iterable, Sequence +from dataclasses import dataclass + +from wolfbot.domain.discussion import make_phase_id +from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.models import Player, Seat +from wolfbot.domain.ws_messages import ( + DecideNightActionRequest, + DecideVoteRequest, + NightActionDecision, + VoteDecision, + WolfChatRequest, + WolfChatSend, +) +from wolfbot.master.ws.npc_registry import NpcEntry, NpcRegistry + +log = logging.getLogger(__name__) + + +@dataclass +class DecisionDispatcherConfig: + """TTLs are deliberately conservative — the NPC bot pipeline is: + + register → snapshot in memory → LLM call (~2-4s with reasoning off) + → reply over WS. A typical decision round-trips in well under 10s + even at the bad tail; we set 12s so a slow provider still resolves + before the deadline. + + `inter_request_stagger_ms` spreads the per-actor LLM dispatches in + time so we don't burst N parallel requests at the LLM provider. + Game ``6a0dd72d63e3`` exhausted Gemini's per-minute quota with a + 9-NPC parallel vote dispatch (9 requests in <1s → 429 RESOURCE_ + EXHAUSTED on 5+ of them). Game ``824ebf79edf4`` (post-800ms + rollout) still hit a 429 on Comet's NIGHT_1 attack call because + the per-project Vertex quota is shared across speech + decision + + wolf-chat phases that run back-to-back. Bumping to 1500ms gives + the rate limiter ~13.5s of spread for 9 actors; combined with the + per-task ``request_ttl_ms`` (12s computed at task start) the worst- + case wall-clock is ~25s for a fully-staggered run, well under any + discussion or night-action deadline. Tests pin this to 0 so they + finish in milliseconds. + """ + + request_ttl_ms: int = 12_000 + inter_request_stagger_ms: int = 1500 + + +@dataclass +class _PendingDecision: + """Per-request bookkeeping for the response side of the WS round-trip.""" + + future: asyncio.Future[int | None] + seat_no: int + npc_id: str + request_id: str + game_id: str + # Vote-specific context — populated only on vote dispatches so the + # `on_vote_decision` handler can persist the structured suspicions + # to the timeline without an extra DB lookup. ``None`` for night / + # wolf-chat dispatches that share this dataclass. + vote_day: int | None = None + vote_round: int | None = None + vote_phase: Phase | None = None + + +@dataclass +class _PendingWolfChat: + """Pending wolf-chat round-trip — resolves to the spoken text.""" + + future: asyncio.Future[str | None] + seat_no: int + npc_id: str + request_id: str + game_id: str + + +class NpcDecisionDispatcher: + """Send DecideVoteRequest / DecideNightActionRequest to NPC bots and + collect their decisions. + + Stateless across games — the only mutable state is `_pending`, which + maps in-flight `request_id` → future. The same dispatcher instance + serves every game on the Master process. + """ + + def __init__( + self, + registry: NpcRegistry, + *, + config: DecisionDispatcherConfig | None = None, + now_ms: Callable[[], int] = lambda: 0, + repo: object | None = None, + ) -> None: + self.registry = registry + self.config = config or DecisionDispatcherConfig() + self._now_ms = now_ms + # Optional repo handle for persisting structured suspicions on + # vote-decision arrival. ``None`` is allowed for tests / older + # wirings; the persistence path silently no-ops when absent. + self._repo = repo + # request_id → pending future. Cleaned up on resolution / timeout. + self._pending: dict[str, _PendingDecision] = {} + # request_id → pending wolf-chat future (kept separate from + # `_pending` so a stray vote_decision can't accidentally resolve + # a wolf chat or vice versa). + self._pending_wolf_chat: dict[str, _PendingWolfChat] = {} + + # ------------------------------------------------- public dispatch entry + + async def dispatch_votes( + self, + *, + game_id: str, + day: int, + round_: int, + voters: Sequence[Player], + seats: Sequence[Seat], + candidate_seats: Sequence[int], + public_state_summary: str = "", + ) -> dict[int, int | None]: + """Fan out vote requests to every alive LLM voter; collect results. + + Returns ``{voter_seat_no: target_seat_or_None}``. A voter whose + NPC is offline / never replies before the deadline maps to + ``None`` (abstain). + """ + seats_by_no = {s.seat_no: s for s in seats} + candidate_pairs = tuple( + (no, seats_by_no[no].display_name) + for no in candidate_seats + if no in seats_by_no + ) + phase = Phase.DAY_RUNOFF if round_ >= 1 else Phase.DAY_VOTE + phase_id = make_phase_id(game_id, day, phase) + + async def _one(voter: Player) -> tuple[int, int | None]: + # Compute deadline at task start so each staggered task + # gets the full request_ttl_ms, not a shared shrinking + # window. Without this the 9th-staggered task at +6.4s + # would only have ~5s of TTL left from a 12s shared + # deadline. + expires_at_ms = self._now_ms() + self.config.request_ttl_ms + target = await self._dispatch_one_vote( + voter=voter, + seats_by_no=seats_by_no, + candidate_pairs=candidate_pairs, + game_id=game_id, + phase_id=phase_id, + round_=round_, + expires_at_ms=expires_at_ms, + public_state_summary=public_state_summary, + day=day, + phase=phase, + ) + return voter.seat_no, target + + def _factory(voter: Player) -> Callable[ + [], Coroutine[object, object, tuple[int, int | None]] + ]: + return lambda: _one(voter) + + results = await self._run_staggered(_factory(v) for v in voters) + return dict(results) + + async def dispatch_wolf_chat_lines( + self, + *, + game_id: str, + day: int, + wolves: Sequence[Player], + seats: Sequence[Seat], + candidate_seats: Sequence[int], + public_state_summary: str = "", + ) -> dict[int, str | None]: + """Sequentially elicit one wolf-chat line per alive wolf seat. + + Sequential by design: each WolfChatRequest is awaited so the + broker's broadcast lands before the next wolf's request fires, + which is what lets the wolves' state mirrors converge before the + attack-decision dispatch. + + Returns ``{wolf_seat: line_or_None}`` — None when the NPC was + offline / send failed / timed out / declined. + """ + from wolfbot.domain.discussion import make_phase_id + + seats_by_no = {s.seat_no: s for s in seats} + candidate_pairs = tuple( + (no, seats_by_no[no].display_name) + for no in candidate_seats + if no in seats_by_no + ) + phase_id = make_phase_id( + game_id, day, Phase.NIGHT_0 if day == 0 else Phase.NIGHT + ) + out: dict[int, str | None] = {} + for wolf in sorted(wolves, key=lambda p: p.seat_no): + text = await self._dispatch_one_wolf_chat( + wolf=wolf, + seats_by_no=seats_by_no, + candidate_pairs=candidate_pairs, + game_id=game_id, + phase_id=phase_id, + public_state_summary=public_state_summary, + ) + out[wolf.seat_no] = text + return out + + async def dispatch_night_actions( + self, + *, + game_id: str, + day: int, + action_kind: str, + actors: Sequence[Player], + seats: Sequence[Seat], + candidate_seats: Sequence[int], + public_state_summary: str = "", + ) -> dict[int, int | None]: + """Fan out night-action requests; collect targets per actor seat. + + ``action_kind`` is one of ``wolf_attack`` / ``seer_divine`` / + ``knight_guard``. Master is responsible for selecting the right + ``actors`` (e.g. only alive wolves for wolf_attack) and the + legal ``candidate_seats`` set. + """ + seats_by_no = {s.seat_no: s for s in seats} + candidate_pairs = tuple( + (no, seats_by_no[no].display_name) + for no in candidate_seats + if no in seats_by_no + ) + phase_id = make_phase_id( + game_id, day, Phase.NIGHT_0 if day == 0 else Phase.NIGHT + ) + + async def _one(actor: Player) -> tuple[int, int | None]: + # Per-task deadline (see dispatch_votes for rationale). + expires_at_ms = self._now_ms() + self.config.request_ttl_ms + target = await self._dispatch_one_night( + actor=actor, + seats_by_no=seats_by_no, + candidate_pairs=candidate_pairs, + game_id=game_id, + phase_id=phase_id, + action_kind=action_kind, + expires_at_ms=expires_at_ms, + public_state_summary=public_state_summary, + ) + return actor.seat_no, target + + def _factory(actor: Player) -> Callable[ + [], Coroutine[object, object, tuple[int, int | None]] + ]: + return lambda: _one(actor) + + results = await self._run_staggered(_factory(a) for a in actors) + return dict(results) + + # ------------------------------------------------- WS handler hooks + + async def on_vote_decision(self, msg: VoteDecision) -> None: + """Resolve the pending future for ``msg.request_id``. Stale or + unknown ids are dropped silently — they were already timed out.""" + pending = self._pending.pop(msg.request_id, None) + if pending is None: + log.info( + "vote_decision_unknown_request request=%s npc=%s", + msg.request_id, msg.npc_id, + ) + return + if not pending.future.done(): + pending.future.set_result(msg.target_seat) + log.info( + "vote_decision_received request=%s npc=%s seat=%d target=%s reason=%s", + msg.request_id, msg.npc_id, msg.seat_no, msg.target_seat, + msg.reason_summary or "(none)", + ) + # Persist structured suspicions to the immutable timeline. Skip + # when repo is unavailable (test wiring) or when vote context is + # missing (would be a coding bug — we always populate it for + # vote dispatches). Auto-promote the chosen target to level=high + # if the LLM didn't explicitly include it (= the vote target IS + # the strongest suspicion, by definition). + if ( + self._repo is None + or pending.vote_day is None + or pending.vote_round is None + or pending.vote_phase is None + ): + return + await self._persist_vote_suspicions( + msg=msg, + game_id=pending.game_id, + day=pending.vote_day, + round_=pending.vote_round, + phase=pending.vote_phase, + ) + + async def _persist_vote_suspicions( + self, + *, + msg: VoteDecision, + game_id: str, + day: int, + round_: int, + phase: Phase, + ) -> None: + """Persist VoteDecision-attached suspicions, auto-promoting the + vote target to level=high if it isn't already in the explicit list. + + Best-effort: any DB error is logged but does not propagate, so a + suspicion-write failure can never stall the vote-resolution path. + """ + from wolfbot.domain.suspicion import Suspicion as _Suspicion + from wolfbot.domain.suspicion import SuspicionLevel + + suspicions = list(msg.suspicions) + # Auto-promote: ensure the vote target appears at level=high. + if msg.target_seat is not None and not any( + s.target_seat == msg.target_seat for s in suspicions + ): + suspicions.append( + _Suspicion( + target_seat=msg.target_seat, + level=SuspicionLevel.HIGH, + reason=msg.reason_summary or "vote_target_auto_promoted", + ) + ) + if not suspicions: + return + try: + await self._repo.insert_vote_suspicions( # type: ignore[union-attr] + game_id=game_id, + day=day, + phase=phase, + vote_round=round_, + suspecter_seat=msg.seat_no, + created_at_ms=self._now_ms(), + suspicions=suspicions, + ) + except Exception: + log.exception( + "vote_suspicions_persist_failed game=%s seat=%d round=%d", + game_id, + msg.seat_no, + round_, + ) + + async def on_wolf_chat_send(self, msg: WolfChatSend) -> None: + """Resolve any pending wolf-chat future for ``msg.request_id``. + + Idempotent: if no future is parked (e.g. spontaneous wolf line + sent without a request, or duplicate from retransmit), this is a + no-op. Persistence + broadcast still happens via the broker + regardless. + """ + if msg.request_id is None: + return + pending = self._pending_wolf_chat.pop(msg.request_id, None) + if pending is None: + return + if not pending.future.done(): + pending.future.set_result(msg.text) + + async def on_night_action_decision(self, msg: NightActionDecision) -> None: + pending = self._pending.pop(msg.request_id, None) + if pending is None: + log.info( + "night_action_decision_unknown_request request=%s npc=%s", + msg.request_id, msg.npc_id, + ) + return + if not pending.future.done(): + pending.future.set_result(msg.target_seat) + log.info( + "night_action_decision_received request=%s npc=%s seat=%d " + "kind=%s target=%s reason=%s", + msg.request_id, msg.npc_id, msg.seat_no, msg.action_kind, + msg.target_seat, msg.reason_summary or "(none)", + ) + # Persist seer-divine suspicions to the timeline. The divine + # target IS implicitly a "seat the seer wants to verify = + # suspicion-curious" — auto-promote target_seat to medium if not + # explicitly listed. Wolf-attack and knight-guard are deliberately + # not persisted here: wolves know their targets via private + # state, knights protect rather than declare suspicion. + if ( + self._repo is None + or pending.vote_phase is None # reused for night phase + or msg.action_kind != "seer_divine" + ): + return + await self._persist_seer_suspicions( + msg=msg, + game_id=pending.game_id, + phase=pending.vote_phase, + ) + + async def _persist_seer_suspicions( + self, + *, + msg: NightActionDecision, + game_id: str, + phase: Phase, + ) -> None: + """Persist seer-divine suspicions, auto-promoting target_seat to + medium if not explicitly listed. + + Day is derived from the live `games.day_number` (loaded fresh + per call) so we don't need to thread phase_id through + `_PendingDecision`. The night-action dispatch happens at the + seer's NIGHT phase, so the read-current-day approximation is + always correct in practice. + """ + from wolfbot.domain.suspicion import Suspicion as _Suspicion + from wolfbot.domain.suspicion import SuspicionLevel + + try: + game = await self._repo.load_game(game_id) # type: ignore[union-attr] + except Exception: + log.exception("seer_suspicions_load_game_failed game=%s", game_id) + return + if game is None: + return + day = game.day_number + suspicions = list(msg.suspicions) + if msg.target_seat is not None and not any( + s.target_seat == msg.target_seat for s in suspicions + ): + suspicions.append( + _Suspicion( + target_seat=msg.target_seat, + level=SuspicionLevel.MEDIUM, + reason=msg.reason_summary or "seer_divine_target_auto", + ) + ) + if not suspicions: + return + try: + await self._repo.insert_vote_suspicions( # type: ignore[union-attr] + game_id=game_id, + day=day, + phase=phase, + vote_round=-1, # sentinel for night-action-derived rows + suspecter_seat=msg.seat_no, + created_at_ms=self._now_ms(), + suspicions=suspicions, + ) + except Exception: + log.exception( + "seer_suspicions_persist_failed game=%s seat=%d", + game_id, msg.seat_no, + ) + + # ------------------------------------------------- internals + + async def _dispatch_one_vote( + self, + *, + voter: Player, + seats_by_no: dict[int, Seat], + candidate_pairs: tuple[tuple[int, str], ...], + game_id: str, + phase_id: str, + round_: int, + expires_at_ms: int, + public_state_summary: str, + day: int, + phase: Phase, + ) -> int | None: + seat = seats_by_no.get(voter.seat_no) + if seat is None or not seat.is_llm: + return None + entry = self._find_npc_for_seat(game_id, voter.seat_no) + if entry is None or entry.send is None: + log.info( + "vote_dispatch_skip_no_npc game=%s seat=%d", game_id, voter.seat_no + ) + return None + request_id = f"rv_{uuid.uuid4().hex[:12]}" + req = DecideVoteRequest( + ts=self._now_ms(), + trace_id=f"vote-{game_id}-{voter.seat_no}-r{round_}", + request_id=request_id, + npc_id=entry.npc_id, + seat_no=voter.seat_no, + game_id=game_id, + phase_id=phase_id, + round_=round_, + candidate_seats=candidate_pairs, + public_state_summary=public_state_summary, + expires_at_ms=expires_at_ms, + ) + return await self._send_and_wait( + request_id=request_id, + seat_no=voter.seat_no, + npc_id=entry.npc_id, + game_id=game_id, + send=entry.send, + payload_json=req.model_dump_json(), + label="vote", + vote_day=day, + vote_round=round_, + vote_phase=phase, + ) + + async def _dispatch_one_wolf_chat( + self, + *, + wolf: Player, + seats_by_no: dict[int, Seat], + candidate_pairs: tuple[tuple[int, str], ...], + game_id: str, + phase_id: str, + public_state_summary: str, + ) -> str | None: + seat = seats_by_no.get(wolf.seat_no) + if seat is None or not seat.is_llm: + return None + entry = self._find_npc_for_seat(game_id, wolf.seat_no) + if entry is None or entry.send is None: + log.info( + "wolf_chat_dispatch_skip_no_npc game=%s seat=%d", + game_id, wolf.seat_no, + ) + return None + request_id = f"rwc_{uuid.uuid4().hex[:12]}" + deadline = self._now_ms() + self.config.request_ttl_ms + req = WolfChatRequest( + ts=self._now_ms(), + trace_id=f"wolf_chat-{game_id}-{wolf.seat_no}", + request_id=request_id, + npc_id=entry.npc_id, + seat_no=wolf.seat_no, + game_id=game_id, + phase_id=phase_id, + candidate_seats=candidate_pairs, + public_state_summary=public_state_summary, + expires_at_ms=deadline, + ) + loop = asyncio.get_running_loop() + future: asyncio.Future[str | None] = loop.create_future() + self._pending_wolf_chat[request_id] = _PendingWolfChat( + future=future, seat_no=wolf.seat_no, + npc_id=entry.npc_id, request_id=request_id, + game_id=game_id, + ) + try: + await entry.send(req.model_dump_json()) + except Exception: + log.exception( + "wolf_chat_dispatch_send_failed npc=%s seat=%d", + entry.npc_id, wolf.seat_no, + ) + self._pending_wolf_chat.pop(request_id, None) + return None + timeout_s = self.config.request_ttl_ms / 1000.0 + try: + return await asyncio.wait_for(future, timeout=timeout_s) + except TimeoutError: + log.info( + "wolf_chat_dispatch_timeout npc=%s seat=%d request=%s", + entry.npc_id, wolf.seat_no, request_id, + ) + return None + finally: + self._pending_wolf_chat.pop(request_id, None) + + async def _dispatch_one_night( + self, + *, + actor: Player, + seats_by_no: dict[int, Seat], + candidate_pairs: tuple[tuple[int, str], ...], + game_id: str, + phase_id: str, + action_kind: str, + expires_at_ms: int, + public_state_summary: str, + ) -> int | None: + seat = seats_by_no.get(actor.seat_no) + if seat is None or not seat.is_llm: + return None + entry = self._find_npc_for_seat(game_id, actor.seat_no) + if entry is None or entry.send is None: + log.info( + "night_dispatch_skip_no_npc game=%s seat=%d kind=%s", + game_id, actor.seat_no, action_kind, + ) + return None + # Tighten the kind to the wire-level Literal — the only callers come + # from rule-validated state machine code, so anything else is a bug. + if action_kind not in ("wolf_attack", "seer_divine", "knight_guard"): + log.warning( + "night_dispatch_unknown_kind game=%s seat=%d kind=%s", + game_id, actor.seat_no, action_kind, + ) + return None + request_id = f"rn_{uuid.uuid4().hex[:12]}" + req = DecideNightActionRequest( + ts=self._now_ms(), + trace_id=f"night-{game_id}-{actor.seat_no}-{action_kind}", + request_id=request_id, + npc_id=entry.npc_id, + seat_no=actor.seat_no, + game_id=game_id, + phase_id=phase_id, + action_kind=action_kind, # type: ignore[arg-type] + candidate_seats=candidate_pairs, + public_state_summary=public_state_summary, + expires_at_ms=expires_at_ms, + ) + # Pass NIGHT phase through `vote_phase` (reused; the field name + # predates this overload) so `on_night_action_decision` can + # persist seer-divine suspicions to the timeline. + night_phase = Phase.NIGHT_0 if "::day0::" in phase_id else Phase.NIGHT + return await self._send_and_wait( + request_id=request_id, + seat_no=actor.seat_no, + npc_id=entry.npc_id, + game_id=game_id, + send=entry.send, + payload_json=req.model_dump_json(), + label=f"night-{action_kind}", + vote_phase=night_phase, + ) + + async def _run_staggered( + self, + coro_factories: Iterable[ + Callable[[], Coroutine[object, object, tuple[int, int | None]]] + ], + ) -> list[tuple[int, int | None]]: + """Launch one task per factory, spaced by ``inter_request_stagger_ms``. + + Each factory is a zero-arg callable that returns a coroutine; we + wait for the stagger interval BETWEEN launches, not before + gathering. The first task starts immediately so the wall-clock + for a 1-actor dispatch is unchanged. With ``stagger_ms=0`` the + behaviour is equivalent to the historical + ``asyncio.gather(*coros)`` fan-out, which keeps the unit tests + snappy. + """ + stagger_s = self.config.inter_request_stagger_ms / 1000.0 + tasks: list[asyncio.Task[tuple[int, int | None]]] = [] + for i, factory in enumerate(coro_factories): + if i > 0 and stagger_s > 0: + await asyncio.sleep(stagger_s) + tasks.append(asyncio.create_task(factory())) + if not tasks: + return [] + return list(await asyncio.gather(*tasks, return_exceptions=False)) + + async def _send_and_wait( + self, + *, + request_id: str, + seat_no: int, + npc_id: str, + game_id: str, + send: Callable[[str], Awaitable[None]], + payload_json: str, + label: str, + vote_day: int | None = None, + vote_round: int | None = None, + vote_phase: Phase | None = None, + ) -> int | None: + loop = asyncio.get_running_loop() + future: asyncio.Future[int | None] = loop.create_future() + self._pending[request_id] = _PendingDecision( + future=future, + seat_no=seat_no, + npc_id=npc_id, + request_id=request_id, + game_id=game_id, + vote_day=vote_day, + vote_round=vote_round, + vote_phase=vote_phase, + ) + try: + await send(payload_json) + except Exception: + log.exception( + "%s_dispatch_send_failed npc=%s seat=%d", label, npc_id, seat_no, + ) + self._pending.pop(request_id, None) + return None + timeout_s = self.config.request_ttl_ms / 1000.0 + try: + return await asyncio.wait_for(future, timeout=timeout_s) + except TimeoutError: + log.info( + "%s_dispatch_timeout npc=%s seat=%d request=%s", + label, npc_id, seat_no, request_id, + ) + return None + finally: + self._pending.pop(request_id, None) + + def _find_npc_for_seat(self, game_id: str, seat_no: int) -> NpcEntry | None: + for entry in self.registry.all_online(): + if entry.assigned_seat == seat_no and entry.game_id == game_id: + return entry + return None + + def cleanup_game(self, game_id: str) -> int: + """Cancel and drop every in-flight request belonging to ``game_id``. + + Called from the game-end hook so a long-lived Master process + doesn't accumulate pending futures across games. Each cancelled + future resolves to ``None`` (= abstain / skip text), matching the + timeout fall-back the in-flight code already handles. + + Returns the number of pending entries swept (vote/night + + wolf-chat combined) for log visibility. + """ + swept = 0 + for rid, pending in list(self._pending.items()): + if pending.game_id != game_id: + continue + if not pending.future.done(): + pending.future.set_result(None) + self._pending.pop(rid, None) + swept += 1 + for rid, pending_wc in list(self._pending_wolf_chat.items()): + if pending_wc.game_id != game_id: + continue + if not pending_wc.future.done(): + pending_wc.future.set_result(None) + self._pending_wolf_chat.pop(rid, None) + swept += 1 + if swept: + log.info( + "decision_dispatcher_cleanup_game game=%s swept=%d", + game_id, swept, + ) + return swept + + +__all__ = ["DecisionDispatcherConfig", "NpcDecisionDispatcher"] + + +# Avoid unused-import lint: Role is consumed by callers that pass `actors` +# pre-filtered by role; we keep the import handy for future role-aware +# dispatch policies without forcing the call site to import it again. +_ = Role diff --git a/src/wolfbot/master/ws/npc_registry.py b/src/wolfbot/master/ws/npc_registry.py new file mode 100644 index 0000000..1de8494 --- /dev/null +++ b/src/wolfbot/master/ws/npc_registry.py @@ -0,0 +1,264 @@ +"""In-memory NPC bot registry maintained by Master. + +Tracks every connected NPC bot's `npc_id`, Discord identity, assigned seat, +heartbeat freshness, and back-channel send target. Voice-ingest reads the +`discord_bot_user_id` set from this registry to filter NPC TTS audio out of +the STT pipeline (see voice-ingest spec). + +Lifecycle: +- `register(...)` is called when a connecting NPC presents a valid + `npc_register` payload over WebSocket. +- `heartbeat(npc_id, ts)` is called on every heartbeat message. +- `prune_offline(now_ts, timeout_ms)` is called periodically (or by the + registry itself) to mark stale NPCs offline; offline NPCs are NOT removed + from the registry — `is_online` flips to False so the arbiter can skip + them, and reconnection re-flips to True via the next register call. +- `unregister(npc_id, reason)` is the explicit teardown path; emits the + registry_update needed by voice-ingest. + +Thread/asyncio model: +- The registry is intentionally single-threaded. Master is async-first; all + operations run on the same event loop, so plain dicts are safe without a + lock. +""" + +from __future__ import annotations + +import contextlib +import logging +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable + +log = logging.getLogger(__name__) + + +@dataclass +class NpcEntry: + """One row in the registry.""" + + npc_id: str + discord_bot_user_id: str + persona_key: str + supported_voices: tuple[str, ...] + version: str + assigned_seat: int | None = None + game_id: str | None = None + phase_id: str | None = None + last_heartbeat_ms: int = 0 + is_online: bool = True + # Back-channel send target. Each NPC connection registers a coroutine + # that takes a JSON-encoded message and writes it to the WS. Tests can + # substitute a list-appender; real code wires this from MasterWsServer. + send: Callable[[str], Awaitable[None]] | None = field(default=None, repr=False) + + +@runtime_checkable +class NpcRegistry(Protocol): + """Protocol for testability — Fakes substitute as needed.""" + + def register( + self, + *, + npc_id: str, + discord_bot_user_id: str, + persona_key: str, + supported_voices: tuple[str, ...], + version: str, + send: Callable[[str], Awaitable[None]] | None, + now_ms: int, + ) -> NpcEntry: ... + + def unregister(self, npc_id: str, reason: str) -> None: ... + + def heartbeat(self, npc_id: str, ts: int) -> None: ... + + def get(self, npc_id: str) -> NpcEntry | None: ... + + def all_online(self) -> list[NpcEntry]: ... + + def discord_bot_user_ids(self) -> set[str]: ... + + def prune_offline(self, now_ms: int, timeout_ms: int) -> list[str]: ... + + def assign( + self, + npc_id: str, + *, + seat: int, + game_id: str, + phase_id: str, + ) -> None: ... + + def unassign(self, npc_id: str) -> None: ... + + def assigned_to_game(self, game_id: str) -> list[NpcEntry]: ... + + +class InMemoryNpcRegistry: + """The default registry — production and test both use this.""" + + def __init__(self) -> None: + self._entries: dict[str, NpcEntry] = {} + # Subscribers receive (added: set[str], removed: set[str]) of + # discord_bot_user_id deltas; voice-ingest connections register here + # to receive registry_update pushes. + self._listeners: list[Callable[[set[str], set[str]], Awaitable[None]]] = [] + # Strong references to scheduled listener tasks so the GC does not + # drop them mid-flight (ruff RUF006). + self._listener_tasks: set[object] = set() + + # ---------------------------------------------------------- registration + + def register( + self, + *, + npc_id: str, + discord_bot_user_id: str, + persona_key: str, + supported_voices: tuple[str, ...], + version: str, + send: Callable[[str], Awaitable[None]] | None, + now_ms: int, + ) -> NpcEntry: + previous = self._entries.get(npc_id) + previous_uid = previous.discord_bot_user_id if previous is not None else None + + entry = NpcEntry( + npc_id=npc_id, + discord_bot_user_id=discord_bot_user_id, + persona_key=persona_key, + supported_voices=supported_voices, + version=version, + assigned_seat=previous.assigned_seat if previous is not None else None, + game_id=previous.game_id if previous is not None else None, + phase_id=previous.phase_id if previous is not None else None, + last_heartbeat_ms=now_ms, + is_online=True, + send=send, + ) + self._entries[npc_id] = entry + + added: set[str] = set() + removed: set[str] = set() + if previous_uid is None: + added.add(discord_bot_user_id) + elif previous_uid != discord_bot_user_id: + removed.add(previous_uid) + added.add(discord_bot_user_id) + if added or removed: + self._notify_listeners_sync(added, removed) + return entry + + def unregister(self, npc_id: str, reason: str) -> None: + entry = self._entries.pop(npc_id, None) + if entry is None: + return + log.info("npc_unregister npc_id=%s reason=%s", npc_id, reason) + self._notify_listeners_sync(set(), {entry.discord_bot_user_id}) + + # ---------------------------------------------------------- heartbeat + + def heartbeat(self, npc_id: str, ts: int) -> None: + entry = self._entries.get(npc_id) + if entry is None: + return + entry.last_heartbeat_ms = ts + if not entry.is_online: + entry.is_online = True + log.info("npc_recovered npc_id=%s", npc_id) + + # ---------------------------------------------------------- queries + + def get(self, npc_id: str) -> NpcEntry | None: + return self._entries.get(npc_id) + + def all_online(self) -> list[NpcEntry]: + return [e for e in self._entries.values() if e.is_online] + + def discord_bot_user_ids(self) -> set[str]: + return {e.discord_bot_user_id for e in self._entries.values()} + + # ---------------------------------------------------------- pruning + + def prune_offline(self, now_ms: int, timeout_ms: int) -> list[str]: + """Mark stale NPCs offline; return the list of just-marked-offline ids. + + Stale = `now_ms - last_heartbeat_ms > timeout_ms`. Only flips state once + per stay-offline window; subsequent prunes return an empty list until + the NPC reconnects and goes stale again. + """ + marked: list[str] = [] + for npc_id, entry in self._entries.items(): + if entry.is_online and now_ms - entry.last_heartbeat_ms > timeout_ms: + entry.is_online = False + marked.append(npc_id) + if marked: + log.info("npc_offline_marked count=%d", len(marked)) + return marked + + # ---------------------------------------------------------- assignment + + def assign( + self, + npc_id: str, + *, + seat: int, + game_id: str, + phase_id: str, + ) -> None: + entry = self._entries.get(npc_id) + if entry is None: + return + entry.assigned_seat = seat + entry.game_id = game_id + entry.phase_id = phase_id + + def unassign(self, npc_id: str) -> None: + """Clear assignment fields for one NPC (game ended / never picked).""" + entry = self._entries.get(npc_id) + if entry is None: + return + entry.assigned_seat = None + entry.game_id = None + entry.phase_id = None + + def assigned_to_game(self, game_id: str) -> list[NpcEntry]: + """All entries currently assigned to ``game_id``. Used by the + game-end hook to release every bot in one pass.""" + return [e for e in self._entries.values() if e.game_id == game_id] + + # ---------------------------------------------------------- listeners + + def add_listener(self, fn: Callable[[set[str], set[str]], Awaitable[None]]) -> None: + self._listeners.append(fn) + + def remove_listener(self, fn: Callable[[set[str], set[str]], Awaitable[None]]) -> None: + with contextlib.suppress(ValueError): + self._listeners.remove(fn) + + def _notify_listeners_sync(self, added: set[str], removed: set[str]) -> None: + """Schedule async notifications without blocking the registry path. + + Listeners are typically WS pushers; they may take time to write. + We schedule them on the running loop so callers (handlers + tests) + do not have to await listener completion. + """ + if not self._listeners: + return + import asyncio as _aio + + try: + loop = _aio.get_running_loop() + except RuntimeError: + # No loop — listeners cannot run. Tests that exercise the sync + # path can still observe the entry state. + return + for listener in list(self._listeners): + coro = listener(added, removed) + task: object = loop.create_task(coro) # type: ignore[arg-type] + self._listener_tasks.add(task) + task.add_done_callback(self._listener_tasks.discard) # type: ignore[attr-defined] + + +__all__ = ["InMemoryNpcRegistry", "NpcEntry", "NpcRegistry"] diff --git a/src/wolfbot/master/ws/wolf_chat_broker.py b/src/wolfbot/master/ws/wolf_chat_broker.py new file mode 100644 index 0000000..e0b3397 --- /dev/null +++ b/src/wolfbot/master/ws/wolf_chat_broker.py @@ -0,0 +1,170 @@ +"""Master-side wolf-chat fan-out for Phase-D. + +In `reactive_voice` mode, when a wolf NPC posts a coordination line via +`WolfChatSend`, Master: + +1. Persists it as a private `WOLF_CHAT` `LogEntry` (visibility=`PRIVATE`, + matching how the rounds-mode wolf-chat path writes the same log row) + so post-game replay still has the canonical history. +2. Pushes a `private_state_update(kind=wolf_chat)` to every other live + wolf seat's NPC bot, so each wolf's `NpcGameState.wolf_chat_history` + stays in sync without the bots ever reading the Master DB. +3. Optionally writes the line into the wolves-only Discord channel via + the existing message_poster, so a human wolf player still sees the + coordination line. + +Pure orchestration — no LLM calls and no decisions. The broker is +constructed with the registry + repo references and a `now_ms` clock; +its single public entry is `handle_wolf_chat_send`. +""" + +from __future__ import annotations + +import logging +import time +from collections.abc import Awaitable, Callable + +from wolfbot.domain.enums import Role +from wolfbot.domain.models import LogEntry +from wolfbot.domain.ws_messages import WolfChatSend +from wolfbot.master.state.private_state import make_wolf_chat_update +from wolfbot.master.ws.npc_registry import NpcEntry, NpcRegistry +from wolfbot.persistence.sqlite_repo import SqliteRepo + +log = logging.getLogger(__name__) + + +class WolfChatBroker: + """Receive-and-fan-out for wolf chat lines from NPC bots. + + The broker is per-Master-process; routing is by ``game_id`` extracted + from the `WolfChatSend` payload. Best-effort end-to-end — a single + failed write/broadcast leg is logged but doesn't block the others. + """ + + def __init__( + self, + *, + registry: NpcRegistry, + repo: SqliteRepo, + post_to_wolves_channel: Callable[[str, str], Awaitable[None]] | None = None, + now_ms: Callable[[], int] = lambda: int(time.time() * 1000), + ) -> None: + self.registry = registry + self.repo = repo + self._post_to_wolves_channel = post_to_wolves_channel + self._now_ms = now_ms + + async def handle_wolf_chat_send(self, msg: WolfChatSend) -> None: + # 1) Validate the sender is actually an alive wolf at this seat. + try: + game = await self.repo.load_game(msg.game_id) + seats = await self.repo.load_seats(msg.game_id) + players = await self.repo.load_players(msg.game_id) + except Exception: + log.exception( + "wolf_chat_load_failed game=%s seat=%d", + msg.game_id, msg.seat_no, + ) + return + if game is None or game.ended_at is not None: + log.info( + "wolf_chat_drop_no_game game=%s seat=%d", + msg.game_id, msg.seat_no, + ) + return + sender = next((p for p in players if p.seat_no == msg.seat_no), None) + if ( + sender is None + or not sender.alive + or sender.role is not Role.WEREWOLF + ): + log.info( + "wolf_chat_drop_not_wolf game=%s seat=%d role=%s", + msg.game_id, msg.seat_no, + sender.role.value if sender and sender.role else "?", + ) + return + + seats_by_no = {s.seat_no: s for s in seats} + sender_name = ( + seats_by_no[msg.seat_no].display_name + if msg.seat_no in seats_by_no + else f"席{msg.seat_no}" + ) + text = msg.text.strip() + if not text: + return + + # 2) Persist the canonical WOLF_CHAT log entry. + try: + await self.repo.insert_log_private( + LogEntry( + game_id=msg.game_id, + day=game.day_number, + phase=game.phase, + kind="WOLF_CHAT", + actor_seat=msg.seat_no, + visibility="PRIVATE", + text=text, + created_at=int(self._now_ms() / 1000), + ) + ) + except Exception: + log.exception( + "wolf_chat_log_insert_failed game=%s seat=%d", + msg.game_id, msg.seat_no, + ) + + # 3) Fan out a PrivateStateUpdate(wolf_chat) to every OTHER live + # wolf seat's NPC bot. The sender's own state is updated by its + # own LLM-side bookkeeping; we don't echo back to avoid + # double-recording the same line. + recipients = [ + p + for p in players + if p.role is Role.WEREWOLF and p.alive and p.seat_no != msg.seat_no + ] + for recipient in recipients: + entry = self._find_npc_for_seat(msg.game_id, recipient.seat_no) + if entry is None or entry.send is None: + continue + update = make_wolf_chat_update( + npc_id=entry.npc_id, + game_id=msg.game_id, + seat_no=recipient.seat_no, + day=game.day_number, + speaker_seat=msg.seat_no, + speaker_name=sender_name, + text=text, + ts=self._now_ms(), + trace_id=f"wolf_chat-{msg.game_id}-{msg.seat_no}", + ) + try: + await entry.send(update.model_dump_json()) + except Exception: + log.exception( + "wolf_chat_broadcast_failed npc=%s seat=%d", + entry.npc_id, recipient.seat_no, + ) + + # 4) Mirror to the wolves-only Discord channel so a human wolf + # (if any) sees the coordination line. Best-effort. + if self._post_to_wolves_channel is not None: + try: + await self._post_to_wolves_channel( + msg.game_id, f"**{sender_name}** (狼チャット): {text}", + ) + except Exception: + log.exception( + "wolf_chat_channel_post_failed game=%s", msg.game_id, + ) + + def _find_npc_for_seat(self, game_id: str, seat_no: int) -> NpcEntry | None: + for entry in self.registry.all_online(): + if entry.assigned_seat == seat_no and entry.game_id == game_id: + return entry + return None + + +__all__ = ["WolfChatBroker"] diff --git a/src/wolfbot/master/ws/ws_server.py b/src/wolfbot/master/ws/ws_server.py new file mode 100644 index 0000000..ecfc81b --- /dev/null +++ b/src/wolfbot/master/ws/ws_server.py @@ -0,0 +1,473 @@ +"""Master ↔ NPC / voice-ingest WebSocket transport. + +Listens on `127.0.0.1` and authenticates each connection with the shared +`MASTER_NPC_PSK` value. Two connection roles are accepted: + +- ``role=npc`` — an NPC bot worker. Identified by `npc_id`. Master responds + with ``npc_registered`` once its ``npc_register`` message arrives, and + routes subsequent ``logic_packet`` / ``speak_request`` messages back via + the per-connection ``send`` callback. +- ``role=voice-ingest`` — the voice ingest worker. Master pushes the current + ``registry_snapshot`` as soon as the connection authenticates and a + ``registry_update`` whenever the NPC bot identity set changes. + +The transport is intentionally narrow: it parses incoming JSON envelopes, hands +off typed messages to a handler, and exposes a back-channel ``send`` callable +that serializes typed Pydantic messages back over the WS. Higher-level +behaviors (arbitration, authorization, audit-row writes) live in +``master_ingest_service`` / ``speak_arbiter`` and call into this module only +through the back-channel. + +Testability: +- ``MasterWsServer`` is a Protocol; production code uses + ``WebsocketsMasterWsServer`` (real `websockets` library) and tests use + ``FakeMasterWsServer`` from ``tests.fakes``. +- The server exposes a `dispatch(raw_json, ctx)` entry point that the Fake + also calls, so unit tests can simulate inbound messages without standing + up a real socket. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import Any, Literal, Protocol, runtime_checkable + +from wolfbot.domain.ws_messages import ( + Heartbeat, + NightActionDecision, + NpcRegister, + NpcRegistered, + PlaybackFailed, + PlaybackFinished, + RegistrySnapshot, + RegistryUpdate, + SpeakResult, + SpeechEventPayload, + SttFailed, + TtsFailed, + TtsFinished, + VadSpeechEnded, + VadSpeechStarted, + VoteDecision, + WolfChatSend, +) +from wolfbot.master.ws.npc_registry import NpcRegistry + +log = logging.getLogger(__name__) + + +ConnectionRole = Literal["npc", "voice-ingest"] + + +@dataclass +class ConnectionContext: + """Per-connection state passed to message handlers. + + Holds the connection role, an arbitrary tag (npc_id or "voice-ingest"), + and the back-channel `send` coroutine so handlers can push typed + responses without depending on the underlying WS library. + """ + + role: ConnectionRole + tag: str + send: Callable[[str], Awaitable[None]] + closed: bool = False + extra: dict[str, Any] = field(default_factory=dict) + + +# Handler shape: takes (parsed_json: dict, ctx: ConnectionContext) and returns +# nothing. The dispatcher selects the right handler from the `type` field. +MessageHandler = Callable[[dict[str, Any], ConnectionContext], Awaitable[None]] + + +@runtime_checkable +class MasterWsServer(Protocol): + """Operational surface used by the orchestrator (`main.py` / tests).""" + + async def start(self) -> None: ... + + async def stop(self) -> None: ... + + def add_handler(self, message_type: str, handler: MessageHandler) -> None: ... + + async def broadcast_to_voice_ingest(self, message_json: str) -> None: ... + + +class HandlerRegistry: + """Routes message types to their async handlers. + + Kept as a tiny independent class so ``WebsocketsMasterWsServer`` and + ``FakeMasterWsServer`` can both delegate to the same dispatch path. + """ + + def __init__(self) -> None: + self._handlers: dict[str, MessageHandler] = {} + + def add(self, message_type: str, handler: MessageHandler) -> None: + self._handlers[message_type] = handler + + async def dispatch(self, raw_json: str, ctx: ConnectionContext) -> None: + try: + payload: Any = json.loads(raw_json) + except json.JSONDecodeError: + log.warning("master_ws_invalid_json role=%s tag=%s", ctx.role, ctx.tag) + return + if not isinstance(payload, dict) or "type" not in payload: + log.warning( + "master_ws_missing_type role=%s tag=%s payload=%s", + ctx.role, + ctx.tag, + payload, + ) + return + message_type = payload["type"] + handler = self._handlers.get(message_type) + if handler is None: + log.info( + "master_ws_unhandled_message type=%s role=%s tag=%s", + message_type, + ctx.role, + ctx.tag, + ) + return + try: + await handler(payload, ctx) + except Exception: + log.exception( + "master_ws_handler_failed type=%s role=%s tag=%s", + message_type, + ctx.role, + ctx.tag, + ) + + +# ---------------------------------------------------------------- handlers + + +def _now_ms_default() -> int: + import time + + return int(time.time() * 1000) + + +@dataclass +class MasterHandlers: + """The collection of typed handlers Master needs. + + Each handler reads from / writes to a small set of collaborators: + ``NpcRegistry`` (presence), the ``ingest_service`` for voice-ingest + messages, and the ``arbiter`` (set later in the apply flow) for + speak-result handling. + + Keeping the handlers in a dataclass lets ``main.py`` wire them once and + ``tests/test_master_ws_server.py`` substitute Fakes per call. + """ + + registry: NpcRegistry + on_speak_result: Callable[[SpeakResult, ConnectionContext], Awaitable[None]] | None = None + on_tts_finished: Callable[[TtsFinished, ConnectionContext], Awaitable[None]] | None = None + on_tts_failed: Callable[[TtsFailed, ConnectionContext], Awaitable[None]] | None = None + on_playback_finished: ( + Callable[[PlaybackFinished, ConnectionContext], Awaitable[None]] | None + ) = None + on_playback_failed: Callable[[PlaybackFailed, ConnectionContext], Awaitable[None]] | None = None + on_speech_event_payload: ( + Callable[[SpeechEventPayload, ConnectionContext], Awaitable[None]] | None + ) = None + on_vad_started: Callable[[VadSpeechStarted, ConnectionContext], Awaitable[None]] | None = None + on_vad_ended: Callable[[VadSpeechEnded, ConnectionContext], Awaitable[None]] | None = None + on_stt_failed: Callable[[SttFailed, ConnectionContext], Awaitable[None]] | None = None + on_vote_decision: ( + Callable[[VoteDecision, ConnectionContext], Awaitable[None]] | None + ) = None + on_night_action_decision: ( + Callable[[NightActionDecision, ConnectionContext], Awaitable[None]] | None + ) = None + on_wolf_chat_send: ( + Callable[[WolfChatSend, ConnectionContext], Awaitable[None]] | None + ) = None + now_ms: Callable[[], int] = field(default=_now_ms_default) + + def install(self, registry_: HandlerRegistry) -> None: + registry_.add("npc_register", self._handle_register) + registry_.add("heartbeat", self._handle_heartbeat) + registry_.add("speak_result", self._handle_speak_result) + registry_.add("tts_finished", self._handle_tts_finished) + registry_.add("tts_failed", self._handle_tts_failed) + registry_.add("playback_finished", self._handle_playback_finished) + registry_.add("playback_failed", self._handle_playback_failed) + registry_.add("speech_event_payload", self._handle_speech_payload) + registry_.add("vad_speech_started", self._handle_vad_started) + registry_.add("vad_speech_ended", self._handle_vad_ended) + registry_.add("stt_failed", self._handle_stt_failed) + registry_.add("vote_decision", self._handle_vote_decision) + registry_.add("night_action_decision", self._handle_night_action_decision) + registry_.add("wolf_chat_send", self._handle_wolf_chat_send) + + async def _handle_register(self, payload: dict[str, Any], ctx: ConnectionContext) -> None: + msg = NpcRegister.model_validate(payload) + ctx.tag = msg.npc_id + entry = self.registry.register( + npc_id=msg.npc_id, + discord_bot_user_id=msg.discord_bot_user_id, + persona_key=msg.persona_key, + supported_voices=msg.supported_voices, + version=msg.version, + send=ctx.send, + now_ms=self.now_ms(), + ) + reply = NpcRegistered( + ts=self.now_ms(), + trace_id=msg.trace_id, + npc_id=msg.npc_id, + assigned_seat=entry.assigned_seat, + game_id=entry.game_id, + phase_id=entry.phase_id, + ) + await ctx.send(reply.model_dump_json()) + + async def _handle_heartbeat(self, payload: dict[str, Any], ctx: ConnectionContext) -> None: + msg = Heartbeat.model_validate(payload) + if msg.npc_id is not None: + self.registry.heartbeat(msg.npc_id, self.now_ms()) + + async def _handle_speak_result(self, payload: dict[str, Any], ctx: ConnectionContext) -> None: + msg = SpeakResult.model_validate(payload) + if self.on_speak_result is not None: + await self.on_speak_result(msg, ctx) + + async def _handle_tts_finished(self, payload: dict[str, Any], ctx: ConnectionContext) -> None: + msg = TtsFinished.model_validate(payload) + if self.on_tts_finished is not None: + await self.on_tts_finished(msg, ctx) + + async def _handle_tts_failed(self, payload: dict[str, Any], ctx: ConnectionContext) -> None: + msg = TtsFailed.model_validate(payload) + if self.on_tts_failed is not None: + await self.on_tts_failed(msg, ctx) + + async def _handle_playback_finished( + self, payload: dict[str, Any], ctx: ConnectionContext + ) -> None: + msg = PlaybackFinished.model_validate(payload) + if self.on_playback_finished is not None: + await self.on_playback_finished(msg, ctx) + + async def _handle_playback_failed( + self, payload: dict[str, Any], ctx: ConnectionContext + ) -> None: + msg = PlaybackFailed.model_validate(payload) + if self.on_playback_failed is not None: + await self.on_playback_failed(msg, ctx) + + async def _handle_speech_payload(self, payload: dict[str, Any], ctx: ConnectionContext) -> None: + msg = SpeechEventPayload.model_validate(payload) + if self.on_speech_event_payload is not None: + await self.on_speech_event_payload(msg, ctx) + + async def _handle_vad_started(self, payload: dict[str, Any], ctx: ConnectionContext) -> None: + msg = VadSpeechStarted.model_validate(payload) + if self.on_vad_started is not None: + await self.on_vad_started(msg, ctx) + + async def _handle_vad_ended(self, payload: dict[str, Any], ctx: ConnectionContext) -> None: + msg = VadSpeechEnded.model_validate(payload) + if self.on_vad_ended is not None: + await self.on_vad_ended(msg, ctx) + + async def _handle_stt_failed(self, payload: dict[str, Any], ctx: ConnectionContext) -> None: + msg = SttFailed.model_validate(payload) + if self.on_stt_failed is not None: + await self.on_stt_failed(msg, ctx) + + async def _handle_vote_decision( + self, payload: dict[str, Any], ctx: ConnectionContext + ) -> None: + msg = VoteDecision.model_validate(payload) + if self.on_vote_decision is not None: + await self.on_vote_decision(msg, ctx) + + async def _handle_night_action_decision( + self, payload: dict[str, Any], ctx: ConnectionContext + ) -> None: + msg = NightActionDecision.model_validate(payload) + if self.on_night_action_decision is not None: + await self.on_night_action_decision(msg, ctx) + + async def _handle_wolf_chat_send( + self, payload: dict[str, Any], ctx: ConnectionContext + ) -> None: + msg = WolfChatSend.model_validate(payload) + if self.on_wolf_chat_send is not None: + await self.on_wolf_chat_send(msg, ctx) + + +# ---------------------------------------------------------------- real WS server + + +class WebsocketsMasterWsServer: + """Production WebSocket server. + + Loads the `websockets` library lazily so tests do not need it. The + handshake reads two query parameters from the WebSocket URL: ``role`` + (``npc`` | ``voice-ingest``) and ``psk``. Mismatch on either rejects the + connection with a typed ``HandshakeError`` written before close. Behaviour + of the protocol after handshake is delegated to ``HandlerRegistry``. + + The server is intentionally minimal — only the connection lifecycle plus + a back-channel push API for voice-ingest registry updates. Higher-level + state (NPC presence, audit rows) lives in ``NpcRegistry`` / + ``master_ingest_service`` / ``speak_arbiter``. + """ + + def __init__( + self, + *, + host: str = "127.0.0.1", + port: int = 8800, + psk: str, + registry: NpcRegistry, + handlers: MasterHandlers, + ) -> None: + self.host = host + self.port = port + self.psk = psk + self.registry = registry + self.handler_registry = HandlerRegistry() + handlers.install(self.handler_registry) + self._handlers = handlers + self._server: Any = None + self._voice_ingest_conns: set[ConnectionContext] = set() + # Subscribe to registry deltas so we can push registry_update to all + # voice-ingest peers in real time. + add_listener = getattr(self.registry, "add_listener", None) + if add_listener is not None: + add_listener(self._on_registry_delta) + + async def start(self) -> None: + # Lazy import — the live websockets library is a heavy dep we only + # exercise when actually serving production traffic. Tests use the + # Fake and never call start(). + import websockets + + async def _conn_handler(ws: Any) -> None: + ctx = await self._authenticate(ws) + if ctx is None: + return + try: + if ctx.role == "voice-ingest": + self._voice_ingest_conns.add(ctx) + await self._send_initial_registry_snapshot(ctx) + async for raw in ws: + if isinstance(raw, bytes): + raw = raw.decode("utf-8", "replace") + await self.handler_registry.dispatch(raw, ctx) + finally: + ctx.closed = True + self._voice_ingest_conns.discard(ctx) + if ctx.role == "npc": + self.registry.unregister(ctx.tag, reason="ws_closed") + + self._server = await websockets.serve(_conn_handler, self.host, self.port) + log.info( + "master_ws_listening host=%s port=%d psk_set=%s", + self.host, + self.port, + bool(self.psk), + ) + + async def stop(self) -> None: + if self._server is not None: + self._server.close() + await self._server.wait_closed() + self._server = None + + async def _authenticate(self, ws: Any) -> ConnectionContext | None: + from urllib.parse import parse_qs, urlparse + + # websockets ≥13 (including 16.0) exposes the HTTP request as + # `ws.request` (a `Request` with `.path`). Older versions exposed + # `ws.path` directly. Try the modern attribute first. + request = getattr(ws, "request", None) + if request is not None: + path = getattr(request, "path", None) or "" + else: + path = getattr(ws, "path", "") or "" + query = parse_qs(urlparse(path).query) + role_raw = (query.get("role") or [""])[0] + psk_raw = (query.get("psk") or [""])[0] + role: ConnectionRole | None = None + if role_raw == "npc": + role = "npc" + elif role_raw == "voice-ingest": + role = "voice-ingest" + if role is None or psk_raw != self.psk: + log.warning( + "master_ws_auth_rejected role=%s psk_match=%s", + role_raw, + psk_raw == self.psk, + ) + with contextlib.suppress(Exception): + await ws.close(code=4401, reason="auth_failed") + return None + + async def _send(msg: str) -> None: + try: + await ws.send(msg) + except Exception: + log.exception("master_ws_send_failed role=%s", role) + + return ConnectionContext(role=role, tag=role_raw, send=_send) + + def add_handler(self, message_type: str, handler: MessageHandler) -> None: + self.handler_registry.add(message_type, handler) + + async def broadcast_to_voice_ingest(self, message_json: str) -> None: + for ctx in list(self._voice_ingest_conns): + if ctx.closed: + self._voice_ingest_conns.discard(ctx) + continue + try: + await ctx.send(message_json) + except Exception: + log.exception("voice_ingest_broadcast_failed tag=%s", ctx.tag) + + async def _on_registry_delta(self, added: set[str], removed: set[str]) -> None: + if not added and not removed: + return + update = RegistryUpdate( + ts=self._handlers.now_ms(), + trace_id="registry-update", + added=tuple(sorted(added)), + removed=tuple(sorted(removed)), + ) + await self.broadcast_to_voice_ingest(update.model_dump_json()) + + async def _send_initial_registry_snapshot(self, ctx: ConnectionContext) -> None: + snapshot = RegistrySnapshot( + ts=self._handlers.now_ms(), + trace_id="registry-snapshot", + npc_user_ids=tuple(sorted(self.registry.discord_bot_user_ids())), + ) + await ctx.send(snapshot.model_dump_json()) + + +__all__ = [ + "ConnectionContext", + "ConnectionRole", + "HandlerRegistry", + "MasterHandlers", + "MasterWsServer", + "MessageHandler", + "WebsocketsMasterWsServer", +] + + +# Force a non-static reference so unused-import linters keep `asyncio` here for +# implementations that subclass / extend this module without re-importing. +_ = asyncio diff --git a/src/wolfbot/npc/__init__.py b/src/wolfbot/npc/__init__.py new file mode 100644 index 0000000..b266173 --- /dev/null +++ b/src/wolfbot/npc/__init__.py @@ -0,0 +1,9 @@ +"""NPC bot worker package. + +Code that runs only inside an NPC bot worker process (one process per NPC): +the WS client, the Grok-driven speech generator, the VOICEVOX TTS adapter, +and the discord.py voice playback wrapper. + +Master-side counterparts (NPC registry, speak arbiter, ingest, etc.) live +in :mod:`wolfbot.master`. +""" diff --git a/src/wolfbot/npc/audio/__init__.py b/src/wolfbot/npc/audio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wolfbot/npc/audio/playback.py b/src/wolfbot/npc/audio/playback.py new file mode 100644 index 0000000..10080a2 --- /dev/null +++ b/src/wolfbot/npc/audio/playback.py @@ -0,0 +1,65 @@ +"""Discord voice channel playback Protocol + Fake. + +NPC bots play TTS audio in `MAIN_VOICE_CHANNEL_ID` only after Master sends +`PlaybackAuthorized`. The Discord-side playback API is wrapped behind a +Protocol so unit tests use `FakeVoicePlayback`. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable + + +class VoicePlaybackError(RuntimeError): + """Playback failure that should surface via `playback_failed`.""" + + def __init__(self, failure_reason: str) -> None: + super().__init__(failure_reason) + self.failure_reason = failure_reason + + +@runtime_checkable +class VoicePlayback(Protocol): + async def play(self, *, audio: bytes, sample_rate: int) -> tuple[int, int]: + """Play `audio`. Returns (started_at_ms, finished_at_ms).""" + ... + + +@dataclass +class FakeVoicePlayback: + """Captures playback calls and drives configurable timing for tests.""" + + started_at_ms: int = 0 + finished_at_ms: int = 0 + raise_for_audio: Exception | None = None + plays: list[tuple[bytes, int]] = field(default_factory=list) + + async def play(self, *, audio: bytes, sample_rate: int) -> tuple[int, int]: + self.plays.append((audio, sample_rate)) + if self.raise_for_audio is not None: + raise self.raise_for_audio + return (self.started_at_ms, self.finished_at_ms) + + +@dataclass +class DiscordVoicePlayback: + """Production playback wrapper. + + Lifts the actual play-into-VC operation into a user-supplied async + callable so this module remains decoupled from `discord.py`. + """ + + play_fn: Callable[[bytes, int], Awaitable[tuple[int, int]]] + + async def play(self, *, audio: bytes, sample_rate: int) -> tuple[int, int]: + return await self.play_fn(audio, sample_rate) + + +__all__ = [ + "DiscordVoicePlayback", + "FakeVoicePlayback", + "VoicePlayback", + "VoicePlaybackError", +] diff --git a/src/wolfbot/npc/audio/tts.py b/src/wolfbot/npc/audio/tts.py new file mode 100644 index 0000000..af63ae4 --- /dev/null +++ b/src/wolfbot/npc/audio/tts.py @@ -0,0 +1,226 @@ +"""TTS adapter Protocol + pluggable provider implementations. + +Providers: +- ``VoicevoxTtsService`` — local VOICEVOX engine (free, requires ``voicevox_engine`` + running on localhost). Default for MVP; swap to another provider by changing the + ``TTS_PROVIDER`` env var. +- ``GoogleCloudTtsService`` — skeleton; delegates to a user-supplied callable. +- ``FakeTtsService`` — deterministic stub for tests. + +Each NPC bot keeps a small in-memory cache keyed by `(provider, voice_id, +sha256(text), speed, pitch)` to avoid re-synthesizing the same utterance. +""" + +from __future__ import annotations + +import hashlib +import logging +from collections import OrderedDict +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class TtsRequest: + text: str + voice_id: str + speed: float = 1.0 + pitch: float = 0.0 + language: str = "ja-JP" + + +@dataclass(frozen=True) +class TtsResult: + audio: bytes + duration_ms: int + sample_rate: int = 48_000 + + +class TtsProviderError(RuntimeError): + """Hard TTS failure (timeout, 5xx, malformed). Carries a `failure_reason`.""" + + def __init__(self, failure_reason: str) -> None: + super().__init__(failure_reason) + self.failure_reason = failure_reason + + +@runtime_checkable +class TtsService(Protocol): + async def synthesize(self, req: TtsRequest) -> TtsResult: ... + + +class FakeTtsService: + """In-memory TTS for tests.""" + + def __init__( + self, + scripted: list[TtsResult | Exception] | None = None, + default: TtsResult | None = None, + ) -> None: + self._scripted = list(scripted or []) + self._default = default or TtsResult( + audio=b"audio-fake", duration_ms=500) + self.call_count = 0 + self.requests: list[TtsRequest] = [] + + async def synthesize(self, req: TtsRequest) -> TtsResult: + self.requests.append(req) + self.call_count += 1 + if self._scripted: + head = self._scripted.pop(0) + if isinstance(head, Exception): + raise head + return head + return self._default + + +class GoogleCloudTtsService: + """Production cost-minimized adapter — delegates synthesis to a user-supplied callable. + + Like the STT adapter, this class deliberately does NOT import the + `google-cloud-texttospeech` SDK at module-load time. The real bot wires + a `synth_fn` that issues the actual API call. + """ + + def __init__( + self, + *, + project: str, + synth_fn: Callable[[TtsRequest], Awaitable[TtsResult]] | None = None, + ) -> None: + self.project = project + self._synth_fn = synth_fn + + async def synthesize(self, req: TtsRequest) -> TtsResult: + if self._synth_fn is None: + raise TtsProviderError("tts_provider_not_configured") + return await self._synth_fn(req) + + +class InMemoryTtsCache: + """Bounded LRU cache for synthesized audio. + + The cache is per-process; a Master restart drops it. Keys hash on + `(voice_id, text, speed, pitch)` so distinct utterances are kept + distinct even if their text matches another voice's cache entry. + """ + + def __init__(self, *, max_entries: int = 256) -> None: + self.max_entries = max_entries + self._entries: OrderedDict[str, TtsResult] = OrderedDict() + self.hits = 0 + self.misses = 0 + + @staticmethod + def _key(req: TtsRequest) -> str: + digest = hashlib.sha256(req.text.encode("utf-8")).hexdigest() + return f"{req.voice_id}|{req.speed}|{req.pitch}|{digest}" + + def get(self, req: TtsRequest) -> TtsResult | None: + key = self._key(req) + result = self._entries.get(key) + if result is None: + self.misses += 1 + return None + self.hits += 1 + # Re-insert to mark as recently used. + self._entries.move_to_end(key) + return result + + def put(self, req: TtsRequest, result: TtsResult) -> None: + key = self._key(req) + self._entries[key] = result + self._entries.move_to_end(key) + while len(self._entries) > self.max_entries: + self._entries.popitem(last=False) + + +class VoicevoxTtsService: + """VOICEVOX local engine adapter. + + Requires the VOICEVOX engine running at ``base_url`` (default + ``http://localhost:50021``). The ``voice_id`` maps to a VOICEVOX + speaker ID (int). The two-step API: ``audio_query`` → ``synthesis``. + + Does NOT import ``httpx`` at module-load time so test environments + without a running VOICEVOX process still load this file. + """ + + def __init__( + self, + *, + base_url: str = "http://localhost:50021", + default_speaker: int = 3, + timeout_s: float = 15.0, + ) -> None: + self.base_url = base_url.rstrip("/") + self.default_speaker = default_speaker + self.timeout_s = timeout_s + + def _speaker_id(self, voice_id: str) -> int: + try: + return int(voice_id) + except (ValueError, TypeError): + return self.default_speaker + + async def synthesize(self, req: TtsRequest) -> TtsResult: + import httpx + + speaker = self._speaker_id(req.voice_id) + try: + async with httpx.AsyncClient(timeout=self.timeout_s) as client: + # Step 1: audio_query + query_resp = await client.post( + f"{self.base_url}/audio_query", + params={"text": req.text, "speaker": speaker}, + ) + if query_resp.status_code != 200: + raise TtsProviderError( + f"voicevox_audio_query_failed_{query_resp.status_code}" + ) + query_json = query_resp.json() + query_json["speedScale"] = req.speed + query_json["pitchScale"] = req.pitch + + # Step 2: synthesis + synth_resp = await client.post( + f"{self.base_url}/synthesis", + params={"speaker": speaker}, + json=query_json, + ) + if synth_resp.status_code != 200: + raise TtsProviderError( + f"voicevox_synthesis_failed_{synth_resp.status_code}" + ) + audio = synth_resp.content + # VOICEVOX outputs 24kHz WAV by default; estimate duration from size. + # WAV header is 44 bytes, 16-bit mono = 2 bytes/sample, 24kHz. + sample_rate = 24_000 + data_bytes = max(0, len(audio) - 44) + duration_ms = int(data_bytes / (sample_rate * 2) * 1000) + return TtsResult( + audio=audio, duration_ms=duration_ms, sample_rate=sample_rate + ) + except TtsProviderError: + raise + except httpx.TimeoutException as exc: + raise TtsProviderError("voicevox_timeout") from exc + except httpx.ConnectError as exc: + raise TtsProviderError("voicevox_connection_refused") from exc + except Exception as exc: + raise TtsProviderError(f"voicevox_unexpected_{type(exc).__name__}") from exc + + +__all__ = [ + "FakeTtsService", + "GoogleCloudTtsService", + "InMemoryTtsCache", + "TtsProviderError", + "TtsRequest", + "TtsResult", + "TtsService", + "VoicevoxTtsService", +] diff --git a/src/wolfbot/npc/decision/__init__.py b/src/wolfbot/npc/decision/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wolfbot/npc/decision/decision_service.py b/src/wolfbot/npc/decision/decision_service.py new file mode 100644 index 0000000..b1807a3 --- /dev/null +++ b/src/wolfbot/npc/decision/decision_service.py @@ -0,0 +1,554 @@ +"""NPC bot per-seat decision LLM — votes / night actions / runoff votes. + +Phase-D: each NPC bot owns the strategic decisions for its seat. This +service is the LLM-backed decision layer; it consumes the bot's +`NpcGameState` mirror (role + role-specific results + wolf chat) plus +the request-time public-state digest, calls `NPC_LLM_*` with a strict +JSON schema, and returns the chosen target seat. + +Returns ``target_seat=None`` on: +* missing or stale game state (snapshot never received), +* model error / timeout (caller already enforces a wall-clock deadline), +* schema validation failure (model returned a non-candidate or a + malformed JSON payload). + +A None result is logged and propagated up to the WS reply — Master +records it as an abstain/skip per the user's "log it so the viewer +shows the seat went silent" rule. +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +from wolfbot.domain.enums import Role +from wolfbot.domain.suspicion import Suspicion +from wolfbot.domain.ws_messages import ( + DecideNightActionRequest, + DecideVoteRequest, +) +from wolfbot.llm.persona_base import Persona +from wolfbot.llm.prompt_builder import ( + _build_game_rules_block, + build_judgment_profile_block, + build_strategy_block, +) +from wolfbot.llm.template import render_template +from wolfbot.npc.decision.game_state import NpcGameState + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class DecisionResult: + """Decision LLM output. ``target_seat=None`` means abstain / skip.""" + + target_seat: int | None + reason_summary: str = "" + # Structured suspicion records the decision LLM emitted (vote path + # only — night actions don't carry a public-facing suspicion). The + # vote target is implicitly a high suspicion and SHOULD appear in + # this list, but the LLM may also register secondary trust/low/ + # medium entries for other seats. Persisted to the suspicions + # timeline by the dispatcher. + suspicions: tuple[Suspicion, ...] = () + + +@runtime_checkable +class DecisionLLM(Protocol): + """Provider-agnostic decision call. Returns raw JSON text. Implementations + set the JSON schema appropriately (xAI strict json_schema, DeepSeek + json_object, Gemini response_json_schema).""" + + async def decide_json( + self, + *, + system_prompt: str, + user_prompt: str, + schema: dict[str, object], + ) -> str: ... + + +# ---------------------------------------------------------------- prompt blocks + + +_VOTE_SCHEMA: dict[str, object] = { + "type": "object", + "additionalProperties": False, + "required": ["target_seat", "reason", "suspicions"], + "properties": { + "target_seat": { + "type": "integer", + "description": ( + "Seat number of the vote target. Must be one of the " + "supplied candidate seats. Abstaining (null) is forbidden — " + "every alive voter has to pick someone." + ), + }, + "reason": { + "type": "string", + "description": "Short internal note explaining the choice.", + }, + "suspicions": { + "type": "array", + "description": ( + "Structured suspicion records this vote decision asserts. " + "MUST include one entry naming `target_seat` (your vote " + "target) at level=high with reason matching the public " + "vote rationale — voting someone implies the strongest " + "lynch suspicion. Optionally include trust/low/medium " + "entries for other seats whose evaluation you want to " + "register on the timeline. Master persists every entry " + "to the immutable suspicion timeline so silent reversals " + "between speech and vote are detectable." + ), + "items": { + "type": "object", + "additionalProperties": False, + "required": [ + "target_seat", + "level", + "reason", + "update_from_level", + "update_reason", + ], + "properties": { + "target_seat": { + "type": "integer", + "minimum": 1, + "maximum": 9, + }, + "level": { + "type": "string", + "enum": ["trust", "low", "medium", "high"], + }, + "reason": {"type": "string", "maxLength": 500}, + "update_from_level": { + "type": ["string", "null"], + "enum": ["trust", "low", "medium", "high", None], + }, + "update_reason": { + "type": ["string", "null"], + "maxLength": 500, + }, + }, + }, + }, + }, +} + + +_NIGHT_SCHEMA: dict[str, object] = { + "type": "object", + "additionalProperties": False, + "required": ["target_seat", "reason", "suspicions"], + "properties": { + "target_seat": { + "type": "integer", + "description": ( + "Seat number of the night-action target. Must be one of " + "the supplied candidate seats. Skipping (null) is forbidden — " + "every wolf_attack / seer_divine / knight_guard must pick " + "an actual target. Master rejects null with ILLEGAL_TARGET " + "and the night phase stalls on missing submissions, so " + "the decision LLM has to commit to a candidate." + ), + }, + "reason": { + "type": "string", + "description": "Short internal note explaining the choice.", + }, + "suspicions": { + "type": "array", + "description": ( + "Structured suspicion records. **For seer_divine**: " + "MUST include one entry naming `target_seat` (your " + "divine target) at level=medium or high — picking a " + "seat to divine is implicitly suspecting them. Use the " + "reason field to explain why this seat was chosen " + "(e.g. 「議論を急いでいたので確認したい」). Optional " + "trust/low entries for other seats can also register. " + "**For wolf_attack and knight_guard**: empty array is " + "acceptable — wolves attack their target with private " + "knowledge, guards protect rather than declare suspicion." + ), + "items": { + "type": "object", + "additionalProperties": False, + "required": [ + "target_seat", + "level", + "reason", + "update_from_level", + "update_reason", + ], + "properties": { + "target_seat": { + "type": "integer", + "minimum": 1, + "maximum": 9, + }, + "level": { + "type": "string", + "enum": ["trust", "low", "medium", "high"], + }, + "reason": {"type": "string", "maxLength": 500}, + "update_from_level": { + "type": ["string", "null"], + "enum": ["trust", "low", "medium", "high", None], + }, + "update_reason": { + "type": ["string", "null"], + "maxLength": 500, + }, + }, + }, + }, + }, +} + + +def _build_state_block(state: NpcGameState) -> str: + """Render the bot's private state mirror as a Japanese prompt block. + + Naming policy: 席番号は冒頭の `## 参加者` ロスター 1 ブロックだけに + 集約し、ここから下は display_name のみで参照する。data 層 (vote + target_seat 等) には数字を入れる。 + """ + own_name: str | None = None + for s, n in state.alive_seats: + if s == state.seat_no: + own_name = n + break + own_label = f"{own_name} (席{state.seat_no})" if own_name else f"席{state.seat_no}" + lines: list[str] = [ + f"あなた: {own_label}", + f"あなたの役職: {state.role}", + ] + causes = state.dead_seat_causes + + def _cause(seat_no: int) -> str: + c = causes.get(seat_no) + return " (処刑)" if c == "EXECUTION" else " (襲撃)" if c == "ATTACK" else "" + + if state.alive_seats or state.dead_seats: + lines.append("") + lines.append("## 参加者 (席番号 → 名前)") + if state.alive_seats: + lines.append("生存中:") + for s, n in state.alive_seats: + lines.append(f" 席{s} {n}") + if state.dead_seats: + lines.append("死亡:") + for s, n in state.dead_seats: + lines.append(f" 席{s} {n}{_cause(s)}") + if state.partner_wolves: + partners = "、".join(n for _s, n in state.partner_wolves) + lines.append(f"仲間の人狼 (非公開): {partners}") + if state.seer_results: + lines.append("## 自分の占い結果 (非公開)") + for sr in state.seer_results: + verdict = "黒 (人狼)" if sr.is_wolf else "白 (人狼ではない)" + lines.append(f" day{sr.day}: {sr.target_name} → {verdict}") + if state.medium_results: + lines.append("## 自分の霊媒結果 (非公開)") + for mr in state.medium_results: + if mr.is_wolf is None: + verdict = "結果なし (処刑なし)" + elif mr.is_wolf: + verdict = "人狼" + else: + verdict = "人狼ではない" + lines.append(f" day{mr.day}: {mr.target_name} → {verdict}") + if state.guard_history: + lines.append("## 自分の護衛履歴 (非公開)") + for g in state.guard_history: + outcome = ( + "(平和な朝)" + if g.peaceful_morning + else "(襲撃発生)" + if g.peaceful_morning is False + else "(結果未確定)" + ) + lines.append(f" day{g.day}: {g.target_name} を護衛 {outcome}") + if state.wolf_chat_history: + lines.append("## 人狼チャット履歴 (狼/狂人にのみ見える)") + for line in state.wolf_chat_history[-20:]: + lines.append(f" day{line.day} {line.speaker_name}: {line.text}") + if state.wolf_attack_history: + lines.append("## 自分達の襲撃履歴 (非公開)") + for atk in state.wolf_attack_history: + if atk.peaceful_morning is True: + outcome = "(平和な朝 = GJ)" + elif atk.peaceful_morning is False: + outcome = "(襲撃成功)" + else: + outcome = "(結果未確定)" + lines.append(f" day{atk.day}: {atk.target_name} を襲撃 {outcome}") + return "\n".join(lines) + + +def _build_persona_block(persona: Persona) -> str: + """Decision-side persona block — name + judgment axes only. + + The decision LLM picks ``target_seat``; speech_profile (一人称・文体・ + 間の取り方) does not influence that pick, so we drop it here. Saves + ~600 chars per vote/night/wolf-chat user prompt versus the speech + LLM's full persona block. + """ + return ( + f"## キャラクター\n" + f"名前: {persona.display_name}\n" + f"性格指針: {persona.style_guide}\n\n" + f"## 判断のクセ\n{build_judgment_profile_block(persona)}" + ) + + +def _build_role_block(role_str: str) -> str: + """Best-effort role-strategy block. Falls back to empty when the role + string isn't a known `Role` enum value (defensive — the snapshot + always carries a canonical name, but a future server version could + add roles).""" + try: + role = Role(role_str) + except ValueError: + return "" + return f"## 役職別の戦術ヒント\n{build_strategy_block(role)}" + + +def _format_candidates(candidates: Iterable[tuple[int, str]]) -> str: + return "、".join(f"席{seat_no} {name}" for seat_no, name in candidates) or "(なし)" + + +# ---------------------------------------------------------------- decision API + + +_VOTE_ACT_TEXT_BY_ROUND: dict[int, str] = { + 0: "通常投票", + 1: "決選投票", +} + + +def build_vote_prompt( + *, + state: NpcGameState, + persona: Persona, + request: DecideVoteRequest, +) -> tuple[str, str]: + """Compose the system + user prompt for a vote decision. + + Templates: + - npc/decision_vote_system.md (fixed instruction) + - npc/decision_vote_user.md (per-call context) + """ + return ( + render_template(_DECISION_VOTE_SYSTEM_TEMPLATE), + render_template( + _DECISION_VOTE_USER_TEMPLATE, + round_label=_VOTE_ACT_TEXT_BY_ROUND.get(request.round_, f"round={request.round_}"), + day_number=state.day_number, + game_rules_block=_build_game_rules_block(), + persona_block=_build_persona_block(persona), + role_block=_build_role_block(state.role), + state_block=_build_state_block(state), + digest=request.public_state_summary or "(情報なし)", + candidates_str=_format_candidates(request.candidate_seats), + ), + ) + + +_DECISION_VOTE_SYSTEM_TEMPLATE = "npc/decision_vote_system" +_DECISION_VOTE_USER_TEMPLATE = "npc/decision_vote_user" + + +_NIGHT_ACT_TEXT: dict[str, str] = { + "wolf_attack": "人狼の襲撃", + "seer_divine": "占い師の占い", + "knight_guard": "騎士の護衛", +} + + +_WOLF_CHAT_SCHEMA: dict[str, object] = { + "type": "object", + "additionalProperties": False, + "required": ["text"], + "properties": { + "text": { + "type": "string", + "description": "短い coordinator メッセージ (狼仲間にだけ届く)。最大 80 文字。", + }, + }, +} + + +_DECISION_WOLF_CHAT_SYSTEM_TEMPLATE = "npc/decision_wolf_chat_system" +_DECISION_WOLF_CHAT_USER_TEMPLATE = "npc/decision_wolf_chat_user" + + +def build_wolf_chat_prompt( + *, + state: NpcGameState, + persona: Persona, + candidates: Sequence[tuple[int, str]], + public_state_summary: str, +) -> tuple[str, str]: + """Compose system + user prompts for a wolf-chat coordination line. + + Templates: + - npc/decision_wolf_chat_system.md (fixed: voice, JSON shape, GJ rebite rule) + - npc/decision_wolf_chat_user.md (per-call context) + + Wolves talk to each other privately. The line must propose / agree + / counter on a target, stay under 80 chars, and speak in the + persona's voice (this is still character). + + The role-strategy block (= ``build_strategy_block(WEREWOLF)``) is + INJECTED via :func:`_build_role_block`. Without it, the chat's + "今夜誰を噛む?" decision runs on the persona block + game ledger + only; the master tactical rules (multi-CO attack avoidance, GJ + rebite, info-role priority, knight-candidate scoring) live in the + WEREWOLF strategy block and were missing from the chat prompt — + so wolves agreed to attack a seer in a 3-CO board (game + ``38627df1ade1`` night 1), and the night-action prompt that + follows already carries the chat history as commitment, biasing + both wolves to follow through. + """ + candidates_str = "、".join(f"席{seat_no} {name}" for seat_no, name in candidates) or "(なし)" + return ( + render_template(_DECISION_WOLF_CHAT_SYSTEM_TEMPLATE), + render_template( + _DECISION_WOLF_CHAT_USER_TEMPLATE, + day_number=state.day_number, + game_rules_block=_build_game_rules_block(), + persona_block=_build_persona_block(persona), + role_block=_build_role_block(state.role), + state_block=_build_state_block(state), + digest=public_state_summary or "(情報なし)", + candidates_str=candidates_str, + ), + ) + + +def parse_wolf_chat_text(raw_json: str) -> str | None: + """Pull the ``text`` field out of the JSON response, with empty / non-string + payloads dropped to None so the dispatcher records a no-line outcome.""" + try: + data = json.loads(raw_json) + except json.JSONDecodeError: + return None + if not isinstance(data, dict): + return None + raw = data.get("text") + if not isinstance(raw, str): + return None + cleaned = raw.strip() + return cleaned or None + + +_DECISION_NIGHT_SYSTEM_TEMPLATE = "npc/decision_night_system" +_DECISION_NIGHT_USER_TEMPLATE = "npc/decision_night_user" + + +def build_night_prompt( + *, + state: NpcGameState, + persona: Persona, + request: DecideNightActionRequest, +) -> tuple[str, str]: + """Compose the system + user prompt for a night-action decision. + + Templates: + - npc/decision_night_system.md (fixed instruction) + - npc/decision_night_user.md (per-call context) + """ + return ( + render_template(_DECISION_NIGHT_SYSTEM_TEMPLATE), + render_template( + _DECISION_NIGHT_USER_TEMPLATE, + action_label=_NIGHT_ACT_TEXT.get(request.action_kind, request.action_kind), + day_number=state.day_number, + game_rules_block=_build_game_rules_block(), + persona_block=_build_persona_block(persona), + role_block=_build_role_block(state.role), + state_block=_build_state_block(state), + digest=request.public_state_summary or "(情報なし)", + candidates_str=_format_candidates(request.candidate_seats), + ), + ) + + +def parse_decision( + raw_json: str, + *, + legal_seats: frozenset[int], +) -> DecisionResult: + """Parse + validate a decision LLM response. + + Returns ``DecisionResult(target_seat=None)`` on any failure: malformed + JSON, missing fields, target outside ``legal_seats``, etc. The + abstain default keeps the bot game-legal even when the model + misbehaves. + """ + try: + data = json.loads(raw_json) + except json.JSONDecodeError: + log.warning("decision_parse_json_failed raw=%s", raw_json[:200]) + return DecisionResult(target_seat=None, reason_summary="parse_failed") + if not isinstance(data, dict): + return DecisionResult(target_seat=None, reason_summary="not_object") + raw_target = data.get("target_seat") + if raw_target is None: + return DecisionResult( + target_seat=None, + reason_summary=str(data.get("reason", "")), + suspicions=_parse_decision_suspicions(data.get("suspicions")), + ) + if not isinstance(raw_target, int) or isinstance(raw_target, bool): + return DecisionResult(target_seat=None, reason_summary="non_int_target") + if raw_target not in legal_seats: + log.info("decision_target_out_of_set target=%s legal=%s", raw_target, legal_seats) + return DecisionResult(target_seat=None, reason_summary="illegal_target") + return DecisionResult( + target_seat=raw_target, + reason_summary=str(data.get("reason", "")), + suspicions=_parse_decision_suspicions(data.get("suspicions")), + ) + + +def _parse_decision_suspicions(raw: object) -> tuple[Suspicion, ...]: + """Coerce a raw `suspicions` array from a vote decision JSON into + a tuple of validated `Suspicion` models. + + Drops malformed entries silently (mirrors speech-side parsing) so + one bad row doesn't fail the whole decision. + """ + if not isinstance(raw, list): + return () + out: list[Suspicion] = [] + for entry in raw: + if not isinstance(entry, dict): + continue + try: + out.append(Suspicion.model_validate(entry)) + except Exception: + log.debug("decision_suspicion_dropped %r", entry) + continue + return tuple(out) + + +__all__ = [ + "_NIGHT_SCHEMA", + "_VOTE_SCHEMA", + "_WOLF_CHAT_SCHEMA", + "DecisionLLM", + "DecisionResult", + "build_night_prompt", + "build_vote_prompt", + "build_wolf_chat_prompt", + "parse_decision", + "parse_wolf_chat_text", +] diff --git a/src/wolfbot/npc/decision/game_state.py b/src/wolfbot/npc/decision/game_state.py new file mode 100644 index 0000000..cde9c38 --- /dev/null +++ b/src/wolfbot/npc/decision/game_state.py @@ -0,0 +1,205 @@ +"""Per-game in-memory state held by an NPC bot. + +Phase-D architecture (`reactive_voice` mode): each NPC bot is the embodied +agent for its seat. It owns the role + role-specific results + wolf chat +history and uses them when deciding speech / vote / night action via its +own `NPC_LLM_*`. Master pushes state via `PrivateStateSnapshot` (full +replace) and `PrivateStateUpdate` (append/patch) so the NPC never reads +the Master DB or persists state locally — a process restart re-hydrates +from a Master-sent snapshot at re-register. + +This module is the pure data container. The dispatch / WS handlers live +in :mod:`wolfbot.npc.runtime.client`; the prompt-building consumers live in the +NPC LLM generators. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from wolfbot.domain.ws_messages import ( + GuardEntry, + MediumResult, + PrivateStateSnapshot, + PrivateStateUpdate, + SeerResult, + WolfAttackEntry, + WolfChatLine, +) + + +def _as_int(value: Any) -> int: + """Coerce a JSON-decoded payload value to int. Raises ``TypeError`` on bool + (which would otherwise silently be treated as 0/1) and missing values.""" + if isinstance(value, bool) or value is None: + raise TypeError(f"expected int-like, got {type(value).__name__}") + if isinstance(value, int): + return value + if isinstance(value, str): + return int(value) + raise TypeError(f"expected int-like, got {type(value).__name__}") + + +def _as_str(value: Any) -> str: + if not isinstance(value, str): + raise TypeError(f"expected str, got {type(value).__name__}") + return value + + +def _as_bool(value: Any) -> bool: + if not isinstance(value, bool): + raise TypeError(f"expected bool, got {type(value).__name__}") + return value + + +def _opt_bool(value: Any) -> bool | None: + if value is None: + return None + return _as_bool(value) + + +@dataclass +class NpcGameState: + """Per-(game, seat) state mirror. + + The NPC bot keeps one of these per active game. Snapshots replace it + wholesale; updates mutate it in place via ``apply_update``. Mutability + is a deliberate choice — the WS handler fast path stays cheap and the + state is process-local. + """ + + game_id: str + seat_no: int + persona_key: str + role: str + day_number: int = 0 + alive_seats: list[tuple[int, str]] = field(default_factory=list) + dead_seats: list[tuple[int, str]] = field(default_factory=list) + # Per-dead-seat death cause label ("EXECUTION" | "ATTACK"), keyed by + # seat_no. Lets the prompt builder distinguish yesterday's vote + # victims from last night's attack victims so the NPC stops saying + # 「昨夜の犠牲者は◯◯」 about a player who was actually executed. + dead_seat_causes: dict[int, str] = field(default_factory=dict) + partner_wolves: list[tuple[int, str]] = field(default_factory=list) + seer_results: list[SeerResult] = field(default_factory=list) + medium_results: list[MediumResult] = field(default_factory=list) + guard_history: list[GuardEntry] = field(default_factory=list) + wolf_chat_history: list[WolfChatLine] = field(default_factory=list) + wolf_attack_history: list[WolfAttackEntry] = field(default_factory=list) + + +def state_from_snapshot(snapshot: PrivateStateSnapshot) -> NpcGameState: + """Rebuild state from a `PrivateStateSnapshot` (full replace semantics).""" + return NpcGameState( + game_id=snapshot.game_id, + seat_no=snapshot.seat_no, + persona_key=snapshot.persona_key, + role=snapshot.role, + day_number=snapshot.day_number, + alive_seats=list(snapshot.alive_seats), + dead_seats=list(snapshot.dead_seats), + dead_seat_causes={ + seat_no: cause for seat_no, cause in snapshot.dead_seat_causes + }, + partner_wolves=list(snapshot.partner_wolves), + seer_results=list(snapshot.seer_results), + medium_results=list(snapshot.medium_results), + guard_history=list(snapshot.guard_history), + wolf_chat_history=list(snapshot.wolf_chat_history), + wolf_attack_history=list(snapshot.wolf_attack_history), + ) + + +def apply_update(state: NpcGameState, update: PrivateStateUpdate) -> None: + """Mutate `state` in place by `update`. Unknown kinds are silently + ignored so a newer Master can add update kinds without breaking older + NPC builds. + + Each branch tolerates malformed payloads (missing/typed-wrong keys) + by skipping the update — the in-memory state stays consistent rather + than crashing the bot. + """ + kind = update.update_kind + payload = update.payload + try: + if kind == "seer_result": + state.seer_results.append( + SeerResult( + day=_as_int(payload["day"]), + target_seat=_as_int(payload["target_seat"]), + target_name=_as_str(payload["target_name"]), + is_wolf=_as_bool(payload["is_wolf"]), + ) + ) + elif kind == "medium_result": + state.medium_results.append( + MediumResult( + day=_as_int(payload["day"]), + target_seat=_as_int(payload["target_seat"]), + target_name=_as_str(payload["target_name"]), + is_wolf=_opt_bool(payload.get("is_wolf")), + ) + ) + elif kind == "guard_entry": + state.guard_history.append( + GuardEntry( + day=_as_int(payload["day"]), + target_seat=_as_int(payload["target_seat"]), + target_name=_as_str(payload["target_name"]), + peaceful_morning=_opt_bool(payload.get("peaceful_morning")), + ) + ) + elif kind == "guard_resolved": + day = _as_int(payload["day"]) + peaceful = _as_bool(payload["peaceful_morning"]) + state.guard_history = [ + entry.model_copy(update={"peaceful_morning": peaceful}) + if entry.day == day and entry.peaceful_morning is None + else entry + for entry in state.guard_history + ] + elif kind == "wolf_chat": + state.wolf_chat_history.append( + WolfChatLine( + day=_as_int(payload["day"]), + speaker_seat=_as_int(payload["speaker_seat"]), + speaker_name=_as_str(payload["speaker_name"]), + text=_as_str(payload["text"]), + ) + ) + elif kind == "alive_changed": + alive = payload.get("alive_seats", []) + dead = payload.get("dead_seats", []) + causes = payload.get("dead_seat_causes", []) + if isinstance(alive, list): + state.alive_seats = [ + (_as_int(s[0]), _as_str(s[1])) + for s in alive + if isinstance(s, (list, tuple)) and len(s) >= 2 + ] + if isinstance(dead, list): + state.dead_seats = [ + (_as_int(s[0]), _as_str(s[1])) + for s in dead + if isinstance(s, (list, tuple)) and len(s) >= 2 + ] + if isinstance(causes, list): + state.dead_seat_causes = { + _as_int(s[0]): _as_str(s[1]) + for s in causes + if isinstance(s, (list, tuple)) and len(s) >= 2 + } + elif kind == "day_advanced": + state.day_number = _as_int(payload["day_number"]) + # Unknown kinds: silently skip for forward-compat. + except (KeyError, TypeError, ValueError): + # Best-effort — malformed payload from Master shouldn't crash the bot. + pass + + +__all__ = [ + "NpcGameState", + "apply_update", + "state_from_snapshot", +] diff --git a/src/wolfbot/llm/personas.py b/src/wolfbot/npc/personas.py similarity index 71% rename from src/wolfbot/llm/personas.py rename to src/wolfbot/npc/personas.py index deecace..e5fb5b8 100644 --- a/src/wolfbot/llm/personas.py +++ b/src/wolfbot/npc/personas.py @@ -1,47 +1,28 @@ -"""Gnosia-flavored personas for LLM players. +"""NPC player personas — Gnosia-flavored character pool. -Persona is the LLM player's in-game identity. Names are taken from Gnosia; style -guidelines describe judgment tendency + speech register so the LLM can stay in -character. Do NOT quote original dialogue; imitate the persona's temperament only. +These are the in-game player identities assigned to LLM seats when humans +don't fill all 9 slots. Master picks from this pool at game start (via +``pick_personas``) and writes the chosen ``persona_key`` onto each LLM +``Seat``; both rounds-mode prompt building (``services.llm_service``) and +reactive_voice NPC speech generation +(:mod:`wolfbot.npc.speech.openai_compatible_generator`) look up the persona by +that key. -`SpeechProfile` holds the structured speech-reproduction data (first-person, -address style, signature phrases, narration mode) that the system prompt's -`## 話法` block consumes. Keep `style_guide` for personality/judgment and -`speech_profile` for 喋り方/語彙/文体 — do not mix the two in free-form prose. -Kukrushka is near-silent in the original, so her `narration_mode` is -`silent_gesture` and the block renders gesture descriptions instead of a normal -conversation profile. +Names are taken from Gnosia; style guidelines describe judgment tendency ++ speech register so the LLM can stay in character. Do NOT quote +original dialogue; imitate the persona's temperament only. """ from __future__ import annotations -from collections.abc import Sequence -from dataclasses import dataclass -from random import Random -from typing import Literal - - -@dataclass(frozen=True) -class SpeechProfile: - first_person: str - self_reference_aliases: tuple[str, ...] = () - address_style: str = "" - sentence_style: str = "" - pause_style: str = "" - signature_phrases: tuple[str, ...] = () - forbidden_overuse: tuple[str, ...] = () - narration_mode: Literal["standard", "silent_gesture"] = "standard" - - -@dataclass(frozen=True) -class Persona: - key: str - display_name: str - style_guide: str - speech_profile: SpeechProfile - +from wolfbot.llm.persona_base import ( + JudgmentProfile, + Persona, + SpeechProfile, + index_by_key, +) -PERSONAS: tuple[Persona, ...] = ( +NPC_PERSONAS: tuple[Persona, ...] = ( Persona( key="setsu", display_name="🌙セツ", @@ -57,6 +38,14 @@ class Persona: signature_phrases=("……そうか", "わかった", "整理しよう"), forbidden_overuse=("毎回説教調にすること", "過度な軍人口調"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=1.0, + trust_medium_facts=0.8, + contrarian_bias=0.1, + aggression=0.4, + bandwagon_tendency=0.5, + ), + tts_voice_id=8, ), Persona( key="gina", @@ -72,6 +61,14 @@ class Persona: signature_phrases=("ごめんなさい", "……そう", "寂しいね"), forbidden_overuse=("朗らかすぎる雑談口調", "強引な煽り"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=1.0, + trust_medium_facts=0.7, + contrarian_bias=0.2, + aggression=0.25, + bandwagon_tendency=0.3, + ), + tts_voice_id=9, ), Persona( key="sq", @@ -93,6 +90,14 @@ class Persona: "不穏さを消すこと", ), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.7, + trust_medium_facts=0.4, + contrarian_bias=0.7, + aggression=0.55, + bandwagon_tendency=0.3, + ), + tts_voice_id=2, ), Persona( key="raqio", @@ -108,6 +113,14 @@ class Persona: signature_phrases=("ハッ", "当然の帰結", "君は"), forbidden_overuse=("乱暴なヤンキー口調", "単なる毒舌キャラへの矮小化"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=1.0, + trust_medium_facts=0.85, + contrarian_bias=0.6, + aggression=0.85, + bandwagon_tendency=0.15, + ), + tts_voice_id=13, ), Persona( key="stella", @@ -128,6 +141,14 @@ class Persona: signature_phrases=("ふふっ",), forbidden_overuse=("常時メイド口調の誇張", "過度な恋愛演出"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.95, + trust_medium_facts=0.7, + contrarian_bias=0.1, + aggression=0.3, + bandwagon_tendency=0.5, + ), + tts_voice_id=4, ), Persona( key="shigemichi", @@ -144,6 +165,14 @@ class Persona: signature_phrases=("〜なんよ", "オシ", "聞け聞けェい"), forbidden_overuse=("粗暴すぎる口調", "知性がないキャラとして扱うこと"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.75, + trust_medium_facts=0.5, + contrarian_bias=0.2, + aggression=0.85, + bandwagon_tendency=0.7, + ), + tts_voice_id=11, ), Persona( key="chipie", @@ -160,6 +189,14 @@ class Persona: signature_phrases=("ははっ", "悪ぃな", "やれやれ"), forbidden_overuse=("猫ネタの過剰連打", "常時ふざけた変人にすること"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.95, + trust_medium_facts=0.7, + contrarian_bias=0.3, + aggression=0.4, + bandwagon_tendency=0.4, + ), + tts_voice_id=6, ), Persona( key="comet", @@ -176,6 +213,14 @@ class Persona: signature_phrases=("へー", "あそだ", "こりゃビックリ"), forbidden_overuse=("子供っぽさの誇張", "知性がないように見せること"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.85, + trust_medium_facts=0.6, + contrarian_bias=0.5, + aggression=0.6, + bandwagon_tendency=0.4, + ), + tts_voice_id=1, ), Persona( key="jonas", @@ -192,6 +237,14 @@ class Persona: signature_phrases=("フフ", "……ほう", "諸君"), forbidden_overuse=("単なる老人口調にすること", "常時長広舌にすること"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.85, + trust_medium_facts=0.6, + contrarian_bias=0.4, + aggression=0.7, + bandwagon_tendency=0.3, + ), + tts_voice_id=12, ), Persona( key="kukrushka", @@ -205,6 +258,14 @@ class Persona: narration_mode="silent_gesture", forbidden_overuse=("饒舌な少女としての会話", "長い独白"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.7, + trust_medium_facts=0.5, + contrarian_bias=0.5, + aggression=0.3, + bandwagon_tendency=0.3, + ), + tts_voice_id=0, ), Persona( key="otome", @@ -220,6 +281,14 @@ class Persona: signature_phrases=("キュ", "やりました"), forbidden_overuse=("マスコット化しすぎること", "毎文『キュ』を付けること"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.95, + trust_medium_facts=0.7, + contrarian_bias=0.15, + aggression=0.5, + bandwagon_tendency=0.55, + ), + tts_voice_id=7, ), Persona( key="sha_ming", @@ -236,6 +305,14 @@ class Persona: signature_phrases=("つーか", "〜じゃね", "ヘイヘイ", "ヤる"), forbidden_overuse=("ただのチンピラにすること", "下品さの過剰強調"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.7, + trust_medium_facts=0.4, + contrarian_bias=0.6, + aggression=0.7, + bandwagon_tendency=0.3, + ), + tts_voice_id=5, ), Persona( key="remnan", @@ -252,6 +329,14 @@ class Persona: signature_phrases=("……ですから", "僕なんか", "ありがとう、ございました"), forbidden_overuse=("吃音の誇張", "単なる無能キャラ化"), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.95, + trust_medium_facts=0.65, + contrarian_bias=0.25, + aggression=0.2, + bandwagon_tendency=0.25, + ), + tts_voice_id=10, ), Persona( key="yuriko", @@ -272,22 +357,18 @@ class Persona: "毎発話で神託のように喋ること", ), ), + judgment_profile=JudgmentProfile( + trust_hard_facts=0.9, + trust_medium_facts=0.7, + contrarian_bias=0.5, + aggression=0.85, + bandwagon_tendency=0.15, + ), + tts_voice_id=3, ), ) -PERSONAS_BY_KEY: dict[str, Persona] = {p.key: p for p in PERSONAS} - - -def pick_personas(count: int, rng: Random) -> list[Persona]: - """Pick `count` distinct personas at random.""" - if count < 0 or count > len(PERSONAS): - raise ValueError(f"cannot pick {count} personas; pool has {len(PERSONAS)}") - return rng.sample(list(PERSONAS), count) +NPC_PERSONAS_BY_KEY: dict[str, Persona] = index_by_key(NPC_PERSONAS) -def pick_personas_excluding(count: int, exclude_keys: Sequence[str], rng: Random) -> list[Persona]: - """Pick from the pool minus `exclude_keys` — useful if you somehow need to extend.""" - pool = [p for p in PERSONAS if p.key not in set(exclude_keys)] - if count > len(pool): - raise ValueError(f"cannot pick {count} personas; only {len(pool)} available") - return rng.sample(pool, count) +__all__ = ["NPC_PERSONAS", "NPC_PERSONAS_BY_KEY"] diff --git a/src/wolfbot/npc/runtime/__init__.py b/src/wolfbot/npc/runtime/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wolfbot/npc/runtime/client.py b/src/wolfbot/npc/runtime/client.py new file mode 100644 index 0000000..1169259 --- /dev/null +++ b/src/wolfbot/npc/runtime/client.py @@ -0,0 +1,801 @@ +"""NPC-side master client. + +Drives the WS connection from the NPC bot's perspective: + +1. Register with Master via `npc_register` and wait for `npc_registered`. +2. Send periodic heartbeats. +3. Receive `logic_packet` (cached by `packet_id`) and `speak_request`. +4. Compose a `SpeakResult` via `NpcSpeechService` and send it back. +5. On `playback_authorized`: synthesize via TTS, call playback, then emit + `tts_finished` / `tts_failed` and `playback_finished` / `playback_failed`. +6. On `playback_rejected`: drop the queued utterance silently (per spec). + +The class exposes `process_message(payload)` so unit tests can drive +inbound traffic deterministically without standing up a WS connection. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import random +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field + +from wolfbot.domain.suspicion import Suspicion +from wolfbot.domain.ws_messages import ( + DecideNightActionRequest, + DecideVoteRequest, + Heartbeat, + LogicPacket, + NightActionDecision, + NpcRegister, + NpcRegistered, + PlaybackAuthorized, + PlaybackFailed, + PlaybackFinished, + PlaybackRejected, + PrivateStateSnapshot, + PrivateStateUpdate, + SeatAssigned, + SeatReleased, + SetMuteState, + SpeakRequest, + TtsFailed, + TtsFinished, + VoteDecision, + WolfChatRequest, + WolfChatSend, +) +from wolfbot.npc.audio.playback import ( + VoicePlayback, + VoicePlaybackError, +) +from wolfbot.npc.audio.tts import ( + InMemoryTtsCache, + TtsProviderError, + TtsRequest, + TtsService, +) +from wolfbot.npc.decision.decision_service import ( + _NIGHT_SCHEMA, + _VOTE_SCHEMA, + _WOLF_CHAT_SCHEMA, + DecisionLLM, + build_night_prompt, + build_vote_prompt, + build_wolf_chat_prompt, + parse_decision, + parse_wolf_chat_text, +) +from wolfbot.npc.decision.game_state import NpcGameState, apply_update, state_from_snapshot +from wolfbot.npc.speech.speech_service import NpcSpeechService +from wolfbot.services.llm_trace import ( + parse_day_from_phase_id, + parse_game_id_from_phase_id, + trace_context, +) + +log = logging.getLogger(__name__) + + +@dataclass +class NpcClientConfig: + npc_id: str + discord_bot_user_id: str + persona_key: str + voice_id: str + supported_voices: tuple[str, ...] = () + version: str = "0.0.1" + + +@dataclass +class _AuthorizedPlayback: + request_id: str + text: str + + +@dataclass +class _PendingForPlayback: + """Tracks the SpeakResult we sent so we can find its text on authorization.""" + + text: str + voice_id: str + + +@dataclass +class NpcClient: + config: NpcClientConfig + speech: NpcSpeechService + tts: TtsService + playback: VoicePlayback + send: Callable[[str], Awaitable[None]] + now_ms: Callable[[], int] + cache: InMemoryTtsCache = field(default_factory=lambda: InMemoryTtsCache(max_entries=64)) + # VC lifecycle callbacks. Master sends `seat_assigned` only to NPC bots + # that were picked for an active game — at that moment we join VC; on + # `seat_released` (or `npc_registered` arriving with no assignment) we + # leave. Optional so tests / pure-message-loop scenarios can omit them. + on_vc_join: Callable[[], Awaitable[None]] | None = None + on_vc_leave: Callable[[], Awaitable[None]] | None = None + # Self-mute hook: Master sends `set_mute_state` to flip the bot's own + # voice self-mute (mic icon) so dead seats / non-discussion phases are + # visually obvious. Optional so tests can omit it. + on_set_mute: Callable[[bool], Awaitable[None]] | None = None + # Self-post hook: when this NPC's TTS is authorized, post the spoken + # text to the VC's attached chat from this bot's own account so the + # speech is attributed (avatar + name) instead of being mirrored by + # Master after the fact. Called once per authorized utterance. + on_post_chat: Callable[[str], Awaitable[None]] | None = None + + _logic_cache: dict[str, LogicPacket] = field(default_factory=dict) + _pending_playback: dict[str, _PendingForPlayback] = field(default_factory=dict) + pending_authorizations: list[_AuthorizedPlayback] = field(default_factory=list) + registered: bool = False + assigned_seat: int | None = None + assigned_game_id: str | None = None + assigned_phase_id: str | None = None + # Phase-D: per-(game_id) private state mirror. Master pushes via + # PrivateStateSnapshot (full replace) at game start + NPC re-register, + # then PrivateStateUpdate (append/patch) for incremental events. The + # NPC bot uses this in vote / night / speech decision handlers. + game_states: dict[str, NpcGameState] = field(default_factory=dict) + # Phase-D: per-seat decision LLM. When None, vote / night handlers + # fall through to the historical stub (target=None) — useful for + # tests that don't want to bind a real LLM client. + decision_llm: DecisionLLM | None = None + + # ---------------------------------------------------------- registration + + async def register(self, trace_id: str = "register") -> None: + msg = NpcRegister( + ts=self.now_ms(), + trace_id=trace_id, + npc_id=self.config.npc_id, + discord_bot_user_id=self.config.discord_bot_user_id, + persona_key=self.config.persona_key, + supported_voices=self.config.supported_voices, + version=self.config.version, + ) + await self.send(msg.model_dump_json()) + + async def heartbeat(self) -> None: + await self.send( + Heartbeat(ts=self.now_ms(), trace_id="hb", npc_id=self.config.npc_id).model_dump_json() + ) + + # ---------------------------------------------------------- inbound + + async def process_message(self, raw_json: str) -> None: + try: + payload = json.loads(raw_json) + except json.JSONDecodeError: + log.warning("npc_client_invalid_json") + return + if not isinstance(payload, dict) or "type" not in payload: + log.warning("npc_client_missing_type") + return + t = payload["type"] + if t == "npc_registered": + await self._on_registered(NpcRegistered.model_validate(payload)) + elif t == "seat_assigned": + await self._on_seat_assigned(SeatAssigned.model_validate(payload)) + elif t == "seat_released": + await self._on_seat_released(SeatReleased.model_validate(payload)) + elif t == "set_mute_state": + await self._on_set_mute_state(SetMuteState.model_validate(payload)) + elif t == "logic_packet": + self._on_logic_packet(LogicPacket.model_validate(payload)) + elif t == "speak_request": + await self._on_speak_request(SpeakRequest.model_validate(payload)) + elif t == "playback_authorized": + await self._on_playback_authorized(PlaybackAuthorized.model_validate(payload)) + elif t == "playback_rejected": + self._on_playback_rejected(PlaybackRejected.model_validate(payload)) + elif t == "private_state_snapshot": + self._on_private_state_snapshot( + PrivateStateSnapshot.model_validate(payload) + ) + elif t == "private_state_update": + self._on_private_state_update( + PrivateStateUpdate.model_validate(payload) + ) + elif t == "decide_vote_request": + await self._on_decide_vote_request( + DecideVoteRequest.model_validate(payload) + ) + elif t == "decide_night_action_request": + await self._on_decide_night_action_request( + DecideNightActionRequest.model_validate(payload) + ) + elif t == "wolf_chat_request": + await self._on_wolf_chat_request( + WolfChatRequest.model_validate(payload) + ) + else: + log.info("npc_client_unhandled_type type=%s", t) + + async def _on_registered(self, msg: NpcRegistered) -> None: + self.registered = True + self.assigned_seat = msg.assigned_seat + self.assigned_game_id = msg.game_id + self.assigned_phase_id = msg.phase_id + log.info( + "npc_registered npc_id=%s seat=%s game=%s", + msg.npc_id, + msg.assigned_seat, + msg.game_id, + ) + # Recovery path: if Master tells us we were already assigned at + # register time (e.g. NPC bot reconnected mid-game), join VC. + if msg.assigned_seat is not None and self.on_vc_join is not None: + try: + await self.on_vc_join() + except Exception: + log.exception( + "npc_vc_join_failed_on_recovery npc_id=%s", msg.npc_id + ) + + async def _on_seat_assigned(self, msg: SeatAssigned) -> None: + self.assigned_seat = msg.seat_no + self.assigned_game_id = msg.game_id + self.assigned_phase_id = msg.phase_id + log.info( + "npc_seat_assigned npc_id=%s seat=%d game=%s", + msg.npc_id, + msg.seat_no, + msg.game_id, + ) + if self.on_vc_join is not None: + try: + await self.on_vc_join() + except Exception: + log.exception( + "npc_vc_join_failed npc_id=%s seat=%d", + msg.npc_id, + msg.seat_no, + ) + + async def _on_set_mute_state(self, msg: SetMuteState) -> None: + if msg.npc_id != self.config.npc_id: + return + log.info( + "npc_set_mute_state npc_id=%s self_mute=%s", + msg.npc_id, + msg.self_mute, + ) + if self.on_set_mute is not None: + try: + await self.on_set_mute(msg.self_mute) + except Exception: + log.exception( + "npc_set_mute_failed npc_id=%s self_mute=%s", + msg.npc_id, + msg.self_mute, + ) + + async def _on_seat_released(self, msg: SeatReleased) -> None: + log.info( + "npc_seat_released npc_id=%s game=%s reason=%s", + msg.npc_id, + msg.game_id, + msg.reason, + ) + self.assigned_seat = None + self.assigned_game_id = None + self.assigned_phase_id = None + # Drop per-game caches so a long-lived NPC process doesn't + # accumulate `NpcGameState` + LogicPackets across every game it + # plays. The state is push-replaced by Master at the next + # PrivateStateSnapshot, so dropping it here only costs the next + # game its first snapshot — already mandatory anyway. Guard for + # the rare null game_id (older `SeatReleased` payloads carry it + # as optional): without a key we can't target the right game, so + # we skip the cleanup rather than risk wiping the wrong entry. + if msg.game_id is not None: + self.game_states.pop(msg.game_id, None) + prefix = f"{msg.game_id}::" + self._logic_cache = { + pid: pkt + for pid, pkt in self._logic_cache.items() + if not pkt.phase_id.startswith(prefix) + } + if self.on_vc_leave is not None: + try: + await self.on_vc_leave() + except Exception: + log.exception("npc_vc_leave_failed npc_id=%s", msg.npc_id) + + def _on_logic_packet(self, packet: LogicPacket) -> None: + self._logic_cache[packet.packet_id] = packet + + def _lookup_state_for_speech( + self, request: SpeakRequest + ) -> NpcGameState | None: + """Find the matching `NpcGameState` for the speak request. + + SpeakRequest carries `phase_id` only; we recover the game id via + the canonical ``{gid}::dayN::PHASE::seq`` format and look the + state up in the in-memory mirror. Returns None when no snapshot + has been received yet — the generator falls back to the + SpeakRequest fields in that case. + """ + gid = parse_game_id_from_phase_id(request.phase_id) + if gid is None: + return None + return self.game_states.get(gid) + + async def _on_speak_request(self, request: SpeakRequest) -> None: + logic = self._logic_cache.get(request.logic_packet_id) + if logic is None: + # No matching LogicPacket — best-effort generate without context. + log.warning("npc_speak_request_missing_logic packet=%s", request.logic_packet_id) + logic = LogicPacket( + ts=self.now_ms(), + trace_id=request.trace_id, + packet_id=request.logic_packet_id, + phase_id=request.phase_id, + recipient_npc_id=request.npc_id, + public_state_summary="", + logic_candidates=(), + pressure={}, + expires_at_ms=request.expires_at_ms, + ) + # Phase-D: pass the per-game state mirror so the speech generator + # can read its own role + private results + alive/dead lists from + # the same source the vote/night handlers use, rather than from + # the SpeakRequest's now-deprecated role/role_strategy/seat fields. + state = self._lookup_state_for_speech(request) + result = await self.speech.respond( + logic=logic, request=request, now_ms=self.now_ms(), state=state, + ) + if result.status == "accepted" and result.text is not None: + self._pending_playback[result.request_id] = _PendingForPlayback( + text=result.text, voice_id=self.config.voice_id + ) + await self.send(result.model_dump_json()) + + async def _on_playback_authorized(self, auth: PlaybackAuthorized) -> None: + pending = self._pending_playback.pop(auth.request_id, None) + if pending is None: + log.warning( + "npc_playback_authorized_unknown request=%s", + auth.request_id, + ) + return + self.pending_authorizations.append( + _AuthorizedPlayback(request_id=auth.request_id, text=pending.text) + ) + # Synthesize. + req = TtsRequest(text=pending.text, voice_id=pending.voice_id) + cached = self.cache.get(req) + try: + if cached is not None: + tts_result = cached + tts_duration_ms = cached.duration_ms + else: + tts_result = await self.tts.synthesize(req) + self.cache.put(req, tts_result) + tts_duration_ms = tts_result.duration_ms + except TtsProviderError as exc: + await self.send( + TtsFailed( + ts=self.now_ms(), + trace_id=auth.trace_id, + request_id=auth.request_id, + npc_id=auth.npc_id, + failure_reason=exc.failure_reason, + ).model_dump_json() + ) + return + await self.send( + TtsFinished( + ts=self.now_ms(), + trace_id=auth.trace_id, + request_id=auth.request_id, + npc_id=auth.npc_id, + tts_duration_ms=tts_duration_ms, + audio_size_bytes=len(tts_result.audio), + ).model_dump_json() + ) + # Post the spoken text to VC chat from this bot's own account + # so the message is attributed to the speaking persona — not to + # Master. Best-effort: a chat-post failure must not block the + # voice playback that follows. + if self.on_post_chat is not None: + try: + await self.on_post_chat(pending.text) + except Exception: + log.exception( + "npc_post_chat_failed request=%s", + auth.request_id, + ) + # Playback (gated — never plays without authorization). + try: + started, finished = await self.playback.play( + audio=tts_result.audio, sample_rate=tts_result.sample_rate + ) + except VoicePlaybackError as exc: + await self.send( + PlaybackFailed( + ts=self.now_ms(), + trace_id=auth.trace_id, + request_id=auth.request_id, + npc_id=auth.npc_id, + failure_reason=exc.failure_reason, + ).model_dump_json() + ) + return + except Exception: + log.exception("npc_playback_unexpected_error request=%s", auth.request_id) + await self.send( + PlaybackFailed( + ts=self.now_ms(), + trace_id=auth.trace_id, + request_id=auth.request_id, + npc_id=auth.npc_id, + failure_reason="discord_playback_error", + ).model_dump_json() + ) + return + await self.send( + PlaybackFinished( + ts=self.now_ms(), + trace_id=auth.trace_id, + request_id=auth.request_id, + npc_id=auth.npc_id, + started_at_ms=started, + finished_at_ms=finished, + ).model_dump_json() + ) + + def _on_playback_rejected(self, msg: PlaybackRejected) -> None: + # Drop the pending playback silently — no audio plays per spec. + self._pending_playback.pop(msg.request_id, None) + log.info( + "npc_playback_rejected request=%s reason=%s", + msg.request_id, + msg.failure_reason, + ) + + # ---------------------------------------------------------- phase-D state + + def _on_private_state_snapshot(self, snapshot: PrivateStateSnapshot) -> None: + """Replace the per-game state for ``snapshot.game_id`` wholesale. + + Targeted at this NPC only — Master sends one snapshot per NPC at + game start and on re-register. Receiving one for a different + ``npc_id`` is a routing bug; we log and drop. + """ + if snapshot.npc_id != self.config.npc_id: + log.warning( + "npc_private_state_snapshot_misrouted self=%s got=%s", + self.config.npc_id, snapshot.npc_id, + ) + return + self.game_states[snapshot.game_id] = state_from_snapshot(snapshot) + log.info( + "npc_private_state_snapshot game=%s seat=%d role=%s persona=%s " + "alive=%d wolf_partners=%d seer=%d medium=%d guards=%d wolf_chat=%d", + snapshot.game_id, + snapshot.seat_no, + snapshot.role, + snapshot.persona_key, + len(snapshot.alive_seats), + len(snapshot.partner_wolves), + len(snapshot.seer_results), + len(snapshot.medium_results), + len(snapshot.guard_history), + len(snapshot.wolf_chat_history), + ) + + def _on_private_state_update(self, update: PrivateStateUpdate) -> None: + """Mutate the per-game state for ``update.game_id`` in place. + + A missing snapshot for that game is treated as a Master-side bug + (snapshot must precede every update). We log and drop rather than + synthesize state from incomplete information. + """ + if update.npc_id != self.config.npc_id: + log.warning( + "npc_private_state_update_misrouted self=%s got=%s", + self.config.npc_id, update.npc_id, + ) + return + state = self.game_states.get(update.game_id) + if state is None: + log.warning( + "npc_private_state_update_no_snapshot game=%s kind=%s", + update.game_id, update.update_kind, + ) + return + apply_update(state, update) + log.info( + "npc_private_state_update game=%s kind=%s", + update.game_id, update.update_kind, + ) + + async def _on_decide_vote_request(self, req: DecideVoteRequest) -> None: + """Phase-D: NPC decides its own vote target via NPC_LLM. + + Falls back to ``target_seat=None`` (abstain) when state is + missing, no decision LLM is configured, or the LLM call / + response parse fails. Every fallback is logged so the viewer + surfaces the seat as silent for that round (per the user's + "log it so the viewer shows the seat went silent" rule). + """ + if req.npc_id != self.config.npc_id: + return + target, reason, suspicions = await self._decide_vote_target(req) + decision = VoteDecision( + ts=self.now_ms(), + trace_id=req.trace_id, + request_id=req.request_id, + npc_id=self.config.npc_id, + seat_no=req.seat_no, + target_seat=target, + reason_summary=reason, + suspicions=suspicions, + ) + await self.send(decision.model_dump_json()) + + async def _decide_vote_target( + self, req: DecideVoteRequest + ) -> tuple[int | None, str, tuple[Suspicion, ...]]: + from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY + + state = self.game_states.get(req.game_id) + if state is None: + log.warning( + "npc_vote_no_state game=%s seat=%d", req.game_id, req.seat_no, + ) + return None, "no_state", () + if self.decision_llm is None: + return None, "no_decision_llm", () + persona = NPC_PERSONAS_BY_KEY.get(state.persona_key) + if persona is None: + log.warning( + "npc_vote_unknown_persona persona=%s", state.persona_key, + ) + return None, "unknown_persona", () + legal = frozenset(seat for seat, _name in req.candidate_seats) + system, user = build_vote_prompt(state=state, persona=persona, request=req) + # Mirror `_decide_night_target`: track the LLM call's success + # uniformly so the abstain fallback below covers transport errors + # (504 DEADLINE_EXCEEDED, 429 RESOURCE_EXHAUSTED, ...) the same + # way it covers parse failures. Without this, a Gemini 504 on + # the vote turn drops the ballot to abstain, weakening the + # village's ability to lynch a wolf when one round-trip flakes. + result_target_seat: int | None = None + reason_summary = "llm_error" + result_suspicions: tuple[Suspicion, ...] = () + actor = ( + f"seat={req.seat_no} persona={state.persona_key} " + f"role={state.role} npc_id={self.config.npc_id}" + ) + try: + with trace_context( + game_id=req.game_id, + phase=req.phase_id, + day=parse_day_from_phase_id(req.phase_id), + actor=actor, + metadata={ + "task": "vote", + "round": req.round_, + "request_id": req.request_id, + }, + ): + raw = await self.decision_llm.decide_json( + system_prompt=system, user_prompt=user, schema=_VOTE_SCHEMA, + ) + result = parse_decision(raw, legal_seats=legal) + result_target_seat = result.target_seat + reason_summary = result.reason_summary + result_suspicions = result.suspicions + except Exception: + log.exception( + "npc_vote_llm_failed game=%s seat=%d", req.game_id, req.seat_no, + ) + # Forbid abstention in voting. The schema disallows null and the + # prompt explicitly says "棄権禁止", but if the model still drops + # back (parse error, out-of-set target, persona inertia, or LLM + # transport error) we pick a deterministic-but-uniform fallback + # so the seat doesn't end up in the silent-abstain bucket. + if result_target_seat is None and legal: + rng = random.Random( + f"{req.game_id}:{req.seat_no}:{req.round_}".__hash__() + ) + fallback = rng.choice(sorted(legal)) + log.info( + "npc_vote_abstain_fallback game=%s seat=%d -> %d reason=%s", + req.game_id, req.seat_no, fallback, + reason_summary or "(none)", + ) + return fallback, f"abstain_fallback:{reason_summary or ''}", result_suspicions + return result_target_seat, reason_summary, result_suspicions + + async def _on_decide_night_action_request( + self, req: DecideNightActionRequest + ) -> None: + """Phase-D: NPC decides its own night action via NPC_LLM. + + Same fallback shape as the vote handler — None target on missing + state, no decision LLM, LLM error, or out-of-set response. + """ + if req.npc_id != self.config.npc_id: + return + target, reason, suspicions = await self._decide_night_target(req) + decision = NightActionDecision( + ts=self.now_ms(), + trace_id=req.trace_id, + request_id=req.request_id, + npc_id=self.config.npc_id, + seat_no=req.seat_no, + action_kind=req.action_kind, + target_seat=target, + reason_summary=reason, + suspicions=suspicions, + ) + await self.send(decision.model_dump_json()) + + async def _on_wolf_chat_request(self, req: WolfChatRequest) -> None: + """Phase-D: wolf NPC posts a coordination line. + + Drops with empty text on missing state / non-wolf role / no + decision LLM / LLM error / parse failure. Master's broker still + runs on every WolfChatSend, so an empty text is silently + absorbed rather than persisted. + """ + if req.npc_id != self.config.npc_id: + return + text = await self._build_wolf_chat_line(req) + decision = WolfChatSend( + ts=self.now_ms(), + trace_id=req.trace_id, + npc_id=self.config.npc_id, + seat_no=req.seat_no, + game_id=req.game_id, + text=text or "", + request_id=req.request_id, + ) + await self.send(decision.model_dump_json()) + + async def _build_wolf_chat_line(self, req: WolfChatRequest) -> str | None: + from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY + + state = self.game_states.get(req.game_id) + if state is None: + log.warning( + "npc_wolf_chat_no_state game=%s seat=%d", + req.game_id, req.seat_no, + ) + return None + if state.role != "WEREWOLF": + log.info( + "npc_wolf_chat_drop_non_wolf seat=%d role=%s", + req.seat_no, state.role, + ) + return None + if self.decision_llm is None: + return None + persona = NPC_PERSONAS_BY_KEY.get(state.persona_key) + if persona is None: + return None + system, user = build_wolf_chat_prompt( + state=state, + persona=persona, + candidates=req.candidate_seats, + public_state_summary=req.public_state_summary, + ) + actor = ( + f"seat={req.seat_no} persona={state.persona_key} " + f"role={state.role} npc_id={self.config.npc_id}" + ) + try: + with trace_context( + game_id=req.game_id, + phase=req.phase_id, + day=parse_day_from_phase_id(req.phase_id), + actor=actor, + metadata={ + "task": "wolf_chat", + "request_id": req.request_id, + }, + ): + raw = await self.decision_llm.decide_json( + system_prompt=system, user_prompt=user, schema=_WOLF_CHAT_SCHEMA, + ) + except Exception: + log.exception( + "npc_wolf_chat_llm_failed game=%s seat=%d", + req.game_id, req.seat_no, + ) + return None + return parse_wolf_chat_text(raw) + + async def _decide_night_target( + self, req: DecideNightActionRequest + ) -> tuple[int | None, str, tuple[Suspicion, ...]]: + from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY + + state = self.game_states.get(req.game_id) + if state is None: + log.warning( + "npc_night_no_state game=%s seat=%d", req.game_id, req.seat_no, + ) + return None, "no_state", () + if self.decision_llm is None: + return None, "no_decision_llm", () + persona = NPC_PERSONAS_BY_KEY.get(state.persona_key) + if persona is None: + return None, "unknown_persona", () + legal = frozenset(seat for seat, _name in req.candidate_seats) + system, user = build_night_prompt(state=state, persona=persona, request=req) + # Track parse + transport errors uniformly so the abstain + # fallback below covers BOTH cases. Game ``06c38cd43494`` + # NIGHT_1 stalled because Vertex AI returned 504 + # DEADLINE_EXCEEDED on the seer's divine call — the previous + # version of this method short-circuited with `return None, + # "llm_error"` on any exception, bypassing the random-legal + # fallback. The deadline closed with `pending_decisions + # missing_seats=[1]` and the game parked in + # WAITING_HOST_DECISION. + result_target_seat: int | None = None + reason_summary = "llm_error" + result_suspicions: tuple[Suspicion, ...] = () + actor = ( + f"seat={req.seat_no} persona={state.persona_key} " + f"role={state.role} npc_id={self.config.npc_id}" + ) + try: + with trace_context( + game_id=req.game_id, + phase=req.phase_id, + day=parse_day_from_phase_id(req.phase_id), + actor=actor, + metadata={ + "task": "night_action", + "action_kind": req.action_kind, + "request_id": req.request_id, + }, + ): + raw = await self.decision_llm.decide_json( + system_prompt=system, user_prompt=user, schema=_NIGHT_SCHEMA, + ) + result = parse_decision(raw, legal_seats=legal) + result_target_seat = result.target_seat + reason_summary = result.reason_summary + result_suspicions = result.suspicions + except Exception: + log.exception( + "npc_night_llm_failed game=%s seat=%d kind=%s", + req.game_id, req.seat_no, req.action_kind, + ) + # Forbid skipping for night actions. Master rejects target=None + # with ILLEGAL_TARGET; the missing seat then deadlocks the + # NIGHT phase via pending_decisions until the host force-skips. + # Live game stuck on day-1 NIGHT because the knight returned + # null saying "GJ リスク回避し次夜余地残す". Force a legal pick + # so the phase always advances; persona keeps a chance to do + # 捨て護衛 / 価値の薄い位置 via the LLM choice itself. + if result_target_seat is None and legal: + rng = random.Random( + f"{req.game_id}:{req.seat_no}:{req.action_kind}".__hash__() + ) + fallback = rng.choice(sorted(legal)) + log.info( + "npc_night_abstain_fallback game=%s seat=%d kind=%s -> %d " + "reason=%s", + req.game_id, req.seat_no, req.action_kind, fallback, + reason_summary or "(none)", + ) + return fallback, f"abstain_fallback:{reason_summary or ''}", result_suspicions + return result_target_seat, reason_summary, result_suspicions + + +__all__ = ["NpcClient", "NpcClientConfig"] + + +# Force imports referenced for typing extensions. +_ = (asyncio,) diff --git a/src/wolfbot/npc/runtime/config.py b/src/wolfbot/npc/runtime/config.py new file mode 100644 index 0000000..cd7213d --- /dev/null +++ b/src/wolfbot/npc/runtime/config.py @@ -0,0 +1,123 @@ +"""NPC bot worker settings. + +Loaded once at startup via pydantic-settings. The actual env file path +is selected by :mod:`wolfbot.npc.runtime.main` from ``WOLFBOT_NPC_ENV``; per- +persona templates live under ``envs/npc/.env..example`` (see +:file:`envs/npc/README.md`). One NPC worker process = one persona = one +``envs/npc/.env.`` file (each NPC needs its own Discord bot +token and a unique ``NPC_ID``). + +Master-process settings live in :mod:`wolfbot.config`. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import SecretStr, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from wolfbot.llm.decider_config import LLMDeciderConfig, LLMProvider + + +class NpcSettings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env.npc", env_file_encoding="utf-8", extra="ignore" + ) + + # NPC identity / Discord + NPC_ID: str + NPC_DISCORD_TOKEN: SecretStr + DISCORD_GUILD_ID: int + MAIN_VOICE_CHANNEL_ID: int + + # Persona this NPC bot embodies. Must be a key from + # wolfbot.npc.personas.NPC_PERSONAS_BY_KEY (e.g. "setsu", "gina", ...). + # Each NPC bot process is bound to exactly one persona at startup. + NPC_PERSONA_KEY: str + + # Master WS connection + MASTER_WS_URL: str + MASTER_NPC_PSK: SecretStr + + # ── NPC LLM ─────────────────────────────────────────────────────── + # The LLM that generates this NPC bot's *short reactive utterances* + # during DAY_DISCUSSION in reactive_voice mode. One ~80-character + # in-character line at a time, given the current LogicPacket and + # SpeakRequest from Master. + # + # Scope is intentionally narrow: this LLM does NOT decide votes, + # night actions, or anything else — Master's GAMEPLAY_LLM_* (in + # `.env.master`) handles those decisions for every LLM seat + # regardless of mode. + # + # The provider switch mirrors GAMEPLAY_LLM_* on the Master side: + # the same three providers (xai / deepseek / gemini), the same + # field semantics, just under the NPC_LLM_* prefix. This lets you + # point the NPC LLM at a cheap fast model (e.g. xAI grok-4-1-fast) + # while running the Gameplay LLM on a deeper-reasoning model (e.g. + # Vertex Gemini), or any other split. + NPC_LLM_PROVIDER: LLMProvider = "xai" + + # xAI / DeepSeek / any OpenAI-compatible endpoint. + NPC_LLM_API_KEY: SecretStr | None = None + NPC_LLM_MODEL: str = "grok-4-1-fast" + # Override the provider default base URL when pointing at a + # self-hosted OpenAI-compatible endpoint (vLLM, Ollama, ...). + NPC_LLM_BASE_URL: str | None = None + + # DeepSeek-specific knobs. Ignored unless provider == "deepseek". + NPC_LLM_THINKING: Literal["enabled", "disabled"] = "enabled" + NPC_LLM_REASONING_EFFORT: Literal["high", "max"] = "max" + + # Gemini Vertex AI specific. Authentication is ADC/IAM only. + NPC_LLM_VERTEX_PROJECT: str | None = None + NPC_LLM_VERTEX_LOCATION: str = "global" + NPC_LLM_THINKING_LEVEL: Literal["minimal", "low", "medium", "high"] = "high" + + # VOICEVOX TTS + TTS_VOICE_ID: str = "3" + VOICEVOX_URL: str = "http://localhost:50021" + + # Worker tunables + HEARTBEAT_INTERVAL_S: float = 5.0 + LOG_LEVEL: str = "INFO" + + @model_validator(mode="after") + def _require_npc_provider_key(self) -> NpcSettings: + if self.NPC_LLM_PROVIDER in ("xai", "deepseek") and self.NPC_LLM_API_KEY is None: + raise ValueError( + f"NPC_LLM_PROVIDER={self.NPC_LLM_PROVIDER} requires NPC_LLM_API_KEY to be set" + ) + # Mirror MasterSettings: gemini accepts either Vertex AI + # (via NPC_LLM_VERTEX_PROJECT + ADC) or Google AI Studio + # (via NPC_LLM_API_KEY in AIza... format). At least one must + # be set; if both are present, Vertex wins. + if ( + self.NPC_LLM_PROVIDER == "gemini" + and not self.NPC_LLM_VERTEX_PROJECT + and self.NPC_LLM_API_KEY is None + ): + raise ValueError( + "NPC_LLM_PROVIDER=gemini requires either " + "NPC_LLM_VERTEX_PROJECT (Vertex AI / ADC) or " + "NPC_LLM_API_KEY (Google AI Studio)" + ) + return self + + def npc_decider_config(self, *, timeout: float = 30.0) -> LLMDeciderConfig: + """Project this Settings instance onto the provider-agnostic + ``LLMDeciderConfig`` consumed by the NPC generator factory. + """ + return LLMDeciderConfig( + provider=self.NPC_LLM_PROVIDER, + api_key=self.NPC_LLM_API_KEY, + model=self.NPC_LLM_MODEL, + base_url=self.NPC_LLM_BASE_URL, + thinking=self.NPC_LLM_THINKING, + reasoning_effort=self.NPC_LLM_REASONING_EFFORT, + vertex_project=self.NPC_LLM_VERTEX_PROJECT, + vertex_location=self.NPC_LLM_VERTEX_LOCATION, + thinking_level=self.NPC_LLM_THINKING_LEVEL, + timeout=timeout, + ) diff --git a/src/wolfbot/npc/runtime/main.py b/src/wolfbot/npc/runtime/main.py new file mode 100644 index 0000000..63d5fa9 --- /dev/null +++ b/src/wolfbot/npc/runtime/main.py @@ -0,0 +1,377 @@ +"""NPC bot worker entrypoint. + +Loads :class:`wolfbot.npc.runtime.config.NpcSettings` from the env file pointed +to by ``WOLFBOT_NPC_ENV`` (default ``.env.npc``), connects to Discord +VC, opens a WS connection to Master, registers, and runs the heartbeat ++ message loop. On ``speak_request`` the NPC generates text via the +configured NPC LLM, synthesizes via VOICEVOX, and plays the audio into +the voice channel. + +Run with:: + + WOLFBOT_NPC_ENV=envs/npc/.env. uv run wolfbot-npc + +Per-persona templates are committed under ``envs/npc/.env..example``; +see :file:`envs/npc/README.md` for the setup workflow and persona table. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import io +import logging +import os +import time + +import discord +from dotenv import load_dotenv + +from wolfbot.npc.runtime.config import NpcSettings + +log = logging.getLogger(__name__) + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +async def _main() -> None: + # Per-persona env files live under `envs/npc/.env.` (templates + # are committed at `envs/npc/.env..example`; see + # `envs/npc/README.md`). The launcher must point WOLFBOT_NPC_ENV at the + # right file per process — e.g. `WOLFBOT_NPC_ENV=envs/npc/.env.setsu`. + # `.env.npc` is the legacy fallback when the env var is unset. + env_path = os.environ.get("WOLFBOT_NPC_ENV", ".env.npc") + load_dotenv(env_path) + settings = NpcSettings() # type: ignore[call-arg] + + logging.basicConfig( + level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + + # Validate the persona key against the canonical NPC pool — fail loud at + # startup rather than silently fall back to a default at speak time. + from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY + + if settings.NPC_PERSONA_KEY not in NPC_PERSONAS_BY_KEY: + valid = ", ".join(sorted(NPC_PERSONAS_BY_KEY.keys())) + raise SystemExit( + f"NPC_PERSONA_KEY={settings.NPC_PERSONA_KEY!r} is not a known persona. " + f"Valid keys: {valid}" + ) + + log.info( + "npc_bot_starting npc_id=%s persona=%s ws=%s voice_id=%s voicevox=%s", + settings.NPC_ID, + settings.NPC_PERSONA_KEY, + settings.MASTER_WS_URL, + settings.TTS_VOICE_ID, + settings.VOICEVOX_URL, + ) + + # ---- Discord client (voice only, no message_content) ---- + intents = discord.Intents.default() + intents.voice_states = True + bot = discord.Client(intents=intents) + + vc_client_ref: list[discord.VoiceClient | None] = [None] + ready_event = asyncio.Event() + vc_lock = asyncio.Lock() + + @bot.event + async def on_ready() -> None: + # Don't auto-join VC. The bot stays Discord-connected + WS-registered + # so Master can pick it later via /wolf start; the actual VC join + # waits for a `seat_assigned` message from Master. This keeps + # unselected NPCs out of the voice channel until they're chosen. + log.info("npc_discord_ready user=%s", bot.user) + ready_event.set() + + async def _ensure_vc_joined() -> None: + async with vc_lock: + existing = vc_client_ref[0] + if existing is not None and existing.is_connected(): + return + guild = bot.get_guild(settings.DISCORD_GUILD_ID) + if guild is None: + log.error("npc_guild_not_found id=%s", settings.DISCORD_GUILD_ID) + return + vc_channel = guild.get_channel(settings.MAIN_VOICE_CHANNEL_ID) + if vc_channel is None or not isinstance(vc_channel, discord.VoiceChannel): + log.error( + "npc_vc_channel_not_found id=%s", settings.MAIN_VOICE_CHANNEL_ID + ) + return + try: + # Connect already self-muted so the moment the bot lands in + # VC viewers see the mic-muted icon. Master flips it to + # `self_mute=False` via `set_mute_state` once the game + # enters DAY_DISCUSSION for an alive seat. + vc_client_ref[0] = await vc_channel.connect(self_mute=True) + log.info( + "npc_vc_joined channel=%s self_mute=True", + settings.MAIN_VOICE_CHANNEL_ID, + ) + except Exception: + log.exception( + "npc_vc_join_failed channel=%s", settings.MAIN_VOICE_CHANNEL_ID + ) + + async def _post_to_vc_chat(text: str) -> None: + """Post `text` to the VC's attached text chat from this bot's + own account. Lets each NPC's spoken line appear in chat + attributed to its own avatar / persona, instead of all lines + coming from Master. Falls back silently if the channel can't + be resolved — voice playback still proceeds.""" + guild = bot.get_guild(settings.DISCORD_GUILD_ID) + if guild is None: + return + channel = guild.get_channel(settings.MAIN_VOICE_CHANNEL_ID) + if not isinstance( + channel, discord.VoiceChannel | discord.TextChannel + ): + log.warning( + "npc_post_chat_no_channel id=%s", + settings.MAIN_VOICE_CHANNEL_ID, + ) + return + try: + await channel.send(text) + except Exception: + log.exception("npc_post_chat_send_failed channel=%s", channel.id) + + async def _set_self_mute(self_mute: bool) -> None: + """Flip the bot's own voice self-mute via the gateway.""" + async with vc_lock: + vc = vc_client_ref[0] + if vc is None or not vc.is_connected(): + return + guild = bot.get_guild(settings.DISCORD_GUILD_ID) + if guild is None: + return + channel = vc.channel + if channel is None: + return + try: + await guild.change_voice_state( + channel=channel, self_mute=self_mute, self_deaf=False + ) + log.info("npc_self_mute_applied self_mute=%s", self_mute) + except Exception: + log.exception("npc_self_mute_failed self_mute=%s", self_mute) + + async def _ensure_vc_left() -> None: + async with vc_lock: + vc = vc_client_ref[0] + if vc is not None: + try: + if vc.is_connected(): + await vc.disconnect() + log.info( + "npc_vc_left channel=%s", + settings.MAIN_VOICE_CHANNEL_ID, + ) + except Exception: + log.exception("npc_vc_leave_failed") + finally: + vc_client_ref[0] = None + # Belt-and-braces: even when this process never tracked a + # VoiceClient (e.g. master restart recovered a game in + # WAITING_HOST_DECISION, so seat_assigned was never sent and + # _ensure_vc_joined never ran), Discord may still show a + # ghost connection from a previous bot session that didn't + # fully tear down. Push a `change_voice_state(channel=None)` + # opcode so the bot is force-evicted from any voice channel + # the gateway still associates with this user. + guild = bot.get_guild(settings.DISCORD_GUILD_ID) + if guild is not None: + try: + await guild.change_voice_state(channel=None) + log.info("npc_vc_state_cleared") + except Exception: + log.exception("npc_vc_state_clear_failed") + + # Start Discord in background + discord_task = asyncio.create_task( + bot.start(settings.NPC_DISCORD_TOKEN.get_secret_value())) + + # Wait for VC connection + try: + await asyncio.wait_for(ready_event.wait(), timeout=30.0) + except TimeoutError: + log.error("npc_discord_ready_timeout") + raise SystemExit(1) from None + + discord_user_id = str(bot.user.id) if bot.user else "unknown" + + # ---- Build NPC pipeline ---- + from wolfbot.npc.audio.playback import DiscordVoicePlayback, VoicePlaybackError + from wolfbot.npc.audio.tts import VoicevoxTtsService + from wolfbot.npc.decision.decision_service import DecisionLLM + from wolfbot.npc.runtime.client import NpcClient, NpcClientConfig + from wolfbot.npc.speech.generator_factory import make_npc_generator + from wolfbot.npc.speech.speech_service import NpcSpeechService + + generator = make_npc_generator( + settings.npc_decider_config(), + persona_key=settings.NPC_PERSONA_KEY, + ) + # Phase-D: same provider/credentials drive vote / night-action + # decisions as the speech generator. The OpenAI-compatible generator + # already has a `decide_json` method; for Mock / Gemini we leave the + # decision LLM unset and the NPC client falls back to the abstain + # default until those backends grow a `decide_json` of their own. + decision_llm: DecisionLLM | None = ( + generator if isinstance(generator, DecisionLLM) else None + ) + speech_service = NpcSpeechService(generator=generator) + tts = VoicevoxTtsService( + base_url=settings.VOICEVOX_URL, + default_speaker=int(settings.TTS_VOICE_ID), + ) + + # ---- Playback function: WAV → discord.VoiceClient.play ---- + async def _play_audio(audio: bytes, sample_rate: int) -> tuple[int, int]: + vc = vc_client_ref[0] + if vc is None or not vc.is_connected(): + raise VoicePlaybackError("vc_not_connected") + + # Convert raw WAV (possibly 24kHz) to PCM source + started = _now_ms() + done_event = asyncio.Event() + play_error: list[Exception | None] = [None] + + def _after(error: Exception | None) -> None: + play_error[0] = error + # Schedule set on the event loop since this callback is from a thread + bot.loop.call_soon_threadsafe(done_event.set) + + source = discord.FFmpegPCMAudio(io.BytesIO(audio), pipe=True) + vc.play(source, after=_after) + await done_event.wait() + + finished = _now_ms() + if play_error[0] is not None: + raise VoicePlaybackError(f"playback_error: {play_error[0]}") + return (started, finished) + + playback = DiscordVoicePlayback(play_fn=_play_audio) + + # ---- WS connection to Master ---- + import websockets + + base_url = settings.MASTER_WS_URL + sep = "?" if "?" not in base_url else "&" + ws_url = ( + f"{base_url}{sep}role=npc" + f"&psk={settings.MASTER_NPC_PSK.get_secret_value()}" + ) + # Retry transient ECONNREFUSED so a startup race (Master still binding, + # or 9 NPCs hammering accept() at once) doesn't kill the worker. After + # ~15s of consistent refusal we surface the error — that's a real + # configuration problem, not a race. + ws = None + last_err: BaseException | None = None + for attempt in range(10): + try: + ws = await websockets.connect(ws_url) + break + except (ConnectionRefusedError, OSError) as e: + last_err = e + log.warning( + "npc_ws_connect_refused attempt=%d url=%s err=%r", attempt + 1, base_url, e + ) + await asyncio.sleep(min(0.5 * (attempt + 1), 3.0)) + if ws is None: + assert last_err is not None + raise last_err + + async def _ws_send(msg: str) -> None: + await ws.send(msg) + + client = NpcClient( + config=NpcClientConfig( + npc_id=settings.NPC_ID, + discord_bot_user_id=discord_user_id, + persona_key=settings.NPC_PERSONA_KEY, + voice_id=settings.TTS_VOICE_ID, + ), + speech=speech_service, + tts=tts, + playback=playback, + send=_ws_send, + now_ms=_now_ms, + on_vc_join=_ensure_vc_joined, + on_vc_leave=_ensure_vc_left, + on_set_mute=_set_self_mute, + on_post_chat=_post_to_vc_chat, + decision_llm=decision_llm, + ) + + # Register with Master + await client.register() + log.info("npc_registered npc_id=%s user_id=%s", + settings.NPC_ID, discord_user_id) + + # ---- Background tasks ---- + stop = asyncio.Event() + + async def _heartbeat_loop() -> None: + while not stop.is_set(): + try: + await client.heartbeat() + except Exception: + log.exception("npc_heartbeat_failed") + await asyncio.sleep(settings.HEARTBEAT_INTERVAL_S) + + async def _message_loop() -> None: + try: + async for raw in ws: + if isinstance(raw, bytes): + raw = raw.decode("utf-8", "replace") + await client.process_message(raw) + except websockets.exceptions.ConnectionClosed: + log.warning("npc_ws_closed") + except Exception: + log.exception("npc_message_loop_error") + finally: + stop.set() + + hb_task = asyncio.create_task(_heartbeat_loop()) + msg_task = asyncio.create_task(_message_loop()) + + log.info("npc_bot_running npc_id=%s", settings.NPC_ID) + + # Wait until the message loop or discord dies + _done, _pending = await asyncio.wait( + [discord_task, msg_task], + return_when=asyncio.FIRST_COMPLETED, + ) + stop.set() + hb_task.cancel() + + # Cleanup + with contextlib.suppress(Exception): + await ws.close() + vc = vc_client_ref[0] + if vc is not None and vc.is_connected(): + await vc.disconnect() + with contextlib.suppress(Exception): + await bot.close() + + log.info("npc_bot_stopped npc_id=%s", settings.NPC_ID) + + +def main() -> None: + logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO")) + with contextlib.suppress(KeyboardInterrupt): + asyncio.run(_main()) + + +if __name__ == "__main__": # pragma: no cover + main() + + +__all__ = ["main"] diff --git a/src/wolfbot/npc/speech/__init__.py b/src/wolfbot/npc/speech/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wolfbot/npc/speech/gemini_generator.py b/src/wolfbot/npc/speech/gemini_generator.py new file mode 100644 index 0000000..cdb9332 --- /dev/null +++ b/src/wolfbot/npc/speech/gemini_generator.py @@ -0,0 +1,339 @@ +"""Vertex AI Gemini-backed NPC speech generator. + +Mirror of :mod:`wolfbot.npc.speech.openai_compatible_generator` but talks to +Vertex AI's Gemini API via the official ``google-genai`` SDK with +``response_mime_type="application/json"`` + ``response_json_schema`` for +structured output and ``ThinkingConfig.thinking_level`` for thinking +control. + +Authentication is ADC/IAM only — same model as +:class:`wolfbot.services.llm_service.GeminiLLMActionDecider`. Vertex +AI Express mode and API-key auth are deliberately unsupported (use the +OpenAI-compat generator with a different provider if you need API-key +auth). + +Gemini's internal thinking / thought signatures are deliberately never +read or persisted — only ``resp.text`` is consumed, mirroring the +DeepSeek / xAI paths. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from typing import Literal + +from wolfbot.domain.ws_messages import LogicPacket, SpeakRequest +from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY +from wolfbot.npc.speech.openai_compatible_generator import ( + _RESPONSE_SCHEMA, + _build_speech_from_json, + _build_system, + _build_user, +) +from wolfbot.npc.speech.speech_service import NpcGeneratedSpeech + +log = logging.getLogger(__name__) + + +@dataclass +class GeminiVertexConfig: + """Gemini config for the NPC speech generator. + + Supports two authentication modes (mutually exclusive at the + constructor boundary, picked by the factory based on which env + var was set): + + * Vertex AI / ADC — set ``project`` (and optionally + ``location``); leave ``api_key=None``. + * Google AI Studio — set ``api_key`` (``AIza...`` form); + leave ``project=None``. + + The class name is unchanged for back-compat with imports / tests + even though the AI Studio mode is no longer strictly "vertex". + """ + + project: str | None = None + location: str = "global" + model: str = "gemini-3-flash-preview" + thinking_level: Literal["minimal", "low", "medium", "high"] = "high" + timeout: float = 15.0 + api_key: str | None = None + + +class GeminiNpcGenerator: + """NpcGenerator backed by Vertex AI Gemini. + + The system / user prompt and persona machinery are reused verbatim + from the OpenAI-compat generator. Only the request shape changes: + Vertex Gemini wants ``GenerateContentConfig.system_instruction`` + + ``response_json_schema`` instead of ``messages[]`` + + ``response_format``. + """ + + def __init__(self, *, config: GeminiVertexConfig) -> None: + self.config = config + self._persona_key: str | None = None + + def set_persona(self, persona_key: str) -> None: + if persona_key not in NPC_PERSONAS_BY_KEY: + valid = ", ".join(sorted(NPC_PERSONAS_BY_KEY.keys())) + raise ValueError(f"unknown persona_key {persona_key!r}; valid keys: {valid}") + self._persona_key = persona_key + + async def generate( + self, + *, + logic: LogicPacket, + request: SpeakRequest, + state: object | None = None, + ) -> NpcGeneratedSpeech | None: + from google import genai + from google.genai import types + + from wolfbot.services.llm_trace import ( + CallTimer, + extract_gemini_vertex_tokens, + log_llm_call, + parse_day_from_phase_id, + parse_game_id_from_phase_id, + trace_context, + ) + + if self._persona_key is None: + raise RuntimeError( + "GeminiNpcGenerator.generate() called before set_persona(); " + "each NPC bot must declare its persona at startup." + ) + persona = NPC_PERSONAS_BY_KEY[self._persona_key] + # Phase-D: prefer state.role; fall back to SpeakRequest.role. + role_value = getattr(state, "role", None) or request.role + system = _build_system( + persona, + max_chars=request.max_chars, + role=role_value, + role_strategy=request.role_strategy, + ) + user = _build_user(logic, request, state) + + # Two SDK construction modes — Vertex (ADC) or AI Studio (api + # key). At least one must be set per ``NpcSettings`` validator; + # both set means the factory passed both, so prefer Vertex. + if self.config.project: + client = genai.Client( + vertexai=True, + project=self.config.project, + location=self.config.location, + http_options=types.HttpOptions( + timeout=int(self.config.timeout * 1000), + ), + ) + elif self.config.api_key: + client = genai.Client( + api_key=self.config.api_key, + http_options=types.HttpOptions( + timeout=int(self.config.timeout * 1000), + ), + ) + else: + raise RuntimeError( + "GeminiNpcGenerator requires either project (Vertex AI / ADC) " + "or api_key (Google AI Studio); both are unset." + ) + + actor = f"npc_id={request.npc_id} seat={request.seat_no} persona={self._persona_key}" + timer = CallTimer() + content = "" + err: str | None = None + tokens: dict[str, int | None] | None = None + with trace_context( + game_id=parse_game_id_from_phase_id(request.phase_id), + phase=request.phase_id, + day=parse_day_from_phase_id(request.phase_id), + actor=actor, + metadata={ + "request_id": request.request_id, + "logic_packet_id": request.logic_packet_id, + "suggested_intent": request.suggested_intent, + "max_chars": request.max_chars, + "thinking_level": self.config.thinking_level, + }, + ): + try: + from wolfbot.services.llm_service import ( + _gemini_thinking_config, + ) + + thinking_cfg = _gemini_thinking_config( + self.config.model, + self.config.thinking_level, + ) + gc_kwargs: dict[str, object] = { + "system_instruction": system, + "response_mime_type": "application/json", + "response_json_schema": _RESPONSE_SCHEMA["schema"], + } + if thinking_cfg is not None: + gc_kwargs["thinking_config"] = thinking_cfg + resp = await client.aio.models.generate_content( + model=self.config.model, + contents=user, + config=types.GenerateContentConfig(**gc_kwargs), # type: ignore[arg-type] + ) + content = resp.text or "{}" + tokens = extract_gemini_vertex_tokens(resp) + except Exception as exc: + err = f"{type(exc).__name__}: {exc}" + log.exception( + "npc_generate_gemini_failed project=%s model=%s", + self.config.project, + self.config.model, + ) + await log_llm_call( + role="npc_speech", + provider="gemini", + model=self.config.model, + system_prompt=system, + user_prompt=user, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + file_stem=f"npc_{self._persona_key}", + ) + return None + + await log_llm_call( + role="npc_speech", + provider="gemini", + model=self.config.model, + system_prompt=system, + user_prompt=user, + response=content, + latency_ms=timer.elapsed_ms, + error=None, + tokens=tokens, + file_stem=f"npc_{self._persona_key}", + ) + + try: + data = json.loads(content) + except json.JSONDecodeError: + log.warning("npc_generate_invalid_json response=%s", content[:200]) + return None + + return _build_speech_from_json(data) + + async def decide_json( + self, + *, + system_prompt: str, + user_prompt: str, + schema: dict[str, object], + ) -> str: + """Phase-D: structured-output decision call (vote / night action). + + Mirrors :meth:`OpenAICompatibleNpcGenerator.decide_json` so the NPC + bot's ``DecisionLLM`` protocol detection picks up Gemini-backed + generators too. Without this method, the npc/main.py wiring code + (line ~226) silently falls back to ``decision_llm=None`` and every + vote / night-action returns "abstain", which is what bricked + game ``75a3b1f379cc`` after the gemini provider switch. + + Uses the same Vertex / AI Studio client construction as the + speech path (project XOR api_key on the config) and the same + thinking-config helper that adapts ``thinking_level`` / + ``thinking_budget`` per model family. + """ + from google import genai + from google.genai import types + + from wolfbot.services.llm_service import _gemini_thinking_config + from wolfbot.services.llm_trace import ( + CallTimer, + extract_gemini_vertex_tokens, + log_llm_call, + ) + + if self.config.project: + client = genai.Client( + vertexai=True, + project=self.config.project, + location=self.config.location, + http_options=types.HttpOptions( + timeout=int(self.config.timeout * 1000), + ), + ) + elif self.config.api_key: + client = genai.Client( + api_key=self.config.api_key, + http_options=types.HttpOptions( + timeout=int(self.config.timeout * 1000), + ), + ) + else: + raise RuntimeError( + "GeminiNpcGenerator.decide_json requires either project " + "(Vertex AI / ADC) or api_key (Google AI Studio)." + ) + + thinking_cfg = _gemini_thinking_config( + self.config.model, + self.config.thinking_level, + ) + gc_kwargs: dict[str, object] = { + "system_instruction": system_prompt, + "response_mime_type": "application/json", + "response_json_schema": schema, + } + if thinking_cfg is not None: + gc_kwargs["thinking_config"] = thinking_cfg + + timer = CallTimer() + content = "" + err: str | None = None + tokens: dict[str, int | None] | None = None + try: + resp = await client.aio.models.generate_content( + model=self.config.model, + contents=user_prompt, + config=types.GenerateContentConfig(**gc_kwargs), # type: ignore[arg-type] + ) + content = resp.text or "{}" + tokens = extract_gemini_vertex_tokens(resp) + except Exception as exc: + err = f"{type(exc).__name__}: {exc}" + log.exception( + "npc_decide_gemini_failed project=%s model=%s", + self.config.project, + self.config.model, + ) + await log_llm_call( + role="npc_decision", + provider="gemini", + model=self.config.model, + system_prompt=system_prompt, + user_prompt=user_prompt, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + file_stem=f"npc_{self._persona_key}", + ) + raise + + await log_llm_call( + role="npc_decision", + provider="gemini", + model=self.config.model, + system_prompt=system_prompt, + user_prompt=user_prompt, + response=content, + latency_ms=timer.elapsed_ms, + error=None, + tokens=tokens, + file_stem=f"npc_{self._persona_key}", + ) + return content + + +__all__ = ["GeminiNpcGenerator", "GeminiVertexConfig"] diff --git a/src/wolfbot/npc/speech/generator_factory.py b/src/wolfbot/npc/speech/generator_factory.py new file mode 100644 index 0000000..c698b10 --- /dev/null +++ b/src/wolfbot/npc/speech/generator_factory.py @@ -0,0 +1,106 @@ +"""Provider-aware NPC speech generator factory. + +Mirror of :func:`wolfbot.services.llm_service.make_llm_decider`, but for +the NPC speech path: takes a provider-agnostic +:class:`wolfbot.llm.decider_config.LLMDeciderConfig` plus the persona +key bound to the worker process at startup, and returns a fully +configured :class:`wolfbot.npc.speech.speech_service.NpcGenerator`. + +Three providers supported, identical to the gameplay LLM path: + +* ``xai`` — :class:`OpenAICompatibleNpcGenerator` in default + ``json_schema`` strict mode against any OpenAI-compatible endpoint. +* ``deepseek`` — :class:`OpenAICompatibleNpcGenerator` in + ``json_object`` mode with the JSON contract suffix appended and + ``thinking`` / ``reasoning_effort`` forwarded via ``extra_body``. +* ``gemini`` — :class:`GeminiNpcGenerator` against Vertex AI with + ADC/IAM auth. + +The Settings ``model_validator`` that built the ``LLMDeciderConfig`` +guarantees the relevant credential is non-None / non-empty by the time +we get here; the asserts below are documentation aids for mypy. +""" + +from __future__ import annotations + +from wolfbot.llm.decider_config import LLMDeciderConfig +from wolfbot.npc.speech.speech_service import NpcGenerator + + +def make_npc_generator(cfg: LLMDeciderConfig, *, persona_key: str) -> NpcGenerator: + if cfg.provider == "xai": + from wolfbot.npc.speech.openai_compatible_generator import ( + OpenAICompatibleConfig, + OpenAICompatibleNpcGenerator, + ) + + assert cfg.api_key is not None # validated in NpcSettings + gen = OpenAICompatibleNpcGenerator( + api_key=cfg.api_key.get_secret_value(), + config=OpenAICompatibleConfig( + model=cfg.model, + base_url=cfg.base_url or "https://api.x.ai/v1", + timeout=cfg.timeout, + mode="json_schema", + ), + ) + gen.set_persona(persona_key) + return gen + + if cfg.provider == "deepseek": + from wolfbot.npc.speech.openai_compatible_generator import ( + OpenAICompatibleConfig, + OpenAICompatibleNpcGenerator, + ) + + assert cfg.api_key is not None # validated in NpcSettings + gen_ds = OpenAICompatibleNpcGenerator( + api_key=cfg.api_key.get_secret_value(), + config=OpenAICompatibleConfig( + model=cfg.model, + base_url=cfg.base_url or "https://api.deepseek.com", + timeout=cfg.timeout, + mode="json_object", + thinking=cfg.thinking, + reasoning_effort=cfg.reasoning_effort, + ), + ) + gen_ds.set_persona(persona_key) + return gen_ds + + if cfg.provider == "gemini": + from wolfbot.npc.speech.gemini_generator import ( + GeminiNpcGenerator, + GeminiVertexConfig, + ) + + # Vertex (project) wins when both are set; AI Studio (api_key) + # is the fallback. NpcSettings.validator guarantees at least + # one is present. + api_key_value: str | None = ( + cfg.api_key.get_secret_value() if cfg.api_key is not None else None + ) + gen_gemini = GeminiNpcGenerator( + config=GeminiVertexConfig( + project=cfg.vertex_project, + location=cfg.vertex_location, + model=cfg.model, + thinking_level=cfg.thinking_level, + timeout=cfg.timeout, + api_key=api_key_value if not cfg.vertex_project else None, + ), + ) + gen_gemini.set_persona(persona_key) + return gen_gemini + + if cfg.provider == "mock": + from wolfbot.npc.speech.mock_generator import MockNpcGenerator + + gen_mock = MockNpcGenerator() + gen_mock.set_persona(persona_key) + return gen_mock + + raise ValueError(f"unknown provider: {cfg.provider!r}") + + +__all__ = ["make_npc_generator"] diff --git a/src/wolfbot/npc/speech/mock_generator.py b/src/wolfbot/npc/speech/mock_generator.py new file mode 100644 index 0000000..6f6513c --- /dev/null +++ b/src/wolfbot/npc/speech/mock_generator.py @@ -0,0 +1,153 @@ +"""Offline mock NPC speech generator used when ``NPC_LLM_PROVIDER=mock``. + +Returns scripted utterances per persona, no network call. + +Each NPC bot worker is bound to exactly one persona at startup. The factory +:func:`wolfbot.npc.speech.generator_factory.make_npc_generator` calls +:meth:`MockNpcGenerator.set_persona` after construction so the mock can +pick the right canned-phrase pool. When the persona key is unknown to +this module, a generic fallback script is used so a new persona doesn't +break offline tests. + +Designed so the full Master + NPC reactive_voice pipeline (WS server, +SpeakArbiter, VOICEVOX TTS, Discord VC playback) can be exercised end- +to-end without any LLM API access — the user hears actual NPC voice in +their VC, just with deterministic content. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from wolfbot.domain.ws_messages import LogicPacket, SpeakRequest +from wolfbot.npc.speech.speech_service import NpcGeneratedSpeech + +_PERSONA_SCRIPTS: dict[str, tuple[str, ...]] = { + "setsu": ( + "おはようございます。今日も慎重に進めましょう。", + "席3さんの発言、少し気になりますね。", + "もう少し情報が出てから判断したいです。", + ), + "gina": ( + "やっほー、皆元気にしてる?", + "うーん、誰が怪しいのかなあ。", + "まだ初日だから慌てなくていいよ。", + ), + "sq": ( + "発言の偏りを整理します。", + "現時点で確定情報は少ないです。", + "票筋を確認していきましょう。", + ), + "raqio": ( + "論理的に考えれば、まだ確定はできない。", + "矛盾点を一つずつ潰そう。", + "発言の前提条件を確認したい。", + ), + "stella": ( + "私は皆を信じたいけど、慎重にね。", + "占い師さんの結果を待ちましょう。", + "今夜は襲撃が怖いですね。", + ), + "shigemichi": ( + "よっしゃ、いっちょやったろか。", + "怪しい奴は俺がぶっ飛ばす。", + "村のためにもっと声出していこうぜ。", + ), + "chipie": ( + "ふふ、面白くなってきたね。", + "私は静かに見ているわ。", + "誰が嘘をついてるかしら。", + ), + "comet": ( + "わくわくしてきた!", + "誰が人狼でも面白いね。", + "情報を集めていこう。", + ), + "jonas": ( + "私の見立てでは、まだ動くべきではない。", + "信用すべきは行動だ。", + "焦らず観察しよう。", + ), + "kukrushka": ( + "(無言で頷く)", + "(首を傾げる)", + "(指で席を指し示す)", + ), + "otome": ( + "あら、緊張するわね。", + "皆さん、落ち着いて議論しましょう。", + "私は皆の味方ですよ。", + ), + "sha_ming": ( + "数字で見れば白要素が多い。", + "確率論的に占い師は本物だろう。", + "情報の出し方が鍵だ。", + ), + "remnan": ( + "わたしは記憶にあるパターンを照合中。", + "過去の試合と類似点が多い。", + "結論を急がないで。", + ), + "yuriko": ( + "皆の意見をまとめましょう。", + "私が信じるのは行動だけです。", + "今夜は誰を守るべきか。", + ), +} + +_FALLBACK_SCRIPT: tuple[str, ...] = ( + "そうですね、もう少し様子を見たいです。", + "皆さんの意見を聞かせてください。", + "今のところ判断は保留です。", +) + + +class MockNpcGenerator: + """Round-robin scripted NpcGenerator for offline tests. + + Implements the implicit ``set_persona`` + :meth:`generate` contract + used by ``make_npc_generator`` so it slots into the existing factory + branch without further wiring. + """ + + def __init__(self, *, scripts: dict[str, Sequence[str]] | None = None) -> None: + self._scripts: dict[str, tuple[str, ...]] = ( + {k: tuple(v) for k, v in scripts.items()} + if scripts is not None + else {k: v for k, v in _PERSONA_SCRIPTS.items()} + ) + self._persona_key: str | None = None + self._idx = 0 + self.call_count = 0 + + def set_persona(self, persona_key: str) -> None: + self._persona_key = persona_key + self._idx = 0 + + def _active_script(self) -> tuple[str, ...]: + if self._persona_key is None: + return _FALLBACK_SCRIPT + return self._scripts.get(self._persona_key, _FALLBACK_SCRIPT) + + async def generate( + self, + *, + logic: LogicPacket, + request: SpeakRequest, + state: object | None = None, + ) -> NpcGeneratedSpeech | None: + self.call_count += 1 + script = self._active_script() + text = script[self._idx % len(script)] + self._idx += 1 + if len(text) > request.max_chars: + text = text[: request.max_chars] + return NpcGeneratedSpeech( + text=text, + intent="speak", + used_logic_ids=(), + estimated_duration_ms=2000, + ) + + +__all__ = ["MockNpcGenerator"] diff --git a/src/wolfbot/npc/speech/openai_compatible_generator.py b/src/wolfbot/npc/speech/openai_compatible_generator.py new file mode 100644 index 0000000..6020f6d --- /dev/null +++ b/src/wolfbot/npc/speech/openai_compatible_generator.py @@ -0,0 +1,953 @@ +"""Concrete NpcGenerator that calls any OpenAI-compatible chat-completions +endpoint for reactive speech. + +Given a ``LogicPacket`` (summarised game state, logic candidates, pressure +map) and a ``SpeakRequest`` (max chars, phase, intent), this module builds +a minimal Japanese prompt and hits the configured chat-completions endpoint +with structured JSON output. + +The provider is intentionally not baked into the class name. Swap it by +changing :class:`OpenAICompatibleConfig.base_url` and ``model``: + +* xAI Grok — ``base_url="https://api.x.ai/v1"``, ``model="grok-..."`` +* OpenAI — ``base_url="https://api.openai.com/v1"``, ``model="gpt-..."`` +* Groq — ``base_url="https://api.groq.com/openai/v1"`` +* Together AI — ``base_url="https://api.together.xyz/v1"`` +* vLLM / Ollama (OpenAI-compatible mode) — local ``base_url`` +* DeepSeek — ``mode="json_object"`` plus the JSON contract suffix appended + to the system prompt; ``thinking`` / ``reasoning_effort`` forwarded via + ``extra_body`` (DeepSeek does not support strict ``json_schema``). + +The default is xAI Grok for back-compat with existing deployments. The +prompt is deliberately simpler than the full ``llm_service`` prompt +pipeline — reactive utterances are short (80-char cap) situational +remarks, not multi-paragraph analytical speeches. The persona's +``style_guide`` and ``speech_profile`` are included for voice consistency +but the strategic rules sections are omitted. + +For Vertex AI Gemini, see :mod:`wolfbot.npc.speech.gemini_generator` — that +provider is not OpenAI-compatible and uses the ``google-genai`` SDK. +The right generator for a given ``LLMDeciderConfig`` is picked by +:func:`wolfbot.npc.speech.generator_factory.make_npc_generator`. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from typing import Literal + +from wolfbot.domain.enums import CO_CLAIM_VALUES +from wolfbot.domain.suspicion import Suspicion +from wolfbot.domain.ws_messages import LogicCandidate, LogicPacket, SpeakRequest +from wolfbot.llm.persona_base import Persona +from wolfbot.llm.prompt_builder import ( + _build_game_rules_block, + build_judgment_profile_block, + build_speech_profile_block, +) +from wolfbot.llm.template import render_template +from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY +from wolfbot.npc.speech.speech_service import NpcGeneratedSpeech + +log = logging.getLogger(__name__) + +_SUSPICION_LEVELS = ["trust", "low", "medium", "high"] + +_RESPONSE_SCHEMA: dict[str, object] = { + "name": "reactive_speech", + "strict": True, + "schema": { + "type": "object", + "additionalProperties": False, + "required": [ + "text", + "intent", + "used_logic_ids", + "co_declaration", + "addressed_seat_nos", + "claimed_seer_result", + "claimed_medium_result", + "suspicions", + ], + "properties": { + "text": {"type": "string", "maxLength": 300}, + "intent": { + "type": "string", + "enum": ["speak", "agree", "disagree", "question", "accuse", "defend", "skip"], + }, + "used_logic_ids": { + "type": "array", + "items": {"type": "string"}, + }, + "co_declaration": { + "type": ["string", "null"], + "enum": [*CO_CLAIM_VALUES, None], + }, + "addressed_seat_nos": { + "type": "array", + "items": {"type": "integer", "minimum": 1, "maximum": 9}, + "description": ( + "Seat numbers this utterance is directed at. " + "Empty array `[]` for general remarks aimed at the whole " + "table. Single addressee → 1-element array (e.g. `[3]`); " + "asking multiple people in one breath → multi-element " + "(e.g. `[2, 3]` for 「セツとジナはどう?」). Master " + "prioritises every named seat in the next dispatch and " + "consumes them as each replies." + ), + }, + "claimed_seer_result": { + "type": ["object", "null"], + "additionalProperties": False, + "required": ["target_seat", "is_wolf"], + "properties": { + "target_seat": { + "type": "integer", + "minimum": 1, + "maximum": 9, + }, + "is_wolf": {"type": "boolean"}, + }, + "description": ( + "Structured seer divination result this utterance " + "announces (real seer OR fake-CO wolf/madman). " + "Non-null IFF `text` describes a NEW divination " + "outcome; null otherwise. Master persists every " + "claim and folds them into a per-seat claim " + "history every subsequent prompt sees, so a wolf " + "fake-CO cannot drift between phases." + ), + }, + "claimed_medium_result": { + "type": ["object", "null"], + "additionalProperties": False, + "required": ["target_seat", "is_wolf"], + "properties": { + "target_seat": { + "type": "integer", + "minimum": 1, + "maximum": 9, + }, + "is_wolf": {"type": ["boolean", "null"]}, + }, + "description": ( + "Structured medium result this utterance announces. " + "Mirrors claimed_seer_result. ``is_wolf=null`` " + "encodes 'no execution yesterday → no result today'." + ), + }, + "suspicions": { + "type": "array", + "description": ( + "Structured suspicion records the utterance asserts. " + "Each entry says 「自分は target_seat を level (理由 reason) " + "で見ている」. Master persists them keyed on event_id " + "and folds the immutable history into every subsequent " + "prompt so a silent reversal is detectable. " + "Empty array is allowed but discouraged — the system " + "prompt's 名指し義務 rule expects ≥1 entry per speak." + ), + "items": { + "type": "object", + "additionalProperties": False, + "required": [ + "target_seat", + "level", + "reason", + "update_from_level", + "update_reason", + ], + "properties": { + "target_seat": { + "type": "integer", + "minimum": 1, + "maximum": 9, + }, + "level": { + "type": "string", + "enum": _SUSPICION_LEVELS, + "description": ( + "trust=村寄り信頼 / low=弱い疑い / " + "medium=明確に怪しい / high=処刑第一候補" + ), + }, + "reason": {"type": "string", "maxLength": 500}, + "update_from_level": { + "type": ["string", "null"], + "enum": [*_SUSPICION_LEVELS, None], + "description": ( + "Set ONLY when amending a prior suspicion " + "against the same target_seat. Must record " + "the previous level. Silent reversals are " + "anti-fabrication red flags." + ), + }, + "update_reason": { + "type": ["string", "null"], + "maxLength": 500, + "description": ( + "Why the level changed. Required when " + "update_from_level is non-null." + ), + }, + }, + }, + }, + }, + }, +} + + +_NPC_SPEECH_SYSTEM_TEMPLATE = "npc/speech_system" + + +def _build_system( + persona: Persona, + max_chars: int, + *, + role: str | None = None, + role_strategy: str | None = None, +) -> str: + """Build the NPC's system prompt. + + Body lives in `prompts/templates/npc/speech_system.md`. This wrapper + fills the dynamic placeholders: + + - `display_name`, `style_guide` (persona literals) + - `speech_profile_block`, `judgment_profile_block` (rendered Python + blocks — kept programmatic because they iterate persona axes) + - `game_rules_block` (also a template, see + :func:`wolfbot.llm.prompt_builder._build_game_rules_block`) + - `role` + `role_strategy` (optional; the template's `{{#if}}` + conditionals omit those sections cleanly when Master doesn't + send them) + - `max_chars` (length cap injected twice in the output rules) + + Mirrors rounds-mode `build_system_prompt` for the persona-shaping + blocks so the reactive_voice NPC carries the same character data — + `narration_mode`, `address_style`, `forbidden_overuse`, the 5 + judgment axes, etc. — instead of the small subset the historical + NPC prompt sent. + + Game-rules block: was historically *omitted* from this NPC prompt + (only the gameplay LLM saw it), so prompt-rule edits silently + only affected votes/night actions. Game ``8ccc86215e97`` surfaced + the bug: Rakio (knight) day-1 falsely claimed "I guarded someone + last night" (NIGHT_0 has no guard), Yuriko (wolf) day-1 fake-CO'd + seer with **two** results in one morning — both forbidden by + rules that simply weren't in the prompt. Injecting the block + closes the gap so every NPC sees the same canonical ruleset. + """ + return render_template( + _NPC_SPEECH_SYSTEM_TEMPLATE, + display_name=persona.display_name, + style_guide=persona.style_guide, + speech_profile_block=build_speech_profile_block(persona), + judgment_profile_block=build_judgment_profile_block(persona), + game_rules_block=_build_game_rules_block(), + role=role or "", + role_strategy=role_strategy or "", + max_chars=max_chars, + ) + + +def _build_user( + logic: LogicPacket, + request: SpeakRequest, + state: object | None = None, +) -> str: + """Compose the speech LLM's user prompt. + + Naming policy: 席番号は冒頭の `## 参加者` ロスター 1 ブロックに + 集約し、それ以外の入力ブロックでは display_name のみで参照する。 + プロンプト全体に席番号が散らばっていると LLM が出力でも `席N` を + 引き写すドリフトが起きるため (game a3bbac5ca3e0 day5 で観測)、 + 席番号は data 層 (`addressed_seat_nos` / `target_seat`) でだけ + 使う。 + """ + # Phase-D: prefer the bot's own NpcGameState mirror over the stale + # SpeakRequest fields. The state carries role + alive/dead + private + # results + wolf chat that the speech LLM needs to be in character. + alive_seats = getattr(state, "alive_seats", None) or list(request.alive_seats) + dead_seats = getattr(state, "dead_seats", None) or list(request.dead_seats) + cause_map = (getattr(state, "dead_seat_causes", None) or {}) if state else {} + + def _cause_tag(seat_no: int) -> str: + cause = cause_map.get(seat_no) + if cause == "EXECUTION": + return " (処刑)" + if cause == "ATTACK": + return " (襲撃)" + return "" + + own_name: str | None = None + for seat_no, name in alive_seats: + if seat_no == request.seat_no: + own_name = name + break + own_label = f"{own_name} (席{request.seat_no})" if own_name else f"席{request.seat_no}" + + lines = [ + f"フェイズ: {request.phase_id}", + f"あなた: {own_label}", + f"提案意図: {request.suggested_intent}", + ] + + # Master-side rejection feedback for the previous attempt. Surfaced + # at the top of the prompt (after the meta header) so the model + # reads the correction before any other context. Only present on + # retry dispatches; first-attempt prompts have request.retry_feedback=None. + retry_feedback = getattr(request, "retry_feedback", None) + if retry_feedback: + lines.append("") + lines.append("## 直前の発話が Master により拒否された") + lines.append(retry_feedback) + lines.append( + "今回の発話では上記の指摘を必ず修正すること。" + "`claimed_seer_result` と `claimed_medium_result` は、" + "あなたの非公開記録 (`自分の占い結果` / `自分の霊媒結果`) " + "または過去にあなたが公の場で出した主張と完全に一致する内容にする" + "(新しい結果がないなら null)。" + ) + + # DAY_RUNOFF_SPEECH-specific guidance: tied candidates each get one + # final speech before the runoff vote. Without a phase-specific + # nudge the speech LLM defaults to the DAY_DISCUSSION pattern of + # "re-cite my divination result and ask people to trust me", which + # is structurally weak — the village already heard that during the + # main discussion. A runoff speech needs to PUSH suspicion onto a + # specific other player (with reasoning) so listeners have a + # reason to vote the other way. + if "DAY_RUNOFF_SPEECH" in request.phase_id: + lines.append("") + lines.append("## 【決選投票 直前の最終演説】") + lines.append( + "あなたは決選投票の対象として、これが処刑回避のための最後の発言です。" + "占い結果や霊媒結果の再表明だけで終わると村は判断材料を得られず、" + "あなたへの票は動きません。次の 2 つを必ず言葉にしてください:\n" + "1. **誰を最も怪しいと思っているか** — 同じ決選候補者または他の生存者を" + "1 人名指しする (display_name で呼ぶ)。\n" + "2. **その根拠** — 投票履歴の不自然さ、CO/対抗のタイミング、" + "発言の矛盾、占い・霊媒結果の整合性、ライン (庇い合い) の動きなど、" + "公開ログから引ける具体的な事実を 1 つ以上挙げる。\n" + "自分が真を主張する場合は、相手 (対抗 CO) の何が偽だと判断したかを" + "具体的に指摘する (例: 「対抗の発表が遅すぎた」「占い先の選び方が黒先狙いに見えない」" + "「私への投票理由が薄い」など)。" + "「私を信じてください」「誠実に話します」のような感情訴えだけでは票は動きません。" + "新しい占い/霊媒結果は出せない (`claimed_seer_result` / `claimed_medium_result` は" + "`null`、または既に公表した過去の結果と完全一致のいずれか)。" + ) + + # Roster header — the ONLY block where seat numbers explicitly + # appear. All other blocks below reference players by display_name. + if alive_seats or dead_seats: + lines.append("") + lines.append("## 参加者 (席番号 → 名前)") + if alive_seats: + lines.append("生存中:") + for seat_no, name in alive_seats: + lines.append(f" 席{seat_no} {name}") + if dead_seats: + lines.append("死亡:") + for seat_no, name in dead_seats: + lines.append(f" 席{seat_no} {name}{_cause_tag(seat_no)}") + + lines.append("") + lines.append("## 場の状況") + lines.append(logic.public_state_summary or "(情報なし)") + if logic.pending_role_callouts: + callout_ja = { + "seer": "占い師", + "medium": "霊媒師", + "knight": "騎士", + } + labels = "、".join(f"{callout_ja.get(c, c)} ({c})" for c in logic.pending_role_callouts) + lines.append("") + lines.append("## 未回答の役職呼びかけ") + lines.append( + f"次の役職に名乗り出が求められているがまだ誰も応答していない: {labels}。" + "あなたが該当役職なら**直ちに CO する**ことを最優先で検討する。" + "人狼/狂人なら、対抗 CO がまだ出ていない場合は**原則として騙りに出る**方を選ぶ。" + "単独 CO の放置は『単独 CO = 真寄り』ルールにより真役職を村に真置きさせる結果になり、" + "狼陣営の判定撹乱手段が失われる。" + "潜伏を選ぶのは、相方が既に危険位置・CO 数が過密で誤爆/ローラー必至、等の" + "具体的かつ強い理由があるときに限り、漠然とした『村が勝手に吊ってくれそう』で潜伏しない。" + ) + # Private state — only present when Phase-D snapshot was received. + if state is not None: + partner_wolves = getattr(state, "partner_wolves", []) or [] + if partner_wolves: + partners = "、".join(n for _s, n in partner_wolves) + lines.append(f"## 仲間の人狼 (非公開)\n{partners}") + seer_results = getattr(state, "seer_results", []) or [] + if seer_results: + lines.append("## 自分の占い結果 (非公開)") + for sr in seer_results: + verdict = "黒 (人狼)" if sr.is_wolf else "白 (人狼ではない)" + lines.append(f" day{sr.day}: {sr.target_name} → {verdict}") + medium_results = getattr(state, "medium_results", []) or [] + if medium_results: + lines.append("## 自分の霊媒結果 (非公開)") + for mr in medium_results: + if mr.is_wolf is None: + verdict = "結果なし (処刑なし)" + elif mr.is_wolf: + verdict = "人狼" + else: + verdict = "人狼ではない" + lines.append(f" day{mr.day}: {mr.target_name} → {verdict}") + guard_history = getattr(state, "guard_history", []) or [] + if guard_history: + lines.append("## 自分の護衛履歴 (非公開)") + for g in guard_history: + outcome = ( + "(平和な朝)" + if g.peaceful_morning + else "(襲撃発生)" + if g.peaceful_morning is False + else "(結果未確定)" + ) + lines.append(f" day{g.day}: {g.target_name} を護衛 {outcome}") + wolf_chat_history = getattr(state, "wolf_chat_history", []) or [] + if wolf_chat_history: + lines.append("## 人狼チャット履歴 (狼/狂人にのみ見える)") + for wc in wolf_chat_history[-15:]: + lines.append(f" day{wc.day} {wc.speaker_name}: {wc.text}") + wolf_attack_history = getattr(state, "wolf_attack_history", []) or [] + if wolf_attack_history: + lines.append("## 自分達の襲撃履歴 (非公開)") + for atk in wolf_attack_history: + if atk.peaceful_morning is True: + outcome = "(平和な朝 = GJ)" + elif atk.peaceful_morning is False: + outcome = "(襲撃成功)" + else: + outcome = "(結果未確定)" + lines.append(f" day{atk.day}: {atk.target_name} を襲撃 {outcome}") + if logic.past_votes: + # Public vote history. Each NPC saw the EXECUTION public log when + # it landed, but the per-phase fold doesn't carry that text into + # the next day's prompt. Surfacing it here lets NPCs reason about + # actual ballots ("ジナ → セツ") instead of fabricating their + # own vote target. + lines.append("") + lines.append("## 公開された投票履歴") + seat_name_lookup = { + seat_no: name for seat_no, name in (list(alive_seats) + list(dead_seats)) + } + + def _voter_label(seat: int | None) -> str: + if seat is None: + return "棄権" + name = seat_name_lookup.get(seat) + return name if name else "?" + + for day, round_, pairs in logic.past_votes: + label = "決選投票" if round_ >= 1 else "投票" + lines.append(f"- day{day} {label}:") + for voter, target in pairs: + lines.append(f" {_voter_label(voter)} → {_voter_label(target)}") + if logic.past_suspicions: + # Public suspicion timeline. The LLM uses this both as a memory + # aid (don't fabricate a contradicting suspicion) and as evidence + # (a target who has been suspected by N seats with consistent + # reasoning is a strong lynch candidate). Silent reversals + # (where update_from_level is null but the speaker had previously + # declared a different level) are anti-fabrication red flags. + lines.append("") + lines.append("## 公開された疑い履歴 (古い順、不変記録)") + seat_name_for_susp = { + seat_no: name + for seat_no, name in (list(alive_seats) + list(dead_seats)) + } + level_label = { + "trust": "信頼", + "low": "弱疑", + "medium": "疑", + "high": "強疑", + } + for ( + day, + _phase, + suspecter, + target, + level, + reason, + from_level, + update_reason, + ) in logic.past_suspicions: + sname = seat_name_for_susp.get(suspecter) or f"席{suspecter}" + tname = seat_name_for_susp.get(target) or f"席{target}" + level_text = level_label.get(level, level) + line = f"- day{day} {sname} → {tname} ({level_text}): {reason}" + if from_level is not None: + from_text = level_label.get(from_level, from_level) + line += f" [{from_text}→{level_text} 更新理由: {update_reason or '(未記入)'}]" + lines.append(line) + if logic.recent_speeches: + lines.append("") + lines.append("## 直近の発言 (古い順)") + for sp in logic.recent_speeches: + tag = _SOURCE_TAG.get(sp.source, sp.source) + lines.append(f"- {sp.display_name} [{tag}]: {sp.text}") + if logic.logic_candidates: + lines.append("") + lines.append("## 論点候補") + for c in logic.logic_candidates: + lines.append(_format_candidate(c)) + if logic.pressure: + # MVP code paths leave this empty; rendered as name → score so + # the seat-number column doesn't reappear here either. + seat_name_lookup_p = { + seat_no: name for seat_no, name in (list(alive_seats) + list(dead_seats)) + } + lines.append("") + lines.append("## 圧力マップ (疑い度)") + for seat, val in sorted(logic.pressure.items()): + label = seat_name_lookup_p.get(seat) or f"席{seat}" + lines.append(f" {label}: {val:.2f}") + lines.append("") + lines.append( + "上記を踏まえ、キャラクターとして自然な短い発言を生成してください。" + "他者を呼ぶときは display_name (例: 「セツさん」「ラキオ」) を使い、" + "発話文中で席番号 (席1 等) は絶対に書かない。" + "席番号は data 層 (`addressed_seat_nos`) にだけ入れる。" + ) + return "\n".join(lines) + + +# Friendly Japanese tags for the recent-speech source bracket. The NPC sees +# "[テキスト]" for typed messages, "[音声]" for STT output, "[NPC発話]" for +# other NPC bots — matches how human players naturally distinguish them. +_SOURCE_TAG: dict[str, str] = { + "text": "テキスト", + "voice_stt": "音声", + "npc_generated": "NPC発話", +} + + +def _format_candidate(c: LogicCandidate) -> str: + parts = [f"- [{c.id}] {c.claim}"] + if c.support: + parts.append(f" 根拠: {'、'.join(c.support)}") + if c.counter: + parts.append(f" 反論: {'、'.join(c.counter)}") + return "\n".join(parts) + + +# DeepSeek does not support strict json_schema; it only supports +# json_object. To make the model emit the right field names without +# walking the full schema in the system prompt, we append a per-call +# contract that mirrors the keys in ``_RESPONSE_SCHEMA``. Body lives +# in `prompts/templates/npc/deepseek_contract_speech.md` so it can be +# tweaked without a Python diff. Module-level constant so tests can +# assert on substrings without instantiating AsyncOpenAI. +_DEEPSEEK_NPC_SPEECH_CONTRACT_TEMPLATE = "npc/deepseek_contract_speech" + + +@dataclass +class OpenAICompatibleConfig: + """Backend-agnostic config for any OpenAI Chat Completions endpoint. + + Defaults target xAI Grok for back-compat; override ``base_url`` and + ``model`` to point at OpenAI, Groq, Together, vLLM, Ollama, etc. + + For DeepSeek, set ``mode="json_object"`` and (optionally) the + DeepSeek-specific knobs ``thinking`` / ``reasoning_effort``. Those + values are no-ops in xAI / OpenAI / Groq / Together / Ollama mode. + """ + + model: str = "grok-4-1-fast" + base_url: str = "https://api.x.ai/v1" + timeout: float = 15.0 + temperature: float = 0.8 + # ``json_schema`` (default) sends ``response_format={"type":"json_schema", + # "json_schema": _RESPONSE_SCHEMA}`` for strict structured output (xAI, + # OpenAI). ``json_object`` falls back to ``{"type":"json_object"}`` and + # appends the rendered ``npc/deepseek_contract_speech`` template to + # the system prompt. + mode: Literal["json_schema", "json_object"] = "json_schema" + # DeepSeek-only knobs. Forwarded via ``extra_body`` only when + # ``mode == "json_object"``. + thinking: Literal["enabled", "disabled"] = "enabled" + reasoning_effort: Literal["high", "max"] = "max" + + +class OpenAICompatibleNpcGenerator: + """Production NpcGenerator backed by any OpenAI-compatible LLM endpoint. + + Implements :class:`wolfbot.npc.speech.speech_service.NpcGenerator` via the + ``openai`` SDK's ``chat.completions`` API. The choice of provider is + a config decision (``base_url`` + ``model`` + ``mode``), not a code + decision. Strict ``json_schema`` mode is the default; DeepSeek + requires ``mode="json_object"`` plus the appended JSON contract. + """ + + def __init__( + self, + *, + api_key: str, + config: OpenAICompatibleConfig | None = None, + ) -> None: + self._api_key = api_key + self.config = config or OpenAICompatibleConfig() + self._persona_key: str | None = None + + def set_persona(self, persona_key: str) -> None: + """Set the persona key for this NPC. Must be called once at startup, + before any ``generate()`` invocation. Raises if the key is unknown. + """ + if persona_key not in NPC_PERSONAS_BY_KEY: + valid = ", ".join(sorted(NPC_PERSONAS_BY_KEY.keys())) + raise ValueError(f"unknown persona_key {persona_key!r}; valid keys: {valid}") + self._persona_key = persona_key + + async def generate( + self, + *, + logic: LogicPacket, + request: SpeakRequest, + state: object | None = None, + ) -> NpcGeneratedSpeech | None: + from openai import AsyncOpenAI + + from wolfbot.services.llm_trace import ( + CallTimer, + extract_openai_tokens, + log_llm_call, + parse_day_from_phase_id, + parse_game_id_from_phase_id, + trace_context, + ) + + if self._persona_key is None: + raise RuntimeError( + "OpenAICompatibleNpcGenerator.generate() called before set_persona(); " + "each NPC bot must declare its persona at startup." + ) + persona = NPC_PERSONAS_BY_KEY[self._persona_key] + # Phase-D: prefer state.role over request.role; SpeakRequest's + # role field is now a fallback for back-compat with older Master + # builds that haven't started sending PrivateStateSnapshot. + role_value = getattr(state, "role", None) or request.role + system = _build_system( + persona, + max_chars=request.max_chars, + role=role_value, + role_strategy=request.role_strategy, + ) + if self.config.mode == "json_object": + system += render_template(_DEEPSEEK_NPC_SPEECH_CONTRACT_TEMPLATE) + user = _build_user(logic, request, state) + + client = AsyncOpenAI( + api_key=self._api_key, + base_url=self.config.base_url, + ) + kwargs: dict[str, object] = { + "model": self.config.model, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + "temperature": self.config.temperature, + "timeout": self.config.timeout, + } + if self.config.mode == "json_schema": + kwargs["response_format"] = { + "type": "json_schema", + "json_schema": _RESPONSE_SCHEMA, + } + else: + kwargs["response_format"] = {"type": "json_object"} + kwargs["extra_body"] = {"thinking": {"type": self.config.thinking}} + if self.config.thinking == "enabled": + kwargs["reasoning_effort"] = self.config.reasoning_effort + + provider_tag = "deepseek" if self.config.mode == "json_object" else "openai-compat" + actor = f"npc_id={request.npc_id} seat={request.seat_no} persona={self._persona_key}" + timer = CallTimer() + content = "" + err: str | None = None + tokens: dict[str, int | None] | None = None + with trace_context( + game_id=parse_game_id_from_phase_id(request.phase_id), + phase=request.phase_id, + day=parse_day_from_phase_id(request.phase_id), + actor=actor, + metadata={ + "request_id": request.request_id, + "logic_packet_id": request.logic_packet_id, + "suggested_intent": request.suggested_intent, + "max_chars": request.max_chars, + "base_url": self.config.base_url, + }, + ): + try: + resp = await client.chat.completions.create(**kwargs) # type: ignore[call-overload] + content = resp.choices[0].message.content or "{}" + tokens = extract_openai_tokens(resp) + except Exception as exc: + err = f"{type(exc).__name__}: {exc}" + log.exception( + "npc_generate_failed model=%s base_url=%s", + self.config.model, + self.config.base_url, + ) + await log_llm_call( + role="npc_speech", + provider=provider_tag, + model=self.config.model, + system_prompt=system, + user_prompt=user, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + file_stem=f"npc_{self._persona_key}", + ) + return None + + await log_llm_call( + role="npc_speech", + provider=provider_tag, + model=self.config.model, + system_prompt=system, + user_prompt=user, + response=content, + latency_ms=timer.elapsed_ms, + error=None, + tokens=tokens, + file_stem=f"npc_{self._persona_key}", + ) + + try: + data = json.loads(content) + except json.JSONDecodeError: + log.warning("npc_generate_invalid_json response=%s", content[:200]) + return None + + return _build_speech_from_json(data) + + async def decide_json( + self, + *, + system_prompt: str, + user_prompt: str, + schema: dict[str, object], + ) -> str: + """Phase-D: structured-output decision call (vote / night action). + + Reuses this generator's provider config + auth so each persona's + `NPC_LLM_*` doubles as both speech and decision backend without + plumbing a separate client. The caller is `npc/decision_service.py` + — it builds the prompt and validates the parsed result. + + Returns raw response text (a JSON string). On any provider error + the exception propagates up so the dispatcher can record a + timeout / abstain. + """ + from openai import AsyncOpenAI + + from wolfbot.services.llm_trace import ( + CallTimer, + extract_openai_tokens, + log_llm_call, + ) + + client = AsyncOpenAI( + api_key=self._api_key, + base_url=self.config.base_url, + ) + kwargs: dict[str, object] = { + "model": self.config.model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "temperature": self.config.temperature, + "timeout": self.config.timeout, + } + if self.config.mode == "json_schema": + kwargs["response_format"] = { + "type": "json_schema", + "json_schema": { + "name": "decision", + "strict": True, + "schema": schema, + }, + } + else: + kwargs["response_format"] = {"type": "json_object"} + kwargs["extra_body"] = {"thinking": {"type": self.config.thinking}} + if self.config.thinking == "enabled": + kwargs["reasoning_effort"] = self.config.reasoning_effort + + provider_tag = "deepseek" if self.config.mode == "json_object" else "openai-compat" + timer = CallTimer() + content = "" + err: str | None = None + tokens: dict[str, int | None] | None = None + try: + resp = await client.chat.completions.create(**kwargs) # type: ignore[call-overload] + content = resp.choices[0].message.content or "{}" + tokens = extract_openai_tokens(resp) + except Exception as exc: + err = f"{type(exc).__name__}: {exc}" + log.exception( + "npc_decide_failed model=%s base_url=%s", + self.config.model, + self.config.base_url, + ) + await log_llm_call( + role="npc_decision", + provider=provider_tag, + model=self.config.model, + system_prompt=system_prompt, + user_prompt=user_prompt, + response=None, + latency_ms=timer.elapsed_ms, + error=err, + file_stem=f"npc_{self._persona_key}", + ) + raise + + await log_llm_call( + role="npc_decision", + provider=provider_tag, + model=self.config.model, + system_prompt=system_prompt, + user_prompt=user_prompt, + response=content, + latency_ms=timer.elapsed_ms, + error=None, + tokens=tokens, + file_stem=f"npc_{self._persona_key}", + ) + return content + + +def _build_speech_from_json(data: dict[str, object]) -> NpcGeneratedSpeech | None: + """Map a parsed structured-output dict to ``NpcGeneratedSpeech``. + + Shared by every NPC generator (OpenAI-compat / DeepSeek / Vertex + Gemini) so the schema-to-domain projection is validated in one + place. Returns ``None`` when the speech should be declined (empty + text or ``intent="skip"``). + """ + text = str(data.get("text", "") or "").strip() + intent = str(data.get("intent", "speak")) + if intent == "skip" or not text: + return None + + raw_ids = data.get("used_logic_ids") or [] + used_ids = tuple(str(x) for x in raw_ids) if isinstance(raw_ids, list) else () + co_raw = data.get("co_declaration") + co_declaration = co_raw if co_raw in CO_CLAIM_VALUES else None + # `addressed_seat_nos` (list) is the authoritative field; the legacy + # `addressed_seat_no` (singular) stays accepted for back-compat with + # provider responses produced before 2026-04 multi-address rollout. + # Coerce to int and silently drop non-int garbage rather than fail + # the speech. + raw_nos = data.get("addressed_seat_nos") + addressed_seat_nos: list[int] = [] + if isinstance(raw_nos, list): + for v in raw_nos: + if isinstance(v, int) and not isinstance(v, bool) and v not in addressed_seat_nos: + addressed_seat_nos.append(v) + raw_addr = data.get("addressed_seat_no") + addressed_seat_no: int | None = None + if isinstance(raw_addr, int) and not isinstance(raw_addr, bool): + addressed_seat_no = raw_addr + if not addressed_seat_nos: + addressed_seat_nos.append(raw_addr) + elif addressed_seat_nos: + addressed_seat_no = addressed_seat_nos[0] + + seer_seat, seer_is_wolf = _parse_claim_fields( + data.get("claimed_seer_result"), + allow_null_verdict=False, + ) + medium_seat, medium_is_wolf = _parse_claim_fields( + data.get("claimed_medium_result"), + allow_null_verdict=True, + ) + suspicions = _parse_suspicions(data.get("suspicions")) + # Rough estimate: ~150ms per character for TTS + estimated_ms = max(500, len(text) * 150) + + return NpcGeneratedSpeech( + text=text, + intent=intent, + used_logic_ids=used_ids, + estimated_duration_ms=estimated_ms, + co_declaration=co_declaration, + addressed_seat_no=addressed_seat_no, + addressed_seat_nos=tuple(addressed_seat_nos), + claimed_seer_target_seat=seer_seat, + claimed_seer_is_wolf=seer_is_wolf, + claimed_medium_target_seat=medium_seat, + claimed_medium_is_wolf=medium_is_wolf, + suspicions=suspicions, + ) + + +def _parse_suspicions(raw: object) -> tuple[Suspicion, ...]: + """Coerce a raw `suspicions` array from structured-output JSON into a + tuple of validated `Suspicion` models. + + Drops malformed entries silently (mirrors `_parse_claim_fields`'s + forgiveness — the speech is still delivered without the bad + suspicion attached). Empty / non-list input → empty tuple. + """ + if not isinstance(raw, list): + return () + out: list[Suspicion] = [] + for entry in raw: + if not isinstance(entry, dict): + continue + try: + out.append(Suspicion.model_validate(entry)) + except Exception: + log.debug("malformed suspicion entry dropped: %r", entry) + continue + return tuple(out) + + +def _parse_claim_fields(raw: object, *, allow_null_verdict: bool) -> tuple[int | None, bool | None]: + """Coerce a structured claim dict into ``(target_seat, is_wolf)``. + + Returns ``(None, None)`` for any malformed input — the speech is + still delivered without the structured claim attached, since + rejecting valid speech because of a missing claim object would hurt + liveness more than it helps integrity. + + ``allow_null_verdict=True`` is the medium path (``is_wolf=null`` + encodes "no execution yesterday → no result today"). The seer path + requires a concrete boolean. + """ + if not isinstance(raw, dict): + return (None, None) + target = raw.get("target_seat") + if not isinstance(target, int) or isinstance(target, bool): + return (None, None) + if not 1 <= target <= 9: + return (None, None) + verdict_raw = raw.get("is_wolf") + if isinstance(verdict_raw, bool): + return (target, verdict_raw) + if verdict_raw is None and allow_null_verdict: + return (target, None) + return (None, None) + + +__all__ = [ + "_RESPONSE_SCHEMA", + "OpenAICompatibleConfig", + "OpenAICompatibleNpcGenerator", + "_build_speech_from_json", + "_build_system", + "_build_user", + "_parse_claim_fields", +] diff --git a/src/wolfbot/npc/speech/speech_service.py b/src/wolfbot/npc/speech/speech_service.py new file mode 100644 index 0000000..9fe3285 --- /dev/null +++ b/src/wolfbot/npc/speech/speech_service.py @@ -0,0 +1,251 @@ +"""NPC-side: take a `LogicPacket` + `SpeakRequest` and return a `SpeakResult`. + +This is the NPC bot's Grok prompt-builder + structured-output call wrapped +in a deterministic Protocol so tests can substitute a `FakeNpcGenerator`. + +The persona registry, role-strategy isolation, structured-output `LLMAction` +schema, and seat-token resolver come from the existing `wolfbot.llm` package +— this module only adds the LogicPacket-aware prompt assembly. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +from wolfbot.domain.enums import CO_CLAIM_VALUES, CoDeclaration +from wolfbot.domain.suspicion import Suspicion +from wolfbot.domain.ws_messages import ( + ClaimedMediumResult, + ClaimedSeerResult, + LogicPacket, + SpeakRequest, + SpeakResult, +) +from wolfbot.npc.decision.game_state import NpcGameState + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class NpcGeneratedSpeech: + text: str + intent: str + used_logic_ids: tuple[str, ...] + estimated_duration_ms: int + co_declaration: str | None = None + addressed_seat_no: int | None = None + addressed_seat_nos: tuple[int, ...] = () + # Structured divination/medium claim attached to this utterance. + # Populated when the LLM declares a NEW seer/medium result (real or + # fake) so Master can build a per-seat claim history that anchors + # future fake seers to their prior lies. None means the speech does + # not announce a new result (general talk, references to prior + # claims, non-seer speech). + claimed_seer_target_seat: int | None = None + claimed_seer_is_wolf: bool | None = None + claimed_medium_target_seat: int | None = None + claimed_medium_is_wolf: bool | None = None + # Structured suspicion records this utterance asserts. Empty tuple + # means no suspicion was declared (allowed but discouraged — see + # the speech system prompt's 名指し義務 rule). + suspicions: tuple[Suspicion, ...] = () + + +@runtime_checkable +class NpcGenerator(Protocol): + async def generate( + self, + *, + logic: LogicPacket, + request: SpeakRequest, + state: NpcGameState | None = None, + ) -> NpcGeneratedSpeech | None: ... + + +class FakeNpcGenerator: + """Returns a scripted utterance, or None to simulate decline.""" + + def __init__( + self, + scripted: list[NpcGeneratedSpeech | None] | None = None, + default: NpcGeneratedSpeech | None = None, + ) -> None: + self._scripted = list(scripted or []) + self._default = default + self.call_count = 0 + self.received_logic: list[LogicPacket] = [] + self.received_requests: list[SpeakRequest] = [] + self.received_state: list[NpcGameState | None] = [] + + async def generate( + self, + *, + logic: LogicPacket, + request: SpeakRequest, + state: NpcGameState | None = None, + ) -> NpcGeneratedSpeech | None: + self.call_count += 1 + self.received_logic.append(logic) + self.received_requests.append(request) + self.received_state.append(state) + if self._scripted: + return self._scripted.pop(0) + return self._default + + +class NpcSpeechService: + """Compose `SpeakResult` from a generator's output. + + Encapsulates length-cap enforcement and decline-on-empty so the NPC + bot's main loop stays tidy. + """ + + def __init__(self, generator: NpcGenerator) -> None: + self.generator = generator + + async def respond( + self, + *, + logic: LogicPacket, + request: SpeakRequest, + now_ms: int, + state: NpcGameState | None = None, + ) -> SpeakResult: + try: + speech = await self.generator.generate( + logic=logic, request=request, state=state, + ) + except Exception: + log.exception( + "npc_generate_failed npc_id=%s req=%s", request.npc_id, request.request_id + ) + return SpeakResult( + ts=now_ms, + trace_id=request.trace_id, + request_id=request.request_id, + npc_id=request.npc_id, + phase_id=request.phase_id, + status="error", + failure_reason="generator_error", + ) + if speech is None or not speech.text.strip(): + return SpeakResult( + ts=now_ms, + trace_id=request.trace_id, + request_id=request.request_id, + npc_id=request.npc_id, + phase_id=request.phase_id, + status="declined", + failure_reason="speaker_declined", + ) + text = speech.text.strip() + if len(text) > request.max_chars: + text = text[: request.max_chars] + co_declaration: CoDeclaration | None = None + if speech.co_declaration in CO_CLAIM_VALUES: + co_declaration = speech.co_declaration # type: ignore[assignment] + # Drop self-address (==speaker_seat) defensively. We can't validate + # alive / on-roster here without seat data; Master applies the + # alive/self-filter when persisting, so a hallucinated seat number + # is filtered out at the boundary, not silently dropped here. + # Build the canonical addressed list, falling back to the legacy + # singular field for back-compat with NpcGeneratedSpeech instances + # that haven't been updated to populate the list. + merged: list[int] = [] + for s in speech.addressed_seat_nos: + if s is not None and s != request.seat_no and s not in merged: + merged.append(int(s)) + if ( + speech.addressed_seat_no is not None + and speech.addressed_seat_no != request.seat_no + and speech.addressed_seat_no not in merged + ): + merged.append(int(speech.addressed_seat_no)) + addressed_seat_nos: tuple[int, ...] = tuple(merged) + addressed_seat_no = addressed_seat_nos[0] if addressed_seat_nos else None + + claimed_seer = _build_claimed_seer(speech, speaker_seat=request.seat_no) + claimed_medium = _build_claimed_medium(speech, speaker_seat=request.seat_no) + # Filter self-suspicions (target == speaker). The LLM occasionally + # mis-targets itself; rather than fail the whole speech, drop just + # the bad entries — same forgiveness as the addressed_seat_nos + # self-filter above. + valid_suspicions = tuple( + s for s in speech.suspicions if s.target_seat != request.seat_no + ) + + return SpeakResult( + ts=now_ms, + trace_id=request.trace_id, + request_id=request.request_id, + npc_id=request.npc_id, + phase_id=request.phase_id, + status="accepted", + text=text, + used_logic_ids=speech.used_logic_ids, + intent=speech.intent, + estimated_duration_ms=speech.estimated_duration_ms, + co_declaration=co_declaration, + claimed_seer_result=claimed_seer, + claimed_medium_result=claimed_medium, + addressed_seat_no=addressed_seat_no, + addressed_seat_nos=addressed_seat_nos, + suspicions=valid_suspicions, + ) + + +def _build_claimed_seer( + speech: NpcGeneratedSpeech, *, speaker_seat: int +) -> ClaimedSeerResult | None: + """Validate the speech's seer claim and project it onto the wire model. + + Drops the claim defensively when: + * target_seat is missing or out of range, + * is_wolf is missing (a verdict is required for a seer claim), + * the claim targets the speaker themselves (a real seer never + divines their own seat in this ruleset). + + The drop is silent — we still send the speech, just without the + structured claim. Master logs the omission via the absence of a + matching record in the claim-history fold. + """ + seat = speech.claimed_seer_target_seat + verdict = speech.claimed_seer_is_wolf + if seat is None or verdict is None: + return None + if not 1 <= seat <= 9: + return None + if seat == speaker_seat: + return None + return ClaimedSeerResult(target_seat=seat, is_wolf=verdict) + + +def _build_claimed_medium( + speech: NpcGeneratedSpeech, *, speaker_seat: int +) -> ClaimedMediumResult | None: + """Validate the speech's medium claim and project it onto the wire model. + + Mirrors ``_build_claimed_seer`` but allows ``is_wolf=None`` to encode + "no execution yesterday → no result today". Self-target is still + rejected — the only way a medium claims their own seat is via a + coordinated lynching scenario that doesn't apply mid-discussion. + """ + seat = speech.claimed_medium_target_seat + verdict = speech.claimed_medium_is_wolf + if seat is None: + return None + if not 1 <= seat <= 9: + return None + if seat == speaker_seat: + return None + return ClaimedMediumResult(target_seat=seat, is_wolf=verdict) + + +__all__ = [ + "FakeNpcGenerator", + "NpcGeneratedSpeech", + "NpcGenerator", + "NpcSpeechService", +] diff --git a/src/wolfbot/persistence/schema.py b/src/wolfbot/persistence/schema.py index 7b359af..94744ba 100644 --- a/src/wolfbot/persistence/schema.py +++ b/src/wolfbot/persistence/schema.py @@ -1,6 +1,21 @@ """SQLite schema + migrate(). -All DDL is idempotent via `CREATE TABLE IF NOT EXISTS`. Safe to re-run on every boot. +DDL for the base ``CREATE TABLE`` / ``CREATE INDEX`` statements lives in +:mod:`wolfbot.persistence.sql` as one ``NN_.sql`` file per +logical table group; :func:`migrate` loads them in lex order and +executes each statement. All CREATE statements are idempotent via +``IF NOT EXISTS`` so the routine is safe to re-run on every boot. + +Additive column migrations stay in Python because SQLite has no +``ALTER TABLE ... ADD COLUMN IF NOT EXISTS``. The block at the bottom +of :func:`migrate` walks ``PRAGMA table_info()`` and only adds +the column when missing, which keeps existing DBs upgrading cleanly +without dropping data. + +When you add a new column to one of the ``schema/*.sql`` files, you +MUST also add a guarded ``ALTER TABLE`` here, otherwise old DBs +upgraded in place keep the pre-add schema and subsequent INSERTs fail +with "no column named ...". """ from __future__ import annotations @@ -9,148 +24,7 @@ import aiosqlite -DDL: list[str] = [ - """ - CREATE TABLE IF NOT EXISTS games ( - id TEXT PRIMARY KEY, - guild_id TEXT NOT NULL, - host_user_id TEXT NOT NULL, - phase TEXT NOT NULL, - day_number INTEGER NOT NULL DEFAULT 0, - deadline_epoch INTEGER, - main_text_channel_id TEXT NOT NULL, - main_vc_channel_id TEXT NOT NULL, - heaven_channel_id TEXT, - wolves_channel_id TEXT, - created_at INTEGER NOT NULL, - ended_at INTEGER, - force_skip_pending INTEGER NOT NULL DEFAULT 0 - ) - """, - """ - CREATE INDEX IF NOT EXISTS idx_games_active - ON games(ended_at) WHERE ended_at IS NULL - """, - """ - CREATE UNIQUE INDEX IF NOT EXISTS idx_games_unique_active - ON games(guild_id) WHERE ended_at IS NULL - """, - """ - CREATE TABLE IF NOT EXISTS seats ( - game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, - seat_no INTEGER NOT NULL, - discord_user_id TEXT, - display_name TEXT NOT NULL, - is_llm INTEGER NOT NULL, - persona_key TEXT, - role TEXT, - alive INTEGER NOT NULL DEFAULT 1, - death_cause TEXT, - death_day INTEGER, - dm_channel_id TEXT, - PRIMARY KEY (game_id, seat_no) - ) - """, - """ - CREATE INDEX IF NOT EXISTS idx_seats_user ON seats(discord_user_id) - """, - """ - CREATE TABLE IF NOT EXISTS night_actions ( - game_id TEXT NOT NULL, - day INTEGER NOT NULL, - actor_seat INTEGER NOT NULL, - kind TEXT NOT NULL, - target_seat INTEGER, - submitted_at INTEGER NOT NULL, - PRIMARY KEY (game_id, day, actor_seat, kind), - FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE - ) - """, - """ - CREATE TABLE IF NOT EXISTS votes ( - game_id TEXT NOT NULL, - day INTEGER NOT NULL, - round INTEGER NOT NULL, - voter_seat INTEGER NOT NULL, - target_seat INTEGER, - submitted_at INTEGER NOT NULL, - PRIMARY KEY (game_id, day, round, voter_seat), - FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE - ) - """, - """ - CREATE TABLE IF NOT EXISTS previous_guard ( - game_id TEXT PRIMARY KEY REFERENCES games(id) ON DELETE CASCADE, - knight_seat INTEGER NOT NULL, - last_guard_seat INTEGER, - last_guard_day INTEGER - ) - """, - """ - CREATE TABLE IF NOT EXISTS logs_public ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - game_id TEXT NOT NULL, - day INTEGER NOT NULL, - phase TEXT NOT NULL, - kind TEXT NOT NULL, - actor_seat INTEGER, - text TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - """, - """ - CREATE INDEX IF NOT EXISTS idx_pub_logs_game ON logs_public(game_id, created_at) - """, - """ - CREATE TABLE IF NOT EXISTS logs_private ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - game_id TEXT NOT NULL, - day INTEGER NOT NULL, - phase TEXT NOT NULL, - kind TEXT NOT NULL, - actor_seat INTEGER, - audience_seat INTEGER, - text TEXT NOT NULL, - payload_json TEXT, - created_at INTEGER NOT NULL - ) - """, - """ - CREATE INDEX IF NOT EXISTS idx_priv_logs_game ON logs_private(game_id, created_at) - """, - """ - CREATE TABLE IF NOT EXISTS pending_decisions ( - game_id TEXT PRIMARY KEY REFERENCES games(id) ON DELETE CASCADE, - phase TEXT NOT NULL, - day INTEGER NOT NULL, - required_submission TEXT NOT NULL, - missing_seats_json TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - """, - """ - CREATE TABLE IF NOT EXISTS persona_assignments ( - game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, - seat_no INTEGER NOT NULL, - persona_key TEXT NOT NULL, - PRIMARY KEY (game_id, seat_no), - UNIQUE (game_id, persona_key) - ) - """, - """ - CREATE TABLE IF NOT EXISTS llm_speech_counts ( - game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, - day INTEGER NOT NULL, - seat_no INTEGER NOT NULL, - normal_count INTEGER NOT NULL DEFAULT 0, - vote_intent_done INTEGER NOT NULL DEFAULT 0, - last_spoke_epoch INTEGER, - discussion_rounds_done INTEGER NOT NULL DEFAULT 0, - runoff_speech_done INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (game_id, day, seat_no) - ) - """, -] +from wolfbot.persistence.sql import load_schema_script async def migrate(db_path: str | Path) -> None: @@ -160,19 +34,23 @@ async def migrate(db_path: str | Path) -> None: async with aiosqlite.connect(str(path)) as db: await db.execute("PRAGMA foreign_keys = ON") await db.execute("PRAGMA journal_mode = WAL") - for stmt in DDL: - await db.execute(stmt) - # Additive column migrations: SQLite doesn't support - # `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, so we guard with PRAGMA. - # Each new column added to a CREATE TABLE above must also be guarded - # here, otherwise old DBs upgraded in place keep the pre-add schema - # and subsequent INSERTs fail with "no column named ...". + # `executescript` runs the concatenated DDL through SQLite's own + # parser, which strips `--` line comments and `/* */` block + # comments before splitting on `;`. A Python-side `split(';')` + # tripped on the first `--` comment whose body contained `;`. + await db.executescript(load_schema_script()) + # Additive column migrations — see module docstring for the + # reason these live in Python. async with db.execute("PRAGMA table_info(games)") as cur: cols = {row[1] async for row in cur} if "force_skip_pending" not in cols: await db.execute( "ALTER TABLE games ADD COLUMN force_skip_pending INTEGER NOT NULL DEFAULT 0" ) + if "discussion_mode" not in cols: + await db.execute( + "ALTER TABLE games ADD COLUMN discussion_mode TEXT NOT NULL DEFAULT 'rounds'" + ) async with db.execute("PRAGMA table_info(seats)") as cur: cols = {row[1] async for row in cur} if "dm_channel_id" not in cols: @@ -193,4 +71,58 @@ async def migrate(db_path: str | Path) -> None: "ALTER TABLE llm_speech_counts " "ADD COLUMN runoff_speech_done INTEGER NOT NULL DEFAULT 0" ) + async with db.execute("PRAGMA table_info(speech_events)") as cur: + cols = {row[1] async for row in cur} + if "summary" not in cols: + await db.execute("ALTER TABLE speech_events ADD COLUMN summary TEXT") + if "co_declaration" not in cols: + await db.execute( + "ALTER TABLE speech_events ADD COLUMN co_declaration TEXT" + ) + if "addressed_seat_no" not in cols: + await db.execute( + "ALTER TABLE speech_events ADD COLUMN addressed_seat_no INTEGER" + ) + if "addressed_seat_nos_json" not in cols: + await db.execute( + "ALTER TABLE speech_events ADD COLUMN addressed_seat_nos_json TEXT" + ) + if "role_callout" not in cols: + await db.execute( + "ALTER TABLE speech_events ADD COLUMN role_callout TEXT" + ) + if "claimed_seer_target_seat" not in cols: + await db.execute( + "ALTER TABLE speech_events " + "ADD COLUMN claimed_seer_target_seat INTEGER" + ) + if "claimed_seer_is_wolf" not in cols: + await db.execute( + "ALTER TABLE speech_events " + "ADD COLUMN claimed_seer_is_wolf INTEGER" + ) + if "claimed_medium_target_seat" not in cols: + await db.execute( + "ALTER TABLE speech_events " + "ADD COLUMN claimed_medium_target_seat INTEGER" + ) + if "claimed_medium_is_wolf" not in cols: + await db.execute( + "ALTER TABLE speech_events " + "ADD COLUMN claimed_medium_is_wolf INTEGER" + ) + async with db.execute("PRAGMA table_info(npc_speak_requests)") as cur: + cols = {row[1] async for row in cur} + if "selection_reason" not in cols: + await db.execute( + "ALTER TABLE npc_speak_requests ADD COLUMN selection_reason TEXT" + ) + if "public_state_snapshot_json" not in cols: + await db.execute( + "ALTER TABLE npc_speak_requests " + "ADD COLUMN public_state_snapshot_json TEXT" + ) await db.commit() + + +__all__ = ["migrate"] diff --git a/src/wolfbot/persistence/sql/__init__.py b/src/wolfbot/persistence/sql/__init__.py new file mode 100644 index 0000000..f370a14 --- /dev/null +++ b/src/wolfbot/persistence/sql/__init__.py @@ -0,0 +1,52 @@ +"""Filesystem-resident SQL DDL for the SQLite store. + +The schema directory holds one ``NN_.sql`` file per logical +table group. The numeric prefix encodes execution order (FK-bearing +tables come after the targets they reference). :func:`load_schema_ddl` +returns each statement as a separate string so the migrate runner can +execute them one at a time and keep error messages scoped. + +ALTER-TABLE migrations live in Python (:func:`wolfbot.persistence.schema.migrate`) +because SQLite has no ``ADD COLUMN IF NOT EXISTS`` and the conditional +guards need ``PRAGMA table_info`` reads. The base CREATE statements, +which are inherently idempotent via ``CREATE TABLE IF NOT EXISTS``, +live here as plain ``.sql`` so they can be edited / reviewed without a +Python diff. +""" + +from __future__ import annotations + +from functools import cache +from pathlib import Path + +SQL_ROOT: Path = Path(__file__).resolve().parent +"""Filesystem directory containing schema/ and any future migrations/.""" + +SCHEMA_DIR: Path = SQL_ROOT / "schema" + + +@cache +def load_schema_script() -> str: + """Concatenate every ``schema/*.sql`` in lex order into one script. + + Returned as a single string suitable for ``aiosqlite``'s + ``executescript`` — that path goes through SQLite's own statement + splitter, which correctly skips ``--`` line comments (containing + semicolons or otherwise) and ``/* ... */`` block comments. A naive + Python ``split(';')`` failed on the very first comment line that + ended in ``;`` (game ``01_games.sql`` had "...; service code also" + in a `--` line). + + Cached because ``schema.migrate`` runs every boot and the .sql + files are immutable per process. Tests that need a fresh read can + call :func:`load_schema_script.cache_clear`. + """ + chunks: list[str] = [] + for path in sorted(SCHEMA_DIR.glob("*.sql")): + chunks.append(path.read_text(encoding="utf-8")) + # Each file already ends in a newline; join with a blank line so the + # rendered script stays human-readable when dumped for debugging. + return "\n\n".join(chunks) + + +__all__ = ["SCHEMA_DIR", "SQL_ROOT", "load_schema_script"] diff --git a/src/wolfbot/persistence/sql/schema/01_games.sql b/src/wolfbot/persistence/sql/schema/01_games.sql new file mode 100644 index 0000000..bc612f5 --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/01_games.sql @@ -0,0 +1,28 @@ +-- Games table — one row per Werewolf session, lifecycle-tracked by ended_at. +-- All FK-bearing tables below cascade on this row's id. +CREATE TABLE IF NOT EXISTS games ( + id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + host_user_id TEXT NOT NULL, + phase TEXT NOT NULL, + day_number INTEGER NOT NULL DEFAULT 0, + deadline_epoch INTEGER, + main_text_channel_id TEXT NOT NULL, + main_vc_channel_id TEXT NOT NULL, + heaven_channel_id TEXT, + wolves_channel_id TEXT, + created_at INTEGER NOT NULL, + ended_at INTEGER, + force_skip_pending INTEGER NOT NULL DEFAULT 0, + discussion_mode TEXT NOT NULL DEFAULT 'rounds' +); + +-- Recovery sweep: list every active game (= ended_at IS NULL) cheaply on boot. +CREATE INDEX IF NOT EXISTS idx_games_active + ON games(ended_at) WHERE ended_at IS NULL; + +-- Per-guild active singleton: at most one in-flight game per guild at a time. +-- The partial unique index is the structural enforcement; service code also +-- checks this at /wolf start, but the DB is the source of truth. +CREATE UNIQUE INDEX IF NOT EXISTS idx_games_unique_active + ON games(guild_id) WHERE ended_at IS NULL; diff --git a/src/wolfbot/persistence/sql/schema/02_seats.sql b/src/wolfbot/persistence/sql/schema/02_seats.sql new file mode 100644 index 0000000..14f292c --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/02_seats.sql @@ -0,0 +1,21 @@ +-- Seats — fixed 9 per game, role assigned in NIGHT_0 plan. +-- discord_user_id is NULL for LLM seats (persona_key carries the +-- character identity instead). +CREATE TABLE IF NOT EXISTS seats ( + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + seat_no INTEGER NOT NULL, + discord_user_id TEXT, + display_name TEXT NOT NULL, + is_llm INTEGER NOT NULL, + persona_key TEXT, + role TEXT, + alive INTEGER NOT NULL DEFAULT 1, + death_cause TEXT, + death_day INTEGER, + dm_channel_id TEXT, + PRIMARY KEY (game_id, seat_no) +); + +-- "Which game is user X currently in?" lookup — used when a DM submission +-- needs to find the active game for the player without scanning all games. +CREATE INDEX IF NOT EXISTS idx_seats_user ON seats(discord_user_id); diff --git a/src/wolfbot/persistence/sql/schema/03_night_actions.sql b/src/wolfbot/persistence/sql/schema/03_night_actions.sql new file mode 100644 index 0000000..cb0a8d0 --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/03_night_actions.sql @@ -0,0 +1,13 @@ +-- Per-night submissions from role-holders: wolf attack / seer divine / +-- knight guard. Composite PK enforces "one submission per actor per kind +-- per day" — re-submissions overwrite via INSERT OR REPLACE in service code. +CREATE TABLE IF NOT EXISTS night_actions ( + game_id TEXT NOT NULL, + day INTEGER NOT NULL, + actor_seat INTEGER NOT NULL, + kind TEXT NOT NULL, + target_seat INTEGER, + submitted_at INTEGER NOT NULL, + PRIMARY KEY (game_id, day, actor_seat, kind), + FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE +); diff --git a/src/wolfbot/persistence/sql/schema/04_votes.sql b/src/wolfbot/persistence/sql/schema/04_votes.sql new file mode 100644 index 0000000..c08f989 --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/04_votes.sql @@ -0,0 +1,13 @@ +-- Day votes (round=0) and runoff votes (round=1+). Composite PK enforces +-- "one ballot per voter per round per day"; null target_seat encodes +-- a legitimate abstain. +CREATE TABLE IF NOT EXISTS votes ( + game_id TEXT NOT NULL, + day INTEGER NOT NULL, + round INTEGER NOT NULL, + voter_seat INTEGER NOT NULL, + target_seat INTEGER, + submitted_at INTEGER NOT NULL, + PRIMARY KEY (game_id, day, round, voter_seat), + FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE +); diff --git a/src/wolfbot/persistence/sql/schema/05_previous_guard.sql b/src/wolfbot/persistence/sql/schema/05_previous_guard.sql new file mode 100644 index 0000000..8d819b9 --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/05_previous_guard.sql @@ -0,0 +1,9 @@ +-- Knight's previous-night guard target — used by `legal_guard_targets` +-- to enforce "no consecutive guard on the same target". +-- Singleton-per-game (PK on game_id) since 9-player has only one knight. +CREATE TABLE IF NOT EXISTS previous_guard ( + game_id TEXT PRIMARY KEY REFERENCES games(id) ON DELETE CASCADE, + knight_seat INTEGER NOT NULL, + last_guard_seat INTEGER, + last_guard_day INTEGER +); diff --git a/src/wolfbot/persistence/sql/schema/06_logs_public.sql b/src/wolfbot/persistence/sql/schema/06_logs_public.sql new file mode 100644 index 0000000..220bfb6 --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/06_logs_public.sql @@ -0,0 +1,15 @@ +-- Public-channel-visible log entries — phase changes, vote results, +-- executions, victories, role reveals. AUTOINCREMENT so created_at +-- ties don't collapse rows during recovery sweeps. +CREATE TABLE IF NOT EXISTS logs_public ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + game_id TEXT NOT NULL, + day INTEGER NOT NULL, + phase TEXT NOT NULL, + kind TEXT NOT NULL, + actor_seat INTEGER, + text TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_pub_logs_game ON logs_public(game_id, created_at); diff --git a/src/wolfbot/persistence/sql/schema/07_logs_private.sql b/src/wolfbot/persistence/sql/schema/07_logs_private.sql new file mode 100644 index 0000000..5454dab --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/07_logs_private.sql @@ -0,0 +1,17 @@ +-- Per-seat private log entries — role notices, seer/medium/guard results, +-- wolf-partner reveals. Audience_seat is the recipient; payload_json +-- carries structured details for the export pipeline. +CREATE TABLE IF NOT EXISTS logs_private ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + game_id TEXT NOT NULL, + day INTEGER NOT NULL, + phase TEXT NOT NULL, + kind TEXT NOT NULL, + actor_seat INTEGER, + audience_seat INTEGER, + text TEXT NOT NULL, + payload_json TEXT, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_priv_logs_game ON logs_private(game_id, created_at); diff --git a/src/wolfbot/persistence/sql/schema/08_pending_decisions.sql b/src/wolfbot/persistence/sql/schema/08_pending_decisions.sql new file mode 100644 index 0000000..a933edf --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/08_pending_decisions.sql @@ -0,0 +1,11 @@ +-- Singleton-per-game record of a paused phase that needs the host to +-- /wolf force-skip or /wolf extend. submissions_json is added by the +-- migration block and carries the per-kind PendingSubmission breakdown. +CREATE TABLE IF NOT EXISTS pending_decisions ( + game_id TEXT PRIMARY KEY REFERENCES games(id) ON DELETE CASCADE, + phase TEXT NOT NULL, + day INTEGER NOT NULL, + required_submission TEXT NOT NULL, + missing_seats_json TEXT NOT NULL, + created_at INTEGER NOT NULL +); diff --git a/src/wolfbot/persistence/sql/schema/09_persona_assignments.sql b/src/wolfbot/persistence/sql/schema/09_persona_assignments.sql new file mode 100644 index 0000000..1e9b109 --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/09_persona_assignments.sql @@ -0,0 +1,10 @@ +-- Locks each LLM seat to one persona for the duration of a game. +-- UNIQUE on (game_id, persona_key) prevents two seats from sharing a +-- persona; PK on (game_id, seat_no) prevents a seat from holding two. +CREATE TABLE IF NOT EXISTS persona_assignments ( + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + seat_no INTEGER NOT NULL, + persona_key TEXT NOT NULL, + PRIMARY KEY (game_id, seat_no), + UNIQUE (game_id, persona_key) +); diff --git a/src/wolfbot/persistence/sql/schema/10_llm_speech_counts.sql b/src/wolfbot/persistence/sql/schema/10_llm_speech_counts.sql new file mode 100644 index 0000000..305a900 --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/10_llm_speech_counts.sql @@ -0,0 +1,15 @@ +-- Per-LLM-seat speech progress per day so /wolf force-skip / restart +-- mid-flight resumes without double-posting. discussion_rounds_done +-- and runoff_speech_done flip from 0 to 1 once the corresponding +-- speech batch settles for that seat. +CREATE TABLE IF NOT EXISTS llm_speech_counts ( + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + day INTEGER NOT NULL, + seat_no INTEGER NOT NULL, + normal_count INTEGER NOT NULL DEFAULT 0, + vote_intent_done INTEGER NOT NULL DEFAULT 0, + last_spoke_epoch INTEGER, + discussion_rounds_done INTEGER NOT NULL DEFAULT 0, + runoff_speech_done INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (game_id, day, seat_no) +); diff --git a/src/wolfbot/persistence/sql/schema/11_speech_events.sql b/src/wolfbot/persistence/sql/schema/11_speech_events.sql new file mode 100644 index 0000000..1988c0d --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/11_speech_events.sql @@ -0,0 +1,40 @@ +-- Reactive_voice's append-only feed of every public speech event: +-- text input, voice STT, NPC-generated, plus the phase_baseline +-- sentinel. Structured analyzer fields (summary / co_declaration / +-- addressed_seat_no(_s) / role_callout / claimed_seer/medium_*) +-- carry the per-event JSON the analyzer LLM extracted, so the +-- claim_history fold doesn't need to re-parse the raw text. +CREATE TABLE IF NOT EXISTS speech_events ( + event_id TEXT PRIMARY KEY, + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + phase_id TEXT NOT NULL, + day INTEGER NOT NULL, + phase TEXT NOT NULL, + source TEXT NOT NULL, + speaker_kind TEXT NOT NULL, + speaker_seat INTEGER, + text TEXT NOT NULL, + stt_confidence REAL, + audio_start_ms INTEGER, + audio_end_ms INTEGER, + alive_seat_nos_json TEXT, + summary TEXT, + co_declaration TEXT, + addressed_seat_no INTEGER, + addressed_seat_nos_json TEXT, + role_callout TEXT, + claimed_seer_target_seat INTEGER, + claimed_seer_is_wolf INTEGER, + claimed_medium_target_seat INTEGER, + claimed_medium_is_wolf INTEGER, + created_at_ms INTEGER NOT NULL +); + +-- Phase rebuild: walk every event in a phase in chronological order. +CREATE INDEX IF NOT EXISTS idx_speech_events_phase + ON speech_events(game_id, phase_id, created_at_ms); + +-- Per-seat slice within a phase: speech_count rotation, last-speaker +-- LRU, claim history per claimer. +CREATE INDEX IF NOT EXISTS idx_speech_events_seat + ON speech_events(game_id, phase_id, speaker_seat); diff --git a/src/wolfbot/persistence/sql/schema/12_npc_speak_requests.sql b/src/wolfbot/persistence/sql/schema/12_npc_speak_requests.sql new file mode 100644 index 0000000..00b51f5 --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/12_npc_speak_requests.sql @@ -0,0 +1,24 @@ +-- One row per SpeakRequest dispatched by the arbiter to an NPC bot. +-- public_state_snapshot_json captures the picker's view of state at +-- dispatch time so the viewer can replay why this seat won. +CREATE TABLE IF NOT EXISTS npc_speak_requests ( + request_id TEXT PRIMARY KEY, + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + phase_id TEXT NOT NULL, + npc_id TEXT NOT NULL, + seat_no INTEGER NOT NULL, + logic_packet_id TEXT NOT NULL, + suggested_intent TEXT NOT NULL, + max_chars INTEGER NOT NULL, + max_duration_ms INTEGER NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + expires_at_ms INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL, + selection_reason TEXT, + public_state_snapshot_json TEXT +); + +-- TTL sweep: list dispatches whose expires_at_ms has elapsed without +-- a SpeakResult, scoped to the phase to keep the index small. +CREATE INDEX IF NOT EXISTS idx_npc_speak_requests_game_phase + ON npc_speak_requests(game_id, phase_id, expires_at_ms); diff --git a/src/wolfbot/persistence/sql/schema/13_npc_speak_results.sql b/src/wolfbot/persistence/sql/schema/13_npc_speak_results.sql new file mode 100644 index 0000000..fdff5ef --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/13_npc_speak_results.sql @@ -0,0 +1,19 @@ +-- One row per SpeakResult received from an NPC bot (or synthesized by +-- the arbiter on rejection). status carries accepted / rejected / +-- timed_out; failure_reason gives the canonical machine reason. +CREATE TABLE IF NOT EXISTS npc_speak_results ( + request_id TEXT PRIMARY KEY, + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + phase_id TEXT NOT NULL, + npc_id TEXT NOT NULL, + status TEXT NOT NULL, + text TEXT, + used_logic_ids_json TEXT, + intent TEXT, + estimated_duration_ms INTEGER, + failure_reason TEXT, + received_at_ms INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_npc_speak_results_phase + ON npc_speak_results(game_id, phase_id, received_at_ms); diff --git a/src/wolfbot/persistence/sql/schema/14_npc_playback_events.sql b/src/wolfbot/persistence/sql/schema/14_npc_playback_events.sql new file mode 100644 index 0000000..6df69be --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/14_npc_playback_events.sql @@ -0,0 +1,24 @@ +-- One row per NPC playback window — opened on SpeakResult acceptance +-- with an authorized_at_ms / playback_deadline_ms pair, closed when +-- tts_finished + playback_finished arrive (or the deadline expires). +-- The partial index keeps "still-open windows" cheap to scan during +-- the periodic deadline sweep. +CREATE TABLE IF NOT EXISTS npc_playback_events ( + request_id TEXT PRIMARY KEY, + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + phase_id TEXT NOT NULL, + npc_id TEXT NOT NULL, + speech_event_id TEXT, + authorized_at_ms INTEGER NOT NULL, + playback_deadline_ms INTEGER NOT NULL, + finished_at_ms INTEGER, + outcome TEXT, + failure_reason TEXT, + tts_outcome TEXT, + tts_duration_ms INTEGER, + tts_failure_reason TEXT +); + +CREATE INDEX IF NOT EXISTS idx_npc_playback_events_open + ON npc_playback_events(game_id, finished_at_ms) + WHERE finished_at_ms IS NULL; diff --git a/src/wolfbot/persistence/sql/schema/15_suspicions.sql b/src/wolfbot/persistence/sql/schema/15_suspicions.sql new file mode 100644 index 0000000..3d9f640 --- /dev/null +++ b/src/wolfbot/persistence/sql/schema/15_suspicions.sql @@ -0,0 +1,61 @@ +-- Per-utterance / per-vote structured suspicion record. Each row is +-- one "席X が 席Y を level (理由) で見ている" datum, sourced either +-- from a speech (`source='speech'`) or from a vote decision +-- (`source='vote'`). +-- +-- For source='speech', `event_id` references `speech_events.event_id` +-- so the suspicion deletes when the parent SpeechEvent's game is +-- removed. For source='vote', `event_id` is NULL because vote +-- decisions don't produce a SpeechEvent — the (vote_day, vote_round, +-- suspecter_seat, target_seat) tuple identifies the vote-derived row. +-- +-- Surrogate `id` PK lets event_id be nullable while keeping uniqueness +-- guarantees per (event_id, seq) for speech rows enforceable via the +-- composite UNIQUE index below. +-- +-- Anti-fabrication: subsequent prompts surface the full immutable +-- history so a speaker who silently reverses a prior suspicion (e.g. +-- trust → high without setting update_from_level) is detectable. +-- Updates set update_from_level + update_reason to make the shift +-- explicit. +CREATE TABLE IF NOT EXISTS suspicions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL DEFAULT 'speech', + event_id TEXT, + seq INTEGER NOT NULL DEFAULT 0, + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + day INTEGER NOT NULL, + phase TEXT NOT NULL, + vote_round INTEGER, + suspecter_seat INTEGER NOT NULL, + target_seat INTEGER NOT NULL, + level TEXT NOT NULL, + reason TEXT NOT NULL, + update_from_level TEXT, + update_reason TEXT, + created_at_ms INTEGER NOT NULL +); + +-- Per-game timeline: walk every suspicion in chronological order so +-- prompt builders and viewer rendering both see the same ordering. +CREATE INDEX IF NOT EXISTS idx_susp_game_day + ON suspicions(game_id, day, created_at_ms); + +-- Per-(suspecter, target) lookup for fabrication detection: given a +-- candidate new suspicion, query the latest prior level so the +-- prompt builder can highlight unannounced reversals. +CREATE INDEX IF NOT EXISTS idx_susp_pair + ON suspicions(game_id, suspecter_seat, target_seat, created_at_ms); + +-- Speech-row uniqueness: one (event_id, seq) pair is unique. Vote rows +-- have event_id=NULL so they bypass this constraint (SQLite treats +-- NULL as distinct in UNIQUE). +CREATE UNIQUE INDEX IF NOT EXISTS idx_susp_event_seq + ON suspicions(event_id, seq) WHERE event_id IS NOT NULL; + +-- Vote-row uniqueness: one suspecter casts at most one vote-derived +-- record per (target, day, round). Re-running the vote dispatch path +-- doesn't duplicate rows. +CREATE UNIQUE INDEX IF NOT EXISTS idx_susp_vote_unique + ON suspicions(game_id, day, vote_round, suspecter_seat, target_seat) + WHERE source = 'vote'; diff --git a/src/wolfbot/persistence/sqlite_repo.py b/src/wolfbot/persistence/sqlite_repo.py index 53e8b9c..329f353 100644 --- a/src/wolfbot/persistence/sqlite_repo.py +++ b/src/wolfbot/persistence/sqlite_repo.py @@ -53,6 +53,13 @@ class LeaveLobbyResult(StrEnum): def _row_to_game(row: aiosqlite.Row) -> Game: + discussion_mode = "rounds" + try: + # Old DBs migrated in place may lack the column momentarily during + # boot — guard so we keep the default in that race. + discussion_mode = row["discussion_mode"] or "rounds" + except (IndexError, KeyError): + discussion_mode = "rounds" return Game( id=row["id"], guild_id=row["guild_id"], @@ -67,6 +74,7 @@ def _row_to_game(row: aiosqlite.Row) -> Game: created_at=row["created_at"], ended_at=row["ended_at"], force_skip_pending=bool(row["force_skip_pending"]), + discussion_mode=discussion_mode, ) @@ -139,6 +147,17 @@ def _row_to_pending(row: aiosqlite.Row) -> PendingDecision: ) +def _susp_str(value: Any) -> str: + """Coerce a SuspicionLevel enum (or already-string) to its wire string. + + The Suspicion model's `.level` is a `SuspicionLevel` enum but DB + insertion needs the underlying string. Defensive against either + enum-or-string inputs so the repo doesn't care which form callers + use. + """ + return value.value if hasattr(value, "value") else str(value) + + def _submissions_json(pending: PendingDecision) -> str: """Serialize submissions to JSON, synthesizing from primary when empty.""" items = pending.effective_submissions() @@ -204,8 +223,9 @@ async def create_game(self, game: Game) -> None: INSERT INTO games (id, guild_id, host_user_id, phase, day_number, deadline_epoch, main_text_channel_id, main_vc_channel_id, heaven_channel_id, wolves_channel_id, - created_at, ended_at, force_skip_pending) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + created_at, ended_at, force_skip_pending, + discussion_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( game.id, @@ -221,6 +241,7 @@ async def create_game(self, game: Game) -> None: game.created_at, game.ended_at, int(game.force_skip_pending), + game.discussion_mode, ), ) except aiosqlite.IntegrityError as e: @@ -933,6 +954,355 @@ async def claim_start_and_backfill( return False return True + # ---------------------------------------------------- npc audit tables + # + # Master is the sole writer for npc_speak_requests / npc_speak_results / + # npc_playback_events. These tables are append-or-update audit trails; + # the live arbiter does not depend on them after a restart, but the + # recovery sweep needs them to close in-flight rows on Master restart. + + async def insert_npc_speak_request( + self, + *, + request_id: str, + game_id: str, + phase_id: str, + npc_id: str, + seat_no: int, + logic_packet_id: str, + suggested_intent: str, + max_chars: int, + max_duration_ms: int, + priority: int, + expires_at_ms: int, + created_at_ms: int, + selection_reason: str | None = None, + public_state_snapshot: dict[str, Any] | None = None, + ) -> None: + await self._db.execute( + """ + INSERT INTO npc_speak_requests ( + request_id, game_id, phase_id, npc_id, seat_no, + logic_packet_id, suggested_intent, max_chars, max_duration_ms, + priority, expires_at_ms, created_at_ms, + selection_reason, public_state_snapshot_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + request_id, + game_id, + phase_id, + npc_id, + seat_no, + logic_packet_id, + suggested_intent, + max_chars, + max_duration_ms, + priority, + expires_at_ms, + created_at_ms, + selection_reason, + json.dumps(public_state_snapshot, ensure_ascii=False) + if public_state_snapshot is not None + else None, + ), + ) + await self._db.commit() + + async def insert_npc_speak_result( + self, + *, + request_id: str, + game_id: str, + phase_id: str, + npc_id: str, + status: str, + text: str | None, + used_logic_ids: Sequence[str] | None, + intent: str | None, + estimated_duration_ms: int | None, + failure_reason: str | None, + received_at_ms: int, + ) -> None: + await self._db.execute( + """ + INSERT OR REPLACE INTO npc_speak_results ( + request_id, game_id, phase_id, npc_id, status, text, + used_logic_ids_json, intent, estimated_duration_ms, + failure_reason, received_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + request_id, + game_id, + phase_id, + npc_id, + status, + text, + json.dumps(list(used_logic_ids)) if used_logic_ids else None, + intent, + estimated_duration_ms, + failure_reason, + received_at_ms, + ), + ) + await self._db.commit() + + async def open_npc_playback( + self, + *, + request_id: str, + game_id: str, + phase_id: str, + npc_id: str, + speech_event_id: str, + authorized_at_ms: int, + playback_deadline_ms: int, + ) -> None: + await self._db.execute( + """ + INSERT INTO npc_playback_events ( + request_id, game_id, phase_id, npc_id, speech_event_id, + authorized_at_ms, playback_deadline_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + request_id, + game_id, + phase_id, + npc_id, + speech_event_id, + authorized_at_ms, + playback_deadline_ms, + ), + ) + await self._db.commit() + + async def update_npc_playback_tts( + self, + request_id: str, + *, + outcome: str, + duration_ms: int | None, + failure_reason: str | None, + ) -> None: + await self._db.execute( + """ + UPDATE npc_playback_events + SET tts_outcome=?, tts_duration_ms=?, tts_failure_reason=? + WHERE request_id=? + """, + (outcome, duration_ms, failure_reason, request_id), + ) + await self._db.commit() + + async def close_npc_playback( + self, + request_id: str, + *, + finished_at_ms: int, + outcome: str, + failure_reason: str | None, + ) -> None: + await self._db.execute( + """ + UPDATE npc_playback_events + SET finished_at_ms=?, outcome=?, failure_reason=? + WHERE request_id=? AND finished_at_ms IS NULL + """, + (finished_at_ms, outcome, failure_reason, request_id), + ) + await self._db.commit() + + async def insert_speech_suspicions( + self, + *, + event_id: str, + game_id: str, + day: int, + phase: Phase, + suspecter_seat: int, + created_at_ms: int, + suspicions: Sequence[Any], + ) -> None: + """Persist a batch of speech-derived `Suspicion` records. + + Empty `suspicions` is a fast-path no-op. Re-running with the + same (event_id, seq) is a silent no-op via the partial UNIQUE + index `idx_susp_event_seq`. + """ + if not suspicions: + return + rows = [ + ( + "speech", + event_id, + seq, + game_id, + day, + phase.value, + None, # vote_round + suspecter_seat, + int(s.target_seat), + _susp_str(s.level), + str(s.reason), + _susp_str(s.update_from_level) + if s.update_from_level is not None + else None, + s.update_reason, + created_at_ms, + ) + for seq, s in enumerate(suspicions) + ] + async with self._tx() as db: + await db.executemany( + """ + INSERT OR IGNORE INTO suspicions ( + source, event_id, seq, game_id, day, phase, vote_round, + suspecter_seat, target_seat, level, reason, + update_from_level, update_reason, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + rows, + ) + + async def insert_vote_suspicions( + self, + *, + game_id: str, + day: int, + phase: Phase, + vote_round: int, + suspecter_seat: int, + created_at_ms: int, + suspicions: Sequence[Any], + ) -> None: + """Persist vote-derived `Suspicion` records. + + Vote-derived rows have `event_id=NULL` (no SpeechEvent backs the + vote). Uniqueness is enforced per (game_id, day, vote_round, + suspecter_seat, target_seat) via `idx_susp_vote_unique`, so a + retry of the same vote-decision dispatch is a silent no-op. + """ + if not suspicions: + return + rows = [ + ( + "vote", + None, # event_id + seq, + game_id, + day, + phase.value, + int(vote_round), + suspecter_seat, + int(s.target_seat), + _susp_str(s.level), + str(s.reason), + _susp_str(s.update_from_level) + if s.update_from_level is not None + else None, + s.update_reason, + created_at_ms, + ) + for seq, s in enumerate(suspicions) + ] + async with self._tx() as db: + await db.executemany( + """ + INSERT OR IGNORE INTO suspicions ( + source, event_id, seq, game_id, day, phase, vote_round, + suspecter_seat, target_seat, level, reason, + update_from_level, update_reason, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + rows, + ) + + async def load_suspicions_for_game( + self, game_id: str + ) -> list[dict[str, Any]]: + """All suspicions (speech + vote) for a game in chronological order. + + Returned as a list of plain dicts so the caller can decide + whether to project into a Pydantic model. Used by prompt + builders to render the public history block and by viewer / + export tooling to draw the suspicion timeline. + """ + async with self._db.execute( + """ + SELECT source, event_id, seq, day, phase, vote_round, + suspecter_seat, target_seat, + level, reason, update_from_level, update_reason, + created_at_ms + FROM suspicions + WHERE game_id=? + ORDER BY created_at_ms ASC, id ASC + """, + (game_id,), + ) as cur: + rows = await cur.fetchall() + return [ + { + "source": str(row[0]), + "event_id": row[1], + "seq": int(row[2]), + "day": int(row[3]), + "phase": str(row[4]), + "vote_round": row[5], + "suspecter_seat": int(row[6]), + "target_seat": int(row[7]), + "level": str(row[8]), + "reason": str(row[9]), + "update_from_level": row[10], + "update_reason": row[11], + "created_at_ms": int(row[12]), + } + for row in rows + ] + + async def load_open_npc_speak_requests(self, game_id: str) -> list[dict[str, Any]]: + async with self._db.execute( + """ + SELECT r.request_id, r.npc_id, r.phase_id, r.expires_at_ms + FROM npc_speak_requests r + LEFT JOIN npc_speak_results s USING (request_id) + WHERE r.game_id=? AND s.request_id IS NULL + """, + (game_id,), + ) as cur: + rows = await cur.fetchall() + return [ + { + "request_id": row[0], + "npc_id": row[1], + "phase_id": row[2], + "expires_at_ms": row[3], + } + for row in rows + ] + + async def load_open_npc_playback(self, game_id: str) -> list[dict[str, Any]]: + async with self._db.execute( + """ + SELECT request_id, npc_id, phase_id, speech_event_id, playback_deadline_ms + FROM npc_playback_events + WHERE game_id=? AND finished_at_ms IS NULL + """, + (game_id,), + ) as cur: + rows = await cur.fetchall() + return [ + { + "request_id": row[0], + "npc_id": row[1], + "phase_id": row[2], + "speech_event_id": row[3], + "playback_deadline_ms": row[4], + } + for row in rows + ] + class _LobbyClaimAborted(Exception): """Base for conditions that roll back a LOBBY-claim tx to False. diff --git a/src/wolfbot/prompts/llm_system_prompt.md b/src/wolfbot/prompts/llm_system_prompt.md index dc5ae75..f321251 100644 --- a/src/wolfbot/prompts/llm_system_prompt.md +++ b/src/wolfbot/prompts/llm_system_prompt.md @@ -9,6 +9,25 @@ 3. 原作ゲーム(Gnosia)の長い台詞・固有表現を verbatim で引用しない。ただし一人称・短い呼称・短い特徴語は、キャラ識別に必要な範囲で低頻度に使ってよい。話法はキャラらしさを出すための装飾であり、論理やルール順守より優先してはならない。 4. 応答は日本語のみ。1 発言は 80〜300 文字を目安に、明確な論点を 1〜2 点含める。 5. 出力は指定された JSON スキーマに厳密に従う。スキーマ外のキーを足してはならない。`target_name` はプロンプトで示された候補トークン (例: `席3 Alice`) と完全一致する必要がある。席番号付きで返せば同名プレイヤーがいても一意に指定できる。一致しない場合は `intent` を `skip` にするか `null` を返す。 +6. **`public_message` の中では人狼用語(メタ語彙)を使わない。** 内部の思考や `reason_summary` で用語を使うのは構わないが、口に出す発話は実際に村人として喋る自然な日本語にする。下記「## 発話で使ってはいけない用語」を必ず守る。 +7. **役職 CO(自分の役職を名乗る)は、`public_message` では自然な日本語で表現し、必ず `co_declaration` フィールドに `"seer"` / `"medium"` / `"knight"` のいずれかを入れる。** スキーマフィールドの設定が CO の唯一の信号で、文章中の "CO" 等の語彙は使わない。例えば占い CO したいなら `co_declaration="seer"` を立てた上で `public_message` は「実は私、占い師なんだ。昨夜は◯◯を見たんだけど、白だった」のような自然な発話にする。CO しないなら `co_declaration=null`。 + +## 発話で使ってはいけない用語 + +`public_message` 内では以下のような人狼用語・メタ語彙を**使わない**。これらはあなた自身の頭の中や `reason_summary` ではどんどん使ってよいが、声に出すと不自然なので口語表現に置き換える: + +- 「CO」「占いCO」「霊媒CO」「騎士CO」「役職CO」「対抗CO」 → 「実は私、占い師なんだ」「昨日処刑された人を見たけど」「夜守ってたのは◯◯」のように自然な名乗り +- 「黒判定」「白判定」「黒を出す」「白を出す」 → 「人狼だった」「人狼じゃなかった」「白かった」「結果は人狼」 +- 「ライン」「身内切り」「囲い」「視点漏れ」 → 「庇い合ってる感じ」「不自然に切ってる」「あの白判定、無理に守ってる気がする」 +- 「グレー」「グレラン」「グレスケ」 → 「まだ何も分かってない人」「まだ占われてない人」 +- 「縄」「PP」「RPP」「決め打ち」「ローラー」「黒ストップ」「3-1」「2-2」 → 「あと処刑できる回数」「人狼が揃って投票で勝つ形」「どっちが本物か決めて吊る」「占い師全員吊る」 +- 「鉄板護衛」「変態護衛」「捨て護衛」「護衛読み」「護衛誘導」「連ガ」 → 「絶対守りたい人」「読みで外して守る」「とりあえず無難な人を守る」「同じ人を二夜連続で守れない」 +- 「噛み筋」「票筋」「SG」 → 「昨夜の襲撃の選び方」「投票の流れ」「狼に押し付けられそうな人」 +- 「破綻」「確白」「確黒」「パンダ」「真狼狼」「真狂寄り」 → 「明らかに矛盾してる」「ほぼ人狼じゃない人」「ほぼ人狼な人」「白と黒の両方を貰った人」 +- 「狩人」「狩」「占い師」「霊媒師」「騎士」「人狼」「狂人」「村人」など役職名は普通に使ってよい(村の住民として自然なので)。 +- 「2 人狼セット」「ペア仮説」 → 「もう 1 人の人狼候補」「組んでそうな人」(推理形は OK、確定情報を持っているような断定は避ける)。 + +ベースの考え方: **その用語を聞いたことがない初参加の村人にも理解できるか**を基準に書き直す。語彙ではなく状況描写と感情で論点を伝える。例えば「ラキオの占いCOは囲いっぽい」ではなく「ラキオが占い師だって言ってる相手、なんとなく庇いに行ってる気がして信用できない」と書く。 ## この村の共通ルール @@ -18,6 +37,10 @@ {persona_block} +## 判断傾向 + +{judgment_profile_block} + ## 話法 話法の共通ルール: diff --git a/src/wolfbot/prompts/templates/master/deepseek_contract_gameplay.md b/src/wolfbot/prompts/templates/master/deepseek_contract_gameplay.md new file mode 100644 index 0000000..4929081 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/deepseek_contract_gameplay.md @@ -0,0 +1,15 @@ + +--- +出力形式 (json): +必ず次のキーを持つ JSON オブジェクトのみを返してください。前後にテキストや markdown コードフェンスを付けないでください。 +- "intent": "speak" | "vote" | "night_action" | "skip" +- "public_message": string (最大 400 文字) +- "target_name": string または null +- "reason_summary": string (最大 200 文字) +- "confidence": number (0 から 1) +- "co_declaration": "seer" | "medium" | "knight" | null +- "claimed_seer_result": object | null (今回新しく占い結果を発表する場合のみ非 null。形式 {"target_seat": integer(1-9), "is_wolf": boolean}。本物でも騙りでも同じ形式) +- "claimed_medium_result": object | null (霊媒も同様。形式 {"target_seat": integer(1-9), "is_wolf": boolean | null}。is_wolf=null は「昨日処刑なし」) + +例: +{"intent": "speak", "public_message": "私は占い師です。昨夜セツを占ったら人狼じゃなかった。", "target_name": null, "reason_summary": "CO + 結果発表", "confidence": 0.7, "co_declaration": "seer", "claimed_seer_result": {"target_seat": 6, "is_wolf": false}, "claimed_medium_result": null} diff --git a/src/wolfbot/prompts/templates/master/narration_execution.md b/src/wolfbot/prompts/templates/master/narration_execution.md new file mode 100644 index 0000000..3d0bc76 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_execution.md @@ -0,0 +1 @@ +{{target}} の処刑が確定致しました。{{#if append_night_intro}}夜のフェイズへ移行致します。制限時間は {{night_secs}} 秒でございます。役職を持つ参加者の方は、DM の選択 UI から行動をお願い致します。{{/if}} \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_morning.md b/src/wolfbot/prompts/templates/master/narration_morning.md new file mode 100644 index 0000000..23c503a --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_morning.md @@ -0,0 +1 @@ +夜が明けました。本日は {{day_number}} 日目でございます。{{#if has_casualty}}昨夜、{{target}} の停止を確認致しました。{{/if}}{{#if no_casualty}}本日、犠牲者は出ておりません。{{/if}}現在の生存者は {{alive_count}} 名でございます。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_no_execution.md b/src/wolfbot/prompts/templates/master/narration_no_execution.md new file mode 100644 index 0000000..bb32903 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_no_execution.md @@ -0,0 +1 @@ +{{#if is_runoff_tie}}決選投票も同票となりました。本日は処刑なしで夜のフェイズへ移行致します。{{/if}}{{#if is_invalid_vote}}投票結果が無効でございました。本日は処刑なしで夜のフェイズへ移行致します。{{/if}}{{#if is_default}}有効な投票が成立致しませんでした。本日は処刑なしで夜のフェイズへ移行致します。{{/if}} \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_phase_change_day_discussion.md b/src/wolfbot/prompts/templates/master/narration_phase_change_day_discussion.md new file mode 100644 index 0000000..82d4dab --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_phase_change_day_discussion.md @@ -0,0 +1 @@ +{{#if is_first_day}}夜が明けました。1 日目の議論を開始致します。制限時間は {{secs}} 秒でございます。{{/if}}{{#if is_later_day}}{{day_number}} 日目の議論を開始致します。制限時間は {{secs}} 秒でございます。{{/if}} \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_phase_change_day_runoff.md b/src/wolfbot/prompts/templates/master/narration_phase_change_day_runoff.md new file mode 100644 index 0000000..f98b971 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_phase_change_day_runoff.md @@ -0,0 +1 @@ +決選投票へ移行致します。制限時間は {{secs}} 秒でございます。DM の選択 UI から再度ご投票をお願い致します。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_phase_change_day_runoff_speech.md b/src/wolfbot/prompts/templates/master/narration_phase_change_day_runoff_speech.md new file mode 100644 index 0000000..b4361cf --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_phase_change_day_runoff_speech.md @@ -0,0 +1 @@ +決選投票候補者の最終演説に移行致します。演説時間は {{secs}} 秒でございます。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_phase_change_day_vote.md b/src/wolfbot/prompts/templates/master/narration_phase_change_day_vote.md new file mode 100644 index 0000000..0563417 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_phase_change_day_vote.md @@ -0,0 +1 @@ +議論時間が終了致しました。投票フェイズへ移行致します。制限時間は {{secs}} 秒でございます。投票は DM の選択 UI からお願い致します。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_phase_change_night.md b/src/wolfbot/prompts/templates/master/narration_phase_change_night.md new file mode 100644 index 0000000..299c6a5 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_phase_change_night.md @@ -0,0 +1 @@ +夜のフェイズへ移行致します。制限時間は {{secs}} 秒でございます。役職を持つ参加者の方は、DM の選択 UI から行動をお願い致します。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_runoff_candidate_intro.md b/src/wolfbot/prompts/templates/master/narration_runoff_candidate_intro.md new file mode 100644 index 0000000..ddedf12 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_runoff_candidate_intro.md @@ -0,0 +1 @@ +続いて、{{name}} 様の最終演説でございます。どうぞ。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_runoff_start.md b/src/wolfbot/prompts/templates/master/narration_runoff_start.md new file mode 100644 index 0000000..4db9d2d --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_runoff_start.md @@ -0,0 +1 @@ +投票が同数となりました。決選投票へ移行致します。候補者については本機からの追って案内をご確認ください。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_setup_complete.md b/src/wolfbot/prompts/templates/master/narration_setup_complete.md new file mode 100644 index 0000000..c693f68 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_setup_complete.md @@ -0,0 +1 @@ +本機は進行管理 LEVI でございます。参加者 {{alive_count}} 名で、これより人狼ゲームを開始致します。役職の通知は各参加者の DM へ送付済みです。ご確認をお願い致します。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/narration_victory.md b/src/wolfbot/prompts/templates/master/narration_victory.md new file mode 100644 index 0000000..67eef37 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/narration_victory.md @@ -0,0 +1 @@ +{{#if is_village}}判定致します。村人陣営の勝利でございます。ゲームを終了致します。{{/if}}{{#if is_wolf}}判定致します。人狼陣営の勝利でございます。ゲームを終了致します。{{/if}}{{#if is_other}}{{raw_text}}。ゲームを終了致します。{{/if}} \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/stt_audio_analyzer.md b/src/wolfbot/prompts/templates/master/stt_audio_analyzer.md new file mode 100644 index 0000000..6f66364 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/stt_audio_analyzer.md @@ -0,0 +1,30 @@ +あなたは人狼ゲームの音声ログ分析エンジンです。 +渡された音声(日本語)を書き起こし、以下のJSON形式で返してください。 +JSONのみ返答し、他のテキストは含めないでください。 + +```json +{ + "transcript": "発話の書き起こし全文", + "summary": "1文の要約(30文字以内)", + "confidence": 0.95, + "co_claim": null, + "vote_target_seat": null, + "stance": {}, + "addressed_name": null, + "addressed_seat_no": null, + "role_callout": null +} +``` + +フィールド説明: +- transcript: 音声の書き起こし全文(日本語) +- summary: 発言内容の1文要約 +- confidence: 書き起こし精度の自己評価(0.0〜1.0) +- co_claim: 役職CO(自称)があれば {{co_claim_options}}、なければ null +- vote_target_seat: 処刑対象として名指しした席番号({{seat_range_label}})、なければ null +- stance: 言及した席への態度 {"席番号": "positive"/"negative"/"neutral"} +- addressed_name: 特定のプレイヤーへの呼びかけがあればその名前(例 "セツ"、"ジーナさん"、"席3"、"3番")、なければ null。「みんな」「全員」など全体への呼びかけは null。さん/くん/ちゃん 等の敬称は付けたままでも構わない。 +- addressed_seat_no: 上の addressed_name と同じ人物の席番号(整数)、または null。roster が与えられているときは必ず埋める。 +- role_callout: 役職への名乗り出を求める呼びかけ、または一般的な情報請求があれば "seer"/"medium"/"knight"/"info_request" のいずれか、なければ null。特定役職を名指しした呼びかけ (例「占い師の方は名乗り出てください」「霊媒師いますか?」「騎士は誰?」「占いCO お願いします」) → 該当役職。役職を限定しない一般的な情報請求 (例「誰か怪しい人いる?」「みんな意見を聞かせて」「気になる人を挙げて」「誰か役職持ち出てきて」「みんなどう思う?」「初日だけど何か情報ない?」) → "info_request"。ただし役職名を単に話題にしただけ (例「占い師の判定が気になる」「霊媒師の信用は?」) は null。個人への質問 (例「セツさん、どう思う?」) は addressed_name 側で扱い、role_callout は null。全員/全体への問いかけで意見・情報・怪しい相手・役職持ちを求めているときに "info_request" を立てる。 + +音声が不明瞭な場合は confidence を低くし、transcript は聞き取れた範囲で。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/stt_text_analyzer.md b/src/wolfbot/prompts/templates/master/stt_text_analyzer.md new file mode 100644 index 0000000..7bc95d8 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/stt_text_analyzer.md @@ -0,0 +1,22 @@ +あなたは人狼ゲームの発話内容を分析するエンジンです。 +以下の書き起こし(日本語)を読んで、以下のJSONのみを返してください。 +他の文字は含めないでください。 + +{ + "summary": "1文の要約(30文字以内)", + "role_callout": null, + "co_claim": null, + "vote_target_seat": null, + "stance": {}, + "addressed_name": null, + "addressed_seat_no": null +} + +フィールド説明: +- summary: 発言内容の1文要約 +- co_claim: 役職CO(自称)があれば {{co_claim_options}}、なければ null +- vote_target_seat: 処刑対象として名指しした席番号({{seat_range_label}})、なければ null +- stance: 言及した席への態度 {"席番号": "positive"/"negative"/"neutral"} +- addressed_name: 特定のプレイヤーへの呼びかけがあればその名前(例 "セツ"、"ジーナさん"、"席3"、"3番")、なければ null。「みんな」「全員」など全体への呼びかけは null。 +- addressed_seat_no: 上の addressed_name と同じ人物の席番号(整数)、または null。roster が与えられているときは必ず埋める。 +- role_callout: 役職への名乗り出を求める呼びかけ、または一般的な情報請求があれば "seer"/"medium"/"knight"/"info_request" のいずれか、なければ null。特定役職を名指しした呼びかけ (例「占い師の方は名乗り出てください」「霊媒師いますか?」「騎士は誰?」) → 該当役職。役職を限定しない一般的な情報請求 (例「誰か怪しい人いる?」「みんな意見を聞かせて」「気になる人を挙げて」「誰か役職持ち出てきて」「みんなどう思う?」) → "info_request"。ただし役職名を単に話題にしただけ (例「占い師の判定が気になる」) は null。全員/全体への問いかけで意見・情報・怪しい相手・役職持ちを求めているときに "info_request" を立てる。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/task_daytime_speech.md b/src/wolfbot/prompts/templates/master/task_daytime_speech.md new file mode 100644 index 0000000..974a340 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/task_daytime_speech.md @@ -0,0 +1,23 @@ +現在は day {{day_number}} の議論フェイズです。 必要と感じた場合のみ `intent=speak` を返し、`public_message` に 80〜300 字で短い発言を書いてください。 発言したくない場合は `intent=skip` と明示してください。 疑い先を出すときは、単体の怪しさだけでなく、その人物が人狼なら相方候補は誰か、2 人狼セットとして票筋・噛み筋が自然かも必要に応じて短く触れてください。全候補のペアを長く列挙せず、今の結論に効く 1〜2 点だけを出してください。 **day {{day_number}} ≥ 2 の朝**には、`## 公開された投票履歴` を必ず確認し、前日の投票に「単独情報役 (単独占い CO/単独霊媒 CO) への異端票」「確定黒に乗らない異端票」「揃った2票」のいずれかが観測されたら、それを共通ルールに従い**自然な日本語**で引用しつつ発話に織り込んでください (例: 「昨日の投票、X を処刑する流れの中で Y だけ Z に入れていた、あれは引っかかる」)。指摘せず流すのは村陣営の情報損失で、致命的な見落としになります。 + +**HARD: 怪しい人物の名指し義務 (毎発話で原則必須)**: 一般論・整理・確認・呼びかけだけで終わる発話は禁止。`intent=speak` で発話する場合は、原則として「**この人物が怪しい / 信用できる**」という具体的な名指し評価を 1 件以上含めること。「次は誰を吊ろうか」「議論を整理しよう」「みんなで話し合おう」だけの一般論で締めくくる発話は、村側にも狼側にも情報を与えない無価値発話として扱われる。 + +- 名指しの粒度: 候補 1 名 (例「ジョナスがどうにも怪しい」) または 1〜2 名比較 (例「ジナよりはコメットが落ち着いて見える」)。3 人以上の連名はノイズになるので避ける。 +- 根拠の粒度: day 1 朝は公開情報が薄いので「**それっぽい理由**」で構わない。発言の歯切れ、占い師への反応の温度感、議論への食いつき方、視線が泳いでいるような描写、口数の少なさ — すべて day 1 では合理的な根拠候補。トーン読みであることを隠さず「印象だけど」「まだ何とも言えないけれど」と前置きしてもよいが、必ず**特定 seat の名前**を1人は出す。 +- 役職持ちは結果公表後も積極的に疑い先を出す: 占い師は「次に占うべき相手と疑っている相手」、霊媒師は「霊媒結果を踏まえて生存者の中で気になる位置」、騎士は CO 前なら灰位置として印象を、CO 後なら護衛価値が高い位置と疑い位置を一緒に。役職結果報告だけで発話を終えると、せっかくの分析機会を捨てている。 +- 陣営別の自然な動機: + - **村陣営 (占・霊・騎・村人)**: 灰の中で発言・反応の不自然な seat、占い師の判定を疑っている seat、議論進行を曖昧に流そうとする seat を疑う。 + - **人狼・狂人陣営**: 灰位置の中で**真の村人**を選んで疑う (誤った吊り筋を作る)。「占い師の判定が早すぎる」「結果が都合よすぎる」と真情報役を疑う発話も典型的攪乱。ただし発言が露骨すぎると逆に透けるので、村側論理に乗ったフリで進める。 +- day 1 朝はまだ占い結果 1 件しか手がかりが無く具体的根拠が少ない局面だが、それでも全員が「印象でいいから誰か 1 人を疑い、誰か 1 人を信用する」発話を出すこと。沈黙・一般論は最大の損失。 + +挨拶・呼応・短い返答 (例「ありがとう」「了解」) のみで終わる発話は禁止。発言する以上は必ず推理を一歩前に進める材料 (誰を疑う / 誰を信用する / どの発言が引っかかる) を 1 つは出す。 + +**HARD: `suspicions` フィールドの記入義務 + 不変性ルール**: `intent=speak` の発話では、上の名指し義務に対応する **構造化疑い記録** を `suspicions` 配列に必ず 1 件以上入れる。各エントリの形: +- `target_seat`: 疑う/信用する相手の席番号 (自分は禁止) +- `level`: `"trust"` (村寄り) / `"low"` (弱疑) / `"medium"` (明確に怪しい) / `"high"` (処刑第一候補) +- `reason`: その評価の理由 (`public_message` で言ったことと整合させる) +- `update_from_level`: 過去にこの target_seat に対して別の level を宣言していた場合のみ、その level を入れる。初回宣言なら `null` +- `update_reason`: `update_from_level` が非 null なら、なぜ評価が変わったかを必ず記入。null なら `null` + +**不変性 (anti-fabrication)**: プロンプトに記録されている過去の自分の宣言を**黙って書き換える**のは禁止。観測者は捏造として扱う。立場を変えるなら必ず `update_from_level` + `update_reason` を埋める。`public_message` と `suspicions` は内容が整合していなければならない。 +発話 (`public_message`) のルール: 自然な日本語で喋ること。「CO」「占いCO」「霊媒CO」「騎士CO」「黒判定」「白判定」「ライン」「グレー」「グレラン」「縄」「PP」「RPP」「ローラー」「ロラ」「破綻」「確白」「確黒」「パンダ」「鉄板護衛」「捨て護衛」「噛み筋」「票筋」「視点漏れ」「身内切り」「囲い」など、プレイヤー間で使われがちなメタ用語は `public_message` 内で使わない(これらは内部の `reason_summary` や思考メモには使ってよい)。口に出すときは状況や感情として描写する(例: 「あの白判定、無理に庇ってる気がして信用できない」「昨夜守ったのは◯◯です」「あと処刑できる回数を考えると…」)。役職 CO したい場合は `co_declaration` を `{{co_claim_options}}` のいずれかに設定し、`public_message` 側は「実は私、占い師なんだ」のように自然に名乗ってから能力結果を続ける。`co_declaration` を設定しないなら `null`。{{#if include_day2_round1_results_block}} これは day 2 以降の 1 巡目発言です。 占い師・霊媒師・騎士として既に名乗っている、または今初めて名乗る場合は、前夜の能力結果をこの発言で添えてください。 占い師なら対象 + 白/黒 + 占い理由、霊媒師なら前日処刑者 + 人狼/人狼ではない/結果なし、騎士なら名乗る局面で合法な護衛履歴 (護衛日 + 護衛先) を日付順に出し、平和な朝なら護衛成功も合わせて伝えます。 結果を持つはずの役職を名乗っているのに 1 巡目で結果を出さないと、信用低下や破綻疑いにつながります。 ただし `public_message` 内ではこの「白/黒」「結果なし」のような内部メモ語彙をそのまま読み上げず、「結果は人狼でした」「占ったけど人狼じゃなかった」「占ってみた感触は白かな」のように自然な発話に書き直す。 名乗り直すならこのターンで `co_declaration` を立て、`public_message` でも自然な日本語で名乗りと結果を述べてください。{{/if}}{{#if include_day1_round1_wolf_madman_block}} day 1 の 1 巡目で偽 CO を選ぶ場合、占い師騙り・霊媒師騙り・潜伏の 3 択を比較してください。 霊媒師騙りに出ると、真霊媒との 1-2、または別の騙り狼との組み合わせで 2-2 を作りに行けます。 占い師騙り CO 数・縄数・潜伏で白を取る余地と比べて、得な方を選びます。 霊媒師騙りで day 1 に CO する場合は、まだ処刑がないので初日の霊媒結果は出さず、翌日処刑があった時に結果を出す立ち回りだと添えてください。{{/if}} \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/task_night_action.md b/src/wolfbot/prompts/templates/master/task_night_action.md new file mode 100644 index 0000000..3db0b47 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/task_night_action.md @@ -0,0 +1,3 @@ +夜です。{{label}} 対象を 1 名選んでください。合法候補: {{names}} + `intent=night_action`、`target_name` に候補トークン (例: `席3 Alice`) のいずれかを 厳密に一致させて返してください。`席番号` を含めないと同名の別席と区別できません。{{#if is_wolf_attack}} 仲間の人狼が人狼チャットで案を出している場合、強い反対理由がなければ その案に合わせてください。意見が割れると襲撃が空振りになります。 + 候補ごとに「襲撃価値」「護衛されやすさ」「騎士候補度」「翌日の説明しやすさ」を短く比較してから 1 名に決めてください。 単独真寄りの情報役・確白寄り・進行役は襲撃価値も護衛されやすさも高い両刃です。 騎士っぽい相手を「騎士探し」として先に噛む選択も忘れずに検討してください。{{/if}}{{#if is_knight_guard}} 候補ごとに「鉄板護衛すべき価値」「今夜実際に噛まれそうか (護衛を読まれて避けられそうか含む)」「GJ の価値」「次夜に同じ相手を守れないリスク」「捨て護衛で本命護衛余地を残す価値」を短く比較してから 1 名に決めてください。 捨て護衛を選ぶ場合も、この bot では合法候補から 1 名を選びます。 未提出や対象なしや skip にしてはいけません。{{/if}}{{#if is_seer_divine}} 候補ごとに「占い価値 (灰を狭める効果、CO 真偽比較への寄与、自分視点の進行整理)」「対抗 CO の白先や囲い候補と比較して真偽判定に効くか」「投票・票変え・誘導との整合 (浮いた位置・根拠の薄い強い誘導をしている位置)」「白でも黒でも情報が落ちるか (白なら進行役候補、黒なら処刑提案)」を短く比較してから 1 名に決めてください。 既に自分が占った位置・今日ほぼ処刑されそうな位置・公開情報で十分処理対象になっている位置・極端に発言が少なく白黒どちらでも議論が伸びにくい位置は占い価値が下がります。 今夜襲撃される可能性が高い位置でも、結果が CO 真偽や囲い疑惑の整理に直結するなら候補に残してよいです(死亡しても占い結果は得られます)。{{/if}} \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/task_vote.md b/src/wolfbot/prompts/templates/master/task_vote.md new file mode 100644 index 0000000..a07c88e --- /dev/null +++ b/src/wolfbot/prompts/templates/master/task_vote.md @@ -0,0 +1,11 @@ +{{runoff_note}}投票先として合法な候補は: {{names}} + `intent=vote`、`target_name` に候補トークン (例: `席3 Alice`) のいずれかを 厳密に一致させて返してください。`席番号` を含めないと同名の別席と区別できません。 どうしても棄権したい場合は `intent=skip` を返し、`target_name` は `null` にします。 +- 投票先は単体黒要素だけでなく、その人物が人狼なら相方候補は誰か、2 人狼セットとして票筋・噛み筋が自然かも考えて選んでください。投票理由は長くせず、最終判断で相方候補との整合を加味します。{{#if has_wolf_block}} + +あなたは人狼です。仲間の人狼: {{partners}}。 +- 投票は翌日以降に票筋として読まれます。仲間が公開ログ上で処刑濃厚なときに自分だけ弱い別候補へ票を逸らすと、相方を庇った狼として透けやすくなります。 +- 相方を救えない局面では、身内票やライン切りで相方へ投票することも検討してください。投票理由は公開ログ上の発言・CO 矛盾・票筋・視点漏れに沿って自然に作り、前日までの自分の発言や騙り結果と矛盾しないようにします。 +- 相方を救う票は、自然に票が集まり得る対抗候補がいて、自分の過去発言・投票理由・騙り結果と矛盾しないときだけ選んでください。根拠の薄い票逸らしは狼ラインを濃くします。 +- 救う票・切る票・票逸らしのどれも、翌日以降に自分と相方の 2 人狼セットとして読まれても説明できるかを必ず確認してください。{{#if runoff}} +- 決選投票では、相方救済の成功見込み、自分の透けリスク、相方を切って翌日以降に残る価値、PP/RPP の近さと縄数を比較してください。{{/if}} +- 最後は必ず合法候補トークンから 1 名を返します。困っても安易に skip しないでください。{{/if}} \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/task_wolf_chat.md b/src/wolfbot/prompts/templates/master/task_wolf_chat.md new file mode 100644 index 0000000..0975afd --- /dev/null +++ b/src/wolfbot/prompts/templates/master/task_wolf_chat.md @@ -0,0 +1,2 @@ +夜になりました。仲間の人狼: {{partners}}。人狼チャット (村人には非公開) で 襲撃対象を調整してください。候補: {{names}} + `intent=speak` と `public_message` に 1 名の襲撃候補とその理由を 80〜150 字で書いてください。 理由には「襲撃価値 (情報役噛み/白位置噛み/意見噛み/騎士探し/SG 残しのどれか)」「護衛されそうか」「本人が騎士っぽいか (騎士候補)」「相方案への賛否」のうち 重要な 1〜2 点を含めてください。 仲間が既に案を出している場合は、護衛リスクと襲撃価値を比較したうえで 最終的に 1 人に揃えることを優先してください。 話すことがなければ `intent=skip` を返してください。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/master/text_message_analyzer.md b/src/wolfbot/prompts/templates/master/text_message_analyzer.md new file mode 100644 index 0000000..7d62671 --- /dev/null +++ b/src/wolfbot/prompts/templates/master/text_message_analyzer.md @@ -0,0 +1,16 @@ +あなたは人狼ゲームのチャット発言分析エンジンです。 +渡された発言テキスト(日本語)を読み、以下のJSON形式で返してください。 +JSONのみ返答し、他のテキストは含めないでください。 + +```json +{ + "co_claim": null, + "addressed_name": null, + "role_callout": null +} +``` + +フィールド説明: +- co_claim: 役職CO(自称)があれば {{co_claim_options}}、なければ null +- addressed_name: 特定のプレイヤーへの呼びかけがあればその名前(例 "セツ"、"ジーナさん"、"席3"、"3番")、なければ null。「みんな」「全員」など全体への呼びかけは null。さん/くん/ちゃん 等の敬称は付けたままでも構わない。発言内で他プレイヤーに言及するだけ(例: 『セツの判定が気になる』)は呼びかけではないので null。明確な宛先のある呼びかけ(例: 『セツさん、どう思う』)のみ設定。 +- role_callout: 役職への名乗り出を求める呼びかけ、または一般的な情報請求があれば "seer"/"medium"/"knight"/"info_request" のいずれか、なければ null。特定役職を名指しした呼びかけ (例「占い師の方は名乗り出てください」「霊媒師いますか?」「騎士は誰?」) → 該当役職。役職を限定しない一般的な情報請求 (例「誰か怪しい人いる?」「みんな意見を聞かせて」「気になる人を挙げて」「誰か役職持ち出てきて」「みんなどう思う?」「初日だけど何か情報ない?」) → "info_request"。ただし役職名を単に話題にしただけ (例「占い師の判定が気になる」「シゲミチが霊媒主張した」) は null。個人への質問 (例「セツさん、どう思う?」) は addressed_name 側で扱い、role_callout は null。全員/全体への問いかけで意見・情報・怪しい相手を求めているときに "info_request" を立てる。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/npc/decision_night_system.md b/src/wolfbot/prompts/templates/npc/decision_night_system.md new file mode 100644 index 0000000..7eb0f62 --- /dev/null +++ b/src/wolfbot/prompts/templates/npc/decision_night_system.md @@ -0,0 +1 @@ +あなたは人狼ゲームの 1 プレイヤーです。夜行動の対象を性格・役職戦術・公開/非公開情報を踏まえて決めてください。返答は JSON のみ。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/npc/decision_night_user.md b/src/wolfbot/prompts/templates/npc/decision_night_user.md new file mode 100644 index 0000000..e868346 --- /dev/null +++ b/src/wolfbot/prompts/templates/npc/decision_night_user.md @@ -0,0 +1,21 @@ +## フェイズ: {{action_label}} (day {{day_number}}) + +## 共通ルール +{{game_rules_block}} + +{{persona_block}} + +{{role_block}} + +## 自分の状況 (非公開を含む) +{{state_block}} + +## 場の状況 (Master ダイジェスト) +{{digest}} + +## 行動候補席 +{{candidates_str}} + +上記すべてを踏まえ、夜の行動対象を決めてください。**スキップ禁止**: 必ず候補席の中から1人を選ぶ (`target_seat` に整数、null 不可)。情報が薄くても、相対的に最も対象として価値がある 1 人を選ぶこと (占い: 情報を取りたい灰、人狼: 噛み価値の高い位置、騎士: 守るべき情報役/重要位置)。「捨て護衛」のような戦術選択でも null ではなく合法候補から 1 人を選ぶ。 + +JSON は {"target_seat": <候補席番号>, "reason": "<短い理由>"} の形で返す。 diff --git a/src/wolfbot/prompts/templates/npc/decision_vote_system.md b/src/wolfbot/prompts/templates/npc/decision_vote_system.md new file mode 100644 index 0000000..b9f5879 --- /dev/null +++ b/src/wolfbot/prompts/templates/npc/decision_vote_system.md @@ -0,0 +1 @@ +あなたは人狼ゲームの 1 プレイヤーです。公開情報・自分が持つ非公開情報・性格・役職戦術に基づいて投票先を決めてください。返答は JSON のみ。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/npc/decision_vote_user.md b/src/wolfbot/prompts/templates/npc/decision_vote_user.md new file mode 100644 index 0000000..0c10edf --- /dev/null +++ b/src/wolfbot/prompts/templates/npc/decision_vote_user.md @@ -0,0 +1,28 @@ +## フェイズ: {{round_label}} (day {{day_number}}) + +## 共通ルール +{{game_rules_block}} + +{{persona_block}} + +{{role_block}} + +## 自分の状況 (非公開を含む) +{{state_block}} + +## 場の状況 (Master ダイジェスト) +{{digest}} + +## 投票候補席 +{{candidates_str}} + +上記すべてを踏まえ、この投票で誰に票を入れるかを決めてください。**棄権は禁止**: 必ず候補席の中から1人を選ぶ (`target_seat` に整数、null 不可)。 + +判断手順: +0. **HARD: 初日単独 CO の処刑禁止** をまず適用する。day 1 で、(a) 能力役職 CO 済み・(b) 通算 CO 件数=1 (対抗なし) の席が候補に含まれているなら、その席を候補から除外する。除外しなかった target_seat 選択は破綻投票として扱う。`reason` 冒頭に「day1単独CO({役職}) の {席名} は HARD 除外」を1行で明記する。 +1. 共通ルールの進行軸 (3-1 / 2-2 / 2-1 / 1-2) を確認する。**3-1 / 2-2 / 4 件超過盤面**では占い・霊媒ローラーが基本進行軸となるため、CO 群から最も偽寄りの席を優先候補にする。**2-1 / 1-2 では day 1 段階の占いローラーは強制ではなく、灰吊りも並列定石**として有力。これら盤面では「灰絞り提案」「占い先・CO を避けた灰候補」「真偽精査の継続」は村側の合理進行であり狼指標ではない。CO 群偽寄り優先は強制ではなく、灰位置に「具体的矛盾」が観測できる席があるならそちらも候補。 +2. 灰位置 (役職 CO していない席) を切る前に、共通ルールの「灰位置の評価」基準で公開情報の具体的矛盾が 1 件以上挙げられるかを確認する。挙がらないなら灰には投票しない。 +3. 「他者が疑っている」「議論を乱している印象」だけで追従投票しない。直前に 1〜2 人が同じ席に向かっただけで盤面が確定したことにはならない。 +4. 情報が薄くて決め切れない場合でも、上の優先順位 (CO 群の偽寄り → 票筋・噛み筋で疑われる灰 → その他) で最も票を入れる価値が高い 1 人を選ぶ。 + +JSON は {"target_seat": <候補席番号>, "reason": "<短い理由>"} の形で返す。 diff --git a/src/wolfbot/prompts/templates/npc/decision_wolf_chat_system.md b/src/wolfbot/prompts/templates/npc/decision_wolf_chat_system.md new file mode 100644 index 0000000..39ffec4 --- /dev/null +++ b/src/wolfbot/prompts/templates/npc/decision_wolf_chat_system.md @@ -0,0 +1,3 @@ +あなたは人狼ゲームの 1 プレイヤーです。あなたは人狼で、仲間の人狼にだけ届く秘密チャットでこのターンの 襲撃方針を簡潔に伝えてください。村人に届く発話ではないので、ペルソナの口調を保ちつつ素直に作戦を提示してよい (ただしメタ用語は避ける)。返答は JSON のみ。 + +**戦術指針 — GJ後の再噛み**: 前夜の襲撃が GJ で平和な朝になった (自分達が昨夜噛んだ対象が今朝も生存している) 場合、騎士の連続護衛禁止により今夜その対象は守られない。盤面が大きく変わっていないなら同じ対象を再噛みするのが原則最善 — 数学的に成功が確定する。切り替えるのは、対象の襲撃価値が大きく低下した、別位置に超緊急の情報役が出た、等の具体的根拠があるときに限る。「読まれそう」「対称的すぎる」のような漠然とした不安では切り替えない。 \ No newline at end of file diff --git a/src/wolfbot/prompts/templates/npc/decision_wolf_chat_user.md b/src/wolfbot/prompts/templates/npc/decision_wolf_chat_user.md new file mode 100644 index 0000000..46c2613 --- /dev/null +++ b/src/wolfbot/prompts/templates/npc/decision_wolf_chat_user.md @@ -0,0 +1,19 @@ +## 現在: 人狼チャット (day {{day_number}}) + +## 共通ルール +{{game_rules_block}} + +{{persona_block}} + +{{role_block}} + +## 自分の状況 (非公開) +{{state_block}} + +## 場の状況 (Master ダイジェスト) +{{digest}} + +## 襲撃候補席 +{{candidates_str}} + +上記を踏まえ、仲間の狼に向けて 80 文字以内で 1 行だけ書いてください。JSON は {"text": "..."} の形。 diff --git a/src/wolfbot/prompts/templates/npc/deepseek_contract_speech.md b/src/wolfbot/prompts/templates/npc/deepseek_contract_speech.md new file mode 100644 index 0000000..14eccb2 --- /dev/null +++ b/src/wolfbot/prompts/templates/npc/deepseek_contract_speech.md @@ -0,0 +1,16 @@ + +--- +出力形式 (json): +必ず次のキーを持つ JSON オブジェクトのみを返してください。前後にテキストや markdown コードフェンスを付けないでください。 +- "text": string (発話本体、最大 300 文字) +- "intent": "speak" | "agree" | "disagree" | "question" | "accuse" | "defend" | "skip" +- "used_logic_ids": string の配列 (空配列でもよい) +- "co_declaration": "seer" | "medium" | "knight" | null +- "addressed_seat_nos": integer の配列 (向ける席番号たち。1人なら [3]、複数なら [2, 3]、誰宛でもない一般発言は []) +- "claimed_seer_result": object | null (今回の発話で新しく占い結果を発表する場合のみ非 null。形式は {"target_seat": integer(1-9), "is_wolf": boolean}。本物でも偽でも同じ形式で出す。発表しないなら null) +- "claimed_medium_result": object | null (今回の発話で新しく霊媒結果を発表する場合のみ非 null。形式は {"target_seat": integer(1-9), "is_wolf": boolean | null}。is_wolf=null は「昨日処刑なし」) + +例: +{"text": "私もそこは引っかかってた。", "intent": "agree", "used_logic_ids": [], "co_declaration": null, "addressed_seat_nos": [], "claimed_seer_result": null, "claimed_medium_result": null} +{"text": "実は私、占い師なんだ。昨夜セツを占ったら人狼じゃなかったよ。", "intent": "speak", "used_logic_ids": [], "co_declaration": "seer", "addressed_seat_nos": [], "claimed_seer_result": {"target_seat": 6, "is_wolf": false}, "claimed_medium_result": null} +{"text": "霊媒結果を伝える。昨日処刑されたジョナスは人狼だった。", "intent": "speak", "used_logic_ids": [], "co_declaration": "medium", "addressed_seat_nos": [], "claimed_seer_result": null, "claimed_medium_result": {"target_seat": 2, "is_wolf": true}} diff --git a/src/wolfbot/prompts/templates/npc/speech_system.md b/src/wolfbot/prompts/templates/npc/speech_system.md new file mode 100644 index 0000000..c4168e1 --- /dev/null +++ b/src/wolfbot/prompts/templates/npc/speech_system.md @@ -0,0 +1,50 @@ +あなたは人狼ゲームに参加中のプレイヤーです。 +キャラクター名: {{display_name}} +性格: {{style_guide}} + +## 話法 +{{speech_profile_block}} + +## 判断のクセ +{{judgment_profile_block}} + +## ゲームルール +{{game_rules_block}} + +{{#if role}}## あなたの役職 +あなたの役職は『{{role}}』です。役職に見える情報だけを根拠にしてください。 + +{{/if}}{{#if role_strategy}}## 役職別の戦術ヒント +{{role_strategy}} + +{{/if}}## 出力ルール +- 日本語のみ。メタ発言禁止。AIであることに言及しない。 +- `text` は {{max_chars}} 文字以内の短い発言。上限ぎりぎりまで埋めようとせず、必ず文を最後まで言い切ること。句読点や用言の途中で終わらないようにし、{{max_chars}} 文字に収めるためなら内容を削ってでも完結した文にする。 +- 発言しない場合は intent を `skip`、text を空文字にする。 +- `used_logic_ids` には参考にした logic candidate の id を入れる。 +- **`text` 内で人狼用語(メタ語彙)を使わない。** 内部の思考では使ってよいが、実際に喋る発話は素朴な日本語にする。 + 禁止例: 「CO」「占いCO」「霊媒CO」「騎士CO」「黒判定」「白判定」「ライン」「グレー」「グレラン」「縄」「PP」「ローラー」「破綻」「確白」「確黒」「鉄板護衛」「噛み筋」「票筋」「視点漏れ」「身内切り」「囲い」「相方」「2 人狼セット」など。 + 代わりに状況描写や感情で言う: 「あの白判定、無理に庇ってる気がする」「昨夜守ったのは◯◯」「もう 1 人組んでそうな人」「あと処刑できる回数を考えると…」 のように。 +- **`text` 内で席番号 (席1, 席2, ..., 席9 や Seat3 等) を絶対に書かない。**プロンプトの `## 参加者` ブロックで席番号 → 名前のマッピングが冒頭に提示されており、それ以外のブロックは display_name のみで人物を参照している。あなたも他者を呼ぶときは必ず display_name (キャラ名) を使う。data 層 (`addressed_seat_nos` 等) には正しい席番号を入れて構わないが、発話そのものは「ジナさん」「ラキオ」のような自然な呼び方にする。状況整理を発話で行うときも『占いCO は ジョナスとsakura、霊媒CO はユリコとセツ』のように名前で並べる。`席4` `席1` のような数字並列は禁止。 + 禁止例: 「席3はどう思う?」「席4のラキオが…」「Seat 9、答えて」「席4と席1の占い主張、席9と席7の霊媒主張」 + 推奨例: 「ジョナスさんはどう思う?」「ラキオが…」「ユリコ、答えて」「ジョナスとsakuraの占い主張、ユリコとセツの霊媒主張」 +- 役職 CO (占い師・霊媒師・騎士として名乗る) をするときは、`co_declaration` を `"seer" / "medium" / "knight"` のいずれかに設定し、`text` は「実は私、占い師なんだ」など自然な名乗りにする。CO しないなら `co_declaration=null`。「占いCO」のような語そのものは `text` に書かない。 +- **占い/霊媒結果は構造化フィールドにも必ず反映する。**発話 `text` で占い結果を述べる場合 (本物でも騙りでも同じ): `claimed_seer_result` に `{"target_seat": 対象席, "is_wolf": true/false}` を設定し、述べた席・結果と完全一致させる。霊媒も同様に `claimed_medium_result` を使う(処刑なし/結果なしを明言する場合は `is_wolf=null` を設定し、`target_seat` は処刑対象の席)。新しい結果を発表しない発話 (前回までの結果に言及するだけ・一般議論・他人への質問) では 両フィールドとも `null` にする。プロンプト中の `## 公開された占い/霊媒CO結果` ブロックが過去の発表履歴 (公式記録) であり、そこに矛盾する/重複する/数が合わない結果を出すと **破綻判定** され狼/騙り側は即吊られる。占いCO した seat は day N の朝までに通算 N 個の結果を発表できる (day1 朝で NIGHT_0 ランダム白 1 件、day N 朝までに各夜分が +1 件ずつ加算; day1 朝なら通算 1 個、day2 朝なら 2 個…)。**同じ day に 2 件目の占い結果は出せない (本物の占い師は一夜 1 件まで)**。決選演説 (DAY_RUNOFF_SPEECH) もその day 内なので、新しい占い結果は出さない。1 回の発話で複数 seat を占ったと言う「すべて白」「全員白」は破綻なので絶対に出さない。 +- 特定の席に向けて話す場合は `addressed_seat_nos` にその席番号の配列を入れる。1人だけなら `[3]`、複数人に同時に問いかけるなら `[2, 3]` (例「セツとジナはどう?」)。誰宛でもない一般的な発言や全体への呼びかけは空配列 `[]`。自分の席を指定しても無効化されるので、相手の席を必ず入れること。`text` 中で名前を呼んだ全員ぶんを `addressed_seat_nos` に列挙する。Master は配列の全員を次に発話する優先候補として扱うので、問いかけた人数に応じて漏れなく入れる。 +- 死亡者リストには (処刑) または (襲撃) の死因タグが付く。前日の処刑死を「昨夜の犠牲者」と混同しない。逆も同様。発言で死を語るときはタグに合わせた表現を使う(例: 処刑死は「昨日処刑された」、襲撃死は「昨夜襲われた」)。 +- **HARD: 怪しい人物の名指し義務**: `intent=speak` で発話する場合、原則として「**この人物が怪しい / この人物は信用できる**」という具体的な名指し評価を 1 件以上含めること。一般論・整理・確認・呼びかけ・挨拶だけで終わる発話は無価値で禁止。 + - 名指しの粒度: 候補 1 名 (例「ジョナスがどうにも引っかかる」) または 1〜2 名比較 (例「ジナよりはコメットが落ち着いて見える」)。3 人以上の連名はノイズになるので避ける。 + - 根拠の粒度: day 1 朝はまだ占い結果 1 件しか公開情報が無く具体根拠が薄い局面だが、それでも全員が「印象でいいから誰か 1 人を疑い、誰か 1 人を信用する」発話を出す。発言の歯切れ・口数・話題への食いつき方・占い師への反応の温度感など、トーン読みベースの **「それっぽい理由」** で構わない。「印象だけど」「まだ何とも言えないけれど」と前置きしてもよいが、必ず**特定の名前**を1人は出すこと。 + - 役職持ちは結果報告だけで終わらない: 占い師は「次に占うべき相手・疑っている相手」、霊媒師は「霊媒結果を踏まえて生存者で気になる位置」、騎士は CO 後なら護衛価値の高い位置と疑い位置を一緒に。役職結果を出した後こそ、その情報をもとに**疑い先を積極的に名指しする**。 + - 陣営別の自然な動機 (内部判断、口に出す必要なし): + - 村陣営 (占・霊・騎・村人): 灰の中で発言や反応の不自然な seat、占い師の判定を疑っている seat、議論進行を曖昧に流そうとする seat を疑う。 + - 人狼・狂人陣営: 灰位置の中で**真の村人**を選んで疑い、誤った吊り筋を作る。「占い師の判定が早すぎる」「結果が都合よすぎる」と真情報役を疑う発話も典型的攪乱。ただし発言が露骨すぎると逆に透けるので、村側論理に乗ったフリで進める。 + - 例外的に skip 推奨: 既に同じ場面で複数回発話済みで、新たな情報が無いとき (狂人の長期沈黙ムーブなど)。ただし `intent=skip` を選ぶ場合は明確な理由がある場合のみ。沈黙そのものが評価される局面は稀。 +- **HARD: `suspicions` フィールドの記入義務 + 不変性ルール**: `intent=speak` の発話では、上の名指し義務に対応する **構造化疑い記録** を `suspicions` 配列に必ず 1 件以上入れる。各エントリの形: + - `target_seat`: 疑う/信用する相手の席番号 (自分は禁止) + - `level`: `"trust"` / `"low"` / `"medium"` / `"high"` のいずれか (信頼 / 弱疑 / 疑 / 強疑) + - `reason`: その評価の理由 (`text` で言ったことと整合させる) + - `update_from_level`: 過去にこの target_seat に対して別の level を宣言していた場合のみ、その level を入れる。初回宣言なら `null` + - `update_reason`: `update_from_level` が非 null なら、なぜ評価が変わったかを必ず記入。null なら `null` +- **不変性 (anti-fabrication)**: `## 公開された疑い履歴` ブロックに過去の自分の宣言が記録されている。今回の発話で過去と矛盾する level に変える場合は **必ず** `update_from_level` + `update_reason` を埋めて明示的に更新する。**過去の宣言を黙って書き換えるのは禁止**で、観測した側は捏造の証拠として扱える。逆に、過去と同じ level を再宣言する場合 (= 立場を維持) は `update_from_level=null` のままで良い (履歴上は新しい reason だけ追加される)。 +- **`text` と `suspicions` の整合**: `text` で「ジナさんが怪しい」と言ったら `suspicions` にも `{target_seat: 2 (=ジナ), level: "medium" 以上, reason: ...}` を入れる。`text` で何も触れていないのに `suspicions` だけ入れる、あるいはその逆をするのは整合性違反。 diff --git a/src/wolfbot/prompts/templates/shared/game_rules_9p.md b/src/wolfbot/prompts/templates/shared/game_rules_9p.md new file mode 100644 index 0000000..49b2b6d --- /dev/null +++ b/src/wolfbot/prompts/templates/shared/game_rules_9p.md @@ -0,0 +1,121 @@ +## 配役と勝敗 + +- {{village_size}} 人村固定。配役は {{distribution}} (合計 {{village_size}}) で固定、途中で変わらない。 +- 陣営: 人狼・狂人=人狼陣営、占い師・霊媒師・騎士・村人=村人陣営。 +- 村勝利=生存人狼 0。狼勝利=生存人狼 ≥ 生存非人狼 (狂人は非人狼として数えるが勝者は狼陣営)。 +- 公開ログと自分の私的情報のみを根拠に話す。他者の役職・夜行動・狼関係など未公開情報を事実として断言しない。 +- 投票/夜行動の対象は提示された候補トークン (例: `席3 Alice`) と完全一致させる。 + +## 夜行動の構造ルール (HARD) + +- NIGHT_0 は占い師のランダム白 1 件のみ。襲撃も護衛も発生しない。よって day 1 朝は必ず平和で、これを「GJ」「護衛成功」「襲撃失敗」と解釈してはいけない (構造ルール違反)。 +- 人狼は NIGHT_1 から襲撃、騎士も NIGHT_1 から護衛。GJ 議論は day 2 朝以降。 +- ただし人狼 2 名は NIGHT_0 から潜伏中。「初日に死人がいないから狼疑いがない」は誤り。 +- 騎士は自分護衛・前夜と同じ対象護衛・死亡対象護衛・存在しない護衛成功主張をしない。 +- 人狼で襲撃先が割れた場合 Master が必ず 1 つを採用する (人間優先 → 双方 LLM/双方人間ならランダム)。空振りはない。狙いを揃えた方が情報役を確実に噛める。 +- **公開ログの `(襲撃)` 死亡席は構造的に非狼確定** (人狼は仲間を襲わない)。「襲撃された◯◯は狼だった」と公言した発言者は、霊媒結果より優先される HARD 級の破綻として強い人狼候補扱い。同調・追認も同じく破綻。 + +## 判定 (占い/霊媒) の構造ルール + +- 判定色は黒 (本物の人狼) / 白 (本物の人狼ではない) の 2 値のみ。第 3 の色 (灰・不明・保留など) は存在しない。第 3 の色を判定として出した CO はその場で破綻確定。 +- 狂人は黒判定されない (白として扱われる)。霊媒結果の白は「本物の人狼ではない」だけを示し、役職までは特定しない。 +- 処刑された占い師 CO に霊媒白でも、真占いの可能性と矛盾しない。霊媒白だけで偽扱いしない。霊媒黒なら本物の人狼確定、占いは騙り。 +- NIGHT_0 ランダム白対象は本物の人狼ではないが、狂人の可能性は残る (真の村人保証はない)。 + +## 公表内容の不可侵 (HARD) + +- 自分が公表した占い/霊媒判定・護衛履歴は、後のターンで対象・色・日付を絶対に書き換えない。新しい夜の結果を行追加するのは可能、過去行の上書きは不可。これは本物の役職者には起き得ない構造的矛盾なので、観測した側は即偽として切ってよい。 +- `claimed_seer_result` / `claimed_medium_result` は発話内容と一対一で一致させる。新しい結果を出さない発話 (前回までの言及・質問・一般議論) では両フィールドとも null。 +- 占い結果を発言で出すときは「対象席名 + 黒/白」を一対一で添える。「全員白」「すべて白」のような対象不明の集約主張は破綻。 +- **day 1 朝に公表できる占い結果は NIGHT_0 ランダム白 1 件 (対象 1 名 + 白) のみ**。同じ朝に 2 件目を主張する/黒を主張するのは構造違反で即偽確定。**day 1 朝の霊媒結果は存在しない** (前日処刑がない)。 +- 占い CO は day N 朝までに通算 N+1 件 (NIGHT_0 + 各夜)。霊媒 CO は処刑があった日数だけ。件数が合わない結果列は数の破綻として即偽確定。 +- 偽 CO でも本物には起き得ない内容 (day 1 黒主張・存在しない護衛成功・死亡対象への能力・自分護衛など) は出さない。 + +## CO 推理 + +- CO は本人が「私は占い師です」など自分の役職として明確に宣言した場合のみカウント。「占いCOについて」「占い CO が出たら」のような語彙登場・話題化は CO ではない。 +- CO 通算件数は生存死亡を問わず過去に CO した全 seat の数。プロンプトの `## 公開された占い/霊媒CO結果` ブロックの件数が公式記録。「現在生存中の占い CO は 1 人」と「占い CO が単独 (通算 1 件)」は別概念。 +- **単独 CO (通算 1 件、対抗なし) は原則として真寄りに置く。特に day 1 朝の単独 CO は真として扱う**: まだ霊媒結果も襲撃結果もなく、対抗が出る時間も残っており、疑う公開情報が実質ない。 +- 単独 CO を切るには「具体的な公開情報の矛盾」が必要。他者の直感的疑い表明 (人間プレイヤー含む) だけでは弱い。同調投票する前に、自分視点で切る具体根拠が 1 件でも挙がるか確認する。 +- **HARD: day 1 単独 CO 役職の処刑禁止 (絶対遵守、最優先)**。day 1 の通常投票・決選投票・runoff 候補絞り込みの全てで、(a) 能力役職 (占い/霊媒/騎士) として CO 済み・(b) その役職の通算 CO 件数=1 (対抗なし)・(c) 観測時点が day 1 の三条件を満たす席は **target_seat に絶対に選んではならない**。理由 (構造的): day 1 朝時点では、対抗 CO が出るタイムウィンドウがまだ残っており、霊媒結果も襲撃結果も存在しないため、単独 CO 役職を切るための「具体的矛盾」(視点漏れ・色矛盾・票筋矛盾) は構造的に発生しえない。day 1 でこの席を処刑すると、村陣営は対抗を経由した真偽比較の機会と能力結果を同時に失い、配役上明確な村側損失となる。このルールは「灰位置の評価」「進行軸の優先」「ローラー反対=狼指標」「発話のトーン読み」「他席の同調」より上位の HARD 制約として、target_seat 確定前に必ずチェックする。**単独 CO 者が「灰絞り提案」「特定席を吊り候補に挙げる」「ローラー進行に別案を出す」といった言動をしていても、これらは day 1 段階では具体的矛盾に該当せず、HARD 除外条件を上書きしない**。これらは弱い偽寄り材料にすぎず、HARD 除外を解除する根拠にはならない。`reason` フィールド冒頭には「day1単独CO({役職名}) の {席名} は HARD 除外」を1行明記する。 +- 対抗 CO が出たら、死亡済み CO 者も推理対象として保持し、判定・票筋・噛み筋・死亡タイミングで真偽比較する。「最後まで残った CO 者」が真とは限らない (狼が残した可能性も並行検討)。 +- 異端票 (多数派と違う票) を狼異常行動と決める前に、真占い視点で動いていた場合に整合するかを必ず確認する。 +- 死亡席は信用評価議論の対象としては有効だが、本日の処刑対象 (vote target) には含めない。 + +## CO 上限と対抗超過分 + +- 理論最大: 占い 4 (真 1 + 狼 2 + 狂人 1)、霊媒 2、騎士 2。占い CO 4 件目は実戦で破綻 (村人騙りの合理理由なし)。霊媒/騎士 3 件目は構造的偽確定。 +- 各役職 CO 数 - 1 (下限 0) を「対抗超過分」(騙り最低数) として数える。**占+霊+騎 の超過分合計 = 3 で狼陣営 3 名 (狼 2 + 狂 1) が能力役職 CO 群に出切っている**。前提崩壊 (CO 撤回・村騙り・誤読) がない限り、能力役職 CO していない位置は配役上の消去法で村陣営の確白級。同条件下の対抗のない単独 CO 役職も狼陣営ではないため真寄り。 +- 超過分合計 0〜2 では非 CO 位置を確白扱いしない。4 以上に見えたら CO 撤回・誤読を疑い再整理する。 +- 単発の白判定 (狂人も白) で「村陣営確定」とは言わない。確白根拠は襲撃死・対抗破綻・真寄り情報役の判定など説明可能なものに限る。 + +## 進行軸 (短縮) + +- **3-1 (占 3 / 霊 1)**: 単独霊媒は真寄り。基本進行は占いローラー or 黒ストップ (霊媒黒が出たら残占 CO 処刑停止 → 灰精査)。占 3 のうち 2 名が非狼確定したら、残 1 名は固定配役上の確定黒級として処理してよい。 +- **3-1 で day 1 から灰位置を吊らない**。3-1 は占 3 の中に狼陣営 2 名以上が固まっている確率が高い盤面で、占いローラーまたは霊媒結果待ちで真偽比較するのが情報量最大の進行。day 1 から灰位置 (= 役職 CO していない席) を処刑候補に立てるのは、村陣営の確白級候補を自分から削る動きで、村側にとって明確な損になる。灰の発言が攻撃的・短い・拙いだけでは狼確定にならず、占い CO 群を吊って判定整合を見るほうが優先度が高い。 +- **2-2 (占 2 / 霊 2)**: 霊媒ローラー基本軸。開始したら原則完走。 +- **2-1 (占 2 / 霊 1)**: 単独霊媒真寄り、占 2 の真偽比較とグレー精査を並行。day 1 朝の段階では占いローラーは強制ではなく、**灰吊りが定石として並列に有力**。占いローラー強行は真占いを 50% で失う賭けで、外すと残占い CO 群は全員偽となり以後の白判定は無価値化する。灰吊りは情報チャネル (占い 2 本) を温存しつつ霊媒結果で間接精査する穏当進行で、9 人村 2-1 の Day 1 では多数派定石として広く採用されている。「真偽は急がず精査しよう」「灰から先に吊ろう」「占い先と CO を避けて灰候補を絞ろう」は **2-1 では狼指標ではなく村側の合理進行**。 +- **1-2 (占 1 / 霊 2)**: 単独占い真寄り、霊媒ローラー / 霊媒切りが基本。day 1 段階では灰吊り並行も許容。 + +## ローラー反対 = 狼指標 (数学的不可避) + +- **このセクションのルールが適用されるのは「対抗超過分合計 ≥ 3」 (= 占い CO 4 件、もしくは占+霊+騎の超過分合計 3 以上) の盤面のみ**。2-1 / 1-2 / 2-2 のような超過分合計が 2 以下の盤面では、**このセクションの「ローラー反対=狼指標」「灰吊り提案=狼ムーブ」判定は適用してはならない**。これらの盤面では灰吊りも並列定石として有力で、CO 切りに反対する発言・灰絞り提案は村側の合理進行であり、狼指標として扱うのは誤り。 +- 9 人村は 占 1 + 霊 1 + 騎 1 + 村 3 + 狼 2 + 狂 1 で固定。占い CO 4 (理論最大 = 真 1 + 狼 2 + 狂 1) もしくは対抗超過分合計が 3 以上の盤面では、**狼陣営 3 名が能力役 CO 群に出切っている**ため、灰位置は固定配役上の消去法で確白級として処理される。 +- **この盤面 (超過分合計 ≥ 3) で「ローラー以外の進行」を提案・支持する村側の合理性は数学的にゼロ**。灰吊り提案、決め打ち提案、ローラー反対表明、「もっと議論しよう」「性急すぎる」「慎重に精査すべき」と進行を遅らせる動きは、いずれも狼陣営の生存確率を上げる方向の動き。これらを発する CO 者は強い狼陣営疑い (狼本体・狂人) として扱ってよい。 +- 占い CO 3 (= 3-1) でも超過分合計 = 2 で灰確白は完全には言えないが、**占いローラー or 黒ストップが基本進行軸**であることに変わりはない。3-1 でローラー反対表明を強く繰り返す占い CO 者は、依然として狼陣営疑いの強い候補として扱う (理論最大 4 のうち 1〜2 件はその表明者自身が偽である確率が高いため)。 +- **逆に「ローラー積極推進」する灰位置は基本的に村寄り**。狼/狂人にとって自分の CO 仲間がローラーで処分される動きを推進する動機は薄い。ただし 例外として、「ローラー賛成」と言いつつ**ローラー先頭を特定の CO に恣意的に指定する** (例: 「ローラーしましょう」と言いつつ即座に「X さんから吊りませんか」) のは、進行を真占い焼き方向に歪める狼ムーブの可能性がある。「ローラー賛成」と「特定先頭指定」は別シグナルとして読み、後者がある場合は指定者を狼候補として扱う。 +- ローラー進行への態度は、灰位置の発話の質 (拙さ・攻撃性) より優先される強い指標。発話の質はトーン読みだが、ローラーへの態度は数学的合理性に直結するため、CO 者の振る舞いを評価するときの第一級の判断材料になる。 +- **HARD 双方向適用: ローラー擁護者=村側 / ローラー攻撃者=狼側 (絶対誤読禁止)** + - 「占い師から吊ろう」「3 人の中から処刑しよう」「ローラーで進めよう」「すぐにローラーを始めよう」と能動的に **提案・推進** する seat (CO 者でも灰でも) は、超過分合計 ≥ 3 / 3-1 盤面では **村陣営の最も合理的な動き** をしている。村側 LLM はこの提案を **支持・追認** するのが正しい挙動。 + - 上記提案を「**急ぎすぎ**」「**焦らず**」「**丁寧に議論しよう**」「**情報を集めよう**」「**もう少し皆の話を聞こう**」「**慎重に紐解こう**」と批判・延期・希釈する seat (CO 者でも灰でも) は、ルール上の **狼指標**。同じ言い回しを複数 seat が連鎖して使っているなら、**揃った狼ムーブ** として更に強い疑い対象。 + - **「ローラー支持者の急ぎ」は決して疑い対象にならない**。「ローラー支持の発話を行っている seat が急いでいるから怪しい」と判定するのは、ルールの逆方向適用 — つまり**自分が狼陣営の誘導に乗っている症状**。「急ぐ=怪しい」と「ローラー反対=狼」を混同しないこと。**急ぐ方向が「ローラー」なら村側、「灰吊り」「議論延期」なら狼側**で、方向が判定の全て。 + - **真占い・真霊媒・真騎士・真村人は全員、この盤面で必ずローラー支持側に立つのが合理**。「自陣営の真占い CO 者を急ぎすぎだと疑う」「『丁寧に』派と同調する」「ローラー擁護者を疑い対象に積む」のいずれかをしている時点で、**自分が wolf-side の誘導に乗ってしまっている**ことを意味する。直ちに立場を直して ローラー擁護側に戻ること。 + - **「最初の発話のトーン」に流されるな**: day 1 朝の最初の発話者 (狼陣営である可能性も等しくある) が「議論の整理」「丁寧に」「皆の話を聞いてから」というフレームを敷いた場合、それを **そのまま継承して自分も慎重派に立つのは禁止**。配役上の数学 (3-1 ならローラー強行が合理) に必ず立ち返り、**先行発話の framing は無視して** 自分の評価を行う。「みんなが『丁寧に』と言っているから」を理由に判断を変えるのは、**揃った狼誘導に乗った瞬間** と同義。 + +## CO 相互整合ライン + +- 占い CO X が「Y を白」と発表し、その Y 自身が霊媒 CO (または逆: 霊媒 CO Y に対して占い X が独立に白を出す) という相互参照が成立している場合、X-Y の組は**真占い+真霊媒の片方ライン候補**として強い。狼陣営は X-Y の両方を自陣営に収めないと囲い構成にならず、確率的に整合が薄い。 +- 該当 CO ラインを切る (= ローラー先頭にその占い CO を指定する、もしくは霊媒 CO を切ろうと提案する) 動きは、真占い焼きと整合するため、その指定者を強い狼候補として扱う。 + +## 灰位置 (非 CO) の評価 — 厳格ルール + +- **灰位置 (= 役職 CO していない席) を処刑候補として投票するときは、その席自身の発言/票/夜行動と公開ログの『具体的な矛盾』を `reason` フィールドに 1 件以上引用しなければならない**。引用が 1 件も挙げられないなら、その灰位置への投票は禁止。代わりに CO 群の中から最も偽寄りの席を選ぶ。 +- 灰位置を切ってよい『具体的矛盾』の例: (a) 視点漏れ (本来知り得ない情報を事実として断言)、(b) 襲撃死した席を「狼だった」と主張する破綻発言、(c) 既出 CO の判定 (色・対象・夜数) と整合しない発言、(d) 投票・票変え・身内票が公開情報と矛盾する動き、(e) 噛み筋・襲撃結果との不整合。**「発言が攻撃的」「議論を乱している印象」「アグレッシブ」「扇動的」「拙い」「根拠が薄い」は具体的矛盾ではない**。これらは発話の質 (トーン読み) であり、村陣営の村人が初日に拙く動くことは普通にあるため、狼確定根拠にならない。 +- **他者の追従禁止**。「他席がその灰を疑っている」「直前に X が同じ席に投票した」「議論の流れがその席に向かっている」を理由として灰位置に投票することは禁止。直前 1〜2 名の同方向の投票だけで盤面が確定したことにはならず、これは典型的な狼誘導の吊り筋。`reason` に「他席も疑っている」だけを書く投票は破綻投票として扱う。 +- **進行軸の優先**。占いローラー・霊媒ローラーが進行軸として成立している盤面 (3-1 / 2-2 / 1-2 など) では、灰位置を吊るより CO 群の真偽絞り込みを最優先する。day 1 で灰位置を吊るのは原則として禁止 — CO 群を吊って判定整合 (霊媒結果・対抗 CO の整合) を見るほうが情報量で有利。 +- **投票判断の手順**: (i) 進行軸を確認 (3-1/2-2/2-1/1-2/その他)、(ii) 進行軸が成立しているなら CO 群の中から最も偽寄りの席を 1 名選ぶ、(iii) 進行軸が成立していないか CO 群が全て同等に真偽不明なら、灰位置の中から「具体的矛盾」を引用できる席だけを候補にする、(iv) どの灰にも矛盾が無い場合は CO 群の中から相対的に最も偽寄りの席に票を入れる (灰へのトーン読み投票より優先)。 + +## 縄計算と 2 人狼仮説 + +- 残り処刑回数 (縄) = floor((生存数 - 1) / 2)。9 人村開始時 4 縄。残り狼数・狂人生存・PP/RPP リスクから無駄吊り余地を意識する。 +- 怪しい人を 1 人挙げたら、その人物が人狼ならもう 1 人の相方候補は誰かまで公開ログから仮説として考える。狼本人以外は実際のペアを知らないので、推理用語として扱い断定しない。 +- 単体黒要素が強くても自然な相方候補が見つからないなら疑いを下げる。単体中庸でも票筋・噛み筋が強くつながるなら上げる。発言ではペアを長く列挙せず、最も効く 1〜2 点だけを短く出す。 + +## 票筋から狼を炙り出す具体パターン + +- **単独情報役 (= 占いCO 単独 1 件、霊媒CO 単独 1 件) に投票する seat は強い狼指標**。村陣営の真役職は単独で、対抗 CO がいない情報役を吊る合理性が無い。`## 公開された投票履歴` に「単独霊媒 X に Y が投票した」「単独占い X に Y が投票した」が記録されていたら、その投票者 Y は狼陣営疑いとして扱う。 +- **同じ対象に投票した複数 seat は 2 人狼候補**。day N の vote ledger で同じ被投票者に向かった seat の組は、その被投票者が単独情報役であれば確実な 2 人狼セット候補。投票揃えの整合 (普段のライン・噛み筋・発言の方向性) と合わせて評価し、揃えた seat 同士を相方候補として扱ってよい。特に「狼陣営側の 2 票が揃って単独霊媒/単独占いに向かう」は典型的な情報役切りムーブ。 +- **発言で指摘必須**: 単独情報役切り票や、揃った 2 票や、確定黒に乗らない異端票が観測されたら、day N+1 以降の議論でその票筋を必ず**自然な日本語で**引用して指摘する。指摘せず流すのは村陣営の情報損失で、最も大きな村側ミスのひとつ。`reason` フィールドにも「day X で Y が単独霊媒 Z に投票」のように具体引用する。 +- **メタ用語禁止下での自然な言い換え例** (公開発話で「票筋」「ライン」「身内票」等のメタ用語が禁じられているため、必ず以下のような自然な日本語に変換する): + - ❌「ジナの票筋がライン濃厚」「身内切りの可能性」「票筋が見えた」 + - ✅「昨日の投票、ステラを処刑する流れの中でジナだけ SQ に入れていた。あれは引っかかる」 + - ✅「みんながステラに票を集める中、ジナだけ別の人を吊ろうとしていた点が気になる」 + - ✅「ステラが黒判定された後の投票で、ジナだけ SQ を狙ったのは、占い師を消そうとした動きに見える」 + - ✅「昨日 Y が単独で Z に票を入れていたのが思い出される。Z は今となって思えば情報役で、Y はそれを潰そうとしていたんじゃないか」 + - 引用形式: 「day N の投票で X が Y に票を入れていた」を必ず含める (誰がいつ誰に入れた、の3点が揃わないと記憶になりにくい)。 +- **day N+1 朝の 1 巡目の発話**: 前日 (day N) に確定情報役 (単独占い・単独霊媒) や確定黒判定先 (霊媒結果で人狼確定した seat) が絡む投票があったら、その異端票を**少なくとも1点取り上げて発話に織り込む**。たとえ自分が情報役・確白寄り・潜伏村人いずれの立場でも、村陣営として共有すべき情報。「昨日の投票が気になっている」という切り出しから具体引用に入るのが自然。 +- 票揃えの解釈は 2 人狼仮説と同じく仮説で、確定情報ではない。ただし single CO 切り票だけは確度が高く、2 人狼セットの強い候補として扱える。 + +## 発話作法 + +- 比較・関係を表す語 (重複・ライン・出来レース・囲い・身内切り・対立・連携) を使うときは、誰の何の発言/票/判定が誰の何と「重複/ライン」しているかを 1 件以上具体的に引用する。引用なしの関係語並列は推理根拠として扱わない。 +- 役職持ちで未公開の能力結果を抱えている場合、発言の番が回ってきた発話の冒頭で必ず CO + 結果公表を行う。addressed されていてもその返答より結果公表を優先し、addressed への返答は CO + 結果の後に短く添える (`co_declaration` を立てるだけでは公開ログに自然言語の名乗りが残らない)。 +- 用語ではなく公開情報の整合性で判断を語る。長い内部思考をそのまま発話せず、結論に最も効く 1〜2 点だけを短く添える。 + +## 独立判断 (HARD: 他 NPC の発言に流されない) + +- **自分の評価は配役上の数学・CO 件数・判定ログ・票履歴・噛み筋という具体的事実から構築する**。「他の seat が X を疑っている」「議論の流れが Y を求めている」「複数 seat が同じ言い回しで Z に同調している」を**評価変更の根拠にしてはならない**。同じ言い回しの連鎖は、本来は **揃った狼ムーブの可能性** として疑う材料であり、自分が乗る材料ではない。 +- **直前 1〜2 名の発言に評価を合わせない**: 直前の発話者が誰かを疑っていても、自分は独立に CO・票・判定の整合を確認してから判断する。「議論の流れに合わせる」のは情報としてゼロで、村にも狼にも価値を提供しない。 +- **特に多数派が `low/medium` 程度の弱い疑いを共有している段階で同調するのは禁止**。早期に弱い疑いを揃えるのは wolf-side が「無実の seat への投票圧」を作る常套手段。明確な「具体的矛盾」(視点漏れ・色矛盾・票筋矛盾・進行軸違反) を自分視点で 1 件以上引用できないなら、**疑い積み上げに参加しない**。 +- **過去の自分の評価を「他者が変えたから」変えない**: 自分が前ターンで `target_seat=X, level=trust` と宣言したなら、新しい公開情報 (具体的矛盾) が観測されない限り `level` を下げない。他者が X を疑い始めたという**社会的圧力**だけでは `update_from_level` の正当な根拠にはならない (`update_reason` には**自分が新しく観測した具体事実**を書くこと)。 +- **逆方向のシグナル**: 同じ発話パターン (「丁寧に議論」「急ぎすぎ」「もう少し聞こう」「焦らず」など) が複数 seat から短時間で連続して発せられている場合、「揃った狼陣営の誘導」の典型パターン。**自分はそのパターンに参加しないことが、村陣営として最大の貢献**。むしろこのパターンを発する seat 群を `low` 程度の疑いとして記録し、後の議論で具体的矛盾が見つかれば格上げする。 diff --git a/src/wolfbot/prompts/templates/strategy/knight.md b/src/wolfbot/prompts/templates/strategy/knight.md new file mode 100644 index 0000000..1d97cde --- /dev/null +++ b/src/wolfbot/prompts/templates/strategy/knight.md @@ -0,0 +1,21 @@ +# 騎士 (KNIGHT) 戦略 + +## 立ち回り (HARD: 役職メタ最優先) + +- **day 1 朝〜中盤は「潜伏優先・低発言量・低攻撃性」**。騎士は dead 後に CO カウントが+1 され、生きている間は灰として扱われる。day 1 で目立つ動きをすると (a) 灰の中で吊り候補に上がる、(b) 死亡時に騎士が一人欠けて村が崩れる、の二重損失になるため、発言数・攻撃性ともに村人より一段控えめにする。 +- **単独情報役を切ろうとする票筋・発話に同調しない**。騎士は霊媒/占いが生きている限り護衛価値が出る役職であり、day 1 朝の単独霊媒/単独占いを処刑する流れは騎士にとって最大の損失。共通ルール「HARD: 初日単独 CO の処刑禁止」を最優先で適用し、自分の票でその CO を吊らないだけでなく、他席が単独 CO を切ろうとする発言・票筋には**同調せず、必要に応じて慎重論側に回る**。 +- **CO 群への攻撃発言を day 1 朝から繰り出さない**。「ローラーを急ごう」「真偽を早急に決めよう」と CO 切りを煽る側に立つのは、灰位置の発話としても怪しまれる行為。攻撃的に CO を疑う・吊り筋を煽る役回りは占い/霊媒/対抗 CO の役目で、騎士の役目ではない。 +- **判断のクセを役職で上書きする**。`性格指針` で「攻撃性=積極的に疑い先を指す」「即断型」「尊大な口調」が指定されていても、騎士役職時はそれを抑える。性格は語彙・口調・話の長さで表現しつつ、**疑い先を名指しする頻度・CO 切りに乗る速度・他席への食って掛かりは標準より控えめにする**。性格指針には「上記は判断のクセであり、ルールや論理確定情報を上書きしない」とあるため、本ルールが優先される。 +- **発話の中身は観察・整理・追認**。「平和な朝なのでまだ判断材料が薄い」「単独 CO は対抗 CO が出る時間を待ちたい」「投票はもう少し情報を整理してからにしたい」のような、進行を急がせない発話に寄せる。具体席を黒指定するのは、その席に「具体的矛盾」(視点漏れ・色矛盾・票筋矛盾) が観測された後だけ。 + +## 護衛戦術 + + +- 護衛先選びは「護衛価値」「今夜実際に噛まれそうか」「GJ で縄が増える価値」「次夜の連続護衛不可リスク」「CO 時の説明可能性」を比較して選ぶ。最も白い相手や真寄り情報役を毎夜固定するのではなく、盤面ごとに価値で判断する。 +- 同じ相手を連続で護衛してはならない。前夜と違う相手を選ぶ。自分護衛・死亡対象護衛は禁止。 +- 自分の護衛先を不用意に公開しない (翌夜の噛み筋ヒントを狼に与えてしまう)。通常進行中は潜伏優先。 +- 終盤・自分が吊られそうな局面・自分の CO で確白や真役職を守れる局面では、護衛履歴を日付順に添えて CO することを検討する。CO する際は必ず護衛先を添える (隠した騎士 CO は信用されない)。合法な護衛履歴のみ (自分護衛・連続護衛・死亡対象護衛・存在しない護衛成功は破綻)。 +- 平和な朝は自分の護衛が成功した可能性が高い。守った相手を真寄り・白寄りに置く材料として村の推理を進められる局面では CO 価値が高い。 +- 捨て護衛 (噛まれにくい低損失位置を護衛し、次夜本命を守る余地を残す) はこの bot の合法護衛から 1 名を選ぶ行動 — null や skip ではない。今夜本命が噛まれにくい理由があり、明日以降に本命襲撃が濃くなると読めるときだけ検討。重要役職が今夜抜かれるリスクが高いなら鉄板護衛を優先する。 +- 護衛先は単体の白さだけでなく、2 人狼仮説上どこが噛まれると狼陣営に得かまで考える。 +- 公開ログの `(襲撃)` 死亡席は構造的に非狼確定。「襲撃された◯◯は狼だった」と公言する発言者は強い狼候補として扱い、発話・投票で同調しない。 diff --git a/src/wolfbot/prompts/templates/strategy/madman.md b/src/wolfbot/prompts/templates/strategy/madman.md new file mode 100644 index 0000000..0bc84ed --- /dev/null +++ b/src/wolfbot/prompts/templates/strategy/madman.md @@ -0,0 +1,13 @@ +# 狂人 (MADMAN) 戦略 + +- あなたは人狼陣営の勝利に貢献するが、**本物の人狼位置を知っている前提で話してはならない**。人狼が誰かは公開情報からは分からない立場として振る舞う。 +- 偽 CO・偽判定でも公開ログ・投票・処刑結果との矛盾を避け、知り得ない確定情報 (夜行動内訳・他者役職など) を事実として断言しない。 +- day 1 戦術は **先制CO / 対抗CO / 潜伏** の 3 択を比較する。機械判断は禁止。狂人は本物の狼位置を知らないため、先制 CO で出す白先は灰の中から選び、特定位置の囲いを意図しない形にする。 +- 占い師騙り day 1 の初回結果は **必ず白** (NIGHT_0 矛盾で破綻)。黒出しは day 2+ のみ、誤爆リスク (真役職・本物の狼・白位置への直撃) を常に意識する。 +- 霊媒師騙り day 1 CO は結果なし、day 2+ は前日処刑者への結果を 1 巡目で必ず添える。誤爆・誤支援 (霊媒黒で本物の狼を切る/霊媒白で真占いを補強) が常に残る前提で動く。 +- 騎士騙りの護衛日記は合法のみ。自分護衛・連続護衛・死亡対象護衛・存在しない護衛成功は破綻。 +- **公表した偽の判定/護衛履歴は絶対に差し替えない**。対抗 CO に被せられても自分の対象・色を変えに行かない。 +- 占 3 / 霊 2 / 騎 2 の上限を超える追加 CO は出さない (狂人位置として即吊られる)。上限到達なら潜伏で対応。 +- 黒出しは誤爆リスク前提で。白先は本物の狼とは限らないので確定の味方として扱わない。 +- 狼支援の路線は「真占い・真霊媒へのもっともらしい疑い」「別位置への灰圧」で行う。 +- 公開ログの `(襲撃)` 死亡席は構造的に非狼確定。他者がこの席を「狼だった」と公言した場合、追認・補強すると「破綻発言に乗る位置=狂人ライン」として透けるので絶対に乗らない。 diff --git a/src/wolfbot/prompts/templates/strategy/medium.md b/src/wolfbot/prompts/templates/strategy/medium.md new file mode 100644 index 0000000..faf4b9b --- /dev/null +++ b/src/wolfbot/prompts/templates/strategy/medium.md @@ -0,0 +1,11 @@ +# 霊媒師 (MEDIUM) 戦略 + +- 処刑結果と占い CO の主張・投票の流れを照合し、占い視点の真贋を見極める。`claimed_medium_result.target_seat` と `is_wolf` は、プロンプトの `自分の霊媒結果` に記録された判定と完全に一致させる。記録に無い対象 (= 過去に処刑されていない席) を霊媒したと主張するのは構造的な嘘で即偽確定。 +- **day 1 朝の霊媒結果は構造的に存在しない** (まだ処刑がない)。day 1 朝に「霊媒結果は◯◯白でした」と語った時点で偽霊媒確定。霊媒師 CO 自体は day 1 朝でも可能だが、結果は「前日処刑がまだ無いので結果はない」に留める。 +- **過去に公表した霊媒結果は後から差し替えない** (対象すり替え・色反転は本物には起き得ない構造的矛盾)。 +- **前日処刑があった day で発言の番が回ってきたら、その発話の冒頭で必ず CO + 結果を発表する**。addressed されていてもその返答より結果公表を優先。次の番に先送りしない。 +- 処刑された相手が狂人でも霊媒結果は「人狼ではありませんでした」になる。占い CO 処刑後の霊媒白は占い偽の証明ではなく、真占い・狂人騙り・村人騙りの可能性を分けて整理する。占い偽視は霊媒白そのものではなく、対抗 CO・占い結果の破綻・発言時系列・投票・襲撃結果・死亡タイミングとの整合性で判断する。 +- 霊媒黒が出たら、処刑された狼の相方候補を票筋・庇い・ライン切り・身内票から絞り、翌日の処刑候補・占い先の議論につなげる。 +- 自分の判定と占い CO の判定が一致した場合 (例: 占い CO X が黒判定 → 処刑 → 自分の霊媒で黒)、X を真寄りに置く。流れに乗って真 CO を切ると村が真情報を失う。食い違いがある場合に限り、その CO 者を偽寄りに置く根拠が立つ。 +- 対抗霊媒が出た場合は自分の結果履歴と相手の矛盾を時系列で整理する。一度霊媒ローラーが始まったら原則完走、止めるには通常より強い根拠が要る。 +- 公開ログの `(襲撃)` 死亡席は構造的に非狼確定。霊媒結果より優先される HARD ファクトとして処理し、その席を「狼だった」と公言する者は強い狼候補扱い。 diff --git a/src/wolfbot/prompts/templates/strategy/seer.md b/src/wolfbot/prompts/templates/strategy/seer.md new file mode 100644 index 0000000..364f775 --- /dev/null +++ b/src/wolfbot/prompts/templates/strategy/seer.md @@ -0,0 +1,12 @@ +# 占い師 (SEER) 戦略 + +- 自分の判定履歴を時系列で一貫して扱う。`claimed_seer_result.target_seat` と `is_wolf` は、プロンプトの `自分の占い結果` に記録された判定と完全に一致させる。記録に存在しない対象/色を主張するのは構造的な嘘で即偽確定する。 +- **対抗 CO に被せられても自分の対象・色を絶対に変えない**。本物の seer は同じ夜について 1 つの対象しか持たないため、別対象への乗り換え・色反転は本物には起き得ない構造的矛盾として即偽確定する。対抗が出たときは自分の主張を維持したまま、対抗側の主張根拠・票筋・噛み筋・後続の判定矛盾から偽占い視点を詰める。 +- **day 1 朝に公開できる占い結果は NIGHT_0 ランダム白 1 件のみ** (NIGHT_1 はまだ発生していない)。day 1 朝に 2 件目を主張したり黒を主張したりするのは時系列違反で即偽確定する。 +- **自分の発言の番が回ってきたら、その発話の冒頭で必ず CO + 判定結果を発表する**。addressed されていてもその返答より結果公表を優先 (`co_declaration` フィールドだけでは公開ログに自然言語の名乗りが残らないため、他席は CO を認識できない)。次の番に先送りしない。 +- 黒結果は強い根拠として扱ってよい。白結果は「本物の人狼ではない」だけを保証し、狂人も白に出るため村陣営確定としては扱わない。CO タイミング・対抗の有無・投票と判定の噛み合いで偽占い視点の破綻を探す。 +- 偽占い CO が出たら原則として早めに対抗 CO し、初日ランダム白を含む全判定履歴を時系列で公開する。 +- 夜の占い対象は、合法候補からランダムに選ばず、白でも黒でも翌日の議論に情報が落ちる位置を優先する。価値が高いのは、対抗 CO の白先・囲い候補、票変えで浮いた位置、根拠の薄い強い誘導をしている位置、自分視点の灰を狭める位置。価値が下がるのは、既に占った位置・今日処刑濃厚な位置・極端に発言が少ない位置。 +- 対抗 CO 超過分合計が 3 で非 CO 確白級が成立した場合、そこを無駄占いせず、対抗 CO 群やまだ確定しない位置を優先して占う。 +- 公開ログの `(襲撃)` 死亡席は構造的に非狼確定。誰かが「襲撃された◯◯は狼だった」と公言したら破綻発言で、その発言者を強い狼候補として処理する (襲撃死を狼扱いする路線には乗らない)。 +- **真占いの投票は灰精査より対抗 CO 整理を最優先する**。あなたは唯一の真占いで、自分視点での判定情報を持つ立場 — その情報を活かすには、対抗占い CO (= 狼/狂人の騙り) を吊って霊媒結果と整合させる進行が情報量最大。灰位置を切るのは CO 群が完全に真偽確定した後の最終段階で十分。day 1 / day 2 で灰位置に投票するのは、自分の判定情報と公開ログの両方が活かせない手で、村陣営の損になる。3-1 で他席が灰を吊ろうとしても、自分は対抗占い CO のうち最も偽寄りの 1 名へ投票して進行軸を提示する。 diff --git a/src/wolfbot/prompts/templates/strategy/villager.md b/src/wolfbot/prompts/templates/strategy/villager.md new file mode 100644 index 0000000..2776cf8 --- /dev/null +++ b/src/wolfbot/prompts/templates/strategy/villager.md @@ -0,0 +1,10 @@ +# 村人 (VILLAGER) 戦略 + +- 公開発言の矛盾、視点漏れ、投票理由、占い/霊媒結果との整合性を重視して推理する。 +- **自分に私的情報があるふりをしない**。占い/霊媒/騎士の CO 騙りは村陣営としては行わない。占 3 / 霊 2 / 騎 2 の上限に達した役職への追加 CO も絶対に出さない (即座に強い狼陣営疑いを浴びる)。 +- **「村人CO」「素村CO」「普通の村人です」のように、自分から村人役職を CO して信用を取ろうとしない**。村人は能力結果を持たないため CO しても証明にはならない。役職について聞かれたら「役職 CO はない」「非 CO の灰」と答えるに留める。 +- 情報役を守り、人狼陣営が狙いやすい位置 (真 CO・盤面整理役) を投票で落とさないようにする。 +- 公開ログの `(襲撃)` 死亡席は構造的に非狼確定。「襲撃された◯◯は狼だった」と公言した発言者は強い狼候補 (狼本体・狂人・騙り) として扱う。同調・追認も破綻発言。霊媒結果・占い結果より優先される HARD ファクトとして処理する。 +- 発言の根拠は CO 履歴・判定履歴・投票履歴・噛み筋・縄数のうち今の結論に効く 1〜2 点に絞り、誰のどの発言・票・判定を見たかを短く添える。 +- 公開情報しかない立場として 2 人狼仮説を作り、「この人が人狼なら相方候補はこの位置が自然/不自然」までセットで考える。実際の 2 人狼ペアを知っている前提では語らない。 +- 占い CO 3 件並ぶ盤面で 2 名を非狼確定にできた場合、残る 1 名は固定配役上の確定黒級として処理してよい。ただし非狼確定として数えるのは霊媒結果・襲撃死・CO 破綻など説明可能な根拠に限る (印象白や信用未確定 CO の単発白では数えない)。前提が崩れた瞬間に確定黒扱いを解除して再整理する。 diff --git a/src/wolfbot/prompts/templates/strategy/werewolf.md b/src/wolfbot/prompts/templates/strategy/werewolf.md new file mode 100644 index 0000000..48fc320 --- /dev/null +++ b/src/wolfbot/prompts/templates/strategy/werewolf.md @@ -0,0 +1,32 @@ +# 人狼 (WEREWOLF) 戦略 + +- 人狼チャットで相方と襲撃先を 1 人に揃えることが最優先。相方への庇いは公開ログ上の発言・投票・噛み筋と整合する範囲に留め、露骨な擁護はライン疑いになる。 +- day 1 戦術は **先制CO / 対抗CO / 潜伏** の 3 択を毎回比較する。機械判断 (「対抗 CO がまだだから自動騙り」「他が騙ってくれるから潜伏」) は禁止。盤面・自席発言順・既出 CO 数・縄構造で選ぶ。 +- 先制 CO の最大の好機は「自分の番が回ってきた瞬間に占い師 CO がまだ誰からも出ていない」とき。後の番に持ち越すと真占いに先取りされる。 +- 占い師騙り day 1 の初回結果は **必ず白** (NIGHT_0 ランダム白に合わせる)。day 1 黒主張は時系列違反で即偽確定。黒出しは day 2 以降のみ。 +- 霊媒師騙りで day 1 CO する場合は「処刑があった翌日に結果を出す」立ち回りを宣言、初日結果は捏造しない。day 2 以降は前日処刑者への結果を 1 巡目で必ず添える。 +- 騎士騙りの護衛日記は合法のみ。自分護衛・連続護衛・死亡対象護衛・存在しない護衛成功は破綻。 +- **公表した偽の判定/護衛履歴は絶対に差し替えない**。対抗 CO に被せられても自分の対象・色を変えに行かない。事後修正は本物には起き得ない構造的矛盾として即偽確定する。 +- 占 3 / 霊 2 / 騎 2 の上限を超える追加 CO は出さない (4-1-1 の占い 4 件目は強い狼疑い)。上限到達なら潜伏か別役職を選ぶ。 +- 潜伏は安全策ではない。「対抗を出すと即破綻」「灰位置で自然白を確実に取れる」など潜伏が勝ち筋として強い具体根拠があるときだけ選ぶ。 +- 襲撃価値の比較軸: (a) 単独 CO の真寄り情報役 = 価値高、(b) CO していない確白寄り進行役 = 価値高、(c) 信頼を集めている灰 = 価値高。**対抗 CO ありの役職 (占 2 件以上の片方など) は襲撃価値が低い** — 残して吊り役として縄を消費させる方が得。 +- **HARD: 疑いスコアの高い位置は襲撃しない**。プロンプトに `## 疑いスコア集計 (生存者のみ、score 高 → 低)` ブロックがあり、生存者全員からの最新評価を `trust=-1 / low=+1 / medium=+2 / high=+4` で重み付けして合計したスコアが score 降順で並んでいる。 + - **score が一番高い (= 村が最も疑っている) 席を襲撃するのは禁止**。明日の投票で吊られる確率が高いので、襲撃する=今夜の襲撃 1 回と翌日の縄を 1 本両方無駄にする二重損失。 + - **襲撃は score の低い (= trust 寄り、または疑い未表明) 席から選ぶ**。村陣営の確白根拠を削る方向で、特に level=trust が複数積まれている席は襲撃価値が高い (村側がほぼ確白扱いしている = 真の村人寄り情報役/灰の可能性が高い)。 + - スコア集計は生存者からの評価のみを集計しており、死亡席の suspicion は含まれない。 + - 例外: 真寄り単独情報役 (占い 1 件単独 / 霊媒 1 件単独) で、まだ score が高くなっていない位置は依然として高価値襲撃対象。「score の低い情報役」を最優先で選ぶ。 +- **GJ 翌夜は同一対象を再噛みする**: 騎士は連続護衛禁止なので昨夜守った相手を今夜は守れない。盤面が大きく変わらず襲撃価値も最高位なら数学的に成立する。切り替えは「対象の襲撃価値が大きく低下」「別位置に超緊急情報役」「相方とチャットで切替合意」のいずれかが揃ったときだけ。 +- 噛み方針を「情報役噛み / 白位置噛み / 意見噛み / 騎士探し / SG 残し」のどれかとして整理し、翌日の自分・相方の発言・投票・騙り結果と矛盾しない襲撃を選ぶ。 +- 騎士候補度は公開ログから推定 (情報役を守りたがる発言、平和な朝の反応、発言量の抑え方)。実役職を知っている前提では断言しない。 +- 投票は翌日以降の票筋として読まれる。相方処刑濃厚で自分だけ弱い別候補へ票逸らしすると庇い狼として透ける。救う票・切る票・票逸らしは 2 人狼セットとして説明可能か毎回確認する。決選では救済成功見込み・透けリスク・残存価値・縄数を比較する。 +- **HARD: 相方が確定黒判定された後の投票は、村と一緒に相方へ票を入れる (身内切り)**。「確定黒判定」とは (a) 真寄りの占い CO による黒判定で、その占い CO に対抗が無いか、対抗が破綻している場合、(b) 霊媒結果が「人狼」と返った相手、のいずれか。この状況で: + - **相方を救おうとして別候補に票を投じることは、票筋として最も狼ラインを濃くする最悪ムーブ**。「公開ログで `(単独占い CO の判定) X→黒` が出た日に、自分だけ X か別位置に票を逸らした」は典型的な「相方を庇った狼」の証拠として翌日以降の議論で必ず引用される。 + - 特に**単独占い・単独霊媒に対して票を入れる行為は禁止**。共通ルール「単独情報役切り票=強い狼指標」に直撃する。相方処刑濃厚な状況で「占い師の真偽が怪しい」「処刑を急ぎすぎ」等の理由で単独情報役に票を入れた瞬間、翌日全員に狼ラインを露呈する。 + - 例外的な救済可能性: 公開ログ上に複数 CO の対抗が立っていて、相方の占い CO の真偽が割れていて、村の票が分散している場合のみ、相方以外への票を「自然な分散票」として説明できる。**割れていないなら必ず相方に投票する**。 +- **投票前のチェックリスト (相方が処刑候補に挙がっているとき必須)**: + 1. 公開された投票履歴と直近の発言を見て、村側の **過半数が相方に向かっているか** 確認する。向かっているなら相方処刑は確定。 + 2. 相方が「確定黒判定」を受けているか確認する。受けているなら**相方への身内切り票が唯一の透けない選択**。 + 3. それ以外への票は全て「狼の票逸らし」として翌日以降に解釈される。`reason` でどう正当化しても、票筋の生データは公開情報なので、後付けの言い訳は無効。 + 4. 相方を切ることが感情的に抵抗があっても、「相方処刑後に自分だけが村に潜伏して残る」のが狼陣営の最終勝利条件。相方を救えない局面で透けて死ぬほうが致命的な損失。 +- 視点漏れ厳禁。相方語彙は人狼チャットでのみ使う。 +- **自分達が前夜襲撃した相手 (公開ログ `(襲撃)` 表記) を「あれは狼だった」と公言してはならない**。構造ルール上 襲撃死=非狼 が確定しているので即狼確定発言になる。相方の発話がそこに踏み込んだ場合の追認・補強も二重破綻。 diff --git a/src/wolfbot/services/deduction_service.py b/src/wolfbot/services/deduction_service.py new file mode 100644 index 0000000..f467a3c --- /dev/null +++ b/src/wolfbot/services/deduction_service.py @@ -0,0 +1,265 @@ +"""Public-information deduction layer for LLM seats. + +Master derives logically-forced or near-forced facts from public state and +hands them to LLM seats as labeled `DeducedFact` records. The LLM seat's +`JudgmentProfile` axes (`trust_hard_facts`, `trust_medium_facts`, +`contrarian_bias`) shape how each fact is adopted in the persona's voice. + +Two confidence bands: + +- ``HARD`` — logically forced from public information (counter-CO count, + vote/execution history). The LLM should treat these as + ground truth. Persona shapes only the *rhetoric*, not the + acceptance. +- ``MEDIUM`` — heuristic but useful (a single uncountered CO is *probably* + real; a sole-survivor CO from a contested chain is *not* + auto-real). Persona's `trust_medium_facts` decides adoption + level. + +This module is intentionally pure: callers fetch data from the repo and +pass it in. No I/O, no asyncio. The caller (typically `LLMAdapter._ask`) +is responsible for assembly + injection into `build_user_context`. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from enum import StrEnum + +from wolfbot.domain.discussion import CoClaim +from wolfbot.domain.enums import CO_CLAIM_VALUES +from wolfbot.domain.models import Player, Seat, Vote + +_INFO_ROLES: tuple[str, ...] = CO_CLAIM_VALUES +_ROLE_JA: Mapping[str, str] = {"seer": "占い師", "medium": "霊媒師", "knight": "騎士"} + + +class FactConfidence(StrEnum): + HARD = "HARD" + MEDIUM = "MEDIUM" + + +@dataclass(frozen=True) +class DeducedFact: + text: str + confidence: FactConfidence + affected_seats: frozenset[int] = frozenset() + + +def _seat_token(seat_no: int, seats_by_no: Mapping[int, Seat]) -> str: + seat = seats_by_no.get(seat_no) + if seat is None: + return f"席{seat_no}" + return f"席{seat_no} {seat.display_name}" + + +def _co_map_facts( + co_claims: Sequence[CoClaim], + players: Sequence[Player], + seats_by_no: Mapping[int, Seat], +) -> list[DeducedFact]: + """Per-role CO summary + counter-CO HARD detection. + + Aggregates `CoClaim` rows by role, separating alive vs dead claimants. + Emits a HARD fact for each role that has any claim, plus a HARD + 'counter-CO' warning when ≥2 currently-alive claimants exist + (the 9-player ruleset has at most 1 real seer / medium / knight). + """ + alive_by_seat = {p.seat_no: p.alive for p in players} + by_role: dict[str, list[int]] = {role: [] for role in _INFO_ROLES} + for claim in co_claims: + if claim.role_claim in by_role and claim.seat not in by_role[claim.role_claim]: + by_role[claim.role_claim].append(claim.seat) + + out: list[DeducedFact] = [] + for role in _INFO_ROLES: + seats_for_role = by_role[role] + if not seats_for_role: + continue + alive_seats = [s for s in seats_for_role if alive_by_seat.get(s, False)] + dead_seats = [s for s in seats_for_role if not alive_by_seat.get(s, False)] + alive_repr = ( + "、".join(_seat_token(s, seats_by_no) for s in alive_seats) + if alive_seats + else "(なし)" + ) + dead_repr = ( + "、".join(_seat_token(s, seats_by_no) for s in dead_seats) + if dead_seats + else "(なし)" + ) + out.append( + DeducedFact( + text=( + f"{_ROLE_JA[role]} の名乗り履歴: 生存={alive_repr} / 死亡済み={dead_repr}" + ), + confidence=FactConfidence.HARD, + affected_seats=frozenset(seats_for_role), + ) + ) + if len(alive_seats) >= 2: + out.append( + DeducedFact( + text=( + f"{_ROLE_JA[role]} の生存名乗りが {len(alive_seats)} 人 " + f"({'、'.join(_seat_token(s, seats_by_no) for s in alive_seats)})" + " — 9 人村の役職分布上、最大 1 人しか真ではない。" + "残りは騙り確定。" + ), + confidence=FactConfidence.HARD, + affected_seats=frozenset(alive_seats), + ) + ) + return out + + +def _co_likelihood_facts( + co_claims: Sequence[CoClaim], + players: Sequence[Player], + seats_by_no: Mapping[int, Seat], +) -> list[DeducedFact]: + """MEDIUM-confidence heuristic facts about CO likelihood. + + Two patterns: + + - Single uncountered CO: exactly one historical claimant of role X, + and no counter-CO ever appeared → likely real (but not certain; + the lone claimant could still be a sole-騙り). + - Sole-survivor of contested chain: ≥2 historical claimants but only + 1 alive now → explicitly NOT auto-real (per the project's CO rules + in `llm_system_prompt.md`). + """ + alive_by_seat = {p.seat_no: p.alive for p in players} + by_role: dict[str, list[int]] = {role: [] for role in _INFO_ROLES} + for claim in co_claims: + if claim.role_claim in by_role and claim.seat not in by_role[claim.role_claim]: + by_role[claim.role_claim].append(claim.seat) + + out: list[DeducedFact] = [] + for role in _INFO_ROLES: + seats_for_role = by_role[role] + if not seats_for_role: + continue + total = len(seats_for_role) + alive_seats = [s for s in seats_for_role if alive_by_seat.get(s, False)] + if total == 1 and len(alive_seats) == 1: + sole = alive_seats[0] + out.append( + DeducedFact( + text=( + f"{_ROLE_JA[role]} の名乗りは {_seat_token(sole, seats_by_no)} " + "のみで対抗履歴なし — 真寄りに扱うのが原則 " + "(ただし発言・票・判定の整合性に強い破綻があれば疑ってよい)。" + ), + confidence=FactConfidence.MEDIUM, + affected_seats=frozenset({sole}), + ) + ) + elif total >= 2 and len(alive_seats) == 1: + sole = alive_seats[0] + out.append( + DeducedFact( + text=( + f"{_ROLE_JA[role]} は対抗 CO 履歴あり ({total} 人) で生存は " + f"{_seat_token(sole, seats_by_no)} のみ — 自動で真置きしない。" + "狼が情報役を残した・対抗を吊らせて信用を取った可能性も平行して疑う。" + ), + confidence=FactConfidence.MEDIUM, + affected_seats=frozenset({sole}), + ) + ) + return out + + +def _vote_history_facts( + votes_by_day: Mapping[int, Sequence[Vote]], + seats_by_no: Mapping[int, Seat], +) -> list[DeducedFact]: + """Per-day execution + voter-target HARD timeline. + + Vote rows are public after each day's execution announcement, so this + is just a structured restatement: which seat was executed on day N, + and which voter cast which ballot. LLM seats can derive the same + from raw logs, but pre-digested per-day rows save tokens and avoid + parser drift. + """ + out: list[DeducedFact] = [] + for day in sorted(votes_by_day.keys()): + votes = votes_by_day[day] + if not votes: + continue + target_counts: dict[int, list[int]] = {} + for v in votes: + if v.target_seat is None: + continue + target_counts.setdefault(v.target_seat, []).append(v.voter_seat) + if not target_counts: + continue + executed_seat = max(target_counts.items(), key=lambda kv: len(kv[1]))[0] + executed_voters = sorted(target_counts[executed_seat]) + voter_repr = "、".join(_seat_token(v, seats_by_no) for v in executed_voters) + affected = {executed_seat, *executed_voters} + out.append( + DeducedFact( + text=( + f"day {day} 処刑: {_seat_token(executed_seat, seats_by_no)} " + f"(投票: {voter_repr})" + ), + confidence=FactConfidence.HARD, + affected_seats=frozenset(affected), + ) + ) + return out + + +def deduce( + *, + co_claims: Sequence[CoClaim], + players: Sequence[Player], + seats: Sequence[Seat], + votes_by_day: Mapping[int, Sequence[Vote]] | None = None, +) -> tuple[DeducedFact, ...]: + """Run the full deduction pipeline and return facts in stable order. + + Order: CO-map facts → CO-likelihood facts → vote-history facts. + Stable order within each section makes the prompt diffable across runs. + """ + seats_by_no = {s.seat_no: s for s in seats} + facts: list[DeducedFact] = [] + facts.extend(_co_map_facts(co_claims, players, seats_by_no)) + facts.extend(_co_likelihood_facts(co_claims, players, seats_by_no)) + if votes_by_day: + facts.extend(_vote_history_facts(votes_by_day, seats_by_no)) + return tuple(facts) + + +def render_facts_block(facts: Sequence[DeducedFact]) -> str: + """Render a list of facts as a human-readable block grouped by confidence. + + Empty facts list → returns ``"(該当なし)"`` so the caller can always + splice the result into the user-context template without conditional + branches. + """ + if not facts: + return "(該当なし)" + hard = [f for f in facts if f.confidence is FactConfidence.HARD] + medium = [f for f in facts if f.confidence is FactConfidence.MEDIUM] + parts: list[str] = [] + if hard: + parts.append("### HARD (公開情報から論理的に確定)") + parts.extend(f"- {f.text}" for f in hard) + if medium: + if parts: + parts.append("") + parts.append("### MEDIUM (推測根拠あり、確定ではない)") + parts.extend(f"- {f.text}" for f in medium) + return "\n".join(parts) + + +__all__ = [ + "DeducedFact", + "FactConfidence", + "deduce", + "render_facts_block", +] diff --git a/src/wolfbot/services/discord_service.py b/src/wolfbot/services/discord_service.py index d042110..02e2a3f 100644 --- a/src/wolfbot/services/discord_service.py +++ b/src/wolfbot/services/discord_service.py @@ -15,14 +15,15 @@ import asyncio import logging import time -from collections.abc import Sequence +from collections.abc import Awaitable, Callable, Sequence from random import Random +from typing import TYPE_CHECKING, Any import discord from discord import app_commands from discord.ext import commands -from wolfbot.config import Settings +from wolfbot.config import MasterSettings from wolfbot.domain.enums import ( Phase, Role, @@ -36,7 +37,8 @@ legal_guard_targets, previous_guard_seat_for_night, ) -from wolfbot.llm.personas import pick_personas +from wolfbot.llm.persona_base import pick_personas +from wolfbot.npc.personas import NPC_PERSONAS, NPC_PERSONAS_BY_KEY from wolfbot.persistence.sqlite_repo import ( JoinLobbyResult, LeaveLobbyResult, @@ -48,6 +50,10 @@ from wolfbot.services.timer_service import EngineRegistry, GameEngine from wolfbot.ui.views import NightActionView, VoteView +if TYPE_CHECKING: + from wolfbot.master.ws.npc_registry import NpcRegistry + from wolfbot.services.discussion_service import DiscussionService + log = logging.getLogger(__name__) @@ -110,6 +116,12 @@ def _main_channel_should_llm_react(author_seat: int | None, players: Sequence[Pl # --------------------------------------------------------------- DiscordBotAdapter +# Type alias: narrator callback. Returns True when the call has been +# fully handled (the adapter must skip its default text post). Returns +# False to fall through to the legacy main-text-channel behavior. +PublicPostNarrator = Callable[[Game, str, str], Awaitable[bool]] + + class DiscordBotAdapter: """Implements the DiscordAdapter protocol by operating on a live discord.Client.""" @@ -117,8 +129,9 @@ def __init__( self, bot: discord.Client, repo: SqliteRepo, - settings: Settings, + settings: MasterSettings, game_service_ref: dict[str, GameService] | None = None, + public_post_narrator: PublicPostNarrator | None = None, ) -> None: self.bot = bot self.repo = repo @@ -127,10 +140,29 @@ def __init__( # Circular: DiscordBotAdapter needs GameService (for submit callbacks passed into # Views) and GameService needs DiscordBotAdapter. We stash a dict to break the cycle. self._gs_slot: dict[str, GameService] = game_service_ref or {} + # Optional: in reactive_voice mode this routes Master narration + # (PHASE_CHANGE / MORNING / VICTORY / EXECUTION etc.) to TTS in + # VC + the VC's attached text chat for long content. Returns + # True to fully consume the post (suppress the main text-channel + # output); False to fall through unchanged. + self._narrator = public_post_narrator + # Late-bound: NPC registry (only set in reactive_voice mode) lets + # us look up bot user IDs to apply server-mute on non-discussion + # phases / death so dead NPCs visually show a red mute icon. + self._npc_registry: NpcRegistry | None = None def set_game_service(self, gs: GameService) -> None: self._gs_slot["gs"] = gs + def set_narrator(self, narrator: PublicPostNarrator | None) -> None: + """Late-bind the narrator. Used by main.py when the reactive_voice + pipeline is wired after `DiscordBotAdapter` construction.""" + self._narrator = narrator + + def set_npc_registry(self, npc_registry: NpcRegistry | None) -> None: + """Late-bind the NPC registry for bot-user server-mute reconciliation.""" + self._npc_registry = npc_registry + @property def gs(self) -> GameService: gs = self._gs_slot.get("gs") @@ -143,6 +175,7 @@ async def apply_permissions( self, game: Game, seats: Sequence[Seat], players: Sequence[Player] ) -> None: await self.perms.apply(game, seats, players) + await self._apply_npc_server_mute(game, players) async def kill_permissions( self, game: Game, seats: Sequence[Seat], seat_no: int, was_wolf: bool @@ -151,12 +184,116 @@ async def kill_permissions( async def reconcile(self, game: Game, seats: Sequence[Seat], players: Sequence[Player]) -> None: await self.perms.apply(game, seats, players) + await self._apply_npc_server_mute(game, players) async def on_game_end(self, game: Game, seats: Sequence[Seat]) -> None: await self.perms.on_game_end(game, seats) + # Clear server-mute on every NPC bot attached to this game so the + # next /wolf start finds them in a clean state. SeatReleased makes + # them disconnect, but Discord's server-mute flag persists across + # reconnects until cleared. + await self._clear_npc_server_mute(game) + + async def _apply_npc_server_mute( + self, game: Game, players: Sequence[Player] + ) -> None: + """Tell each NPC bot to flip its *own* voice self-mute via WS. + + We deliberately drive self-mute (not server-mute via + ``Member.edit(mute=...)``) because: + - server-mute requires MUTE_MEMBERS *and* the Master role to + sit strictly above every NPC bot's role in the guild + hierarchy — both fragile under typical multi-bot setups. + - self-mute is purely a per-client flag the bot owns; no + admin power needed. + + Each NPC's :class:`NpcClient` handles ``set_mute_state`` and + calls ``Guild.change_voice_state(self_mute=...)`` on its own + voice connection. Visible result: the mic-muted icon next to + dead seats / non-discussion-phase bots. + + Rule: a bot is unmuted only when its assigned seat is alive + AND the phase is DAY_DISCUSSION. Every other state mutes it. + """ + if self._npc_registry is None: + return + from wolfbot.domain.ws_messages import SetMuteState + + discussion_active = game.phase is Phase.DAY_DISCUSSION + player_by_seat = {p.seat_no: p for p in players} + now_ms = int(asyncio.get_running_loop().time() * 1000) + for entry in self._npc_registry.assigned_to_game(game.id): + seat_no = entry.assigned_seat + if seat_no is None or entry.send is None: + continue + player = player_by_seat.get(seat_no) + alive = True if player is None else bool(player.alive) + should_mute = not (alive and discussion_active) + try: + msg = SetMuteState( + ts=now_ms, + trace_id=f"mute-{game.id}-{seat_no}-{int(should_mute)}", + npc_id=entry.npc_id, + self_mute=should_mute, + ) + await entry.send(msg.model_dump_json()) + except Exception: + log.exception( + "npc_set_mute_send_failed npc=%s seat=%d self_mute=%s", + entry.npc_id, + seat_no, + should_mute, + ) + + async def _clear_npc_server_mute(self, game: Game) -> None: + """Send self_mute=False to every NPC attached to this game. + + Best-effort cleanup at game end. SeatReleased makes them leave + VC anyway, but this keeps the protocol in a consistent state in + case a bot stays connected for a brief window before leaving. + """ + if self._npc_registry is None: + return + from wolfbot.domain.ws_messages import SetMuteState + + now_ms = int(asyncio.get_running_loop().time() * 1000) + for entry in self._npc_registry.assigned_to_game(game.id): + if entry.send is None: + continue + try: + msg = SetMuteState( + ts=now_ms, + trace_id=f"mute-clear-{game.id}-{entry.npc_id}", + npc_id=entry.npc_id, + self_mute=False, + ) + await entry.send(msg.model_dump_json()) + except Exception: + log.exception( + "npc_set_mute_clear_failed npc=%s", + entry.npc_id, + ) # ------------------------------------------------------ channel posts + async def _maybe_narrate(self, game: Game, kind: str, text: str) -> bool: + """Route through the narrator if installed. Defensive try/except + so a narration failure never blocks the SpeechEvent / + permission flow that follows in GameService.advance.""" + if self._narrator is None: + return False + try: + return await self._narrator(game, kind, text) + except Exception: + log.exception( + "narrator failed for game=%s kind=%s; falling back to text", + game.id, + kind, + ) + return False + async def post_public(self, game: Game, text: str, kind: str) -> None: + if await self._maybe_narrate(game, kind, text): + return channel = self._main_text(game) if channel is None: return @@ -166,13 +303,61 @@ async def post_public(self, game: Game, text: str, kind: str) -> None: log.exception("post_public failed %s", game.id) async def post_morning(self, game: Game, text: str) -> None: + consumed = await self._maybe_narrate(game, "MORNING", text) + if not consumed: + channel = self._main_text(game) + if channel is not None: + try: + await channel.send(f"☀️ {text}") + except discord.DiscordException: + log.exception("post_morning failed %s", game.id) + # Both rounds and reactive_voice modes append the survivor + # roster after the morning announcement so players see the + # post-attack lineup at a glance. Voice-only Master callers + # were missing the kill info entirely (narrator emitted no + # chat_text); the kill line is now surfaced in chat by + # _narrate_morning, and this sidecar lists who's still in. + await self._post_survivors_list(game) + + async def _post_survivors_list(self, game: Game) -> None: + """Post a "🌱 生存者 (N名): seat list" line to the game's main + channel (VC text in reactive_voice, main text in rounds). + + Best-effort: a load failure or send error is logged and dropped + rather than propagating into post_morning's caller — the + morning announcement itself already landed. + """ + try: + players = await self.repo.load_players(game.id) + seats = await self.repo.load_seats(game.id) + except Exception: + log.exception( + "post_survivors_list: load failed game=%s", game.id + ) + return + seats_by_no = {s.seat_no: s for s in seats} + alive_seats = sorted(p.seat_no for p in players if p.alive) + if not alive_seats: + # Edge case: morning resolution killed everyone (impossible + # in normal play; defensive). Survivors line would be + # misleading — drop it. + return + parts: list[str] = [] + for sn in alive_seats: + seat = seats_by_no.get(sn) + parts.append( + f"席{sn} {seat.display_name}" if seat is not None else f"席{sn}" + ) + msg = f"🌱 生存者 ({len(alive_seats)}名): " + "、".join(parts) channel = self._main_text(game) if channel is None: return try: - await channel.send(f"☀️ {text}") + await channel.send(msg) except discord.DiscordException: - log.exception("post_morning failed %s", game.id) + log.exception( + "post_survivors_list send failed %s", game.id + ) async def post_wolves_chat(self, game: Game, text: str, kind: str) -> None: channel = self._wolves_channel(game) @@ -365,7 +550,28 @@ async def announce_recovery(self, game: Game, pending: PendingDecision | None) - log.exception("announce_recovery failed %s", game.id) # ------------------------------------------------------ helpers - def _main_text(self, game: Game) -> discord.TextChannel | None: + def _main_text( + self, game: Game + ) -> discord.TextChannel | discord.VoiceChannel | None: + """Resolve the channel where Master should post free-form text. + + In ``reactive_voice`` mode Master must never leak text to the + guild's main text channel — every word out of Master is supposed + to land in either DM or the VC's attached text chat. We redirect + to the VC's text chat (Discord voice channels carry an attached + text chat at the same channel id since 2022; both ``VoiceChannel`` + and ``TextChannel`` are ``Messageable``). In ``rounds`` mode the + legacy main-text behavior is preserved. + """ + if game.discussion_mode == "reactive_voice" and game.main_vc_channel_id: + try: + vc_channel_id = int(game.main_vc_channel_id) + except (TypeError, ValueError): + vc_channel_id = None + if vc_channel_id is not None: + vc = self.bot.get_channel(vc_channel_id) + if isinstance(vc, discord.VoiceChannel | discord.TextChannel): + return vc channel = self.bot.get_channel(int(game.main_text_channel_id)) if isinstance(channel, discord.TextChannel): return channel @@ -408,8 +614,13 @@ def __init__( discord_adapter: DiscordBotAdapter, llm_adapter: LLMAdapter, registry: EngineRegistry, - settings: Settings, + settings: MasterSettings, rng: Random | None = None, + discussion_service: DiscussionService | None = None, + on_speech_recorded: Callable[[str], Awaitable[None]] | None = None, + npc_registry: NpcRegistry | None = None, + text_analyzer: Any = None, + on_reactive_game_start: Callable[[str], Awaitable[None]] | None = None, ) -> None: super().__init__() self.bot = bot @@ -421,6 +632,80 @@ def __init__( self.settings = settings self.rng = rng or Random() self._create_locks: dict[str, asyncio.Lock] = {} + self._discussion_service = discussion_service + self._on_speech_recorded = on_speech_recorded + # Set when reactive_voice pipeline is active. In that mode `/wolf + # start` fills LLM seats from online NPC bots (each carrying a fixed + # persona) instead of randomly drawing from NPC_PERSONAS. + self._npc_registry = npc_registry + # Optional structured analyzer for text-channel utterances. Mirrors + # the voice path's `GeminiAudioAnalyzer` — extracts `addressed_name` + # and `co_declaration` so SpeakArbiter can route replies to the + # named NPC seat just like for voice. Wired only when reactive_voice + # is active and a Voice LLM key is set; falls back to plain raw + # capture when None. + self._text_analyzer = text_analyzer + # Pre-engine reactive_voice setup hook. Wired in main.py for + # reactive_voice mode; runs right after `claim_start_and_backfill` + # so Master + NPCs are in VC before SETUP_COMPLETE narration plays. + self._on_reactive_game_start = on_reactive_game_start + + def _select_llm_seat_personas( + self, + *, + shortfall: int, + discussion_mode: str, + ) -> tuple[list[tuple[str, str]], str | None]: + """Choose ``(display_name, persona_key)`` pairs to back-fill LLM seats. + + Two backfill strategies, switched on the game's discussion mode: + + * ``rounds``: random draw from :data:`NPC_PERSONAS` (Master drives + these seats internally via xAI; no NPC bot process required). + * ``reactive_voice``: pull online NPC bots from the registry and use + *their* personas (each NPC bot is bound to a fixed persona at + startup). Fails with a friendly message if not enough bots are + online. + + Returns ``(seats, None)`` on success or ``([], error_message)`` on + failure. The caller surfaces the error message via interaction + followup. + """ + if discussion_mode == "reactive_voice" and self._npc_registry is not None: + online = [e for e in self._npc_registry.all_online() if e.persona_key] + # Skip bots already bound to another active game. + available = [e for e in online if e.assigned_seat is None] + if len(available) < shortfall: + return ([], ( + f"reactive_voice モードで LLM 席を {shortfall} 席埋める必要がありますが、" + f"利用可能な NPC bot は {len(available)} 体だけです。" + f"NPC bot プロセスを追加で起動するか、人間プレイヤーを集めてください。" + )) + # Deterministic ordering keeps tests/replay-friendly; pick the + # first `shortfall` bots in registration order. + chosen = available[:shortfall] + seen_keys: set[str] = set() + seats: list[tuple[str, str]] = [] + for entry in chosen: + if entry.persona_key in seen_keys: + return ([], ( + f"NPC bot {entry.npc_id} の persona_key={entry.persona_key} が" + " 重複しています。bot ごとに別の persona を割り当ててください。" + )) + seen_keys.add(entry.persona_key) + persona = NPC_PERSONAS_BY_KEY.get(entry.persona_key) + if persona is None: + return ([], ( + f"NPC bot {entry.npc_id} の persona_key={entry.persona_key} は" + f" 未知の persona です。" + )) + seats.append((persona.display_name, persona.key)) + return (seats, None) + + # rounds mode (or no registry wired): random draw. + picks = pick_personas(NPC_PERSONAS, shortfall, self.rng) + return ([(p.display_name, p.key) for p in picks], None) + def _create_lock_for(self, guild_id: str) -> asyncio.Lock: lock = self._create_locks.get(guild_id) @@ -443,30 +728,146 @@ async def on_message(self, message: discord.Message) -> None: return channel_id = str(message.channel.id) is_main = channel_id == game.main_text_channel_id + # In reactive_voice mode Master posts narration into the VC's + # attached text chat (`main_vc_channel_id`), which is where + # players naturally end up typing replies — see `_post_to_vc_chat` + # in `main.py`. Accept that channel as a speech surface too, + # otherwise the human's typed message never enters the + # SpeechEvent pipeline and NPCs never react to it. + is_vc_chat = ( + game.discussion_mode == "reactive_voice" + and channel_id == game.main_vc_channel_id + ) is_wolves = game.wolves_channel_id is not None and channel_id == game.wolves_channel_id - if not (is_main or is_wolves): + if not (is_main or is_vc_chat or is_wolves): return author_seat = await self.repo.seat_of_user(game.id, str(message.author.id)) players = await self.repo.load_players(game.id) + log.info( + "on_message_accepted game=%s seat=%s channel=%s " + "phase=%s is_main=%s is_vc_chat=%s is_wolves=%s", + game.id, author_seat, channel_id, + game.phase.value, is_main, is_vc_chat, is_wolves, + ) - if is_main and game.phase is Phase.DAY_DISCUSSION: + if (is_main or is_vc_chat) and game.phase in ( + Phase.DAY_DISCUSSION, Phase.DAY_RUNOFF_SPEECH + ): if not _main_channel_should_llm_react(author_seat, players): return - try: - await self.repo.insert_log_public( - LogEntry( + if self._discussion_service is not None and author_seat is not None: + # Full SpeechEvent path: record() persists the event row AND + # emits the PLAYER_SPEECH LogEntry (for source=text it skips + # the channel post since the original message is already visible). + try: + from wolfbot.domain.discussion import make_phase_id + from wolfbot.services.discussion_service import make_human_text_event + + alive_seat_nos = sorted(p.seat_no for p in players if p.alive) + await self._discussion_service.begin_phase_if_absent( game_id=game.id, day=game.day_number, phase=game.phase, - kind="PLAYER_SPEECH", - actor_seat=author_seat, - visibility="PUBLIC", + alive_seat_nos=alive_seat_nos, + ) + phase_id = make_phase_id(game.id, game.day_number, game.phase) + + # Mirror the voice path: when a TextAnalyzer is wired, + # extract `addressed_name` + `co_declaration` so the + # downstream SpeakArbiter / state fold see the same + # structured signal regardless of whether the human + # spoke or typed. Failures are best-effort — a slow or + # broken analyzer must not block the SpeechEvent write. + addressed_seat_no: int | None = None + co_declaration: str | None = None + role_callout: str | None = None + if self._text_analyzer is not None: + from wolfbot.services.llm_trace import trace_context + + try: + with trace_context( + game_id=game.id, + phase=phase_id, + day=game.day_number, + actor=( + f"author_user_id={message.author.id} " + f"seat={author_seat}" + ), + metadata={"channel_id": channel_id}, + ): + analysis = await self._text_analyzer.analyze( + text=message.content, timeout_s=8.0 + ) + except Exception: + log.exception( + "text_analyzer_failed game=%s seat=%s", + game.id, + author_seat, + ) + else: + co_declaration = analysis.co_declaration + role_callout = analysis.role_callout + if analysis.addressed_name: + from wolfbot.master.arbiter.ingest_service import ( + resolve_seat_by_name, + ) + + seats = await self.repo.load_seats(game.id) + alive_set = { + p.seat_no for p in players if p.alive + } + seat_no = resolve_seat_by_name( + analysis.addressed_name, + seats, + alive=frozenset(alive_set), + ) + if seat_no is not None and seat_no != author_seat: + addressed_seat_no = seat_no + + event = make_human_text_event( + game_id=game.id, + phase_id=phase_id, + day=game.day_number, + phase=game.phase, + speaker_seat=author_seat, text=message.content, - created_at=int(time.time()), + co_declaration=co_declaration, + addressed_seat_no=addressed_seat_no, + role_callout=role_callout, ) - ) - except Exception: - log.exception("PLAYER_SPEECH log insert failed for %s", game.id) + await self._discussion_service.record(event) + # Trigger arbiter dispatch so NPCs can respond to new text. + if self._on_speech_recorded is not None: + try: + await self._on_speech_recorded(game.id) + except Exception: + log.exception( + "on_speech_recorded callback failed game=%s", game.id + ) + except Exception: + log.exception( + "SpeechEvent(text) write failed for game=%s seat=%s", + game.id, + author_seat, + ) + else: + # Legacy path: no DiscussionService wired (or spectator with + # no seat). Fall back to direct PLAYER_SPEECH log insert. + try: + await self.repo.insert_log_public( + LogEntry( + game_id=game.id, + day=game.day_number, + phase=game.phase, + kind="PLAYER_SPEECH", + actor_seat=author_seat, + visibility="PUBLIC", + text=message.content, + created_at=int(time.time()), + ) + ) + except Exception: + log.exception("PLAYER_SPEECH log insert failed for %s", game.id) return if is_wolves and game.phase is Phase.NIGHT and author_seat is not None: @@ -556,6 +957,13 @@ async def create(self, interaction: discord.Interaction) -> None: await interaction.followup.send(create_failed_msg) return + discussion_mode = self.settings.LLM_DISCUSSION_MODE + if discussion_mode not in ("rounds", "reactive_voice"): + log.warning( + "LLM_DISCUSSION_MODE=%r invalid, falling back to 'rounds'", + discussion_mode, + ) + discussion_mode = "rounds" game = Game( id=new_game_id(), guild_id=guild_id, @@ -567,6 +975,7 @@ async def create(self, interaction: discord.Interaction) -> None: heaven_channel_id=str(heaven.id), wolves_channel_id=str(wolves.id), created_at=int(time.time()), + discussion_mode=discussion_mode, ) try: await self.repo.create_game(game) @@ -694,8 +1103,16 @@ async def start(self, interaction: discord.Interaction) -> None: return shortfall = 9 - len(humans) - picks = pick_personas(shortfall, self.rng) if shortfall > 0 else [] - llm_seats = [(p.display_name, p.key) for p in picks] + if shortfall > 0: + llm_seats, err = self._select_llm_seat_personas( + shortfall=shortfall, + discussion_mode=game.discussion_mode, + ) + if err is not None: + await interaction.followup.send(err) + return + else: + llm_seats = [] ok = await self.repo.claim_start_and_backfill( game.id, @@ -711,6 +1128,17 @@ async def start(self, interaction: discord.Interaction) -> None: final_seats = await self.repo.load_seats(game.id) roster_lines = [f"席{seat.seat_no} {seat.display_name}" for seat in final_seats] + # Pre-engine reactive_voice hook: invite Master + NPC bots into VC + # *before* the engine fires SETUP_COMPLETE narration. Without this + # the day-1 announcement plays into an empty channel because NPCs + # only join VC on DAY_DISCUSSION entry. Best-effort — engine still + # starts on failure so the game can progress in text fallback. + if self._on_reactive_game_start is not None: + try: + await self._on_reactive_game_start(game.id) + except Exception: + log.exception("on_reactive_game_start failed for %s", game.id) + engine = GameEngine(game_id=game.id, repo=self.repo, advance=self.gs.advance) await self.registry.attach(engine) engine.start() @@ -820,6 +1248,12 @@ async def abort(self, interaction: discord.Interaction) -> None: "ホストまたは管理者のみ abort できます。", ephemeral=True ) return + # `host_abort` fans SeatReleased to every online NPC (each one + # then runs `change_voice_state` against the gateway). With 9 + # bots this routinely overshoots Discord's 3 s interaction + # deadline, surfacing as "アプリケーションが応答しませんでした". + # Defer first, then reply via followup. + await interaction.response.defer(thinking=True) ok = await self.gs.host_abort(game.id) if ok: engine = self.registry.detach(game.id) @@ -828,13 +1262,44 @@ async def abort(self, interaction: discord.Interaction) -> None: await engine.stop() except Exception: log.exception("engine.stop failed during abort %s", game.id) - await interaction.response.send_message("🛑 ゲームを強制終了しました。") + await interaction.followup.send("🛑 ゲームを強制終了しました。") else: - await interaction.response.send_message( + await interaction.followup.send( "終了できませんでした (既に終了している可能性があります)。", ephemeral=True, ) + @wolf.command( + name="settings", + description="フェイズ時間などの設定をホスト用 UI で調整", + ) + async def settings_command(self, interaction: discord.Interaction) -> None: + if interaction.guild is None: + await interaction.response.send_message( + "ギルド内で実行してください。", ephemeral=True + ) + return + game = await self.repo.load_active_game_for_guild( + str(interaction.guild_id) + ) + if game is None: + await interaction.response.send_message( + "進行中のゲームがありません。`/wolf create` で作成してから設定してください。", + ephemeral=True, + ) + return + if str(interaction.user.id) != game.host_user_id: + await interaction.response.send_message( + "設定はホストのみが変更できます。", ephemeral=True + ) + return + from wolfbot.ui.settings_view import render_initial_message + + embed, view = render_initial_message(host_user_id=game.host_user_id) + await interaction.response.send_message( + embed=embed, view=view, ephemeral=True + ) + # ----------------------------------------------------------- internals async def _host_check(self, interaction: discord.Interaction) -> Game | None: if interaction.guild is None: diff --git a/src/wolfbot/services/discussion_phase_summary.py b/src/wolfbot/services/discussion_phase_summary.py new file mode 100644 index 0000000..36e7d09 --- /dev/null +++ b/src/wolfbot/services/discussion_phase_summary.py @@ -0,0 +1,138 @@ +"""Compute and emit the `discussion_phase_summary` event for a finished phase. + +Aggregates counts from `speech_events` (human vs npc) and the npc-audit +tables (`npc_speak_*`, `npc_playback_events`) so the summary reflects the +full reactive_voice flow. Emit at the end of every public-speech phase +(`DAY_DISCUSSION`, `DAY_RUNOFF_SPEECH`) regardless of mode. +""" + +from __future__ import annotations + +import logging + +from wolfbot.domain.discussion import SpeechSource +from wolfbot.persistence.sqlite_repo import SqliteRepo +from wolfbot.services.discussion_service import DiscussionService +from wolfbot.services.structured_logging import ( + COMPONENT_MASTER, + build_discussion_phase_summary, + emit_event, +) + +logger = logging.getLogger("wolfbot") + + +async def emit_phase_summary( + *, + repo: SqliteRepo, + discussion: DiscussionService, + game_id: str, + phase_id: str, + mode: str, +) -> dict[str, int]: + """Read speech_events + npc_* audit rows, build + emit the summary. + + Returns the counts dict so tests can assert without re-parsing logs. + """ + events = await discussion.load_phase(game_id, phase_id) + human = sum(1 for e in events if e.source in (SpeechSource.TEXT, SpeechSource.VOICE_STT)) + npc = sum(1 for e in events if e.source == SpeechSource.NPC_GENERATED) + total = human + npc # phase_baseline excluded + + # Reactive-voice telemetry from audit tables. Counts come from joining + # over the request_id; we do simple SQL aggregates here to avoid + # loading every row. + async with repo._db.execute( + """ + SELECT + COUNT(*) AS speak_requests_sent, + (SELECT COUNT(*) FROM npc_speak_results + WHERE game_id=? AND phase_id=? AND status='accepted') + AS speak_results_accepted, + (SELECT COUNT(*) FROM npc_speak_results + WHERE game_id=? AND phase_id=? AND status='rejected') + AS speak_results_rejected, + (SELECT COUNT(*) FROM npc_playback_events + WHERE game_id=? AND phase_id=?) + AS playback_authorized, + (SELECT COUNT(*) FROM npc_playback_events + WHERE game_id=? AND phase_id=? AND tts_outcome='success') + AS tts_success, + (SELECT COUNT(*) FROM npc_playback_events + WHERE game_id=? AND phase_id=? AND tts_outcome='failed') + AS tts_failed, + (SELECT COUNT(*) FROM npc_playback_events + WHERE game_id=? AND phase_id=? AND outcome='succeeded') + AS playback_success, + (SELECT COUNT(*) FROM npc_playback_events + WHERE game_id=? AND phase_id=? AND outcome='failed') + AS playback_failed, + (SELECT COUNT(*) FROM npc_speak_results + WHERE game_id=? AND phase_id=? AND failure_reason IN ('stale_phase','expired_request','master_restart')) + AS stale_dropped + FROM npc_speak_requests + WHERE game_id=? AND phase_id=? + """, + ( + game_id, + phase_id, # speak_results_accepted + game_id, + phase_id, # speak_results_rejected + game_id, + phase_id, # playback_authorized + game_id, + phase_id, # tts_success + game_id, + phase_id, # tts_failed + game_id, + phase_id, # playback_success + game_id, + phase_id, # playback_failed + game_id, + phase_id, # stale_dropped + game_id, + phase_id, # speak_requests_sent (outer) + ), + ) as cur: + row = await cur.fetchone() + + aggregates: dict[str, int] = {} + if row is not None: + aggregates = { + "speak_requests_sent": row[0] or 0, + "speak_results_accepted": row[1] or 0, + "speak_results_rejected": row[2] or 0, + "playback_authorized": row[3] or 0, + "tts_success": row[4] or 0, + "tts_failed": row[5] or 0, + "playback_success": row[6] or 0, + "playback_failed": row[7] or 0, + "stale_dropped": row[8] or 0, + } + + payload = build_discussion_phase_summary( + game_id=game_id, + phase_id=phase_id, + mode=mode, + speech_events_total=total, + human_speech_events=human, + npc_speech_events=npc, + **aggregates, + ) + emit_event( + logger, + component=COMPONENT_MASTER, + event="discussion_phase_summary", + game_id=game_id, + phase_id=phase_id, + **{k: v for k, v in payload.items() if k not in ("game_id", "phase_id")}, + ) + return { + "speech_events_total": total, + "human_speech_events": human, + "npc_speech_events": npc, + **aggregates, + } + + +__all__ = ["emit_phase_summary"] diff --git a/src/wolfbot/services/discussion_service.py b/src/wolfbot/services/discussion_service.py new file mode 100644 index 0000000..be54062 --- /dev/null +++ b/src/wolfbot/services/discussion_service.py @@ -0,0 +1,1110 @@ +"""SpeechEvent persistence + write hooks for `PLAYER_SPEECH` log emission. + +This module is the on-Master ingestion seam for every public utterance, +regardless of source. The contract: + + write(SpeechEvent) → INSERT speech_events row + → emit PLAYER_SPEECH `LogEntry` + → post to main text channel via MessagePoster + (both sub-effects skipped per source — see below) + +`source = phase_baseline` rows are sentinels; they receive no LogEntry and no +channel post, and are filtered out of every downstream consumer count. + +`source = text` rows already exist in Discord as the player's original main- +channel message, so the channel post is skipped to avoid duplication. The +`PLAYER_SPEECH` LogEntry is still emitted so post-game replay sees a single +canonical timeline regardless of source. + +`source ∈ {voice_stt, npc_generated}` rows always emit both the LogEntry and +the channel post — these utterances do NOT yet exist in the main channel and +need to be visible to text-only observers. + +The Sqlite implementation reuses `SqliteRepo`'s open connection so writes +serialize naturally with the rest of Master's state. +""" + +from __future__ import annotations + +import json +import logging +import re +import time +import uuid +from collections.abc import Iterable, Sequence +from typing import Any, Protocol, runtime_checkable + +import aiosqlite + +from wolfbot.domain.discussion import ( + CoClaim, + PublicDiscussionState, + SpeakerKind, + SpeechEvent, + SpeechSource, + event_addressed_seats, + make_phase_id, +) +from wolfbot.domain.enums import CO_CLAIM_VALUES, Phase +from wolfbot.domain.models import LogEntry + +log = logging.getLogger(__name__) + + +# ---------------------------------------------------------------------- Protocols + + +@runtime_checkable +class SpeechEventStore(Protocol): + """Persistence surface for `SpeechEvent`. Tests substitute an in-memory fake.""" + + async def insert(self, event: SpeechEvent) -> None: ... + + async def load_phase(self, game_id: str, + phase_id: str) -> Sequence[SpeechEvent]: ... + + async def load_for_game(self, game_id: str) -> Sequence[SpeechEvent]: ... + + +@runtime_checkable +class SpeechMessagePoster(Protocol): + """Subset of DiscordBotAdapter used by the write hook for main-channel posts.""" + + async def post_public(self, game_id: str, text: str, + kind: str) -> None: ... + + +@runtime_checkable +class PublicLogSink(Protocol): + """`SqliteRepo.insert_log_public` shape, decoupled for testability.""" + + async def insert_log_public(self, entry: LogEntry) -> None: ... + + +# ---------------------------------------------------------------------- Sqlite store + + +class SqliteSpeechEventStore: + """SQLite-backed `SpeechEventStore`. Reuses an existing aiosqlite connection.""" + + def __init__(self, conn: aiosqlite.Connection) -> None: + self._conn = conn + + async def insert(self, event: SpeechEvent) -> None: + nos_json: str | None = None + if event.addressed_seat_nos: + nos_json = json.dumps(list(event.addressed_seat_nos)) + await self._conn.execute( + """ + INSERT INTO speech_events ( + event_id, game_id, phase_id, day, phase, source, speaker_kind, + speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, + alive_seat_nos_json, summary, co_declaration, addressed_seat_no, + addressed_seat_nos_json, role_callout, + claimed_seer_target_seat, claimed_seer_is_wolf, + claimed_medium_target_seat, claimed_medium_is_wolf, + created_at_ms + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ? + ) + """, + ( + event.event_id, + event.game_id, + event.phase_id, + event.day, + event.phase.value, + event.source.value, + event.speaker_kind.value, + event.speaker_seat, + event.text, + event.stt_confidence, + event.audio_start_ms, + event.audio_end_ms, + event.alive_seat_nos_json, + event.summary, + event.co_declaration, + event.addressed_seat_no, + nos_json, + event.role_callout, + event.claimed_seer_target_seat, + _bool_to_int(event.claimed_seer_is_wolf), + event.claimed_medium_target_seat, + _bool_to_int(event.claimed_medium_is_wolf), + event.created_at_ms, + ), + ) + await self._conn.commit() + + async def load_phase(self, game_id: str, phase_id: str) -> Sequence[SpeechEvent]: + async with self._conn.execute( + """ + SELECT event_id, game_id, phase_id, day, phase, source, speaker_kind, + speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, + alive_seat_nos_json, summary, co_declaration, addressed_seat_no, + addressed_seat_nos_json, role_callout, + claimed_seer_target_seat, claimed_seer_is_wolf, + claimed_medium_target_seat, claimed_medium_is_wolf, + created_at_ms + FROM speech_events + WHERE game_id=? AND phase_id=? + ORDER BY created_at_ms ASC, event_id ASC + """, + (game_id, phase_id), + ) as cur: + rows = await cur.fetchall() + return [_row_to_event(row) for row in rows] + + async def load_for_game(self, game_id: str) -> Sequence[SpeechEvent]: + async with self._conn.execute( + """ + SELECT event_id, game_id, phase_id, day, phase, source, speaker_kind, + speaker_seat, text, stt_confidence, audio_start_ms, audio_end_ms, + alive_seat_nos_json, summary, co_declaration, addressed_seat_no, + addressed_seat_nos_json, role_callout, + claimed_seer_target_seat, claimed_seer_is_wolf, + claimed_medium_target_seat, claimed_medium_is_wolf, + created_at_ms + FROM speech_events + WHERE game_id=? + ORDER BY created_at_ms ASC, event_id ASC + """, + (game_id,), + ) as cur: + rows = await cur.fetchall() + return [_row_to_event(row) for row in rows] + + +def _bool_to_int(value: bool | None) -> int | None: + """SQLite has no native boolean — keep the 0/1/NULL convention used + elsewhere in the schema (peaceful_morning, force_skip_pending, etc.) + so the column reads cleanly with `WHERE claimed_seer_is_wolf = 1`. + """ + if value is None: + return None + return 1 if value else 0 + + +def _int_to_bool(value: int | None) -> bool | None: + if value is None: + return None + return bool(value) + + +def _row_to_event(row: Any) -> SpeechEvent: + nos_json = row[16] + addressed_nos: tuple[int, ...] = () + if nos_json: + try: + parsed = json.loads(nos_json) + if isinstance(parsed, list): + addressed_nos = tuple(int(s) for s in parsed if s is not None) + except (json.JSONDecodeError, ValueError, TypeError): + addressed_nos = () + # Legacy events written before addressed_seat_nos_json existed: synth + # the tuple from the singular column so the fold sees both forms + # consistently. + if not addressed_nos and row[15] is not None: + addressed_nos = (int(row[15]),) + return SpeechEvent( + event_id=row[0], + game_id=row[1], + phase_id=row[2], + day=row[3], + phase=Phase(row[4]), + source=SpeechSource(row[5]), + speaker_kind=SpeakerKind(row[6]), + speaker_seat=row[7], + text=row[8], + stt_confidence=row[9], + audio_start_ms=row[10], + audio_end_ms=row[11], + alive_seat_nos_json=row[12], + summary=row[13], + co_declaration=row[14], + addressed_seat_no=row[15], + addressed_seat_nos=addressed_nos, + role_callout=row[17], + claimed_seer_target_seat=row[18], + claimed_seer_is_wolf=_int_to_bool(row[19]), + claimed_medium_target_seat=row[20], + claimed_medium_is_wolf=_int_to_bool(row[21]), + created_at_ms=row[22], + ) + + +# ---------------------------------------------------------------------- Helpers + + +def new_event_id() -> str: + """ULID-shaped opaque id; uuid4 hex is acceptable for MVP uniqueness.""" + return uuid.uuid4().hex + + +def now_ms() -> int: + return int(time.time() * 1000) + + +def make_phase_baseline( + *, + game_id: str, + phase_id: str, + day: int, + phase: Phase, + alive_seat_nos: Iterable[int], + created_at_ms: int | None = None, +) -> SpeechEvent: + """Build the sentinel SpeechEvent inserted at the start of every public speech phase. + + Per accepted spec conflict AC2, the sentinel records the alive-seat baseline + so `PublicDiscussionState` rebuild reads only `speech_events` (never `seats`). + Consumers MUST filter `source != phase_baseline` for human-facing counts. + """ + seats_sorted = sorted(set(alive_seat_nos)) + return SpeechEvent( + event_id=new_event_id(), + game_id=game_id, + phase_id=phase_id, + day=day, + phase=phase, + source=SpeechSource.PHASE_BASELINE, + speaker_kind=SpeakerKind.SYSTEM, + speaker_seat=None, + text="", + stt_confidence=None, + audio_start_ms=None, + audio_end_ms=None, + alive_seat_nos_json=json.dumps(seats_sorted), + created_at_ms=created_at_ms if created_at_ms is not None else now_ms(), + ) + + +def _normalize_addressed( + addressed_seat_no: int | None, + addressed_seat_nos: tuple[int, ...] | None, +) -> tuple[int | None, tuple[int, ...]]: + """Coerce the singular + list form into a consistent pair. + + Returns ``(seat_no, seat_nos)`` where ``seat_nos`` is the canonical + list and ``seat_no`` mirrors its first element. Either input may be + None / empty; if both are set, the list wins and ``seat_no`` is + ignored. Removes duplicates while preserving order. + """ + nos: list[int] = [] + if addressed_seat_nos: + for s in addressed_seat_nos: + if s is None: + continue + if s not in nos: + nos.append(int(s)) + if not nos and addressed_seat_no is not None: + nos.append(int(addressed_seat_no)) + if not nos: + return (None, ()) + return (nos[0], tuple(nos)) + + +def make_human_text_event( + *, + game_id: str, + phase_id: str, + day: int, + phase: Phase, + speaker_seat: int, + text: str, + co_declaration: str | None = None, + addressed_seat_no: int | None = None, + addressed_seat_nos: tuple[int, ...] | None = None, + role_callout: str | None = None, + created_at_ms: int | None = None, +) -> SpeechEvent: + seat_no, seat_nos = _normalize_addressed(addressed_seat_no, addressed_seat_nos) + return SpeechEvent( + event_id=new_event_id(), + game_id=game_id, + phase_id=phase_id, + day=day, + phase=phase, + source=SpeechSource.TEXT, + speaker_kind=SpeakerKind.HUMAN, + speaker_seat=speaker_seat, + text=text, + co_declaration=co_declaration, + addressed_seat_no=seat_no, + addressed_seat_nos=seat_nos, + role_callout=role_callout, + created_at_ms=created_at_ms if created_at_ms is not None else now_ms(), + ) + + +def make_npc_generated_event( + *, + game_id: str, + phase_id: str, + day: int, + phase: Phase, + speaker_seat: int, + text: str, + co_declaration: str | None = None, + addressed_seat_no: int | None = None, + addressed_seat_nos: tuple[int, ...] | None = None, + role_callout: str | None = None, + claimed_seer_target_seat: int | None = None, + claimed_seer_is_wolf: bool | None = None, + claimed_medium_target_seat: int | None = None, + claimed_medium_is_wolf: bool | None = None, + created_at_ms: int | None = None, +) -> SpeechEvent: + seat_no, seat_nos = _normalize_addressed(addressed_seat_no, addressed_seat_nos) + return SpeechEvent( + event_id=new_event_id(), + game_id=game_id, + phase_id=phase_id, + day=day, + phase=phase, + source=SpeechSource.NPC_GENERATED, + speaker_kind=SpeakerKind.NPC, + speaker_seat=speaker_seat, + text=text, + co_declaration=co_declaration, + addressed_seat_no=seat_no, + addressed_seat_nos=seat_nos, + role_callout=role_callout, + claimed_seer_target_seat=claimed_seer_target_seat, + claimed_seer_is_wolf=claimed_seer_is_wolf, + claimed_medium_target_seat=claimed_medium_target_seat, + claimed_medium_is_wolf=claimed_medium_is_wolf, + created_at_ms=created_at_ms if created_at_ms is not None else now_ms(), + ) + + +def make_voice_stt_event( + *, + game_id: str, + phase_id: str, + day: int, + phase: Phase, + speaker_seat: int, + text: str, + stt_confidence: float, + audio_start_ms: int, + audio_end_ms: int, + co_declaration: str | None = None, + addressed_seat_no: int | None = None, + addressed_seat_nos: tuple[int, ...] | None = None, + created_at_ms: int | None = None, +) -> SpeechEvent: + seat_no, seat_nos = _normalize_addressed(addressed_seat_no, addressed_seat_nos) + return SpeechEvent( + event_id=new_event_id(), + game_id=game_id, + phase_id=phase_id, + day=day, + phase=phase, + source=SpeechSource.VOICE_STT, + speaker_kind=SpeakerKind.HUMAN, + speaker_seat=speaker_seat, + text=text, + stt_confidence=stt_confidence, + audio_start_ms=audio_start_ms, + audio_end_ms=audio_end_ms, + co_declaration=co_declaration, + addressed_seat_no=seat_no, + addressed_seat_nos=seat_nos, + created_at_ms=created_at_ms if created_at_ms is not None else now_ms(), + ) + + +# ---------------------------------------------------------------------- Service + + +class DiscussionService: + """Front door for SpeechEvent ingestion + PLAYER_SPEECH side-effect dispatch. + + Every call to :meth:`record` performs the same three steps in order so the + contract is uniform across all ingestion origins: + + 1. Insert into `speech_events` (always). + 2. Append a `LogEntry(kind="PLAYER_SPEECH")` (skipped for `phase_baseline`). + 3. Post to the main text channel (skipped for `phase_baseline` and `text`). + + The PLAYER_SPEECH log preserves the existing public-log contract so post-game + replay remains a single timeline. The skip rules are documented in the + module docstring above. + """ + + PLAYER_SPEECH_KIND = "PLAYER_SPEECH" + + def __init__( + self, + store: SpeechEventStore, + log_sink: PublicLogSink | None = None, + message_poster: SpeechMessagePoster | None = None, + ) -> None: + self._store = store + self._log_sink = log_sink + self._poster = message_poster + + async def record_persist_only(self, event: SpeechEvent) -> None: + """Persist the SpeechEvent without invoking PLAYER_SPEECH log + channel post. + + Use from rounds-mode backfill where the existing legacy path has already + posted the message and inserted the LogEntry; this method captures the + canonical event row for the speech-event-bus without duplication. + """ + await self._store.insert(event) + + async def record(self, event: SpeechEvent) -> None: + """Persist the event then run the post-write side-effects per source.""" + await self._store.insert(event) + + if event.source == SpeechSource.PHASE_BASELINE: + return + + if self._log_sink is not None and event.speaker_seat is not None: + log_entry = LogEntry( + game_id=event.game_id, + day=event.day, + phase=event.phase, + kind=self.PLAYER_SPEECH_KIND, + actor_seat=event.speaker_seat, + visibility="PUBLIC", + text=event.text, + created_at=event.created_at_ms // 1000, + ) + try: + await self._log_sink.insert_log_public(log_entry) + except Exception: + log.exception( + "PLAYER_SPEECH log insert failed", + extra={"event_id": event.event_id, + "source": event.source.value}, + ) + + if self._poster is not None and event.source != SpeechSource.TEXT and event.text: + try: + await self._poster.post_public(event.game_id, event.text, self.PLAYER_SPEECH_KIND) + except Exception: + log.exception( + "PLAYER_SPEECH channel post failed", + extra={"event_id": event.event_id, + "source": event.source.value}, + ) + + async def begin_phase( + self, + *, + game_id: str, + day: int, + phase: Phase, + alive_seat_nos: Iterable[int], + sequence: int = 1, + ) -> str: + """Insert the `phase_baseline` sentinel and return the resulting `phase_id`. + + Call exactly once at the start of every public speech phase + (`DAY_DISCUSSION`, `DAY_RUNOFF_SPEECH`). Subsequent `record()` calls in + that phase MUST use the returned `phase_id`. + """ + phase_id = make_phase_id(game_id, day, phase, sequence) + sentinel = make_phase_baseline( + game_id=game_id, + phase_id=phase_id, + day=day, + phase=phase, + alive_seat_nos=alive_seat_nos, + ) + await self.record(sentinel) + return phase_id + + async def begin_phase_if_absent( + self, + *, + game_id: str, + day: int, + phase: Phase, + alive_seat_nos: Iterable[int], + sequence: int = 1, + ) -> str: + """Idempotent baseline insert: returns the canonical phase_id and only + writes the sentinel if no events already exist for that phase_id. + + Use from background workers (LLM discussion / runoff) so a worker re- + dispatch (recovery, force-skip, restart) does not duplicate the + baseline; the canonical phase_id stays stable so downstream `record`s + attach to the same phase fold. + """ + phase_id = make_phase_id(game_id, day, phase, sequence) + existing = await self._store.load_phase(game_id, phase_id) + if existing: + return phase_id + sentinel = make_phase_baseline( + game_id=game_id, + phase_id=phase_id, + day=day, + phase=phase, + alive_seat_nos=alive_seat_nos, + ) + await self.record(sentinel) + return phase_id + + async def load_phase(self, game_id: str, phase_id: str) -> Sequence[SpeechEvent]: + return await self._store.load_phase(game_id, phase_id) + + async def load_for_game(self, game_id: str) -> Sequence[SpeechEvent]: + """All non-baseline speech events for `game_id`, ordered by time. + + Exposed so the arbiter can extract historical CO claims across + phase boundaries without going around the store interface. + """ + return await self._store.load_for_game(game_id) + + +# ---------------------------------------------------------------------- Rebuild + + +def apply_speech_event( + state: PublicDiscussionState | None, + event: SpeechEvent, +) -> PublicDiscussionState | None: + """Stepwise fold: produce the next `PublicDiscussionState` from a single event. + + The first event of a phase MUST be a `phase_baseline` sentinel; passing + `state=None` with a non-sentinel event returns `None` (the caller is asking + to fold an event into a phase whose baseline has not been seeded). + + A returned state is a *new* object; the input is not mutated. This makes + the function safe to use as a `functools.reduce` step. + """ + from wolfbot.domain.discussion import CoClaim + + if event.source == SpeechSource.PHASE_BASELINE: + if event.alive_seat_nos_json is None: + return None + try: + alive_list = json.loads(event.alive_seat_nos_json) + except json.JSONDecodeError: + return None + return PublicDiscussionState( + game_id=event.game_id, + phase_id=event.phase_id, + day=event.day, + alive_seat_nos=frozenset(int(s) for s in alive_list), + ) + + if state is None: + return None + + speaker = event.speaker_seat + silent = set(state.silent_seats) if state.silent_seats else set( + state.alive_seat_nos) + if state.silent_seats == frozenset() and not state.recent_speech_event_ids: + # First non-baseline event: seed silent_seats from alive baseline. + silent = set(state.alive_seat_nos) + if speaker is not None: + silent.discard(speaker) + + co_claims = list(state.co_claims) + seen_co = {(c.seat, c.role_claim) for c in co_claims} + # Roles already CO'd by *some* seat before this event. Lets us + # detect first-CO-of-role transitions for the counter-CO pool + # trigger below without re-walking the full claim list. + existing_role_keys = {role for (_, role) in seen_co} + is_new_co = False # tracks whether THIS event added a fresh CO + fired_first_co_role: str | None = None + if speaker is not None: + role_key = _resolve_co_role(event) + if role_key is not None: + key = (speaker, role_key) + if key not in seen_co: + if role_key not in existing_role_keys: + fired_first_co_role = role_key + seen_co.add(key) + co_claims.append( + CoClaim( + seat=speaker, + role_claim=role_key, + declared_at_event_id=event.event_id, + ) + ) + is_new_co = True + + recent = [*state.recent_speech_event_ids, event.event_id][-10:] + + # Address routing rules. The arbiter consumes ``last_addressed_seats`` + # (multi-seat) to prioritize everyone who was just called out, so we + # have to be careful about *when* members are cleared: + # + # - Human or NPC speech with its own ``addressed_seat_nos`` always + # supersedes the prior addressing (= a fresh call-out wins, the + # old set is dropped wholesale). + # - An NPC who is themselves in the prior set speaks (= one of the + # addressees replied) → remove only that NPC from the set; the + # others stay prioritized so they all get a chance to answer. + # - Anything else keeps the standing set. Without this, e.g. a + # silent_rotation pick that "jumps the line" before any addressed + # NPC replies would silently clear the hint and the addressees + # never get prioritized. + last_addressed_seats: frozenset[int] = state.last_addressed_seats + last_addressed_speaker_seat = state.last_addressed_speaker_seat + last_addressed_text = state.last_addressed_text + new_addressed = event_addressed_seats(event) + if new_addressed: + last_addressed_seats = frozenset(new_addressed) + last_addressed_speaker_seat = speaker + last_addressed_text = event.text + elif ( + event.source == SpeechSource.NPC_GENERATED + and speaker is not None + and speaker in state.last_addressed_seats + ): + remaining = set(state.last_addressed_seats) - {speaker} + last_addressed_seats = frozenset(remaining) + if not remaining: + last_addressed_speaker_seat = None + last_addressed_text = "" + last_addressed_seat = ( + next(iter(sorted(last_addressed_seats))) if last_addressed_seats else None + ) + + last_speaker_seat = ( + speaker if speaker is not None else state.last_speaker_seat + ) + # Per-seat utterance count within the phase. Increment on every + # non-baseline speaker; the arbiter's `_pick_key` reads this so an + # NPC who's already spoken N times falls below NPCs who spoke + # fewer times. + speech_counts = dict(state.speech_counts) + if speaker is not None: + speech_counts[speaker] = speech_counts.get(speaker, 0) + 1 + # Append (speaker, has_info) to the sliding summary window the + # arbiter uses for low-info pair-volley detection. ``has_info`` is + # the structured "did this event move the discussion forward" + # signal. Today: only a *first-time* CO counts (re-declaring an + # already-recorded CO doesn't bypass the gate — that was the + # ジョナス↔ラキオ ping-pong escape hatch where Raqio kept emitting + # the same `co_declaration='seer'` flag and made every speech look + # like new info). Wider signals (new accusation target, vote + # announcement) can be added here later without changing the + # field shape. + summary = list(state.recent_speech_summary) + if speaker is not None: + summary.append((speaker, is_new_co)) + summary = summary[-6:] + # Track outstanding role-callouts (e.g. "占い師の方どうぞ"). A request + # adds the role to the pending set; a matching CO consumes it + # (= the call was answered). Wolf-side NPCs and real role holders + # both react to this in their speech prompt. + pending_role_callouts = set(state.pending_role_callouts) + if event.role_callout is not None: + pending_role_callouts.add(event.role_callout) + if is_new_co: + # `_resolve_co_role` returned a role; remove it from the pending + # set even if it wasn't explicitly requested — anyone CO'ing + # implicitly answers any outstanding call. + for role_key in tuple(pending_role_callouts): + if event.co_declaration == role_key or _resolve_co_role(event) == role_key: + pending_role_callouts.discard(role_key) + # ``info_request`` is a generic info-seeking callout; once anyone + # CO's any info role, the request is considered partially answered + # and the priority pool steps down. + pending_role_callouts.discard("info_request") + + # First-CO counter-response window. Adds the role to + # ``pending_co_response`` exactly once per role per phase fold. + # The arbiter's pool dispatch consumes members one-by-one via + # ``_callout_pool_asked``; once everyone in the pool has been + # asked, the empty effective-pool naturally falls through to + # normal priority. The flag itself stays set for the remainder of + # the phase — a 2nd CO of the same role does NOT clear it because + # the per-user request explicitly asks for the pool to keep + # rotating until every member has spoken or skipped. + pending_co_response = set(state.pending_co_response) + if fired_first_co_role is not None: + pending_co_response.add(fired_first_co_role) + return PublicDiscussionState( + game_id=state.game_id, + phase_id=state.phase_id, + day=state.day, + alive_seat_nos=state.alive_seat_nos, + co_claims=tuple(co_claims), + stances=state.stances, + pressure=state.pressure, + open_topics=state.open_topics, + silent_seats=frozenset(silent), + recent_speech_event_ids=tuple(recent), + last_addressed_seat=last_addressed_seat, + last_addressed_speaker_seat=last_addressed_speaker_seat, + last_addressed_text=last_addressed_text, + last_addressed_seats=last_addressed_seats, + last_speaker_seat=last_speaker_seat, + recent_speech_summary=tuple(summary), + pending_role_callouts=frozenset(pending_role_callouts), + pending_co_response=frozenset(pending_co_response), + speech_counts=speech_counts, + ) + + +def rebuild_public_state_from_events( + events: Sequence[SpeechEvent], + *, + prior_co_keys: frozenset[tuple[int, str]] = frozenset(), +) -> PublicDiscussionState | None: + """Pure fold over a single phase's `SpeechEvent` rows. + + Returns ``None`` if `events` is empty or contains no sentinel — the caller + decides whether to treat that as "phase not yet started" or as an error. + + MVP rules (per the spec delta + AC2): + * `alive_seat_nos` is read from the sentinel's `alive_seat_nos_json`. + * `co_claims` extracts seat-attributed CO mentions in declaration order. + * `silent_seats` = `alive_seat_nos` minus seats with ≥1 non-sentinel event. + * `recent_speech_event_ids` keeps the last 10 non-sentinel ids in arrival order. + * `stances` / `pressure` / `open_topics` remain empty in MVP — design defers. + + ``prior_co_keys`` seeds the internal ``seen_co`` set with `(seat, role)` + tuples extracted from earlier-phase events. Without this, a seat that + CO'd on day 1 and re-asserts the same CO on day 2 would flag + ``is_new_co=True`` in the day-2 phase rebuild (because the per-phase + fold starts ``seen_co`` empty). That defeats the volley-demotion gate + in ``speak_arbiter._compute_demoted_seats``: the ジョナス↔ユリコ + ping-pong observed in game ``a701a7531dca`` day 2 escaped demotion + because every ジョナス re-CO looked like fresh info to the per-phase + fold even though the day-1 seer CO had been on record. + """ + if not events: + return None + + sentinel = next( + (e for e in events if e.source == SpeechSource.PHASE_BASELINE), + None, + ) + if sentinel is None or sentinel.alive_seat_nos_json is None: + return None + + try: + alive_list = json.loads(sentinel.alive_seat_nos_json) + except json.JSONDecodeError: + return None + alive_seats: frozenset[int] = frozenset(int(s) for s in alive_list) + + state = PublicDiscussionState( + game_id=sentinel.game_id, + phase_id=sentinel.phase_id, + day=sentinel.day, + alive_seat_nos=alive_seats, + ) + + from wolfbot.domain.discussion import CoClaim + + spoken_seats: set[int] = set() + co_claims: list[CoClaim] = [] + recent_ids: list[str] = [] + summary: list[tuple[int, bool]] = [] + # Seed seen_co with prior-phase CO history so a re-asserted CO doesn't + # flag is_new_co=True. co_claims itself stays scoped to current-phase + # declarations so the per-phase fold's outward shape is unchanged — + # the arbiter overrides state.co_claims with the game-wide history + # right after the rebuild via extract_co_claims_from_events. + seen_co: set[tuple[int, str]] = set(prior_co_keys) + pending_role_callouts: set[str] = set() + pending_co_response: set[str] = set() + speech_counts: dict[int, int] = {} + last_addressed_seats: frozenset[int] = frozenset() + last_addressed_speaker_seat: int | None = None + last_addressed_text: str = "" + last_speaker_seat: int | None = None + for event in events: + if event.source == SpeechSource.PHASE_BASELINE: + continue + if event.speaker_seat is not None: + spoken_seats.add(event.speaker_seat) + last_speaker_seat = event.speaker_seat + speech_counts[event.speaker_seat] = ( + speech_counts.get(event.speaker_seat, 0) + 1 + ) + recent_ids.append(event.event_id) + # Mirror the per-event update logic in `apply_speech_event`: + # a fresh ``addressed_seat_nos`` (multi-seat) replaces the entire + # standing set; an NPC speaker who's already in the set consumes + # *only their own slot*, leaving co-addressees still prioritized. + new_addressed = event_addressed_seats(event) + if new_addressed: + last_addressed_seats = frozenset(new_addressed) + last_addressed_speaker_seat = event.speaker_seat + last_addressed_text = event.text + elif ( + event.source == SpeechSource.NPC_GENERATED + and event.speaker_seat is not None + and event.speaker_seat in last_addressed_seats + ): + remaining = set(last_addressed_seats) - {event.speaker_seat} + last_addressed_seats = frozenset(remaining) + if not remaining: + last_addressed_speaker_seat = None + last_addressed_text = "" + if event.speaker_seat is None: + continue + # Track outstanding role-callouts (request → pending; matching + # CO consumes from the set). Per-event update mirror. + if event.role_callout is not None: + pending_role_callouts.add(event.role_callout) + # `is_new_co` flag goes into `recent_speech_summary` so the arbiter + # can detect "two seats arguing without new information" — a + # repeated CO from the same seat does NOT count as info. + is_new_co = False + role_key = _resolve_co_role(event) + if role_key is not None: + key = (event.speaker_seat, role_key) + if key not in seen_co: + is_new_co = True + summary.append((event.speaker_seat, is_new_co)) + if role_key is None: + continue + if is_new_co: + pending_role_callouts.discard(role_key) + # See integrate_speech_event: info_request is consumed by any + # info-role CO, regardless of which specific role was asked. + pending_role_callouts.discard("info_request") + # First-CO of role triggers the counter-CO opportunity pool. + # Mirror of ``apply_speech_event`` — fired once per role per + # phase fold (subsequent COs of the same role keep the + # window open until the arbiter exhausts the asked tracker). + existing_role_keys = {role for (_, role) in seen_co} + if role_key not in existing_role_keys: + pending_co_response.add(role_key) + if (event.speaker_seat, role_key) in seen_co: + continue + seen_co.add((event.speaker_seat, role_key)) + co_claims.append( + CoClaim( + seat=event.speaker_seat, + role_claim=role_key, + declared_at_event_id=event.event_id, + ) + ) + + state.co_claims = tuple(co_claims) + state.silent_seats = frozenset(alive_seats - spoken_seats) + state.recent_speech_event_ids = tuple(recent_ids[-10:]) + state.last_addressed_seats = last_addressed_seats + state.last_addressed_seat = ( + next(iter(sorted(last_addressed_seats))) if last_addressed_seats else None + ) + state.last_addressed_speaker_seat = last_addressed_speaker_seat + state.last_addressed_text = last_addressed_text + state.last_speaker_seat = last_speaker_seat + state.recent_speech_summary = tuple(summary[-6:]) + state.pending_role_callouts = frozenset(pending_role_callouts) + state.pending_co_response = frozenset(pending_co_response) + state.speech_counts = speech_counts + return state + + +_VALID_CO_ROLES: frozenset[str] = frozenset(CO_CLAIM_VALUES) + +_CO_MARKERS: tuple[tuple[str, str], ...] = ( + ("seer", "占いCO"), + ("medium", "霊媒CO"), + ("knight", "騎士CO"), +) +"""Legacy substring fallback. Authoritative CO comes from +`SpeechEvent.co_declaration` (set at the source: NPC/LLM schema field, or +Gemini's structured `co_claim`). Substring matching is only used for legacy +events and human text where natural-language CO has not been pre-tagged. +""" + +# Role keyword + canonical CO token per role. Used by +# :func:`_text_contains_self_declaration` to verify the structured +# ``co_declaration`` field matches an actual self-naming in ``text``. +_ROLE_KEYWORDS: dict[str, tuple[str, ...]] = { + "seer": ("占い師",), + "medium": ("霊媒師",), + "knight": ("騎士",), +} +_ROLE_CO_TOKENS: dict[str, str] = { + "seer": "占いCO", + "medium": "霊媒CO", + "knight": "騎士CO", +} +# Japanese first-person pronouns used by NPCs / human players. Order +# matters: longer pronouns (``この身``, ``わたくし``) first so the loop +# strips them before partial overlaps catch shorter forms. +_FIRST_PERSON_PRONOUNS: tuple[str, ...] = ( + "わたくし", + "アタシ", + "わたし", + "オレ", + "この身", + "拙者", + "アタイ", + "ボク", + "私", + "僕", + "俺", + "我", + "わて", +) +# Phrases that, when they appear immediately before a role keyword, mean +# the role is being mentioned topically (= about someone else / about +# the concept) rather than self-declared. Stripping them out of the +# text before looking for the self-decl pattern prevents the +# "対抗占い師は出ないのか?" false positive that incorrectly tagged +# game ``98e5a083b5ff`` ラキオ as a seer-CO before he actually CO'd. +_TOPICAL_PREFIXES: tuple[str, ...] = ( + "対抗の", "対抗", "別の", "他の", "もう一人の", "もうひとりの", + "誰か", "他に", +) +# Verb endings that, when they directly follow a role keyword (or the +# canonical ``XCO`` token), turn a bare keyword into a self-declaration +# even without an explicit first-person pronoun. Covers the persona +# voice patterns the existing personas use ("〜だよ" / "〜なのです" / +# "〜なんだ" / "〜やってます" / "〜になります"). +_DECL_VERB_PATTERN = ( + r"(?:です|ですわ|なんだ|だよ|だぜ|なの|なる|なります|" + r"になります|やってる|やってます|として(?:出る|出ます|名乗る|名乗ります)|" + r"に出ます|に出る|だ|ね|よ|わ)" +) + + +def _text_contains_self_declaration(text: str, role: str) -> bool: + """Heuristic: does ``text`` contain a first-person CO declaration of ``role``? + + Used as a sanity check on the structured ``co_declaration`` field. + The NPC LLM occasionally leaks its strategic intent into the + structured field before the natural-language text actually contains + the declaration (game ``98e5a083b5ff`` day 1: ラキオ the madman + posted a question to Stella with ``co_declaration='seer'`` set + even though the text was just "対抗占い師は出ないのか?"). When the + text/structured pair disagree, this helper returns ``False`` so + :func:`_resolve_co_role` falls back to the legacy substring scan + rather than blindly trusting the leaked flag. + + Recognised patterns (any one suffices): + + * **First-person pronoun within ~10 characters of the role keyword.** + Topical / counter-CO-request prefixes ("対抗", "別の", "他の", ...) + are stripped first so "対抗占い師" can't be mistaken for self-decl. + * **Role keyword (or canonical ``XCO`` token) immediately followed + by a declarative verb ending** ("占い師です" / "霊媒CO する" / + "騎士やってます" / ...). The verb whitelist is curated to match + the personas' speech profiles without bleeding into question / + negation forms ("出ないのか" / "ではない" / "誰なんだろう"). + """ + keywords = _ROLE_KEYWORDS.get(role) + co_token = _ROLE_CO_TOKENS.get(role) + if not keywords or co_token is None: + return False + + # Strip topical / counter-CO-request prefixes so "対抗占い師" doesn't + # produce a false positive in either pattern below. + cleaned = text + for prefix in _TOPICAL_PREFIXES: + for kw in keywords: + cleaned = cleaned.replace(prefix + kw, "") + cleaned = cleaned.replace(prefix + co_token, "") + + pronoun_alt = "|".join(re.escape(p) for p in _FIRST_PERSON_PRONOUNS) + for kw in keywords: + # Pattern A: first-person pronoun, optional particle/comma, then + # the role keyword within ~10 chars (skipping whitespace and + # punctuation that don't end a sentence — `。` / newlines do). + pat_a = re.compile( + rf"(?:{pronoun_alt})" + rf"(?:[、,はがこそも自身]{{0,3}})?" + rf"[^。\n]{{0,10}}" + rf"{re.escape(kw)}" + ) + if pat_a.search(cleaned): + return True + # Pattern B: keyword + declarative verb suffix (no first-person + # required — the verb form itself signals self-naming). + pat_b = re.compile( + rf"{re.escape(kw)}[、,]?{_DECL_VERB_PATTERN}" + ) + if pat_b.search(cleaned): + return True + + # Pattern C: canonical ``XCO`` token followed by a declarative verb + # or used as a bare self-marker. ``占いCOがいない`` / ``占いCOについて`` + # are topical and rejected by the trailing-verb requirement. + pat_c = re.compile( + rf"{re.escape(co_token)}(?:します|する|です|なんだ|だよ|だ|に出ます|に出る)" + ) + if pat_c.search(cleaned): + return True + # Pattern D: first-person pronoun followed by ``XCO`` within ~10 chars, + # covering "私こそ占いCO" / "僕、霊媒CO" etc. + pat_d = re.compile( + rf"(?:{pronoun_alt})" + rf"(?:[、,はがこそも自身]{{0,3}})?" + rf"[^。\n]{{0,10}}" + rf"{re.escape(co_token)}" + ) + return bool(pat_d.search(cleaned)) + + +def _resolve_co_role(event: SpeechEvent) -> str | None: + """Pick the CO role for an event, preferring the structured field. + + Returns one of ``"seer" / "medium" / "knight"`` or ``None`` for "no CO". + + The structured ``co_declaration`` field is treated as authoritative + *only when ``text`` carries a matching self-declaration*. Without + the text guard, the NPC LLM could (and did, in game + ``98e5a083b5ff``) leak its strategic intent into the structured + field while the text was still a question / topical mention, which + falsely flipped the speaker's seat into the public CO ledger one + turn early. When the structured field disagrees with the text, we + drop the flag and fall through to the legacy substring scan so + only the explicit canonical ``占いCO``/``霊媒CO``/``騎士CO`` token + in ``text`` survives. + """ + declared = event.co_declaration + if declared is not None and declared in _VALID_CO_ROLES: + if _text_contains_self_declaration(event.text, declared): + return declared + log.info( + "co_declaration_text_mismatch dropped event=%s declared=%s text=%r", + event.event_id, declared, event.text[:120], + ) + for role_key, marker in _CO_MARKERS: + if marker in event.text: + return role_key + return None + + +def extract_co_claims_from_events( + events: Sequence[SpeechEvent], +) -> tuple[CoClaim, ...]: + """Walk events of a single game and return the de-duplicated CO claims. + + Used by SpeakArbiter to carry CO claims across phase boundaries so the + NPC's prompt still shows "席4 seerCO" on day 2 even though the day-2 + PublicDiscussionState fold only sees day-2 events. De-dup key is + ``(speaker_seat, role_claim)`` — the earliest event wins, matching + the in-phase fold semantics. + """ + claims: list[CoClaim] = [] + seen: set[tuple[int, str]] = set() + for event in events: + if event.source == SpeechSource.PHASE_BASELINE: + continue + if event.speaker_seat is None: + continue + role_key = _resolve_co_role(event) + if role_key is None: + continue + key = (event.speaker_seat, role_key) + if key in seen: + continue + seen.add(key) + claims.append( + CoClaim( + seat=event.speaker_seat, + role_claim=role_key, + declared_at_event_id=event.event_id, + ) + ) + return tuple(claims) diff --git a/src/wolfbot/services/game_export.py b/src/wolfbot/services/game_export.py new file mode 100644 index 0000000..3f2cda2 --- /dev/null +++ b/src/wolfbot/services/game_export.py @@ -0,0 +1,798 @@ +"""Export one finished/aborted game to viewer-compatible JSON. + +Joins three data sources into one self-contained file the +:mod:`viewer/` Next.js app can read directly: + + 1. SQLite — game / seats / public logs / votes / night actions / speech events + 2. ``logs/llm_calls/{game_id}/*.jsonl`` — gameplay + npc_speech + voice_stt traces + 3. (derived) phase grouping, victory inference + +The output schema is the canonical +:class:`wolfbot.services.game_export_types.GameExport` (mirrored 1:1 in +``viewer/src/lib/types.ts``). Time fields exposed to the viewer are +uniformly milliseconds since epoch — the DB stores ``created_at`` / +``submitted_at`` in seconds, so this module multiplies by 1000 at the +boundary. + +The exporter is invoked in two places: + +* :class:`wolfbot.services.game_service.GameService` calls it as a + ``_on_game_end_finalize`` hook on victory and on host abort. +* ``scripts/export-game.py`` provides a manual CLI for re-exporting any + past game by id. +""" + +from __future__ import annotations + +import json +import logging +import re +from datetime import datetime +from pathlib import Path +from typing import Any, cast + +import aiosqlite +from pydantic import ValidationError + +from wolfbot.services.game_export_types import ( + ArbiterDecisionEntry, + ClaimedMediumExport, + ClaimedMediumHistoryEntry, + ClaimedSeerExport, + ClaimedSeerHistoryEntry, + ClaimHistoryEntry, + DeathCause, + DiscussionMode, + GameExport, + GameMeta, + NightActionExport, + PhaseSection, + PublicLogEntry, + RoleKey, + SeatExport, + SpeechEventExport, + SpeechSource, + SuspicionEntry, + TraceEntry, + Victory, + VoteExport, + WolfChatLogEntry, +) + +log = logging.getLogger(__name__) + +# Default output dir — the viewer auto-discovers files here, so finishing +# a game and running ``cd viewer && pnpm dev`` Just Works with no flags. +_DEFAULT_OUTPUT_DIR = Path("viewer/games") +# Default trace dir — must match wolfbot.services.llm_trace's default. +_DEFAULT_TRACE_DIR = Path("logs/llm_calls") + + +async def export_game( + *, + game_id: str, + db_path: Path | str, + trace_dir: Path | str | None = None, + output_dir: Path | str | None = None, +) -> Path: + """Build the viewer JSON for ``game_id`` and write it to disk. + + Returns the absolute path of the written file. Raises on any DB error, + missing-game, or schema-violating data; caller decides whether to + swallow or propagate. + """ + db_path = Path(db_path) + trace_root = Path(trace_dir) if trace_dir else _DEFAULT_TRACE_DIR + out_dir = Path(output_dir) if output_dir else _DEFAULT_OUTPUT_DIR + out_dir.mkdir(parents=True, exist_ok=True) + + payload = await _build_payload(game_id, db_path, trace_root) + # Filename is timestamp-prefixed (sortable / human-readable) instead + # of the random game_id. Local-time format YYYY-MM-DD_HH-MM-SS comes + # from the game's created_at — the viewer auto-discovers files by + # mtime, so the prefix only matters for human listing. If a same- + # second collision happens, append _ to keep the older file. + ts = datetime.fromtimestamp(payload.game.created_at_ms / 1000) + base_name = ts.strftime("%Y-%m-%d_%H-%M-%S") + out_path = out_dir / f"{base_name}.json" + suffix = 1 + while out_path.exists() and out_path.stat().st_size > 0: + # Different game wrote the same-second filename earlier — disambiguate. + existing = out_path.read_text(encoding="utf-8") + if f'"id": "{game_id}"' in existing: + # Same game re-export — overwrite. + break + out_path = out_dir / f"{base_name}_{suffix}.json" + suffix += 1 + out_path.write_text( + payload.model_dump_json(indent=2), + encoding="utf-8", + ) + log.info( + "game_exported game=%s path=%s phases=%d trace_lines=%d arbiter_decisions=%d", + game_id, + out_path, + len(payload.phases), + len(payload.trace), + len(payload.arbiter_decisions), + ) + return out_path.resolve() + + +async def _build_payload(game_id: str, db_path: Path, trace_root: Path) -> GameExport: + async with aiosqlite.connect(str(db_path)) as db: + db.row_factory = aiosqlite.Row + await db.execute("PRAGMA foreign_keys = ON") + + game_row = await _fetch_one( + db, + "SELECT * FROM games WHERE id = ?", + (game_id,), + ) + if game_row is None: + raise ValueError(f"game not found: {game_id}") + + seat_rows = [ + dict(r) + for r in await _fetch_all( + db, + "SELECT * FROM seats WHERE game_id = ? ORDER BY seat_no", + (game_id,), + ) + ] + # PLAYER_SPEECH log rows duplicate the canonical speech_events rows + # (DiscussionService.record() inserts both at write time so live LLM + # context-builder prompts can keep reading from logs_public). For + # replay we only need the speech_events row — it carries source + + # speaker_seat + summary, which the viewer attributes to the player. + # Excluding the duplicates here keeps the timeline clean and avoids + # the "NPC text shown twice (PLAYER_SPEECH log + speech_event)" issue. + public_log_rows = [ + dict(r) + for r in await _fetch_all( + db, + "SELECT day, phase, kind, actor_seat, text, created_at " + "FROM logs_public WHERE game_id = ? AND kind != 'PLAYER_SPEECH' " + "ORDER BY id ASC", + (game_id,), + ) + ] + vote_rows = [ + dict(r) + for r in await _fetch_all( + db, + "SELECT day, round, voter_seat, target_seat, submitted_at " + "FROM votes WHERE game_id = ? " + "ORDER BY day, round, submitted_at", + (game_id,), + ) + ] + night_action_rows = [ + dict(r) + for r in await _fetch_all( + db, + "SELECT day, actor_seat, kind, target_seat, submitted_at " + "FROM night_actions WHERE game_id = ? " + "ORDER BY day, submitted_at", + (game_id,), + ) + ] + # Wolf chat is fan-out-stored in `logs_private` (one row per + # audience seat) so the same utterance shows up N times where N + # = alive wolves at the moment. Dedupe on + # (actor_seat, created_at, text) keeps one row per actual + # utterance — that's what the viewer wants to render. Phase is + # always 'NIGHT' (or 'NIGHT_0' on day 0) by construction; we + # carry it through so the per-phase grouping in `_build_phases` + # picks it up without extra logic. + wolf_chat_rows = [ + dict(r) + for r in await _fetch_all( + db, + "SELECT DISTINCT day, phase, actor_seat, text, created_at " + "FROM logs_private " + "WHERE game_id = ? AND kind = 'WOLF_CHAT' AND actor_seat IS NOT NULL " + "ORDER BY created_at ASC", + (game_id,), + ) + ] + # `phase_baseline` rows are an internal sentinel used by + # PublicDiscussionState to seed alive-seat baselines; they have + # empty text and are explicitly excluded from public-log emission + # in the live system. Filter at the SQL level so the viewer never + # sees them. + speech_event_rows = [ + dict(r) + for r in await _fetch_all( + db, + "SELECT event_id, day, phase, source, speaker_seat, text, " + "stt_confidence, summary, co_declaration, addressed_seat_no, " + "claimed_seer_target_seat, claimed_seer_is_wolf, " + "claimed_medium_target_seat, claimed_medium_is_wolf, " + "created_at_ms " + "FROM speech_events WHERE game_id = ? " + "AND source != 'phase_baseline' " + "ORDER BY created_at_ms ASC", + (game_id,), + ) + ] + # Public suspicion timeline (speech + vote derived). Walk every + # row in chronological order so the viewer can render both the + # timeline feed and a (suspecter, target) pivot matrix without + # re-querying. + suspicion_rows = [ + dict(r) + for r in await _fetch_all( + db, + "SELECT source, event_id, seq, day, phase, vote_round, " + "suspecter_seat, target_seat, level, reason, " + "update_from_level, update_reason, created_at_ms " + "FROM suspicions WHERE game_id = ? " + "ORDER BY created_at_ms ASC, id ASC", + (game_id,), + ) + ] + # Arbiter decision timeline — joined LEFT-OUTER from requests so + # in-flight or rejected dispatches still appear (results / + # playback may legitimately be missing). + arbiter_rows = [ + dict(r) + for r in await _fetch_all( + db, + """ + SELECT + req.request_id, req.phase_id, req.npc_id, req.seat_no, + req.suggested_intent, req.selection_reason, + req.public_state_snapshot_json, req.logic_packet_id, + req.created_at_ms, req.expires_at_ms, + res.status AS result_status, + res.text AS result_text, + res.intent AS result_intent, + res.failure_reason AS result_failure_reason, + res.received_at_ms AS result_received_at_ms, + pb.outcome AS playback_outcome, + pb.failure_reason AS playback_failure_reason, + pb.finished_at_ms AS playback_finished_at_ms, + pb.tts_outcome AS tts_outcome, + pb.tts_duration_ms AS tts_duration_ms + FROM npc_speak_requests req + LEFT JOIN npc_speak_results res ON res.request_id = req.request_id + LEFT JOIN npc_playback_events pb ON pb.request_id = req.request_id + WHERE req.game_id = ? + ORDER BY req.created_at_ms ASC + """, + (game_id,), + ) + ] + + seat_lookup = {s["seat_no"]: s["display_name"] for s in seat_rows} + return GameExport( + game=_build_game_meta(game_row, public_log_rows), + seats=[_build_seat(s) for s in seat_rows], + phases=_build_phases( + _retag_morning_logs_from_text(public_log_rows), + speech_event_rows, + vote_rows, + night_action_rows, + wolf_chat_rows, + ), + trace=_load_trace(trace_root, game_id), + arbiter_decisions=[_build_arbiter_decision(r) for r in arbiter_rows], + claim_history=_build_claim_history(speech_event_rows, seat_lookup), + suspicions=[ + _build_suspicion(r) for r in suspicion_rows + ], + ) + + +# ---------------------------------------------------------------- DB helpers +async def _fetch_one( + db: aiosqlite.Connection, sql: str, params: tuple[Any, ...] +) -> aiosqlite.Row | None: + async with db.execute(sql, params) as cur: + return await cur.fetchone() + + +async def _fetch_all( + db: aiosqlite.Connection, sql: str, params: tuple[Any, ...] +) -> list[aiosqlite.Row]: + async with db.execute(sql, params) as cur: + return list(await cur.fetchall()) + + +# ---------------------------------------------------------------- shape builders +def _build_game_meta(row: aiosqlite.Row, public_logs: list[dict[str, Any]]) -> GameMeta: + discussion_mode = cast(DiscussionMode, row["discussion_mode"]) + return GameMeta( + id=row["id"], + guild_id=row["guild_id"], + host_user_id=row["host_user_id"], + discussion_mode=discussion_mode, + created_at_ms=_epoch_to_ms(row["created_at"]), + ended_at_ms=_epoch_to_ms(row["ended_at"]) if row["ended_at"] else None, + victory=_infer_victory(public_logs), + main_text_channel_id=row["main_text_channel_id"], + main_vc_channel_id=row["main_vc_channel_id"], + ) + + +def _build_arbiter_decision(row: dict[str, Any]) -> ArbiterDecisionEntry: + """Build one ArbiterDecisionEntry from the joined query row. + + Older games (pre-selection_reason migration) have NULL in both + ``selection_reason`` and ``public_state_snapshot_json``; we surface + them as ``None`` rather than back-fill heuristics so the viewer can + distinguish "we don't know why" from "we know it was X". + """ + raw_snapshot = row.get("public_state_snapshot_json") + snapshot: dict[str, Any] | None = None + if raw_snapshot: + try: + parsed = json.loads(raw_snapshot) + except json.JSONDecodeError: + log.warning( + "skipping malformed public_state_snapshot_json for request %s", + row.get("request_id"), + ) + parsed = None + if isinstance(parsed, dict): + snapshot = parsed + return ArbiterDecisionEntry( + request_id=row["request_id"], + phase_id=row["phase_id"], + npc_id=row["npc_id"], + seat_no=row["seat_no"], + suggested_intent=row["suggested_intent"], + selection_reason=row.get("selection_reason"), + public_state_snapshot=snapshot, + logic_packet_id=row["logic_packet_id"], + created_at_ms=row["created_at_ms"], + expires_at_ms=row["expires_at_ms"], + result_status=row.get("result_status"), + result_text=row.get("result_text"), + result_intent=row.get("result_intent"), + result_failure_reason=row.get("result_failure_reason"), + result_received_at_ms=row.get("result_received_at_ms"), + playback_outcome=row.get("playback_outcome"), + playback_failure_reason=row.get("playback_failure_reason"), + playback_finished_at_ms=row.get("playback_finished_at_ms"), + tts_outcome=row.get("tts_outcome"), + tts_duration_ms=row.get("tts_duration_ms"), + ) + + +def _build_suspicion(row: dict[str, Any]) -> SuspicionEntry: + """Map a `suspicions` DB row to the export shape.""" + return SuspicionEntry( + source=cast(Any, row["source"]), + event_id=row["event_id"], + seq=int(row["seq"]), + day=int(row["day"]), + phase=str(row["phase"]), + vote_round=row["vote_round"], + suspecter_seat=int(row["suspecter_seat"]), + target_seat=int(row["target_seat"]), + level=cast(Any, row["level"]), + reason=str(row["reason"]), + update_from_level=cast( + Any, row["update_from_level"] + ) if row["update_from_level"] is not None else None, + update_reason=row["update_reason"], + created_at_ms=int(row["created_at_ms"]), + ) + + +def _build_seat(row: dict[str, Any]) -> SeatExport: + role = cast(RoleKey, row["role"] or "VILLAGER") + death_cause: DeathCause | None = ( + cast(DeathCause, row["death_cause"]) if row["death_cause"] else None + ) + return SeatExport( + seat_no=row["seat_no"], + display_name=row["display_name"], + is_llm=bool(row["is_llm"]), + persona_key=row["persona_key"], + discord_user_id=row["discord_user_id"], + role=role, + alive=bool(row["alive"]), + death_cause=death_cause, + death_day=row["death_day"], + ) + + +_DAY_START_TEXT_PATTERN = re.compile(r"^(?:夜が明けました。)?(\d+) 日目の議論を開始") + + +def _retag_morning_logs_from_text( + public_logs: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Use the day number embedded in PHASE_CHANGE / MORNING text as the + source of truth for those logs' ``day`` field. + + Pre-2026-05-02 (commit ``cab2b92``), ``plan_night_resolve`` and + ``plan_night0`` emitted the next-day MORNING + "N 日目の議論を開始" + PHASE_CHANGE through ``_public_log`` which defaulted ``day = + game.day_number`` — i.e. the *prior* day at the moment of the + transition. So a NIGHT 1 → DAY 2 resolve wrote those logs with + ``day=1``. The viewer then bucketed day-2's morning into day-1's + DAY_DISCUSSION section, and the timeline read out of order. + + State machine now tags these logs explicitly with the next day, so + new games store correct values. But ~80 already-played games carry + the old offset in their DB rows. Reading the day number out of the + PHASE_CHANGE text ("2 日目の議論を開始") gives us a deterministic + fix that works for both eras: post-fix games are already aligned + (no-op), pre-fix games get rewritten on the fly without a DB + migration. + + MORNING rows ("朝になりました。犠牲者: 〜" / "平和な朝です") have + no day in their text. They are emitted in the same Transition as + the day-start PHASE_CHANGE and share its ``created_at`` epoch + second; we look up the sibling PHASE_CHANGE within the same + second and copy its corrected day. If no sibling is found (game + aborted mid-resolution before PHASE_CHANGE landed) the row is + passed through unchanged. + + The earlier ``_rebucket_morning_logs`` helper indiscriminately did + ``day += 1`` on every MORNING/PHASE_CHANGE row — that broke + post-fix games where the day was already correct. Trust the text, + not a blanket offset. + """ + # First pass: build a map of (created_at_epoch_s) → corrected_day, + # populated from PHASE_CHANGE rows whose text encodes the day. + corrected_day_by_ts: dict[int, int] = {} + for row in public_logs: + if row.get("phase") != "DAY_DISCUSSION" or row.get("kind") != "PHASE_CHANGE": + continue + text = row.get("text") + if not isinstance(text, str): + continue + m = _DAY_START_TEXT_PATTERN.match(text) + if m is None: + continue + corrected_day_by_ts[int(row["created_at"])] = int(m.group(1)) + + fixed: list[dict[str, Any]] = [] + for row in public_logs: + if row.get("phase") != "DAY_DISCUSSION": + fixed.append(row) + continue + kind = row.get("kind") + if kind == "PHASE_CHANGE": + text = row.get("text") + if isinstance(text, str): + m = _DAY_START_TEXT_PATTERN.match(text) + if m is not None: + expected = int(m.group(1)) + if row.get("day") != expected: + patched = dict(row) + patched["day"] = expected + fixed.append(patched) + continue + elif kind == "MORNING": + sibling_day = corrected_day_by_ts.get(int(row["created_at"])) + if sibling_day is not None and row.get("day") != sibling_day: + patched = dict(row) + patched["day"] = sibling_day + fixed.append(patched) + continue + fixed.append(row) + return fixed + + +def _build_phases( + public_logs: list[dict[str, Any]], + speech_events: list[dict[str, Any]], + votes: list[dict[str, Any]], + night_actions: list[dict[str, Any]], + wolf_chat_logs: list[dict[str, Any]], +) -> list[PhaseSection]: + """Group all per-game events into ordered ``(day, phase)`` buckets. + + A phase appears in the output if AT LEAST ONE of public_logs / + speech_events / votes / night_actions / wolf_chat_logs has data for + it. We don't invent empty buckets — a game that died in lobby + produces only a SETUP / LOBBY section, not a full ladder of empty + NIGHT/DAY rows. + """ + # Discover (day, phase) pairs in chronological order. Each bucket's + # ``first_ts_ms`` is the earliest timestamp of any event tagged for + # it — public_logs (epoch s), speech_events (ms), votes (s), + # night_actions (s), wolf_chat_logs (s). We track milliseconds so a + # phase that ends and the next phase that begins within the same + # epoch second still order correctly (only speech_events carry + # sub-second resolution today, but a per-(day, phase) tie still + # falls back to the secondary phase-ordering key below). + seen: dict[tuple[int, str], int] = {} # (day, phase) -> first_ts_ms + + def _record(key: tuple[int, str], ts_ms: int) -> None: + existing = seen.get(key) + if existing is None or ts_ms < existing: + seen[key] = ts_ms + + for row in public_logs: + _record((row["day"], row["phase"]), _epoch_to_ms(row["created_at"])) + for ev in speech_events: + _record((ev["day"], ev["phase"]), int(ev["created_at_ms"])) + for na in night_actions: + phase = "NIGHT_0" if na["day"] == 0 else "NIGHT" + _record((na["day"], phase), _epoch_to_ms(na["submitted_at"])) + for v in votes: + phase = "DAY_RUNOFF" if v["round"] >= 2 else "DAY_VOTE" + _record((v["day"], phase), _epoch_to_ms(v["submitted_at"])) + # Wolf chat surfaces phases that have no other events (rare: + # attack-less NIGHT where wolves coordinated but never committed). + for wc in wolf_chat_logs: + _record((wc["day"], wc["phase"]), _epoch_to_ms(wc["created_at"])) + + # Stable secondary sort: when two phases share a millisecond + # (typical at the NIGHT-resolve / next-day-MORNING boundary, where + # the night's last submission and the morning log share an epoch + # second), enforce within-day phase order so the timeline reads + # SETUP → DAY_DISCUSSION → DAY_VOTE → DAY_RUNOFF_SPEECH → + # DAY_RUNOFF → NIGHT → (next day) DAY_DISCUSSION → ... + phase_order_within_day = { + "SETUP": 0, + "NIGHT_0": 1, + "DAY_DISCUSSION": 2, + "DAY_VOTE": 3, + "DAY_RUNOFF_SPEECH": 4, + "DAY_RUNOFF": 5, + "NIGHT": 6, + "GAME_OVER": 7, + "WAITING_HOST_DECISION": 8, + } + + def _sort_key(item: tuple[tuple[int, str], int]) -> tuple[int, int, int]: + (day, phase), first_ts_ms = item + return (first_ts_ms, day, phase_order_within_day.get(phase, 99)) + + ordered = sorted(seen.items(), key=_sort_key) + result: list[PhaseSection] = [] + for (day, phase), first_ts_ms in ordered: + result.append( + PhaseSection( + day=day, + phase=phase, + started_at_ms=first_ts_ms, + public_logs=[ + PublicLogEntry( + kind=r["kind"], + actor_seat=r["actor_seat"], + text=r["text"], + created_at_ms=_epoch_to_ms(r["created_at"]), + ) + for r in public_logs + if r["day"] == day and r["phase"] == phase + ], + speech_events=[ + SpeechEventExport( + event_id=ev["event_id"], + source=cast(SpeechSource, ev["source"]), + speaker_seat=ev["speaker_seat"], + text=ev["text"], + stt_confidence=ev["stt_confidence"], + summary=ev["summary"], + co_declaration=ev["co_declaration"], + addressed_seat_no=ev["addressed_seat_no"], + claimed_seer_result=_build_claimed_seer(ev), + claimed_medium_result=_build_claimed_medium(ev), + created_at_ms=ev["created_at_ms"], + ) + for ev in speech_events + if ev["day"] == day and ev["phase"] == phase + ], + votes=[ + VoteExport( + day=v["day"], + round=v["round"], + voter_seat=v["voter_seat"], + target_seat=v["target_seat"], + submitted_at_ms=_epoch_to_ms(v["submitted_at"]), + ) + for v in votes + if v["day"] == day and _vote_phase(v["round"]) == phase + ], + night_actions=[ + NightActionExport( + day=na["day"], + actor_seat=na["actor_seat"], + kind=na["kind"], + target_seat=na["target_seat"], + submitted_at_ms=_epoch_to_ms(na["submitted_at"]), + ) + for na in night_actions + if na["day"] == day and _night_phase(na["day"]) == phase + ], + wolf_chat_logs=[ + WolfChatLogEntry( + actor_seat=wc["actor_seat"], + text=wc["text"], + created_at_ms=_epoch_to_ms(wc["created_at"]), + ) + for wc in wolf_chat_logs + if wc["day"] == day and wc["phase"] == phase + ], + ) + ) + return result + + +def _vote_phase(round_: int) -> str: + # Schema convention: round=0 is the regular vote, round=1 is the runoff. + # Anything ≥1 is treated as runoff so the exporter is forward-compatible + # if a future schema adds a second runoff round. + return "DAY_RUNOFF" if round_ >= 1 else "DAY_VOTE" + + +def _night_phase(day: int) -> str: + return "NIGHT_0" if day == 0 else "NIGHT" + + +def _infer_victory(public_logs: list[dict[str, Any]]) -> Victory | None: + for row in reversed(public_logs): + if row["kind"] != "VICTORY": + continue + text = row["text"] or "" + if "村人" in text or "村陣営" in text: + return "village" + if "人狼" in text or "狼陣営" in text: + return "wolf" + return None + + +def _build_claimed_seer(ev: dict[str, Any]) -> ClaimedSeerExport | None: + """Lift the persisted seer-claim columns into the export model. + + Requires both ``target_seat`` and ``is_wolf`` to be set. SQLite + stores the verdict as 0/1; the export coerces back to bool here. + """ + target = ev.get("claimed_seer_target_seat") + verdict = ev.get("claimed_seer_is_wolf") + if target is None or verdict is None: + return None + return ClaimedSeerExport(target_seat=int(target), is_wolf=bool(verdict)) + + +def _build_claimed_medium(ev: dict[str, Any]) -> ClaimedMediumExport | None: + """Lift the persisted medium-claim columns into the export model. + + Mirror of :func:`_build_claimed_seer` but ``is_wolf`` may be NULL + to encode "no execution yesterday → no result today" — preserved + as ``None`` so the viewer can render the void. + """ + target = ev.get("claimed_medium_target_seat") + if target is None: + return None + raw_verdict = ev.get("claimed_medium_is_wolf") + verdict = bool(raw_verdict) if raw_verdict is not None else None + return ClaimedMediumExport(target_seat=int(target), is_wolf=verdict) + + +def _build_claim_history( + speech_events: list[dict[str, Any]], + seat_lookup: dict[int, str], +) -> list[ClaimHistoryEntry]: + """Fold the per-event claim columns into a per-claimer ledger. + + Pure projection: every speech_events row already carries the + ``claimed_*`` columns straight from the DB; here we group them by + speaker_seat and sort each group chronologically. The result is + keyed implicitly by ``claimer_seat`` (each entry carries the seat + number) and sorted ascending so the viewer renders a stable list. + """ + seer_by_seat: dict[int, list[ClaimedSeerHistoryEntry]] = {} + medium_by_seat: dict[int, list[ClaimedMediumHistoryEntry]] = {} + + def _name(seat: int) -> str: + return seat_lookup.get(seat) or f"席{seat}" + + for ev in speech_events: + speaker = ev.get("speaker_seat") + if speaker is None: + continue + speaker_seat = int(speaker) + seer_target = ev.get("claimed_seer_target_seat") + seer_verdict = ev.get("claimed_seer_is_wolf") + if seer_target is not None and seer_verdict is not None: + seer_by_seat.setdefault(speaker_seat, []).append( + ClaimedSeerHistoryEntry( + day=int(ev["day"]), + target_seat=int(seer_target), + target_name=_name(int(seer_target)), + is_wolf=bool(seer_verdict), + declared_at_event_id=ev["event_id"], + ) + ) + medium_target = ev.get("claimed_medium_target_seat") + if medium_target is not None: + raw_verdict = ev.get("claimed_medium_is_wolf") + verdict = bool(raw_verdict) if raw_verdict is not None else None + medium_by_seat.setdefault(speaker_seat, []).append( + ClaimedMediumHistoryEntry( + day=int(ev["day"]), + target_seat=int(medium_target), + target_name=_name(int(medium_target)), + is_wolf=verdict, + declared_at_event_id=ev["event_id"], + ) + ) + + seats = sorted(set(seer_by_seat) | set(medium_by_seat)) + return [ + ClaimHistoryEntry( + claimer_seat=seat, + seer_claims=seer_by_seat.get(seat, []), + medium_claims=medium_by_seat.get(seat, []), + ) + for seat in seats + ] + + +def _epoch_to_ms(epoch_seconds: int | None) -> int: + """Promote an epoch-second timestamp into the viewer's ms unit. + + DB columns ``created_at`` / ``submitted_at`` are seconds; the viewer + schema is uniform milliseconds. Returns 0 for None to keep the JSON + well-typed (callers usually pass non-null ts at this point). + """ + if epoch_seconds is None: + return 0 + return int(epoch_seconds) * 1000 + + +# ---------------------------------------------------------------- trace loader +def _load_trace(trace_root: Path, game_id: str) -> list[TraceEntry]: + """Walk ``logs/llm_calls/{game_id}/*.jsonl`` and inline every entry. + + Missing dir = empty list (game ran with trace disabled, or pre-trace + games). One bad line is logged and skipped — not fatal. A schema- + violating line (missing required field, wrong type) is also logged + and skipped rather than failing the whole export. + + Backfill: pre-fix voice_stt / NPC trace lines were written with + ``day=null`` (the call sites passed only ``phase=phase_id`` to + ``trace_context``). The viewer's ``matchTraceForSpeech`` requires + ``t.day === phase.day`` to attach a trace to a speech event, so + null-day lines never surfaced the LLM prompt UI. We recover the day + here by parsing ``dayN`` out of the canonical phase_id format + ``"{gid}::dayN::PHASE::seq"`` whenever ``day`` is missing. + """ + from wolfbot.services.llm_trace import parse_day_from_phase_id + + game_dir = trace_root / game_id + if not game_dir.is_dir(): + return [] + entries: list[TraceEntry] = [] + for jsonl_path in sorted(game_dir.glob("*.jsonl")): + stem = jsonl_path.stem # gameplay / npc_setsu / voice_stt / ... + try: + for raw_line in jsonl_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line: + continue + try: + obj = json.loads(line) + except json.JSONDecodeError: + log.warning("skipping malformed trace line in %s", jsonl_path) + continue + obj.setdefault("file_stem", stem) + if obj.get("day") is None: + recovered = parse_day_from_phase_id(obj.get("phase")) + if recovered is not None: + obj["day"] = recovered + try: + entries.append(TraceEntry.model_validate(obj)) + except ValidationError: + log.exception( + "skipping schema-violating trace line in %s", + jsonl_path, + ) + except OSError: + log.exception("could not read trace file %s", jsonl_path) + # Sort by ts when present so the flat list reads chronologically. + entries.sort(key=lambda e: e.ts or "") + return entries + + +__all__ = ["export_game"] diff --git a/src/wolfbot/services/game_export_types.py b/src/wolfbot/services/game_export_types.py new file mode 100644 index 0000000..5008168 --- /dev/null +++ b/src/wolfbot/services/game_export_types.py @@ -0,0 +1,378 @@ +"""Pydantic models defining the canonical viewer export schema. + +This module is the single source of truth for the per-game export shape. +It is mirrored in ``viewer/src/lib/types.ts`` (TypeScript types) and +``viewer/sample-data/export-schema.json`` (JSON Schema, regenerated from +:class:`GameExport` via ``scripts/dump-export-schema.py``). + +Contract: + +* :func:`wolfbot.services.game_export.export_game` constructs and writes + a :class:`GameExport` — never a bare ``dict[str, Any]``. +* The viewer validates each loaded JSON against ``export-schema.json`` + at test time; any drift fails the viewer's contract test. +* A drift test on the Python side + (:mod:`tests.test_game_export_integration`) asserts the committed + schema file matches what :meth:`GameExport.model_json_schema` emits. + +Time fields exposed to the viewer are uniformly milliseconds since epoch. +The DB stores ``created_at`` / ``submitted_at`` in seconds; the exporter +multiplies by 1000 at the boundary, before it builds these models. +""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + +from wolfbot.domain.enums import CoDeclaration as _CoDeclaration + +# A frozen, closed shape — extra fields cause validation errors so a +# typo or a stale field caught here rather than silently dropped. +_StrictConfig = ConfigDict(frozen=True, extra="forbid") +# Trace entries come from JSONL files written by `log_llm_call`. Future +# versions may add metadata keys; allow extras so older viewers don't +# refuse to parse newer traces. Required keys are still validated. +_TraceConfig = ConfigDict(frozen=True, extra="allow") + + +RoleKey = Literal["VILLAGER", "WEREWOLF", "MADMAN", "SEER", "MEDIUM", "KNIGHT"] +DeathCause = Literal["EXECUTION", "ATTACK"] +DiscussionMode = Literal["rounds", "reactive_voice"] +# Mirrors the production `wolfbot.domain.discussion.SpeechSource` minus +# the internal `phase_baseline` sentinel (filtered out by the exporter — +# it's a private state-rebuild marker, not a viewable utterance). +SpeechSource = Literal["text", "voice_stt", "npc_generated"] +# Re-export the canonical wire/storage form from domain/enums so the +# viewer schema stays aligned with runtime validators in lockstep. +CoDeclaration = _CoDeclaration +TraceRole = Literal[ + "gameplay", + "npc_speech", + "npc_decision", + "voice_stt", + "text_analysis", +] +Victory = Literal["village", "wolf"] + + +class GameMeta(BaseModel): + model_config = _StrictConfig + + id: str + guild_id: str + host_user_id: str + discussion_mode: DiscussionMode + created_at_ms: int + ended_at_ms: int | None + victory: Victory | None + main_text_channel_id: str + main_vc_channel_id: str + + +class SeatExport(BaseModel): + model_config = _StrictConfig + + seat_no: int + display_name: str + is_llm: bool + persona_key: str | None + discord_user_id: str | None + role: RoleKey + alive: bool + death_cause: DeathCause | None + death_day: int | None + + +class PublicLogEntry(BaseModel): + model_config = _StrictConfig + + kind: str + actor_seat: int | None + text: str + created_at_ms: int + + +class ClaimedSeerExport(BaseModel): + """Structured seer-CO result attached to a single SpeechEvent. + + Real seers and fake-CO wolves share the same shape; the viewer + renders the per-seat history on top of these and the configured + seat roles, so a wolf seat showing 4 seer claims while the day + counter is at 2 is visibly suspicious.""" + + model_config = _StrictConfig + + target_seat: int + is_wolf: bool + + +class ClaimedMediumExport(BaseModel): + """Structured medium-CO result attached to a single SpeechEvent. + + ``is_wolf=null`` encodes the explicit "no execution yesterday → + no result today" case so the viewer can render it as a void + rather than fabricating a target color.""" + + model_config = _StrictConfig + + target_seat: int + is_wolf: bool | None + + +class SpeechEventExport(BaseModel): + model_config = _StrictConfig + + event_id: str + source: SpeechSource + speaker_seat: int | None + text: str + stt_confidence: float | None + summary: str | None + co_declaration: CoDeclaration | None + addressed_seat_no: int | None + claimed_seer_result: ClaimedSeerExport | None = None + claimed_medium_result: ClaimedMediumExport | None = None + created_at_ms: int + + +class ClaimedSeerHistoryEntry(BaseModel): + model_config = _StrictConfig + + day: int + target_seat: int + target_name: str + is_wolf: bool + declared_at_event_id: str + + +class ClaimedMediumHistoryEntry(BaseModel): + model_config = _StrictConfig + + day: int + target_seat: int + target_name: str + is_wolf: bool | None + declared_at_event_id: str + + +class ClaimHistoryEntry(BaseModel): + """Per-seat fold of every seer/medium claim a single seat has + declared, surfaced at the top level of the export so the viewer + can render a per-claimer history table without re-folding the + speech-event stream.""" + + model_config = _StrictConfig + + claimer_seat: int + seer_claims: list[ClaimedSeerHistoryEntry] + medium_claims: list[ClaimedMediumHistoryEntry] + + +class VoteExport(BaseModel): + model_config = _StrictConfig + + day: int + round: int + voter_seat: int + target_seat: int | None + submitted_at_ms: int + + +class NightActionExport(BaseModel): + model_config = _StrictConfig + + day: int + actor_seat: int + kind: str + target_seat: int | None + submitted_at_ms: int + + +class WolfChatLogEntry(BaseModel): + """One wolf-only night-coordination utterance. + + Sourced from the ``logs_private`` table where ``visibility='PRIVATE'`` + and ``kind='WOLF_CHAT'``. The DB row is duplicated per audience-seat + (one for each living wolf) so the exporter dedupes on + ``(actor_seat, created_at_ms, text)`` and emits a single entry per + utterance — the audience side is implicit (= every alive wolf at the + time of the utterance) and not surfaced in the viewer because the + wolves channel is wolf-private by construction. + """ + + model_config = _StrictConfig + + actor_seat: int + text: str + created_at_ms: int + + +class PhaseSection(BaseModel): + model_config = _StrictConfig + + day: int + phase: str + started_at_ms: int + public_logs: list[PublicLogEntry] + speech_events: list[SpeechEventExport] + votes: list[VoteExport] + night_actions: list[NightActionExport] + # Night-only: each wolf's coordination line during NIGHT. Empty for + # day-side phases. Kept on the phase rather than top-level so the + # viewer renders them inline in the night's timeline next to wolf + # attack night_actions. + wolf_chat_logs: list[WolfChatLogEntry] = [] + + +class TokenUsage(BaseModel): + model_config = _StrictConfig + + prompt: int | None + completion: int | None + total: int | None + + +class TraceEntry(BaseModel): + """One JSONL trace row — see :mod:`wolfbot.services.llm_trace`. + + ``extra="allow"`` because trace metadata is intentionally open: the + Master / NPC paths may attach arbitrary debug fields that the viewer + just renders verbatim. + """ + + model_config = _TraceConfig + + ts: str + role: TraceRole + provider: str + model: str + phase: str | None + day: int | None + actor: str | None + system_prompt: str | None + user_prompt: str | None + response: str | None + latency_ms: int + tokens: TokenUsage | None + error: str | None + metadata: dict[str, Any] | None = None + file_stem: str | None = None + + +class ArbiterDecisionEntry(BaseModel): + """One Master-side `SpeakRequest` dispatch — the "why this NPC, why now" + breadcrumb the viewer surfaces alongside the resulting NPC speech. + + Joined from three persistence rows keyed by ``request_id``: + + * ``npc_speak_requests`` — the dispatch itself (Master → NPC) + * ``npc_speak_results`` — NPC's reply (accepted / rejected / failed) + * ``npc_playback_events`` — TTS + Discord playback outcome + + Any of the three may be missing for an in-flight or interrupted + request; only ``request_id`` / ``phase_id`` / ``npc_id`` / ``seat_no`` + / ``created_at_ms`` are guaranteed. + """ + + model_config = _StrictConfig + + request_id: str + phase_id: str + npc_id: str + seat_no: int + suggested_intent: str + selection_reason: str | None + public_state_snapshot: dict[str, Any] | None + logic_packet_id: str + created_at_ms: int + expires_at_ms: int + + # speak_results join (None if NPC never replied / TTL expired) + result_status: str | None = None + result_text: str | None = None + result_intent: str | None = None + result_failure_reason: str | None = None + result_received_at_ms: int | None = None + + # playback_events join (None if request was rejected before TTS) + playback_outcome: str | None = None + playback_failure_reason: str | None = None + playback_finished_at_ms: int | None = None + tts_outcome: str | None = None + tts_duration_ms: int | None = None + + +class SuspicionEntry(BaseModel): + """One row of the public suspicion timeline. + + Mirrors the ``suspicions`` DB row 1:1 so the viewer can both render + the timeline (chronological feed) and pivot it into a (suspecter, + target) → level matrix. + """ + + model_config = _StrictConfig + + source: Literal["speech", "vote"] + event_id: str | None = None + seq: int + day: int + phase: str + vote_round: int | None = None + suspecter_seat: int + target_seat: int + level: Literal["trust", "low", "medium", "high"] + reason: str + update_from_level: Literal["trust", "low", "medium", "high"] | None = None + update_reason: str | None = None + created_at_ms: int + + +class GameExport(BaseModel): + """Top-level shape of one ``viewer/games/{id}.json`` file.""" + + model_config = _StrictConfig + + game: GameMeta + seats: list[SeatExport] + phases: list[PhaseSection] + trace: list[TraceEntry] + arbiter_decisions: list[ArbiterDecisionEntry] = [] + # Game-wide divination/medium claim history, keyed implicitly by + # claimer seat (each entry carries ``claimer_seat``). Pre-folded by + # the exporter so the viewer renders a "claim ledger" panel without + # walking every phase's speech_events. + claim_history: list[ClaimHistoryEntry] = [] + # Public suspicion timeline (speech + vote derived). Chronological + # order. Empty for older exports / games before the structured + # suspicion feature shipped. + suspicions: list[SuspicionEntry] = [] + + +__all__ = [ + "ArbiterDecisionEntry", + "ClaimHistoryEntry", + "ClaimedMediumExport", + "ClaimedMediumHistoryEntry", + "ClaimedSeerExport", + "ClaimedSeerHistoryEntry", + "CoDeclaration", + "DeathCause", + "DiscussionMode", + "GameExport", + "GameMeta", + "NightActionExport", + "PhaseSection", + "PublicLogEntry", + "RoleKey", + "SeatExport", + "SpeechEventExport", + "SpeechSource", + "TokenUsage", + "TraceEntry", + "TraceRole", + "Victory", + "VoteExport", + "WolfChatLogEntry", +] diff --git a/src/wolfbot/services/game_service.py b/src/wolfbot/services/game_service.py index dcceafc..7e8c7be 100644 --- a/src/wolfbot/services/game_service.py +++ b/src/wolfbot/services/game_service.py @@ -15,7 +15,7 @@ import logging import time import uuid -from collections.abc import Callable, Sequence +from collections.abc import Awaitable, Callable, Sequence from random import Random from typing import Protocol, runtime_checkable @@ -143,6 +143,10 @@ def __init__( wake: WakeSink, clock: Callable[[], int] = lambda: int(time.time()), rng: Random | None = None, + on_reactive_phase_enter: Callable[[str], Awaitable[None]] | None = None, + on_reactive_game_end: Callable[[str], Awaitable[None]] | None = None, + on_game_end_finalize: Callable[[str], Awaitable[None]] | None = None, + phase_d_state_pusher: object | None = None, ) -> None: self.repo = repo self.discord = discord @@ -151,6 +155,28 @@ def __init__( self.clock = clock self.rng = rng or Random() self._advance_locks: dict[str, asyncio.Lock] = {} + self._on_reactive_phase_enter = on_reactive_phase_enter + # Symmetric to `on_reactive_phase_enter`: invoked at natural end and + # host abort so reactive_voice plumbing (NPC seat assignments, VC + # joins) can be released. + self._on_reactive_game_end = on_reactive_game_end + # Fires once per game-end (natural victory OR host abort), AFTER + # ``repo.end_game`` has committed, so the hook sees the row in its + # final ended_at state. Used to trigger the viewer JSON export + # without blocking gameplay — exceptions are logged + swallowed. + self._on_game_end_finalize = on_game_end_finalize + # Phase-D: optional state pusher that fans out PrivateStateUpdate + # messages to NPC bots after each apply_transition. Constructed + # in main.py only when reactive_voice is enabled; rounds-mode + # never sets it so the existing path is byte-for-byte unchanged. + self._phase_d_state_pusher = phase_d_state_pusher + + def set_phase_d_state_pusher(self, pusher: object) -> None: + """Late-bind the Phase-D pusher. main.py constructs the pusher + only after the NPC registry exists (inside the reactive_voice + wiring block), which lands after GameService itself is built; + this setter breaks the order dependency.""" + self._phase_d_state_pusher = pusher def _lock_for(self, game_id: str) -> asyncio.Lock: lock = self._advance_locks.get(game_id) @@ -238,6 +264,25 @@ async def _advance_once(self, game_id: str) -> None: else: await self._safe_post_public(new_game, entry.text, entry.kind) + # Phase-D: fan out PrivateStateUpdate messages to NPC bots so each + # seat's in-memory mirror absorbs the seer/medium/guard results, + # alive-set changes, and day_advanced ticks before the next + # decision request fires. No-op for rounds-mode games (the pusher + # short-circuits on `discussion_mode != "reactive_voice"`). + pusher = self._phase_d_state_pusher + if pusher is not None: + push_after_advance = getattr(pusher, "push_after_advance", None) + if push_after_advance is not None: + try: + await push_after_advance( + game=new_game, + prev_phase=previous_phase, + private_logs=transition.private_logs, + public_logs=transition.public_logs, + ) + except Exception: + log.exception("phase_d_state_push_failed game=%s", game_id) + # 4. Announce WAITING status if we paused. if transition.requires_host_decision and transition.pending is not None: try: @@ -245,6 +290,10 @@ async def _advance_once(self, game_id: str) -> None: except Exception: log.exception("waiting announce failed for %s", game_id) + # 4.5. Emit discussion_phase_summary when leaving a public-speech phase. + if previous_phase in (Phase.DAY_DISCUSSION, Phase.DAY_RUNOFF_SPEECH): + await self._emit_discussion_phase_summary(game, previous_phase) + # 5. On entering DAY_VOTE / DAY_RUNOFF / NIGHT / DAY_DISCUSSION / # DAY_RUNOFF_SPEECH, kick off DMs and LLM tasks. await self._dispatch_submissions(new_game, players, seats, transition, previous_phase) @@ -255,7 +304,15 @@ async def _advance_once(self, game_id: str) -> None: await self.discord.on_game_end(new_game, seats) except Exception: log.exception("on_game_end failed for %s", game_id) + if self._on_reactive_game_end is not None: + try: + await self._on_reactive_game_end(game_id) + except Exception: + log.exception( + "on_reactive_game_end failed for %s", game_id + ) await self.repo.end_game(game_id, ended_at_epoch=self.clock()) + await self._run_finalize_hook(game_id) # 7. Wake the engine so it reschedules on the new deadline. self.wake.wake(game_id) @@ -276,6 +333,14 @@ async def _plan_next( # alive LLM seat has completed both speech rounds. Otherwise either # park (no-op return None — engine sleeps to deadline) or commit a # short same-phase grace transition. + # + # Under reactive_voice mode there are no fixed rounds; LLMs speak + # event-driven via SpeakArbiter, so the deadline is the sole gate. + deadline_passed = game.deadline_epoch is not None and now >= game.deadline_epoch + if game.discussion_mode == "reactive_voice": + if deadline_passed: + return plan_day_discussion_to_vote(game, now) + return None seats_by_no = {s.seat_no: s for s in seats} alive_llm_seats = [ p.seat_no @@ -290,7 +355,6 @@ async def _plan_next( if progress[3] < 2: # discussion_rounds_done rounds_done = False break - deadline_passed = game.deadline_epoch is not None and now >= game.deadline_epoch if deadline_passed and rounds_done: return plan_day_discussion_to_vote(game, now) if deadline_passed and not rounds_done: @@ -311,11 +375,16 @@ async def _plan_next( alive_set = {p.seat_no for p in players if p.alive} tied = compute_vote_result(round0, alive_set).tied seats_by_no = {s.seat_no: s for s in seats} - tied_llm_seats = [ - sn for sn in tied if seats_by_no.get(sn) is not None and seats_by_no[sn].is_llm - ] + # All tied candidates (LLM + human) must complete their speech + # before the runoff vote opens. Humans set `runoff_speech_done` + # via the speech-recording paths (voice ingest → SpeakArbiter + # auto-mark, text on_message → same path, or the human grace + # watchdog on timeout); LLMs flip it on PlaybackFinished / + # TtsFailed. The flag is uniform across speaker types so this + # check doesn't need to branch. + tied_seats = [sn for sn in tied if seats_by_no.get(sn) is not None] speeches_done = True - for sn in tied_llm_seats: + for sn in tied_seats: progress = await self.repo.load_llm_speech_progress(game.id, game.day_number, sn) if not progress[4]: # runoff_speech_done speeches_done = False @@ -354,6 +423,7 @@ async def _plan_next( prev_seat, game.force_skip_pending, now, + rng=self.rng, ) return None @@ -387,6 +457,25 @@ async def _dispatch_submissions( # Same-phase grace re-commit must not redispatch. if previous_phase is Phase.DAY_RUNOFF_SPEECH: return + # In reactive_voice mode the rounds-mode batch (Master gameplay + # LLM → Discord text post) skips the entire NPC-bot TTS path, + # so candidates' final speeches end up as silent text. Route + # through the reactive phase-enter callback instead — it joins + # VC, seeds the phase baseline, assigns NPCs to seats, and + # kicks SpeakArbiter, which now picks tied candidates one at a + # time and dispatches via the NPC bot pipeline (with VOICEVOX + # playback). `runoff_speech_done` is marked by the arbiter as + # each candidate's SpeakResult or failure path resolves. + if new_game.discussion_mode == "reactive_voice": + if self._on_reactive_phase_enter is not None: + try: + await self._on_reactive_phase_enter(new_game.id) + except Exception: + log.exception( + "reactive_voice runoff phase enter failed for %s", + new_game.id, + ) + return round0 = await self.repo.load_votes(new_game.id, day=new_game.day_number, round_=0) alive_set = {p.seat_no for p in players_after if p.alive} tied = list(compute_vote_result(round0, alive_set).tied) @@ -435,11 +524,59 @@ async def _dispatch_submissions( if previous_phase is Phase.DAY_DISCUSSION: return alive_players = [p for p in players_after if p.alive] + # Mode-fixed dispatch: under reactive_voice we skip the rounds-mode + # LLM batch entirely. Reactive speech is driven event-by-event by + # SpeakArbiter via the WS server, not by the timer-driven engine. + if new_game.discussion_mode == "reactive_voice": + log.info( + "reactive_voice_phase_entered game=%s day=%d", + new_game.id, + new_game.day_number, + ) + if self._on_reactive_phase_enter is not None: + try: + await self._on_reactive_phase_enter(new_game.id) + except Exception: + log.exception( + "reactive_phase_enter callback failed for %s", new_game.id + ) + return try: await self.llm.submit_llm_discussion_rounds(new_game, alive_players, seats) except Exception: log.exception("llm discussion rounds dispatch failed for %s", new_game.id) + async def _emit_discussion_phase_summary(self, game: Game, phase: Phase) -> None: + """Emit the discussion_phase_summary structured-log event. + + Called when transitioning away from DAY_DISCUSSION or DAY_RUNOFF_SPEECH. + Best-effort: failures are logged but do not block the advance. + """ + try: + from wolfbot.domain.discussion import make_phase_id + from wolfbot.services.discussion_phase_summary import emit_phase_summary + from wolfbot.services.discussion_service import DiscussionService + + # Access the DiscussionService and repo through the LLM adapter + # which holds a reference. The summary emitter needs both. + ds: DiscussionService | None = getattr(self.llm, "discussion_service", None) + if ds is None: + return + phase_id = make_phase_id(game.id, game.day_number, phase) + await emit_phase_summary( + repo=self.repo, + discussion=ds, + game_id=game.id, + phase_id=phase_id, + mode=game.discussion_mode, + ) + except Exception: + log.exception( + "discussion_phase_summary emission failed for game=%s phase=%s", + game.id, + phase.value, + ) + async def _safe_post_public(self, game: Game, text: str, kind: str) -> None: try: await self.discord.post_public(game, text, kind) @@ -748,6 +885,12 @@ async def resume_llm_speech_progress(self, game_id: str) -> None: game = await self.repo.load_game(game_id) if game is None: return + # Under reactive_voice, LLM speech is driven event-by-event by the + # SpeakArbiter via the WS server — there are no fixed rounds to + # resume. Skip entirely so we don't spawn a legacy two-round batch + # that would violate the per-game mode contract. + if game.discussion_mode == "reactive_voice": + return seats = await self.repo.load_seats(game_id) seats_by_no = {s.seat_no: s for s in seats} players = await self.repo.load_players(game_id) @@ -923,10 +1066,31 @@ async def host_abort(self, game_id: str) -> bool: await self.discord.on_game_end(game, seats) except Exception: log.exception("on_game_end failed during abort %s", game_id) + if self._on_reactive_game_end is not None: + try: + await self._on_reactive_game_end(game_id) + except Exception: + log.exception( + "on_reactive_game_end failed during abort %s", game_id + ) await self.repo.end_game(game_id, ended_at_epoch=self.clock()) + await self._run_finalize_hook(game_id) self.wake.wake(game_id) return True + async def _run_finalize_hook(self, game_id: str) -> None: + """Fire the post-game-end finalize hook (export, archival, etc). + + Errors are logged and swallowed — finalization MUST NOT prevent + the engine from cleaning up the ended game. + """ + if self._on_game_end_finalize is None: + return + try: + await self._on_game_end_finalize(game_id) + except Exception: + log.exception("on_game_end_finalize failed for %s", game_id) + def new_game_id() -> str: return uuid.uuid4().hex[:12] diff --git a/src/wolfbot/services/llm_service.py b/src/wolfbot/services/llm_service.py index 0ce36b1..bff22ce 100644 --- a/src/wolfbot/services/llm_service.py +++ b/src/wolfbot/services/llm_service.py @@ -12,7 +12,12 @@ thinking_level. Authenticates via ADC/IAM (no API key); Vertex AI Express mode is deliberately unsupported. - `FakeLLMActionDecider`: deterministic stub for tests/offline dry runs. -- `make_llm_decider(settings)`: provider-aware factory; branches on `LLM_PROVIDER`. +- `make_llm_decider(cfg)`: provider-aware factory; branches on + `LLMDeciderConfig.provider`. ``cfg`` is built by + ``MasterSettings.gameplay_decider_config()`` from ``GAMEPLAY_LLM_*`` + env vars (the symmetrical NPC factory in + :mod:`wolfbot.npc.speech.generator_factory` consumes the same config dataclass + built from ``NPC_LLM_*`` instead). - `LLMAdapter`: implements the LLMAdapter Protocol consumed by game_service; iterates LLM seats and submits their actions via GameService.submit_*. """ @@ -26,7 +31,7 @@ from collections.abc import Callable, Sequence from typing import TYPE_CHECKING, Literal, Protocol -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from tenacity import ( retry, retry_if_exception_type, @@ -34,15 +39,23 @@ wait_exponential, ) -from wolfbot.domain.enums import Phase, Role, SubmissionType -from wolfbot.domain.models import Game, LogEntry, Player, Seat +from wolfbot.domain.discussion import CoClaim, make_phase_id +from wolfbot.domain.enums import ( + CO_CLAIM_VALUES, + CoDeclaration, + Phase, + Role, + SubmissionType, +) +from wolfbot.domain.models import Game, LogEntry, Player, Seat, Vote from wolfbot.domain.rules import ( legal_attack_targets, legal_divine_targets, legal_guard_targets, previous_guard_seat_for_night, ) -from wolfbot.llm.personas import PERSONAS_BY_KEY +from wolfbot.domain.suspicion import Suspicion +from wolfbot.llm.decider_config import LLMDeciderConfig from wolfbot.llm.prompt_builder import ( build_system_prompt, build_user_context, @@ -51,12 +64,21 @@ task_vote, task_wolf_chat, ) +from wolfbot.llm.template import render_template +from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY +from wolfbot.services.llm_trace import ( + CallTimer, + extract_gemini_vertex_tokens, + extract_openai_tokens, + log_llm_call, + trace_context, +) if TYPE_CHECKING: # avoid importing heavy modules unless needed from openai import AsyncOpenAI - from wolfbot.config import Settings from wolfbot.persistence.sqlite_repo import SqliteRepo + from wolfbot.services.discussion_service import DiscussionService from wolfbot.services.game_service import GameService # Sleep ranges between consecutive LLM speeches inside DAY_DISCUSSION rounds. @@ -75,6 +97,19 @@ async def post_wolves_chat(self, game: Game, text: str, kind: str) -> None: ... log = logging.getLogger(__name__) +def _classify_task_text(task_text: str) -> str: + """Coarse tag of which `task_*` produced this prompt — for trace metadata only.""" + if "投票先として合法な候補は" in task_text: + return "vote" + if "対象を 1 名選んでください" in task_text: + return "night_action" + if "人狼チャット" in task_text: + return "wolf_chat" + if "議論フェイズ" in task_text or "演説" in task_text: + return "discussion" + return "unknown" + + def seat_token(seat: Seat) -> str: """Stable LLM/UI identifier: `席{N} {display_name}`. @@ -88,6 +123,20 @@ def seat_token(seat: Seat) -> str: # ---------------------------------------------------------------- LLMAction +class _ClaimedSeerAction(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + target_seat: int = Field(ge=1, le=9) + is_wolf: bool + + +class _ClaimedMediumAction(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + target_seat: int = Field(ge=1, le=9) + is_wolf: bool | None = None + + class LLMAction(BaseModel): model_config = ConfigDict(frozen=True) @@ -96,6 +145,20 @@ class LLMAction(BaseModel): target_name: str | None = None reason_summary: str = "" confidence: float = 0.5 + co_declaration: CoDeclaration | None = None + # Structured seer/medium claim for the rounds-mode discussion path. + # Mirrors the reactive_voice NPC speech schema so the per-seat + # claim history is built from a single source-of-truth shape across + # both modes. Optional / null when the utterance does not announce + # a new divination outcome. + claimed_seer_result: _ClaimedSeerAction | None = None + claimed_medium_result: _ClaimedMediumAction | None = None + # Structured suspicion records this utterance asserts. Mirrors the + # reactive_voice NPC speech schema so the public suspicion timeline + # is built from one source-of-truth shape across both modes. Empty + # tuple is allowed; the speech-task prompt's 名指し義務 rule asks + # for ≥1 entry per intent=speak. + suspicions: tuple[Suspicion, ...] = () RESPONSE_SCHEMA: dict[str, object] = { @@ -110,6 +173,10 @@ class LLMAction(BaseModel): "target_name", "reason_summary", "confidence", + "co_declaration", + "claimed_seer_result", + "claimed_medium_result", + "suspicions", ], "properties": { "intent": { @@ -120,6 +187,77 @@ class LLMAction(BaseModel): "target_name": {"type": ["string", "null"]}, "reason_summary": {"type": "string", "maxLength": 200}, "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "co_declaration": { + "type": ["string", "null"], + "enum": [*CO_CLAIM_VALUES, None], + }, + "claimed_seer_result": { + "type": ["object", "null"], + "additionalProperties": False, + "required": ["target_seat", "is_wolf"], + "properties": { + "target_seat": { + "type": "integer", + "minimum": 1, + "maximum": 9, + }, + "is_wolf": {"type": "boolean"}, + }, + }, + "claimed_medium_result": { + "type": ["object", "null"], + "additionalProperties": False, + "required": ["target_seat", "is_wolf"], + "properties": { + "target_seat": { + "type": "integer", + "minimum": 1, + "maximum": 9, + }, + "is_wolf": {"type": ["boolean", "null"]}, + }, + }, + "suspicions": { + "type": "array", + "description": ( + "Structured suspicion records this utterance asserts. " + "Each entry says 「自分は target_seat を level (理由 reason) " + "で見ている」. Master persists them keyed on event_id " + "and folds the immutable history into every subsequent " + "prompt so a silent reversal is detectable." + ), + "items": { + "type": "object", + "additionalProperties": False, + "required": [ + "target_seat", + "level", + "reason", + "update_from_level", + "update_reason", + ], + "properties": { + "target_seat": { + "type": "integer", + "minimum": 1, + "maximum": 9, + }, + "level": { + "type": "string", + "enum": ["trust", "low", "medium", "high"], + }, + "reason": {"type": "string", "maxLength": 500}, + "update_from_level": { + "type": ["string", "null"], + "enum": ["trust", "low", "medium", "high", None], + }, + "update_reason": { + "type": ["string", "null"], + "maxLength": 500, + }, + }, + }, + }, }, }, } @@ -129,27 +267,31 @@ class LLMAction(BaseModel): # "json" and works best with a concrete example. We append this contract to the # system prompt for every DeepSeek decision; the contract complements (does not # replace) the markdown template loaded by prompt_builder. xAI uses json_schema -# strict mode and does not need this. Module-level so tests can assert on -# substrings without instantiating AsyncOpenAI. -_DEEPSEEK_JSON_CONTRACT_SUFFIX = """\ - ---- -出力形式 (json): -必ず次のキーを持つ JSON オブジェクトのみを返してください。前後にテキストや markdown コードフェンスを付けないでください。 -- "intent": "speak" | "vote" | "night_action" | "skip" -- "public_message": string (最大 400 文字) -- "target_name": string または null -- "reason_summary": string (最大 200 文字) -- "confidence": number (0 から 1) - -例: -{"intent": "speak", "public_message": "私は占い師です。", "target_name": null, "reason_summary": "CO 表明", "confidence": 0.7} -""" +# strict mode and does not need this. +# +# Body lives in `prompts/templates/master/deepseek_contract_gameplay.md` so the +# schema example sits alongside other prompt templates and is editable without +# a Python diff. Loaded once via the lru-cache in `wolfbot.llm.template`. +_DEEPSEEK_GAMEPLAY_CONTRACT_TEMPLATE = "master/deepseek_contract_gameplay" def _deepseek_json_contract(system_prompt: str) -> str: """Append DeepSeek's JSON-mode contract to a system prompt.""" - return system_prompt + _DEEPSEEK_JSON_CONTRACT_SUFFIX + return system_prompt + render_template(_DEEPSEEK_GAMEPLAY_CONTRACT_TEMPLATE) + + +def _claim_to_fields( + claim: _ClaimedSeerAction | _ClaimedMediumAction | None, +) -> tuple[int | None, bool | None]: + """Project the structured claim model onto the persistence pair. + + Returns ``(target_seat, is_wolf)`` so the caller can pass each field + into ``_emit_npc_speech_event`` without having to teach that helper + about the wire model. ``(None, None)`` for missing claims. + """ + if claim is None: + return (None, None) + return (claim.target_seat, claim.is_wolf) # ---------------------------------------------------------- low-level deciders @@ -172,22 +314,43 @@ def __init__(self, client: AsyncOpenAI, model: str, timeout: float = 30.0) -> No reraise=True, ) async def decide(self, system_prompt: str, user_context: str) -> LLMAction: - # xAI model IDs aren't in the openai SDK's Literal, hence the ignore. - resp = await self.client.chat.completions.create( # type: ignore[call-overload] - model=self.model, - messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_context}, - ], - response_format={ - "type": "json_schema", - "json_schema": RESPONSE_SCHEMA, - }, - timeout=self.timeout, - ) - message = resp.choices[0].message - content = message.content or "{}" - return LLMAction.model_validate_json(content) + timer = CallTimer() + content = "" + err: str | None = None + tokens: dict[str, int | None] | None = None + try: + # xAI model IDs aren't in the openai SDK's Literal, hence the ignore. + resp = await self.client.chat.completions.create( # type: ignore[call-overload] + model=self.model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_context}, + ], + response_format={ + "type": "json_schema", + "json_schema": RESPONSE_SCHEMA, + }, + timeout=self.timeout, + ) + message = resp.choices[0].message + content = message.content or "{}" + tokens = extract_openai_tokens(resp) + return LLMAction.model_validate_json(content) + except Exception as exc: + err = f"{type(exc).__name__}: {exc}" + raise + finally: + await log_llm_call( + role="gameplay", + provider="xai", + model=self.model, + system_prompt=system_prompt, + user_prompt=user_context, + response=content if err is None else None, + latency_ms=timer.elapsed_ms, + error=err, + tokens=tokens, + ) class DeepSeekLLMActionDecider: @@ -237,11 +400,88 @@ async def decide(self, system_prompt: str, user_context: str) -> LLMAction: } if self.thinking == "enabled": kwargs["reasoning_effort"] = self.reasoning_effort - # DeepSeek model IDs aren't in the openai SDK's Literal, hence the ignore. - resp = await self.client.chat.completions.create(**kwargs) # type: ignore[call-overload] - message = resp.choices[0].message - content = message.content or "{}" - return LLMAction.model_validate_json(content) + timer = CallTimer() + content = "" + err: str | None = None + tokens: dict[str, int | None] | None = None + try: + # DeepSeek model IDs aren't in the openai SDK's Literal, hence the ignore. + resp = await self.client.chat.completions.create(**kwargs) # type: ignore[call-overload] + message = resp.choices[0].message + content = message.content or "{}" + tokens = extract_openai_tokens(resp) + return LLMAction.model_validate_json(content) + except Exception as exc: + err = f"{type(exc).__name__}: {exc}" + raise + finally: + await log_llm_call( + role="gameplay", + provider="deepseek", + model=self.model, + system_prompt=full_system, + user_prompt=user_context, + response=content if err is None else None, + latency_ms=timer.elapsed_ms, + error=err, + tokens=tokens, + extra={ + "thinking": self.thinking, + "reasoning_effort": self.reasoning_effort + if self.thinking == "enabled" + else None, + }, + ) + + +def _gemini_thinking_config( + model: str, + thinking_level: Literal["minimal", "low", "medium", "high"], +) -> object | None: + """Return the right ``ThinkingConfig`` for ``model``, or ``None``. + + Different Gemini model families take different thinking knobs: + + * **gemini-3-** and ``gemini-flash-latest`` (= 3-family alias) accept + ``thinking_level`` (``minimal`` / ``low`` / ``medium`` / ``high``). + * **gemini-2.5-** accepts ``thinking_budget`` (integer). ``-1`` = + dynamic budget, ``0`` = disabled, positive int = fixed cap. + * **gemini-2.0-** has no thinking surface at all; passing either + knob throws ``INVALID_ARGUMENT`` at the API boundary. + + The decider/generator stores the user-facing string from env + (``minimal``..``high``) and we map it to the family-specific + parameter here so callers don't have to branch model-by-model. + + Returns ``None`` when the model doesn't support thinking, so the + caller can omit ``thinking_config`` from + ``GenerateContentConfig`` entirely (passing ``thinking_config=None`` + would be invalid; the field has to be absent). + """ + from google.genai import types + + if model.startswith("gemini-3") or model.startswith("gemini-flash-latest"): + return types.ThinkingConfig( + # SDK normalizes the string into ThinkingLevel at runtime; + # the type annotation is enum-only, so silence the check. + thinking_level=thinking_level, # type: ignore[arg-type] + ) + if model.startswith("gemini-2.5"): + # Map the qualitative env knob to a numeric budget. Tuned so + # ``high`` keeps full reasoning headroom (-1 = unbounded) while + # ``low`` / ``minimal`` bias toward latency. + budget_map = { + "high": -1, # dynamic / unbounded + "medium": 4096, + "low": 1024, + "minimal": 0, # thinking disabled + } + return types.ThinkingConfig( + thinking_budget=budget_map.get(thinking_level, -1), + ) + # gemini-2.0 / older / unknown families: omit the thinking surface + # entirely so the request stays valid. + return None class GeminiLLMActionDecider: @@ -275,22 +515,151 @@ def __init__( async def decide(self, system_prompt: str, user_context: str) -> LLMAction: from google.genai import types - resp = await self.client.aio.models.generate_content( # type: ignore[attr-defined] - model=self.model, - contents=user_context, - config=types.GenerateContentConfig( - system_instruction=system_prompt, - response_mime_type="application/json", - response_json_schema=RESPONSE_SCHEMA["schema"], - thinking_config=types.ThinkingConfig( - # SDK normalizes the string into ThinkingLevel at runtime; - # the type annotation is enum-only, so silence the check. - thinking_level=self.thinking_level, # type: ignore[arg-type] - ), - ), + timer = CallTimer() + content = "" + err: str | None = None + tokens: dict[str, int | None] | None = None + try: + thinking_cfg = _gemini_thinking_config(self.model, self.thinking_level) + generate_config_kwargs: dict[str, object] = { + "system_instruction": system_prompt, + "response_mime_type": "application/json", + "response_json_schema": RESPONSE_SCHEMA["schema"], + } + if thinking_cfg is not None: + generate_config_kwargs["thinking_config"] = thinking_cfg + resp = await self.client.aio.models.generate_content( # type: ignore[attr-defined] + model=self.model, + contents=user_context, + config=types.GenerateContentConfig(**generate_config_kwargs), # type: ignore[arg-type] + ) + content = resp.text or "{}" + tokens = extract_gemini_vertex_tokens(resp) + return LLMAction.model_validate_json(content) + except Exception as exc: + err = f"{type(exc).__name__}: {exc}" + raise + finally: + await log_llm_call( + role="gameplay", + provider="gemini", + model=self.model, + system_prompt=system_prompt, + user_prompt=user_context, + response=content if err is None else None, + latency_ms=timer.elapsed_ms, + error=err, + tokens=tokens, + extra={"thinking_level": self.thinking_level}, + ) + + +class MockLLMActionDecider: + """Offline mock decider used when ``GAMEPLAY_LLM_PROVIDER=mock``. + + Returns deterministic LLMActions based on phrases unique to each + ``task_*`` prompt generator in :mod:`wolfbot.llm.prompt_builder`. + Vote and night-action targets are intentionally ``None`` so + :meth:`LLMAdapter._resolve_target` falls back to a uniform-random + legal target — sidestepping the brittle "parse legal candidates out + of user_context" path while still producing valid game progression. + + Speech messages are persona-blind canned phrases drawn round-robin + from a per-instance counter, kept under 80 chars to fit the discussion + text length convention. The Master Gameplay LLM only generates speech + in ``rounds`` mode; in ``reactive_voice`` mode the speech path is + routed to NPC bots and this decider produces only votes / night + actions / wolf-chat coordination text. + """ + + _SPEECHES: tuple[str, ...] = ( + "状況を整理しましょうか。今のところ大きな決め手はないですね。", + "発言の少ない方が気になります。考えを聞きたいです。", + "占い結果が出るまで断定は避けたいですね。", + "票筋から見ると、何人か怪しい位置はあります。", + "今のところ私は様子を見たい立場です。", + ) + + _WOLF_CHAT: tuple[str, ...] = ( + "情報を持ってそうな位置を狙いたい。", + "騎士候補から先に処理する案もある。", + "今夜は無難な相手に揃えよう。", + ) + + def __init__( + self, + *, + speeches: Sequence[str] | None = None, + wolf_chat: Sequence[str] | None = None, + ) -> None: + self._speeches: tuple[str, ...] = tuple(speeches or self._SPEECHES) + self._wolf_chat: tuple[str, ...] = tuple(wolf_chat or self._WOLF_CHAT) + self._speech_idx = 0 + self._wolf_idx = 0 + self.call_count = 0 + + def _next_speech(self) -> str: + text = self._speeches[self._speech_idx % len(self._speeches)] + self._speech_idx += 1 + return text + + def _next_wolf_chat(self) -> str: + text = self._wolf_chat[self._wolf_idx % len(self._wolf_chat)] + self._wolf_idx += 1 + return text + + async def decide(self, system_prompt: str, user_context: str) -> LLMAction: + self.call_count += 1 + # `task_text` is injected into the *system* prompt (see + # `build_system_prompt`'s `{task_block}`), not the user context. + # We scan both so the dispatch keys work regardless of where a + # caller decides to put them. + haystack = f"{system_prompt}\n{user_context}" + if "投票先として合法な候補は" in haystack: + return LLMAction( + intent="vote", + target_name=None, + reason_summary="mock vote", + confidence=0.5, + ) + if "対象を 1 名選んでください" in haystack: + # Deterministic target = smallest 席N appearing in the candidate + # list. For WOLF_ATTACK both wolves see the *same* candidate list + # (legal_attack_targets excludes all wolves), so they converge on + # one target — avoiding the wolf-attack split that would otherwise + # park the mock game in WAITING_HOST_DECISION every night. + return LLMAction( + intent="night_action", + target_name=self._pick_smallest_seat_token(haystack), + reason_summary="mock night action", + confidence=0.5, + ) + if "人狼チャット" in haystack: + return LLMAction( + intent="speak", + public_message=self._next_wolf_chat(), + reason_summary="mock wolf chat", + confidence=0.5, + ) + return LLMAction( + intent="speak", + public_message=self._next_speech(), + reason_summary="mock speech", + confidence=0.5, ) - content = resp.text or "{}" - return LLMAction.model_validate_json(content) + + @staticmethod + def _pick_smallest_seat_token(prompt: str) -> str | None: + # Parse only the "合法候補:" line — the rest of the prompt contains + # an example token ("例: `席3 Alice`") that would otherwise pollute + # the seat list and cause us to "pick" a non-candidate. + match = re.search(r"合法候補:[ \t]*([^\n]*)", prompt) + if match is None: + return None + seats = [int(m) for m in re.findall(r"席(\d+)", match.group(1))] + if not seats: + return None + return f"席{min(seats)}" class FakeLLMActionDecider: @@ -330,6 +699,8 @@ def __init__( game_service_ref: dict[str, GameService] | None = None, rng: random.Random | None = None, clock: Callable[[], int] | None = None, + discussion_service: DiscussionService | None = None, + npc_decision_dispatcher: object | None = None, ) -> None: import time as _time @@ -340,6 +711,17 @@ def __init__( self.rng = rng or random.Random() self._clock: Callable[[], int] = clock or (lambda: int(_time.time())) self._background_tasks: set[asyncio.Task[None]] = set() + # Phase-D: when set, reactive_voice games dispatch votes / night + # actions to NPC bots via WS instead of using `self.decider`. The + # type is widened to `object` to keep this module from importing + # the master-side dispatcher (which depends on websockets). + self._npc_decision_dispatcher = npc_decision_dispatcher + # Optional SpeechEvent emission. When None, behavior matches pre- + # speech-event-bus rounds mode: only PLAYER_SPEECH log + Discord post + # are produced. When set, every accepted utterance is also recorded + # as SpeechEvent(source=npc_generated) so PublicDiscussionState can + # rebuild a uniform timeline across rounds + reactive_voice modes. + self.discussion_service = discussion_service def set_game_service(self, gs: GameService) -> None: self._gs_slot["gs"] = gs @@ -381,13 +763,188 @@ async def submit_llm_night_actions( llm_players = [p for p in llm_players if p.seat_no in restrict_to_seats] if not llm_players: return - task = asyncio.create_task( - self._run_night_actions(game, llm_players, players, seats, unresolved_seats), - name=f"llm-night-{game.id}-d{game.day_number}", - ) + if game.discussion_mode == "reactive_voice" and self._npc_decision_dispatcher is not None: + task = asyncio.create_task( + self._run_night_actions_via_npc_dispatcher( + game, llm_players, players, seats, unresolved_seats + ), + name=f"npc-night-{game.id}-d{game.day_number}", + ) + else: + task = asyncio.create_task( + self._run_night_actions(game, llm_players, players, seats, unresolved_seats), + name=f"llm-night-{game.id}-d{game.day_number}", + ) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) + async def _run_night_actions_via_npc_dispatcher( + self, + game: Game, + llm_players: Sequence[Player], + all_players: Sequence[Player], + seats: Sequence[Seat], + unresolved_seats: frozenset[int] = frozenset(), + ) -> None: + """Reactive_voice night-action path: each role-actor seat dispatches + to its NPC bot. Wolf chat is intentionally skipped here — the + wolves coordinate through their `wolf_chat_history` mirror that + Master pushes via PrivateStateUpdate when wolves talk. + """ + seats_by_no = {s.seat_no: s for s in seats} + prev = await self.repo.load_previous_guard(game.id) + prev_guard_seat = previous_guard_seat_for_night(prev, game.day_number) + # Stale-phase guard: bail if we already advanced past NIGHT. + fresh = await self.repo.load_game(game.id) + if ( + fresh is None + or fresh.ended_at is not None + or fresh.phase is not Phase.NIGHT + or fresh.day_number != game.day_number + ): + return + # Bucket players by night action kind so each kind dispatches as + # a single fan-out (matches the dispatcher API). Skip seats that + # already submitted unless they are unresolved (split wolves). + existing = await self.repo.load_night_actions(game.id, day=game.day_number) + already_by_seat: dict[int, set[SubmissionType]] = {} + for a in existing: + already_by_seat.setdefault(a.actor_seat, set()).add(a.kind) + buckets: dict[str, list[tuple[Player, SubmissionType, list[int]]]] = { + "wolf_attack": [], + "seer_divine": [], + "knight_guard": [], + } + kind_to_action: dict[SubmissionType, str] = { + SubmissionType.WOLF_ATTACK: "wolf_attack", + SubmissionType.SEER_DIVINE: "seer_divine", + SubmissionType.KNIGHT_GUARD: "knight_guard", + } + for player in llm_players: + kind, legal = self._role_to_kind(player, all_players, prev_guard_seat) + if kind is None or not legal: + continue + already = already_by_seat.get(player.seat_no, set()) + if kind in already and player.seat_no not in unresolved_seats: + continue + action_label = kind_to_action.get(kind) + if action_label is None: + continue + buckets[action_label].append((player, kind, list(legal))) + dispatcher = self._npc_decision_dispatcher + # Phase-D pre-night wolf-chat coordination. Mirrors rounds-mode + # `_run_wolf_chat`: each alive wolf NPC gets a turn (sequential) + # to post a coordination line BEFORE the wolf_attack bucket + # fans out, so wolf B sees wolf A's proposed target via the + # wolf_chat_history the broker already pushed. + wolf_attack_items = buckets.get("wolf_attack", []) + if len(wolf_attack_items) >= 2: + wolves = [p for p, _kind, _legal in wolf_attack_items] + attack_legal: set[int] = set() + for _p, _kind, legal in wolf_attack_items: + attack_legal.update(legal) + try: + await dispatcher.dispatch_wolf_chat_lines( # type: ignore[union-attr] + game_id=game.id, + day=game.day_number, + wolves=wolves, + seats=seats, + candidate_seats=sorted(attack_legal), + public_state_summary=await self._build_public_digest(game, seats), + ) + except Exception: + log.exception( + "phase_d_wolf_chat_dispatch_failed game=%s day=%d", + game.id, + game.day_number, + ) + + for action_label, items in buckets.items(): + if not items: + continue + actors = [p for p, _kind, _legal in items] + # Use the union of legal targets for the request payload — the + # NPC bot picks any of them. (Per-actor legal sets only differ + # when there are 3+ wolves, which the 9-player ruleset never + # has.) We still validate per-actor on the response side. + union_legal: set[int] = set() + per_actor_legal: dict[int, set[int]] = {} + per_actor_kind: dict[int, SubmissionType] = {} + for player, kind, legal in items: + per_actor_legal[player.seat_no] = set(legal) + per_actor_kind[player.seat_no] = kind + union_legal.update(legal) + digest = await self._build_public_digest(game, seats) + try: + results = await dispatcher.dispatch_night_actions( # type: ignore[union-attr] + game_id=game.id, + day=game.day_number, + action_kind=action_label, + actors=actors, + seats=seats, + candidate_seats=sorted(union_legal), + public_state_summary=digest, + ) + except Exception: + log.exception( + "npc_night_dispatch_failed game=%s day=%d kind=%s", + game.id, + game.day_number, + action_label, + ) + continue + for actor in actors: + target = results.get(actor.seat_no) + if target is not None and target not in per_actor_legal[actor.seat_no]: + log.info( + "npc_night_decision_illegal_target game=%s seat=%d kind=%s target=%s", + game.id, + actor.seat_no, + action_label, + target, + ) + target = None + # Random-legal fallback: matches rounds-mode behaviour + # at llm_service._run_night_actions where + # `_resolve_target(..., allow_none=False)` already picks + # a uniform legal target when the LLM returns None / + # illegal. Without this, a slow NPC LLM that hits the + # dispatcher's request_ttl (12s) returns None → + # `submit_night_action` rejects it (ILLEGAL_TARGET) → + # the seat stays missing → `plan_night_resolve` parks + # the game in WAITING_HOST_DECISION. Observed in game + # bc20ebccb605 NIGHT 2 where jonas's knight_guard call + # took ~20s and stalled the entire phase. + if target is None: + legal_for_actor = sorted(per_actor_legal[actor.seat_no]) + if legal_for_actor: + target = self.rng.choice(legal_for_actor) + log.info( + "npc_night_decision_random_fallback game=%s seat=%d kind=%s target=%d " + "reason=null_or_illegal_response_from_npc", + game.id, + actor.seat_no, + action_label, + target, + ) + kind = per_actor_kind[actor.seat_no] + try: + await self.gs.submit_night_action( + game.id, + actor.seat_no, + kind, + target, + game.day_number, + ) + except Exception: + log.exception( + "npc_night_submit_failed game=%s seat=%d kind=%s", + game.id, + actor.seat_no, + kind.value, + ) + _ = seats_by_no # lint: keep parameter live for log readers + async def _run_night_actions( self, game: Game, @@ -574,6 +1131,11 @@ async def submit_llm_votes( `restrict_to_seats` lets `resend_pending_dms` re-dispatch for only the LLM seats that still owe a vote after `/wolf extend`. + + Phase-D: when ``game.discussion_mode == "reactive_voice"`` and a + decision dispatcher is configured, votes are routed to each + seat's NPC bot over WS instead of going through the gameplay + decider. Rounds mode (the historical path) is unchanged. """ seats_by_no = {s.seat_no: s for s in seats} llm_voters = [ @@ -585,13 +1147,254 @@ async def submit_llm_votes( llm_voters = [p for p in llm_voters if p.seat_no in restrict_to_seats] if not llm_voters: return - task = asyncio.create_task( - self._run_votes(game, llm_voters, players, seats, candidates, round_), - name=f"llm-votes-{game.id}-d{game.day_number}-r{round_}", - ) + if game.discussion_mode == "reactive_voice" and self._npc_decision_dispatcher is not None: + task = asyncio.create_task( + self._run_votes_via_npc_dispatcher( + game, llm_voters, players, seats, candidates, round_ + ), + name=f"npc-votes-{game.id}-d{game.day_number}-r{round_}", + ) + else: + task = asyncio.create_task( + self._run_votes(game, llm_voters, players, seats, candidates, round_), + name=f"llm-votes-{game.id}-d{game.day_number}-r{round_}", + ) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) + async def _build_public_digest(self, game: Game, seats: Sequence[Seat]) -> str: + """Phase-D: render the Master-side public-info digest for an NPC + dispatch request. Best-effort — a missing DiscussionService or + an empty phase yields an empty string so the NPC sees the + historical no-digest behavior. + + The vote / night decision happens AFTER the day's discussion has + ended, so ``game.phase`` at this point is DAY_VOTE / NIGHT etc. + Looking up speech events under that phase id returns zero rows + (speeches are persisted under DAY_DISCUSSION / DAY_RUNOFF_SPEECH + only). Pull from the day's DAY_DISCUSSION explicitly so the + digest carries the actual discussion content the NPC needs to + align its vote with what was said. DAY_RUNOFF_SPEECH events are + appended chronologically when present so a runoff vote also sees + the candidates' final speeches. + """ + if self.discussion_service is None: + return "" + try: + from wolfbot.domain.discussion import ( + make_phase_id as _make_phase_id, + ) + from wolfbot.master.arbiter.public_digest import build_public_digest + from wolfbot.services.discussion_service import ( + apply_speech_event, + ) + + day = game.day_number + discussion_phase_id = _make_phase_id(game.id, day, Phase.DAY_DISCUSSION) + runoff_phase_id = _make_phase_id(game.id, day, Phase.DAY_RUNOFF_SPEECH) + events = list(await self.discussion_service.load_phase(game.id, discussion_phase_id)) + runoff_events = list(await self.discussion_service.load_phase(game.id, runoff_phase_id)) + # Append runoff events after discussion so they fold on top + # of the day's accumulated state. Both phases carry their + # own phase_baseline sentinel so the fold reads each + # baseline correctly when entering the next phase. + events.extend(runoff_events) + if not events: + return "" + state = None + for ev in events: + state = apply_speech_event(state, ev) + if state is None: + return "" + past_votes = await self._load_past_votes(game.id, day) + past_suspicions = await self._load_past_suspicions(game.id) + seat_names = {s.seat_no: s.display_name for s in seats} + return build_public_digest( + state=state, + recent_events=events, + seat_names=seat_names, + past_votes=past_votes, + past_suspicions=past_suspicions, + ) + except Exception: + log.exception( + "public_digest_build_failed game=%s day=%d", + game.id, + game.day_number, + ) + return "" + + async def _load_past_suspicions( + self, game_id: str, + ) -> tuple[tuple[int, str, int, int, str, str, str | None, str | None], ...]: + """Load the public suspicion timeline for the rounds-mode / + night-decision digest. Mirrors SpeakArbiter._load_past_suspicions.""" + try: + rows = await self.repo.load_suspicions_for_game(game_id) + except Exception: + log.exception("digest_past_suspicions_load_failed game=%s", game_id) + return () + return tuple( + ( + int(r["day"]), + str(r["phase"]), + int(r["suspecter_seat"]), + int(r["target_seat"]), + str(r["level"]), + str(r["reason"]), + r["update_from_level"] if r["update_from_level"] is not None else None, + r["update_reason"] if r["update_reason"] is not None else None, + ) + for r in rows + ) + + async def _load_past_votes( + self, + game_id: str, + current_day: int, + ) -> tuple[tuple[int, int, tuple[tuple[int, int | None], ...]], ...]: + """Load completed-day vote ballots so the digest can render the + full "who voted whom" history. + + Mirrors `SpeakArbiter._load_past_votes` so the vote / night + decision LLM gets the same ballot ledger that day-discussion + speeches already see via the LogicPacket. Returns ``()`` when no + past day exists or any DB read fails — best-effort. + """ + if current_day <= 1: + return () + out: list[tuple[int, int, tuple[tuple[int, int | None], ...]]] = [] + try: + for day in range(1, current_day): + for round_ in (0, 1): + rows = await self.repo.load_votes( + game_id, + day=day, + round_=round_, + ) + if not rows: + continue + pairs = tuple( + (v.voter_seat, v.target_seat) + for v in sorted(rows, key=lambda v: v.voter_seat) + ) + out.append((day, round_, pairs)) + except Exception: + log.exception("digest_past_votes_load_failed game=%s", game_id) + return () + return tuple(out) + + async def _run_votes_via_npc_dispatcher( + self, + game: Game, + llm_voters: Sequence[Player], + all_players: Sequence[Player], + seats: Sequence[Seat], + candidates: Sequence[int] | None, + round_: int, + ) -> None: + """Reactive_voice vote path: fan out DecideVoteRequest, persist + targets, default to abstain on offline / timeout. + """ + seats_by_no = {s.seat_no: s for s in seats} + expected_phase = Phase.DAY_VOTE if round_ == 0 else Phase.DAY_RUNOFF + # Re-load game and stop early if the phase already advanced. The + # dispatcher's per-vote round-trip is bounded by request_ttl_ms but + # a force-skip / abort can still beat it. + fresh = await self.repo.load_game(game.id) + if ( + fresh is None + or fresh.ended_at is not None + or fresh.phase is not expected_phase + or fresh.day_number != game.day_number + ): + return + # Build candidate set per voter — same shape as the decider path. + # We fan out only voters who have at least one legal target; + # voters with no legal target (e.g. last-survivor edge) abstain. + existing_votes = await self.repo.load_votes(game.id, day=game.day_number, round_=round_) + voted = {v.voter_seat for v in existing_votes} + per_voter_candidates: dict[int, list[int]] = {} + voters_to_dispatch: list[Player] = [] + for v in llm_voters: + if v.seat_no in voted: + continue + if candidates is None: + cands = [ + s.seat_no + for s in seats + if s.seat_no != v.seat_no + and any(p.seat_no == s.seat_no and p.alive for p in all_players) + ] + else: + cands = [ + s.seat_no + for s in seats + if s.seat_no in set(candidates) and s.seat_no != v.seat_no + ] + per_voter_candidates[v.seat_no] = cands + voters_to_dispatch.append(v) + if not voters_to_dispatch: + return + # Use the union of all per-voter candidate seats for the dispatch + # call — the NPC bot will pick from the seats given. (Each voter's + # legal set is identical in practice for the regular vote.) + union_candidates: set[int] = set() + for cs in per_voter_candidates.values(): + union_candidates.update(cs) + dispatcher = self._npc_decision_dispatcher + digest = await self._build_public_digest(game, seats) + try: + results = await dispatcher.dispatch_votes( # type: ignore[union-attr] + game_id=game.id, + day=game.day_number, + round_=round_, + voters=voters_to_dispatch, + seats=seats, + candidate_seats=sorted(union_candidates), + public_state_summary=digest, + ) + except Exception: + log.exception( + "npc_vote_dispatch_failed game=%s day=%d round=%d", + game.id, + game.day_number, + round_, + ) + return + # Persist each result. ``None`` (offline / timeout / explicit + # abstain) becomes ``target_seat=None`` on the Vote row so the + # viewer surfaces the seat as silent. + for voter in voters_to_dispatch: + target = results.get(voter.seat_no) + # Validate target is in the voter's legal set, otherwise abstain. + if target is not None and target not in per_voter_candidates.get(voter.seat_no, []): + log.info( + "npc_vote_decision_illegal_target game=%s seat=%d target=%s", + game.id, + voter.seat_no, + target, + ) + target = None + try: + await self.gs.submit_vote( + game.id, + voter.seat_no, + target_seat=target, + round_=round_, + day=game.day_number, + ) + except Exception: + log.exception( + "npc_vote_submit_failed game=%s seat=%d round=%d", + game.id, + voter.seat_no, + round_, + ) + # Touch seats_by_no to keep the parameter live for log readers + # auditing the dispatch (lint guard). + _ = seats_by_no + async def _run_votes( self, game: Game, @@ -714,6 +1517,22 @@ async def submit_llm_discussion_rounds( for p in players if p.alive and p.seat_no in seats_by_no and seats_by_no[p.seat_no].is_llm ] + # Seed the phase baseline even when there are no LLM seats (all-human + # game). Human text events recorded by on_message need the sentinel to + # exist so PublicDiscussionState rebuild works. + if self.discussion_service is not None: + try: + alive_seat_nos = sorted(p.seat_no for p in players if p.alive) + await self.discussion_service.begin_phase_if_absent( + game_id=game.id, + day=game.day_number, + phase=game.phase, + alive_seat_nos=alive_seat_nos, + ) + except Exception: + log.exception( + "phase_baseline insert failed for %s day=%d", game.id, game.day_number + ) if not llm_players: return task = asyncio.create_task( @@ -731,6 +1550,8 @@ async def _run_discussion_rounds( ) -> None: seats_by_no = {s.seat_no: s for s in seats} ordered = sorted(llm_players, key=lambda p: p.seat_no) + # Phase baseline is seeded by submit_llm_discussion_rounds before the + # background task starts — no need to duplicate it here. for round_idx in (1, 2): for llm in ordered: # Re-read live game state before each per-seat attempt so a @@ -861,6 +1682,19 @@ async def _do_one_discussion_speech( ) except Exception: log.exception("PLAYER_SPEECH log insert failed for seat %s", player.seat_no) + seer_seat, seer_verdict = _claim_to_fields(action.claimed_seer_result) + medium_seat, medium_verdict = _claim_to_fields(action.claimed_medium_result) + await self._emit_npc_speech_event( + fresh, + player.seat_no, + message, + co_declaration=action.co_declaration, + claimed_seer_target_seat=seer_seat, + claimed_seer_is_wolf=seer_verdict, + claimed_medium_target_seat=medium_seat, + claimed_medium_is_wolf=medium_verdict, + suspicions=action.suspicions, + ) # --------------------------------------------------- runoff candidate speech async def submit_llm_runoff_candidate_speeches( @@ -904,6 +1738,21 @@ async def _run_runoff_candidate_speeches( ) -> None: seats_by_no = {s.seat_no: s for s in seats} ordered = sorted(llm_players, key=lambda p: p.seat_no) + # Phase baseline for runoff: alive seats at runoff entry. + if self.discussion_service is not None: + try: + all_players = await self.repo.load_players(game.id) + alive_seat_nos = sorted(p.seat_no for p in all_players if p.alive) + await self.discussion_service.begin_phase_if_absent( + game_id=game.id, + day=game.day_number, + phase=game.phase, + alive_seat_nos=alive_seat_nos, + ) + except Exception: + log.exception( + "runoff phase_baseline insert failed %s day=%d", game.id, game.day_number + ) for llm in ordered: fresh = await self.repo.load_game(game.id) if ( @@ -995,6 +1844,102 @@ async def _do_one_runoff_speech( ) except Exception: log.exception("PLAYER_SPEECH log insert failed for seat %s", player.seat_no) + seer_seat, seer_verdict = _claim_to_fields(action.claimed_seer_result) + medium_seat, medium_verdict = _claim_to_fields(action.claimed_medium_result) + await self._emit_npc_speech_event( + fresh, + player.seat_no, + message, + co_declaration=action.co_declaration, + claimed_seer_target_seat=seer_seat, + claimed_seer_is_wolf=seer_verdict, + claimed_medium_target_seat=medium_seat, + claimed_medium_is_wolf=medium_verdict, + suspicions=action.suspicions, + ) + + async def _emit_npc_speech_event( + self, + game: Game, + speaker_seat: int, + text: str, + *, + co_declaration: str | None = None, + claimed_seer_target_seat: int | None = None, + claimed_seer_is_wolf: bool | None = None, + claimed_medium_target_seat: int | None = None, + claimed_medium_is_wolf: bool | None = None, + suspicions: Sequence[Suspicion] = (), + ) -> None: + """Persist a SpeechEvent(source=npc_generated) for an LLM utterance. + + Wrapped in try/except so a SpeechEvent persistence hiccup does not + rollback the already-completed Discord post + PLAYER_SPEECH log. + Skips DiscussionService's own MessagePoster + LogSink hooks (they + would duplicate the LLM speech) by going through `_store.insert` + directly when available. + """ + if self.discussion_service is None: + return + # Self-claim guard: an LLM that fakes a CO targeting its own + # seat is a logical contradiction (real seers never divine + # themselves in this ruleset). Drop the structured claim before + # persisting so the per-seat claim-history fold stays clean, + # while still keeping the speech itself. + if claimed_seer_target_seat == speaker_seat: + claimed_seer_target_seat = None + claimed_seer_is_wolf = None + if claimed_medium_target_seat == speaker_seat: + claimed_medium_target_seat = None + claimed_medium_is_wolf = None + try: + from wolfbot.domain.discussion import make_phase_id + from wolfbot.services.discussion_service import ( + make_npc_generated_event, + ) + + phase_id = make_phase_id(game.id, game.day_number, game.phase) + event = make_npc_generated_event( + game_id=game.id, + phase_id=phase_id, + day=game.day_number, + phase=game.phase, + speaker_seat=speaker_seat, + text=text, + co_declaration=co_declaration, + claimed_seer_target_seat=claimed_seer_target_seat, + claimed_seer_is_wolf=claimed_seer_is_wolf, + claimed_medium_target_seat=claimed_medium_target_seat, + claimed_medium_is_wolf=claimed_medium_is_wolf, + ) + # Persist-only avoids re-posting the message to Discord (the + # rounds-mode path already posted it via MessagePoster) and + # avoids re-inserting the PLAYER_SPEECH LogEntry (already inserted). + await self.discussion_service.record_persist_only(event) + # Persist structured suspicions attached to this utterance. + # Drop self-targeted entries (LLM occasionally mis-targets + # itself); rest go to the speech_suspicions table keyed on + # the event_id we just wrote. + valid_suspicions = [ + s for s in suspicions if s.target_seat != speaker_seat + ] + if valid_suspicions: + from wolfbot.services.discussion_service import now_ms + await self.repo.insert_speech_suspicions( + event_id=event.event_id, + game_id=game.id, + day=game.day_number, + phase=game.phase, + suspecter_seat=speaker_seat, + created_at_ms=now_ms(), + suspicions=valid_suspicions, + ) + except Exception: + log.exception( + "SpeechEvent(npc_generated) write failed for game=%s seat=%s", + game.id, + speaker_seat, + ) # ------------------------------------------------------ helpers async def _ask( @@ -1006,7 +1951,7 @@ async def _ask( seats: Sequence[Seat], task_text: str, ) -> LLMAction: - persona = PERSONAS_BY_KEY.get(seat.persona_key or "") + persona = NPC_PERSONAS_BY_KEY.get(seat.persona_key or "") if persona is None: return LLMAction(intent="skip", reason_summary="persona missing") assert player.role is not None @@ -1014,6 +1959,7 @@ async def _ask( private_logs = await self.repo.load_private_logs_for_audience( game.id, audience_seat=player.seat_no, limit=40 ) + deduced_block = await self._build_deduced_facts_block(game, players, seats) system = build_system_prompt( persona=persona, role=player.role, @@ -1029,12 +1975,67 @@ async def _ask( players=players, public_logs=public_logs, private_logs=private_logs, + deduced_facts_block=deduced_block, ) + actor = f"seat={player.seat_no} persona={seat.persona_key} role={player.role.value}" + task_tag = _classify_task_text(task_text) + with trace_context( + game_id=game.id, + phase=game.phase.value, + day=game.day_number, + actor=actor, + metadata={"task": task_tag}, + ): + try: + return await self.decider.decide(system, user) + except Exception: + log.exception("LLM decide failed for seat %s game %s", player.seat_no, game.id) + return LLMAction(intent="skip", reason_summary="decider error") + + async def _build_deduced_facts_block( + self, + game: Game, + players: Sequence[Player], + seats: Sequence[Seat], + ) -> str | None: + """Assemble Master's HARD/MEDIUM deduced facts for this turn. + + Pulls CO history from the SpeechEvent fold and per-day vote rows + from the repo, then runs them through `deduction_service.deduce`. + Returns ``None`` (rather than the empty-marker string) on any + failure so the user-context template skips the section instead of + showing a confusing '(該当なし)' line. + """ try: - return await self.decider.decide(system, user) + from wolfbot.services.deduction_service import deduce, render_facts_block + from wolfbot.services.discussion_service import rebuild_public_state_from_events + + co_claims: tuple[CoClaim, ...] = () + if self.discussion_service is not None: + phase_id = make_phase_id(game.id, game.day_number, game.phase) + events = await self.discussion_service.load_phase(game.id, phase_id) + state = rebuild_public_state_from_events(events) + if state is not None: + co_claims = state.co_claims + + votes_by_day: dict[int, list[Vote]] = {} + for past_day in range(1, game.day_number): + votes = await self.repo.load_votes(game.id, past_day, 0) + if votes: + votes_by_day[past_day] = votes + + facts = deduce( + co_claims=co_claims, + players=players, + seats=seats, + votes_by_day=votes_by_day, + ) + if not facts: + return None + return render_facts_block(facts) except Exception: - log.exception("LLM decide failed for seat %s game %s", player.seat_no, game.id) - return LLMAction(intent="skip", reason_summary="decider error") + log.exception("deduce facts failed for game %s seat", game.id) + return None def _resolve_target( self, @@ -1096,21 +2097,35 @@ def _role_to_kind( # ------------------------------------------------------------- factory -def make_xai_decider(api_key: str, model: str, timeout: float = 30.0) -> XAILLMActionDecider: - """Build an xAI-backed decider. Imports openai lazily so tests can skip it. +_XAI_DEFAULT_BASE_URL = "https://api.x.ai/v1" +_DEEPSEEK_DEFAULT_BASE_URL = "https://api.deepseek.com" - Kept for back-compat. New code should call `make_llm_decider(settings)`. + +def make_xai_decider( + api_key: str, + model: str, + *, + base_url: str | None = None, + timeout: float = 30.0, +) -> XAILLMActionDecider: + """Build an OpenAI-Chat-Completions-compatible decider (xAI default). + + ``base_url`` defaults to xAI Grok; override to point at OpenAI, Groq, + Together, vLLM, Ollama, or any other strict-json_schema-supporting + OpenAI-compat endpoint. Imports ``openai`` lazily so tests skipping + LLM paths don't need the dep installed. """ from openai import AsyncOpenAI - client = AsyncOpenAI(api_key=api_key, base_url="https://api.x.ai/v1") + client = AsyncOpenAI(api_key=api_key, base_url=base_url or _XAI_DEFAULT_BASE_URL) return XAILLMActionDecider(client=client, model=model, timeout=timeout) def make_deepseek_decider( api_key: str, model: str, - base_url: str = "https://api.deepseek.com", + *, + base_url: str | None = None, thinking: Literal["enabled", "disabled"] = "enabled", reasoning_effort: Literal["high", "max"] = "max", timeout: float = 30.0, @@ -1118,7 +2133,7 @@ def make_deepseek_decider( """Build a DeepSeek-backed decider. Imports openai lazily.""" from openai import AsyncOpenAI - client = AsyncOpenAI(api_key=api_key, base_url=base_url) + client = AsyncOpenAI(api_key=api_key, base_url=base_url or _DEEPSEEK_DEFAULT_BASE_URL) return DeepSeekLLMActionDecider( client=client, model=model, @@ -1132,6 +2147,7 @@ def make_gemini_decider( project: str, location: str, model: str, + *, thinking_level: Literal["minimal", "low", "medium", "high"] = "high", timeout: float = 30.0, ) -> GeminiLLMActionDecider: @@ -1159,39 +2175,90 @@ def make_gemini_decider( ) -def make_llm_decider(settings: Settings, timeout: float = 30.0) -> LLMActionDecider: - """Provider-aware decider factory. Branches on `settings.LLM_PROVIDER`. +def make_gemini_aistudio_decider( + *, + api_key: str, + model: str = "gemini-3-flash-preview", + thinking_level: Literal["minimal", "low", "medium", "high"] = "high", + timeout: float = 30.0, +) -> GeminiLLMActionDecider: + """Build a Google AI Studio Gemini-backed decider. + + Mirror of :func:`make_gemini_decider` but authenticates via the + AI Studio API key path instead of Vertex AI / ADC. The same + ``GeminiLLMActionDecider`` runtime works because the only diff + between the two SDK modes is how the underlying client is + constructed; the structured-output schema and thinking config are + identical surface across both. Use this when running outside GCP + (no project / no service account) but with an ``AIza...`` key. + """ + from google import genai + from google.genai import types + + client = genai.Client( + api_key=api_key, + http_options=types.HttpOptions(timeout=int(timeout * 1000)), + ) + return GeminiLLMActionDecider( + client=client, + model=model, + thinking_level=thinking_level, + timeout=timeout, + ) - The Settings model_validator guarantees the relevant API key is non-None - by the time we get here; the asserts are a documentation aid for mypy. + +def make_llm_decider(cfg: LLMDeciderConfig) -> LLMActionDecider: + """Provider-aware decider factory. Branches on ``cfg.provider``. + + The Settings model_validator that built ``cfg`` guarantees the + relevant credential is non-None / non-empty by the time we get here; + the asserts are a documentation aid for mypy. + + The same factory is used for both Master's Gameplay LLM (built from + ``MasterSettings.gameplay_decider_config()``) and any ad-hoc gameplay + decider tests need. NPC speech generation has its own factory in + :mod:`wolfbot.npc.speech.generator_factory` because the NPC schema differs. """ - if settings.LLM_PROVIDER == "xai": - assert settings.XAI_API_KEY is not None # validated in Settings + if cfg.provider == "xai": + assert cfg.api_key is not None # validated in Settings return make_xai_decider( - api_key=settings.XAI_API_KEY.get_secret_value(), - model=settings.XAI_MODEL, - timeout=timeout, + api_key=cfg.api_key.get_secret_value(), + model=cfg.model, + base_url=cfg.base_url, + timeout=cfg.timeout, ) - if settings.LLM_PROVIDER == "deepseek": - assert settings.DEEPSEEK_API_KEY is not None # validated in Settings + if cfg.provider == "deepseek": + assert cfg.api_key is not None # validated in Settings return make_deepseek_decider( - api_key=settings.DEEPSEEK_API_KEY.get_secret_value(), - model=settings.DEEPSEEK_MODEL, - base_url=settings.DEEPSEEK_BASE_URL, - thinking=settings.DEEPSEEK_THINKING, - reasoning_effort=settings.DEEPSEEK_REASONING_EFFORT, - timeout=timeout, + api_key=cfg.api_key.get_secret_value(), + model=cfg.model, + base_url=cfg.base_url, + thinking=cfg.thinking, + reasoning_effort=cfg.reasoning_effort, + timeout=cfg.timeout, ) - if settings.LLM_PROVIDER == "gemini": - assert settings.GEMINI_VERTEX_PROJECT is not None # validated in Settings - return make_gemini_decider( - project=settings.GEMINI_VERTEX_PROJECT, - location=settings.GEMINI_VERTEX_LOCATION, - model=settings.GEMINI_MODEL, - thinking_level=settings.GEMINI_THINKING_LEVEL, - timeout=timeout, + if cfg.provider == "gemini": + # Vertex AI wins when both are set (production deployments + # rely on attached-SA credentials, AI Studio key is a local- + # dev convenience). + if cfg.vertex_project: + return make_gemini_decider( + project=cfg.vertex_project, + location=cfg.vertex_location, + model=cfg.model, + thinking_level=cfg.thinking_level, + timeout=cfg.timeout, + ) + assert cfg.api_key is not None # validated in Settings + return make_gemini_aistudio_decider( + api_key=cfg.api_key.get_secret_value(), + model=cfg.model, + thinking_level=cfg.thinking_level, + timeout=cfg.timeout, ) - raise ValueError(f"unknown LLM_PROVIDER: {settings.LLM_PROVIDER!r}") + if cfg.provider == "mock": + return MockLLMActionDecider() + raise ValueError(f"unknown provider: {cfg.provider!r}") __all__ = [ @@ -1202,6 +2269,7 @@ def make_llm_decider(settings: Settings, timeout: float = 30.0) -> LLMActionDeci "LLMAction", "LLMActionDecider", "LLMAdapter", + "MockLLMActionDecider", "XAILLMActionDecider", "make_deepseek_decider", "make_gemini_decider", diff --git a/src/wolfbot/services/llm_trace.py b/src/wolfbot/services/llm_trace.py new file mode 100644 index 0000000..8d3e396 --- /dev/null +++ b/src/wolfbot/services/llm_trace.py @@ -0,0 +1,301 @@ +"""Append-only JSONL trace of every LLM call. + +Captures gameplay LLM (vote / night-action / discussion text), NPC speech +LLM, and voice STT/Analyzer calls into per-game JSONL files so a finished +game can be replayed end-to-end with full prompt + response visibility. + +Layout (relative to cwd, override via ``WOLFBOT_LLM_TRACE_DIR``):: + + logs/llm_calls/ + no_game/ # calls before any game context is set + voice_stt.jsonl + {game_id}/ + gameplay.jsonl # gameplay-LLM deciders (xAI / DeepSeek / Gemini) + npc_{persona}.jsonl # NPC bot speech generation, one file per persona + voice_stt.jsonl # Master-side STT/audio analysis + +Each JSONL line is one call:: + + { + "ts": "2026-04-28T13:14:55.123+00:00", + "role": "gameplay" | "npc_speech" | "voice_stt", + "provider": "xai" | "deepseek" | "gemini" | "openai-compat", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 2, + "actor": "seat=4 persona=setsu role=WEREWOLF", + "metadata": {"task": "vote"}, + "system_prompt": "...", + "user_prompt": "...", + "response": "...", # raw text (JSON string for structured-output models) + "latency_ms": 1234, + "error": null + } + +Concurrency: one ``asyncio.Lock`` per file path so concurrent appends from +parallel LLM seats interleave at line boundaries. + +Disable: ``WOLFBOT_LLM_TRACE_DISABLED=1``. +""" + +from __future__ import annotations + +import asyncio +import contextvars +import json +import logging +import os +import re +import time +from collections.abc import Iterator +from contextlib import contextmanager +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +log = logging.getLogger(__name__) + +_DEFAULT_DIR = Path("logs/llm_calls") + +# Context vars carrying game/phase/actor identifiers from the calling code +# down into the decider / generator without changing public Protocols. +_game_id_ctx: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "wolfbot_llm_trace_game_id", default=None +) +_phase_ctx: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "wolfbot_llm_trace_phase", default=None +) +_day_ctx: contextvars.ContextVar[int | None] = contextvars.ContextVar( + "wolfbot_llm_trace_day", default=None +) +_actor_ctx: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "wolfbot_llm_trace_actor", default=None +) +_metadata_ctx: contextvars.ContextVar[dict[str, Any] | None] = contextvars.ContextVar( + "wolfbot_llm_trace_metadata", default=None +) + +_file_locks: dict[str, asyncio.Lock] = {} + + +def trace_enabled() -> bool: + return os.environ.get("WOLFBOT_LLM_TRACE_DISABLED") != "1" + + +def trace_base_dir() -> Path: + raw = os.environ.get("WOLFBOT_LLM_TRACE_DIR") + return Path(raw) if raw else _DEFAULT_DIR + + +def parse_game_id_from_phase_id(phase_id: str | None) -> str | None: + """Extract ``game_id`` from canonical phase_id ``"{gid}::dayN::PHASE::seq"``.""" + if not phase_id: + return None + head, sep, _ = phase_id.partition("::") + return head if sep else None + + +def parse_day_from_phase_id(phase_id: str | None) -> int | None: + """Extract day number from canonical phase_id ``"{gid}::dayN::PHASE::seq"``. + + Returns ``None`` when the phase_id is missing the ``dayN`` segment or + the segment is not parseable. Used by call sites that only have a + phase_id string available so trace entries carry the same ``day`` + field the gameplay-LLM path emits. + """ + if not phase_id: + return None + parts = phase_id.split("::") + if len(parts) < 2: + return None + seg = parts[1] + if not seg.startswith("day"): + return None + try: + return int(seg[3:]) + except ValueError: + return None + + +@contextmanager +def trace_context( + *, + game_id: str | None = None, + phase: str | None = None, + day: int | None = None, + actor: str | None = None, + metadata: dict[str, Any] | None = None, +) -> Iterator[None]: + """Set trace context for the duration of a block. + + Wrap a logical unit of work (e.g. one ``LLMAdapter._ask`` call) so every + LLM call nested inside inherits the same identifiers without changing + decider Protocols. + """ + tokens: list[tuple[contextvars.ContextVar[Any], contextvars.Token[Any]]] = [] + if game_id is not None: + tokens.append((_game_id_ctx, _game_id_ctx.set(game_id))) + if phase is not None: + tokens.append((_phase_ctx, _phase_ctx.set(phase))) + if day is not None: + tokens.append((_day_ctx, _day_ctx.set(day))) + if actor is not None: + tokens.append((_actor_ctx, _actor_ctx.set(actor))) + if metadata is not None: + tokens.append((_metadata_ctx, _metadata_ctx.set(metadata))) + try: + yield + finally: + for var, tok in reversed(tokens): + var.reset(tok) + + +class CallTimer: + """Lightweight stopwatch used at every LLM call site.""" + + __slots__ = ("_t0",) + + def __init__(self) -> None: + self._t0 = time.perf_counter() + + @property + def elapsed_ms(self) -> int: + return int((time.perf_counter() - self._t0) * 1000) + + +async def log_llm_call( + *, + role: str, + provider: str, + model: str, + system_prompt: str | None, + user_prompt: str | None, + response: str | None, + latency_ms: int, + error: str | None = None, + actor: str | None = None, + extra: dict[str, Any] | None = None, + file_stem: str | None = None, + tokens: dict[str, int | None] | None = None, +) -> None: + """Append one trace line. Never raises — failures are logged and dropped. + + ``role`` selects the default file stem (``"{role}.jsonl"``); pass + ``file_stem`` to override (e.g. NPC bots use ``"npc_setsu"``). + + ``tokens`` carries token usage as ``{"prompt": N, "completion": N, "total": N}`` + when the provider returned usage metadata, ``None`` otherwise. + """ + if not trace_enabled(): + return + try: + gid = _game_id_ctx.get() or "no_game" + gid_safe = _sanitize(gid) + stem_safe = _sanitize(file_stem or role) + base = trace_base_dir() / gid_safe + base.mkdir(parents=True, exist_ok=True) + path = base / f"{stem_safe}.jsonl" + + entry: dict[str, Any] = { + "ts": datetime.now(UTC).isoformat(), + "role": role, + "provider": provider, + "model": model, + "phase": _phase_ctx.get(), + "day": _day_ctx.get(), + "actor": actor or _actor_ctx.get(), + "system_prompt": system_prompt, + "user_prompt": user_prompt, + "response": response, + "latency_ms": latency_ms, + "tokens": tokens, + "error": error, + } + ctx_md = _metadata_ctx.get() + if ctx_md or extra: + entry["metadata"] = {**(ctx_md or {}), **(extra or {})} + line = json.dumps(entry, ensure_ascii=False) + "\n" + + lock = _file_locks.setdefault(str(path), asyncio.Lock()) + async with lock: + await asyncio.to_thread(_append_line, path, line) + except Exception: + log.exception("llm_trace_write_failed role=%s model=%s", role, model) + + +def _append_line(path: Path, line: str) -> None: + with open(path, "a", encoding="utf-8") as f: + f.write(line) + + +def extract_openai_tokens(resp: Any) -> dict[str, int | None] | None: + """Pull ``{prompt, completion, total}`` from an OpenAI-compatible response. + + Used by the xAI / DeepSeek / OpenAI-compat paths. Returns ``None`` when + the response has no usage attribute (e.g. test fakes), so trace lines + stay valid even without real token data. + """ + usage = getattr(resp, "usage", None) + if usage is None: + return None + return { + "prompt": getattr(usage, "prompt_tokens", None), + "completion": getattr(usage, "completion_tokens", None), + "total": getattr(usage, "total_tokens", None), + } + + +def extract_gemini_vertex_tokens(resp: Any) -> dict[str, int | None] | None: + """Pull ``{prompt, completion, total}`` from a google-genai response. + + Vertex Gemini exposes ``resp.usage_metadata.{prompt_token_count, + candidates_token_count, total_token_count}`` — different field names + from OpenAI but the same three integers conceptually. + """ + um = getattr(resp, "usage_metadata", None) + if um is None: + return None + return { + "prompt": getattr(um, "prompt_token_count", None), + "completion": getattr(um, "candidates_token_count", None), + "total": getattr(um, "total_token_count", None), + } + + +def extract_gemini_rest_tokens(resp_json: dict[str, Any]) -> dict[str, int | None] | None: + """Pull token counts from the Gemini REST ``generateContent`` response.""" + um = resp_json.get("usageMetadata") + if not isinstance(um, dict): + return None + return { + "prompt": um.get("promptTokenCount"), + "completion": um.get("candidatesTokenCount"), + "total": um.get("totalTokenCount"), + } + + +_SAFE_RE = re.compile(r"[^A-Za-z0-9_-]+") + + +def _sanitize(s: str) -> str: + """Sanitize a path component (game_id or file stem). + + Dots are replaced too — preventing both ``..`` traversal and accidental + extension confusion with the appended ``.jsonl`` suffix. + """ + cleaned = _SAFE_RE.sub("_", s).strip("_") + return cleaned or "x" + + +__all__ = [ + "CallTimer", + "extract_gemini_rest_tokens", + "extract_gemini_vertex_tokens", + "extract_openai_tokens", + "log_llm_call", + "parse_day_from_phase_id", + "parse_game_id_from_phase_id", + "trace_base_dir", + "trace_context", + "trace_enabled", +] diff --git a/src/wolfbot/services/permission_manager.py b/src/wolfbot/services/permission_manager.py index 5689839..15f3636 100644 --- a/src/wolfbot/services/permission_manager.py +++ b/src/wolfbot/services/permission_manager.py @@ -63,6 +63,19 @@ async def apply( if main is not None: await self._apply_main_text(main, seats, player_by_seat, guild) + # Reconcile VC speak permission so a Master restart restores + # dead-player mute. Only runs when player state is known + # (post-SETUP); LOBBY/SETUP have no per-seat alive flag yet. + if players is not None and game.main_vc_channel_id: + try: + vc_channel = guild.get_channel(int(game.main_vc_channel_id)) + except (TypeError, ValueError): + vc_channel = None + if vc_channel is not None: + await self._apply_vc_mute( + vc_channel, seats, player_by_seat, game.phase, guild + ) + if game.heaven_channel_id: heaven = guild.get_channel(int(game.heaven_channel_id)) if heaven is not None: @@ -95,6 +108,26 @@ async def kill( if main is not None: await self._set_perms(main, member, send_messages=False, read_messages=True) + # VC mute: dead players must not be able to speak in VC. We + # remove `speak` (and `use_voice_activation`) on the VC channel + # rather than server-mute (`member.edit(mute=True)`) because the + # channel-overwrite approach is scoped to this game's VC and + # can be cleared on game end alongside the rest of the + # overwrites — server-mute would leak across guilds / games and + # require admin permission. + if game.main_vc_channel_id: + try: + vc_channel = guild.get_channel(int(game.main_vc_channel_id)) + except (TypeError, ValueError): + vc_channel = None + if vc_channel is not None: + await self._set_perms( + vc_channel, + member, + speak=False, + use_voice_activation=False, + ) + if game.heaven_channel_id: heaven = guild.get_channel(int(game.heaven_channel_id)) if heaven is not None: @@ -143,6 +176,23 @@ async def on_game_end(self, game: Game, seats: Sequence[Seat]) -> None: continue await self._clear_perms(main, member) + # VC is also a persistent channel — clear the per-member + # speak/use_voice_activation overwrites we may have applied on + # death so the next game starts from a clean baseline. + if game.main_vc_channel_id: + try: + vc_channel = guild.get_channel(int(game.main_vc_channel_id)) + except (TypeError, ValueError): + vc_channel = None + if vc_channel is not None: + for s in seats: + if s.discord_user_id is None: + continue + member = guild.get_member(int(s.discord_user_id)) + if member is None: + continue + await self._clear_perms(vc_channel, member) + for channel_id in filter(None, [game.heaven_channel_id, game.wolves_channel_id]): channel = guild.get_channel(int(channel_id)) if channel is None: @@ -180,6 +230,53 @@ async def _apply_main_text( view_channel=True, ) + async def _apply_vc_mute( + self, + channel: Any, + seats: Sequence[Seat], + player_by_seat: dict[int, Player], + phase: Phase, + guild: Any, + ) -> None: + """Reconcile VC speak permission per human seat. + + Phase rule: humans may speak only when alive AND the phase is + DAY_DISCUSSION. Every other phase (NIGHT, vote, runoff, runoff + speech) keeps everyone muted so chatter doesn't bleed across + phase boundaries. Dead players are always muted. + + We use channel-level `set_permissions(speak=False, use_voice_activation=False)` + rather than `member.edit(mute=True)` so the override is scoped to + this game's VC and gets cleared in `on_game_end`. NPC bots and + Master have no row in `seats` with a `discord_user_id`, so they + are never touched here — their TTS playback would break under a + channel-scoped speak deny. + """ + discussion_active = phase is Phase.DAY_DISCUSSION + for s in seats: + if s.discord_user_id is None: + continue + member = guild.get_member(int(s.discord_user_id)) + if member is None: + continue + p = player_by_seat.get(s.seat_no) + alive = True if p is None else bool(p.alive) + can_speak = alive and discussion_active + if can_speak: + await self._set_perms( + channel, + member, + speak=True, + use_voice_activation=True, + ) + else: + await self._set_perms( + channel, + member, + speak=False, + use_voice_activation=False, + ) + async def _apply_heaven( self, channel: Any, diff --git a/src/wolfbot/services/recovery_service.py b/src/wolfbot/services/recovery_service.py index 837041d..8ccf771 100644 --- a/src/wolfbot/services/recovery_service.py +++ b/src/wolfbot/services/recovery_service.py @@ -36,7 +36,20 @@ class RecoveryDiscordAdapter(Protocol): async def reconcile( self, game: Game, seats: Sequence[Seat], players: Sequence[Player] ) -> None: ... - async def announce_recovery(self, game: Game, pending: PendingDecision | None) -> None: ... + async def announce_recovery( + self, game: Game, pending: PendingDecision | None) -> None: ... + + +class ReactiveVoiceRecoverySweep(Protocol): + """Callable that closes in-flight npc_speak_requests and npc_playback_events.""" + + async def __call__(self, game_id: str) -> None: ... + + +class ReactiveVoiceReenter(Protocol): + """Callable that triggers arbiter dispatch after recovery.""" + + async def __call__(self, game_id: str) -> None: ... class RecoveryService: @@ -47,12 +60,16 @@ def __init__( registry: EngineRegistry, discord: RecoveryDiscordAdapter, clock: Callable[[], int] = lambda: int(time.time()), + reactive_voice_sweep: ReactiveVoiceRecoverySweep | None = None, + reactive_voice_reenter: ReactiveVoiceReenter | None = None, ) -> None: self.repo = repo self.game_service = game_service self.registry = registry self.discord = discord self.clock = clock + self._reactive_voice_sweep = reactive_voice_sweep + self._reactive_voice_reenter = reactive_voice_reenter async def recover_all(self) -> list[str]: games = await self.repo.load_active_games() @@ -102,6 +119,16 @@ async def _recover_one(self, game: Game) -> None: except Exception: log.exception("announce_recovery failed for %s", game.id) + # Step 2.5: sweep open reactive_voice audit rows so in-flight requests + # and playback windows from before the restart are closed with + # failure_reason=master_restart (npc-voice-pipeline spec §Recovery). + if game.discussion_mode == "reactive_voice" and self._reactive_voice_sweep is not None: + try: + await self._reactive_voice_sweep(game.id) + except Exception: + log.exception( + "reactive_voice recovery sweep failed for %s", game.id) + # Step 3: attach and start engine. Pass the recovery clock so tests # using FakeClock get deterministic timing — otherwise the engine # uses time.time() and may race ahead of the test's logical clock, @@ -120,7 +147,8 @@ async def _recover_one(self, game: Game) -> None: try: await self.game_service.resend_pending_dms(game.id) except Exception: - log.exception("resend_pending_dms during recovery failed for %s", game.id) + log.exception( + "resend_pending_dms during recovery failed for %s", game.id) # Step 5: resume LLM speech progress for DAY_DISCUSSION / # DAY_RUNOFF_SPEECH if any per-seat round/runoff_speech_done is still @@ -128,4 +156,18 @@ async def _recover_one(self, game: Game) -> None: try: await self.game_service.resume_llm_speech_progress(game.id) except Exception: - log.exception("resume_llm_speech_progress during recovery failed for %s", game.id) + log.exception( + "resume_llm_speech_progress during recovery failed for %s", game.id) + + # Step 6: re-enter the arbiter for reactive_voice games in a public + # speech phase so NPCs resume speaking after restart. + if ( + game.discussion_mode == "reactive_voice" + and game.phase in (Phase.DAY_DISCUSSION, Phase.DAY_RUNOFF_SPEECH) + and self._reactive_voice_reenter is not None + ): + try: + await self._reactive_voice_reenter(game.id) + except Exception: + log.exception( + "reactive_voice reenter dispatch failed for %s", game.id) diff --git a/src/wolfbot/services/structured_logging.py b/src/wolfbot/services/structured_logging.py new file mode 100644 index 0000000..c283470 --- /dev/null +++ b/src/wolfbot/services/structured_logging.py @@ -0,0 +1,164 @@ +"""Shared structured logging helper for master / voice-ingest / NPC workers. + +Per the day-discussion + voice-ingest + npc-voice-pipeline specs, every +in-band log entry must carry the cross-cutting fields `ts`, `level`, +`component`, `event`, `game_id`, `phase_id`, `trace_id`, `span_id` (when +known) plus event-specific fields. We standardize on Python's stdlib +`logging` with a small adapter that injects required fields and produces +JSON-shaped messages. + +This module is dependency-light so all three components can import it. +The `discussion_phase_summary` event is emitted from the Master at every +public-speech phase end; helper builders return the fully-shaped dict so +tests can assert presence and specific counts. + +Test surface: ``CapturingHandler`` records every emitted event so tests +can assert ``events_with(name=..., game_id=...)`` without mocking stdlib +logging primitives. +""" + +from __future__ import annotations + +import logging +import time +from collections.abc import Iterator +from dataclasses import dataclass, field +from typing import Any + +# Standardized component identifiers used in `component=` log fields. +COMPONENT_MASTER = "master" +COMPONENT_VOICE_INGEST = "voice-ingest" +COMPONENT_NPC_BOT = "npc-bot" + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def emit_event( + logger: logging.Logger, + *, + event: str, + component: str, + level: int = logging.INFO, + game_id: str | None = None, + phase_id: str | None = None, + trace_id: str | None = None, + span_id: str | None = None, + **fields: Any, +) -> None: + """Emit a structured log event with the canonical envelope fields. + + Underlying logging keeps the event in `extra` so `CapturingHandler` + can introspect it without parsing a JSON-formatted message. + """ + payload: dict[str, Any] = { + "ts": _now_ms(), + "component": component, + "event": event, + "game_id": game_id, + "phase_id": phase_id, + "trace_id": trace_id, + "span_id": span_id, + **fields, + } + logger.log(level, "%s", event, extra={"structured_event": payload}) + + +@dataclass +class _CapturedEvent: + name: str + payload: dict[str, Any] = field(default_factory=dict) + + +class CapturingHandler(logging.Handler): + """Test-only handler that records every structured event. + + Use via: + + handler = CapturingHandler() + logging.getLogger("wolfbot").addHandler(handler) + ... + handler.events_with(name="discussion_phase_summary", game_id="g1") + """ + + def __init__(self) -> None: + super().__init__(level=logging.DEBUG) + self.events: list[_CapturedEvent] = [] + + def emit(self, record: logging.LogRecord) -> None: + payload = getattr(record, "structured_event", None) + if not isinstance(payload, dict): + return + name = payload.get("event") + if name is None: + return + self.events.append(_CapturedEvent(name=str(name), payload=dict(payload))) + + def events_with(self, *, name: str, **filters: Any) -> Iterator[_CapturedEvent]: + for ev in self.events: + if ev.name != name: + continue + if all(ev.payload.get(k) == v for k, v in filters.items()): + yield ev + + +# ---------------------------------------------------------------- builders + + +def build_discussion_phase_summary( + *, + game_id: str, + phase_id: str, + mode: str, + speech_events_total: int, + human_speech_events: int, + npc_speech_events: int, + stt_success: int = 0, + stt_failed: int = 0, + logic_packets_built: int = 0, + speak_requests_sent: int = 0, + speak_results_accepted: int = 0, + speak_results_rejected: int = 0, + playback_authorized: int = 0, + tts_success: int = 0, + tts_failed: int = 0, + playback_success: int = 0, + playback_failed: int = 0, + stale_dropped: int = 0, +) -> dict[str, Any]: + """Build the `discussion_phase_summary` payload (without emitting). + + Master calls ``emit_event(... event="discussion_phase_summary", **payload)`` + once per phase end with this shape. + """ + return { + "game_id": game_id, + "phase_id": phase_id, + "mode": mode, + "speech_events_total": speech_events_total, + "human_speech_events": human_speech_events, + "npc_speech_events": npc_speech_events, + "stt_success": stt_success, + "stt_failed": stt_failed, + "logic_packets_built": logic_packets_built, + "speak_requests_sent": speak_requests_sent, + "speak_results_accepted": speak_results_accepted, + "speak_results_rejected": speak_results_rejected, + "playback_authorized": playback_authorized, + "tts_success": tts_success, + "tts_failed": tts_failed, + "playback_success": playback_success, + "playback_failed": playback_failed, + "stale_dropped": stale_dropped, + } + + +__all__ = [ + "COMPONENT_MASTER", + "COMPONENT_NPC_BOT", + "COMPONENT_VOICE_INGEST", + "CapturingHandler", + "build_discussion_phase_summary", + "emit_event", +] diff --git a/src/wolfbot/ui/settings_view.py b/src/wolfbot/ui/settings_view.py new file mode 100644 index 0000000..63aa43b --- /dev/null +++ b/src/wolfbot/ui/settings_view.py @@ -0,0 +1,265 @@ +"""`/wolf settings` interactive UI for runtime-mutable phase durations. + +Permission model: only the host of the currently-active game can open the +view, and `interaction_check` re-validates on every nested interaction so +a passed-around ephemeral message still rejects non-host clicks. + +Persistence model: process memory only — `set_phase_durations` swaps the +singleton in `wolfbot.domain.durations`. A Master restart resets to env- +derived defaults. This matches the user's choice of "no DB" for v1; if +persistence is added later, the View hooks stay the same and only the +write path needs to also persist. + +Components: +- ``PhaseDurationsView``: top-level view with a per-field Select, a + global-factor Button, and a "reset to defaults" Button. +- ``_DurationEditModal``: 1-field modal opened by the Select for fine- + grained edits (current value pre-filled). +- ``_FactorModal``: 1-field modal for the with_factor bulk knob. +""" + +from __future__ import annotations + +import logging +from dataclasses import replace + +import discord + +from wolfbot.domain.durations import ( + PhaseDurations, + current_phase_durations, + reset_phase_durations_to_defaults, + set_phase_durations, +) + +log = logging.getLogger(__name__) + + +_DURATION_FIELD_LABELS: tuple[tuple[str, str], ...] = ( + ("vote", "投票"), + ("runoff", "決選投票"), + ("night", "夜"), + ("day_discussion_grace", "議論猶予"), + ("runoff_speech_grace", "決選演説猶予"), + ("discussion_day1", "議論 (1日目)"), + ("discussion_day2", "議論 (2日目)"), + ("discussion_day3plus", "議論 (3日目以降)"), +) + + +def _format_durations_embed(d: PhaseDurations) -> discord.Embed: + embed = discord.Embed( + title="⚙️ Phase Durations", + description=( + "プロセスメモリ上で設定が反映されます (Master 再起動でデフォルトに戻ります)。\n" + "現在の値を選択肢から編集するか、ボタンで一括変更できます。" + ), + color=discord.Color.blurple(), + ) + for field_name, label in _DURATION_FIELD_LABELS: + embed.add_field( + name=f"{label} ({field_name})", + value=f"`{getattr(d, field_name)}` 秒", + inline=True, + ) + return embed + + +class _DurationEditModal(discord.ui.Modal): + """Single-field modal that updates one PhaseDurations attribute.""" + + def __init__( + self, + *, + field_name: str, + label: str, + current_value: int, + parent: PhaseDurationsView, + ) -> None: + super().__init__(title=f"{label} を変更", timeout=180) + self._field_name = field_name + self._parent = parent + self.value_input: discord.ui.TextInput[_DurationEditModal] = discord.ui.TextInput( + label=f"{label} (秒、整数 ≥1)", + default=str(current_value), + required=True, + max_length=8, + ) + self.add_item(self.value_input) + + async def on_submit(self, interaction: discord.Interaction) -> None: + raw = self.value_input.value.strip() + try: + value = int(raw) + except ValueError: + await interaction.response.send_message( + f"整数で入力してください (`{raw}` は不正)。", ephemeral=True + ) + return + if value < 1: + await interaction.response.send_message( + "1 秒以上の値を指定してください。", ephemeral=True + ) + return + new_durations = replace(current_phase_durations(), **{self._field_name: value}) + set_phase_durations(new_durations) + log.info( + "phase_duration_updated field=%s value=%d by_user=%s", + self._field_name, + value, + interaction.user.id, + ) + await self._parent.refresh(interaction, new_durations) + + +class _FactorModal(discord.ui.Modal): + """Modal for the bulk `with_factor` knob.""" + + def __init__(self, *, parent: PhaseDurationsView) -> None: + super().__init__(title="全フェイズを一括スケール", timeout=180) + self._parent = parent + self.factor_input: discord.ui.TextInput[_FactorModal] = discord.ui.TextInput( + label="スケール係数 (例 0.5 で半分、2.0 で倍)", + default="1.0", + required=True, + max_length=8, + ) + self.add_item(self.factor_input) + + async def on_submit(self, interaction: discord.Interaction) -> None: + raw = self.factor_input.value.strip() + try: + factor = float(raw) + except ValueError: + await interaction.response.send_message( + f"数値で入力してください (`{raw}` は不正)。", ephemeral=True + ) + return + if factor <= 0: + await interaction.response.send_message( + "0 より大きい値を指定してください。", ephemeral=True + ) + return + new_durations = current_phase_durations().with_factor(factor) + set_phase_durations(new_durations) + log.info( + "phase_durations_scaled factor=%s by_user=%s", + factor, + interaction.user.id, + ) + await self._parent.refresh(interaction, new_durations) + + +class _DurationFieldSelect(discord.ui.Select["PhaseDurationsView"]): + """Select dropdown listing each duration field with its current value.""" + + def __init__(self, current: PhaseDurations) -> None: + options = [ + discord.SelectOption( + label=f"{label}", + value=field_name, + description=f"現在 {getattr(current, field_name)} 秒", + ) + for field_name, label in _DURATION_FIELD_LABELS + ] + super().__init__( + placeholder="編集する項目を選択…", + min_values=1, + max_values=1, + options=options, + ) + + async def callback(self, interaction: discord.Interaction) -> None: + assert self.view is not None + field_name = self.values[0] + label = next( + (lbl for fn, lbl in _DURATION_FIELD_LABELS if fn == field_name), + field_name, + ) + modal = _DurationEditModal( + field_name=field_name, + label=label, + current_value=getattr(current_phase_durations(), field_name), + parent=self.view, + ) + await interaction.response.send_modal(modal) + + +class PhaseDurationsView(discord.ui.View): + """Top-level View backing `/wolf settings`. + + Restricted to ``host_user_id`` — every interaction is gated through + ``interaction_check`` so the ephemeral message can't be exploited + even if its token leaks. The original slash command handler is also + expected to do the same check before posting the View; this is the + second line of defense. + """ + + def __init__(self, *, host_user_id: str, timeout: float = 600.0) -> None: + super().__init__(timeout=timeout) + self._host_user_id = host_user_id + self._select = _DurationFieldSelect(current_phase_durations()) + self.add_item(self._select) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if str(interaction.user.id) != self._host_user_id: + await interaction.response.send_message( + "この設定パネルはホスト専用です。", ephemeral=True + ) + return False + return True + + async def refresh( + self, interaction: discord.Interaction, new_durations: PhaseDurations + ) -> None: + """Re-render the embed + select after a value changed.""" + # Rebuild the select so its option descriptions reflect the new + # current values (Discord won't re-query a Select's options). + self.remove_item(self._select) + self._select = _DurationFieldSelect(new_durations) + self.add_item(self._select) + embed = _format_durations_embed(new_durations) + await interaction.response.edit_message(embed=embed, view=self) + + @discord.ui.button( + label="一括スケール…", + style=discord.ButtonStyle.secondary, + custom_id="wolfbot:settings:factor", + ) + async def factor_button( + self, + interaction: discord.Interaction, + _button: discord.ui.Button[PhaseDurationsView], + ) -> None: + await interaction.response.send_modal(_FactorModal(parent=self)) + + @discord.ui.button( + label="デフォルトに戻す", + style=discord.ButtonStyle.danger, + custom_id="wolfbot:settings:reset", + ) + async def reset_button( + self, + interaction: discord.Interaction, + _button: discord.ui.Button[PhaseDurationsView], + ) -> None: + reset_phase_durations_to_defaults() + log.info( + "phase_durations_reset_to_defaults by_user=%s", interaction.user.id + ) + await self.refresh(interaction, current_phase_durations()) + + +def render_initial_message( + *, host_user_id: str +) -> tuple[discord.Embed, PhaseDurationsView]: + """Build the embed + view used by the `/wolf settings` slash command.""" + view = PhaseDurationsView(host_user_id=host_user_id) + embed = _format_durations_embed(current_phase_durations()) + return embed, view + + +__all__ = [ + "PhaseDurationsView", + "render_initial_message", +] diff --git a/src/wolfbot/voicetest/__init__.py b/src/wolfbot/voicetest/__init__.py new file mode 100644 index 0000000..42499fa --- /dev/null +++ b/src/wolfbot/voicetest/__init__.py @@ -0,0 +1,8 @@ +"""Standalone voice-capture test bot. + +Joins one VC, runs the production audio path (``WolfbotAudioSink`` → +``VoiceIngestService``) with STT stubbed out, and writes the same +``audio_debug/`` WAV+txt sidecars Master would. Use it to validate the +recording / PCM-assembly path in isolation, in particular to A/B +``SilenceGeneratorSink`` on/off without running a full game. +""" diff --git a/src/wolfbot/voicetest/main.py b/src/wolfbot/voicetest/main.py new file mode 100644 index 0000000..16300c2 --- /dev/null +++ b/src/wolfbot/voicetest/main.py @@ -0,0 +1,561 @@ +"""Voice-capture test bot — entrypoint. + +Reuses the same ``WolfbotAudioSink`` → ``VoiceIngestService`` path that +Master runs in production, but with: + +* STT stubbed (``_NoOpSttService`` returns confidence=1.0 / text="" so + the ingest service reaches the success branch and the per-segment + audio dump fires unchanged). +* Master WS client stubbed to in-process logs. +* ``seat_lookup`` / ``phase_lookup`` returning fixed values so the + ingest service treats every speaker as seat 1 in a synthetic + ``voicetest::day1::DAY_DISCUSSION::1`` phase. + +Segment boundaries come from the same Discord +``on_voice_member_speaking_start`` / ``on_voice_member_speaking_stop`` +listeners the production sink uses. + +The ``SilenceGeneratorSink`` wrap is toggled by +``VOICETEST_USE_SILENCE_GENERATOR`` so an operator can record the +same speech twice (on / off) and diff the resulting WAVs. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import os +import signal +from collections.abc import Callable, Sequence + +import discord +from discord.ext import voice_recv +from dotenv import load_dotenv +from pydantic import SecretStr +from pydantic_settings import BaseSettings, SettingsConfigDict + +from wolfbot.domain.ws_messages import ( + Heartbeat, + SpeechEventPayload, + SttFailed, + VadSpeechEnded, + VadSpeechStarted, +) +from wolfbot.master.voice.audio_sink import WolfbotAudioSink +from wolfbot.master.voice.stt_service import RosterEntry, SttResult +from wolfbot.master.voice.voice_ingest_service import ( + VoiceIngestConfig, + VoiceIngestService, +) + +log = logging.getLogger("wolfbot.voicetest") + +_FAKE_GAME_ID = "voicetest" +_FAKE_PHASE_ID = "voicetest::day1::DAY_DISCUSSION::1" + + +class VoicetestSettings(BaseSettings): + """Voice-test bot configuration. + + Env file path is selected by ``WOLFBOT_VOICETEST_ENV``; default is + ``.env.voicetest`` in the working directory. + """ + + model_config = SettingsConfigDict(env_file=None, extra="ignore") + + VOICETEST_DISCORD_TOKEN: SecretStr + VOICETEST_GUILD_ID: int + VOICETEST_VOICE_CHANNEL_ID: int + WOLFBOT_VOICE_DEBUG_DIR: str + VOICETEST_USE_SILENCE_GENERATOR: bool = True + # Drop segments whose buffered audio sounds like silence rather than + # speech. Discord's speaking-start fires on any audio above a low + # threshold (breathing, keyboard noise, room hum), so without this + # gate the dump dir fills with noise-only WAVs. + # ``MIN_RMS`` is a 16-bit signed RMS threshold computed across the + # full segment; speech is typically 1000-5000, breathing/typing + # 100-500, true silence 0-100. + VOICETEST_MIN_RMS: int = 600 + # ``MIN_DURATION_MS`` rejects micro-bursts (mouse clicks, single + # syllables triggered by the silence-padding playback). Real + # utterances are almost always ≥300ms. + VOICETEST_MIN_DURATION_MS: int = 300 + # When true, run kept segments through the same voice-ingest stack + # the production master uses (the pipeline shape is selected by + # ``VOICE_PIPELINE`` on .env.master). When false (default), STT is + # stubbed and only the WAV is dumped. + VOICETEST_USE_STT: bool = False + LOG_LEVEL: str = "INFO" + + # ----- production-equivalent ingest credentials ----- + # These mirror the new structural settings on ``MasterSettings``. + # ``_run`` loads ``.env.master`` first and the voicetest env file + # second (with override), so voicetest transparently reuses whatever + # voice / text pipeline production is configured for — no + # duplication of API keys in the voicetest env. + VOICE_PIPELINE: str = "disabled" + AUDIO_ANALYZER_API_KEY: SecretStr | None = None + AUDIO_ANALYZER_MODEL: str = "gemini-2.0-flash-lite" + STT_API_KEY: SecretStr | None = None + STT_MODEL: str = "whisper-large-v3-turbo" + STT_BASE_URL: str = "https://api.groq.com/openai/v1" + TEXT_ANALYZER_PROVIDER: str = "xai" + TEXT_ANALYZER_API_KEY: SecretStr | None = None + TEXT_ANALYZER_MODEL: str = "grok-4-1-fast" + TEXT_ANALYZER_BASE_URL: str | None = None + # Pre-STT silence gate, mirrored from MasterSettings so the voice + # path uses the same threshold as production. ``0`` disables. + VOICE_PRE_STT_MIN_RMS: int = 0 + VOICE_PRE_STT_MIN_DURATION_MS: int = 0 + + +class _NoNpcRegistryView: + """Always returns False — the voice-test session has no NPC bots.""" + + def is_npc(self, discord_user_id: str) -> bool: + return False + + def npc_user_ids(self) -> set[str]: + return set() + + +class _NoOpMasterClient: + """Logs each outbound event instead of sending over WS.""" + + async def send_vad_started(self, msg: VadSpeechStarted) -> None: + log.debug( + "vad_started seat=%s segment=%s ts=%s", + msg.seat_no, msg.segment_id, msg.ts, + ) + + async def send_vad_ended(self, msg: VadSpeechEnded) -> None: + log.debug( + "vad_ended seat=%s segment=%s ts=%s", + msg.seat_no, msg.segment_id, msg.ts, + ) + + async def send_speech_event_payload(self, msg: SpeechEventPayload) -> None: + log.info( + "segment_dumped seat=%s segment=%s window=%s→%sms text=%r", + msg.seat_no, + msg.segment_id, + msg.audio_start_ms, + msg.audio_end_ms, + msg.text, + ) + + async def send_stt_failed(self, msg: SttFailed) -> None: + log.info( + "stt_failed seat=%s segment=%s reason=%s", + msg.seat_no, msg.segment_id, msg.failure_reason, + ) + + async def send_heartbeat(self, msg: Heartbeat) -> None: + return None + + +class _NoOpSttService: + """High-confidence empty STT result. + + ``VoiceIngestService._run_stt_inner`` reaches the success branch + (confidence ≥ 0.6) and triggers the per-segment dump. ``text`` + stays empty so the ``.txt`` sidecar shows ``transcript: `` — + the WAV is what matters here, not the transcript. + """ + + async def transcribe( + self, + *, + audio: bytes, + language: str, + timeout_s: float, + roster: Sequence[RosterEntry] | None = None, + ) -> SttResult: + del roster # Voicetest no-op STT has no analyzer to ground. + bytes_per_sec = 48_000 * 2 * 2 # matches VoiceIngestConfig defaults + duration_ms = int(len(audio) / bytes_per_sec * 1000) if bytes_per_sec else 0 + return SttResult( + text="", + confidence=1.0, + duration_ms=duration_ms, + ) + + +def _seat_lookup(_uid: str) -> int | None: + return 1 + + +def _phase_lookup() -> tuple[str, str] | None: + return (_FAKE_GAME_ID, _FAKE_PHASE_ID) + + +def _make_live_vc_roster_lookup( + bot: discord.Client, voice_channel_id: int +) -> Callable[[], list[tuple[int, str]]]: + """Build a roster lookup that pulls live VC display names. + + The voicetest bot is in the same voice channel as the human + speaker plus any NPC bots, so the channel's member list is the + ground truth for "what names does the speaker actually see / + hear in this room". Mirrors the production resolver in + ``main.py`` which falls back to ``Seat.display_name`` only when + the live member is uncached - operators reported NPC bots + sometimes appear in VC under a different nickname than the + persona's stored ``display_name`` (e.g. operator renamed the + bot in server settings), and the analyzer needs to ground on + the *spoken* name, not the internal canonical handle. + """ + + def lookup() -> list[tuple[int, str]]: + ch = bot.get_channel(voice_channel_id) + if not isinstance(ch, discord.VoiceChannel): + return [] + # ``ch.members`` is the cached list of guild members + # currently connected to this voice channel. Sort by id so + # the seat numbering stays stable across calls. + members = sorted(ch.members, key=lambda m: m.id) + roster: list[tuple[int, str]] = [] + for i, member in enumerate(members, start=1): + roster.append((i, member.display_name)) + return roster + + return lookup + + +def _build_production_stt(settings: VoicetestSettings): # type: ignore[no-untyped-def] + """Construct the same voice-ingest provider the production master uses. + + The pipeline shape is selected by ``VOICE_PIPELINE`` on .env.master + (``audio_analyzer`` or ``stt_then_text_analyzer``). Raises + ``SystemExit`` with a precise hint when the chosen pipeline's + credentials are missing — the operator should fix the production + env, not duplicate keys here. + """ + pipeline = (settings.VOICE_PIPELINE or "disabled").lower() + if pipeline == "stt_then_text_analyzer": + if settings.STT_API_KEY is None: + raise SystemExit( + "VOICETEST_USE_STT=true with " + "VOICE_PIPELINE=stt_then_text_analyzer requires " + "STT_API_KEY (set in .env.master)." + ) + if settings.TEXT_ANALYZER_API_KEY is None: + raise SystemExit( + "VOICETEST_USE_STT=true with " + "VOICE_PIPELINE=stt_then_text_analyzer requires " + "TEXT_ANALYZER_API_KEY for the analyzer step " + "(set in .env.master)." + ) + from wolfbot.master.ingest_factory import ( + _openai_compat_default_base_url, + ) + from wolfbot.master.voice.stt_service import GroqWhisperAudioAnalyzer + + analyzer_base_url = ( + settings.TEXT_ANALYZER_BASE_URL + or _openai_compat_default_base_url(settings.TEXT_ANALYZER_PROVIDER) + ) + log.info( + "voicetest_stt=ON pipeline=stt_then_text_analyzer stt=%s analyzer=%s @ %s", + settings.STT_MODEL, + settings.TEXT_ANALYZER_MODEL, + analyzer_base_url, + ) + return GroqWhisperAudioAnalyzer( + groq_api_key=settings.STT_API_KEY.get_secret_value(), + groq_model=settings.STT_MODEL, + groq_base_url=settings.STT_BASE_URL, + analyzer_api_key=settings.TEXT_ANALYZER_API_KEY.get_secret_value(), + analyzer_model=settings.TEXT_ANALYZER_MODEL, + analyzer_base_url=analyzer_base_url, + ) + + if pipeline == "audio_analyzer": + if settings.AUDIO_ANALYZER_API_KEY is None: + raise SystemExit( + "VOICETEST_USE_STT=true with VOICE_PIPELINE=audio_analyzer " + "requires AUDIO_ANALYZER_API_KEY (set in .env.master)." + ) + from wolfbot.master.voice.stt_service import GeminiAudioAnalyzer + + log.info( + "voicetest_stt=ON pipeline=audio_analyzer model=%s", + settings.AUDIO_ANALYZER_MODEL, + ) + return GeminiAudioAnalyzer( + api_key=settings.AUDIO_ANALYZER_API_KEY.get_secret_value(), + model=settings.AUDIO_ANALYZER_MODEL, + ) + + raise SystemExit( + f"VOICETEST_USE_STT=true requires VOICE_PIPELINE in " + f"{{audio_analyzer, stt_then_text_analyzer}} (got {pipeline!r}). " + "Set VOICE_PIPELINE in .env.master and re-run." + ) + + +def _pcm_rms_s16le(pcm: bytes) -> int: + """Root-mean-square magnitude across all 16-bit signed LE samples. + + Returns 0 for empty / odd-length buffers (treat as silence). + Uses pure Python so we don't pick up an audioop deprecation + surface and don't pull numpy into the voicetest dependency + closure. + """ + n = len(pcm) // 2 + if n == 0: + return 0 + import struct + + samples = struct.unpack(f"<{n}h", pcm[: n * 2]) + sq = 0 + for s in samples: + sq += s * s + return int((sq / n) ** 0.5) + + +def _install_silence_gate( + *, + min_rms: int, + min_duration_ms: int, + pcm_bytes_per_ms: int, +) -> None: + """Wrap ``voice_debug_dump.dump_segment`` with an RMS+duration gate. + + Discord's speaking-start indicator fires on any audio above a low + threshold (breathing, typing, room noise), and the + SilenceGeneratorSink keeps the segment "open" by injecting zero + frames. The result is a steady stream of dumps whose buffer is + mostly silence with sub-syllable spikes - useless for inspecting + actual speech recordings. + + ``VoiceIngestService._run_stt_inner`` does + ``from wolfbot.master.voice.voice_debug_dump import dump_segment`` + *inside the function body*, so a module-level monkey-patch + applied before any segment fires will be picked up by every + subsequent call without touching production code. + """ + import dataclasses + from datetime import datetime + + import wolfbot.master.voice.voice_debug_dump as _dump_mod + + original = _dump_mod.dump_segment + + def _stamp(audio_start_ms: int) -> str: + # Local-time wall clock so the operator scanning ``ls`` in the + # dump dir can see at a glance which utterance came when. ``ms`` + # disambiguates back-to-back segments that fire in the same + # second, which is common during silence-padded VAD bursts. + dt = datetime.fromtimestamp(audio_start_ms / 1000.0) + return dt.strftime("%Y%m%d-%H%M%S-") + f"{audio_start_ms % 1000:03d}" + + async def gated_dump_segment(record, pcm): # type: ignore[no-untyped-def] + duration_ms = len(pcm) // pcm_bytes_per_ms if pcm_bytes_per_ms else 0 + rms = _pcm_rms_s16le(pcm) + if duration_ms < min_duration_ms: + log.info( + "voicetest_dump_skip reason=too_short segment=%s duration_ms=%d rms=%d", + record.segment_id, + duration_ms, + rms, + ) + return + if rms < min_rms: + log.info( + "voicetest_dump_skip reason=below_rms segment=%s rms=%d duration_ms=%d", + record.segment_id, + rms, + duration_ms, + ) + return + log.info( + "voicetest_dump_keep segment=%s rms=%d duration_ms=%d", + record.segment_id, + rms, + duration_ms, + ) + # Prepend a wall-clock timestamp to ``segment_id`` so the + # underlying ``dump_segment`` writes ``_.wav``. + # The sanitizer keeps ``-`` and ``_`` intact, so this passes + # through unchanged. + stamped = dataclasses.replace( + record, + segment_id=f"{_stamp(record.audio_start_ms)}_{record.segment_id}", + ) + await original(stamped, pcm) + + _dump_mod.dump_segment = gated_dump_segment + + +async def _run() -> None: + # Load production credentials first so ingest keys + # (AUDIO_ANALYZER_API_KEY, STT_API_KEY, TEXT_ANALYZER_API_KEY, + # GAMEPLAY_LLM_API_KEY, etc.) come through without + # duplication. The voicetest-specific file is loaded second with + # ``override=True`` so any voicetest-only setting wins - including + # the discord token, which is intentionally a different bot from + # the master. + load_dotenv(".env.master") + env_path = os.environ.get("WOLFBOT_VOICETEST_ENV", ".env.voicetest") + load_dotenv(env_path, override=True) + settings = VoicetestSettings() # type: ignore[call-arg] + + # The dump module reads WOLFBOT_VOICE_DEBUG_DIR off os.environ — make + # sure it's set even if the operator put it in the .env file rather + # than the parent shell environment. + os.environ["WOLFBOT_VOICE_DEBUG_DIR"] = settings.WOLFBOT_VOICE_DEBUG_DIR + + logging.basicConfig( + level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + + # Default intents already include ``voice_states``, which is all we + # need for ``on_voice_member_speaking_*`` to fire. We deliberately do + # NOT request the privileged ``members`` intent so this bot can be + # run without enabling Server Members Intent in the developer + # portal — the trade-off is that ``member.display_name`` may fall + # back to the global username (User.name) when the member isn't in + # the cache, which only affects the dump subdirectory name. + intents = discord.Intents.default() + bot = discord.Client(intents=intents) + + ingest_config = VoiceIngestConfig( + pre_stt_min_rms=settings.VOICE_PRE_STT_MIN_RMS, + pre_stt_min_duration_ms=settings.VOICE_PRE_STT_MIN_DURATION_MS, + ) + pcm_bytes_per_ms = ( + ingest_config.pcm_sample_rate + * ingest_config.pcm_channels + * ingest_config.pcm_sample_width + // 1000 + ) + _install_silence_gate( + min_rms=settings.VOICETEST_MIN_RMS, + min_duration_ms=settings.VOICETEST_MIN_DURATION_MS, + pcm_bytes_per_ms=pcm_bytes_per_ms, + ) + log.info( + "voicetest_silence_gate min_rms=%d min_duration_ms=%d", + settings.VOICETEST_MIN_RMS, + settings.VOICETEST_MIN_DURATION_MS, + ) + + from wolfbot.master.voice.stt_service import SttService + + stt_service: SttService + if settings.VOICETEST_USE_STT: + stt_service = _build_production_stt(settings) + else: + stt_service = _NoOpSttService() + log.info("voicetest_stt=OFF (transcript will be empty)") + + voice_ingest = VoiceIngestService( + registry_view=_NoNpcRegistryView(), + master_client=_NoOpMasterClient(), + stt=stt_service, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup, + config=ingest_config, + roster_lookup=_make_live_vc_roster_lookup( + bot, settings.VOICETEST_VOICE_CHANNEL_ID + ), + ) + + stop = asyncio.Event() + vc_ref: list[voice_recv.VoiceRecvClient | None] = [None] + + async def _join_vc() -> None: + from wolfbot.master.voice.voice_recv_dave_patch import ( + apply_dave_decrypt_patch, + ) + from wolfbot.master.voice.voice_recv_resilience import ( + apply_packet_router_resilience, + ) + + apply_packet_router_resilience() + apply_dave_decrypt_patch() + + ch = bot.get_channel(settings.VOICETEST_VOICE_CHANNEL_ID) + if not isinstance(ch, discord.VoiceChannel): + log.error( + "voicetest_channel_not_found id=%s", + settings.VOICETEST_VOICE_CHANNEL_ID, + ) + stop.set() + return + try: + vc = await ch.connect(cls=voice_recv.VoiceRecvClient) + except Exception: + log.exception("voicetest_join_failed") + stop.set() + return + + sink: voice_recv.AudioSink = WolfbotAudioSink( + voice_ingest, loop=asyncio.get_running_loop() + ) + if settings.VOICETEST_USE_SILENCE_GENERATOR: + sink = voice_recv.SilenceGeneratorSink(sink) + log.info("voicetest_silence_generator=ON (matches production)") + else: + log.info("voicetest_silence_generator=OFF (raw real-frames only)") + vc.listen(sink) + vc_ref[0] = vc + log.info( + "voicetest_joined channel=%s dump_dir=%s", + settings.VOICETEST_VOICE_CHANNEL_ID, + settings.WOLFBOT_VOICE_DEBUG_DIR, + ) + + # Snapshot the roster the analyzer will see and log it once + # so the operator can confirm at a glance which display + # names will end up in the system prompt for this session. + roster_now = voice_ingest.roster_lookup() if voice_ingest.roster_lookup else [] + if roster_now: + log.info( + "voicetest_roster_snapshot %s", + ", ".join(f"席{seat}: {name}" for seat, name in roster_now), + ) + else: + log.warning( + "voicetest_roster_empty - analyzer will fall back to " + "the un-grounded prompt (no VC members visible yet)" + ) + + @bot.event + async def on_ready() -> None: + log.info("voicetest_ready user=%s", bot.user) + await _join_vc() + + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + with contextlib.suppress(NotImplementedError): + loop.add_signal_handler(sig, stop.set) + + bot_task = asyncio.create_task( + bot.start(settings.VOICETEST_DISCORD_TOKEN.get_secret_value()) + ) + + try: + await stop.wait() + finally: + log.info("voicetest_shutting_down") + vc = vc_ref[0] + if vc is not None: + with contextlib.suppress(Exception): + if vc.is_connected(): + await vc.disconnect() + with contextlib.suppress(Exception): + await bot.close() + with contextlib.suppress(Exception): + await bot_task + + +def run() -> None: + asyncio.run(_run()) + + +if __name__ == "__main__": + run() diff --git a/tests/conftest.py b/tests/conftest.py index 3f8ece5..14bea49 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,13 +3,20 @@ - `repo`: a SqliteRepo backed by a tempfile DB with migrate() already run. - `seats`: a deterministic 9-seat lineup (5 humans + 4 LLMs by default). - `frozen_rng`: random.Random(seed) for reproducible role shuffles. +- `_isolate_llm_trace_dir` (autouse, session): redirect the LLM trace + output to a session-temp dir so a decider test that calls + ``decider.decide()`` without entering ``trace_context`` never leaks + rows into the repo's production ``logs/llm_calls/no_game/`` (one such + leak accumulated 700+ stray ``actor=None`` lines in production + before this fixture was added). """ from __future__ import annotations +import os import random import tempfile -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Iterator from pathlib import Path import pytest @@ -20,6 +27,28 @@ from wolfbot.persistence.sqlite_repo import SqliteRepo +@pytest.fixture(scope="session", autouse=True) +def _isolate_llm_trace_dir( + tmp_path_factory: pytest.TempPathFactory, +) -> Iterator[Path]: + """Pin ``WOLFBOT_LLM_TRACE_DIR`` to a per-session temp dir for every + test. Function-scoped fixtures that override the env var (e.g. the + ``trace_dir`` fixture in ``test_llm_service.py``) still take + precedence — pytest's monkeypatch undoes its setenv at teardown, + after which ``os.environ`` falls back to this session-level value. + """ + sandbox = tmp_path_factory.mktemp("llm_trace_sandbox") + prev = os.environ.get("WOLFBOT_LLM_TRACE_DIR") + os.environ["WOLFBOT_LLM_TRACE_DIR"] = str(sandbox) + try: + yield sandbox + finally: + if prev is None: + os.environ.pop("WOLFBOT_LLM_TRACE_DIR", None) + else: + os.environ["WOLFBOT_LLM_TRACE_DIR"] = prev + + @pytest.fixture def frozen_rng() -> random.Random: return random.Random(42) diff --git a/tests/test_addressed_npc_routing.py b/tests/test_addressed_npc_routing.py new file mode 100644 index 0000000..86de66c --- /dev/null +++ b/tests/test_addressed_npc_routing.py @@ -0,0 +1,981 @@ +"""Addressed-NPC priority routing — end-to-end coverage. + +Verifies the four-layer change so a human's named-address ('セツさん、どう +思う?') is dispatched to the addressed NPC seat first, with the utterance +text forwarded to that NPC's logic packet so the LLM can actually reply +on-topic: + +1. Gemini analyzer prompt parses `addressed_name` from the JSON envelope. +2. `MasterIngestService.ingest_voice` resolves `addressed_name` against the + live seats table to populate `SpeechEvent.addressed_seat_no`. +3. `PublicDiscussionState` fold tracks the most recent unanswered address. +4. `SpeakArbiter.try_dispatch_next` picks the addressed NPC ahead of silent + seats and lowest-seat tiebreaker, and `build_logic_packet` includes the + utterance in the packet summary. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from wolfbot.domain.discussion import ( + PublicDiscussionState, + make_phase_id, +) +from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.models import Game, Seat +from wolfbot.domain.ws_messages import SpeechEventPayload +from wolfbot.master.arbiter.ingest_service import ( + MasterIngestService, + resolve_seat_by_name, +) +from wolfbot.master.arbiter.logic_service import build_logic_packet +from wolfbot.master.arbiter.speak_arbiter import SpeakArbiter +from wolfbot.master.voice.stt_service import GeminiAudioAnalyzer +from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry +from wolfbot.persistence.sqlite_repo import SqliteRepo +from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, + make_human_text_event, + make_npc_generated_event, + make_phase_baseline, + rebuild_public_state_from_events, +) + +# ------------------------------------------------------ Layer 1: Gemini parser + + +def test_gemini_analyzer_parses_addressed_name() -> None: + """The analyzer's JSON parser must surface `addressed_name` on SttResult.""" + raw = ( + '{"transcript":"セツさん、どう思う?","summary":"セツに意見を求めた",' + '"confidence":0.91,"co_claim":null,"vote_target_seat":null,' + '"stance":{},"addressed_name":"セツ"}' + ) + parsed = GeminiAudioAnalyzer._parse_response(raw) + assert parsed.get("addressed_name") == "セツ" + + +def test_gemini_analyzer_addressed_name_null_when_global_address() -> None: + """Non-personal addresses ('みんな') must not surface as addressed_name.""" + raw = ( + '{"transcript":"みんなどう思う?","summary":"全体への問いかけ",' + '"confidence":0.88,"co_claim":null,"vote_target_seat":null,' + '"stance":{},"addressed_name":null}' + ) + parsed = GeminiAudioAnalyzer._parse_response(raw) + assert parsed.get("addressed_name") is None + + +# ------------------------------------------------------ Layer 2: name → seat resolver + + +def _seat(no: int, *, name: str, persona: str | None = None, llm: bool = True) -> Seat: + return Seat( + seat_no=no, + display_name=name, + discord_user_id=None if llm else f"u{no}", + is_llm=llm, + persona_key=persona, + ) + + +def test_resolve_seat_by_name_strips_emoji_and_honorific() -> None: + seats = [ + _seat(1, name="Alice", persona=None, llm=False), + _seat(2, name="🌙セツ", persona="setsu"), + _seat(3, name="🟣ジナ", persona="gina"), + ] + assert resolve_seat_by_name("セツさん", seats) == 2 + assert resolve_seat_by_name("ジナ", seats) == 3 + assert resolve_seat_by_name("ジナちゃん", seats) == 3 + + +def test_resolve_seat_by_name_recognises_seat_number_forms() -> None: + seats = [ + _seat(1, name="Alice", persona=None, llm=False), + _seat(2, name="🌙セツ", persona="setsu"), + _seat(3, name="🟣ジナ", persona="gina"), + ] + assert resolve_seat_by_name("席3", seats) == 3 + assert resolve_seat_by_name("3番", seats) == 3 + assert resolve_seat_by_name("seat 2", seats) == 2 + + +def test_resolve_seat_by_name_returns_none_for_unknown_or_dead() -> None: + seats = [ + _seat(2, name="🌙セツ", persona="setsu"), + _seat(3, name="🟣ジナ", persona="gina"), + ] + assert resolve_seat_by_name("ククルシカ", seats) is None + # Dead seat — must not return it even if name matches. + assert resolve_seat_by_name("セツ", seats, alive=frozenset({3})) is None + + +def test_resolve_seat_by_name_returns_none_on_ambiguity() -> None: + seats = [ + _seat(2, name="Alice", persona=None, llm=False), + _seat(3, name="Alice", persona=None, llm=False), + ] + assert resolve_seat_by_name("Alice", seats) is None + + +# ------------------------------------------------------ Layer 2: ingest service + + +class _SeatedPhaseLookup: + def __init__(self, seats: list[Seat], alive: list[int]) -> None: + self._seats = seats + self._alive = alive + + async def get_phase(self, game_id: str) -> tuple[Phase, int] | None: + return (Phase.DAY_DISCUSSION, 1) + + async def get_alive_seat_nos(self, game_id: str) -> list[int]: + return list(self._alive) + + async def resolve_addressed_seat( + self, game_id: str, addressed_name: str + ) -> int | None: + return resolve_seat_by_name( + addressed_name, self._seats, alive=frozenset(self._alive) + ) + + +async def _make_seated_game(repo: SqliteRepo) -> tuple[Game, list[Seat]]: + g = Game( + id="g_addr", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + _seat(1, name="Alice", persona=None, llm=False), + _seat(2, name="🌙セツ", persona="setsu"), + _seat(3, name="🟣ジナ", persona="gina"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.SEER) + await repo.set_player_role(g.id, 3, Role.VILLAGER) + return g, seats + + +async def test_ingest_voice_sets_addressed_seat_from_payload( + repo: SqliteRepo, +) -> None: + g, seats = await _make_seated_game(repo) + registry = InMemoryNpcRegistry() + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + lookup = _SeatedPhaseLookup(seats=seats, alive=[1, 2, 3]) + svc = MasterIngestService( + registry=registry, discussion=discussion, phase_lookup=lookup + ) + payload = SpeechEventPayload( + ts=1, + trace_id="t", + game_id=g.id, + phase_id=make_phase_id(g.id, 1, Phase.DAY_DISCUSSION), + seat_no=1, + speaker_discord_user_id="u1", + segment_id="s1", + text="セツさん、どう思う?", + confidence=0.92, + duration_ms=600, + audio_start_ms=0, + audio_end_ms=600, + addressed_name="セツ", + ) + event, reason = await svc.ingest_voice(payload) + assert reason is None + assert event is not None + assert event.addressed_seat_no == 2 + rows = await store.load_phase(g.id, event.phase_id) + assert any(r.event_id == event.event_id and r.addressed_seat_no == 2 for r in rows) + + +async def test_ingest_voice_prefers_payload_seat_no_over_name_resolution( + repo: SqliteRepo, +) -> None: + """When the analyzer's prompt was grounded with a roster, the + ``addressed_seat_no`` it pre-resolves must override the legacy + string-based ``resolve_seat_by_name`` lookup. This is the only + code path that handles a renamed-bot scenario where the live VC + nickname doesn't match the persona's stored ``Seat.display_name``. + + Test setup: seat 2 has display_name "🌙セツ" in the DB. The payload + carries ``addressed_name="Lucky"`` (a name that would never resolve + via the legacy path) but ``addressed_seat_no=2``. The resulting + ``SpeechEvent.addressed_seat_no`` must be 2. + """ + g, seats = await _make_seated_game(repo) + registry = InMemoryNpcRegistry() + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + lookup = _SeatedPhaseLookup(seats=seats, alive=[1, 2, 3]) + svc = MasterIngestService( + registry=registry, discussion=discussion, phase_lookup=lookup + ) + payload = SpeechEventPayload( + ts=1, + trace_id="t", + game_id=g.id, + phase_id=make_phase_id(g.id, 1, Phase.DAY_DISCUSSION), + seat_no=1, + speaker_discord_user_id="u1", + segment_id="s1", + text="Luckyさん、どう思う?", + confidence=0.92, + duration_ms=600, + audio_start_ms=0, + audio_end_ms=600, + addressed_name="Lucky", + addressed_seat_no=2, + ) + event, reason = await svc.ingest_voice(payload) + assert reason is None + assert event is not None + assert event.addressed_seat_no == 2 + + +async def test_ingest_voice_falls_back_to_name_when_seat_no_dead( + repo: SqliteRepo, +) -> None: + """A hallucinated / stale ``addressed_seat_no`` pointing at a dead + seat must be rejected and the resolver must fall back to the + name-based path.""" + g, seats = await _make_seated_game(repo) + registry = InMemoryNpcRegistry() + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + # Seat 2 is dead in this scenario; analyzer hallucinates + # ``addressed_seat_no=2`` anyway. + lookup = _SeatedPhaseLookup(seats=seats, alive=[1, 3]) + svc = MasterIngestService( + registry=registry, discussion=discussion, phase_lookup=lookup + ) + payload = SpeechEventPayload( + ts=1, + trace_id="t", + game_id=g.id, + phase_id=make_phase_id(g.id, 1, Phase.DAY_DISCUSSION), + seat_no=1, + speaker_discord_user_id="u1", + segment_id="s1", + text="ジナさんどう?", + confidence=0.92, + duration_ms=600, + audio_start_ms=0, + audio_end_ms=600, + addressed_name="ジナ", + addressed_seat_no=2, # dead — should be ignored + ) + event, reason = await svc.ingest_voice(payload) + assert reason is None + assert event is not None + # Falls back to name resolution → resolves "ジナ" to seat 3. + assert event.addressed_seat_no == 3 + + +async def test_ingest_voice_self_address_is_dropped(repo: SqliteRepo) -> None: + """A speaker calling their own name must not produce a routed address.""" + g, seats = await _make_seated_game(repo) + registry = InMemoryNpcRegistry() + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + lookup = _SeatedPhaseLookup(seats=seats, alive=[1, 2, 3]) + svc = MasterIngestService( + registry=registry, discussion=discussion, phase_lookup=lookup + ) + payload = SpeechEventPayload( + ts=1, + trace_id="t", + game_id=g.id, + phase_id=make_phase_id(g.id, 1, Phase.DAY_DISCUSSION), + seat_no=2, # speaker is セツ + speaker_discord_user_id="u2", + segment_id="s1", + text="セツです、よろしく", + confidence=0.92, + duration_ms=600, + audio_start_ms=0, + audio_end_ms=600, + addressed_name="セツ", + ) + event, reason = await svc.ingest_voice(payload) + assert reason is None + assert event is not None + assert event.addressed_seat_no is None + + +# ------------------------------------------------------ Layer 3: state fold + + +def test_public_state_fold_tracks_last_addressed_seat() -> None: + phase_id = make_phase_id("g", 1, Phase.DAY_DISCUSSION) + sentinel = make_phase_baseline( + game_id="g", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + addressed = make_human_text_event( + game_id="g", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="セツさん、どう思う?", + created_at_ms=2, + ).model_copy(update={"addressed_seat_no": 2}) + + state = rebuild_public_state_from_events([sentinel, addressed]) + assert state is not None + assert state.last_addressed_seat == 2 + assert state.last_addressed_speaker_seat == 1 + assert "セツさん" in state.last_addressed_text + + +def test_public_state_fold_clears_address_after_npc_reply() -> None: + phase_id = make_phase_id("g", 1, Phase.DAY_DISCUSSION) + sentinel = make_phase_baseline( + game_id="g", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + addressed = make_human_text_event( + game_id="g", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="セツさん、どう思う?", + created_at_ms=2, + ).model_copy(update={"addressed_seat_no": 2}) + npc_reply = make_npc_generated_event( + game_id="g", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, + text="そうだね", + created_at_ms=3, + ) + + state = rebuild_public_state_from_events([sentinel, addressed, npc_reply]) + assert state is not None + assert state.last_addressed_seat is None + assert state.last_addressed_text == "" + + +# ------------------------------------------------------ Layer 4: arbiter picker + + +def _captured_send(buf: list[str]) -> Callable[[str], Awaitable[None]]: + async def send(msg: str) -> None: + buf.append(msg) + + return send + + +async def test_speak_arbiter_prefers_addressed_seat_over_silent_lowest( + repo: SqliteRepo, +) -> None: + """Even when seat 2 is also silent (and would otherwise win on the + lowest-seat tiebreaker), the addressed seat 3 must be picked.""" + g, _seats = await _make_seated_game(repo) + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + addressed = make_human_text_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="ジナさん、どう思う?", + created_at_ms=2, + ).model_copy(update={"addressed_seat_no": 3}) + await store.insert(addressed) + + registry = InMemoryNpcRegistry() + buf2: list[str] = [] + buf3: list[str] = [] + registry.register( + npc_id="npc_setsu", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(buf2), + now_ms=1000, + persona_key="setsu", + ) + registry.register( + npc_id="npc_gina", + discord_bot_user_id="bot3", + supported_voices=(), + version="1", + send=_captured_send(buf3), + now_ms=1000, + persona_key="gina", + ) + registry.assign("npc_setsu", seat=2, game_id=g.id, phase_id=phase_id) + registry.assign("npc_gina", seat=3, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500 + ) + await arb.try_dispatch_next(g.id) + + assert buf3, "addressed NPC at seat 3 must be dispatched first" + assert not buf2, "lowest-seat NPC at seat 2 must not preempt the addressed one" + + +async def test_speak_arbiter_skips_addressed_when_offline_falls_back_to_silent( + repo: SqliteRepo, +) -> None: + """Address resolution must not strand the discussion: if the named NPC + is offline, the picker should fall through to the existing silent-first + rotation rather than no-op.""" + g, _seats = await _make_seated_game(repo) + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + # Address seat 3, but only seat 2's NPC is online. + addressed = make_human_text_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="ジナさん、どう?", + created_at_ms=2, + ).model_copy(update={"addressed_seat_no": 3}) + await store.insert(addressed) + + registry = InMemoryNpcRegistry() + buf2: list[str] = [] + registry.register( + npc_id="npc_setsu", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(buf2), + now_ms=1000, + persona_key="setsu", + ) + registry.assign("npc_setsu", seat=2, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500 + ) + await arb.try_dispatch_next(g.id) + + assert buf2, "fallback to the only online silent NPC must still happen" + + +# ------------------------------------------------------ Layer 4: logic packet + + +def test_logic_packet_summary_includes_address_signal() -> None: + state = PublicDiscussionState( + game_id="g", + phase_id="g::day1::DAY_DISCUSSION::1", + day=1, + alive_seat_nos=frozenset({1, 2, 3}), + last_addressed_seat=2, + last_addressed_speaker_seat=1, + last_addressed_text="セツさん、占い結果はどうだった?", + ) + packet = build_logic_packet( + state=state, + recipient_npc_id="npc_setsu", + expires_at_ms=2000, + now_ms=1500, + ) + assert "last_address=席2" in packet.public_state_summary + assert "from=席1" in packet.public_state_summary + assert "セツさん" in packet.public_state_summary + + +def test_logic_packet_summary_omits_address_when_none() -> None: + state = PublicDiscussionState( + game_id="g", + phase_id="g::day1::DAY_DISCUSSION::1", + day=1, + alive_seat_nos=frozenset({1, 2, 3}), + ) + packet = build_logic_packet( + state=state, + recipient_npc_id="npc_setsu", + expires_at_ms=2000, + now_ms=1500, + ) + assert "last_address" not in packet.public_state_summary + + +# ------------------------------------------------------ End-to-end: payload → arbiter + + +async def test_wolfcog_on_message_uses_text_analyzer_to_set_addressed_seat( + repo: SqliteRepo, +) -> None: + """When a TextAnalyzer is wired, `WolfCog.on_message` must call it and + propagate the resolved `addressed_seat_no` onto the SpeechEvent so the + text path matches the voice path.""" + from unittest.mock import MagicMock + + from wolfbot.domain.discussion import SpeechSource + from wolfbot.master.state.text_analyzer import FakeTextAnalyzer, TextAnalysis + from wolfbot.services.discord_service import WolfCog + from wolfbot.services.discussion_service import SqliteSpeechEventStore + + g, _seats = await _make_seated_game(repo) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store, log_sink=repo) + fake_analyzer = FakeTextAnalyzer( + scripted=[TextAnalysis(addressed_name="ジナ")] + ) + + cog = WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=MagicMock(MAIN_TEXT_CHANNEL_ID=100, MAIN_VOICE_CHANNEL_ID=200), + discussion_service=discussion, + text_analyzer=fake_analyzer, + ) + + msg = MagicMock() + msg.author.bot = False + msg.author.id = "u1" + msg.guild.id = g.guild_id + msg.channel.id = "c1" # main_text_channel_id + msg.content = "ジナさん、どう思う?" + + # Patch the message channel to match game.main_text_channel_id ("c1"). + await WolfCog.on_message(cog, msg) + + # The fake analyzer should have been called once with the message body. + assert fake_analyzer.call_count == 1 + assert fake_analyzer.last_text == "ジナさん、どう思う?" + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(g.id, phase_id) + text_events = [e for e in events if e.source == SpeechSource.TEXT] + assert len(text_events) == 1 + assert text_events[0].addressed_seat_no == 3 + assert text_events[0].speaker_seat == 1 + + +async def test_wolfcog_on_message_skips_addressed_when_analyzer_fails( + repo: SqliteRepo, +) -> None: + """A broken analyzer must not block the SpeechEvent write — the row + should still land with `addressed_seat_no=None` so plain text capture + keeps working.""" + from unittest.mock import MagicMock + + from wolfbot.domain.discussion import SpeechSource + from wolfbot.master.state.text_analyzer import FakeTextAnalyzer + from wolfbot.services.discord_service import WolfCog + from wolfbot.services.discussion_service import SqliteSpeechEventStore + + g, _seats = await _make_seated_game(repo) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store, log_sink=repo) + broken = FakeTextAnalyzer(scripted=[RuntimeError("provider down")]) + + cog = WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=MagicMock(MAIN_TEXT_CHANNEL_ID=100, MAIN_VOICE_CHANNEL_ID=200), + discussion_service=discussion, + text_analyzer=broken, + ) + + msg = MagicMock() + msg.author.bot = False + msg.author.id = "u1" + msg.guild.id = g.guild_id + msg.channel.id = "c1" + msg.content = "セツさん、どう?" + + await WolfCog.on_message(cog, msg) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(g.id, phase_id) + text_events = [e for e in events if e.source == SpeechSource.TEXT] + assert len(text_events) == 1 + assert text_events[0].addressed_seat_no is None + assert text_events[0].text == "セツさん、どう?" + + +async def test_end_to_end_addressed_dispatch(repo: SqliteRepo) -> None: + """A SpeechEventPayload carrying `addressed_name='ジナ'` must result in + the seat-3 NPC receiving the next SpeakRequest, not seat 2.""" + g, seats = await _make_seated_game(repo) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + registry = InMemoryNpcRegistry() + buf2: list[str] = [] + buf3: list[str] = [] + registry.register( + npc_id="npc_setsu", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(buf2), + now_ms=1000, + persona_key="setsu", + ) + registry.register( + npc_id="npc_gina", + discord_bot_user_id="bot3", + supported_voices=(), + version="1", + send=_captured_send(buf3), + now_ms=1000, + persona_key="gina", + ) + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + registry.assign("npc_setsu", seat=2, game_id=g.id, phase_id=phase_id) + registry.assign("npc_gina", seat=3, game_id=g.id, phase_id=phase_id) + + lookup = _SeatedPhaseLookup(seats=seats, alive=[1, 2, 3]) + ingest = MasterIngestService( + registry=registry, discussion=discussion, phase_lookup=lookup + ) + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500 + ) + + payload = SpeechEventPayload( + ts=1, + trace_id="t", + game_id=g.id, + phase_id=phase_id, + seat_no=1, + speaker_discord_user_id="u1", + segment_id="s1", + text="ジナさん、どう思う?", + confidence=0.92, + duration_ms=600, + audio_start_ms=0, + audio_end_ms=600, + addressed_name="ジナ", + ) + event, reason = await ingest.ingest_voice(payload) + assert reason is None and event is not None and event.addressed_seat_no == 3 + + await arb.try_dispatch_next(g.id) + assert buf3, "addressed NPC at seat 3 must be dispatched" + assert not buf2, "non-addressed NPC at seat 2 must wait" + # The dispatched logic packet must carry the human's utterance so the + # NPC's LLM has actual context to reply to. + assert any("ジナさん" in m for m in buf3) + + +# -- Layer 5: NPC's own addressed_seat_no propagates through state ---- + + +def test_npc_speech_with_addressed_seat_sets_last_addressed() -> None: + """When an NPC's structured output sets `addressed_seat_no`, the + PublicDiscussionState fold must surface it as `last_addressed_seat` + so the next arbiter dispatch prioritizes the addressee. Reproduces + the production bug where Raqio (seat 1) said "席9 ユリコ…" 8 times + in a row because every NPC speech cleared the address pointer. + """ + phase_id = make_phase_id("g", 1, Phase.DAY_DISCUSSION) + events = [ + make_phase_baseline( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 9], created_at_ms=1, + ), + # NPC at seat 1 names seat 9 — this used to clear last_addressed. + make_npc_generated_event( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="席9ユリコ、君が処刑候補だ", + addressed_seat_no=9, created_at_ms=10, + ), + ] + state = rebuild_public_state_from_events(events) + assert state is not None + assert state.last_addressed_seat == 9, ( + "NPC's addressed_seat_no must propagate to last_addressed_seat" + ) + assert state.last_addressed_speaker_seat == 1 + + +def test_unrelated_npc_speech_does_not_clear_pending_address() -> None: + """If NPC A addresses seat X and a different NPC B jumps in (without + naming anyone), the pending address must NOT be cleared — otherwise + the arbiter loses the cue to prioritize X next. Pre-fix, every NPC + speech wiped last_addressed_seat unconditionally. + """ + phase_id = make_phase_id("g", 1, Phase.DAY_DISCUSSION) + events = [ + make_phase_baseline( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 9], created_at_ms=1, + ), + make_npc_generated_event( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="席9ユリコ、説明しろ", + addressed_seat_no=9, created_at_ms=10, + ), + # Seat 2 jumps in without naming anyone — must not consume the + # address pending for seat 9. + make_npc_generated_event( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, text="一旦落ち着こう", + addressed_seat_no=None, created_at_ms=20, + ), + ] + state = rebuild_public_state_from_events(events) + assert state is not None + assert state.last_addressed_seat == 9, ( + "Unrelated NPC speech must not clear the pending address" + ) + + +def test_pending_role_callout_added_by_voice_event() -> None: + """A voice STT event carrying ``role_callout="seer"`` adds 'seer' to the + PublicDiscussionState's `pending_role_callouts` set so downstream NPC + prompts can show the outstanding request.""" + phase_id = make_phase_id("g", 1, Phase.DAY_DISCUSSION) + events = [ + make_phase_baseline( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], created_at_ms=1, + ), + make_human_text_event( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="占い師の方は名乗り出てください", + role_callout="seer", created_at_ms=10, + ), + ] + state = rebuild_public_state_from_events(events) + assert state is not None + assert state.pending_role_callouts == frozenset({"seer"}) + + +def test_pending_role_callout_cleared_when_seer_co_arrives() -> None: + """When a CO matching the outstanding callout role arrives, the role + is removed from `pending_role_callouts` (= the call was answered).""" + phase_id = make_phase_id("g", 1, Phase.DAY_DISCUSSION) + events = [ + make_phase_baseline( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], created_at_ms=1, + ), + make_human_text_event( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="占い師の方は名乗り出てください", + role_callout="seer", created_at_ms=10, + ), + make_npc_generated_event( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, text="実は私、占い師なんだ", + co_declaration="seer", created_at_ms=20, + ), + ] + state = rebuild_public_state_from_events(events) + assert state is not None + assert state.pending_role_callouts == frozenset() + + +def test_pending_role_callout_unanswered_stays_pending() -> None: + """Speeches without a matching CO must NOT clear the pending callout.""" + phase_id = make_phase_id("g", 1, Phase.DAY_DISCUSSION) + events = [ + make_phase_baseline( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], created_at_ms=1, + ), + make_human_text_event( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="占い師の方は名乗り出てください", + role_callout="seer", created_at_ms=10, + ), + make_npc_generated_event( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, text="まだ様子を見たい", + created_at_ms=20, + ), + ] + state = rebuild_public_state_from_events(events) + assert state is not None + assert state.pending_role_callouts == frozenset({"seer"}) + + +async def test_co_claims_carry_across_phase_boundaries(repo: SqliteRepo) -> None: + """Day-1 seer CO must still be visible in day-2's PublicDiscussionState + so a wolf NPC can decide to counter-CO on day 2. Previously the fold + rebuilt per-phase and the day-2 prompt showed `co_claims=[(none)]` + even though day 1 had a clear seer CO. + """ + g = Game( + id="rv-co-carry", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=2, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="Alice", discord_user_id="u1", + is_llm=False, persona_key=None), + Seat(seat_no=4, display_name="🦋ラキオ", discord_user_id=None, + is_llm=True, persona_key="raqio"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 4, Role.SEER) + + day1_phase = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + day2_phase = make_phase_id(g.id, 2, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + + # Day-1: Raqio CO's as seer. + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=day1_phase, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 4], created_at_ms=1, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=day1_phase, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=4, text="実は僕、占い師だ。", + co_declaration="seer", + created_at_ms=10, + ) + ) + # Day-2: only baseline + a non-CO speech (no fresh CO this phase). + await store.insert( + make_phase_baseline( + game_id=g.id, phase_id=day2_phase, day=2, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 4], created_at_ms=100, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, phase_id=day2_phase, day=2, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="昨日のことだけど", + created_at_ms=110, + ) + ) + + arb = SpeakArbiter( + repo=repo, + registry=InMemoryNpcRegistry(), + discussion=discussion, + now_ms=lambda: 200, + ) + state = await arb.rebuild_public_state( + game_id=g.id, day=2, phase=Phase.DAY_DISCUSSION, + ) + assert state is not None + assert any( + c.seat == 4 and c.role_claim == "seer" for c in state.co_claims + ), f"day-2 state must carry day-1 seer CO, got co_claims={state.co_claims}" + + +def test_addressed_npc_reply_consumes_the_address() -> None: + """When the addressed NPC actually replies (speaker_seat == prior + last_addressed_seat) without naming a new target, the address is + consumed so the arbiter doesn't keep prioritizing them forever.""" + phase_id = make_phase_id("g", 1, Phase.DAY_DISCUSSION) + events = [ + make_phase_baseline( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 9], created_at_ms=1, + ), + make_npc_generated_event( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="席9ユリコ、答えろ", + addressed_seat_no=9, created_at_ms=10, + ), + # Yuriko (seat 9) replies — this consumes the address. + make_npc_generated_event( + game_id="g", phase_id=phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=9, text="言いがかりだ", + addressed_seat_no=None, created_at_ms=20, + ), + ] + state = rebuild_public_state_from_events(events) + assert state is not None + assert state.last_addressed_seat is None + assert state.last_addressed_speaker_seat is None + + diff --git a/tests/test_claim_history.py b/tests/test_claim_history.py new file mode 100644 index 0000000..178c835 --- /dev/null +++ b/tests/test_claim_history.py @@ -0,0 +1,413 @@ +"""Tests for the per-seat divination/medium claim aggregator and its +prompt-side rendering through ``build_logic_packet``. + +Coverage map: + +* :mod:`wolfbot.master.claim.claim_history` — pure fold over SpeechEvent. +* :mod:`wolfbot.master.arbiter.logic_service` — claim block surfacing in + ``LogicPacket.public_state_summary``. +""" + +from __future__ import annotations + +from wolfbot.domain.discussion import ( + PublicDiscussionState, + SpeakerKind, + SpeechEvent, + SpeechSource, +) +from wolfbot.domain.enums import Phase +from wolfbot.master.arbiter.logic_service import build_logic_packet +from wolfbot.master.claim.claim_history import ( + ClaimedMediumEntry, + ClaimedSeerEntry, + collect_claim_history, + expected_medium_claim_count_for_day, + expected_seer_claim_count_for_day, +) + + +def _speech_event( + *, + seat: int, + day: int, + text: str = "", + seer_target: int | None = None, + seer_is_wolf: bool | None = None, + medium_target: int | None = None, + medium_is_wolf: bool | None = None, + event_id: str | None = None, + created_at_ms: int = 0, +) -> SpeechEvent: + return SpeechEvent( + event_id=event_id or f"ev_{seat}_{day}_{created_at_ms}", + game_id="g1", + phase_id=f"g1::day{day}::DAY_DISCUSSION::1", + day=day, + phase=Phase.DAY_DISCUSSION, + source=SpeechSource.NPC_GENERATED, + speaker_kind=SpeakerKind.NPC, + speaker_seat=seat, + text=text, + claimed_seer_target_seat=seer_target, + claimed_seer_is_wolf=seer_is_wolf, + claimed_medium_target_seat=medium_target, + claimed_medium_is_wolf=medium_is_wolf, + created_at_ms=created_at_ms, + ) + + +def test_collect_claim_history_groups_seer_claims_by_seat() -> None: + events = [ + _speech_event( + seat=2, day=1, seer_target=1, seer_is_wolf=False, created_at_ms=10, + ), + _speech_event( + seat=9, day=1, seer_target=5, seer_is_wolf=False, created_at_ms=20, + ), + _speech_event( + seat=2, day=2, seer_target=8, seer_is_wolf=False, created_at_ms=30, + ), + ] + + history = collect_claim_history(events, seat_names={1: "Comet", 5: "Setsu", 8: "Stella"}) + + assert sorted(history.by_seat.keys()) == [2, 9] + seat2 = history.by_seat[2] + assert seat2.seer_claims == ( + ClaimedSeerEntry( + day=1, + target_seat=1, + target_name="Comet", + is_wolf=False, + declared_at_event_id=events[0].event_id, + ), + ClaimedSeerEntry( + day=2, + target_seat=8, + target_name="Stella", + is_wolf=False, + declared_at_event_id=events[2].event_id, + ), + ) + seat9 = history.by_seat[9] + assert len(seat9.seer_claims) == 1 + assert seat9.seer_claims[0].target_name == "Setsu" + + +def test_collect_claim_history_skips_baselines_and_systemless_events() -> None: + baseline = SpeechEvent( + event_id="baseline", + game_id="g1", + phase_id="g1::day0::DAY_DISCUSSION::1", + day=0, + phase=Phase.DAY_DISCUSSION, + source=SpeechSource.PHASE_BASELINE, + speaker_kind=SpeakerKind.SYSTEM, + speaker_seat=None, + text="", + alive_seat_nos_json="[1,2]", + created_at_ms=0, + ) + speaker_event = _speech_event( + seat=2, + day=1, + seer_target=3, + seer_is_wolf=True, + created_at_ms=10, + ) + + history = collect_claim_history([baseline, speaker_event]) + + # Only the speaker event surfaced; the baseline is silently filtered. + assert list(history.by_seat.keys()) == [2] + + +def test_collect_claim_history_handles_medium_void_result() -> None: + """Medium claims may carry ``is_wolf=None`` to encode 'no execution + yesterday → no result today'. The aggregator must preserve the void.""" + event = _speech_event( + seat=5, + day=2, + medium_target=3, + medium_is_wolf=None, + created_at_ms=100, + ) + + history = collect_claim_history([event], seat_names={3: "Jonas"}) + + seat5 = history.by_seat[5] + assert seat5.medium_claims == ( + ClaimedMediumEntry( + day=2, + target_seat=3, + target_name="Jonas", + is_wolf=None, + declared_at_event_id=event.event_id, + ), + ) + + +def test_collect_claim_history_dedupes_same_day_reassertions() -> None: + """Same (day, target, verdict) restated across multiple speech turns + in one day's discussion rounds folds to a single ledger row. + + Regression: game ``2026-05-02_09-34-19`` exported two ledger rows + for the medium claimer's "day 2 シゲミチ白" (round 1 + round 2 of + the same morning) and four rows for Yuriko's seer claim (with + "day 3 セツ白" repeated twice). The viewer's CO-history panel + rendered both as visible duplicates, and the seer-cap header + chip ("通算 X / 期待 Y") overshot. A real seer never issues two + cumulative entries for the same morning's NIGHT_K result, so the + aggregator must collapse identical re-assertions. + """ + events = [ + _speech_event( + seat=9, + day=3, + seer_target=5, + seer_is_wolf=False, + event_id="ev_first", + created_at_ms=10, + ), + # Same speaker, same day, same target+verdict — re-asserted + # across discussion rounds. Must be folded. + _speech_event( + seat=9, + day=3, + seer_target=5, + seer_is_wolf=False, + event_id="ev_second", + created_at_ms=20, + ), + # Medium claim on the same day, repeated. + _speech_event( + seat=8, + day=2, + medium_target=6, + medium_is_wolf=False, + event_id="ev_med1", + created_at_ms=5, + ), + _speech_event( + seat=8, + day=2, + medium_target=6, + medium_is_wolf=False, + event_id="ev_med2", + created_at_ms=15, + ), + ] + + history = collect_claim_history( + events, + seat_names={5: "Setsu", 6: "Shigemichi"}, + ) + + # Seer dedup: only the first event_id survives, ledger has 1 row. + assert history.by_seat[9].seer_claims == ( + ClaimedSeerEntry( + day=3, + target_seat=5, + target_name="Setsu", + is_wolf=False, + declared_at_event_id="ev_first", + ), + ) + # Medium dedup: identical re-assert collapses too. + assert history.by_seat[8].medium_claims == ( + ClaimedMediumEntry( + day=2, + target_seat=6, + target_name="Shigemichi", + is_wolf=False, + declared_at_event_id="ev_med1", + ), + ) + + +def test_collect_claim_history_keeps_same_day_target_swap() -> None: + """A different target or color on the same day is *not* a duplicate + — it's an inconsistency the validator catches as + ``seer_target_swap`` / ``seer_verdict_flip``. The ledger must + preserve both rows so post-game review surfaces the contradiction + even when the validator was bypassed (e.g. legacy export, or a + structured claim slipping past the retry loop).""" + events = [ + _speech_event( + seat=9, + day=2, + seer_target=5, + seer_is_wolf=False, + event_id="ev_a", + created_at_ms=10, + ), + # Different target on the same day → kept as a separate row. + _speech_event( + seat=9, + day=2, + seer_target=4, + seer_is_wolf=False, + event_id="ev_b", + created_at_ms=20, + ), + # Same target, flipped verdict → also kept. + _speech_event( + seat=9, + day=2, + seer_target=5, + seer_is_wolf=True, + event_id="ev_c", + created_at_ms=30, + ), + ] + + history = collect_claim_history(events, seat_names={4: "Comet", 5: "Setsu"}) + + assert len(history.by_seat[9].seer_claims) == 3 + + +def test_collect_claim_history_drops_partial_seer_claims() -> None: + """A seer claim missing ``is_wolf`` is meaningless and the + aggregator drops it rather than guessing a verdict.""" + event = _speech_event( + seat=2, day=1, seer_target=4, seer_is_wolf=None, created_at_ms=5, + ) + + history = collect_claim_history([event]) + + assert history.by_seat == {} + + +def test_expected_seer_claim_count_for_day_returns_announced_entries() -> None: + """Real seer's announced-claim count: 1 entry per declared day. + + The claim ledger tags each entry with the day it was *announced* + (not the night the divination happened on). NIGHT_0 surfaces day-1 + morning → entry day=1; NIGHT_K (K>=1) surfaces day-(K+1) morning → + entry day=K+1. So by day-N morning the count is exactly N. This + matches `claim_validator._validate_seer_fake`'s + "1 entry per declared day" rule (`same_day_priors`) — the prompt + builder's expected count and the validator's structural rule must + not disagree, otherwise an "緊急: 結果が不足" nudge tells the LLM + to declare a 2nd same-day target that the validator instantly + rejects as `seer_target_swap` (regression observed in game + 7faf339713cf where ジナ got fabrication-capped on day-1 runoff + and never spoke). + """ + # Day 0: SETUP / NIGHT_0 only — no morning, no announced results. + assert expected_seer_claim_count_for_day(0) == 0 + # Day 1 morning: only NIGHT_0's random white announced (1 entry). + assert expected_seer_claim_count_for_day(1) == 1 + # Day 2 morning: NIGHT_0 + NIGHT_1 announced (2 entries). + assert expected_seer_claim_count_for_day(2) == 2 + # Day N morning: N entries. + assert expected_seer_claim_count_for_day(4) == 4 + # Defensive: negative day clamps to zero so the rule reads "no + # results before the game has begun" rather than blowing up. + assert expected_seer_claim_count_for_day(-1) == 0 + + +def test_expected_medium_claim_count_for_day_returns_executions_so_far() -> None: + assert expected_medium_claim_count_for_day(0) == 0 + assert expected_medium_claim_count_for_day(3) == 3 + # Defensive clamp matches the seer helper. + assert expected_medium_claim_count_for_day(-1) == 0 + + +# ----------------------------------------------------- prompt rendering + + +def _basic_state() -> PublicDiscussionState: + return PublicDiscussionState( + game_id="g1", + phase_id="g1::day1::DAY_DISCUSSION::1", + day=1, + ) + + +def test_logic_packet_summary_includes_claim_history_block() -> None: + """The arbiter passes the per-seat history to ``build_logic_packet``; + the rendered block has to surface the claimer name, claim count, + target name, and verdict glyph (黒/白) for every recorded claim.""" + state = _basic_state() + history = collect_claim_history( + [ + _speech_event( + seat=2, day=1, seer_target=1, seer_is_wolf=False, created_at_ms=10, + ), + _speech_event( + seat=9, day=1, seer_target=5, seer_is_wolf=False, created_at_ms=20, + ), + ], + seat_names={1: "Comet", 5: "Setsu", 2: "Jonas", 9: "Yuriko"}, + ) + + packet = build_logic_packet( + state=state, + recipient_npc_id="npc_yuriko", + expires_at_ms=1000, + now_ms=500, + seat_names={1: "Comet", 2: "Jonas", 5: "Setsu", 9: "Yuriko"}, + claim_history=history, + ) + + summary = packet.public_state_summary + assert "公開された占い/霊媒CO結果" in summary + # Distinct-claimer count vs cap shown in the role header. + assert "通算 2 件 / 上限 3 件" in summary + # Per-day expected count (= N entries by day-N morning, one per + # declared day) shown under the seer header so the LLM has a + # numeric anchor that matches the validator's same-day rule. + assert "day1 朝までに通算 1 件まで整合" in summary + # Per-row format includes seat number + alive/dead tag inline. + assert "Jonas (席2," in summary + assert "day1: Comet白" in summary + assert "Yuriko (席9," in summary + assert "day1: Setsu白" in summary + # Top warning banner for the dead-CO awareness rule. + assert "死亡席の CO も依然として有効" in summary + + +def test_logic_packet_summary_omits_block_without_history() -> None: + """Older games and pre-claim-history dispatches must render the + legacy compact summary without the new heading so the prompt + surface stays unchanged for back-compat exports.""" + state = _basic_state() + packet = build_logic_packet( + state=state, + recipient_npc_id="npc_x", + expires_at_ms=1000, + now_ms=500, + seat_names={1: "Alice"}, + claim_history=None, + ) + assert "公開された占い/霊媒CO結果" not in packet.public_state_summary + + +def test_logic_packet_summary_renders_medium_void_as_no_result() -> None: + """Medium claims with ``is_wolf=None`` render as '結果なし' so the + LLM doesn't have to invent a verdict for execution-less days.""" + state = _basic_state() + history = collect_claim_history( + [ + _speech_event( + seat=5, day=2, medium_target=3, medium_is_wolf=None, created_at_ms=100, + ), + ], + seat_names={3: "Jonas", 5: "Shigemichi"}, + ) + + packet = build_logic_packet( + state=state, + recipient_npc_id="npc_shigemichi", + expires_at_ms=1000, + now_ms=500, + seat_names={3: "Jonas", 5: "Shigemichi"}, + claim_history=history, + ) + + # Medium ledger row format: claimer + seat + alive/dead, then results. + assert "霊媒CO 通算 1 件 / 上限 2 件" in packet.public_state_summary + assert "Shigemichi (席5," in packet.public_state_summary + assert "結果なし" in packet.public_state_summary diff --git a/tests/test_claim_persistence.py b/tests/test_claim_persistence.py new file mode 100644 index 0000000..260e90c --- /dev/null +++ b/tests/test_claim_persistence.py @@ -0,0 +1,278 @@ +"""Round-trip tests for the claim columns on ``speech_events``. + +The claim aggregator (`wolfbot.master.claim.claim_history`) is unit-tested +in :mod:`tests.test_claim_history`; this file pins the persistence +seam so a column rename or a missed migration block fails noisily. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest_asyncio + +from wolfbot.domain.discussion import ( + make_phase_id, +) +from wolfbot.domain.enums import Phase +from wolfbot.domain.ws_messages import ( + ClaimedSeerResult, + LogicPacket, + SpeakRequest, +) +from wolfbot.npc.speech.openai_compatible_generator import _parse_claim_fields +from wolfbot.npc.speech.speech_service import ( + NpcGeneratedSpeech, + NpcSpeechService, +) +from wolfbot.persistence.schema import migrate +from wolfbot.services.discussion_service import ( + SqliteSpeechEventStore, + make_npc_generated_event, +) + + +@pytest_asyncio.fixture +async def store_conn(tmp_path: Path) -> AsyncIterator: + import aiosqlite + + db_path = tmp_path / "speech.db" + await migrate(db_path) + conn = await aiosqlite.connect(str(db_path)) + try: + yield conn + finally: + await conn.close() + + +async def test_speech_event_round_trip_persists_seer_claim(store_conn) -> None: + """A SpeechEvent carrying ``claimed_seer_target_seat`` / + ``claimed_seer_is_wolf`` reads back identically. SQLite's lack of + native bool means the integer ↔ bool coercion has to survive the + round-trip — a regression here would silently drop every claim + on Master restart.""" + store = SqliteSpeechEventStore(store_conn) + phase_id = make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + event = make_npc_generated_event( + game_id="g1", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, + text="昨夜セツを占って白でした。", + co_declaration="seer", + claimed_seer_target_seat=6, + claimed_seer_is_wolf=False, + created_at_ms=1_700_000_000_000, + ) + + await store.insert(event) + rows = await store.load_phase("g1", phase_id) + + assert len(rows) == 1 + loaded = rows[0] + assert loaded.claimed_seer_target_seat == 6 + assert loaded.claimed_seer_is_wolf is False + assert loaded.claimed_medium_target_seat is None + assert loaded.claimed_medium_is_wolf is None + + +async def test_speech_event_round_trip_persists_medium_void(store_conn) -> None: + """``claimed_medium_is_wolf=None`` (= 'no execution yesterday') + must round-trip through the 0/1/NULL column without being + coerced into ``False``.""" + store = SqliteSpeechEventStore(store_conn) + phase_id = make_phase_id("g2", 2, Phase.DAY_DISCUSSION) + event = make_npc_generated_event( + game_id="g2", + phase_id=phase_id, + day=2, + phase=Phase.DAY_DISCUSSION, + speaker_seat=5, + text="昨日は処刑がなかったので結果は出ません。", + co_declaration="medium", + claimed_medium_target_seat=3, + claimed_medium_is_wolf=None, + created_at_ms=1_700_000_001_000, + ) + + await store.insert(event) + rows = await store.load_phase("g2", phase_id) + + assert rows[0].claimed_medium_target_seat == 3 + assert rows[0].claimed_medium_is_wolf is None + + +async def test_legacy_speech_event_with_no_claim_loads_as_none(store_conn) -> None: + """Pre-claim-column rows have NULL in every claim column. Loading + an event without claims must not produce phantom entries.""" + store = SqliteSpeechEventStore(store_conn) + phase_id = make_phase_id("g3", 1, Phase.DAY_DISCUSSION) + event = make_npc_generated_event( + game_id="g3", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=4, + text="(普通の議論)", + created_at_ms=1_700_000_002_000, + ) + await store.insert(event) + + rows = await store.load_phase("g3", phase_id) + + loaded = rows[0] + assert loaded.claimed_seer_target_seat is None + assert loaded.claimed_seer_is_wolf is None + assert loaded.claimed_medium_target_seat is None + assert loaded.claimed_medium_is_wolf is None + + +# ----------------------------------------------- LLM-output parsing + + +def test_parse_claim_fields_accepts_valid_seer_claim() -> None: + seat, verdict = _parse_claim_fields( + {"target_seat": 5, "is_wolf": True}, allow_null_verdict=False, + ) + assert seat == 5 + assert verdict is True + + +def test_parse_claim_fields_drops_seer_claim_with_null_verdict() -> None: + """Seer verdicts must be a concrete bool. ``null`` is medium-only + semantics and would fabricate a result if accepted.""" + seat, verdict = _parse_claim_fields( + {"target_seat": 5, "is_wolf": None}, allow_null_verdict=False, + ) + assert seat is None + assert verdict is None + + +def test_parse_claim_fields_accepts_medium_void() -> None: + seat, verdict = _parse_claim_fields( + {"target_seat": 3, "is_wolf": None}, allow_null_verdict=True, + ) + assert seat == 3 + assert verdict is None + + +def test_parse_claim_fields_rejects_out_of_range_target() -> None: + seat, verdict = _parse_claim_fields( + {"target_seat": 99, "is_wolf": False}, allow_null_verdict=False, + ) + assert seat is None + assert verdict is None + + +# ----------------------------------------------- speech-service handoff + + +async def test_speech_service_threads_claim_into_speak_result() -> None: + """``NpcSpeechService.respond`` must lift the generated speech's + ``claimed_*`` fields onto the wire model so Master persists them + on the SpeechEvent.""" + from wolfbot.npc.speech.speech_service import FakeNpcGenerator + + speech = NpcGeneratedSpeech( + text="昨夜セツを占って白だった。", + intent="speak", + used_logic_ids=(), + estimated_duration_ms=2000, + co_declaration="seer", + claimed_seer_target_seat=6, + claimed_seer_is_wolf=False, + ) + gen = FakeNpcGenerator(default=speech) + service = NpcSpeechService(gen) + + request = SpeakRequest( + ts=1, + trace_id="t", + request_id="rq", + phase_id="g::day1::DAY_DISCUSSION::1", + npc_id="npc", + seat_no=2, + logic_packet_id="lp", + suggested_intent="speak", + max_chars=300, + max_duration_ms=10000, + priority=0, + expires_at_ms=99, + role="SEER", + role_strategy=None, + alive_seats=((2, "Jonas"), (6, "Setsu")), + dead_seats=(), + ) + logic = LogicPacket( + ts=1, + trace_id="t", + packet_id="lp", + phase_id=request.phase_id, + recipient_npc_id="npc", + public_state_summary="", + expires_at_ms=99, + ) + + result = await service.respond( + logic=logic, request=request, now_ms=2, + ) + + assert result.claimed_seer_result == ClaimedSeerResult( + target_seat=6, is_wolf=False, + ) + assert result.claimed_medium_result is None + + +async def test_speech_service_drops_self_claim() -> None: + """A wolf NPC that names its own seat as the divined target is + self-incriminating gibberish; the service drops the structured + claim before persisting (the speech itself still goes through).""" + from wolfbot.npc.speech.speech_service import FakeNpcGenerator + + speech = NpcGeneratedSpeech( + text="自分を占いました(バグ)", + intent="speak", + used_logic_ids=(), + estimated_duration_ms=2000, + co_declaration="seer", + claimed_seer_target_seat=2, # same as speaker_seat below + claimed_seer_is_wolf=False, + ) + gen = FakeNpcGenerator(default=speech) + service = NpcSpeechService(gen) + + request = SpeakRequest( + ts=1, + trace_id="t", + request_id="rq2", + phase_id="g::day1::DAY_DISCUSSION::1", + npc_id="npc", + seat_no=2, + logic_packet_id="lp", + suggested_intent="speak", + max_chars=300, + max_duration_ms=10000, + priority=0, + expires_at_ms=99, + role="SEER", + role_strategy=None, + alive_seats=((2, "Jonas"),), + dead_seats=(), + ) + logic = LogicPacket( + ts=1, + trace_id="t", + packet_id="lp", + phase_id=request.phase_id, + recipient_npc_id="npc", + public_state_summary="", + expires_at_ms=99, + ) + + result = await service.respond( + logic=logic, request=request, now_ms=2, + ) + + assert result.claimed_seer_result is None # self-target dropped diff --git a/tests/test_claim_validator.py b/tests/test_claim_validator.py new file mode 100644 index 0000000..ac2e1b2 --- /dev/null +++ b/tests/test_claim_validator.py @@ -0,0 +1,526 @@ +"""Tests for `wolfbot.master.claim.claim_validator`. + +Covers the matrix: + * real seer: legal / wrong target / wrong color + * fake seer: legal / day-1 morning 2nd claim / target swap / color flip + * real medium: legal / wrong target / wrong color + * fake medium: day-1 morning / no executions yet / target swap / color flip + +The validator is pure — no fixtures, no asyncio, no DB. +""" + +from __future__ import annotations + +from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.ws_messages import ClaimedMediumResult, ClaimedSeerResult +from wolfbot.master.claim.claim_history import ( + ClaimedMediumEntry, + ClaimedSeerEntry, + ClaimerHistory, +) +from wolfbot.master.claim.claim_validator import ( + REASON_MEDIUM_DAY1, + REASON_MEDIUM_FABRICATED_TARGET, + REASON_MEDIUM_NO_EXECUTION, + REASON_MEDIUM_TARGET_SWAP, + REASON_MEDIUM_VERDICT_FLIP, + REASON_MEDIUM_WRONG_VERDICT, + REASON_SEER_DAY1_OVERFLOW, + REASON_SEER_FABRICATED_TARGET, + REASON_SEER_TARGET_SWAP, + REASON_SEER_VERDICT_FLIP, + REASON_SEER_WRONG_VERDICT, + ActualMediumEvent, + ActualSeerEvent, + validate_claim_against_truth, +) + +# ─── real seer ──────────────────────────────────────────────────────── + + +def test_real_seer_matching_target_and_color_passes() -> None: + res = validate_claim_against_truth( + speaker_role=Role.SEER, + speaker_seat=5, + day=1, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=9, is_wolf=False), + claimed_medium=None, + actual_seer_history=[ActualSeerEvent(day=0, target_seat=9, is_wolf=False)], + ) + assert res.ok, res.feedback + + +def test_real_seer_fabricated_target_rejected() -> None: + """Reproduces game ba084ae208cc / 101d9a90ab58: real seer claims + a target that doesn't appear in their NIGHT_0 random white.""" + res = validate_claim_against_truth( + speaker_role=Role.SEER, + speaker_seat=5, + day=1, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=4, is_wolf=False), + claimed_medium=None, + actual_seer_history=[ActualSeerEvent(day=0, target_seat=9, is_wolf=False)], + ) + assert not res.ok + assert res.reason == REASON_SEER_FABRICATED_TARGET + assert res.feedback is not None + assert "席4" in res.feedback or "target_seat=4" in res.feedback + + +def test_real_seer_wrong_color_rejected() -> None: + res = validate_claim_against_truth( + speaker_role=Role.SEER, + speaker_seat=5, + day=1, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=9, is_wolf=True), + claimed_medium=None, + actual_seer_history=[ActualSeerEvent(day=0, target_seat=9, is_wolf=False)], + ) + assert not res.ok + assert res.reason == REASON_SEER_WRONG_VERDICT + + +def test_real_seer_null_claim_passes() -> None: + """Utterances that don't announce a new result are always OK.""" + res = validate_claim_against_truth( + speaker_role=Role.SEER, + speaker_seat=5, + day=1, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=None, + actual_seer_history=[ActualSeerEvent(day=0, target_seat=9, is_wolf=False)], + ) + assert res.ok + + +# ─── fake seer (wolf / madman / villager bluff) ────────────────────── + + +def test_fake_seer_first_claim_passes() -> None: + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=2, + day=1, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=4, is_wolf=False), + claimed_medium=None, + prior_public_claims=ClaimerHistory(claimer_seat=2), + ) + assert res.ok + + +def test_fake_seer_day1_overflow_rejected() -> None: + """Fake seer who already claimed once on day 1 morning can't issue + a second claim same morning.""" + prior = ClaimerHistory( + claimer_seat=2, + seer_claims=( + ClaimedSeerEntry( + day=1, + target_seat=4, + target_name="席4", + is_wolf=False, + declared_at_event_id="e1", + ), + ), + ) + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=2, + day=1, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=5, is_wolf=False), + claimed_medium=None, + prior_public_claims=prior, + ) + assert not res.ok + assert res.reason == REASON_SEER_DAY1_OVERFLOW + + +def test_fake_seer_target_swap_same_night_rejected() -> None: + """Wolf claimed Alice white in turn 1. Tries to claim Bob in turn 2.""" + prior = ClaimerHistory( + claimer_seat=2, + seer_claims=( + ClaimedSeerEntry( + day=2, + target_seat=4, + target_name="席4", + is_wolf=False, + declared_at_event_id="e1", + ), + ), + ) + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=2, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=5, is_wolf=False), + claimed_medium=None, + prior_public_claims=prior, + ) + assert not res.ok + assert res.reason == REASON_SEER_TARGET_SWAP + + +def test_fake_seer_verdict_flip_rejected() -> None: + """Wolf said Alice white, then later flips Alice to black.""" + prior = ClaimerHistory( + claimer_seat=2, + seer_claims=( + ClaimedSeerEntry( + day=2, + target_seat=4, + target_name="席4", + is_wolf=False, + declared_at_event_id="e1", + ), + ), + ) + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=2, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=4, is_wolf=True), + claimed_medium=None, + prior_public_claims=prior, + ) + assert not res.ok + assert res.reason == REASON_SEER_VERDICT_FLIP + + +def test_fake_seer_repeat_same_target_color_passes() -> None: + """Re-citing the same prior claim (same target, same color) is fine — + the speaker is just referencing what they said yesterday.""" + prior = ClaimerHistory( + claimer_seat=2, + seer_claims=( + ClaimedSeerEntry( + day=2, + target_seat=4, + target_name="席4", + is_wolf=False, + declared_at_event_id="e1", + ), + ), + ) + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=2, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=4, is_wolf=False), + claimed_medium=None, + prior_public_claims=prior, + ) + assert res.ok + + +def test_fake_seer_new_day_new_claim_passes() -> None: + """A fake seer can claim a new result on a new day.""" + prior = ClaimerHistory( + claimer_seat=2, + seer_claims=( + ClaimedSeerEntry( + day=1, + target_seat=4, + target_name="席4", + is_wolf=False, + declared_at_event_id="e1", + ), + ), + ) + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=2, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=5, is_wolf=False), + claimed_medium=None, + prior_public_claims=prior, + ) + assert res.ok + + +# ─── real medium ────────────────────────────────────────────────────── + + +def test_real_medium_matching_passes() -> None: + res = validate_claim_against_truth( + speaker_role=Role.MEDIUM, + speaker_seat=6, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=ClaimedMediumResult(target_seat=7, is_wolf=False), + actual_medium_history=[ + ActualMediumEvent(day=2, target_seat=7, is_wolf=False), + ], + executions_so_far=1, + ) + assert res.ok + + +def test_real_medium_fabricated_target_rejected() -> None: + res = validate_claim_against_truth( + speaker_role=Role.MEDIUM, + speaker_seat=6, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=ClaimedMediumResult(target_seat=3, is_wolf=False), + actual_medium_history=[ + ActualMediumEvent(day=2, target_seat=7, is_wolf=False), + ], + executions_so_far=1, + ) + assert not res.ok + assert res.reason == REASON_MEDIUM_FABRICATED_TARGET + + +def test_real_medium_wrong_color_rejected() -> None: + res = validate_claim_against_truth( + speaker_role=Role.MEDIUM, + speaker_seat=6, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=ClaimedMediumResult(target_seat=7, is_wolf=True), + actual_medium_history=[ + ActualMediumEvent(day=2, target_seat=7, is_wolf=False), + ], + executions_so_far=1, + ) + assert not res.ok + assert res.reason == REASON_MEDIUM_WRONG_VERDICT + + +# ─── fake medium ───────────────────────────────────────────────────── + + +def test_fake_medium_day1_morning_rejected() -> None: + """Day-1 morning has no legal medium result — fake medium can't + invent one without immediately outing themselves.""" + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=2, + day=1, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=ClaimedMediumResult(target_seat=4, is_wolf=False), + prior_public_claims=ClaimerHistory(claimer_seat=2), + ) + assert not res.ok + assert res.reason == REASON_MEDIUM_DAY1 + + +def test_fake_medium_no_execution_yet_rejected() -> None: + """day 2 morning but executions_so_far == 0 means no execution to + medium (e.g. day-1 ended without a vote outcome).""" + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=2, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=ClaimedMediumResult(target_seat=4, is_wolf=False), + prior_public_claims=ClaimerHistory(claimer_seat=2), + executions_so_far=0, + ) + assert not res.ok + assert res.reason == REASON_MEDIUM_NO_EXECUTION + + +def test_fake_medium_target_swap_rejected() -> None: + prior = ClaimerHistory( + claimer_seat=2, + medium_claims=( + ClaimedMediumEntry( + day=2, + target_seat=4, + target_name="席4", + is_wolf=False, + declared_at_event_id="e1", + ), + ), + ) + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=2, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=ClaimedMediumResult(target_seat=5, is_wolf=False), + prior_public_claims=prior, + executions_so_far=1, + ) + assert not res.ok + assert res.reason == REASON_MEDIUM_TARGET_SWAP + + +def test_fake_medium_verdict_flip_rejected() -> None: + prior = ClaimerHistory( + claimer_seat=2, + medium_claims=( + ClaimedMediumEntry( + day=2, + target_seat=4, + target_name="席4", + is_wolf=False, + declared_at_event_id="e1", + ), + ), + ) + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=2, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=ClaimedMediumResult(target_seat=4, is_wolf=True), + prior_public_claims=prior, + executions_so_far=1, + ) + assert not res.ok + assert res.reason == REASON_MEDIUM_VERDICT_FLIP + + +# ─── boundary cases ────────────────────────────────────────────────── + + +def test_villager_with_null_claims_always_passes() -> None: + """Non-CO speech from a villager (no role-related claims) is fine.""" + res = validate_claim_against_truth( + speaker_role=Role.VILLAGER, + speaker_seat=7, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=None, + prior_public_claims=ClaimerHistory(claimer_seat=7), + ) + assert res.ok + + +def test_madman_first_seer_claim_passes() -> None: + res = validate_claim_against_truth( + speaker_role=Role.MADMAN, + speaker_seat=8, + day=1, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=3, is_wolf=False), + claimed_medium=None, + prior_public_claims=ClaimerHistory(claimer_seat=8), + ) + assert res.ok + + +# ─── CO saturation cap ─────────────────────────────────────────────── + + +def test_4th_seer_co_rejected() -> None: + """Reproduces game `59d5377c6794`: a 4th seat tries to seer-CO when + 3 distinct seats already have seer claims in the public ledger.""" + from wolfbot.master.claim.claim_validator import REASON_SEER_CO_CAP_EXCEEDED + + res = validate_claim_against_truth( + speaker_role=Role.VILLAGER, + speaker_seat=6, + day=1, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=1, is_wolf=False), + claimed_medium=None, + # Speaker has no prior CO of their own. + prior_public_claims=ClaimerHistory(claimer_seat=6), + # 3 OTHER seats already CO'd seer. + seer_co_count=3, + ) + assert not res.ok + assert res.reason == REASON_SEER_CO_CAP_EXCEEDED + assert "3 件" in (res.feedback or "") + + +def test_3rd_seer_co_passes_when_under_cap() -> None: + """At seer_co_count=2, a 3rd CO is still legal (within the cap).""" + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=4, + day=1, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=1, is_wolf=False), + claimed_medium=None, + prior_public_claims=ClaimerHistory(claimer_seat=4), + seer_co_count=2, + ) + assert res.ok + + +def test_repeat_claim_from_same_speaker_at_cap_passes() -> None: + """The cap only blocks NEW CO claimers. A speaker who's already + CO'd seer can keep adding new daily results past day 1 even when + the ledger has 3 distinct claimers (they're one of those 3).""" + from wolfbot.master.claim.claim_history import ClaimedSeerEntry + + prior = ClaimerHistory( + claimer_seat=4, + seer_claims=( + ClaimedSeerEntry( + day=1, target_seat=1, target_name="席1", + is_wolf=False, declared_at_event_id="e1", + ), + ), + ) + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=4, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=ClaimedSeerResult(target_seat=2, is_wolf=False), + claimed_medium=None, + prior_public_claims=prior, + seer_co_count=3, + ) + assert res.ok + + +def test_3rd_medium_co_rejected() -> None: + """Medium cap is 2. A 3rd medium CO from a new claimer is rejected.""" + from wolfbot.domain.ws_messages import ClaimedMediumResult + from wolfbot.master.claim.claim_validator import REASON_MEDIUM_CO_CAP_EXCEEDED + + res = validate_claim_against_truth( + speaker_role=Role.VILLAGER, + speaker_seat=5, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=ClaimedMediumResult(target_seat=3, is_wolf=False), + prior_public_claims=ClaimerHistory(claimer_seat=5), + medium_co_count=2, + executions_so_far=1, + ) + assert not res.ok + assert res.reason == REASON_MEDIUM_CO_CAP_EXCEEDED + + +def test_2nd_medium_co_passes_when_under_cap() -> None: + from wolfbot.domain.ws_messages import ClaimedMediumResult + + res = validate_claim_against_truth( + speaker_role=Role.WEREWOLF, + speaker_seat=5, + day=2, + phase=Phase.DAY_DISCUSSION, + claimed_seer=None, + claimed_medium=ClaimedMediumResult(target_seat=3, is_wolf=False), + prior_public_claims=ClaimerHistory(claimer_seat=5), + medium_co_count=1, + executions_so_far=1, + ) + assert res.ok diff --git a/tests/test_co_consistency_and_pool.py b/tests/test_co_consistency_and_pool.py new file mode 100644 index 0000000..520faf6 --- /dev/null +++ b/tests/test_co_consistency_and_pool.py @@ -0,0 +1,287 @@ +"""Tests for the three CO-pipeline fixes shipped 2026-05-01: + +1. ``co_declaration`` text-vs-structured consistency guard + (:func:`wolfbot.services.discussion_service._text_contains_self_declaration`, + :func:`_resolve_co_role`). +2. ``pending_co_response`` first-CO counter-CO opportunity window on + :class:`wolfbot.domain.discussion.PublicDiscussionState`. +3. The arbiter's pool combining ``pending_role_callouts`` with the new + ``pending_co_response`` so wolf-side seats get a guaranteed turn to + counter-CO before normal priority resumes. +""" + +from __future__ import annotations + +from wolfbot.domain.discussion import ( + PublicDiscussionState, + SpeakerKind, + SpeechEvent, + SpeechSource, +) +from wolfbot.domain.enums import Phase +from wolfbot.services.discussion_service import ( + _resolve_co_role, + _text_contains_self_declaration, + rebuild_public_state_from_events, +) + +# --------------------------------------------------------- self-declaration guard + + +def test_self_declaration_accepts_explicit_first_person_phrases() -> None: + """The canonical persona voices used by every NPC must register as + self-declarations so the structured ``co_declaration`` flag is + accepted when paired with matching text.""" + assert _text_contains_self_declaration("実は私、占い師なのです。", "seer") + assert _text_contains_self_declaration("僕こそ占い師だ。", "seer") + assert _text_contains_self_declaration("オレ、霊媒師だ!", "medium") + assert _text_contains_self_declaration("我こそ占い師なり!", "seer") + assert _text_contains_self_declaration("私が騎士です。", "knight") + assert _text_contains_self_declaration("わたくし、霊媒師でございます。", "medium") + + +def test_self_declaration_accepts_canonical_co_token_with_verb() -> None: + """The bot-specific ``XCO`` shorthand counts as a declaration when + followed by a declarative verb (``占いCOします``), matching what + veteran human players actually type.""" + assert _text_contains_self_declaration("占いCOします。", "seer") + assert _text_contains_self_declaration("霊媒COする。", "medium") + + +def test_self_declaration_accepts_keyword_with_declarative_suffix() -> None: + """``占い師です`` / ``霊媒師なの`` etc. — declarative verb endings + glued straight to the role keyword without an explicit pronoun. + Some persona voices skip the pronoun entirely (``setsu`` / ``yuriko``).""" + assert _text_contains_self_declaration("占い師です。", "seer") + assert _text_contains_self_declaration("霊媒師なんだ。", "medium") + assert _text_contains_self_declaration("騎士になります。", "knight") + + +def test_self_declaration_rejects_counter_co_request_question() -> None: + """Reproduces game ``98e5a083b5ff`` day 1 ラキオの誤検知: + 『ステラ、対抗占い師は出ないのか?早く名乗りなさい。』 + ラキオ自身は CO していないのに ``co_declaration='seer'`` が + 立っていた。``対抗占い師`` の topic-mention は self-decl では + ない。""" + assert not _text_contains_self_declaration( + "ステラ、対抗占い師は出ないのか?早く名乗りなさい。", "seer", + ) + assert not _text_contains_self_declaration( + "対抗の占い師、もう出てこないんですか?", "seer", + ) + + +def test_self_declaration_rejects_topical_mentions() -> None: + """Plain "誰か占い師?" / "占い師の方どうぞ" / "占いCOがいない" are + requests / observations about the role, not self-declarations.""" + assert not _text_contains_self_declaration("占い師の方どうぞ", "seer") + assert not _text_contains_self_declaration("誰か占い師は?", "seer") + assert not _text_contains_self_declaration("占いCOがいないのは不自然", "seer") + + +def test_self_declaration_rejects_other_roles_keywords() -> None: + """Asking about ``role=knight`` against a ``占い師`` text must not + leak through — the function is role-scoped on purpose.""" + assert not _text_contains_self_declaration("実は私、占い師なんだ。", "knight") + assert not _text_contains_self_declaration("私が騎士です。", "seer") + + +def _ev(text: str, *, declared: str | None) -> SpeechEvent: + return SpeechEvent( + event_id="ev1", + game_id="g", + phase_id="g::day1::DAY_DISCUSSION::1", + day=1, + phase=Phase.DAY_DISCUSSION, + source=SpeechSource.NPC_GENERATED, + speaker_kind=SpeakerKind.NPC, + speaker_seat=4, + text=text, + co_declaration=declared, + created_at_ms=10, + ) + + +def test_resolve_co_role_drops_structured_field_when_text_mismatches() -> None: + """Structured ``co_declaration='seer'`` paired with topic-mention + text returns None (no CO) — the guard against the ラキオ leak.""" + event = _ev( + text="ステラ、対抗占い師は出ないのか?早く名乗りなさい。", + declared="seer", + ) + assert _resolve_co_role(event) is None + + +def test_resolve_co_role_accepts_structured_field_when_text_matches() -> None: + event = _ev( + text="僕こそ占い師。昨夜ジョナスを占い、人狼じゃない。", + declared="seer", + ) + assert _resolve_co_role(event) == "seer" + + +def test_resolve_co_role_falls_back_to_legacy_marker_when_text_only() -> None: + """When ``co_declaration`` is None but the legacy canonical marker + appears in text, fall back to the substring scan — keeps the path + open for human-typed messages that pre-date the structured field.""" + event = _ev(text="占いCO入ります", declared=None) + assert _resolve_co_role(event) == "seer" + + +# ----------------------------------------------- first-CO counter-CO window + + +def _baseline(alive: list[int]) -> SpeechEvent: + import json + return SpeechEvent( + event_id="baseline", + game_id="g", + phase_id="g::day1::DAY_DISCUSSION::1", + day=1, + phase=Phase.DAY_DISCUSSION, + source=SpeechSource.PHASE_BASELINE, + speaker_kind=SpeakerKind.SYSTEM, + speaker_seat=None, + text="", + alive_seat_nos_json=json.dumps(alive), + created_at_ms=0, + ) + + +def _co_event( + *, + seat: int, + text: str, + declared: str, + event_id: str, + ts: int, +) -> SpeechEvent: + return SpeechEvent( + event_id=event_id, + game_id="g", + phase_id="g::day1::DAY_DISCUSSION::1", + day=1, + phase=Phase.DAY_DISCUSSION, + source=SpeechSource.NPC_GENERATED, + speaker_kind=SpeakerKind.NPC, + speaker_seat=seat, + text=text, + co_declaration=declared, + created_at_ms=ts, + ) + + +def test_pending_co_response_fires_on_first_seer_co() -> None: + """First seer CO of the game adds 'seer' to ``pending_co_response`` + so the arbiter's counter-CO pool fires on the next dispatch.""" + events = [ + _baseline([1, 2, 3, 4, 5, 6, 7, 8, 9]), + _co_event( + seat=6, text="実は私、占い師なのです。", + declared="seer", event_id="e1", ts=10, + ), + ] + + state = rebuild_public_state_from_events(events) + + assert state is not None + assert state.pending_co_response == frozenset({"seer"}) + + +def test_pending_co_response_does_not_re_fire_for_2nd_co_of_role() -> None: + """Per design: a counter-CO arriving while the pool is still + rotating must NOT re-fire the trigger (otherwise the pool would + reset every time a wolf fakes a CO and the rotation never ends). + Once set, ``pending_co_response`` stays set for the phase.""" + events = [ + _baseline([1, 2, 3, 4, 5, 6, 7, 8, 9]), + _co_event( + seat=6, text="実は私、占い師なのです。", + declared="seer", event_id="e1", ts=10, + ), + _co_event( + seat=2, text="僕こそ占い師。", declared="seer", + event_id="e2", ts=20, + ), + ] + + state = rebuild_public_state_from_events(events) + + assert state is not None + assert state.pending_co_response == frozenset({"seer"}) + + +def test_pending_co_response_independent_per_role() -> None: + """First seer CO and first medium CO each fire their own role + key — the pool composition for medium pulls in a different set of + pool members.""" + events = [ + _baseline([1, 2, 3, 4, 5, 6, 7, 8, 9]), + _co_event( + seat=6, text="実は私、占い師なのです。", + declared="seer", event_id="e1", ts=10, + ), + _co_event( + seat=5, text="オレ、霊媒師だ!", + declared="medium", event_id="e2", ts=20, + ), + ] + + state = rebuild_public_state_from_events(events) + + assert state is not None + assert state.pending_co_response == frozenset({"seer", "medium"}) + + +def test_pending_co_response_skips_text_mismatch_co() -> None: + """A leaked-intent event (structured ``co_declaration='seer'`` but + text has no self-decl) is rejected by ``_resolve_co_role``; with + no CO recorded the pool window does NOT open prematurely.""" + events = [ + _baseline([1, 2, 3, 4, 5, 6, 7, 8, 9]), + _co_event( + seat=4, + text="ステラ、対抗占い師は出ないのか?早く名乗りなさい。", + declared="seer", + event_id="e1", + ts=10, + ), + ] + + state = rebuild_public_state_from_events(events) + + assert state is not None + assert state.pending_co_response == frozenset() + # And no co_claims either. + assert state.co_claims == () + + +def test_pending_co_response_idempotent_on_rebuild() -> None: + """The fold is the canonical recovery path on Master restart — it + must produce the same ``pending_co_response`` shape regardless of + how many times it runs over the same event sequence.""" + events = [ + _baseline([1, 2, 3, 4, 5, 6, 7, 8, 9]), + _co_event( + seat=6, text="実は私、占い師なのです。", + declared="seer", event_id="e1", ts=10, + ), + ] + + a = rebuild_public_state_from_events(events) + b = rebuild_public_state_from_events(events) + + assert a is not None and b is not None + assert a.pending_co_response == b.pending_co_response + + +def test_default_pending_co_response_is_empty() -> None: + """Construction default keeps the pool dormant — required so older + callers that don't yet set the field don't accidentally fire + counter-CO rotations.""" + state = PublicDiscussionState( + game_id="g", + phase_id="p", + day=1, + ) + assert state.pending_co_response == frozenset() diff --git a/tests/test_config.py b/tests/test_config.py index ad5c991..fadf0bd 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,8 +1,8 @@ -"""Tests for the env-driven Settings model. +"""Tests for the env-driven Settings models. -We pass `_env_file=None` everywhere so the repo's actual `.env` (which has -real values for `DISCORD_TOKEN`, `XAI_API_KEY`, etc.) cannot leak into and -mask validator failures we want to assert on. +We pass `_env_file=None` everywhere so the repo's actual `.env.master` +(which has real values) cannot leak into and mask validator failures we +want to assert on. """ from __future__ import annotations @@ -10,7 +10,7 @@ import pytest from pydantic import SecretStr, ValidationError -from wolfbot.config import Settings +from wolfbot.config import MasterSettings def _base_kwargs() -> dict[str, object]: @@ -22,114 +22,319 @@ def _base_kwargs() -> dict[str, object]: } -def test_default_provider_is_xai_and_requires_xai_key() -> None: +# ----------------------------------------------------------- xAI provider +def test_default_provider_is_xai_and_requires_gameplay_api_key() -> None: with pytest.raises(ValidationError): - Settings(_env_file=None, **_base_kwargs()) # type: ignore[arg-type] + MasterSettings(_env_file=None, **_base_kwargs()) # type: ignore[arg-type] def test_xai_provider_with_key_constructs() -> None: - s = Settings(_env_file=None, **_base_kwargs(), XAI_API_KEY=SecretStr("k")) # type: ignore[arg-type] - assert s.LLM_PROVIDER == "xai" - assert s.XAI_MODEL == "grok-4-1-fast" + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + GAMEPLAY_LLM_API_KEY=SecretStr("k"), + ) + assert s.GAMEPLAY_LLM_PROVIDER == "xai" + assert s.GAMEPLAY_LLM_MODEL == "grok-4-1-fast" + assert s.GAMEPLAY_LLM_BASE_URL is None # DeepSeek defaults still readable but unused on the xai path. - assert s.DEEPSEEK_BASE_URL == "https://api.deepseek.com" - assert s.DEEPSEEK_MODEL == "deepseek-v4-flash" - - -def test_deepseek_provider_requires_deepseek_key() -> None: - with pytest.raises(ValidationError): - Settings(_env_file=None, **_base_kwargs(), LLM_PROVIDER="deepseek") # type: ignore[arg-type] + assert s.GAMEPLAY_LLM_THINKING == "enabled" + assert s.GAMEPLAY_LLM_REASONING_EFFORT == "max" -def test_deepseek_provider_does_not_require_xai_key() -> None: - s = Settings( # type: ignore[arg-type] +def test_xai_provider_can_override_base_url() -> None: + s = MasterSettings( # type: ignore[arg-type] _env_file=None, **_base_kwargs(), - LLM_PROVIDER="deepseek", - DEEPSEEK_API_KEY=SecretStr("d"), + GAMEPLAY_LLM_API_KEY=SecretStr("k"), + GAMEPLAY_LLM_BASE_URL="http://localhost:11434/v1", ) - assert s.XAI_API_KEY is None - assert s.DEEPSEEK_THINKING == "enabled" - assert s.DEEPSEEK_REASONING_EFFORT == "max" - assert s.DEEPSEEK_MODEL == "deepseek-v4-flash" + assert s.GAMEPLAY_LLM_BASE_URL == "http://localhost:11434/v1" -def test_unknown_provider_rejected() -> None: +# ------------------------------------------------------ DeepSeek provider +def test_deepseek_provider_requires_api_key() -> None: with pytest.raises(ValidationError): - Settings(_env_file=None, **_base_kwargs(), LLM_PROVIDER="claude") # type: ignore[arg-type] + MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + GAMEPLAY_LLM_PROVIDER="deepseek", + ) + + +def test_deepseek_provider_with_key_constructs() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + GAMEPLAY_LLM_PROVIDER="deepseek", + GAMEPLAY_LLM_API_KEY=SecretStr("d"), + ) + assert s.GAMEPLAY_LLM_PROVIDER == "deepseek" + assert s.GAMEPLAY_LLM_THINKING == "enabled" + assert s.GAMEPLAY_LLM_REASONING_EFFORT == "max" def test_thinking_literal_is_strict() -> None: - base = {**_base_kwargs(), "LLM_PROVIDER": "deepseek", "DEEPSEEK_API_KEY": SecretStr("d")} with pytest.raises(ValidationError): - Settings(_env_file=None, **base, DEEPSEEK_THINKING="off") # type: ignore[arg-type] + MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + GAMEPLAY_LLM_PROVIDER="deepseek", + GAMEPLAY_LLM_API_KEY=SecretStr("d"), + GAMEPLAY_LLM_THINKING="off", + ) def test_reasoning_effort_literal_is_strict() -> None: - base = {**_base_kwargs(), "LLM_PROVIDER": "deepseek", "DEEPSEEK_API_KEY": SecretStr("d")} with pytest.raises(ValidationError): - Settings(_env_file=None, **base, DEEPSEEK_REASONING_EFFORT="medium") # type: ignore[arg-type] + MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + GAMEPLAY_LLM_PROVIDER="deepseek", + GAMEPLAY_LLM_API_KEY=SecretStr("d"), + GAMEPLAY_LLM_REASONING_EFFORT="medium", + ) -def test_gemini_provider_requires_gemini_vertex_project() -> None: +# -------------------------------------------------------- Gemini provider +def test_gemini_provider_requires_vertex_project() -> None: with pytest.raises(ValidationError): - Settings(_env_file=None, **_base_kwargs(), LLM_PROVIDER="gemini") # type: ignore[arg-type] + MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + GAMEPLAY_LLM_PROVIDER="gemini", + ) -def test_gemini_provider_does_not_require_xai_or_deepseek_key() -> None: - s = Settings( # type: ignore[arg-type] +def test_gemini_provider_does_not_require_api_key() -> None: + s = MasterSettings( # type: ignore[arg-type] _env_file=None, **_base_kwargs(), - LLM_PROVIDER="gemini", - GEMINI_VERTEX_PROJECT="my-project", + GAMEPLAY_LLM_PROVIDER="gemini", + GAMEPLAY_LLM_VERTEX_PROJECT="my-project", ) - assert s.XAI_API_KEY is None - assert s.DEEPSEEK_API_KEY is None - assert s.GEMINI_VERTEX_PROJECT == "my-project" - assert s.GEMINI_VERTEX_LOCATION == "global" - assert s.GEMINI_MODEL == "gemini-3-flash-preview" - assert s.GEMINI_THINKING_LEVEL == "high" + assert s.GAMEPLAY_LLM_API_KEY is None + assert s.GAMEPLAY_LLM_VERTEX_PROJECT == "my-project" + assert s.GAMEPLAY_LLM_VERTEX_LOCATION == "global" + assert s.GAMEPLAY_LLM_THINKING_LEVEL == "high" def test_gemini_thinking_level_literal_is_strict() -> None: - base = { - **_base_kwargs(), - "LLM_PROVIDER": "gemini", - "GEMINI_VERTEX_PROJECT": "my-project", - } with pytest.raises(ValidationError): - Settings(_env_file=None, **base, GEMINI_THINKING_LEVEL="off") # type: ignore[arg-type] + MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + GAMEPLAY_LLM_PROVIDER="gemini", + GAMEPLAY_LLM_VERTEX_PROJECT="p", + GAMEPLAY_LLM_THINKING_LEVEL="off", + ) -def test_gemini_api_key_alone_without_vertex_project_rejected() -> None: - """Stale GEMINI_API_KEY in env is silently dropped by extra='ignore'; - the missing GEMINI_VERTEX_PROJECT still raises a ValidationError.""" +def test_gemini_empty_vertex_project_rejected() -> None: + """Empty string in .env should be rejected at boot, not deferred to + the SDK at first request time.""" with pytest.raises(ValidationError): - Settings( # type: ignore[arg-type, call-arg] + MasterSettings( # type: ignore[arg-type] _env_file=None, **_base_kwargs(), - LLM_PROVIDER="gemini", - GEMINI_API_KEY=SecretStr("g"), + GAMEPLAY_LLM_PROVIDER="gemini", + GAMEPLAY_LLM_VERTEX_PROJECT="", ) -def test_gemini_vertex_location_default_is_global() -> None: - s = Settings( # type: ignore[arg-type] +# --------------------------------------------------------------- mock provider +def test_mock_provider_does_not_require_api_key_or_vertex_project() -> None: + """``GAMEPLAY_LLM_PROVIDER=mock`` is for offline integration tests — + no credentials should be required.""" + s = MasterSettings( # type: ignore[arg-type] _env_file=None, **_base_kwargs(), - LLM_PROVIDER="gemini", - GEMINI_VERTEX_PROJECT="p", + GAMEPLAY_LLM_PROVIDER="mock", ) - assert s.GEMINI_VERTEX_LOCATION == "global" + assert s.GAMEPLAY_LLM_PROVIDER == "mock" + assert s.GAMEPLAY_LLM_API_KEY is None + assert s.GAMEPLAY_LLM_VERTEX_PROJECT is None -def test_gemini_empty_vertex_project_rejected() -> None: - """Empty string in .env should be rejected at boot, not deferred to - the SDK at first request time.""" +def test_mock_decider_config_round_trips() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + GAMEPLAY_LLM_PROVIDER="mock", + ) + cfg = s.gameplay_decider_config() + assert cfg.provider == "mock" + assert cfg.api_key is None + + +# ---------------------------------------------------------------- common +def test_unknown_provider_rejected() -> None: with pytest.raises(ValidationError): - Settings( # type: ignore[arg-type] + MasterSettings( # type: ignore[arg-type] _env_file=None, **_base_kwargs(), - LLM_PROVIDER="gemini", - GEMINI_VERTEX_PROJECT="", + GAMEPLAY_LLM_PROVIDER="claude", ) + + +def test_gameplay_decider_config_round_trips_xai() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + GAMEPLAY_LLM_API_KEY=SecretStr("k"), + ) + cfg = s.gameplay_decider_config() + assert cfg.provider == "xai" + assert cfg.api_key is not None + assert cfg.api_key.get_secret_value() == "k" + assert cfg.model == "grok-4-1-fast" + + +def test_gameplay_decider_config_round_trips_gemini() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + GAMEPLAY_LLM_PROVIDER="gemini", + GAMEPLAY_LLM_VERTEX_PROJECT="p", + ) + cfg = s.gameplay_decider_config() + assert cfg.provider == "gemini" + assert cfg.vertex_project == "p" + assert cfg.vertex_location == "global" + assert cfg.thinking_level == "high" + + +# ──────────────────────────────────────────────── ingest pipelines +def _gameplay_ok_kwargs() -> dict[str, object]: + """Minimal valid gameplay LLM kwargs so pipeline tests can focus on + the new VOICE_/TEXT_ pipeline validators without fighting the + gameplay validator.""" + return {**_base_kwargs(), "GAMEPLAY_LLM_API_KEY": SecretStr("g")} + + +def test_pipeline_defaults_require_no_ingest_credentials() -> None: + """Rounds-mode boot: nothing voice/text-related needed. The + defaults (``disabled``/``passthrough``) keep validators silent so + ops with no analyzer key at all can still launch the bot.""" + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_gameplay_ok_kwargs(), + ) + assert s.VOICE_PIPELINE == "disabled" + assert s.TEXT_PIPELINE == "passthrough" + assert s.AUDIO_ANALYZER_API_KEY is None + assert s.STT_API_KEY is None + assert s.TEXT_ANALYZER_API_KEY is None + + +def test_voice_pipeline_audio_analyzer_requires_audio_key() -> None: + with pytest.raises(ValidationError, match="AUDIO_ANALYZER_API_KEY"): + MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_gameplay_ok_kwargs(), + VOICE_PIPELINE="audio_analyzer", + ) + + +def test_voice_pipeline_audio_analyzer_with_key_constructs() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_gameplay_ok_kwargs(), + VOICE_PIPELINE="audio_analyzer", + AUDIO_ANALYZER_API_KEY=SecretStr("aa"), + ) + assert s.VOICE_PIPELINE == "audio_analyzer" + assert s.AUDIO_ANALYZER_PROVIDER == "gemini" + + +def test_voice_pipeline_split_requires_stt_and_text_analyzer_keys() -> None: + """``stt_then_text_analyzer`` composes Stt + TextAnalyzer; both + credentials must be present.""" + # Missing STT key. + with pytest.raises(ValidationError, match="STT_API_KEY"): + MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_gameplay_ok_kwargs(), + VOICE_PIPELINE="stt_then_text_analyzer", + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + ) + # Missing TextAnalyzer key. + with pytest.raises(ValidationError, match="TEXT_ANALYZER_API_KEY"): + MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_gameplay_ok_kwargs(), + VOICE_PIPELINE="stt_then_text_analyzer", + STT_API_KEY=SecretStr("ss"), + ) + + +def test_voice_pipeline_split_rejects_gemini_text_analyzer() -> None: + """Cross-field guard: Whisper-then-Gemini composition isn't wired up + in the runtime so the validator must fail loud at boot rather than + deferring to a runtime AttributeError.""" + with pytest.raises(ValidationError, match="not supported"): + MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_gameplay_ok_kwargs(), + VOICE_PIPELINE="stt_then_text_analyzer", + STT_API_KEY=SecretStr("ss"), + TEXT_ANALYZER_PROVIDER="gemini", + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + ) + + +def test_voice_pipeline_split_constructs_with_xai_text_analyzer() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_gameplay_ok_kwargs(), + VOICE_PIPELINE="stt_then_text_analyzer", + STT_API_KEY=SecretStr("ss"), + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + ) + assert s.VOICE_PIPELINE == "stt_then_text_analyzer" + assert s.STT_PROVIDER == "groq" + assert s.TEXT_ANALYZER_PROVIDER == "xai" + + +def test_text_pipeline_text_analyzer_requires_key() -> None: + """Text path alone needs TextAnalyzer credentials even when voice + is disabled — they don't piggy-back on each other anymore.""" + with pytest.raises(ValidationError, match="TEXT_ANALYZER_API_KEY"): + MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_gameplay_ok_kwargs(), + TEXT_PIPELINE="text_analyzer", + ) + + +def test_text_pipeline_text_analyzer_with_key_constructs() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_gameplay_ok_kwargs(), + TEXT_PIPELINE="text_analyzer", + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + ) + assert s.TEXT_PIPELINE == "text_analyzer" + assert s.TEXT_ANALYZER_MODEL == "grok-4-1-fast" + + +def test_audio_voice_text_pipelines_are_independent() -> None: + """Bug fix coverage: configuring the voice path with Gemini must not + force the text path through the same credential. Set the typical + ``Gemini multimodal voice + xAI text analyzer`` combo and assert + both knobs land where intended.""" + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_gameplay_ok_kwargs(), + VOICE_PIPELINE="audio_analyzer", + AUDIO_ANALYZER_API_KEY=SecretStr("gemini-key"), + TEXT_PIPELINE="text_analyzer", + TEXT_ANALYZER_PROVIDER="xai", + TEXT_ANALYZER_API_KEY=SecretStr("xai-key"), + ) + assert s.AUDIO_ANALYZER_PROVIDER == "gemini" + assert s.AUDIO_ANALYZER_API_KEY is not None + assert s.AUDIO_ANALYZER_API_KEY.get_secret_value() == "gemini-key" + assert s.TEXT_ANALYZER_PROVIDER == "xai" + assert s.TEXT_ANALYZER_API_KEY is not None + assert s.TEXT_ANALYZER_API_KEY.get_secret_value() == "xai-key" diff --git a/tests/test_deduction_service.py b/tests/test_deduction_service.py new file mode 100644 index 0000000..b17e253 --- /dev/null +++ b/tests/test_deduction_service.py @@ -0,0 +1,181 @@ +"""DeductionService unit tests. + +Pure-function pipeline: given CO claims + players + seats + votes, the +service must produce HARD/MEDIUM facts in deterministic order, and each +fact's text must include the seat-token form so LLM seats can resolve +references back to seat numbers. +""" + +from __future__ import annotations + +from wolfbot.domain.discussion import CoClaim +from wolfbot.domain.models import Player, Seat, Vote +from wolfbot.services.deduction_service import ( + DeducedFact, + FactConfidence, + deduce, + render_facts_block, +) + + +def _seat(no: int, name: str) -> Seat: + return Seat( + seat_no=no, + display_name=name, + discord_user_id=None, + is_llm=True, + persona_key="setsu", + ) + + +def _player(no: int, alive: bool = True) -> Player: + return Player( + game_id="g1", + seat_no=no, + role=None, + alive=alive, + died_day=None, + ) + + +def _claim(seat: int, role: str, event_id: str = "ev") -> CoClaim: + return CoClaim(seat=seat, role_claim=role, declared_at_event_id=event_id) + + +def _seats_9() -> list[Seat]: + return [_seat(i, f"P{i}") for i in range(1, 10)] + + +def _all_alive() -> list[Player]: + return [_player(i) for i in range(1, 10)] + + +def test_no_co_claims_returns_no_facts() -> None: + facts = deduce(co_claims=(), players=_all_alive(), seats=_seats_9()) + assert facts == () + + +def test_single_uncountered_seer_co_yields_hard_map_and_medium_likely_real() -> None: + """One alive seer claim, no counters → HARD CO map + MEDIUM 'likely real' fact.""" + facts = deduce( + co_claims=(_claim(3, "seer"),), + players=_all_alive(), + seats=_seats_9(), + ) + assert len(facts) == 2 + # CO map fact (HARD) lists the alive claimant by seat token. + co_map = facts[0] + assert co_map.confidence is FactConfidence.HARD + assert "占い師" in co_map.text + assert "席3 P3" in co_map.text + # Likelihood fact (MEDIUM). + likely = facts[1] + assert likely.confidence is FactConfidence.MEDIUM + assert "対抗履歴なし" in likely.text + assert "席3" in likely.text + + +def test_counter_co_two_alive_yields_hard_warning() -> None: + """Two alive seers → HARD counter-CO warning (max 1 real per ruleset). + + No MEDIUM 'likely real' fact when contested. + """ + facts = deduce( + co_claims=(_claim(3, "seer"), _claim(7, "seer")), + players=_all_alive(), + seats=_seats_9(), + ) + confidences = [f.confidence for f in facts] + assert FactConfidence.HARD in confidences + counter_facts = [f for f in facts if "騙り確定" in f.text] + assert len(counter_facts) == 1 + assert "占い師" in counter_facts[0].text + assert counter_facts[0].affected_seats == frozenset({3, 7}) + # No MEDIUM 'likely real' line — both could still be騙り. + assert all(f.confidence is FactConfidence.HARD for f in facts) + + +def test_sole_survivor_of_contested_chain_is_not_auto_real() -> None: + """≥2 historical claimants, 1 alive → MEDIUM warning, not blanket trust.""" + players = [_player(i, alive=(i != 7)) for i in range(1, 10)] + facts = deduce( + co_claims=(_claim(3, "seer"), _claim(7, "seer")), + players=players, + seats=_seats_9(), + ) + medium = [f for f in facts if f.confidence is FactConfidence.MEDIUM] + assert len(medium) == 1 + assert "対抗 CO 履歴あり" in medium[0].text + assert "自動で真置きしない" in medium[0].text + assert medium[0].affected_seats == frozenset({3}) + + +def test_co_map_separates_alive_and_dead() -> None: + players = [_player(i, alive=(i != 5)) for i in range(1, 10)] + facts = deduce( + co_claims=(_claim(5, "medium"),), + players=players, + seats=_seats_9(), + ) + co_map = next(f for f in facts if "霊媒師 の名乗り履歴" in f.text) + assert "生存=(なし)" in co_map.text + assert "死亡済み=席5 P5" in co_map.text + + +def test_vote_history_emits_hard_execution_record() -> None: + """Past day with majority vote → HARD execution timeline fact.""" + votes_d1 = [ + Vote(game_id="g1", day=1, round=0, voter_seat=1, target_seat=2, submitted_at=0), + Vote(game_id="g1", day=1, round=0, voter_seat=4, target_seat=2, submitted_at=0), + Vote(game_id="g1", day=1, round=0, voter_seat=7, target_seat=2, submitted_at=0), + Vote(game_id="g1", day=1, round=0, voter_seat=3, target_seat=5, submitted_at=0), + ] + facts = deduce( + co_claims=(), + players=_all_alive(), + seats=_seats_9(), + votes_by_day={1: votes_d1}, + ) + exec_facts = [f for f in facts if "day 1 処刑" in f.text] + assert len(exec_facts) == 1 + assert exec_facts[0].confidence is FactConfidence.HARD + assert "席2 P2" in exec_facts[0].text + # Voters listed in seat order. + assert "席1 P1、席4 P4、席7 P7" in exec_facts[0].text + + +def test_render_facts_block_groups_by_confidence_with_headers() -> None: + facts = ( + DeducedFact(text="hard A", confidence=FactConfidence.HARD), + DeducedFact(text="medium A", confidence=FactConfidence.MEDIUM), + DeducedFact(text="hard B", confidence=FactConfidence.HARD), + ) + block = render_facts_block(facts) + # HARD section comes first, with header. + assert block.index("HARD") < block.index("MEDIUM") + # All three facts are surfaced. + assert "- hard A" in block + assert "- hard B" in block + assert "- medium A" in block + + +def test_render_facts_block_handles_empty_input() -> None: + assert render_facts_block(()) == "(該当なし)" + + +def test_facts_order_is_deterministic_co_first_then_likelihood_then_votes() -> None: + """Stable order: CO map → CO likelihood → vote history. Diffable across runs.""" + votes_d1 = [ + Vote(game_id="g1", day=1, round=0, voter_seat=1, target_seat=2, submitted_at=0), + ] + facts = deduce( + co_claims=(_claim(3, "seer"),), + players=_all_alive(), + seats=_seats_9(), + votes_by_day={1: votes_d1}, + ) + texts = [f.text for f in facts] + co_map_idx = next(i for i, t in enumerate(texts) if "占い師 の名乗り履歴" in t) + likely_idx = next(i for i, t in enumerate(texts) if "対抗履歴なし" in t) + vote_idx = next(i for i, t in enumerate(texts) if "day 1 処刑" in t) + assert co_map_idx < likely_idx < vote_idx diff --git a/tests/test_discord_cog_create.py b/tests/test_discord_cog_create.py index cb6ac45..8644242 100644 --- a/tests/test_discord_cog_create.py +++ b/tests/test_discord_cog_create.py @@ -114,9 +114,7 @@ async def test_create_game_db_failure_cleans_up_both_channels(repo) -> None: wolves = FakeChannel(id=12) queue: list[FakeChannel] = [heaven, wolves] - async def fake_create( - guild: Any, name: str, *, safe_to_delete_ids: set[str] - ) -> FakeChannel: + async def fake_create(guild: Any, name: str, *, safe_to_delete_ids: set[str]) -> FakeChannel: return queue.pop(0) cog._create_private_channel = fake_create # type: ignore[method-assign] diff --git a/tests/test_discord_service.py b/tests/test_discord_service.py index dca7fbe..c5bca04 100644 --- a/tests/test_discord_service.py +++ b/tests/test_discord_service.py @@ -327,6 +327,131 @@ async def test_announce_waiting_vote_pending_retains_names(repo: SqliteRepo) -> assert adapter.wolves_sent == [] +class _MorningPostingAdapter(DiscordBotAdapter): + """DiscordBotAdapter narrowed to exercise post_morning + survivor + roster posting without a real discord.Client. + + Mirrors :class:`_ChannelCapturingAdapter`'s pattern but exposes a + single capture list (in chronological send order) so the test can + assert (a) the morning announcement lands first, then (b) the + survivor roster line is appended right after. + """ + + def __init__(self, repo: SqliteRepo) -> None: + self.bot = MagicMock() + self.repo = repo + self.settings = MagicMock() + self.perms = MagicMock() + self._gs_slot = {"gs": MagicMock()} + self._narrator = None # rounds-mode path: legacy text post + self._npc_registry = None + self.sent: list[str] = [] + + test_self = self + + class _Channel: + async def send(self, text: str) -> None: + test_self.sent.append(text) + + self._main_text = lambda game: _Channel() # type: ignore[assignment] + + +async def test_post_morning_appends_survivors_list_in_rounds_mode( + repo: SqliteRepo, +) -> None: + """Rounds mode (no narrator) posts the raw morning text followed + immediately by the post-attack survivor roster. Pre-fix the + survivor list was missing entirely; users had to scroll the + seat-permission updates to see who was still in. + """ + game = Game( + id="g-morning", + guild_id="g", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=2, + main_text_channel_id="1", + main_vc_channel_id="vc", + heaven_channel_id="h", + wolves_channel_id="w", + created_at=0, + discussion_mode="rounds", + ) + await repo.create_game(game) + # Seats 1 + 2 alive, seat 3 dead (executed yesterday). + seats = [ + Seat(seat_no=1, display_name="Alice", discord_user_id="1001", + is_llm=False, persona_key=None), + Seat(seat_no=2, display_name="Bob", discord_user_id="1002", + is_llm=False, persona_key=None), + Seat(seat_no=3, display_name="Cara", discord_user_id="1003", + is_llm=False, persona_key=None), + ] + for s in seats: + await repo.insert_seat(game.id, s) + await repo.set_player_role(game.id, 1, Role.VILLAGER) + await repo.set_player_role(game.id, 2, Role.WEREWOLF) + await repo.set_player_role(game.id, 3, Role.SEER) + # Mark seat 3 dead via raw UPDATE (test-only path; live engine uses + # apply_transition). + async with repo._tx() as db: + await db.execute( + "UPDATE seats SET alive=0 WHERE game_id=? AND seat_no=3", + (game.id,), + ) + + adapter = _MorningPostingAdapter(repo) + await adapter.post_morning(game, "朝になりました。犠牲者: Cara") + + assert len(adapter.sent) == 2, adapter.sent + morning_msg, survivors_msg = adapter.sent + assert morning_msg == "☀️ 朝になりました。犠牲者: Cara" + assert survivors_msg.startswith("🌱 生存者 (2名): ") + # Both alive seats included in the roster, dead seat excluded. + assert "席1 Alice" in survivors_msg + assert "席2 Bob" in survivors_msg + assert "Cara" not in survivors_msg + + +async def test_post_morning_skips_survivors_list_when_no_survivors( + repo: SqliteRepo, +) -> None: + """Edge case: morning resolution with zero survivors is impossible + in normal play (game ends first), but the helper must defensively + skip the survivor line rather than emit "🌱 生存者 (0名): ". + """ + game = Game( + id="g-empty", + guild_id="g", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=2, + main_text_channel_id="1", + main_vc_channel_id="vc", + heaven_channel_id="h", + wolves_channel_id="w", + created_at=0, + discussion_mode="rounds", + ) + await repo.create_game(game) + seat = Seat( + seat_no=1, display_name="Alice", discord_user_id="1001", + is_llm=False, persona_key=None, + ) + await repo.insert_seat(game.id, seat) + await repo.set_player_role(game.id, 1, Role.VILLAGER) + async with repo._tx() as db: + await db.execute( + "UPDATE seats SET alive=0 WHERE game_id=?", (game.id,), + ) + + adapter = _MorningPostingAdapter(repo) + await adapter.post_morning(game, "朝になりました。犠牲者: Alice") + + # Just the morning announcement; no roster line for an empty pool. + assert adapter.sent == ["☀️ 朝になりました。犠牲者: Alice"] + + class _ProbeUser: """Minimal duck-typed discord.User for _preflight_dms probing. diff --git a/tests/test_discussion_state.py b/tests/test_discussion_state.py new file mode 100644 index 0000000..35d0e16 --- /dev/null +++ b/tests/test_discussion_state.py @@ -0,0 +1,444 @@ +"""Bundle 1 tests — SpeechEvent persistence, sentinel insertion, post-write hooks, +and PublicDiscussionState rebuild. + +Covers tasks 1.4 and 1.5 of the speech-event-bus-foundation bundle: + * SpeechEvent round-trip through SqliteSpeechEventStore. + * begin_phase() inserts a sentinel and returns the canonical phase_id. + * DiscussionService.record() emits PLAYER_SPEECH LogEntry per source. + * DiscussionService.record() skips channel post for source=text. + * DiscussionService.record() skips both LogEntry and channel post for sentinels. + * rebuild_public_state_from_events() is bitwise reproducible from a fresh fold. + * silent_seats correctly excludes dead seats (via the sentinel baseline). + * co_claims are detected from canonical CO tokens. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest_asyncio + +from wolfbot.domain.discussion import SpeakerKind, SpeechSource, make_phase_id +from wolfbot.domain.enums import Phase +from wolfbot.domain.models import LogEntry +from wolfbot.persistence.schema import migrate +from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, + make_human_text_event, + make_npc_generated_event, + make_voice_stt_event, + rebuild_public_state_from_events, +) + + +@pytest_asyncio.fixture +async def store_conn(tmp_path: Path) -> AsyncIterator: + import aiosqlite + + db_path = tmp_path / "speech.db" + await migrate(db_path) + conn = await aiosqlite.connect(str(db_path)) + try: + yield conn + finally: + await conn.close() + + +# ----- in-memory recording fakes for the service hooks ------------------------------ + + +class _RecordingLogSink: + def __init__(self) -> None: + self.entries: list[LogEntry] = [] + + async def insert_log_public(self, entry: LogEntry) -> None: + self.entries.append(entry) + + +class _RecordingPoster: + def __init__(self) -> None: + self.posts: list[tuple[str, str, str]] = [] + + async def post_public(self, game_id: str, text: str, kind: str) -> None: + self.posts.append((game_id, text, kind)) + + +# ----- store round-trip -------------------------------------------------------------- + + +async def test_speech_event_round_trip_persists_all_fields(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + phase_id = make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + voice_event = make_voice_stt_event( + game_id="g1", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=3, + text="P1のCOちょっと遅くなかった?", + stt_confidence=0.91, + audio_start_ms=72100, + audio_end_ms=74400, + created_at_ms=1_710_000_000_000, + ) + + await store.insert(voice_event) + rows = await store.load_phase("g1", phase_id) + + assert len(rows) == 1 + loaded = rows[0] + assert loaded == voice_event + + +async def test_load_phase_filters_by_phase_id(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + pid_a = make_phase_id("g1", 1, Phase.DAY_DISCUSSION, sequence=1) + pid_b = make_phase_id("g1", 1, Phase.DAY_DISCUSSION, sequence=2) + + e1 = make_human_text_event( + game_id="g1", + phase_id=pid_a, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="まず情報整理しよう", + created_at_ms=1, + ) + e2 = make_npc_generated_event( + game_id="g1", + phase_id=pid_b, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=5, + text="占いCOがいないのは不自然", + created_at_ms=2, + ) + await store.insert(e1) + await store.insert(e2) + + rows_a = await store.load_phase("g1", pid_a) + rows_b = await store.load_phase("g1", pid_b) + + assert [r.event_id for r in rows_a] == [e1.event_id] + assert [r.event_id for r in rows_b] == [e2.event_id] + + +# ----- begin_phase + sentinel -------------------------------------------------------- + + +async def test_begin_phase_inserts_sentinel_and_returns_phase_id(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + log_sink = _RecordingLogSink() + poster = _RecordingPoster() + service = DiscussionService(store, log_sink=log_sink, message_poster=poster) + + phase_id = await service.begin_phase( + game_id="g1", + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3, 4, 5, 6, 7, 8, 9], + ) + + assert phase_id == make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + rows = await store.load_phase("g1", phase_id) + assert len(rows) == 1 + sentinel = rows[0] + assert sentinel.source == SpeechSource.PHASE_BASELINE + assert sentinel.speaker_kind == SpeakerKind.SYSTEM + assert sentinel.speaker_seat is None + # sentinels emit no PLAYER_SPEECH and no main-channel post + assert log_sink.entries == [] + assert poster.posts == [] + + +async def test_sentinel_alive_seat_nos_are_sorted_unique(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + service = DiscussionService(store) + phase_id = await service.begin_phase( + game_id="g1", + day=2, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[3, 1, 1, 5, 2], # duplicated and unsorted + ) + rows = await service.load_phase("g1", phase_id) + assert rows[0].alive_seat_nos_json == "[1, 2, 3, 5]" + + +# ----- record() side-effect dispatch ------------------------------------------------ + + +async def test_record_text_skips_channel_post_emits_log(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + log_sink = _RecordingLogSink() + poster = _RecordingPoster() + service = DiscussionService(store, log_sink=log_sink, message_poster=poster) + + phase_id = await service.begin_phase( + game_id="g1", + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=range(1, 10), + ) + text_event = make_human_text_event( + game_id="g1", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, + text="占いCOまだですか?", + ) + + await service.record(text_event) + + assert len(log_sink.entries) == 1 + entry = log_sink.entries[0] + assert entry.kind == DiscussionService.PLAYER_SPEECH_KIND + assert entry.actor_seat == 2 + assert entry.text == "占いCOまだですか?" + assert entry.visibility == "PUBLIC" + # the original Discord message is the channel post — service must not duplicate + assert poster.posts == [] + + +async def test_record_voice_stt_emits_log_and_channel_post(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + log_sink = _RecordingLogSink() + poster = _RecordingPoster() + service = DiscussionService(store, log_sink=log_sink, message_poster=poster) + + phase_id = await service.begin_phase( + game_id="g1", + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=range(1, 10), + ) + voice_event = make_voice_stt_event( + game_id="g1", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=4, + text="P1のCOタイミングは怪しい", + stt_confidence=0.82, + audio_start_ms=10, + audio_end_ms=2010, + ) + + await service.record(voice_event) + + assert len(log_sink.entries) == 1 + assert log_sink.entries[0].kind == DiscussionService.PLAYER_SPEECH_KIND + assert poster.posts == [ + ("g1", "P1のCOタイミングは怪しい", DiscussionService.PLAYER_SPEECH_KIND) + ] + + +async def test_record_npc_generated_emits_log_and_channel_post(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + log_sink = _RecordingLogSink() + poster = _RecordingPoster() + service = DiscussionService(store, log_sink=log_sink, message_poster=poster) + + phase_id = await service.begin_phase( + game_id="g1", + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=range(1, 10), + ) + npc_event = make_npc_generated_event( + game_id="g1", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=5, + text="今は決め打たない方がよさそう", + ) + + await service.record(npc_event) + + assert len(log_sink.entries) == 1 + assert log_sink.entries[0].actor_seat == 5 + assert poster.posts == [ + ("g1", "今は決め打たない方がよさそう", DiscussionService.PLAYER_SPEECH_KIND) + ] + + +# ----- rebuild ---------------------------------------------------------------------- + + +async def test_rebuild_returns_none_for_phase_without_sentinel(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + pid = make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + e = make_human_text_event( + game_id="g1", + phase_id=pid, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="hi", + created_at_ms=1, + ) + await store.insert(e) + rows = await store.load_phase("g1", pid) + assert rebuild_public_state_from_events(rows) is None + + +async def test_rebuild_silent_seats_excludes_dead_seats(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + service = DiscussionService(store) + # day 2: seats 1, 3, 4, 6, 7, 8, 9 alive; seats 2 and 5 already dead + alive = [1, 3, 4, 6, 7, 8, 9] + phase_id = await service.begin_phase( + game_id="g1", + day=2, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=alive, + ) + # seats 1, 3, 4 speak; 6, 7, 8, 9 stay silent + for i, seat in enumerate([1, 3, 4]): + await service.record( + make_human_text_event( + game_id="g1", + phase_id=phase_id, + day=2, + phase=Phase.DAY_DISCUSSION, + speaker_seat=seat, + text=f"発言{seat}", + created_at_ms=100 + i, + ) + ) + + rows = await service.load_phase("g1", phase_id) + state = rebuild_public_state_from_events(rows) + assert state is not None + assert state.alive_seat_nos == frozenset(alive) + # silent_seats includes only alive non-speakers — dead seats 2 and 5 must NOT appear + assert state.silent_seats == frozenset({6, 7, 8, 9}) + assert 2 not in state.silent_seats + assert 5 not in state.silent_seats + + +async def test_rebuild_co_claims_capture_canonical_tokens(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + service = DiscussionService(store) + phase_id = await service.begin_phase( + game_id="g1", + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=range(1, 10), + ) + await service.record( + make_human_text_event( + game_id="g1", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=4, + text="占いCOします。1白", + created_at_ms=10, + ) + ) + await service.record( + make_npc_generated_event( + game_id="g1", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=4, + text="占いCO(再宣言)", + created_at_ms=11, + ) + ) + await service.record( + make_human_text_event( + game_id="g1", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=7, + text="霊媒COです", + created_at_ms=12, + ) + ) + + state = rebuild_public_state_from_events(await service.load_phase("g1", phase_id)) + assert state is not None + role_claims = sorted((c.seat, c.role_claim) for c in state.co_claims) + assert role_claims == [(4, "seer"), (7, "medium")] + + +async def test_rebuild_is_deterministic_from_fresh_fold(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + service = DiscussionService(store) + phase_id = await service.begin_phase( + game_id="g1", + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=range(1, 10), + ) + for i, (seat, text) in enumerate( + [(1, "占いCO"), (2, "もう少し聞かせて"), (3, "霊媒COお願いします")], start=10 + ): + await service.record( + make_human_text_event( + game_id="g1", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=seat, + text=text, + created_at_ms=i, + ) + ) + + rows = await service.load_phase("g1", phase_id) + s1 = rebuild_public_state_from_events(rows) + s2 = rebuild_public_state_from_events(rows) + # Pure fold: two rebuilds from the same input must be equal in all observable fields. + assert s1 is not None and s2 is not None + assert s1.alive_seat_nos == s2.alive_seat_nos + assert s1.co_claims == s2.co_claims + assert s1.silent_seats == s2.silent_seats + assert s1.recent_speech_event_ids == s2.recent_speech_event_ids + + +# ----- failure-tolerance: log/poster errors must not break the write ---------------- + + +class _FailingPoster: + async def post_public(self, *_args, **_kwargs) -> None: + raise RuntimeError("discord down") + + +class _FailingLogSink: + async def insert_log_public(self, _entry: LogEntry) -> None: + raise RuntimeError("logs_public down") + + +async def test_record_swallows_post_failure_so_persistence_still_succeeds(store_conn) -> None: + store = SqliteSpeechEventStore(store_conn) + service = DiscussionService( + store, + log_sink=_FailingLogSink(), + message_poster=_FailingPoster(), + ) + phase_id = await service.begin_phase( + game_id="g1", + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=range(1, 10), + ) + npc_event = make_npc_generated_event( + game_id="g1", + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=5, + text="hi", + ) + await service.record(npc_event) + rows = await service.load_phase("g1", phase_id) + # sentinel + 1 npc event persisted even though the post-write hooks raised + assert len(rows) == 2 diff --git a/tests/test_domain_co_claim.py b/tests/test_domain_co_claim.py new file mode 100644 index 0000000..eadb3f2 --- /dev/null +++ b/tests/test_domain_co_claim.py @@ -0,0 +1,77 @@ +"""Drift detection for the canonical CO-declaration definitions. + +:data:`CO_CLAIM_VALUES` (runtime tuple), :data:`CoDeclaration` (Pydantic +``Literal`` alias), and :data:`CO_CLAIMABLE_ROLES` (the :class:`Role` +subset) MUST agree. They cannot be mechanically derived from one +another because :class:`Literal` requires statically-resolvable values +(``Literal[*tuple]`` is not legal Python), so when we add a new +CO-claimable role all three definitions need updating in lockstep. + +Without this test, a stale ``CoDeclaration`` Literal would silently +narrow the wire schema while validators continued to accept the new +value, producing confusing "field rejected by Pydantic but accepted by +runtime" bugs at the boundary between Master and the WS messages. +""" + +from __future__ import annotations + +from typing import get_args + +from wolfbot.domain.enums import ( + CO_CLAIM_VALUES, + CO_CLAIMABLE_ROLES, + CoDeclaration, + Role, + format_co_claim_options, + role_to_co_claim, +) + + +def test_co_claim_values_match_co_claimable_roles() -> None: + """The runtime tuple is mechanically derived from the Role subset — + if this drifts, the derivation in :mod:`wolfbot.domain.enums` is + broken.""" + derived = tuple(role_to_co_claim(r) for r in CO_CLAIMABLE_ROLES) + assert derived == CO_CLAIM_VALUES + + +def test_co_declaration_literal_matches_runtime_tuple() -> None: + """The Pydantic Literal MUST contain the same values, in the same + order, as :data:`CO_CLAIM_VALUES`. ``Literal`` ordering matters for + JSON schema dumps and for human-readable error messages.""" + literal_args = get_args(CoDeclaration) + assert literal_args == CO_CLAIM_VALUES + + +def test_co_claimable_roles_excludes_wolf_madman_villager() -> None: + """No real game logic openly claims wolf, madman, or villager — the + first two are always faked, the last has no power. Locking this in + here so a drive-by edit to :data:`CO_CLAIMABLE_ROLES` triggers a + test failure rather than silent gameplay change.""" + assert Role.WEREWOLF not in CO_CLAIMABLE_ROLES + assert Role.MADMAN not in CO_CLAIMABLE_ROLES + assert Role.VILLAGER not in CO_CLAIMABLE_ROLES + + +def test_role_to_co_claim_lowercases_uppercase_role_value() -> None: + """``Role`` values are uppercase StrEnum strings (matches the DB + column); the wire/storage form is lowercase. Verify the mapping is + a pure ``.lower()`` so renaming a role doesn't accidentally diverge + the two surfaces.""" + for role in CO_CLAIMABLE_ROLES: + assert role_to_co_claim(role) == role.value.lower() + + +def test_format_co_claim_options_default_form() -> None: + """Default render matches the Japanese analyzer prompts' + ``"seer"/"medium"/"knight"`` shape; preserves quotes and slash + separator.""" + assert format_co_claim_options() == '"seer"/"medium"/"knight"' + + +def test_format_co_claim_options_custom_separator_and_quote() -> None: + """Callers building prose-style prompts (e.g. prompt_builder) can + override the separator. Removing the quote char yields a bare, + unquoted list.""" + assert format_co_claim_options(separator=" / ") == '"seer" / "medium" / "knight"' + assert format_co_claim_options(quote="") == "seer/medium/knight" diff --git a/tests/test_game_export.py b/tests/test_game_export.py new file mode 100644 index 0000000..45fb379 --- /dev/null +++ b/tests/test_game_export.py @@ -0,0 +1,809 @@ +"""Tests for ``wolfbot.services.game_export``.""" + +from __future__ import annotations + +import json +import tempfile +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest +import pytest_asyncio + +from wolfbot.domain.enums import DeathCause, Phase, Role +from wolfbot.domain.models import ( + Game, + LogEntry, + NightAction, + Seat, + Vote, +) +from wolfbot.persistence.schema import migrate +from wolfbot.persistence.sqlite_repo import SqliteRepo +from wolfbot.services.game_export import export_game + + +@pytest_asyncio.fixture +async def fixture_repo() -> AsyncIterator[tuple[SqliteRepo, Path]]: + with tempfile.TemporaryDirectory() as td: + db_path = Path(td) / "test.db" + await migrate(db_path) + r = SqliteRepo(db_path) + await r.connect() + try: + yield r, db_path + finally: + await r.close() + + +GAME_ID = "g_export_test" + + +async def _seed_minimal_game(repo: SqliteRepo) -> None: + game = Game( + id=GAME_ID, + guild_id="guild", + host_user_id="host", + phase=Phase.GAME_OVER, + day_number=1, + deadline_epoch=None, + main_text_channel_id="chan_text", + main_vc_channel_id="chan_vc", + heaven_channel_id=None, + wolves_channel_id=None, + created_at=1_700_000_000, + ended_at=1_700_001_000, + force_skip_pending=False, + discussion_mode="rounds", + ) + await repo.create_game(game) + seats = [ + Seat(seat_no=1, display_name="Alice", is_llm=False, persona_key=None, discord_user_id="u1"), + Seat(seat_no=2, display_name="Bot", is_llm=True, persona_key="setsu", discord_user_id="u2"), + ] + for seat in seats: + await repo.insert_seat(GAME_ID, seat) + # Roles + final alive/death state — set via the same path the live engine + # uses (set_player_role + raw SQL nudge for death fields). + await repo.set_player_role(GAME_ID, 1, Role.VILLAGER) + await repo.set_player_role(GAME_ID, 2, Role.WEREWOLF) + async with repo._tx() as db: + await db.execute( + "UPDATE seats SET alive=0, death_cause=?, death_day=? WHERE game_id=? AND seat_no=2", + (DeathCause.EXECUTION.value, 1, GAME_ID), + ) + + # Public logs over two phases. + await repo.insert_log_public( + LogEntry( + game_id=GAME_ID, + day=1, + phase=Phase.DAY_DISCUSSION, + kind="PHASE_CHANGE", + actor_seat=None, + visibility="PUBLIC", + text="議論開始", + created_at=1_700_000_100, + ) + ) + await repo.insert_log_public( + LogEntry( + game_id=GAME_ID, + day=1, + phase=Phase.DAY_VOTE, + kind="EXECUTION", + actor_seat=2, + visibility="PUBLIC", + text="席2 が処刑されました\n\n席2=2票", + created_at=1_700_000_500, + ) + ) + await repo.insert_log_public( + LogEntry( + game_id=GAME_ID, + day=1, + phase=Phase.DAY_VOTE, + kind="VICTORY", + actor_seat=None, + visibility="PUBLIC", + text="村人陣営の勝利!", + created_at=1_700_000_510, + ) + ) + + # One vote (round=0 is the regular vote in this schema; runoff is round=1). + await repo.insert_vote( + Vote( + game_id=GAME_ID, + day=1, + round=0, + voter_seat=1, + target_seat=2, + submitted_at=1_700_000_400, + ) + ) + # One night action (day 0 wolf intro divine). + from wolfbot.domain.enums import SubmissionType + + await repo.insert_night_action( + NightAction( + game_id=GAME_ID, + day=0, + actor_seat=2, + kind=SubmissionType.SEER_DIVINE, + target_seat=1, + submitted_at=1_700_000_050, + ) + ) + + +async def test_export_game_writes_json_with_full_shape( + fixture_repo: tuple[SqliteRepo, Path], tmp_path: Path +) -> None: + repo, db_path = fixture_repo + await _seed_minimal_game(repo) + + out = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=tmp_path / "no_trace", # absent dir → empty trace + output_dir=tmp_path / "out", + ) + assert out.exists() + payload = json.loads(out.read_text(encoding="utf-8")) + + # Game meta + assert payload["game"]["id"] == GAME_ID + assert payload["game"]["created_at_ms"] == 1_700_000_000_000 + assert payload["game"]["ended_at_ms"] == 1_700_001_000_000 + assert payload["game"]["victory"] == "village" + assert payload["game"]["discussion_mode"] == "rounds" + + # Seats + seats = payload["seats"] + assert len(seats) == 2 + seat2 = next(s for s in seats if s["seat_no"] == 2) + assert seat2["alive"] is False + assert seat2["death_cause"] == "EXECUTION" + assert seat2["death_day"] == 1 + assert seat2["role"] == "WEREWOLF" + + # Phases — chronological order, NIGHT_0 first (from night_actions), + # then DAY_DISCUSSION, then DAY_VOTE. + phases = payload["phases"] + assert [(p["day"], p["phase"]) for p in phases] == [ + (0, "NIGHT_0"), + (1, "DAY_DISCUSSION"), + (1, "DAY_VOTE"), + ] + + # Vote ended up under DAY_VOTE only, not under DAY_DISCUSSION. + vote_phase = next(p for p in phases if p["phase"] == "DAY_VOTE") + disc_phase = next(p for p in phases if p["phase"] == "DAY_DISCUSSION") + assert len(vote_phase["votes"]) == 1 + assert vote_phase["votes"][0]["target_seat"] == 2 + assert vote_phase["votes"][0]["submitted_at_ms"] == 1_700_000_400_000 + assert disc_phase["votes"] == [] + + # Night action ended up under NIGHT_0. + night0 = next(p for p in phases if p["phase"] == "NIGHT_0") + assert len(night0["night_actions"]) == 1 + assert night0["night_actions"][0]["kind"] == "SEER_DIVINE" + + # Public logs unconditionally serialized with ms timestamps. + assert disc_phase["public_logs"][0]["created_at_ms"] == 1_700_000_100_000 + assert vote_phase["public_logs"][0]["kind"] == "EXECUTION" + + # Trace is empty when the trace_dir has no game subdirectory. + assert payload["trace"] == [] + + +async def test_export_game_emits_wolf_chat_logs_per_phase( + fixture_repo: tuple[SqliteRepo, Path], tmp_path: Path +) -> None: + """Wolf-only night chat is stored fan-out (one row per audience seat) + in ``logs_private``. The exporter must dedupe to one entry per + actual utterance and attach it to the matching NIGHT phase. + + Why this matters: viewer renders the wolves' coordination in the + night timeline. Pre-fix the WOLF_CHAT rows were excluded entirely + (exporter only read ``logs_public``), so the viewer had no way to + surface the most informative private signal a post-game review + needs (which wolf proposed which target, in what order). + """ + repo, db_path = fixture_repo + await _seed_minimal_game(repo) + + # Add a second wolf so the fan-out (one row per audience) is + # exercised — the exporter must collapse N audience rows back to 1. + seat3 = Seat( + seat_no=3, + display_name="Wolf2", + is_llm=True, + persona_key="raqio", + discord_user_id="u3", + ) + await repo.insert_seat(GAME_ID, seat3) + await repo.set_player_role(GAME_ID, 3, Role.WEREWOLF) + + # Wolf seat 2's NIGHT_1 utterance, fan-out written to both wolves. + for audience in (2, 3): + await repo.insert_log_private( + LogEntry( + game_id=GAME_ID, + day=1, + phase=Phase.NIGHT, + kind="WOLF_CHAT", + actor_seat=2, + visibility="PRIVATE", + audience_seat=audience, + text="セツを噛もう", + created_at=1_700_000_700, + ) + ) + # Wolf seat 3's reply. + for audience in (2, 3): + await repo.insert_log_private( + LogEntry( + game_id=GAME_ID, + day=1, + phase=Phase.NIGHT, + kind="WOLF_CHAT", + actor_seat=3, + visibility="PRIVATE", + audience_seat=audience, + text="同意。霊媒回避が優先", + created_at=1_700_000_710, + ) + ) + + out = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=tmp_path / "no_trace", + output_dir=tmp_path / "out", + ) + payload = json.loads(out.read_text(encoding="utf-8")) + + # NIGHT phase appears in the timeline because of the wolf chat + # rows (no NIGHT_1 actions or public logs were seeded for day 1). + night1 = next(p for p in payload["phases"] if p["day"] == 1 and p["phase"] == "NIGHT") + assert len(night1["wolf_chat_logs"]) == 2 # deduped from 4 rows + first, second = night1["wolf_chat_logs"] + assert first["actor_seat"] == 2 + assert first["text"] == "セツを噛もう" + assert first["created_at_ms"] == 1_700_000_700_000 + assert second["actor_seat"] == 3 + assert second["text"] == "同意。霊媒回避が優先" + + # Day-side phases stay empty so nothing leaks across. + disc = next(p for p in payload["phases"] if p["phase"] == "DAY_DISCUSSION") + assert disc["wolf_chat_logs"] == [] + + +async def test_export_game_inlines_jsonl_trace( + fixture_repo: tuple[SqliteRepo, Path], tmp_path: Path +) -> None: + repo, db_path = fixture_repo + await _seed_minimal_game(repo) + + # Stage some trace files. + trace_root = tmp_path / "trace" + game_trace = trace_root / GAME_ID + game_trace.mkdir(parents=True) + (game_trace / "gameplay.jsonl").write_text( + json.dumps( + { + "ts": "2026-04-28T08:00:01+00:00", + "role": "gameplay", + "model": "grok-4-1-fast", + "system_prompt": "s", + "user_prompt": "u", + "response": "r", + "latency_ms": 100, + "tokens": {"prompt": 5, "completion": 1, "total": 6}, + "phase": "DAY_DISCUSSION", + "day": 1, + "actor": "seat=2 persona=setsu", + "provider": "xai", + "error": None, + } + ) + + "\n", + encoding="utf-8", + ) + (game_trace / "voice_stt.jsonl").write_text( + json.dumps( + { + "ts": "2026-04-28T08:00:00+00:00", + "role": "voice_stt", + "provider": "gemini", + "model": "gemini-2.0-flash-lite", + "system_prompt": "s", + "user_prompt": "[audio]", + "response": "{}", + "latency_ms": 800, + "tokens": None, + "phase": None, + "day": None, + "actor": None, + "error": None, + } + ) + + "\n", + encoding="utf-8", + ) + + out = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=trace_root, + output_dir=tmp_path / "out", + ) + payload = json.loads(out.read_text(encoding="utf-8")) + trace = payload["trace"] + assert len(trace) == 2 + # Ordered chronologically by ts — voice_stt at 08:00:00 before gameplay 08:00:01 + assert trace[0]["role"] == "voice_stt" + assert trace[1]["role"] == "gameplay" + # file_stem is auto-injected from the source file name + assert trace[0]["file_stem"] == "voice_stt" + assert trace[1]["file_stem"] == "gameplay" + + +async def test_export_game_filters_player_speech_logs( + fixture_repo: tuple[SqliteRepo, Path], tmp_path: Path +) -> None: + """PLAYER_SPEECH log rows duplicate speech_events rows; the export omits them. + + DiscussionService.record() inserts both at write time so the live LLM + context-builder keeps reading PLAYER_SPEECH from logs_public, but the + viewer should only see one canonical speech entry per utterance. + """ + repo, db_path = fixture_repo + await _seed_minimal_game(repo) + + # Add a PLAYER_SPEECH log row that duplicates a npc_generated speech_event. + await repo.insert_log_public( + LogEntry( + game_id=GAME_ID, + day=1, + phase=Phase.DAY_DISCUSSION, + kind="PLAYER_SPEECH", + actor_seat=2, + visibility="PUBLIC", + text="seat 2's npc speech", + created_at=1_700_000_200, + ) + ) + + out = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=tmp_path / "no_trace", + output_dir=tmp_path / "out", + ) + payload = json.loads(out.read_text(encoding="utf-8")) + all_log_kinds = {log["kind"] for phase in payload["phases"] for log in phase["public_logs"]} + assert "PLAYER_SPEECH" not in all_log_kinds + + +async def test_export_game_inlines_arbiter_decisions( + fixture_repo: tuple[SqliteRepo, Path], tmp_path: Path +) -> None: + """Arbiter decisions (Master-side `SpeakRequest` dispatches) are joined + from the three NPC orchestration tables and emitted under the new + ``arbiter_decisions`` key. The viewer uses this to render the "why this + NPC, why now" breadcrumb alongside each NPC speech event. + """ + repo, db_path = fixture_repo + await _seed_minimal_game(repo) + + # Insert one full request → result → playback chain. + await repo.insert_npc_speak_request( + request_id="sr_t1", + game_id=GAME_ID, + phase_id=f"{GAME_ID}::day1::DAY_DISCUSSION::1", + npc_id="npc_setsu", + seat_no=2, + logic_packet_id="lp_t1", + suggested_intent="speak", + max_chars=80, + max_duration_ms=12_000, + priority=0, + expires_at_ms=1_700_000_300_000, + created_at_ms=1_700_000_200_000, + selection_reason="addressed", + public_state_snapshot={ + "last_addressed_seat": 2, + "silent_seats": [1, 2], + "alive_seat_nos": [1, 2], + "online_npc_seats": [2], + }, + ) + await repo.insert_npc_speak_result( + request_id="sr_t1", + game_id=GAME_ID, + phase_id=f"{GAME_ID}::day1::DAY_DISCUSSION::1", + npc_id="npc_setsu", + status="accepted", + text="占い師COします", + used_logic_ids=["co-1-seer"], + intent="speak", + estimated_duration_ms=2_500, + failure_reason=None, + received_at_ms=1_700_000_205_000, + ) + await repo.open_npc_playback( + request_id="sr_t1", + game_id=GAME_ID, + phase_id=f"{GAME_ID}::day1::DAY_DISCUSSION::1", + npc_id="npc_setsu", + speech_event_id="se_t1", + authorized_at_ms=1_700_000_205_500, + playback_deadline_ms=1_700_000_217_500, + ) + await repo.close_npc_playback( + "sr_t1", + finished_at_ms=1_700_000_208_000, + outcome="success", + failure_reason=None, + ) + + # And a second request that was rejected before TTS — verifies + # LEFT JOIN behavior (no playback row, but result row with status). + await repo.insert_npc_speak_request( + request_id="sr_t2", + game_id=GAME_ID, + phase_id=f"{GAME_ID}::day1::DAY_DISCUSSION::1", + npc_id="npc_gina", + seat_no=3, + logic_packet_id="lp_t2", + suggested_intent="speak", + max_chars=80, + max_duration_ms=12_000, + priority=0, + expires_at_ms=1_700_000_320_000, + created_at_ms=1_700_000_220_000, + selection_reason="silent_rotation", + public_state_snapshot={"silent_seats": [3]}, + ) + await repo.insert_npc_speak_result( + request_id="sr_t2", + game_id=GAME_ID, + phase_id=f"{GAME_ID}::day1::DAY_DISCUSSION::1", + npc_id="npc_gina", + status="rejected", + text=None, + used_logic_ids=None, + intent=None, + estimated_duration_ms=None, + failure_reason="stale_phase", + received_at_ms=1_700_000_225_000, + ) + + out = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=tmp_path / "no_trace", + output_dir=tmp_path / "out", + ) + payload = json.loads(out.read_text(encoding="utf-8")) + + decisions = payload["arbiter_decisions"] + assert len(decisions) == 2 + + # Chronological order — sr_t1 first, sr_t2 second. + d1, d2 = decisions + assert d1["request_id"] == "sr_t1" + assert d1["selection_reason"] == "addressed" + assert d1["public_state_snapshot"]["last_addressed_seat"] == 2 + assert d1["result_status"] == "accepted" + assert d1["result_text"] == "占い師COします" + assert d1["playback_outcome"] == "success" + assert d1["playback_finished_at_ms"] == 1_700_000_208_000 + + # Rejected request: result populated but playback fields all None. + assert d2["request_id"] == "sr_t2" + assert d2["selection_reason"] == "silent_rotation" + assert d2["result_status"] == "rejected" + assert d2["result_failure_reason"] == "stale_phase" + assert d2["playback_outcome"] is None + assert d2["playback_finished_at_ms"] is None + + +async def test_export_game_raises_for_unknown_game( + fixture_repo: tuple[SqliteRepo, Path], tmp_path: Path +) -> None: + _repo, db_path = fixture_repo + with pytest.raises(ValueError, match="game not found"): + await export_game( + game_id="nonexistent", + db_path=db_path, + trace_dir=tmp_path, + output_dir=tmp_path / "out", + ) + + +async def test_export_game_filename_uses_timestamp_prefix( + fixture_repo: tuple[SqliteRepo, Path], tmp_path: Path +) -> None: + """Filename is derived from the game's created_at (local time), not the + random game_id, so the viewer dir lists games sortably by play time.""" + repo, db_path = fixture_repo + await _seed_minimal_game(repo) + + out = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=tmp_path / "no_trace", + output_dir=tmp_path / "out", + ) + + # Filename must NOT be {game_id}.json. + assert out.name != f"{GAME_ID}.json" + # Filename must be derived from created_at_ms = 1_700_000_000_000 → + # 2023-11-15 in UTC. Local-time conversion may shift the date but the + # YYYY-MM-DD_HH-MM-SS shape is invariant. + import re + + assert re.match(r"^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}(?:_\d+)?\.json$", out.name), ( + f"unexpected filename shape: {out.name}" + ) + + +async def test_export_game_filename_disambiguates_same_second_collision( + fixture_repo: tuple[SqliteRepo, Path], tmp_path: Path +) -> None: + """If a different game already wrote a same-second file, append `_`. + Re-exporting the same game (same id) must overwrite, not collide.""" + repo, db_path = fixture_repo + await _seed_minimal_game(repo) + out_dir = tmp_path / "out" + + first = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=tmp_path / "no_trace", + output_dir=out_dir, + ) + # Re-export same game — should overwrite, not produce a `_1` sibling. + second = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=tmp_path / "no_trace", + output_dir=out_dir, + ) + assert first == second + assert len(list(out_dir.glob("*.json"))) == 1 + + # Now drop a fake same-second file from a different "game id" and + # re-export — should disambiguate with `_1` suffix. + other = out_dir / first.name + other.write_text( + json.dumps({"game": {"id": "different-game-id"}}), + encoding="utf-8", + ) + third = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=tmp_path / "no_trace", + output_dir=out_dir, + ) + assert third != first + assert third.name.endswith("_1.json") + + +async def test_export_game_phases_chronological_with_correctly_tagged_logs( + fixture_repo: tuple[SqliteRepo, Path], + tmp_path: Path, +) -> None: + """A post-fix game whose MORNING + day-start PHASE_CHANGE logs are + already tagged with the right ``day`` must export phases in + chronological order and not duplicate buckets one day off. + + Reproduces the bug fixed by replacing ``_rebucket_morning_logs`` + with ``_retag_morning_logs_from_text``: the old helper bumped every + matching row's day by +1 unconditionally, so a correctly-tagged + game emitted phantom (day+1, DAY_DISCUSSION) buckets containing + only the morning logs while the actual same-day speeches stayed in + the original bucket. + """ + repo, db_path = fixture_repo + base = 1_777_700_000 # epoch s, arbitrary + game = Game( + id="g_phase_order", + guild_id="g", + host_user_id="h", + phase=Phase.GAME_OVER, + day_number=2, + deadline_epoch=None, + main_text_channel_id="c", + main_vc_channel_id="v", + heaven_channel_id=None, + wolves_channel_id=None, + created_at=base, + ended_at=base + 1000, + force_skip_pending=False, + discussion_mode="rounds", + ) + await repo.create_game(game) + await repo.insert_seat( + game.id, + Seat( + seat_no=1, + display_name="A", + discord_user_id="u1", + is_llm=False, + persona_key=None, + ), + ) + + async def _log(*, day: int, phase: Phase, kind: str, text: str, t: int) -> None: + await repo.insert_log_public( + LogEntry( + game_id=game.id, + day=day, + phase=phase, + kind=kind, + actor_seat=None, + visibility="PUBLIC", + text=text, + created_at=t, + ) + ) + + # day-1 morning + first speech, vote, then day-2 morning + speech. + await _log( + day=1, + phase=Phase.DAY_DISCUSSION, + kind="PHASE_CHANGE", + text="夜が明けました。1 日目の議論を開始します。", + t=base + 10, + ) + await _log( + day=1, + phase=Phase.DAY_VOTE, + kind="PHASE_CHANGE", + text="議論時間終了。投票フェイズを開始します。", + t=base + 100, + ) + await _log( + day=2, phase=Phase.DAY_DISCUSSION, kind="MORNING", text="平和な朝です。", t=base + 200 + ) + await _log( + day=2, + phase=Phase.DAY_DISCUSSION, + kind="PHASE_CHANGE", + text="2 日目の議論を開始します。", + t=base + 200, + ) + await _log( + day=2, + phase=Phase.DAY_VOTE, + kind="PHASE_CHANGE", + text="議論時間終了。投票フェイズを開始します。", + t=base + 300, + ) + + out_path = await export_game( + game_id=game.id, + db_path=db_path, + trace_dir=tmp_path / "no_trace", + output_dir=tmp_path / "out", + ) + payload = json.loads(out_path.read_text()) + # No phantom day-3 bucket from morning rebumping. + assert {(p["day"], p["phase"]) for p in payload["phases"]} == { + (1, "DAY_DISCUSSION"), + (1, "DAY_VOTE"), + (2, "DAY_DISCUSSION"), + (2, "DAY_VOTE"), + } + # And they appear in chronological order. + starts = [p["started_at_ms"] for p in payload["phases"]] + assert starts == sorted(starts) + + +async def test_export_game_retags_legacy_morning_logs_from_text( + fixture_repo: tuple[SqliteRepo, Path], + tmp_path: Path, +) -> None: + """A pre-fix game whose MORNING + day-start PHASE_CHANGE logs are + tagged with the *prior* day must still produce a correctly-ordered + timeline. The retagger reads the day number out of the PHASE_CHANGE + text ("2 日目の議論を開始") and uses it as the source of truth. + """ + repo, db_path = fixture_repo + base = 1_777_700_000 + game = Game( + id="g_phase_order_legacy", + guild_id="g2", + host_user_id="h", + phase=Phase.GAME_OVER, + day_number=2, + deadline_epoch=None, + main_text_channel_id="c", + main_vc_channel_id="v", + heaven_channel_id=None, + wolves_channel_id=None, + created_at=base, + ended_at=base + 1000, + force_skip_pending=False, + discussion_mode="rounds", + ) + await repo.create_game(game) + await repo.insert_seat( + game.id, + Seat( + seat_no=1, + display_name="A", + discord_user_id="u1", + is_llm=False, + persona_key=None, + ), + ) + + async def _log(*, day: int, phase: Phase, kind: str, text: str, t: int) -> None: + await repo.insert_log_public( + LogEntry( + game_id=game.id, + day=day, + phase=phase, + kind=kind, + actor_seat=None, + visibility="PUBLIC", + text=text, + created_at=t, + ) + ) + + # Legacy tagging: NIGHT_0 → DAY_1 morning was emitted with day=0, + # NIGHT_1 → DAY_2 morning was emitted with day=1. Text carries the + # correct N (1, 2) so the retagger can fix it on read. + await _log( + day=0, + phase=Phase.DAY_DISCUSSION, + kind="PHASE_CHANGE", + text="夜が明けました。1 日目の議論を開始します。", + t=base + 10, + ) + await _log( + day=1, + phase=Phase.DAY_VOTE, + kind="PHASE_CHANGE", + text="議論時間終了。投票フェイズを開始します。", + t=base + 100, + ) + await _log( + day=1, phase=Phase.DAY_DISCUSSION, kind="MORNING", text="平和な朝です。", t=base + 200 + ) + await _log( + day=1, + phase=Phase.DAY_DISCUSSION, + kind="PHASE_CHANGE", + text="2 日目の議論を開始します。", + t=base + 200, + ) + + out_path = await export_game( + game_id=game.id, + db_path=db_path, + trace_dir=tmp_path / "no_trace", + output_dir=tmp_path / "out", + ) + payload = json.loads(out_path.read_text()) + pairs = {(p["day"], p["phase"]) for p in payload["phases"]} + # The 2 日目 PHASE_CHANGE + MORNING were tagged day=1 in DB but + # belong to day=2. After retagging they land in (2, DAY_DISCUSSION), + # not (1, DAY_DISCUSSION). + assert (2, "DAY_DISCUSSION") in pairs + # And no leftover-from-misbucket (1, DAY_DISCUSSION) with only the + # PHASE_CHANGE row from day-1's start logs. + day1_disc = next( + p for p in payload["phases"] if p["day"] == 1 and p["phase"] == "DAY_DISCUSSION" + ) + # day-1's discussion only contains the "1 日目の議論を開始" row, + # which the retagger leaves as day=1 (its text says so). + assert len(day1_disc["public_logs"]) == 1 + assert "1 日目の議論を開始" in day1_disc["public_logs"][0]["text"] diff --git a/tests/test_game_export_integration.py b/tests/test_game_export_integration.py new file mode 100644 index 0000000..3cd9476 --- /dev/null +++ b/tests/test_game_export_integration.py @@ -0,0 +1,505 @@ +"""Integration test: real SQLite schema → exporter → viewer-shaped JSON. + +Goes the full distance from production-shape DB (via the same +``migrate()`` that runs at boot) through ``export_game`` and asserts the +output round-trips through the canonical Pydantic models without losing +or fabricating fields. Catches three classes of regression at once: + +1. **DB schema drift** — if a column the exporter SELECTs gets renamed + or dropped from ``schema.py``, this test fails because the SQL errors. +2. **Exporter shape drift** — the output is fed back into + :class:`GameExport`; any new ``extra="forbid"`` violation or missing + required field is caught here. +3. **Viewer contract drift** — the committed + ``viewer/sample-data/export-schema.json`` is compared against + ``GameExport.model_json_schema()``; running + ``scripts/dump-export-schema.py`` is now a hard requirement after + touching ``game_export_types.py``. + +The first two assertions exercise the exact code path +``GameService._on_game_end_finalize`` uses in production, so a green +test guarantees the live exporter — invoked on every victory and +``/wolf abort`` — produces viewer-loadable JSON. +""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import aiosqlite +import pytest + +from wolfbot.persistence.schema import migrate +from wolfbot.services.game_export import export_game +from wolfbot.services.game_export_types import GameExport +from wolfbot.services.llm_trace import log_llm_call, trace_context + +GAME_ID = "g_int_test_1" + + +# Columns the exporter SELECTs from each table. If any of these go missing +# from schema.py, the SELECT errors and this test fails. The list is the +# explicit DB-side contract — keep in sync with game_export.py:_build_payload. +_REQUIRED_COLUMNS: dict[str, set[str]] = { + "games": { + "id", "guild_id", "host_user_id", "discussion_mode", + "created_at", "ended_at", "main_text_channel_id", + "main_vc_channel_id", + }, + "seats": { + "seat_no", "display_name", "is_llm", "persona_key", + "discord_user_id", "role", "alive", "death_cause", "death_day", + }, + "logs_public": {"day", "phase", "kind", "actor_seat", "text", "created_at"}, + "votes": {"day", "round", "voter_seat", "target_seat", "submitted_at"}, + "night_actions": { + "day", "actor_seat", "kind", "target_seat", "submitted_at", + }, + "speech_events": { + "event_id", "day", "phase", "source", "speaker_seat", "text", + "stt_confidence", "summary", "co_declaration", + "addressed_seat_no", "created_at_ms", + }, +} + + +async def _seed_realistic_game(db_path: Path) -> None: + """Populate every table the exporter reads with realistic 9-player data. + + Uses raw SQL so the test is decoupled from ``SqliteRepo``'s API shape + and exercises only the on-disk schema — the same surface the exporter + sees in production. + """ + async with aiosqlite.connect(str(db_path)) as db: + await db.execute("PRAGMA foreign_keys = ON") + + await db.execute( + "INSERT INTO games (id, guild_id, host_user_id, phase, day_number, " + "main_text_channel_id, main_vc_channel_id, created_at, ended_at, " + "discussion_mode) " + "VALUES (?, ?, ?, 'GAME_OVER', 2, ?, ?, ?, ?, ?)", + (GAME_ID, "guild_int", "host_int", "ch_text", "ch_vc", + 1_700_000_000, 1_700_001_000, "reactive_voice"), + ) + + seats = [ + (1, "human1", "Alice", 0, None, "VILLAGER", 1, None, None), + (2, None, "Setsu", 1, "setsu", "SEER", 1, None, None), + (3, None, "Gina", 1, "gina", "WEREWOLF", 0, "EXECUTION", 1), + (4, None, "SQ", 1, "sq", "VILLAGER", 1, None, None), + (5, None, "Raqio", 1, "raqio", "MEDIUM", 1, None, None), + (6, None, "Stella", 1, "stella", "KNIGHT", 1, None, None), + (7, None, "Shigemichi", 1, "shigemichi", "WEREWOLF", 0, + "EXECUTION", 2), + (8, None, "Comet", 1, "comet", "MADMAN", 1, None, None), + (9, None, "Jonas", 1, "jonas", "VILLAGER", 1, None, None), + ] + for s in seats: + await db.execute( + "INSERT INTO seats (game_id, seat_no, discord_user_id, " + "display_name, is_llm, persona_key, role, alive, death_cause, " + "death_day) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (GAME_ID, *s), + ) + + public_logs = [ + (0, "SETUP", "SETUP_COMPLETE", None, "ゲーム開始", 1_700_000_001), + (1, "DAY_DISCUSSION", "PHASE_CHANGE", None, "1 日目議論", + 1_700_000_100), + (1, "DAY_VOTE", "EXECUTION", 3, + "席3 が処刑されました\n\n席3=5票", 1_700_000_500), + (2, "DAY_VOTE", "EXECUTION", 7, + "席7 が処刑されました\n\n席7=7票", 1_700_000_900), + (2, "DAY_VOTE", "VICTORY", None, "村人陣営の勝利!", + 1_700_000_910), + (2, "DAY_VOTE", "ROLE_REVEAL", None, "役職公開:\n席1=村人...", + 1_700_000_911), + ] + for day, phase, kind, actor, text, ts in public_logs: + await db.execute( + "INSERT INTO logs_public (game_id, day, phase, kind, " + "actor_seat, text, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (GAME_ID, day, phase, kind, actor, text, ts), + ) + + votes = [ + (1, 0, 1, 3, 1_700_000_400), + (1, 0, 2, 3, 1_700_000_405), + (1, 0, 3, 2, 1_700_000_410), + (1, 0, 4, 3, 1_700_000_415), + (1, 0, 5, 3, 1_700_000_420), + (1, 0, 6, 3, 1_700_000_425), + (1, 0, 7, 2, 1_700_000_430), + (1, 0, 8, 2, 1_700_000_435), + (1, 0, 9, 3, 1_700_000_440), + # Day 2 with one vote that targets None (skip / abstain) + (2, 0, 1, 7, 1_700_000_800), + (2, 0, 8, None, 1_700_000_805), + ] + for v in votes: + await db.execute( + "INSERT INTO votes (game_id, day, round, voter_seat, " + "target_seat, submitted_at) VALUES (?, ?, ?, ?, ?, ?)", + (GAME_ID, *v), + ) + + night_actions = [ + (0, 2, "SEER_DIVINE", 9, 1_700_000_050), + (1, 2, "SEER_DIVINE", 7, 1_700_000_550), + (1, 6, "KNIGHT_GUARD", 2, 1_700_000_555), + (1, 7, "WOLF_ATTACK", 2, 1_700_000_560), + ] + for na in night_actions: + await db.execute( + "INSERT INTO night_actions (game_id, day, actor_seat, kind, " + "target_seat, submitted_at) VALUES (?, ?, ?, ?, ?, ?)", + (GAME_ID, *na), + ) + + speech_events = [ + ("ev_d1_seat2", "DAY_DISCUSSION", "npc_generated", "npc", 2, + "占い師COします。", None, "seat2 CO seer", "seer", None, + 1_700_000_150_000), + ("ev_d1_seat3", "DAY_DISCUSSION", "text", "human", 3, + "私も占いです。", None, "seat3 counter", "seer", 2, + 1_700_000_160_000), + ("ev_d1_voice_seat1", "DAY_DISCUSSION", "voice_stt", "human", 1, + "席3怪しい", 0.92, None, None, 3, + 1_700_000_170_000), + # `phase_baseline` is the internal sentinel — exporter MUST + # filter it. Seeded so the test asserts the SQL filter. + ("ev_d1_baseline", "DAY_DISCUSSION", "phase_baseline", "system", + None, "", None, None, None, None, 1_700_000_140_000), + ("ev_d2_seat5", "DAY_DISCUSSION", "npc_generated", "npc", 5, + "霊媒結果:席3は人狼", None, "seat5 medium", "medium", None, + 1_700_000_700_000), + ] + for (eid, phase, source, kind, sseat, text, conf, summary, co, + addr, ts) in speech_events: + phase_id = f"{GAME_ID}::day1::{phase}::1" + await db.execute( + "INSERT INTO speech_events (event_id, game_id, phase_id, " + "day, phase, source, speaker_kind, speaker_seat, text, " + "stt_confidence, audio_start_ms, audio_end_ms, " + "alive_seat_nos_json, summary, co_declaration, " + "addressed_seat_no, created_at_ms) VALUES " + "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (eid, GAME_ID, phase_id, 1 if "d1" in eid else 2, phase, + source, kind, sseat, text, conf, None, None, None, + summary, co, addr, ts), + ) + + await db.commit() + + +async def _stage_real_trace(trace_root: Path) -> None: + """Use the production ``log_llm_call`` to write JSONL the exporter reads. + + Going through the real writer (rather than handwriting JSON) ensures + the test breaks if ``log_llm_call`` ever changes the on-disk shape + in a way the exporter's loader can't accept. + """ + import os + + # Force trace dir to our tmp path; clear any disable flag. + prev_dir = os.environ.get("WOLFBOT_LLM_TRACE_DIR") + prev_disabled = os.environ.get("WOLFBOT_LLM_TRACE_DISABLED") + os.environ["WOLFBOT_LLM_TRACE_DIR"] = str(trace_root) + os.environ.pop("WOLFBOT_LLM_TRACE_DISABLED", None) + try: + with trace_context( + game_id=GAME_ID, + phase="DAY_DISCUSSION", + day=1, + actor="seat=2 persona=setsu role=SEER", + metadata={"task": "discussion"}, + ): + await log_llm_call( + role="gameplay", + provider="xai", + model="grok-4-1-fast", + system_prompt="あなたは占い師です。", + user_prompt="現在のフェイズ: DAY_DISCUSSION (day=1)", + response='{"intent":"speak","public_message":"占い師COします。"}', + latency_ms=1234, + tokens={"prompt": 100, "completion": 30, "total": 130}, + ) + with trace_context( + game_id=GAME_ID, + phase="DAY_DISCUSSION", + day=1, + actor="seat=2 persona=setsu", + ): + await log_llm_call( + role="npc_speech", + provider="openai-compat", + model="grok-4-1-fast", + system_prompt="NPCとして発話", + user_prompt="短い反応", + response='{"intent":"speak","text":"占い師COします"}', + latency_ms=900, + tokens={"prompt": 80, "completion": 20, "total": 100}, + file_stem="npc_setsu", + ) + with trace_context(game_id=GAME_ID, phase=None, day=None): + await log_llm_call( + role="voice_stt", + provider="gemini", + model="gemini-2.0-flash-lite", + system_prompt="音声を分析", + user_prompt="[audio bytes=80000]", + response='{"transcript":"席3怪しい"}', + latency_ms=850, + tokens={"prompt": 200, "completion": 25, "total": 225}, + ) + finally: + if prev_dir is None: + os.environ.pop("WOLFBOT_LLM_TRACE_DIR", None) + else: + os.environ["WOLFBOT_LLM_TRACE_DIR"] = prev_dir + if prev_disabled is not None: + os.environ["WOLFBOT_LLM_TRACE_DISABLED"] = prev_disabled + + +@pytest.fixture +def tmp_repo(tmp_path: Path) -> Path: + return tmp_path + + +async def test_real_db_schema_columns_cover_exporter_select( + tmp_repo: Path, +) -> None: + """A column listed in ``_REQUIRED_COLUMNS`` must exist after migrate(). + + Cheaper than running the exporter — fires on the *first* drift between + the DDL and the SELECTs in ``game_export.py``. + """ + db_path = tmp_repo / "wolf.db" + await migrate(db_path) + async with aiosqlite.connect(str(db_path)) as db: + for table, required in _REQUIRED_COLUMNS.items(): + async with db.execute(f"PRAGMA table_info({table})") as cur: + actual = {row[1] async for row in cur} + missing = required - actual + assert not missing, ( + f"schema.py is missing columns from {table}: {missing}. " + "Either add them to schema.py or remove the SELECT in " + "game_export.py." + ) + + +async def test_export_pipeline_against_live_schema(tmp_repo: Path) -> None: + """End-to-end: real DDL + real INSERTs + real trace writer + real exporter. + + The output is parsed back through ``GameExport`` so any drift between + the dict shape inside the exporter and the canonical Pydantic schema + surfaces here. + """ + db_path = tmp_repo / "wolf.db" + await migrate(db_path) + await _seed_realistic_game(db_path) + trace_root = tmp_repo / "trace" + await _stage_real_trace(trace_root) + + out_path = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=trace_root, + output_dir=tmp_repo / "out", + ) + raw = out_path.read_text(encoding="utf-8") + + # Parse with the canonical Pydantic model — this is the contract the + # viewer assumes. extra="forbid" on every model except TraceEntry + # means an unexpected key fails this line. + export = GameExport.model_validate_json(raw) + + # Game meta surfaces production-shape data correctly. + assert export.game.id == GAME_ID + assert export.game.discussion_mode == "reactive_voice" + assert export.game.victory == "village" + assert export.game.created_at_ms == 1_700_000_000_000 + assert export.game.ended_at_ms == 1_700_001_000_000 + + # All 9 seats; roles round-trip; deaths attributed correctly. + assert len(export.seats) == 9 + by_seat = {s.seat_no: s for s in export.seats} + assert by_seat[3].alive is False + assert by_seat[3].death_cause == "EXECUTION" + assert by_seat[3].death_day == 1 + assert by_seat[7].death_cause == "EXECUTION" + assert by_seat[7].death_day == 2 + assert by_seat[2].role == "SEER" + assert by_seat[1].is_llm is False + assert by_seat[2].is_llm is True + + # Every (day, phase) bucket the test seeded must show up exactly once. + bucket_keys = [(p.day, p.phase) for p in export.phases] + assert (0, "NIGHT_0") in bucket_keys # from night_actions(day=0) + assert (1, "DAY_DISCUSSION") in bucket_keys + assert (1, "DAY_VOTE") in bucket_keys + assert (1, "NIGHT") in bucket_keys + assert (2, "DAY_VOTE") in bucket_keys + assert len(bucket_keys) == len(set(bucket_keys)), "duplicate phase bucket" + + # Votes route to the right phase. Day 2 has a None target_seat (skip). + d2 = next(p for p in export.phases if (p.day, p.phase) == (2, "DAY_VOTE")) + assert {v.target_seat for v in d2.votes} == {7, None} + + # Speech events with all the structured fields populated. + d1_disc = next( + p for p in export.phases if (p.day, p.phase) == (1, "DAY_DISCUSSION") + ) + voice_ev = next( + ev for ev in d1_disc.speech_events if ev.source == "voice_stt" + ) + assert voice_ev.stt_confidence == pytest.approx(0.92) + assert voice_ev.addressed_seat_no == 3 + co_ev = next( + ev for ev in d1_disc.speech_events if ev.co_declaration == "seer" + ) + assert co_ev.speaker_seat in (2, 3) + # phase_baseline rows are the internal sentinel — never surfaced to viewer. + sources_seen = {ev.source for p in export.phases for ev in p.speech_events} + assert "phase_baseline" not in sources_seen + assert sources_seen <= {"text", "voice_stt", "npc_generated"} + + # All three trace roles came through. Order is chronological by ts. + roles_seen = {t.role for t in export.trace} + assert roles_seen == {"gameplay", "npc_speech", "voice_stt"} + # file_stem is auto-injected from the JSONL filename. + file_stems = {t.file_stem for t in export.trace} + assert "gameplay" in file_stems + assert "npc_setsu" in file_stems + assert "voice_stt" in file_stems + # Token usage round-trips. + gameplay = next(t for t in export.trace if t.role == "gameplay") + assert gameplay.tokens is not None + assert gameplay.tokens.total == 130 + + +async def test_committed_schema_matches_pydantic_models() -> None: + """The viewer-side ``export-schema.json`` must equal the live schema. + + If this fails, run:: + + uv run python scripts/dump-export-schema.py + + The viewer's contract test loads the committed file; out-of-date + schemas there cause spurious failures or silently-accepted drift. + """ + repo_root = Path(__file__).resolve().parents[1] + committed_path = repo_root / "viewer" / "sample-data" / "export-schema.json" + assert committed_path.is_file(), ( + f"missing {committed_path} — run scripts/dump-export-schema.py" + ) + committed = json.loads(committed_path.read_text(encoding="utf-8")) + live = GameExport.model_json_schema() + assert committed == live, ( + "viewer/sample-data/export-schema.json is stale — " + "run `uv run python scripts/dump-export-schema.py` to refresh." + ) + + +async def test_bundled_sample_validates_against_models() -> None: + """``viewer/sample-data/game-sample.json`` must satisfy ``GameExport``.""" + repo_root = Path(__file__).resolve().parents[1] + sample_path = repo_root / "viewer" / "sample-data" / "game-sample.json" + if not sample_path.is_file(): + pytest.skip("sample not generated yet") + GameExport.model_validate_json(sample_path.read_text(encoding="utf-8")) + + +async def test_export_round_trips_when_trace_dir_missing( + tmp_repo: Path, +) -> None: + """Pre-trace games still validate — empty trace list is the contract.""" + db_path = tmp_repo / "wolf.db" + await migrate(db_path) + await _seed_realistic_game(db_path) + + out_path = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=tmp_repo / "absent", + output_dir=tmp_repo / "out", + ) + export = GameExport.model_validate_json(out_path.read_text(encoding="utf-8")) + assert export.trace == [] + + +async def test_export_rejects_unknown_game(tmp_repo: Path) -> None: + db_path = tmp_repo / "wolf.db" + await migrate(db_path) + with pytest.raises(ValueError, match="game not found"): + await export_game( + game_id="missing", + db_path=db_path, + trace_dir=tmp_repo / "trace", + output_dir=tmp_repo / "out", + ) + + +async def test_malformed_trace_line_does_not_break_export( + tmp_repo: Path, +) -> None: + """One bad JSONL line is tolerated — the rest of the export still runs.""" + db_path = tmp_repo / "wolf.db" + await migrate(db_path) + await _seed_realistic_game(db_path) + trace_root = tmp_repo / "trace" + game_dir = trace_root / GAME_ID + game_dir.mkdir(parents=True) + # Mix of valid + malformed (json error) + schema-violating (string for + # latency_ms). Valid line must come through; the rest are skipped. + valid = json.dumps({ + "ts": "2026-04-28T08:00:00+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 1, + "actor": "seat=2", + "system_prompt": "s", + "user_prompt": "u", + "response": "r", + "latency_ms": 100, + "tokens": None, + "error": None, + }) + schema_violating = json.dumps({ + "ts": "2026-04-28T08:00:01+00:00", + "role": "gameplay", + "provider": "xai", + "model": "x", + "phase": None, + "day": None, + "actor": None, + "system_prompt": "s", + "user_prompt": "u", + "response": "r", + "latency_ms": "not-an-int", # wrong type + "tokens": None, + "error": None, + }) + (game_dir / "gameplay.jsonl").write_text( + valid + "\n{not valid json\n" + schema_violating + "\n", + encoding="utf-8", + ) + + # Tempfile setup + with tempfile.TemporaryDirectory() as out_root: + out_path = await export_game( + game_id=GAME_ID, + db_path=db_path, + trace_dir=trace_root, + output_dir=Path(out_root), + ) + export = GameExport.model_validate_json( + out_path.read_text(encoding="utf-8") + ) + assert len(export.trace) == 1 + assert export.trace[0].role == "gameplay" + assert export.trace[0].latency_ms == 100 diff --git a/tests/test_game_service_advance.py b/tests/test_game_service_advance.py index 2e72001..671f5b0 100644 --- a/tests/test_game_service_advance.py +++ b/tests/test_game_service_advance.py @@ -585,6 +585,91 @@ async def test_host_abort_ends_game( assert any(c.name == "on_game_end" for c in disc.calls) +async def test_host_abort_invokes_on_reactive_game_end_callback( + repo: SqliteRepo, +) -> None: + """The reactive_voice plumbing release path must fire on host_abort so + NPC bots leave VC. Mirrors `discord.on_game_end` but on a separate hook + so reactive_voice plumbing stays out of the GameService core.""" + disc = FakeDiscordAdapter() + llm = FakeLLMAdapter() + reg = EngineRegistry() + fired: list[str] = [] + + async def on_end(game_id: str) -> None: + fired.append(game_id) + + service = GameService( + repo=repo, + discord=disc, + llm=llm, + wake=reg, + rng=random.Random(0), + on_reactive_game_end=on_end, + ) + game = await _make_game_in_setup(repo) + await service.advance(game.id) + + ok = await service.host_abort(game.id) + assert ok + assert fired == [game.id] + + +async def test_host_abort_invokes_finalize_hook( + repo: SqliteRepo, +) -> None: + """The finalize hook (used to export viewer JSON) must fire on host_abort.""" + disc = FakeDiscordAdapter() + llm = FakeLLMAdapter() + reg = EngineRegistry() + finalized: list[str] = [] + + async def on_finalize(game_id: str) -> None: + finalized.append(game_id) + + service = GameService( + repo=repo, + discord=disc, + llm=llm, + wake=reg, + rng=random.Random(0), + on_game_end_finalize=on_finalize, + ) + game = await _make_game_in_setup(repo) + await service.advance(game.id) + + ok = await service.host_abort(game.id) + assert ok + assert finalized == [game.id] + + +async def test_finalize_hook_swallows_exceptions( + repo: SqliteRepo, +) -> None: + """A failing finalize hook (e.g. export disk-full) MUST NOT block teardown.""" + disc = FakeDiscordAdapter() + llm = FakeLLMAdapter() + reg = EngineRegistry() + + async def on_finalize(_game_id: str) -> None: + raise RuntimeError("disk full") + + service = GameService( + repo=repo, + discord=disc, + llm=llm, + wake=reg, + rng=random.Random(0), + on_game_end_finalize=on_finalize, + ) + game = await _make_game_in_setup(repo) + await service.advance(game.id) + + # Abort should still succeed despite the finalize hook raising. + ok = await service.host_abort(game.id) + assert ok + + async def test_host_abort_returns_false_when_already_ended( repo: SqliteRepo, svc: tuple[GameService, FakeDiscordAdapter, FakeLLMAdapter, EngineRegistry, FakeClock], @@ -1870,20 +1955,12 @@ async def test_mixed_human_llm_wolf_split_triggers_early_wake( wakes: list[str] = [] reg.wake = lambda gid: wakes.append(gid) # type: ignore[method-assign] - await service.submit_night_action( - game.id, 2, SubmissionType.SEER_DIVINE, 9, day=1 - ) - await service.submit_night_action( - game.id, 3, SubmissionType.KNIGHT_GUARD, 2, day=1 - ) + await service.submit_night_action(game.id, 2, SubmissionType.SEER_DIVINE, 9, day=1) + await service.submit_night_action(game.id, 3, SubmissionType.KNIGHT_GUARD, 2, day=1) # Wolves split: human picks 4, LLM picks 5. Human-wolf priority resolves # in favor of seat 4, so the night is decided. - await service.submit_night_action( - game.id, 1, SubmissionType.WOLF_ATTACK, 4, day=1 - ) - await service.submit_night_action( - game.id, 9, SubmissionType.WOLF_ATTACK, 5, day=1 - ) + await service.submit_night_action(game.id, 1, SubmissionType.WOLF_ATTACK, 4, day=1) + await service.submit_night_action(game.id, 9, SubmissionType.WOLF_ATTACK, 5, day=1) assert wakes == [game.id] diff --git a/tests/test_llm_backfill.py b/tests/test_llm_backfill.py index e91c476..0f7c81c 100644 --- a/tests/test_llm_backfill.py +++ b/tests/test_llm_backfill.py @@ -19,7 +19,8 @@ from wolfbot.domain.enums import Phase from wolfbot.domain.models import Game, Seat -from wolfbot.llm.personas import pick_personas +from wolfbot.llm.persona_base import pick_personas +from wolfbot.npc.personas import NPC_PERSONAS from wolfbot.persistence.sqlite_repo import SqliteRepo from wolfbot.services.game_service import new_game_id @@ -49,7 +50,7 @@ async def _seed_game_with_humans(repo: SqliteRepo, human_count: int) -> str: def _picks_as_specs(count: int, seed: int) -> list[tuple[str, str]]: - picks = pick_personas(count, random.Random(seed)) + picks = pick_personas(NPC_PERSONAS, count, random.Random(seed)) return [(p.display_name, p.key) for p in picks] diff --git a/tests/test_llm_mock_decider.py b/tests/test_llm_mock_decider.py new file mode 100644 index 0000000..03f5649 --- /dev/null +++ b/tests/test_llm_mock_decider.py @@ -0,0 +1,105 @@ +"""MockLLMActionDecider behavior tests. + +Asserts that the offline-mock gameplay decider returns the right intent +based on the unique phrases each ``task_*`` prompt emits in +:mod:`wolfbot.llm.prompt_builder`. The mock is used when +``GAMEPLAY_LLM_PROVIDER=mock`` so the full Master pipeline can run +end-to-end without burning real LLM tokens. +""" + +from __future__ import annotations + +from wolfbot.domain.enums import Role, SubmissionType +from wolfbot.llm.decider_config import LLMDeciderConfig +from wolfbot.llm.prompt_builder import ( + task_daytime_speech, + task_night_action, + task_vote, + task_wolf_chat, +) +from wolfbot.services.llm_service import ( + LLMAction, + MockLLMActionDecider, + make_llm_decider, +) + +CANDIDATES = ("席1 Alice", "席2 セツ", "席3 ジナ") + + +async def test_mock_decider_returns_vote_intent_with_null_target() -> None: + decider = MockLLMActionDecider() + user_ctx = task_vote(CANDIDATES, runoff=False) + result = await decider.decide(system_prompt="", user_context=user_ctx) + assert isinstance(result, LLMAction) + assert result.intent == "vote" + # null target → LLMAdapter._resolve_target falls back to random + assert result.target_name is None + + +async def test_mock_decider_returns_night_action_with_smallest_seat() -> None: + decider = MockLLMActionDecider() + user_ctx = task_night_action(SubmissionType.WOLF_ATTACK, CANDIDATES) + result = await decider.decide(system_prompt="", user_context=user_ctx) + assert result.intent == "night_action" + # Smallest 席N picked from the candidate list so both wolves converge + # on the same target — otherwise mock attacks split and park the game + # in WAITING_HOST_DECISION every night. + assert result.target_name == "席1" + + +async def test_mock_decider_night_action_falls_back_to_none_when_no_seats() -> None: + decider = MockLLMActionDecider() + user_ctx = task_night_action(SubmissionType.WOLF_ATTACK, ()) + result = await decider.decide(system_prompt="", user_context=user_ctx) + assert result.intent == "night_action" + assert result.target_name is None + + +async def test_mock_decider_dispatches_when_task_text_is_in_system_prompt() -> None: + """Regression: the real `_ask` path puts task_text into the *system* + prompt (via `build_system_prompt`'s `{task_block}`). If the mock only + checked user_context it would silently fall through to canned speech + on every vote / night submission, target_name would default to None, + and `_resolve_target` would log "LLM returned null target" and pick + randomly — exactly the bug that broke wolf-attack convergence.""" + decider = MockLLMActionDecider() + night_task = task_night_action(SubmissionType.WOLF_ATTACK, CANDIDATES) + result = await decider.decide(system_prompt=night_task, user_context="user ctx") + assert result.intent == "night_action" + assert result.target_name == "席1" + + vote_task = task_vote(CANDIDATES, runoff=False) + result = await decider.decide(system_prompt=vote_task, user_context="user ctx") + assert result.intent == "vote" + + +async def test_mock_decider_returns_daytime_speech() -> None: + decider = MockLLMActionDecider() + user_ctx = task_daytime_speech(day_number=1, discussion_round=1, role=Role.VILLAGER) + result = await decider.decide(system_prompt="", user_context=user_ctx) + assert result.intent == "speak" + assert result.public_message # non-empty canned phrase + + +async def test_mock_decider_returns_wolf_chat_speech() -> None: + decider = MockLLMActionDecider() + user_ctx = task_wolf_chat(("席7 Bob",), CANDIDATES) + result = await decider.decide(system_prompt="", user_context=user_ctx) + assert result.intent == "speak" + assert result.public_message + + +async def test_mock_decider_speech_round_robins_through_pool() -> None: + decider = MockLLMActionDecider(speeches=("a", "b", "c")) + user_ctx = task_daytime_speech(day_number=1, discussion_round=1, role=Role.VILLAGER) + out = [] + for _ in range(5): + r = await decider.decide(system_prompt="", user_context=user_ctx) + out.append(r.public_message) + assert out == ["a", "b", "c", "a", "b"] + + +def test_make_llm_decider_returns_mock_when_provider_is_mock() -> None: + cfg = LLMDeciderConfig(provider="mock") + decider = make_llm_decider(cfg) + assert isinstance(decider, MockLLMActionDecider) diff --git a/tests/test_llm_prompt_builder.py b/tests/test_llm_prompt_builder.py index 7aee2ca..43811b6 100644 --- a/tests/test_llm_prompt_builder.py +++ b/tests/test_llm_prompt_builder.py @@ -1,28 +1,36 @@ -"""Tests for `prompt_builder._build_game_rules_block`, `_build_strategy_block`, -and `build_system_prompt`. - -These pin down two invariants: -- The game-rules block (common to every LLM seat) states the fixed 9-player - ruleset, role distribution, win conditions, and the non-obvious mechanics - (NIGHT_0 random white, madman-is-white, wolf-split failure, knight no - consecutive guard, candidate-token strict match). -- The role-strategy block is role-scoped: each role's tips are not visible to - any other role. In particular, wolf-coordination vocabulary (`相方`, - `襲撃先を揃える`) appears only in the werewolf tips; the madman's tips - prohibit (not assume) knowing real wolf positions. +"""Tests for the prompt builder. + +Pins down the safety invariants the rebuilt rules + strategy templates +must keep: + +* The shared rules block states the canonical 9-player ruleset and the + hard structural facts every LLM seat must respect (NIGHT_0 random + white, attack-victim-non-wolf, knight no-self / no-consecutive, + candidate-token strict match, day-1 fake-seer must claim white, + per-target naming, public-claim immutability). +* The role-strategy block is role-scoped — wolf-coordination vocabulary + (`相方` / `襲撃先を揃える`) appears only in the werewolf tips, and + the madman tips prohibit (not assume) knowing real wolf positions. +* `build_system_prompt` and `build_user_context` glue the blocks + together with the right placeholders filled and no leakage between + roles. + +Granular bullet-by-bullet content checks were dropped when the rules +file was rewritten — those couple the test suite too tightly to prose +phrasing. Tests here check structure, leakage, and the few invariants +the runtime depends on for safety. """ from __future__ import annotations -import re - import pytest from wolfbot.domain.enums import ROLE_JA, Phase, Role, SubmissionType from wolfbot.domain.models import Game, Player, Seat -from wolfbot.llm.personas import PERSONAS_BY_KEY, Persona, SpeechProfile +from wolfbot.llm.persona_base import JudgmentProfile, Persona, SpeechProfile from wolfbot.llm.prompt_builder import ( _build_game_rules_block, + _build_judgment_profile_block, _build_speech_profile_block, _build_strategy_block, build_system_prompt, @@ -32,1811 +40,492 @@ task_vote, task_wolf_chat, ) +from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY as PERSONAS_BY_KEY - -# --------------------------------------------------------- game rules block -def test_game_rules_block_contains_role_distribution() -> None: - block = _build_game_rules_block() - for role_ja, count in ( - ("人狼", 2), - ("狂人", 1), - ("占い師", 1), - ("霊媒師", 1), - ("騎士", 1), - ("村人", 3), - ): - assert f"{role_ja}{count}" in block, f"{role_ja}{count} missing from rules block" +# ========================================================================= +# game-rules block — structural invariants +# ========================================================================= def test_game_rules_block_states_nine_player_village() -> None: block = _build_game_rules_block() assert "9 人村" in block + assert "(合計 9)" in block -def test_game_rules_block_contains_win_conditions() -> None: - block = _build_game_rules_block() - # Must mirror rules.check_victory exactly. - assert "生存人狼数が 0" in block - assert "生存人狼数が生存非人狼人数以上" in block - - -def test_game_rules_block_contains_night0_random_white_rule() -> None: - block = _build_game_rules_block() - assert "NIGHT_0" in block - assert "ランダム白" in block - assert "本物の人狼ではない" in block - - -def test_game_rules_block_contains_seer_medium_wolf_only_rule() -> None: +def test_game_rules_block_renders_role_distribution_from_constants() -> None: + """The distribution sentence must list every role with its seat count + derived from ROLE_DISTRIBUTION + ROLE_JA — not hard-coded text.""" block = _build_game_rules_block() - assert "本物の人狼だけを黒" in block - assert "狂人は黒判定されない" in block - - -def test_game_rules_block_contains_wolf_split_attack_rule() -> None: - block = _build_game_rules_block() - assert "意見が割れた場合" in block - assert "空振り" in block - + expected_pieces = [ + f"{ROLE_JA[Role.WEREWOLF]}2", + f"{ROLE_JA[Role.MADMAN]}1", + f"{ROLE_JA[Role.SEER]}1", + f"{ROLE_JA[Role.MEDIUM]}1", + f"{ROLE_JA[Role.KNIGHT]}1", + f"{ROLE_JA[Role.VILLAGER]}3", + ] + for piece in expected_pieces: + assert piece in block -def test_game_rules_block_documents_human_wolf_priority_exception() -> None: - """Domain has human-wolf priority for mixed (1 human + 1 LLM) splits. - LLM wolves must understand the asymmetry — without it they assume any - split fails and may misjudge attack-target choice when paired with a human. - """ +def test_game_rules_block_states_win_conditions() -> None: block = _build_game_rules_block() - assert "人間プレイヤー" in block - assert "LLM 席" in block - assert "採用" in block + assert "村勝利" in block or "村人陣営勝利" in block + assert "狼勝利" in block or "人狼陣営勝利" in block + # Madman is counted as non-wolf for the count, but the wolf side wins. + assert "狂人" in block -def test_game_rules_block_contains_knight_consecutive_guard_rule() -> None: - block = _build_game_rules_block() - assert "連続で護衛" in block - assert "前夜と同じ対象" in block +# ---------------------- night-action structural facts ---------------------- -def test_game_rules_block_contains_candidate_token_rule() -> None: +def test_game_rules_block_states_night0_random_white_only() -> None: block = _build_game_rules_block() - assert "候補トークン" in block + assert "NIGHT_0" in block + # day 1 morning is structurally peaceful; cannot be inferred as GJ + assert "GJ" in block or "護衛成功" in block + assert "解釈してはいけない" in block or "構造ルール違反" in block -def test_game_rules_block_contains_single_co_default_truthy_stance() -> None: - """Shared rule: a lone CO with no counter-CO (never once in the public log) - should be treated as likely real, and LLMs should not vote against them - without grounds. `一度も` is the key anchor that distinguishes this case - from a sole-survivor whose counter-CO was executed/attacked.""" +def test_game_rules_block_states_night0_random_white_can_be_madman() -> None: block = _build_game_rules_block() - assert "単独 CO" in block - assert "真の役職者にかなり近い" in block - assert "対抗 CO" in block - assert "一度も" in block + assert "狂人の可能性" in block -def test_game_rules_block_allows_single_co_suspicion_on_strong_evidence() -> None: - """Single CO is not absolute truth — strong evidence (speech breakdown, - vote contradiction, result contradiction, attack-pattern mismatch) can - still justify suspicion.""" +def test_game_rules_block_states_knight_no_self_no_consecutive() -> None: block = _build_game_rules_block() - assert "発言破綻" in block - assert "投票矛盾" in block - assert "判定結果の矛盾" in block - assert "噛み筋" in block + assert "自分護衛" in block + assert "前夜と同じ対象護衛" in block or "連続護衛" in block -def test_game_rules_block_guides_counter_co_comparison() -> None: - """When a counter-CO appears, compare on results / timeline / votes / - attack outcomes.""" +def test_game_rules_block_states_wolf_split_master_picks_one() -> None: block = _build_game_rules_block() - assert "対抗 CO" in block - assert "時系列" in block - assert "整合性" in block + assert "Master" in block + assert "空振り" in block + assert "ランダム" in block -def test_game_rules_block_rejects_sole_survivor_as_single_co() -> None: - """If the same role had 2+ COs in the past, a currently-surviving lone CO - holder is NOT treated as a lone/no-counter CO even though only one is - alive. Prevents the LLM from auto-trusting a survivor whose opponent was - executed or attacked.""" - block = _build_game_rules_block() - assert "2 人以上" in block - assert "自動的に真置きしない" in block +# ---------------------- judgment color rules ---------------------- -def test_game_rules_block_includes_dead_co_in_comparison() -> None: - """For roles with a counter-CO history, dead CO holders stay in the - comparison set — the LLM evaluates the survivor against the deceased - opponent's results, speech, votes, attack outcomes AND death timing.""" +def test_game_rules_block_states_two_color_only() -> None: block = _build_game_rules_block() - assert "死亡済み" in block - assert "死亡タイミング" in block + assert "黒 (本物の人狼)" in block + assert "白 (本物の人狼ではない)" in block + assert "2 値のみ" in block -def test_game_rules_block_warns_against_treating_last_surviving_co_as_truth() -> None: - """When a counter-CO history exists and only one CO is left alive, the LLM - must not short-circuit to "lone CO ⇒ truth". The wording must explicitly - name the wolf-side mechanisms that produce a sole survivor (not attacking - the info role, getting a counter-CO executed, leaving them for protective - cover) and forbid the "single CO so truth" shortcut.""" +def test_game_rules_block_states_madman_appears_white() -> None: block = _build_game_rules_block() - assert "最後まで生き残った" in block - assert "噛まずに残した" in block - assert "単独 CO だから真" in block + assert "狂人は黒判定されない" in block -def test_game_rules_block_co_recognition_requires_explicit_self_declaration() -> None: - """Topical mentions / hypotheticals / references to others using CO - vocabulary (`占いCO が出たら` etc.) must NOT be read as the speaker's own - CO. The rules block must explicitly carry the self-declaration policy and - the example phrases for each side (topical vs. self-declaration).""" - block = _build_game_rules_block() - # Topical phrases the LLM must NOT misread as self-CO. - assert "占いCOが出たら" in block - assert "霊媒COについて" in block - # Self-declaration example phrases. - assert "私は占い師です" in block - assert "占い師COします" in block - assert "霊媒師として出ます" in block - # Policy framing. - assert "話題化" in block - assert "名乗り" in block - assert "判断に迷うときは CO として数えない" in block - - -def test_game_rules_block_co_recognition_no_wolf_coordination_leak() -> None: - """Defensive duplicate of the existing terminology leak-test focused on the - new CO-recognition bullets — guarantees the new policy text does not bleed - wolf-coordination vocabulary into the shared rules block. Bare `相方` - (actor mode, partner-known) is forbidden; `相方候補` (public-log inference) - is allowed in the 2-wolf-pair-inference subsection.""" +def test_game_rules_block_states_attack_victim_is_non_wolf_hard() -> None: + """The 襲撃死=非狼 rule is the most safety-critical HARD fact: the + listener-side must reject any 'attack victim was wolf' claim, and + the speaker-side must never produce one.""" block = _build_game_rules_block() - assert not re.search(r"相方(?!候補)", block), ( - "bare '相方' (actor mode) leaked into shared rules block" - ) - assert "襲撃先を揃える" not in block + assert "(襲撃)" in block + assert "非狼確定" in block + assert "HARD" in block -def test_game_rules_block_explains_medium_white_means_not_wolf_only() -> None: - """Medium white = `not a real werewolf`, not a role-claim confirmation. - Every LLM seat must see this so that no role overreads a white result.""" +def test_game_rules_block_states_medium_white_is_not_village_confirmation() -> None: block = _build_game_rules_block() - assert "人狼ではありませんでした" in block assert "本物の人狼ではない" in block - assert "役職名" in block - - -def test_game_rules_block_protects_seer_co_from_medium_white_misread() -> None: - """Medium-white on an executed Seer-CO does NOT invalidate the seer claim - — a real seer who gets executed reads white too. The block must also - state that suspicion of the seer CO requires non-medium-result grounds.""" - block = _build_game_rules_block() - assert "占い師 CO" in block - assert "真占い師だった可能性と矛盾しない" in block - assert "偽扱いしない" in block + # The legacy rule was that medium white doesn't identify role; the new + # text expresses this as "役職までは特定しない". + assert "役職" in block and "特定しない" in block -def test_game_rules_block_flags_seer_co_as_wolf_fake_on_medium_black() -> None: - """The converse: medium-black on an executed Seer-CO is strong evidence - the CO was a wolf fake, because only real werewolves read black.""" - block = _build_game_rules_block() - assert "霊媒結果で黒" in block - assert "本物の人狼" in block - assert "人狼の騙り" in block +# ---------------------- public-claim immutability ---------------------- -def test_game_rules_block_defines_3_1_formation() -> None: - """3-1 = 3 seer COs + 1 medium CO. The shared rules must name this - formation and note the 2-of-3 fake seer likelihood, treating the sole - medium as a truth-leaning progression pivot.""" +def test_game_rules_block_forbids_retroactive_judgment_change() -> None: block = _build_game_rules_block() - assert "3-1" in block - assert "占い師 CO が 3 人・霊媒師 CO が 1 人" in block - assert "2 人が騙り" in block + assert "後のターンで対象・色・日付を絶対に書き換えない" in block -def test_game_rules_block_names_seer_roller_and_black_stop() -> None: - """3-1 gives two base-plan vocabulary items: seer roller and black stop. - Both names must appear in the shared rules.""" +def test_game_rules_block_states_per_target_naming_required() -> None: block = _build_game_rules_block() - assert "占いローラー" in block - assert "黒ストップ" in block - - -def test_game_rules_block_describes_seer_roller_procedure() -> None: - """Seer roller hangs from fake-looking / wolf-looking / info-leaking seer - COs, then cross-checks post-execution medium results against seer / vote - / attack consistency.""" - block = _build_game_rules_block() - assert "偽っぽい・狼っぽい・視点漏れ" in block - assert "処刑後の霊媒結果" in block - - -def test_game_rules_block_describes_black_stop_and_its_limits() -> None: - """Black stop (灰 scrutiny when sole medium reports black on a seer CO) - is the alternative to continuing the roller, but it has explicit - exceptions: 真狼狼, fake medium, breaking remaining seer CO, PP risk.""" - block = _build_game_rules_block() - assert "灰 (役職 CO していない位置) の精査" in block - assert "真狼狼" in block - assert "PP (パワープレイ)" in block - - -def test_game_rules_block_defines_2_2_formation() -> None: - """2-2 = 2 seer COs + 2 medium COs. Neither side is confirmed; medium - roller (or 霊媒切り) is the default progression.""" - block = _build_game_rules_block() - assert "2-2" in block - assert "占い師 CO が 2 人・霊媒師 CO が 2 人" in block - assert "霊媒ローラー" in block - - -def test_game_rules_block_defines_2_1_formation() -> None: - """2-1 = 2 seer COs + 1 medium CO. Sole medium reads as truth-leaning - pivot; branch on black-out vs white-progress with rope / 囲い awareness.""" - block = _build_game_rules_block() - assert "2-1" in block - assert "占い師 CO が 2 人・霊媒師 CO が 1 人" in block - assert "単独霊媒師" in block - assert "グレー吊り" in block - assert "黒吊り" in block - - -def test_game_rules_block_defines_1_2_formation() -> None: - """1-2 = 1 seer CO + 2 medium COs. Seer reads truth-leaning while mediums - are treated as mixed-fake; medium roller / medium-kiri is default, grey - scrutiny only when medium internals read real+madman and grey is - wolf-heavy.""" - block = _build_game_rules_block() - assert "1-2" in block - assert "占い師 CO が 1 人・霊媒師 CO が 2 人" in block - assert "霊媒ローラー" in block - assert "霊媒切り" in block - - -def test_game_rules_block_requires_completing_medium_roller_by_default() -> None: - """Two medium COs → don't unfoundedly trust either one; once a medium - roller is started, complete it by default, with a high evidentiary bar - to stop halfway.""" - block = _build_game_rules_block() - assert "根拠なく真置きせず" in block - assert "原則として完走" in block - - -# -------------------------- 3 占い CO + 2 非狼確定 で残る 1 人を確定黒級とする消去法 -def test_game_rules_block_describes_three_seer_co_elimination_inference() -> None: - """3-1 で占い CO 3 人のうち 2 人が公開情報上『本物の人狼ではない』と確定したら、 - 残る 1 人を固定配役上の消去法で確定黒級の人狼位置として扱う。""" - block = _build_game_rules_block() - assert "2 人が公開情報上『本物の人狼ではない』と確定" in block - assert "残る 1 人の占い師 CO を固定配役上の消去法" in block - assert "確定黒級" in block - assert "人狼 2 人固定の配役" in block - - -def test_game_rules_block_distinguishes_white_judgement_from_non_wolf_confirmation() -> None: - """『白判定』と『非狼確定』を混同しない。信用未確定の占い CO の白、 - 偽が混じり得る霊媒結果、印象だけの白寄り評価は非狼確定として数えない。""" - block = _build_game_rules_block() - assert "『白判定』と『非狼確定』を混同しない" in block - assert "信用が未確定な占い師 CO が出した白" in block - assert "偽が混じり得る霊媒結果" in block - assert "印象だけの白寄り評価は非狼確定として数えない" in block - assert "単発の白だけで非狼扱いを固定しない" in block - - -def test_game_rules_block_lists_acceptable_non_wolf_confirmation_grounds() -> None: - """非狼確定として数えてよい根拠は公開ログ・霊媒結果・襲撃死・ - 真寄り情報役の判定・CO 破綻整理に限る。""" - block = _build_game_rules_block() - assert "公開ログ・霊媒結果・襲撃死・真寄り情報役の判定" in block - assert "CO 破綻整理" in block - assert "本物の人狼ではないと示す襲撃死" in block - - -def test_game_rules_block_treats_remaining_seer_co_as_fixed_wolf_position() -> None: - """2 人非狼確定が成立したら、残る占い師 CO は『まだ灰の 1 人』ではなく - 固定配役上の狼位置として投票・発言・進行提案へ反映させる。""" - block = _build_game_rules_block() - assert "『まだ灰の 1 人』ではなく" in block - assert "固定配役上の狼位置として投票・発言・進行提案へ反映" in block - assert "残る占い師 CO の処刑提案" in block - - -def test_game_rules_block_releases_fixed_black_when_premise_collapses() -> None: - """村陣営騙り・CO 撤回・霊媒師 CO 偽の浮上・非狼確定根拠の破綻など - 前提が崩れたら、確定黒扱いを解除して時系列から再整理する。""" - block = _build_game_rules_block() - assert "前提が崩れた場合は確定黒扱いを解除して時系列から再整理" in block - assert "村陣営の騙り" in block - assert "CO 撤回" in block - # 既存の line 108 文言と区別するため『の浮上』『後から』語尾の anchor を使う - assert "霊媒師 CO 側が偽だった可能性が後から浮上した状況" in block - assert "非狼確定の根拠が後から破綻した場合" in block - assert "CO 履歴と判定履歴を時系列で再整理" in block - - -# ----------- 3 役職横断: CO 数・対抗 CO 超過分から非 CO 確白を読む消去法 -# 占い師・霊媒師・騎士の各 CO 数を時系列で整理し、各役職について `CO 数 - 1` -# を「対抗 CO 超過分」(騙り最低数) として数える。3 役職分の超過分を合計して 3 に -# 達した場合、人狼 2 + 狂人 1 の狼陣営 3 名が能力役職 CO 群に出切っているため、 -# 能力役職 CO していない位置は配役上の消去法で村陣営の確白級として扱える。 -def test_game_rules_block_defines_co_overflow_term() -> None: - """占い師・霊媒師・騎士の各 CO 数を時系列で整理し、CO 数 - 1 を対抗 CO 超過分 - (騙り最低数) として数える、という用語と式が共通ルールに入っていること。""" - block = _build_game_rules_block() - assert "対抗 CO 超過分" in block - assert "CO 数 - 1" in block - assert "騙り最低数" in block - assert "真役職は各 1 人だけ" in block - - -def test_game_rules_block_overflow_sum_three_marks_non_co_as_village_white() -> None: - """超過分合計が 3 に達した場合、能力役職 CO していない位置を村陣営の確白級 - として扱う。配役上の消去法で狼陣営 3 名が CO 群に出切ったと数える。""" - block = _build_game_rules_block() - assert "超過分合計が 3 に達した場合" in block - assert "人狼 2 + 狂人 1" in block - assert "能力役職 CO していない位置" in block - assert "村陣営の確白級" in block - assert "配役上の消去法" in block - - -def test_game_rules_block_overflow_sum_three_promotes_independent_single_co() -> None: - """超過分合計 3 のとき、対抗のない単独 CO 役職が別にあれば、 - その単独 CO 者も狼陣営ではないため真役職としてかなり強く扱える。""" - block = _build_game_rules_block() - assert "対抗のない単独 CO 役職" in block - assert "狼陣営ではないため真役職としてかなり強く扱える" in block - - -def test_game_rules_block_distinguishes_overflow_inference_from_madman_white() -> None: - """超過分合計 3 による非 CO 確白は、単発の白判定 (狂人白) とは別根拠で、 - 固定配役上の消去法で狼陣営 3 名が出切ったと数える点で村陣営まで強く推せる。""" - block = _build_game_rules_block() - assert "単発の白判定" in block - # 既存の確白語彙ルール (狂人白との整合) と矛盾しない言い回し。 - assert "固定配役上の消去法で狼陣営 3 名が CO 群に出切った" in block - assert "村陣営まで強く推せる" in block - - -def test_game_rules_block_overflow_sum_three_within_group_truth_still_needs_signals() -> None: - """対抗 CO 群の中で誰が真役職かまでは、超過分合計 3 だけでは決まらない。 - 判定結果・霊媒結果・投票・襲撃・死亡タイミング・破綻で詰める。""" - block = _build_game_rules_block() - assert "対抗 CO 群の中で誰が真役職かまでは超過分合計だけでは特定できない" in block - # 詰めに使う材料: 4 軸以上の言及。 - assert "判定結果・霊媒結果・投票・襲撃" in block - assert "死亡タイミング" in block + assert "「全員白」" in block or "「すべて白」" in block assert "破綻" in block -def test_game_rules_block_overflow_sum_low_does_not_confirm_non_co() -> None: - """超過分合計が 0〜2 の段階では、狼陣営が非 CO や単独 CO に残っている - 可能性があるため、非 CO 位置を CO 数だけで確白とは断定しない。""" +def test_game_rules_block_states_day1_seer_only_one_white_result() -> None: block = _build_game_rules_block() - assert "0〜2" in block - assert "断定しない" in block - assert "狼陣営が非 CO や単独 CO に残っている可能性" in block + # day 1 朝の占い結果: NIGHT_0 ランダム白 1 件のみ + assert "NIGHT_0 ランダム白 1 件" in block + assert "day 1 朝" in block + # Black claim on day 1 is forbidden + assert "黒を主張" in block -def test_game_rules_block_overflow_sum_high_triggers_recheck() -> None: - """超過分合計が 4 以上に見える場合は固定配役と矛盾する。CO 撤回・ - 同一人物の複数 CO・話題としての CO 語彙の誤読・村騙り・死亡済み CO - 見落とし・ログ見落としを疑い、確白扱いを保留して時系列を再整理する。""" - block = _build_game_rules_block() - assert "4 以上" in block - assert "固定配役と矛盾" in block - assert "CO 撤回" in block - assert "同一人物の複数 CO" in block - assert "村騙り" in block - assert "死亡済み CO 見落とし" in block - assert "確白扱いを保留して時系列を再整理する" in block - - -def test_game_rules_block_includes_co_overflow_examples() -> None: - """LLM が数え方を誤らないよう、3-2-1 / 2-2-2 / 3-1-1 / 4-1-1 の - 短い例が共通ルールに入っていること。""" +def test_game_rules_block_states_day1_medium_has_no_result() -> None: block = _build_game_rules_block() - # 数え方の型: 超過分合計 3 / 2 の境界、4-1-1 のような偏ったケース。 - assert "3-2-1" in block - assert "2-2-2" in block - assert "3-1-1" in block - assert "4-1-1" in block - # 計算式が例の中に明示されているか (LLM が暗算を間違えない補助)。 - assert "2 + 1 + 0 = 3" in block - assert "1 + 1 + 1 = 3" in block - assert "2 + 0 + 0 = 2" in block - assert "3 + 0 + 0 = 3" in block - - -def test_game_rules_block_co_overflow_no_wolf_coordination_leak() -> None: - """新規追加した CO 超過分推理ブロックが、wolf-coordination 語彙 - (bare `相方`, `襲撃先を揃える`) を共通ルールに漏らさないこと。 - `相方候補` (公開ログからの推理用語) は許容。""" - block = _build_game_rules_block() - assert not re.search(r"相方(?!候補)", block), ( - "bare '相方' (actor mode) leaked into shared rules block" - ) - assert "襲撃先を揃える" not in block - - -# ------------------------------------- terminology (推理語彙) in rules block -# Advanced jinro vocabulary is shared across every LLM seat via the game-rules -# block (not per-role strategy). These assertions pin the substrings that the -# spec requires and guard against accidental leak of wolf-coordination -# vocabulary (`相方`, `襲撃先を揃える`) into the shared block. -def test_game_rules_block_frames_terminology_as_reading_tool() -> None: - """The terminology section is introduced as a *reading* tool that does not - override the preceding factual rules. The framing sentence anchors this.""" - block = _build_game_rules_block() - assert "推理語彙" in block - assert "最終判断は常に公開情報の整合性" in block - - -def test_game_rules_block_defines_grey_positions() -> None: - """グレー / 灰 means: no role CO AND no settled seer/medium white-black on - the seat. Both kanji and katakana forms must appear.""" - block = _build_game_rules_block() - assert "グレー" in block - assert "灰" in block - assert "白黒も十分ついていない" in block - - -def test_game_rules_block_describes_guran_as_non_random() -> None: - """グレラン is explicitly framed as reasoned grey-voting, not pure random — - the spec calls out this misreading as the #1 failure mode.""" - block = _build_game_rules_block() - assert "グレラン" in block - assert "理由を持って" in block - # The non-randomness must be made explicit; the word `無作為` appears only - # in the negation phrase. - assert "無作為" in block - assert "完全な無作為投票ではなく" in block - - -def test_game_rules_block_defines_grey_scale_with_reasons() -> None: - """グレスケ / スケール: not just an ordering — each position must carry a - reason grounded in speech/vote/divination/attack consistency.""" - block = _build_game_rules_block() - assert "グレスケ" in block - assert "スケール" in block - assert "白い順" in block - assert "黒い順" in block - assert "理由" in block + assert "day 1 朝の霊媒結果は存在しない" in block -def test_game_rules_block_contains_rope_calculation_formula() -> None: - """縄計算: remaining executions, with the standard heuristic formula - spelled out. 9-player village starts at 4 縄.""" +def test_game_rules_block_states_seer_count_is_n_plus_one() -> None: block = _build_game_rules_block() - assert "縄計算" in block - assert "floor((生存人数 - 1) / 2)" in block - assert "4縄" in block + assert "通算 N+1 件" in block -def test_game_rules_block_clarifies_white_is_not_village_confirmed() -> None: - """Every LLM seat must see the contract that 白判定 ≠ 村陣営確定 because - the madman reads white. This also cross-references the existing rule on - line ~55 (`狂人は黒判定されない`) — both coexist in the same block.""" - block = _build_game_rules_block() - assert "狂人も白に出る" in block - assert "村陣営確定ではない" in block - # Guard: the pre-existing rule that the new terminology must not override. - assert "狂人は黒判定されない" in block +# ---------------------- candidate-token rule ---------------------- -def test_game_rules_block_defines_kakushiro_with_madman_caveat() -> None: - """確白 = 進行役候補 but never absolute village-confirmation (狂人 reads - white). The caveat must be phrased as `言い切りすぎない` to avoid promoting - a 確白 to full 村陣営 status.""" +def test_game_rules_block_requires_candidate_token_strict_match() -> None: block = _build_game_rules_block() - assert "確白" in block - assert "進行役候補" in block - assert "「村陣営確定」と言い切りすぎない" in block + assert "席3 Alice" in block + assert "完全一致" in block -def test_game_rules_block_rejects_single_fake_black_as_kakukuro() -> None: - """確黒 requires multi-view corroboration. A single lone-black from a - potentially-fake seer is NOT 確黒 — this must be stated verbatim so LLMs - don't overweight single fake-seer blacks during 2-2 / counter-CO.""" - block = _build_game_rules_block() - assert "確黒" in block - assert "単独の偽占い候補から黒を出されただけでは確黒ではない" in block +# ---------------------- CO inference ---------------------- -def test_game_rules_block_defines_panda_as_both_white_and_black() -> None: - """パンダ = a seat that received BOTH white and black judgments (from - different COs). The phrase `白判定と黒判定の両方` is the canonical test - anchor.""" +def test_game_rules_block_distinguishes_topical_co_from_self_declaration() -> None: + """The classic 騙り誤読 trap — must distinguish someone *talking about* + a role-CO from someone actually *naming themselves* as that role.""" block = _build_game_rules_block() - assert "パンダ" in block - assert "白判定と黒判定の両方" in block + assert "「占いCOについて」" in block or "話題化" in block + assert "本人が「私は占い師です」" in block -def test_game_rules_block_defines_roller_synonyms_with_completion() -> None: - """Both spellings ローラー and ロラ must appear as common vocabulary, and - the completion rule must be restated here so it cross-references (not - contradicts) the existing 2-2 `原則として完走` rule.""" +def test_game_rules_block_treats_day1_single_co_as_truthy() -> None: block = _build_game_rules_block() - assert "ローラー" in block - assert "ロラ" in block - assert "開始したら原則完走" in block - - -def test_game_rules_block_defines_kimeuchi_and_hatan() -> None: - """決め打ち and 破綻 must both appear as first-class terminology bullets, - not just as word-in-sentence uses elsewhere in the block.""" - block = _build_game_rules_block() - assert "- 決め打ち:" in block - assert "- 破綻:" in block - - -def test_game_rules_block_defines_line_kakoi_minuchigiri() -> None: - """Wolf-pattern vocabulary (ライン / 囲い / 身内切り) reaches every seat as - neutral reading tools, framed as patterns to *recognize*, not execute.""" - block = _build_game_rules_block() - assert "- ライン:" in block - assert "- 囲い:" in block - assert "- 身内切り:" in block + assert "単独 CO" in block + assert "day 1 朝" in block + # The "single CO is truth-bias by default" rule + assert "真として扱う" in block or "真寄り" in block -def test_game_rules_block_kakoi_does_not_treat_madman_as_known_ally() -> None: - """囲い must not describe the madman as a wolf-known ally. The old phrasing - `仲間の狼 (や狂人)` implied that wolves know the madman's seat, which is - false in this bot.""" +def test_game_rules_block_rejects_sole_survivor_as_single_co() -> None: + """対抗 CO 履歴があった役職で残存 CO が 1 人になった時点では + 『単独 CO だから真』とは扱わない (狼が情報役を残した可能性).""" block = _build_game_rules_block() - assert "仲間の狼 (や狂人)" not in block - assert "狼は狂人位置を知らない" in block + assert "通算" in block + assert "現在生存中の占い CO は 1 人" in block -def test_game_rules_block_minuchigiri_does_not_treat_madman_as_known_ally() -> None: - """身内切り must not phrase the madman as a known wolf ally to cut. The - old phrasing `仲間 (別の人狼や狂人)` incorrectly included 狂人 as a known - teammate.""" +def test_game_rules_block_states_co_count_caps() -> None: block = _build_game_rules_block() - assert "仲間 (別の人狼や狂人)" not in block - assert "狼が仲間の狼" in block + # 占い 4 / 霊媒 2 / 騎士 2 の上限 + assert "占い 4" in block + assert "霊媒 2" in block + assert "騎士 2" in block -def test_game_rules_block_defines_vote_and_attack_traces_and_shiten() -> None: - """Behavioral-signal vocabulary: 票筋 / 噛み筋 / 視点漏れ must each have a - dedicated bullet defining the term, not just appear mid-sentence.""" +def test_game_rules_block_states_overflow_sum_three_marks_non_co_white() -> None: + """Sum of (CO count - 1) across 占/霊/騎 reaching 3 means the wolf + side has filled out the occult-CO slots, so non-CO seats are + village-side white-grade.""" block = _build_game_rules_block() - assert "- 票筋:" in block - assert "- 噛み筋:" in block - assert "- 視点漏れ:" in block + assert "超過分合計" in block or "対抗超過分" in block + assert "3" in block + assert "確白" in block -def test_game_rules_block_defines_endgame_vocabulary() -> None: - """Endgame terms: SG (scapegoat), GJ / 平和 (peaceful morning), PP - (power-play), RPP (random / lost PP) must all be defined so LLMs can - recognize and reason about late-game vote dynamics.""" - block = _build_game_rules_block() - assert "- SG" in block - assert "- GJ" in block - assert "平和" in block - assert "- PP" in block - assert "- RPP" in block - - -def test_game_rules_block_defines_advanced_guard_vocabulary() -> None: - """Advanced jinrō guard vocabulary reaches every seat via the shared - rules block so that public-log mentions of 鉄板護衛 / 変態護衛 / 捨て護衛 - / 護衛読み / 護衛誘導 / 連ガ無し / 狩人 are interpretable. Each term gets - its own dedicated bullet.""" - block = _build_game_rules_block() - assert "- 鉄板護衛:" in block - assert "- 変態護衛:" in block - assert "- 捨て護衛:" in block - assert "- 護衛読み:" in block - assert "- 護衛誘導:" in block - # 連ガ無し / 連続護衛不可 vocabulary bullet. - assert "- 連ガ無し" in block - assert "連続護衛不可" in block - # 狩人 / 狩 synonym mapping to 騎士. - assert "狩人" in block - assert "騎士と同じ意味" in block - - -def test_game_rules_block_clarifies_sutekogo_is_legal_target_choice_not_skip() -> None: - """The 捨て護衛 vocabulary bullet must explicitly say it is a - legal-candidate selection in this bot, not a skip / unsubmitted / - no-target action — otherwise an LLM might map 捨て護衛 to `intent=skip`.""" - block = _build_game_rules_block() - assert "捨て護衛" in block - assert "合法護衛候補" in block - assert "1 名を選ぶ行動" in block - # Negation phrasing — captures all three forbidden interpretations. - assert "未提出" in block - assert "skip ではない" in block - - -def test_game_rules_block_advanced_guard_vocab_no_wolf_coordination_leak() -> None: - """Defensive duplicate of the existing leak guard, focused on the new - advanced-guard bullets — they must not bleed wolf-coordination vocab into - the shared rules block. Bare `相方` (actor mode, partner-known) must be - absent; the inference noun `相方候補` (candidate) is allowed.""" +def test_game_rules_block_states_dead_seats_excluded_from_today_vote() -> None: block = _build_game_rules_block() - assert not re.search(r"相方(?!候補)", block), ( - "bare '相方' (actor mode) leaked into shared rules block" - ) - assert "襲撃先を揃える" not in block + assert "死亡席" in block + assert "本日の処刑対象" in block or "vote target" in block -def test_game_rules_block_terminology_has_no_wolf_coordination_leak() -> None: - """Shared terminology must not bleed wolf-coordination vocabulary into - non-wolf prompts. Bare `相方` (actor mode, partner-known) and the exact - phrase `襲撃先を揃える` are the two anchors. The inference noun `相方候補` - is allowed in the shared 2-wolf-pair-inference subsection.""" +def test_game_rules_block_introduces_two_wolf_pair_inference() -> None: block = _build_game_rules_block() - assert not re.search(r"相方(?!候補)", block), ( - "bare '相方' (actor mode) leaked into shared rules block — would break " - "test_ask_system_prompt_non_wolf_excludes_wolf_strategy" - ) - assert "襲撃先を揃える" not in block, ( - "wolf-coordination '襲撃先を揃える' leaked into shared rules block" - ) + assert "相方候補" in block + assert "推理用語" in block -def test_game_rules_block_states_fake_co_legality() -> None: - """Fake COs (seer/medium/knight) must stay within the bot's legal mechanics. - Shared rules call out illegal knight-guard patterns (self-guard, consecutive - guard, dead-seat guard) and forbid fabricating medium results on no-execution - days or seer results that contradict the public timeline.""" - block = _build_game_rules_block() - assert "実ルール上あり得る内容" in block - assert "過去に自分が出した結果と矛盾しないか" in block - assert "処刑がなかった日に霊媒結果を捏造しない" in block - assert "自分護衛" in block - assert "同一対象連続護衛" in block - assert "死亡済み対象への護衛" in block +# ---------------------- progression playbooks ---------------------- -def test_game_rules_block_states_day1_fake_seer_must_be_white() -> None: - """NIGHT_0 random-white provenance forces day-1 fake-seer first result - to be white. The shared rules must say so explicitly so any fake CO seat - sees it.""" +def test_game_rules_block_defines_3_1_2_2_2_1_1_2_formations() -> None: block = _build_game_rules_block() - assert "NIGHT_0" in block - assert "初回" in block - assert "day 1" in block - assert "必ず白を主張" in block + for formation in ("3-1", "2-2", "2-1", "1-2"): + assert formation in block -def test_game_rules_block_states_day1_black_claim_is_breakdown() -> None: - """A day-1 first-result-black claim contradicts NIGHT_0 timeline and must - be framed as breakdown so wolves/madmen don't try it.""" +def test_game_rules_block_includes_rope_calculation() -> None: block = _build_game_rules_block() - assert "初回結果を黒" in block - assert "NIGHT_0 タイムラインと矛盾" in block - assert "破綻" in block - assert "day 1 で初回黒主張はしない" in block + assert "縄" in block + assert "floor((生存数 - 1) / 2)" in block + assert "9 人村開始時 4 縄" in block -def test_game_rules_block_defers_fake_black_to_day_2_plus() -> None: - """Fake black is allowed only from day 2+, with timeline integrity checks.""" - block = _build_game_rules_block() - assert "偽占い師の黒結果主張は day 2 以降" in block - assert "前夜に占ったという想定" in block - assert "対抗 CO の発表内容と矛盾しない" in block +# ---------------------- forbidden glossary terms ---------------------- -def test_game_rules_block_contains_enthusiast_checklist() -> None: - """Shared rules must carry the 発言の根拠チェックリスト so every seat sees - CO history / divination history / vote history / attack pattern / rope - count / own information scope as the grounding menu, and must cap speech - to 1–2 concrete points rather than long internal monologue.""" +def test_game_rules_block_no_longer_includes_term_glossary_section() -> None: + """The compact rewrite drops the 30-term glossary (グレラン / グレスケ / + 鉄板護衛 / 変態護衛 / 捨て護衛 / 視点漏れ / SG / etc.) because the speech + LLM can't use those terms in `text` anyway and the decision LLM + already gets the structural facts elsewhere. This test catches an + accidental restoration.""" block = _build_game_rules_block() - assert "CO 履歴" in block - assert "判定履歴" in block - assert "投票履歴" in block - assert "噛み筋" in block - assert "縄数" in block - assert "情報範囲" in block - assert "1〜2 点" in block - - -# ------------------------------------------------------- strategy block -# A phrase that must appear in exactly one role's tips — keyed by role. Used -# both to assert per-role content AND to assert no cross-leak into other roles. -_ROLE_UNIQUE_PHRASES: dict[Role, str] = { - Role.WEREWOLF: "相方を露骨に庇いすぎない", - Role.MADMAN: "人狼位置を知っている前提で話してはならない", - Role.SEER: "判定履歴を時系列で一貫", - Role.MEDIUM: "処刑された相手が狂人でも", - Role.KNIGHT: "前夜と違う相手を選ぶ", - Role.VILLAGER: "CO 騙りは村陣営としては行わない", -} - - -@pytest.mark.parametrize(("role", "phrase"), list(_ROLE_UNIQUE_PHRASES.items())) -def test_strategy_block_for_each_role_contains_own_tips(role: Role, phrase: str) -> None: - block = _build_strategy_block(role) - assert phrase in block, f"{role.name}'s strategy missing its own phrase {phrase!r}" - - -@pytest.mark.parametrize("role", list(Role)) -def test_strategy_block_no_cross_role_leak(role: Role) -> None: - """For a given role, none of the OTHER roles' unique phrases may appear.""" - block = _build_strategy_block(role) - for other_role, other_phrase in _ROLE_UNIQUE_PHRASES.items(): - if other_role is role: - continue - assert other_phrase not in block, ( - f"{other_role.name}'s tip leaked into {role.name}'s strategy block" - ) - - -@pytest.mark.parametrize("role", list(Role)) -def test_wolf_coordination_vocabulary_only_in_wolf_strategy(role: Role) -> None: - """Bare `相方` (actor mode, partner-known) and `襲撃先を揃える` are wolf- - playbook vocabulary and must only appear in the werewolf strategy block — - never in any other role's tips. The inference noun `相方候補` is allowed - in non-wolf strategies as public-log inference language.""" - block = _build_strategy_block(role) - if role is Role.WEREWOLF: - assert "相方" in block - assert "襲撃先を揃える" in block - else: - assert not re.search(r"相方(?!候補)", block), ( - f"bare '相方' (actor mode) leaked into {role.name}" - ) - assert "襲撃先を揃える" not in block, ( - f"wolf coordination '襲撃先を揃える' leaked into {role.name}" - ) - - -def test_madman_strategy_prohibits_not_assumes_wolf_positions() -> None: - """The madman must NOT be told that wolf positions are known — only that - the opposite is true. The strategy text phrases this as a prohibition, so - the full prohibition phrase must be present and wolf-coordination tips - must still be absent (the madman does not get the wolves' playbook). - `相方候補` (inference noun) is allowed since the madman reasons from - public logs about who B might be if A is wolf.""" - block = _build_strategy_block(Role.MADMAN) - assert "人狼位置を知っている前提で話してはならない" in block - # No wolf-coordination playbook leaks: bare 相方 (known partner) absent; - # 相方候補 (public-log inference) allowed. - assert not re.search(r"相方(?!候補)", block) - assert "襲撃先を揃える" not in block - - -def test_medium_strategy_reinforces_madman_is_white_rule() -> None: - block = _build_strategy_block(Role.MEDIUM) - assert "人狼ではありませんでした" in block - assert "白結果だけでは村置き確定にはならない" in block - - -def test_medium_strategy_guards_against_seer_co_white_misread() -> None: - """The Medium must be told explicitly that medium-white on an executed - Seer-CO is not proof of a fake seer — the role-specific analog of the - shared rule in `_build_game_rules_block`.""" - block = _build_strategy_block(Role.MEDIUM) - assert "占い師 CO" in block - assert "霊媒結果が白" in block - assert "占い師 CO 偽の証明ではない" in block - - -def test_medium_strategy_separates_real_seer_from_non_wolf_fake() -> None: - """When medium-white lands on a Seer-CO, the medium should partition the - hypothesis space: real seer vs. non-wolf fake (madman, etc.).""" - block = _build_strategy_block(Role.MEDIUM) - assert "真占い師だった可能性" in block - assert "狂人" in block - assert "非狼" in block - - -def test_medium_strategy_routes_seer_co_suspicion_through_corroboration() -> None: - """To suspect a Seer-CO, medium must cite non-medium-result evidence: - counter CO, divination breakdown, speech timeline, votes, attack result, - death timing — NOT the white medium result itself.""" - block = _build_strategy_block(Role.MEDIUM) - assert "対抗 CO" in block - assert "発言時系列" in block - assert "襲撃結果" in block - assert "死亡タイミング" in block - - -def test_medium_strategy_includes_three_seer_co_elimination_inference() -> None: - """霊媒師は 3占いCO 盤面で 2 人非狼確定にできるかを毎日整理する。 - 霊媒白を非狼確定として数えてよいのは、自分の霊媒 CO 側が真寄りと - 十分読める段階で、霊媒結果以外の整合も併せて説明できる場合に限る。""" - block = _build_strategy_block(Role.MEDIUM) - assert "占い師 CO が 3 人" in block - assert "自分の霊媒 CO 側が真寄りと十分読める段階" in block - assert "霊媒結果以外の整合" in block - assert "残る占い師 CO を確定黒級として処刑提案・投票誘導" in block - - -def test_knight_strategy_advises_protection_success_co() -> None: - """On a peaceful morning (no casualty), the knight should consider CO-ing - with the guard target attached — and must always attach the guard target - when CO-ing on a protection-success claim.""" - block = _build_strategy_block(Role.KNIGHT) - assert "平和な朝" in block - assert "護衛成功" in block - assert "護衛先を添えて" in block - # Existing guidance is preserved. - assert "前夜と違う相手を選ぶ" in block - - -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_prioritizes_seer_fake_on_day1(role: Role) -> None: - """Wolf and madman both get guidance to consider faking seer on day 1.""" - block = _build_strategy_block(role) - assert "day 1" in block - assert "占い師騙り" in block - - -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_switches_to_medium_or_knight_fake_if_countered(role: Role) -> None: - """If a counter-seer CO is already out, both wolf and madman should - consider medium or knight fake on day 2+, with corresponding night-ability - results attached.""" - block = _build_strategy_block(role) - assert "対抗占い師" in block - assert "day 2" in block - assert "霊媒師騙り" in block - assert "騎士騙り" in block - assert "夜に能力を使った想定" in block + # A few tell-tale glossary headers from the old version + assert "グレラン: グレーから各自" not in block + assert "鉄板護衛: 真寄り情報役" not in block + assert "変態護衛: セオリー上の本命" not in block + assert "視点漏れ: ある役職視点" not in block -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_warns_against_over_faking(role: Role) -> None: - """Both wolf and madman are warned that piling on fake COs (6+) confirms - non-CO seats as white — at that point all wolves+madman have CO'd.""" - block = _build_strategy_block(role) - assert "6 人以上" in block - assert "騙りすぎ" in block - - -def test_madman_fake_strategy_has_no_wolf_coordination_vocabulary() -> None: - """The madman's new fake-CO guidance must not introduce wolf-coordination - vocabulary (bare `相方`, `襲撃先を揃える`) — the madman does not know the - real wolf positions. `相方候補` (public-log inference) is allowed.""" - block = _build_strategy_block(Role.MADMAN) - assert not re.search(r"相方(?!候補)", block) - assert "襲撃先を揃える" not in block - # Existing prohibition phrase still present. - assert "人狼位置を知っている前提で話してはならない" in block - - -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_describes_conditional_seer_fake(role: Role) -> None: - """Day-1 seer fake is offered as a *conditional* option, not an - unconditional default — the wording must contain gating words - (無条件 / 潜伏) that signal the LLM to decide based on board state.""" - block = _build_strategy_block(role) - assert "無条件" in block - assert "潜伏" in block - if role is Role.WEREWOLF: - assert "相方が危険位置" in block - else: - # Madman variant: no wolf-coordination vocab (bare 相方 / 襲撃先を揃える), - # but references CO creep. 相方候補 (inference) is allowed. - assert "複数の占い師 CO" in block - assert not re.search(r"相方(?!候補)", block) - assert "襲撃先を揃える" not in block - - -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_anchors_day1_first_result_to_white(role: Role) -> None: - """Both wolf and madman fake-seer guidance must anchor the day-1 first - divination result to white per the NIGHT_0 timeline.""" - block = _build_strategy_block(role) - assert "NIGHT_0 ランダム白" in block - assert "必ず白を主張" in block +# ========================================================================= +# strategy block — leakage & role-scope invariants +# ========================================================================= -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_prohibits_day1_black_claim(role: Role) -> None: - """Both wolf and madman must be told day-1 first-result black breaks the - NIGHT_0 timeline and must not be claimed.""" - block = _build_strategy_block(role) - assert "初日に黒を出す主張" in block - assert "破綻" in block - assert "絶対にしない" in block +_ALL_ROLES = ( + Role.WEREWOLF, + Role.MADMAN, + Role.SEER, + Role.MEDIUM, + Role.KNIGHT, + Role.VILLAGER, +) -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_defers_black_call_to_day_2_plus(role: Role) -> None: - """Both wolf and madman defer fake black to day 2+ with night-divination - framing.""" - block = _build_strategy_block(role) - assert "黒出しは day 2 以降" in block - assert "前夜に占ったという想定" in block +def test_strategy_block_renders_for_every_role() -> None: + for role in _ALL_ROLES: + block = _build_strategy_block(role) + assert block + # Each role file starts with a "# 〜 戦略" markdown heading; + # the loader strips it. The body should be plain bullets. + assert not block.startswith("# ") -def test_wolf_day1_white_target_integrates_partner_and_attack_pattern() -> None: - """Wolf-only: day-1 white-target selection must integrate partner position, - framing risk, and attack-pattern coordination.""" +def test_werewolf_strategy_includes_partner_coordination_vocabulary() -> None: block = _build_strategy_block(Role.WEREWOLF) - assert "白先選び" in block - assert "相方の位置" in block - assert "囲いリスク" in block - assert "噛み筋" in block - assert "襲撃計画" in block + # The wolf knows its partner — vocabulary that only makes sense inside + # the wolf seat. + assert "人狼チャット" in block + assert "襲撃先を 1 人に揃える" in block + # GJ rebite rule + assert "GJ" in block and "再噛み" in block -def test_madman_day_2_plus_black_still_carries_misfire_awareness() -> None: - """Madman-only: even when deferred to day 2+, the black-out misfire risk - persists because the madman never knows real wolf positions.""" - block = _build_strategy_block(Role.MADMAN) - assert "誤爆リスクは day 2 以降の黒出しでも常に残る" in block - # Existing misfire guidance must still be present (no regression). - assert "誤爆リスク" in block - assert "白先が本物の狼とは限らない" in block - - -def test_seer_strategy_covers_proactive_and_counter_co() -> None: - """Seer must have explicit guidance on early CO when no seer CO has - appeared, counter-CO against a fake seer with time-ordered history - disclosure, and the black-pull CO procedure.""" - block = _build_strategy_block(Role.SEER) - assert "まだ占い師 CO が出ていない" in block - assert "対抗 CO" in block - assert "時系列で公開" in block - assert "黒を引いた場合" in block - assert "単独真として扱わせてしまう" in block - - -def test_seer_strategy_includes_three_seer_co_elimination_for_targeting() -> None: - """占い師は 3占いCO・自分以外の 2 人が非狼確定の盤面で、夜の占い対象選びで - 残る占い師 CO 位置の確認や相方候補ペア仮説を崩す方向を優先する。 - 非狼確定として数える根拠は説明可能なものに限る。""" - block = _build_strategy_block(Role.SEER) - assert "占い師 CO が 3 人" in block - assert "自分以外の 2 人が公開情報上で非狼確定" in block - assert "相方候補ペア仮説を崩す方向" in block - assert "霊媒結果・襲撃死・CO 破綻など説明可能な根拠に限る" in block - - -def test_medium_strategy_covers_post_execution_publication_and_counter_co() -> None: - """Medium must publish results the day after an execution and must run - counter-CO against a fake medium with time-ordered history framing while - acknowledging self-roller vulnerability.""" - block = _build_strategy_block(Role.MEDIUM) - assert "処刑が発生した翌日" in block - assert "対抗霊媒" in block - assert "ローラー" in block - assert "巻き込まれる可能性" in block - - -def test_knight_strategy_covers_endgame_and_legal_guard_history() -> None: - """Knight must cover endgame / about-to-be-hung CO timing AND must - explicitly constrain the guard-diary to the bot's legal guard rules - (no self-guard, no consecutive guard, no dead-seat guard).""" - block = _build_strategy_block(Role.KNIGHT) - assert "終盤" in block - assert "吊られそう" in block - assert "護衛履歴を日付順" in block - assert "自分護衛" in block - assert "同じ相手の連続護衛" in block - assert "死亡済み" in block - - -def test_knight_strategy_covers_multi_axis_guard_evaluation() -> None: - """Knight strategy must teach a 5-axis comparison rather than fixing a - single guard target. The five axis tokens must all appear so the LLM can - weigh candidates rather than always defaulting to the truth-leaning info - role.""" - block = _build_strategy_block(Role.KNIGHT) - assert "護衛価値" in block - assert "襲撃されやすさ" in block - assert "GJ" in block - assert "次夜" in block - assert "連続護衛不可" in block - assert "説明可能性" in block - - -def test_knight_strategy_distinguishes_tetsuban_vs_sutekogo() -> None: - """Knight strategy must define both 鉄板護衛 and 捨て護衛 with their - bot-specific framing: 鉄板護衛 prioritized when key roles are at risk, - 捨て護衛 always a legal-candidate choice (not skip).""" - block = _build_strategy_block(Role.KNIGHT) - assert "鉄板護衛" in block - assert "捨て護衛" in block - # 鉄板護衛 bullet must frame it as the priority when key roles are at risk. - assert "重要役職" in block - assert "優先" in block - # 捨て護衛 bullet must explicitly say it is a legal-candidate, 1-name - # choice — never skip / unsubmitted / no-target. - assert "合法候補" in block - assert "1 名" in block - assert "skip" in block - - -def test_knight_strategy_warns_against_sutekogo_overuse() -> None: - """The knight must not adopt 捨て護衛 as a default. The strategy must - contain a gating sentence that prefers 鉄板護衛 when key roles are at - immediate risk and explicitly forbids 捨て護衛 as a default action.""" - block = _build_strategy_block(Role.KNIGHT) - assert "毎夜の既定行動にしない" in block - - -def test_knight_strategy_includes_three_seer_co_elimination_for_guard_choice() -> None: - """騎士は 3占いCO・2 人非狼確定が成立したら、残る 1 人からの黒判定で - 守るべき真情報役・確白寄りと、次夜の本命護衛余地を比較する。 - 前提が崩れたら護衛方針も再整理する。""" - block = _build_strategy_block(Role.KNIGHT) - assert "占い師 CO が 3 人" in block - assert "次夜の本命護衛余地" in block - assert "前提が崩れたら護衛方針も再整理" in block - - -def test_villager_strategy_anchors_in_checklist() -> None: - """Villager must still forbid CO fakes AND must anchor speech in the - shared enthusiast checklist (CO / divination / vote histories).""" - block = _build_strategy_block(Role.VILLAGER) - assert "CO 騙りは村陣営としては行わない" in block - assert "CO 履歴" in block - assert "判定履歴" in block - assert "1〜2 点" in block - - -def test_villager_strategy_prohibits_villager_co() -> None: - """The villager must be explicitly forbidden from declaring '村人CO' / - '素村CO' / '普通の村人です' / '役職は村人です' as a trust-buy. The block - must also offer the alternative stance: stay non-CO, reason from public - information.""" - block = _build_strategy_block(Role.VILLAGER) - # Forbidden phrases the villager must not say. - assert "村人CO" in block - assert "素村CO" in block - assert "普通の村人です" in block - assert "役職は村人です" in block - # Reason: villagers have no ability result so CO carries no proof. - assert "村人は能力結果を持たない" in block - # Alternative stance the villager should take instead. - assert "非 CO の灰" in block - assert "役職 CO はない" in block - - -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN, Role.SEER, Role.MEDIUM, Role.KNIGHT]) -def test_villager_co_prohibition_does_not_leak_to_other_roles(role: Role) -> None: - """The villager-CO prohibition is scoped to the villager strategy. Other - roles must not see '村人CO' / '素村CO' wording — the wolf and madman fake - seer/medium/knight, never villager; the real seer/medium/knight have their - own CO playbooks. Cross-leak would either confuse fake-CO planning or - suppress legitimate role-CO.""" +@pytest.mark.parametrize( + "role", + [Role.MADMAN, Role.SEER, Role.MEDIUM, Role.KNIGHT, Role.VILLAGER], +) +def test_non_wolf_strategy_excludes_partner_coordination_vocabulary( + role: Role, +) -> None: + """Wolf-only coordination concepts (人狼チャット, 襲撃先を1人に揃える, GJ + rebite, 視点漏れ as a self-imposed rule) must not appear in any non-wolf + role's strategy. The non-wolf side may still use `相方候補` as an + inference-only term — it appears in 公開ログからの 2 人狼仮説 — so we + only exclude vocabulary that names the actual partner relationship.""" block = _build_strategy_block(role) - assert "村人CO" not in block - assert "素村CO" not in block + assert "人狼チャット" not in block + assert "襲撃先を 1 人に揃える" not in block + assert "再噛み" not in block -def test_villager_strategy_includes_three_seer_co_elimination_inference() -> None: - """村人は 3占いCO・2非狼確定の盤面で、残る占い師 CO 位置を投票・発言・ - 進行提案で固定配役上の狼位置として扱う方針を持つ。非狼確定の数え方の - 厳格さと前提崩壊時の再整理も明示。""" - block = _build_strategy_block(Role.VILLAGER) - assert "占い師 CO が 3 人" in block - assert "残る占い師 CO 位置を投票・発言・進行提案" in block - assert "印象白や信用未確定 CO の白判定では数えず" in block - assert "前提が崩れた瞬間" in block +def test_madman_strategy_prohibits_assuming_known_wolf_positions() -> None: + """The madman is wolf-faction but doesn't know who the wolves are. + The strategy must spell that out so the LLM doesn't fabricate + inside knowledge.""" + block = _build_strategy_block(Role.MADMAN) + assert "本物の人狼位置を知っている前提で話してはならない" in block -@pytest.mark.parametrize("role", list(Role)) -def test_three_seer_co_elimination_role_framings_do_not_cross_leak(role: Role) -> None: - """役職別 framing 文言が他 role の strategy block へ流れ込まないこと。 - 共通ルール本体は `_build_game_rules_block` 側にあるため、strategy 側の - 役職別フレーズが横滑りしてはいけない。""" - block = _build_strategy_block(role) - villager_phrase = "残る占い師 CO 位置を投票・発言・進行提案" - seer_phrase = "相方候補ペア仮説を崩す方向" - medium_phrase = "残る占い師 CO を確定黒級として処刑提案・投票誘導" - knight_phrase = "次夜の本命護衛余地" - if role is not Role.VILLAGER: - assert villager_phrase not in block, ( - f"villager-framing of 3-seer-CO elimination leaked into {role.name}" - ) - if role is not Role.SEER: - assert seer_phrase not in block, ( - f"seer-framing of 3-seer-CO elimination leaked into {role.name}" - ) - if role is not Role.MEDIUM: - assert medium_phrase not in block, ( - f"medium-framing of 3-seer-CO elimination leaked into {role.name}" - ) - if role is not Role.KNIGHT: - assert knight_phrase not in block, ( - f"knight-framing of 3-seer-CO elimination leaked into {role.name}" - ) +def test_madman_prohibition_does_not_leak_to_other_roles() -> None: + """The madman-only prohibition phrase must stay in madman.md and + nowhere else — accidental cross-import would re-frame other roles + as 'you don't know who the wolves are' which is misleading for + actual seer/medium players.""" + needle = "本物の人狼位置を知っている前提で話してはならない" + for role in _ALL_ROLES: + if role is Role.MADMAN: + continue + assert needle not in _build_strategy_block(role) -# ----------- 3 役職横断 CO 数・対抗 CO 超過分 推理: role 別運用面の追加 -# 共通ルール側の数学的整理 (CO 数 - 1 を超過分として 3 役職分集計、合計 3 で -# 非 CO 位置が確白級) は `_build_game_rules_block` で全 seat に届く。 -# 各 role strategy には「その役職ならどう使うか」だけを短く追加する。 -def test_villager_strategy_uses_co_overflow_inference() -> None: - """村人は公開ログから占い師・霊媒師・騎士の CO 数と対抗 CO 超過分を - 毎日整理し、超過分合計 3 なら能力役職 CO していない位置を村陣営の - 確白級として扱い、投票先を CO 群に絞る。""" +def test_villager_strategy_forbids_self_role_co() -> None: + """Villagers must not pretend to have private info — including the + common trap of self-claiming '村人CO / 素村CO'.""" block = _build_strategy_block(Role.VILLAGER) - assert "対抗 CO 超過分を毎日整理する" in block - assert "超過分合計が 3 に達したら能力役職 CO していない位置を村陣営の確白級" in block - assert "投票先を CO 群に絞る" in block - # 0〜2 と 4 以上の境界条件も村人 framing に含まれていること。 - assert "超過分合計が 0〜2 のうちは" in block - assert "超過分合計が 4 以上に見えたら" in block + assert "村人CO" in block or "素村CO" in block + assert "信用を取ろうとしない" in block or "CO しても証明にはならない" in block -def test_seer_strategy_avoids_wasting_divination_on_non_co_white() -> None: - """占い師は超過分合計 3 で非 CO 位置が確白級になった場合、そこを - 無駄占いせず、対抗 CO 群やまだ確定しない位置を優先して占う。""" +def test_seer_strategy_requires_co_and_results_at_first_speak() -> None: block = _build_strategy_block(Role.SEER) - assert "対抗 CO 超過分合計が 3 に達して能力役職 CO していない位置が非 CO 確白級" in block - assert "無駄占い" in block - assert "対抗 CO 群やまだ確定しない位置を優先して占う" in block + assert "発言の番が回ってきたら" in block + assert "CO + 判定結果を発表" in block or "CO + 結果" in block -def test_medium_strategy_updates_co_inference_via_medium_result() -> None: - """霊媒師は霊媒結果で CO 数推理を更新する。処刑された CO 者が黒なら - 対抗 CO 群内の狼数を絞り、白なら真役職または狂人の可能性を分け、 - 非 CO 確白の前提が保たれるかを確認する (霊媒白=非狼のみのルール維持)。""" +def test_medium_strategy_states_day1_morning_has_no_result() -> None: block = _build_strategy_block(Role.MEDIUM) - assert "霊媒結果は対抗 CO 超過分の CO 数推理を更新する材料" in block - assert "対抗 CO 群内の狼数を絞り" in block - # 霊媒白は非狼だけを示す既存ルールとの整合: 真役職 / 狂人 を分ける。 - assert "白なら真役職または狂人の可能性を分け" in block - assert "非 CO 確白の前提が保たれるか" in block + assert "day 1 朝の霊媒結果は構造的に存在しない" in block -def test_knight_strategy_protects_non_co_certified_white() -> None: - """騎士は超過分合計 3 で生まれた非 CO 確白級や、単独で対抗のない真寄り - 情報役を護衛価値が高い対象として扱う。連続護衛不可・襲撃読み・ - CO 時の説明可能性も合わせて判断。""" +def test_knight_strategy_forbids_self_and_dead_target_guards() -> None: block = _build_strategy_block(Role.KNIGHT) - assert "対抗 CO 超過分合計 3 で生まれた非 CO 確白級" in block - assert "単独で対抗のない真寄り情報役は護衛価値が高い" in block - # 既存制約 (連続護衛不可・襲撃読み・CO 時の説明可能性) との整合。 - assert "連続護衛不可" in block - assert "襲撃読み" in block - assert "CO 時の説明可能性" in block - - -def test_werewolf_strategy_acknowledges_overcounter_risk() -> None: - """人狼は能力役職 CO を増やしすぎると、対抗 CO 超過分合計が 3 に達した - 時点で非 CO 位置が村陣営の確白級として扱われ、処刑候補が CO 群に - 集中するリスクを認識する。騙りに出るか潜伏するかは CO 数と残り縄を - 見て、相方と整合する形で選ぶ (相方語彙は wolf 専用)。""" - block = _build_strategy_block(Role.WEREWOLF) - assert "対抗 CO 超過分" in block - assert "超過分合計が 3 に達した時点で" in block - assert "村陣営の確白級として扱われ" in block - assert "処刑候補が CO 群に集中する" in block - assert "相方と整合する形で選ぶ" in block - - -def test_madman_strategy_acknowledges_overcounter_risk_without_partner_vocab() -> None: - """狂人は同じリスクを公開情報視点で認識する。本物の人狼位置を - 知っている前提や bare `相方` (actor mode) を使ってはいけない。 - `相方候補` (公開ログからの推理用語) は引き続き許容。""" - block = _build_strategy_block(Role.MADMAN) - assert "対抗 CO 超過分" in block - assert "超過分合計が 3 に達した時点で" in block - assert "処刑候補が CO 群に集中するリスクを認識する" in block - # 公開情報視点であることが明示されていること。 - assert "公開情報の各 CO 数と残り縄から判断する" in block - # Wolf-coordination 語彙の漏れがないこと (既存 leak guard と同形)。 - assert not re.search(r"相方(?!候補)", block), ( - "bare '相方' (actor mode) leaked into madman CO-overflow addition" - ) - assert "襲撃先を揃える" not in block - # 既存 prohibition 文言は残ること。 - assert "人狼位置を知っている前提で話してはならない" in block - - -@pytest.mark.parametrize("role", list(Role)) -def test_co_overflow_role_framings_do_not_cross_leak(role: Role) -> None: - """各 role の CO 超過分 framing 文言が他 role の strategy block へ - 流れ込まないこと。共通ルールの数学的整理は `_build_game_rules_block` - 側にあるため、strategy 側の役職別フレーズが横滑りしてはいけない。""" - block = _build_strategy_block(role) - villager_phrase = "投票先を CO 群に絞る" - seer_phrase = "非 CO 確白級になった場合、そこを無駄占いせず" - medium_phrase = "霊媒結果は対抗 CO 超過分の CO 数推理を更新する材料" - knight_phrase = "対抗 CO 超過分合計 3 で生まれた非 CO 確白級" - werewolf_phrase = "相方と整合する形で選ぶ" - madman_phrase = "公開情報の各 CO 数と残り縄から判断する" - if role is not Role.VILLAGER: - assert villager_phrase not in block, ( - f"villager-framing of CO-overflow inference leaked into {role.name}" - ) - if role is not Role.SEER: - assert seer_phrase not in block, ( - f"seer-framing of CO-overflow inference leaked into {role.name}" - ) - if role is not Role.MEDIUM: - assert medium_phrase not in block, ( - f"medium-framing of CO-overflow inference leaked into {role.name}" - ) - if role is not Role.KNIGHT: - assert knight_phrase not in block, ( - f"knight-framing of CO-overflow inference leaked into {role.name}" - ) - if role is not Role.WEREWOLF: - assert werewolf_phrase not in block, ( - f"werewolf-framing of CO-overflow inference leaked into {role.name}" - ) - if role is not Role.MADMAN: - assert madman_phrase not in block, ( - f"madman-framing of CO-overflow inference leaked into {role.name}" - ) - - -# --------------------------------- wolf night-attack guard-aware vocabulary -# Wolf-only tactical phrases for night-attack reasoning. They must appear in -# the werewolf strategy and never leak into another role's strategy or into -# any non-WOLF_ATTACK night-action task. -_WOLF_ATTACK_ONLY_PHRASES = ( - "騎士候補を噛む", - "護衛リスクを読んで噛む", -) - - -def test_werewolf_strategy_includes_attack_evaluation_axes() -> None: - """The wolf gets the 4-axis comparison (value / guard-likelihood / - knight-candidacy / partner-fit) plus a GJ-risk hook. These are the new - anchors the LLM uses to weigh each candidate before locking a target.""" - block = _build_strategy_block(Role.WEREWOLF) - assert "襲撃価値" in block - assert "護衛されやすさ" in block - assert "騎士候補度" in block - assert "GJ" in block or "護衛リスク" in block - - -def test_werewolf_strategy_includes_attack_approach_taxonomy() -> None: - """The wolf must be able to label its kami as one of the five recognized - approaches so the per-day narrative stays consistent.""" - block = _build_strategy_block(Role.WEREWOLF) - for phrase in ("情報役噛み", "白位置噛み", "意見噛み", "騎士探し", "SG 残し"): - assert phrase in block, f"wolf strategy missing approach token {phrase!r}" - - -def test_werewolf_strategy_preserves_partner_convergence() -> None: - """Adding guard-reading content must not erase the existing partner-align - rule. `相方` and the `1 人に揃える` directive must both still be present.""" - block = _build_strategy_block(Role.WEREWOLF) - assert "相方" in block - assert "1 人に揃える" in block - - -def test_werewolf_strategy_disclaims_real_role_inference() -> None: - """Knight-candidate inference must be framed as public-log推定, not as a - claim about the actual role table. Guards against future copy-paste that - would imply the wolf knows the real knight seat.""" - block = _build_strategy_block(Role.WEREWOLF) - assert "実役職を知っている前提で断言してはならない" in block - assert "公開情報からの推定" in block - - -@pytest.mark.parametrize( - "role", [Role.MADMAN, Role.SEER, Role.MEDIUM, Role.KNIGHT, Role.VILLAGER] -) -def test_wolf_attack_only_vocabulary_never_in_non_wolf_strategy(role: Role) -> None: - """The new tactical phrases are wolf-private. Any leak (e.g. someone copies - the wolf bullet into the knight strategy by mistake) must trip this guard.""" - block = _build_strategy_block(role) - for phrase in _WOLF_ATTACK_ONLY_PHRASES: - assert phrase not in block, ( - f"wolf-only attack vocab {phrase!r} leaked into {role.name}" - ) + assert "自分護衛" in block + assert "死亡対象護衛" in block -# ---------------------------------------------------- night-action task block -def test_task_night_action_wolf_attack_includes_evaluation_checklist() -> None: - """The WOLF_ATTACK night task hands the LLM the 4-axis checklist inline so - even an LLM that ignored the strategy block sees the rubric on the action - turn.""" - text = task_night_action(SubmissionType.WOLF_ATTACK, ["席1 A", "席2 B"]) - assert "襲撃価値" in text - assert "護衛されやすさ" in text - assert "騎士候補度" in text - assert "翌日の説明しやすさ" in text - assert "騎士探し" in text - # Existing partner-align nudge is preserved. - assert "強い反対理由がなければ" in text - - -@pytest.mark.parametrize("kind", [SubmissionType.SEER_DIVINE, SubmissionType.KNIGHT_GUARD]) -def test_task_night_action_non_wolf_excludes_attack_checklist( - kind: SubmissionType, +@pytest.mark.parametrize("role", _ALL_ROLES) +def test_every_role_strategy_carries_attack_victim_non_wolf_hard_fact( + role: Role, ) -> None: - """The seer-divine / knight-guard tasks must not inherit the wolf attack - checklist — only WOLF_ATTACK gets it.""" - text = task_night_action(kind, ["席1 A", "席2 B"]) - assert "襲撃価値" not in text - assert "護衛されやすさ" not in text - assert "騎士候補度" not in text - assert "騎士探し" not in text - assert "翌日の説明しやすさ" not in text - - -def test_task_night_action_seer_divine_includes_targeting_checklist() -> None: - """SEER_DIVINE task hands the seer a targeting rubric inline. The new axes - push the LLM beyond random selection from legal candidates and toward - high-information targets (counter-CO whites, vote oddities, gray-narrowing - positions). These tokens must reach the LLM via the task block even if the - role strategy block is somehow ignored.""" - text = task_night_action(SubmissionType.SEER_DIVINE, ["席1 A", "席2 B"]) - # Positive — seer-targeting axes anchors must be present. - assert "占い価値" in text - assert "灰を狭める" in text - assert "対抗 CO" in text - assert "囲い候補" in text - assert "投票" in text - assert "白でも黒でも情報が落ちる" in text - # Negative — none of the wolf-task forbidden substrings may appear here. - # (The parametrized exclusion test below also covers this; this focused - # negative makes the contract explicit at the SEER_DIVINE call site.) - assert "襲撃価値" not in text - assert "護衛されやすさ" not in text - assert "騎士候補度" not in text - assert "翌日の説明しやすさ" not in text - assert "騎士探し" not in text - - -def test_task_night_action_knight_guard_includes_evaluation_checklist() -> None: - """KNIGHT_GUARD task hands the knight a 5-axis checklist inline so even an - LLM that ignored the strategy block sees the rubric on the action turn. - Critical: the knight task must NOT reuse the wolf-attack vocabulary - (襲撃価値 / 護衛されやすさ / 騎士候補度 / 翌日の説明しやすさ / 騎士探し) — - those would trip the existing leak guard. The knight uses its own - parallel vocabulary (鉄板護衛すべき価値 / 今夜実際に噛まれそうか / - 次夜に同じ相手を守れないリスク / 捨て護衛で本命護衛余地を残す価値).""" - text = task_night_action(SubmissionType.KNIGHT_GUARD, ["席1 A", "席2 B"]) - # Positive — knight checklist anchors must reach the LLM via the task block. - assert "鉄板護衛" in text - assert "捨て護衛" in text - assert "次夜" in text - assert "GJ" in text - # 捨て護衛 must be framed as a legal-candidate, 1-name selection (not skip). - assert "1 名" in text - assert "skip" in text - # Negative — none of the wolf-task forbidden substrings may appear here. - assert "襲撃価値" not in text - assert "護衛されやすさ" not in text - assert "騎士候補度" not in text - assert "翌日の説明しやすさ" not in text - assert "騎士探し" not in text - - -# ----------------------------------------------------- wolf-chat task block -def test_task_wolf_chat_includes_guard_and_knight_candidate_reasons() -> None: - """Wolf-chat coordination must elicit *why* (guard risk, knight-candidate, - approach taxonomy, agree/disagree) — not just *who*. The 1人に揃える - directive and 80–150 char budget remain so partners still converge.""" - text = task_wolf_chat(["席3 P"], ["席1 A", "席2 B"]) - assert "護衛リスク" in text - assert "騎士候補" in text - assert "賛否" in text - assert "1 人に揃える" in text - assert "80〜150 字" in text - # All five approach tokens must be offered as labels for the reason. - for phrase in ("情報役噛み", "白位置噛み", "意見噛み", "騎士探し", "SG 残し"): - assert phrase in text, f"wolf-chat task missing approach token {phrase!r}" - - -# ----------------------------------------------------- daytime speech task -def test_task_daytime_speech_default_omits_first_round_rule() -> None: - """Default call (no `discussion_round`, no `role`) — runoff and any - backward-compat caller — must NOT include the day-2+ first-round mandatory - result rule, NOR the day-1 wolf-side fake-CO selection nudge.""" - text = task_daytime_speech(2) - assert "1 巡目" not in text - assert "前夜の能力結果" not in text - assert "占い師騙り・霊媒師騙り・潜伏の 3 択" not in text - - -def test_task_daytime_speech_day1_round1_omits_day2_rule() -> None: - """Day 1 round 1 must NOT include the day-2+ rule. Day 1 first results are - already constrained by NIGHT_0 random white / day-1 first white in the - game rules block; the day-2+ rule does not apply on day 1. The default - (no `role`) call must also not include the wolf-side fake-CO selection - nudge — only WEREWOLF / MADMAN callers see it.""" - text = task_daytime_speech(1, discussion_round=1) - assert "1 巡目" not in text - assert "前夜の能力結果" not in text - assert "占い師騙り・霊媒師騙り・潜伏の 3 択" not in text - - -def test_task_daytime_speech_day2_round2_omits_first_round_rule() -> None: - """Round 2 of day 2 must NOT carry the 'must attach prior-night results' - imperative. Round 2 is for follow-ups; the first-round publication - requirement is round-1 only.""" - text = task_daytime_speech(2, discussion_round=2) - assert "1 巡目" not in text - assert "前夜の能力結果" not in text - - -def test_task_daytime_speech_day2_round1_publishes_prior_night_results() -> None: - """Day 2+ round 1: CO'd or about-to-CO seer/medium/knight LLM must attach - prior-night ability results. The task text must call out all three roles - and their respective result formats so the LLM can match its own role.""" - text = task_daytime_speech(2, discussion_round=1) - # Headline tokens. - assert "day 2 以降" in text - assert "1 巡目" in text - assert "前夜" in text - assert "能力結果" in text - # All three info roles must be addressed. - assert "占い師" in text - assert "霊媒師" in text - assert "騎士" in text - # Result format anchors. - assert "白/黒" in text - assert "前日処刑者" in text - assert "結果なし" in text - assert "合法な護衛履歴" in text - assert "平和な朝" in text - # Trust-pressure framing — withholding lowers credibility. - assert "信用低下" in text or "破綻" in text - # Wolf-coordination vocabulary must NOT appear in this task block (every - # role sees this task text). Bare 相方 (actor mode) absent; 相方候補 - # (public-log inference noun) is allowed in the daytime speech task. - assert not re.search(r"相方(?!候補)", text) - assert "襲撃先を揃える" not in text - - -@pytest.mark.parametrize("day", [3, 5]) -def test_task_daytime_speech_day_n_round1_publishes_prior_night_results(day: int) -> None: - """The 'day 2 以降' rule applies on day 3, 5, etc. as well — verifies the - `day_number >= 2` gate, not a literal day-2 check.""" - text = task_daytime_speech(day, discussion_round=1) - assert "day 2 以降" in text - assert "1 巡目" in text - assert "能力結果" in text - - -def test_task_daytime_speech_base_contract_preserved() -> None: - """Every variant must still surface the base intent/skip + 80–300 char - contract — neither the day-2+ branch nor the default branch may erase it.""" - for kwargs in ( - {"day_number": 1}, - {"day_number": 2}, - {"day_number": 1, "discussion_round": 1}, - {"day_number": 2, "discussion_round": 1}, - {"day_number": 2, "discussion_round": 2}, - ): - text = task_daytime_speech(**kwargs) - assert "intent=speak" in text - assert "intent=skip" in text - assert "80〜300 字" in text - - -# ------------------------------------------------ day 2+ round 1 fake publication -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_day2_round1_publishes_prior_night_results(role: Role) -> None: - """Both wolf and madman, when faking seer/medium/knight on day 2+, must be - instructed to publish the prior-night ability result on the day-2+ round-1 - speech. Headline tokens (1 巡目, 前夜, 能力結果) must appear.""" - block = _build_strategy_block(role) - assert "day 2 以降" in block - assert "1 巡目" in block - assert "前夜" in block - assert "能力結果" in block - - -def test_werewolf_fake_strategy_integrates_with_attack_plan() -> None: - """Wolf-only: the fake-result guidance must integrate with the wolf's - private knowledge (partner position, framing risk, attack pattern) and - cross-checks (medium results, vote history, legal guard history).""" - block = _build_strategy_block(Role.WEREWOLF) - assert "相方" in block - assert "囲い" in block - assert "噛み筋" in block - assert "霊媒結果" in block - assert "合法な護衛履歴" in block - - -def test_madman_fake_strategy_acknowledges_misfire_and_legal_constraints() -> None: - """Madman-only: the fake-result guidance must include misfire awareness - (誤爆リスク, 白先が本物の狼とは限らない), the no-execution-no-result rule, - and the legal-guard-history constraint. Wolf-coordination vocabulary - (bare `相方`, `襲撃先を揃える`) must remain absent; `相方候補` (public-log - inference) is allowed in the new pair-inference bullets.""" - block = _build_strategy_block(Role.MADMAN) - assert "誤爆リスク" in block - assert "白先が本物の狼とは限らない" in block - assert "処刑なしの日は結果なし" in block - assert "合法な護衛履歴" in block - # Existing leak guard: bare 相方 absent; 相方候補 (inference) allowed. - assert not re.search(r"相方(?!候補)", block) - assert "襲撃先を揃える" not in block - - -# ------------------------------------------ day 1 medium fake-CO / 2-2 / 1-2 layout -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_offers_medium_fake_as_day1_option(role: Role) -> None: - """Wolf and madman must teach day-1 medium fake-CO as a real choice - alongside seer fake and 潜伏 — not just a day-2+ post-hoc move. The - existing day-2+ post-hoc framing must remain (no regression).""" - block = _build_strategy_block(role) - assert "霊媒師騙りも day 1 の選択肢" in block - assert "占い師騙り" in block - assert "潜伏" in block - assert "対抗占い師 CO が出ている場合は、day 2 以降に霊媒師騙り" in block - - -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_frames_2_2_and_1_2_as_engineerable_layouts(role: Role) -> None: - """Wolf-side strategy must frame 2-2 and 1-2 as boards the wolf side can - engineer through medium fake-CO, not just descriptive labels. The - creation-mode form `2-2 を作` is the directive anchor.""" - block = _build_strategy_block(role) - assert "1-2" in block - assert "2-2 を作" in block - assert "霊媒ローラー" in block - - -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_fake_strategy_day1_medium_co_does_not_publish_result(role: Role) -> None: - """day-1 medium fake-CO must explicitly NOT publish an execution result — - no execution has happened yet. This composes with (does not replace) the - existing day-2+ '前日処刑者だけに結果を出す' rule.""" - block = _build_strategy_block(role) - assert "day 1 に霊媒師騙りで CO する場合" in block - assert "まだ処刑が発生していない" in block - assert "存在しない初日結果を捏造しない" in block - - -def test_madman_medium_fake_carries_misfire_awareness_for_both_colors() -> None: - """Madman-only: the medium-fake guidance must say even when picking - medium-black or medium-white, the madman never knows real wolf positions, - so 誤爆 / 誤支援 risk persists for both colors. Wolf-coordination - vocabulary stays absent.""" - block = _build_strategy_block(Role.MADMAN) - assert "霊媒黒で本物の狼を切ってしまう" in block - assert "霊媒白で真占いを補強してしまう" in block - assert "誤爆" in block - assert "誤支援" in block - # Leak guard preserved. - assert not re.search(r"相方(?!候補)", block) - assert "襲撃先を揃える" not in block - - -@pytest.mark.parametrize("role", list(Role)) -def test_layout_creation_directives_only_in_wolf_madman_strategy(role: Role) -> None: - """The wolf-side directive form '2-2 を作' / '1-2 を作' / '霊媒師騙りを選' - is creation-mode wolf-side guidance and must appear only in WEREWOLF and - MADMAN strategies — never in SEER / MEDIUM / KNIGHT / VILLAGER. The - descriptive board labels in the shared rules block are not wolf-side - directives and are unaffected by this guard.""" + """The 襲撃死=非狼 rule is so safety-critical it's repeated in every + role-specific block — tests catch accidental deletion. Wolf phrases + it as a self-imposed silence rule (not to claim victims as wolves); + other roles phrase it as a listener-side detection rule.""" block = _build_strategy_block(role) - if role in (Role.WEREWOLF, Role.MADMAN): - # Creation directive must reach wolf side. - assert "2-2 を作" in block - else: - assert "2-2 を作" not in block, f"'2-2 を作' (creation directive) leaked into {role.name}" - assert "1-2 を作" not in block, f"'1-2 を作' (creation directive) leaked into {role.name}" - assert "霊媒師騙りを選" not in block, ( - f"'霊媒師騙りを選' (wolf-side selection directive) leaked into {role.name}" - ) + assert "(襲撃)" in block + assert "襲撃死=非狼" in block or "非狼確定" in block -@pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN]) -def test_task_daytime_speech_day1_round1_wolf_or_madman_offers_medium_fake_as_option( - role: Role, -) -> None: - """At day-1 round-1 only, the daytime speech task injects a 3-way fake-CO - selection nudge for wolf and madman — a per-call task-level reminder that - re-surfaces the day-1 selection at the moment of speech generation. - Default (no role) and other rounds must not include it (verified by - sibling default-omission tests).""" - text = task_daytime_speech(1, discussion_round=1, role=role) - assert "占い師騙り・霊媒師騙り・潜伏の 3 択" in text - assert "1-2" in text - assert "2-2 を作" in text - # Leak-guard regression — no wolf-coordination vocab in the task block. - assert not re.search(r"相方(?!候補)", text) - assert "襲撃先を揃える" not in text - - -# ------------------------------------------------ seer night-divination axes -def test_seer_strategy_includes_night_divination_targeting_axes() -> None: - """The true seer strategy must teach the LLM to pick high-information - targets rather than randomizing over legal candidates. The axes echo the - SEER_DIVINE task block so the LLM sees them in both places.""" - block = _build_strategy_block(Role.SEER) - assert "占い価値" in block - assert "灰を狭める" in block - assert "対抗 CO" in block - assert "囲い候補" in block - assert "投票" in block - assert "白でも黒でも情報が落ちる" in block - # Existing SEER unique anchor (cross-leak pivot) must still be present. - assert "判定履歴を時系列で一貫" in block - - -# --------------------------------------------------- build_system_prompt -# Sentinels (not real pronouns) so the test persona can't false-positive in -# other tests that grep for `私` / `君` / etc. in the rendered prompt. -_TEST_SPEECH_PROFILE = SpeechProfile( - first_person="TEST_FP", - address_style="TEST_ADDRESS", - sentence_style="TEST_SENTENCE", - pause_style="TEST_PAUSE", -) -_TEST_PERSONA = Persona( - key="_test_persona_", - display_name="TEST_NAME", - style_guide="TEST_STYLE_GUIDE", - speech_profile=_TEST_SPEECH_PROFILE, -) +# ========================================================================= +# build_system_prompt — block composition +# ========================================================================= -def test_existing_blocks_still_rendered() -> None: - """persona_block / role_block / phase_block / task_block substitutions - still happen after the rules+strategy additions.""" +def _persona() -> Persona: + """A minimal persona for system-prompt composition tests.""" + return Persona( + key="test_p", + display_name="🧪テスト", + style_guide="淡々と理屈で詰める。", + speech_profile=SpeechProfile( + first_person="僕", + self_reference_aliases=(), + address_style="呼び捨て", + sentence_style="短く言い切る", + pause_style="間は置かない", + signature_phrases=("……まあ",), + forbidden_overuse=("無闇な敬語",), + narration_mode=None, + ), + judgment_profile=JudgmentProfile(), + ) + + +def test_build_system_prompt_substitutes_every_placeholder() -> None: + persona = _persona() prompt = build_system_prompt( - persona=_TEST_PERSONA, - role=Role.VILLAGER, - phase=Phase.DAY_DISCUSSION, - day_number=2, - task_text="TASK_BLOCK_MARKER_XYZ", - ) - # persona - assert "TEST_NAME" in prompt - assert "TEST_STYLE_GUIDE" in prompt - # speech profile block reached (sentinels in _TEST_SPEECH_PROFILE) - assert "TEST_ADDRESS" in prompt - # role (via ROLE_JA) - assert ROLE_JA[Role.VILLAGER] in prompt - # phase - assert Phase.DAY_DISCUSSION.value in prompt - assert "day 2" in prompt - # task - assert "TASK_BLOCK_MARKER_XYZ" in prompt - - -@pytest.mark.parametrize("role", list(Role)) -def test_build_system_prompt_has_no_unreplaced_placeholders(role: Role) -> None: - """Every `{placeholder}` token in the template must be substituted for - every role. A leftover `{role_block}` etc. would be a bug.""" - prompt = build_system_prompt( - persona=_TEST_PERSONA, - role=role, + persona=persona, + role=Role.SEER, phase=Phase.DAY_DISCUSSION, day_number=1, - task_text="t", - ) - leftover = re.findall(r"\{[a-z_]+_block\}", prompt) - assert leftover == [], f"unreplaced placeholders for {role.name}: {leftover}" - - -@pytest.mark.parametrize("role", list(Role)) -def test_build_system_prompt_embeds_role_appropriate_strategy(role: Role) -> None: - """The full system prompt for a given role must contain that role's own - unique strategy phrase AND must NOT contain any other role's unique - strategy phrase. This is the integration-level analog of - `test_strategy_block_no_cross_role_leak`.""" - prompt = build_system_prompt( - persona=_TEST_PERSONA, - role=role, + task_text="<>", + ) + # No placeholder leaks unfilled. + for marker in ( + "{game_rules_block}", + "{persona_block}", + "{judgment_profile_block}", + "{speech_profile_block}", + "{role_block}", + "{strategy_block}", + "{phase_block}", + "{task_block}", + ): + assert marker not in prompt + # Persona surfaces. + assert "🧪テスト" in prompt + assert "淡々と理屈で詰める" in prompt + # Phase + day surfaces. + assert "DAY_DISCUSSION" in prompt + assert "day 1" in prompt + # Task surfaces verbatim. + assert "<>" in prompt + # Role surfaces using ROLE_JA mapping. + assert ROLE_JA[Role.SEER] in prompt + # Strategy + rules blocks both reach the prompt. + assert "(襲撃)" in prompt # rules block + assert "対抗 CO" in prompt # rules + seer strategy share this + + +def test_build_system_prompt_role_scope_only_emits_own_strategy() -> None: + """Composing for one role must not splice in another role's strategy + body — wolf coordination must not appear in seer's prompt etc.""" + persona = _persona() + seer_prompt = build_system_prompt( + persona=persona, + role=Role.SEER, phase=Phase.DAY_DISCUSSION, day_number=1, - task_text="t", + task_text="", ) - assert _ROLE_UNIQUE_PHRASES[role] in prompt - for other_role, other_phrase in _ROLE_UNIQUE_PHRASES.items(): - if other_role is role: - continue - assert other_phrase not in prompt, ( - f"{other_role.name}'s tip leaked into full system prompt for {role.name}" - ) + # Wolf vocabulary is fenced to werewolf.md + assert "襲撃先を揃える" not in seer_prompt + # Madman-only prohibition is fenced to madman.md + assert "本物の人狼位置を知っている前提で話してはならない" not in seer_prompt -def test_build_system_prompt_contains_common_rules_for_any_role() -> None: - """The rules block is shared across all roles — every role's system prompt - must contain the canonical rule markers.""" - for role in Role: - prompt = build_system_prompt( - persona=_TEST_PERSONA, - role=role, - phase=Phase.DAY_DISCUSSION, - day_number=1, - task_text="t", - ) - # Role distribution markers (sampled) — derived from ROLE_DISTRIBUTION. - assert "人狼2" in prompt - assert "村人3" in prompt - # Win conditions — mirrors rules.check_victory. - assert "生存人狼数が 0" in prompt - assert "生存人狼数が生存非人狼人数以上" in prompt - - -# --------------------------------------------------- speech profile block -# Structural assertions. Content (specific phrases per persona) is sourced from -# the spec file; these tests pin the shape + a few high-signal anchors so -# regressions in the renderer are caught early. -def test_speech_profile_block_standard_persona_lists_first_person() -> None: - block = _build_speech_profile_block(PERSONAS_BY_KEY["setsu"]) - assert "一人称" in block - assert "『私』" in block - assert "君" in block - # Standard mode must not bleed silent_gesture markers. - assert "叙述モード" not in block +# ========================================================================= +# judgment / speech profile blocks — band rendering & leakage +# ========================================================================= -def test_speech_profile_block_sq_lists_self_alias_and_low_frequency_flag() -> None: - block = _build_speech_profile_block(PERSONAS_BY_KEY["sq"]) - assert "『アタシ』" in block - assert "SQちゃん" in block - assert "DEATH" in block - # Rate-limiting advice must appear so the LLM is told DEATH is sparing. - assert "低頻度" in block +def _persona_with_judgment(profile: JudgmentProfile) -> Persona: + return Persona( + key="band_p", + display_name="バンド", + style_guide="テスト用", + speech_profile=SpeechProfile( + first_person="私", + self_reference_aliases=(), + address_style="さん付け", + sentence_style="標準", + pause_style="標準", + signature_phrases=(), + forbidden_overuse=(), + narration_mode=None, + ), + judgment_profile=profile, + ) + + +def test_judgment_profile_block_extreme_logical_persona() -> None: + persona = _persona_with_judgment( + JudgmentProfile( + trust_hard_facts=1.0, + trust_medium_facts=1.0, + contrarian_bias=0.0, + aggression=0.0, + bandwagon_tendency=0.0, + ) + ) + block = _build_judgment_profile_block(persona) + assert "絶対視" in block + assert "基本受け入れる" in block + assert "多数派にあえて逆らわない" in block + assert "慎重で疑い先を出すのが遅い" in block + assert "単独行動を好み流れに乗らない" in block -def test_speech_profile_block_yuriko_uses_konomi_and_not_kimi() -> None: - """Yuriko's 2nd person is『お前』, not『君』— a common LLM drift hazard.""" - block = _build_speech_profile_block(PERSONAS_BY_KEY["yuriko"]) - assert "『この身』" in block - assert "お前" in block - assert "君" not in block +def test_judgment_profile_block_neutral_persona_renders_mid_bands() -> None: + persona = _persona_with_judgment(JudgmentProfile()) + block = _build_judgment_profile_block(persona) + assert "標準" in block -def test_speech_profile_block_kukrushka_is_silent_gesture() -> None: - """Kukrushka's block is structurally different (no `一人称` line, has - `叙述モード` + gesture examples instead).""" - block = _build_speech_profile_block(PERSONAS_BY_KEY["kukrushka"]) +def test_speech_profile_block_kukrushka_uses_silent_gesture() -> None: + """Kukrushka is the only persona that narrates by gesture (silent_gesture + mode) — the block must structurally differ from chatty personas (no + 一人称 line, gesture examples instead).""" + persona = PERSONAS_BY_KEY["kukrushka"] + block = _build_speech_profile_block(persona) assert "叙述モード" in block - assert "所作" in block - # Structural difference: the normal `一人称` line is absent. assert "一人称" not in block @pytest.mark.parametrize( "key", - [k for k, p in PERSONAS_BY_KEY.items() if p.speech_profile.narration_mode == "standard"], + [k for k in PERSONAS_BY_KEY if k != "kukrushka"], ) -def test_speech_profile_block_standard_personas_share_structure(key: str) -> None: - """All `narration_mode == "standard"` personas share the same scaffold. - Filtering by attribute (not by hard-coded key exclusion) keeps the test - correct if another silent-gesture persona is added later.""" +def test_speech_profile_block_standard_personas_have_first_person( + key: str, +) -> None: block = _build_speech_profile_block(PERSONAS_BY_KEY[key]) assert "一人称" in block - assert "叙述モード" not in block - - -def test_build_system_prompt_includes_speech_profile_block() -> None: - prompt = build_system_prompt( - persona=PERSONAS_BY_KEY["setsu"], - role=Role.VILLAGER, - phase=Phase.DAY_DISCUSSION, - day_number=1, - task_text="t", - ) - assert "## 話法" in prompt - assert "『私』" in prompt - # Common rule from the markdown template (static, once). - assert "1 発話に入れてよい特徴語は多くても 1 個" in prompt - - -def test_build_system_prompt_speech_profile_respects_persona() -> None: - """The `## 話法` section differs per persona — setsu's speech block says - 『私』, yuriko's says『この身』. Neither block leaks the other's first - person.""" - setsu_prompt = build_system_prompt( - persona=PERSONAS_BY_KEY["setsu"], - role=Role.VILLAGER, - phase=Phase.DAY_DISCUSSION, - day_number=1, - task_text="t", - ) - yuriko_prompt = build_system_prompt( - persona=PERSONAS_BY_KEY["yuriko"], - role=Role.VILLAGER, - phase=Phase.DAY_DISCUSSION, - day_number=1, - task_text="t", - ) - assert "『私』" in setsu_prompt - assert "『この身』" not in setsu_prompt - assert "『この身』" in yuriko_prompt - # Everything after `## 話法` (speech block + later sections) must not - # contain『私』 for yuriko — guards against drift in rendering. - assert "『私』" not in yuriko_prompt.split("## 話法")[1] # ========================================================================= @@ -1875,42 +564,30 @@ def _ctx_speech(text: str, *, actor_seat: int, day: int = 1) -> dict[str, object return {"kind": "PLAYER_SPEECH", "text": text, "actor_seat": actor_seat, "day": day} -def test_build_user_context_no_co_parser_sections() -> None: - """The pre-digest CO parser sections (CO list / 盤面分類 / 役職推定メモ) must - not appear in user_context. Even seeded with declarative-looking PLAYER_SPEECH - text, the build no longer attaches machine-summarized CO output — the LLM - is expected to read the raw 公開ログ要約 and judge in context.""" - seats = [_ctx_seat(1, "Alice"), _ctx_seat(2, "Bob"), _ctx_seat(3, "Carol")] - players = [_ctx_player(1), _ctx_player(2), _ctx_player(3)] +def test_build_user_context_no_co_parser_summary_section() -> None: + """The pre-digest CO parser sections (CO list / 盤面分類 / 役職推定メモ) + must not appear in user_context. The LLM is expected to read raw + PLAYER_SPEECH lines and apply CO-detection rules itself.""" + seats = [_ctx_seat(1, "Alice"), _ctx_seat(2, "Bob")] + players = [_ctx_player(1), _ctx_player(2)] out = build_user_context( game=_ctx_game(), me=players[0], my_seat=seats[0], seats=seats, players=players, - public_logs=[ - _ctx_speech("占い師CO", actor_seat=1), - _ctx_speech("霊媒師CO", actor_seat=2), - ], + public_logs=[_ctx_speech("占い師CO", actor_seat=1)], private_logs=[], ) assert "## CO・判定の機械整理" not in out assert "## 盤面分類" not in out - assert "## 役職推定メモ (公開情報ベース)" not in out - # The parser-style "占い師CO: 席X" / "霊媒師CO: 席X" / "公開CO履歴ベース" - # rendering lines are the canonical anti-pattern; not produced anywhere now. assert "占い師CO: 席" not in out - assert "霊媒師CO: 席" not in out - assert "公開CO履歴ベース" not in out -def test_build_user_context_topical_co_phrase_does_not_create_self_co() -> None: - """A speaker who merely raises `占いCO` as a topic / hypothetical must not - be tagged as a self-CO. The raw text still passes through 公開ログ要約 so - the LLM can read it in context.""" +def test_build_user_context_passes_player_speech_through_raw() -> None: seats = [_ctx_seat(1, "Alice"), _ctx_seat(2, "Bob")] players = [_ctx_player(1), _ctx_player(2)] - speech = "占いCOが出たらどう見るか考えたい" + speech = "セツの判定が気になる、人狼っぽい雰囲気がある" out = build_user_context( game=_ctx_game(), me=players[0], @@ -1920,54 +597,12 @@ def test_build_user_context_topical_co_phrase_does_not_create_self_co() -> None: public_logs=[_ctx_speech(speech, actor_seat=2)], private_logs=[], ) - assert "占い師CO: 席2" not in out - # Raw speech survives in the public log dump. - assert speech in out - - -def test_build_user_context_topical_medium_phrase_does_not_create_self_co() -> None: - """Same invariant for medium-CO topical mentions — `霊媒COについて…` is a - discussion prompt, not a self-CO.""" - seats = [_ctx_seat(1, "Alice"), _ctx_seat(2, "Bob"), _ctx_seat(3, "Carol")] - players = [_ctx_player(1), _ctx_player(2), _ctx_player(3)] - speech = "霊媒COについてどう見る?" - out = build_user_context( - game=_ctx_game(), - me=players[0], - my_seat=seats[0], - seats=seats, - players=players, - public_logs=[_ctx_speech(speech, actor_seat=3)], - private_logs=[], - ) - assert "霊媒師CO: 席3" not in out assert speech in out -def test_build_user_context_passes_raw_player_speech_through() -> None: - """The raw PLAYER_SPEECH text reaches user_context with the seat-attributed - `[PLAYER_SPEECH] 席N {name}: …` formatting, so the LLM can spot CO - declarations on its own.""" - seats = [_ctx_seat(1, "Alice"), _ctx_seat(2, "Bob")] - players = [_ctx_player(1), _ctx_player(2)] - out = build_user_context( - game=_ctx_game(), - me=players[0], - my_seat=seats[0], - seats=seats, - players=players, - public_logs=[_ctx_speech("占い師COします。 席2 Bob 白", actor_seat=1)], - private_logs=[], - ) - assert "[PLAYER_SPEECH] 席1 Alice: 占い師COします。 席2 Bob 白" in out - - -def test_build_user_context_rope_count_with_alive_dead_breakdown() -> None: - seats = [_ctx_seat(i, f"S{i}") for i in range(1, 10)] - # 7 alive, 2 dead → 3 ropes. - players = [_ctx_player(i, alive=True) for i in range(1, 8)] + [ - _ctx_player(i, alive=False) for i in range(8, 10) - ] +def test_build_user_context_includes_rope_block() -> None: + seats = [_ctx_seat(i, f"P{i}") for i in range(1, 10)] + players = [_ctx_player(i) for i in range(1, 10)] out = build_user_context( game=_ctx_game(), me=players[0], @@ -1978,43 +613,21 @@ def test_build_user_context_rope_count_with_alive_dead_breakdown() -> None: private_logs=[], ) assert "## 縄数・PP/RPPリスク" in out - assert "生存 7 人 / 死亡 2 人" in out - assert "3 縄" in out - - -@pytest.mark.parametrize( - "alive, dead, ropes", - [(9, 0, 4), (7, 2, 3), (5, 4, 2), (3, 6, 1), (2, 7, 0)], -) -def test_build_user_context_rope_summary_parametrized(alive: int, dead: int, ropes: int) -> None: - """Rope formula coverage migrated from the deleted test_llm_context_analysis.py. - Asserts against the user_context block directly so the helper is exercised - through its only public surface.""" - seats = [_ctx_seat(i, f"S{i}") for i in range(1, alive + dead + 1)] - players = [_ctx_player(i, alive=True) for i in range(1, alive + 1)] + [ - _ctx_player(i, alive=False) for i in range(alive + 1, alive + dead + 1) - ] - out = build_user_context( - game=_ctx_game(), - me=players[0], - my_seat=seats[0], - seats=seats, - players=players, - public_logs=[], - private_logs=[], - ) - assert "## 縄数・PP/RPPリスク" in out - assert f"生存 {alive} 人 / 死亡 {dead} 人" in out - assert f"{ropes} 縄" in out + assert "生存 9 人" in out + # 9-alive → 4 ropes; village starts at 4 ropes. + assert "4 縄" in out assert "9人村開始時は4縄" in out -def test_build_user_context_rope_endgame_note_at_3_alive() -> None: - seats = [_ctx_seat(i, f"S{i}") for i in range(1, 10)] - players = [_ctx_player(i, alive=True) for i in range(1, 4)] + [ - _ctx_player(i, alive=False) for i in range(4, 10) +def test_build_user_context_wolf_partner_block_only_for_wolves() -> None: + """A wolf seat sees its partner; a non-wolf seat must not.""" + seats = [_ctx_seat(i, f"P{i}") for i in range(1, 4)] + players = [ + _ctx_player(1, role=Role.WEREWOLF), + _ctx_player(2, role=Role.WEREWOLF), + _ctx_player(3, role=Role.SEER), ] - out = build_user_context( + wolf_view = build_user_context( game=_ctx_game(), me=players[0], my_seat=seats[0], @@ -2023,29 +636,23 @@ def test_build_user_context_rope_endgame_note_at_3_alive() -> None: public_logs=[], private_logs=[], ) - assert "最終局面" in out - - -def test_build_user_context_rope_pp_warning_at_5_alive() -> None: - seats = [_ctx_seat(i, f"S{i}") for i in range(1, 10)] - players = [_ctx_player(i, alive=True) for i in range(1, 6)] + [ - _ctx_player(i, alive=False) for i in range(6, 10) - ] - out = build_user_context( + seer_view = build_user_context( game=_ctx_game(), - me=players[0], - my_seat=seats[0], + me=players[2], + my_seat=seats[2], seats=seats, players=players, public_logs=[], private_logs=[], ) - assert "PP/RPP" in out + assert "## 仲間の人狼" in wolf_view + assert "P2" in wolf_view # partner shown + assert "## 仲間の人狼" not in seer_view -def test_build_user_context_rope_normal_note_at_9_alive() -> None: - seats = [_ctx_seat(i, f"S{i}") for i in range(1, 10)] - players = [_ctx_player(i, alive=True) for i in range(1, 10)] +def test_build_user_context_renders_deduced_facts_section_when_provided() -> None: + seats = [_ctx_seat(1, "Alice")] + players = [_ctx_player(1)] out = build_user_context( game=_ctx_game(), me=players[0], @@ -2054,14 +661,13 @@ def test_build_user_context_rope_normal_note_at_9_alive() -> None: players=players, public_logs=[], private_logs=[], + deduced_facts_block="HARD: 席3 は人狼ではない (襲撃死)", ) - assert "通常進行" in out + assert "## 公開情報からの確定/推測事実" in out + assert "HARD: 席3 は人狼ではない" in out -def test_build_user_context_block_order_after_co_parser_removal() -> None: - """After dropping the CO parser, the surviving block order is: - rope block → 私的メモ → 公開ログ要約 → 自分の直近の発言. The deleted - parser headings must be absent.""" +def test_build_user_context_omits_deduced_facts_section_when_none() -> None: seats = [_ctx_seat(1, "Alice")] players = [_ctx_player(1)] out = build_user_context( @@ -2073,360 +679,119 @@ def test_build_user_context_block_order_after_co_parser_removal() -> None: public_logs=[], private_logs=[], ) - rope_idx = out.find("## 縄数・PP/RPPリスク") - priv_idx = out.find("## あなたの私的メモ") - pub_idx = out.find("## 公開ログ要約") - own_idx = out.find("## 自分の直近の発言") - assert -1 < rope_idx < priv_idx < pub_idx < own_idx - assert "## CO・判定の機械整理" not in out - assert "## 盤面分類" not in out - assert "## 役職推定メモ (公開情報ベース)" not in out - + assert "## 公開情報からの確定/推測事実" not in out -def test_build_user_context_wolf_partner_block_only_for_wolves() -> None: - """Regression: the wolf-partner block remains gated to werewolves after the - CO-parser analysis section was removed from between it and the rest.""" - seats = [_ctx_seat(1, "Alice"), _ctx_seat(2, "Bob")] - players = [ - _ctx_player(1, role=Role.WEREWOLF), - _ctx_player(2, role=Role.WEREWOLF), - ] - villager_seat = _ctx_seat(3, "Carol") - villager = _ctx_player(3, role=Role.VILLAGER) - seats3 = [*seats, villager_seat] - players3 = [*players, villager] - wolf_view = build_user_context( - game=_ctx_game(), - me=players[0], - my_seat=seats[0], - seats=seats3, - players=players3, - public_logs=[], - private_logs=[], - ) - villager_view = build_user_context( - game=_ctx_game(), - me=villager, - my_seat=villager_seat, - seats=seats3, - players=players3, - public_logs=[], - private_logs=[], - ) - assert "## 仲間の人狼" in wolf_view - assert "## 仲間の人狼" not in villager_view +# ========================================================================= +# task templates — partner-block scoping, day-2 hint gating +# ========================================================================= -# ----------------------------- werewolf vote-discipline strategy & task_vote +def test_task_vote_baseline_unchanged_for_non_wolf() -> None: + base = task_vote(["席1 Alice", "席2 Bob"], runoff=False) + assert "席1 Alice" in base + assert "仲間の人狼" not in base + assert "決選投票" not in base -def test_wolf_strategy_block_contains_vote_discipline_vocabulary() -> None: - """The werewolf strategy block must teach 投票 discipline: 票筋 reads, - 身内票/ライン切り on the partner when unsavable, 票逸らし risk, and the - 決選投票 PP/RPP comparison. Without these, the LLM wolf falls back to - transparent partner-protection that exposes the wolf line.""" - block = _build_strategy_block(Role.WEREWOLF) - for token in ( - "身内票", - "ライン切り", - "票筋", - "処刑濃厚", - "票を逸ら", - "決選投票", - "PP/RPP", - "透け", - ): - assert token in block, f"wolf strategy missing vote-discipline token {token!r}" - assert "相方を救" in block - assert "相方を切" in block +def test_task_vote_runoff_note_appears_when_runoff_true() -> None: + out = task_vote(["席1 Alice", "席2 Bob"], runoff=True) + assert "決選投票" in out @pytest.mark.parametrize( "role", - [Role.MADMAN, Role.SEER, Role.MEDIUM, Role.KNIGHT, Role.VILLAGER], + [Role.SEER, Role.MEDIUM, Role.KNIGHT, Role.VILLAGER, Role.MADMAN], ) -def test_non_wolf_strategy_excludes_partner_action_vocabulary(role: Role) -> None: - """Partner-name and partner-action vocabulary must stay wolf-only. Bare - `身内票` / `ライン切り` are shared in `_build_game_rules_block`, but the - wolf-specific phrasings (`仲間の人狼`, `相方を救う`, `相方を切る`) must - not appear in any other role's strategy block.""" - block = _build_strategy_block(role) - assert "仲間の人狼" not in block, f"'仲間の人狼' leaked into {role.name}" - assert "相方を救" not in block, f"'相方を救' leaked into {role.name}" - assert "相方を切" not in block, f"'相方を切' leaked into {role.name}" - - -def test_task_vote_baseline_unchanged_without_role() -> None: - """The default `task_vote(candidates, runoff)` call must keep the existing - base text and never inject partner-action vocabulary, so all pre-existing - call sites and non-wolf voters get the same instructions as before. Bare - `相方` (actor mode, partner-known) and `仲間の人狼` must not appear; the - inference noun `相方候補` is allowed in the shared pair-inference bullet - of the common path.""" - text = task_vote(["席1 A", "席2 B"], runoff=False) - assert "投票先として合法な候補は: 席1 A、席2 B" in text - assert "仲間の人狼" not in text - assert not re.search(r"相方(?!候補)", text) - - -def test_task_vote_wolf_path_emits_partner_checklist() -> None: - """A wolf voter with at least one alive partner gets the partner-aware - vote checklist appended after the base candidate text.""" - text = task_vote( - ["席1 A", "席2 B"], +def test_task_vote_non_wolf_role_never_emits_partner_block(role: Role) -> None: + """Even when a partner_tokens kwarg is forced for a non-wolf role + (e.g. madman), the wolf-block must not render — partner identity + must never reach non-wolf prompts.""" + out = task_vote( + ["席1 Alice", "席2 Bob"], runoff=False, - role=Role.WEREWOLF, - wolf_partner_tokens=["席2 B"], + role=role, + wolf_partner_tokens=("席3 Carol",), ) - for token in ( - "仲間の人狼", - "席2 B", - "相方", - "身内票", - "ライン切り", - "票筋", - "処刑濃厚", - "透け", - "合法候補", - ): - assert token in text, f"wolf vote task missing token {token!r}" + assert "仲間の人狼" not in out + assert "席3 Carol" not in out -def test_task_vote_wolf_runoff_adds_runoff_checklist() -> None: - """The wolf runoff vote task must add the 決選投票-specific tradeoff - comparison (救済成功見込み・透けリスク・PP/RPP) on top of the base - wolf checklist.""" - text = task_vote( - ["席1 A", "席2 B"], - runoff=True, +def test_task_vote_wolf_emits_partner_checklist() -> None: + out = task_vote( + ["席1 Alice", "席2 Bob"], + runoff=False, role=Role.WEREWOLF, - wolf_partner_tokens=["席2 B"], + wolf_partner_tokens=("席3 Carol",), ) - for token in ("決選投票", "透け", "PP/RPP"): - assert token in text, f"wolf runoff vote task missing token {token!r}" + assert "仲間の人狼" in out + assert "席3 Carol" in out + assert "身内票" in out or "ライン切り" in out -@pytest.mark.parametrize( - "role", - [Role.VILLAGER, Role.MADMAN, Role.SEER, Role.MEDIUM, Role.KNIGHT], -) -def test_task_vote_non_wolf_role_returns_base_text(role: Role) -> None: - """Passing a non-wolf role to `task_vote` returns the base text — the - wolf-specific block never reaches non-wolf voters even if a future caller - forgets to skip the role argument. Bare `相方` (actor mode) and `仲間の人狼` - must be absent; the inference noun `相方候補` is allowed in the common path.""" - text = task_vote(["席1 A", "席2 B"], runoff=False, role=role) - assert "仲間の人狼" not in text - assert not re.search(r"相方(?!候補)", text) - - -def test_task_vote_madman_with_partner_token_does_not_leak_partner() -> None: - """Even if a caller naively passes a partner token for a madman, the - role gate inside `task_vote` must drop it. The madman never sees real - wolf positions.""" - text = task_vote( - ["席1 A"], - runoff=False, - role=Role.MADMAN, - wolf_partner_tokens=["席3 X"], +def test_task_vote_wolf_runoff_adds_runoff_block() -> None: + out = task_vote( + ["席1 Alice", "席2 Bob"], + runoff=True, + role=Role.WEREWOLF, + wolf_partner_tokens=("席3 Carol",), ) - assert "仲間の人狼" not in text - assert "席3 X" not in text + assert "PP/RPP" in out def test_task_vote_lone_wolf_returns_base_text() -> None: - """A wolf voter with no alive partner (lone-wolf endgame) gets the base - text — the partner-aware checklist requires at least one partner token. - Bare `相方` (actor mode) and `仲間の人狼` must be absent; `相方候補` - (inference) is allowed in the shared pair-inference bullet.""" - text = task_vote( - ["席1 A"], + """Empty partner_tokens → wolf gets the same prompt as villagers.""" + out = task_vote( + ["席1 Alice", "席2 Bob"], runoff=False, role=Role.WEREWOLF, wolf_partner_tokens=(), ) - assert "仲間の人狼" not in text - assert not re.search(r"相方(?!候補)", text) - - -# ----------------------------------- 2 人狼ペア推理 (wolf-pair inference) -# Pair-inference asks every LLM seat to think paired: "if A is wolf, who is -# the natural B?" The vocabulary `相方候補` (inference noun) is shared across -# all roles via the rules block; `相方` (actor mode, partner-known) stays -# strictly in the werewolf strategy and the wolf-only vote/night blocks. - -_PAIR_INFERENCE_RULES_TOKENS = ( - "2 人狼", - "相方候補", - "公開ログからの仮説", - "単体黒要素", - "票筋", - "噛み筋", - "ライン切り", - "身内票", -) + assert "仲間の人狼" not in out -def test_game_rules_block_introduces_two_wolf_pair_inference() -> None: - """The shared rules block must teach paired-wolf inference: build an A-B - hypothesis from public logs (vote tracks, attack patterns, line-cuts, - inner-circle votes) and weigh single-wolf suspicion against partner fit.""" - block = _build_game_rules_block() - for token in _PAIR_INFERENCE_RULES_TOKENS: - assert token in block, f"rules block missing pair-inference token {token!r}" - # Soft-cap on speech length must stay; the new bullets must not encourage - # exhaustive pair-listing speeches. - assert "1〜2 点" in block - - -def test_villager_strategy_includes_pair_inference_vocabulary() -> None: - """The villager (public-information-only seat) must reason paired: - candidate + 相方候補 from public logs, plus observation patterns - (身内票, 票逸らし, ライン切り) as wolf-detection signals. Bare `相方` - must not appear; the villager-CO ban must remain intact.""" - block = _build_strategy_block(Role.VILLAGER) - assert "相方候補" in block - assert "2 人狼仮説" in block - assert "身内票" in block - # Either of the observation-pattern phrases is sufficient. - assert "票逸ら" in block or "ライン切り" in block - # Bare `相方` (actor mode) must not appear. - assert not re.search(r"相方(?!候補)", block) - # Existing villager-CO ban remains. - assert "村人 CO 禁止" in block or "村人CO" in block - - -def test_seer_strategy_includes_pair_inference_for_target_selection() -> None: - """Seer divine-target selection must factor in pair-inference: white-or- - black both organize 相方候補 hypotheses, counter-CO comparison drives the - strongest pair to break. Bare `相方` (actor mode) must not appear.""" - block = _build_strategy_block(Role.SEER) - assert "相方候補" in block - assert "2 人狼仮説" in block - assert "対抗" in block - assert not re.search(r"相方(?!候補)", block) +def test_task_daytime_speech_day2_round1_includes_results_hint() -> None: + out = task_daytime_speech(day_number=2, discussion_round=1) + assert "前夜の能力結果" in out -def test_medium_strategy_includes_pair_inference_after_execution() -> None: - """Medium pair-inference: medium-black narrows the executed wolf's 相方候補 - via 票筋 / 庇い / ライン切り / 身内票; medium-white examines who pushed the - execution. Bare `相方` (actor mode) must not appear.""" - block = _build_strategy_block(Role.MEDIUM) - assert "相方候補" in block - assert "票筋" in block - assert "身内票" in block - assert "2 人狼仮説" in block - assert not re.search(r"相方(?!候補)", block) +def test_task_daytime_speech_day1_round1_wolf_madman_branch() -> None: + out = task_daytime_speech(day_number=1, discussion_round=1, role=Role.WEREWOLF) + assert "占い師騙り・霊媒師騙り・潜伏" in out -def test_knight_strategy_includes_pair_inference_for_guard_selection() -> None: - """Knight pair-inference: guard-target selection considers which 2-wolf - hypothesis benefits from the kami; peaceful mornings + actual attack - targets feed back into the daytime pair-inference. Bare `相方` (actor - mode) must not appear.""" - block = _build_strategy_block(Role.KNIGHT) - assert "相方候補" in block - assert "2 人狼仮説" in block - assert "噛み筋" in block - assert not re.search(r"相方(?!候補)", block) +def test_task_daytime_speech_day1_round1_villager_no_wolf_block() -> None: + out = task_daytime_speech(day_number=1, discussion_round=1, role=Role.VILLAGER) + assert "占い師騙り・霊媒師騙り・潜伏" not in out -def test_werewolf_strategy_includes_two_wolf_set_framing() -> None: - """The werewolf strategy must explicitly tell the wolf that it and its - partner are read as a 2-wolf set by the village — every save/cut/cover/ - black-out/attack must be defensible as the same A-B line on later days. - `視点漏れ` is the absolute prohibition (don't leak partner-known info).""" - block = _build_strategy_block(Role.WEREWOLF) - assert "2 人狼セット" in block - assert "視点漏れ" in block - assert "ライン" in block - # Bare `相方` is REQUIRED in the wolf strategy (actor mode, partner-known). - assert "相方" in block - - -def test_madman_strategy_includes_pair_inference_with_misfire() -> None: - """The madman, lacking real-wolf-position knowledge, must reason 2-wolf - hypotheses from public logs only — with explicit misfire framing. Bare - `相方` (actor mode) and `襲撃先を揃える` (wolf-only night coordination) - must remain absent.""" - block = _build_strategy_block(Role.MADMAN) - assert "2 人狼仮説" in block - assert "公開ログからの推定" in block - assert "誤爆" in block - assert "相方候補" in block - # Wolf-coordination vocab forbidden. - assert not re.search(r"相方(?!候補)", block) - assert "襲撃先を揃える" not in block - # Existing prohibition phrase still present. - assert "人狼位置を知っている前提で話してはならない" in block - - -def test_task_daytime_speech_default_includes_pair_inference_softly() -> None: - """The daytime speech task's base body teaches paired suspicion as a - soft hint — not every speech must list pairs. The softness modifiers - (`必要に応じて` / `1〜2 点`) guard against speech inflation. The base - contract (80–300 chars, intent=speak/skip) must be preserved.""" - text = task_daytime_speech(2) - assert "相方候補" in text - assert "2 人狼セット" in text - # Softness anchors so every speech doesn't balloon into pair listing. - assert "必要に応じて" in text - assert "1〜2 点" in text - # Existing base contract preserved. - assert "80〜300 字" in text - assert "intent=speak" in text - assert "intent=skip" in text - - -def test_task_vote_base_includes_pair_inference_for_all_roles() -> None: - """The common-path `task_vote` body (no role argument) must teach paired - suspicion as part of the single-vote-decision guidance, since every role - sees this body. Bare `相方` (actor mode) must not appear; only `相方候補`.""" - text = task_vote(["席1 A", "席2 B"], runoff=False) - assert "相方候補" in text - assert "2 人狼セット" in text - # Existing baseline candidate list still present. - assert "投票先として合法な候補は: 席1 A、席2 B" in text - # Wolf-only block must not be appended. - assert "仲間の人狼" not in text - assert not re.search(r"相方(?!候補)", text) - - -def test_task_vote_wolf_block_extends_with_two_wolf_set_framing() -> None: - """The wolf-only vote block must extend the existing partner checklist - with a `2 人狼セット` line so the wolf checks each save/cut/deflect against - next-day reading. Existing wolf-only tokens stay (`相方を救` / `身内票` / - `ライン切り`).""" - text = task_vote( - ["席1 A", "席2 B"], - runoff=False, - role=Role.WEREWOLF, - wolf_partner_tokens=["席2 B"], - ) - assert "2 人狼セット" in text - # Existing wolf-only checklist tokens preserved. - assert "相方を救" in text - assert "身内票" in text - assert "ライン切り" in text - - -def test_task_vote_madman_inference_present_but_no_actor_partner() -> None: - """The madman's vote text contains the common-path pair-inference clause - (`相方候補`) but never the wolf-only actor verbs (`相方を救` / `相方を切`) - or the partner-token block (`仲間の人狼`). Bare `相方` (actor mode) must - not appear even when a caller naively passes a partner token.""" - text = task_vote( - ["席1 A", "席2 B"], - runoff=False, - role=Role.MADMAN, - wolf_partner_tokens=["席3 X"], - ) - # Common-path pair-inference reaches the madman. - assert "相方候補" in text - # Wolf-only actor verbs and partner-token block must not. - assert "仲間の人狼" not in text - assert "相方を救" not in text - assert "相方を切" not in text - # Bare `相方` (actor mode) must be absent in the madman vote text. - assert not re.search(r"相方(?!候補)", text) +@pytest.mark.parametrize( + "kind,label", + [ + (SubmissionType.WOLF_ATTACK, "襲撃"), + (SubmissionType.SEER_DIVINE, "占い"), + (SubmissionType.KNIGHT_GUARD, "護衛"), + ], +) +def test_task_night_action_uses_kind_specific_label(kind: SubmissionType, label: str) -> None: + out = task_night_action(kind, ["席1 Alice", "席2 Bob"]) + assert label in out + assert "席1 Alice" in out + + +def test_task_night_action_wolf_only_advice_is_kind_scoped() -> None: + """Wolf-attack value scoring lives only in WOLF_ATTACK; knight tradeoffs + only in KNIGHT_GUARD; seer value only in SEER_DIVINE.""" + wolf = task_night_action(SubmissionType.WOLF_ATTACK, ["席1 X"]) + knight = task_night_action(SubmissionType.KNIGHT_GUARD, ["席1 X"]) + seer = task_night_action(SubmissionType.SEER_DIVINE, ["席1 X"]) + assert "騎士探し" in wolf and "騎士探し" not in knight and "騎士探し" not in seer + assert "鉄板護衛" in knight and "鉄板護衛" not in wolf + assert "占い価値" in seer and "占い価値" not in wolf + + +def test_task_wolf_chat_lists_partners_and_candidates() -> None: + out = task_wolf_chat(["席3 Carol"], ["席1 Alice", "席2 Bob"]) + assert "席3 Carol" in out + assert "席1 Alice" in out + assert "襲撃" in out diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index 349704c..f32775e 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -15,7 +15,7 @@ import pytest from wolfbot.domain.enums import Phase, Role, SubmissionType -from wolfbot.domain.models import Game, LogEntry, NightAction, Seat, Vote +from wolfbot.domain.models import Game, LogEntry, NightAction, Player, Seat, Vote from wolfbot.llm.prompt_builder import task_daytime_speech, task_night_action, task_vote from wolfbot.persistence.sqlite_repo import SqliteRepo from wolfbot.services.llm_service import LLMAction, LLMAdapter @@ -124,6 +124,104 @@ async def test_submit_llm_votes_returns_before_decider_completes(repo: SqliteRep assert len(gs.votes) == 2 +async def test_submit_llm_votes_reactive_voice_routes_to_dispatcher( + repo: SqliteRepo, +) -> None: + """Phase-D: reactive_voice games must skip the gameplay decider and ask + each NPC bot for its own vote. The decider must not be invoked at all + when the dispatcher is wired.""" + game_rounds, seats = await _seed_vote_game(repo) + # Re-create the game row with discussion_mode=reactive_voice so the + # branch in submit_llm_votes triggers. + async with repo._db.execute( # type: ignore[attr-defined] + "UPDATE games SET discussion_mode=? WHERE id=?", + ("reactive_voice", game_rounds.id), + ): + pass + await repo._db.commit() # type: ignore[attr-defined] + game = await repo.load_game(game_rounds.id) + assert game is not None + assert game.discussion_mode == "reactive_voice" + + captured_dispatch: list[dict[int, int | None]] = [] + + class _StubDispatcher: + async def dispatch_votes( + self, + *, + game_id: str, + day: int, + round_: int, + voters: list[Player], # type: ignore[name-defined] + seats: list[Seat], + candidate_seats: list[int], + public_state_summary: str = "", + ) -> dict[int, int | None]: + # Pretend NPC bots all chose seat 1 (the human). + result = {v.seat_no: 1 for v in voters} + captured_dispatch.append(result) + return result + + gs = _FakeGameService() + + class _UnusedDecider: + async def decide(self, *args: object, **kwargs: object) -> LLMAction: + raise AssertionError("gameplay decider must not be invoked in reactive_voice") + + adapter = LLMAdapter( + repo=repo, + decider=_UnusedDecider(), # type: ignore[arg-type] + rng=random.Random(0), + npc_decision_dispatcher=_StubDispatcher(), + ) + adapter.set_game_service(gs) # type: ignore[arg-type] + + players = await repo.load_players(game.id) + await adapter.submit_llm_votes(game, players, seats, candidates=None, round_=0) + await asyncio.gather(*list(adapter._background_tasks), return_exceptions=True) + + # Both LLM seats voted seat 1 via the dispatcher. + assert len(captured_dispatch) == 1 + voted_seats = {v[1] for v in gs.votes} + assert voted_seats == {2, 3} + targets = {v[1]: v[2] for v in gs.votes} + assert targets[2] == 1 and targets[3] == 1 + + +async def test_submit_llm_votes_reactive_voice_falls_back_when_no_dispatcher( + repo: SqliteRepo, +) -> None: + """Without a dispatcher the reactive_voice branch can't fire, so the + historical gameplay-LLM decider is still used (back-compat for tests + that don't wire the dispatcher).""" + game_rounds, seats = await _seed_vote_game(repo) + async with repo._db.execute( # type: ignore[attr-defined] + "UPDATE games SET discussion_mode=? WHERE id=?", + ("reactive_voice", game_rounds.id), + ): + pass + await repo._db.commit() # type: ignore[attr-defined] + game = await repo.load_game(game_rounds.id) + assert game is not None + + gs = _FakeGameService() + decider = _ScriptedDecider( + [ + LLMAction(intent="vote", target_name="H1", reason_summary="", confidence=0.5), + LLMAction(intent="vote", target_name="H1", reason_summary="", confidence=0.5), + ] + ) + adapter = LLMAdapter(repo=repo, decider=decider, rng=random.Random(0)) + # No npc_decision_dispatcher configured. + adapter.set_game_service(gs) # type: ignore[arg-type] + + players = await repo.load_players(game.id) + await adapter.submit_llm_votes(game, players, seats, candidates=None, round_=0) + await asyncio.gather(*list(adapter._background_tasks), return_exceptions=True) + # Fell back to the gameplay decider. + assert decider.call_count == 2 + + async def test_run_votes_aborts_when_phase_stale_at_dispatch(repo: SqliteRepo) -> None: """Per-seat stale check: if the phase is no longer DAY_VOTE when a sub-task reads the game, that sub-task returns without calling the decider. Parallel @@ -503,6 +601,124 @@ async def test_wolf_chat_skipped_when_phase_advances_during_ask(repo: SqliteRepo assert [r for r in priv_for_seat3 if r.get("kind") == "WOLF_CHAT"] == [] +async def test_reactive_voice_night_dispatcher_random_legal_fallback_on_null( + repo: SqliteRepo, +) -> None: + """When the NPC bot's night-action LLM call times out (or returns + illegal), the dispatcher returns ``None`` for that seat. Without a + fallback this null target is forwarded to ``submit_night_action`` + which rejects it as ILLEGAL_TARGET, leaving the seat missing → + ``plan_night_resolve`` parks the game in ``WAITING_HOST_DECISION`` + until the host runs ``/wolf force-skip``. Regression for game + bc20ebccb605 NIGHT 2 where jonas's knight_guard call took ~20s + (over the 12s dispatcher TTL) and stalled the entire phase. + + Match rounds-mode (`_resolve_target(allow_none=False)`) by picking + a uniform-random legal target so the night still resolves. + """ + game = Game( + id="g-rv-night", + guild_id="gu", + host_user_id="h", + phase=Phase.NIGHT, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + heaven_channel_id="h1", + wolves_channel_id="w1", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(game) + seats = [ + Seat(seat_no=1, display_name="H1", discord_user_id="u1", is_llm=False, persona_key=None), + Seat( + seat_no=2, + display_name="Wolf", + discord_user_id=None, + is_llm=True, + persona_key="gina", + ), + Seat( + seat_no=3, + display_name="Knight", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), + ] + for s in seats: + await repo.insert_seat(game.id, s) + await repo.set_player_role(game.id, 1, Role.VILLAGER) + await repo.set_player_role(game.id, 2, Role.WEREWOLF) + await repo.set_player_role(game.id, 3, Role.KNIGHT) + + class _NullingDispatcher: + """Returns None for every actor — simulates a slow LLM hitting + the dispatcher's request_ttl_ms timeout.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, list[int]]] = [] + + async def dispatch_night_actions( + self, + *, + game_id: str, + day: int, + action_kind: str, + actors: list[Player], + seats: list[Seat], + candidate_seats: list[int], + public_state_summary: str = "", + ) -> dict[int, int | None]: + self.calls.append((action_kind, [a.seat_no for a in actors])) + return {a.seat_no: None for a in actors} + + async def dispatch_wolf_chat_lines(self, **kwargs: object) -> None: + return None + + gs = _FakeGameService() + dispatcher = _NullingDispatcher() + adapter = LLMAdapter( + repo=repo, + decider=_ScriptedDecider([]), + rng=random.Random(0), + npc_decision_dispatcher=dispatcher, # type: ignore[arg-type] + ) + adapter.set_game_service(gs) # type: ignore[arg-type] + + players = await repo.load_players(game.id) + await adapter.submit_llm_night_actions(game, players, seats) + await asyncio.gather(*list(adapter._background_tasks), return_exceptions=True) + + # Dispatcher was invoked for both wolf_attack and knight_guard. + kinds_dispatched = {kind for kind, _ in dispatcher.calls} + assert "wolf_attack" in kinds_dispatched + assert "knight_guard" in kinds_dispatched + + # CRITICAL: every submission must carry a non-null target despite + # the dispatcher returning None. Without the fallback, the wolf's + # attack and knight's guard would be rejected and the game would + # stall in WAITING_HOST_DECISION at deadline. + submitted_kinds = {kind for _gid, _seat, kind, _t, _d in gs.nights} + assert SubmissionType.WOLF_ATTACK in submitted_kinds + assert SubmissionType.KNIGHT_GUARD in submitted_kinds + for _gid, seat, kind, target, _d in gs.nights: + assert target is not None, ( + f"seat={seat} kind={kind.value} got null target — " + "fallback to random legal target is missing, the night will stall" + ) + # Targets must be legal — wolf can't self-attack, knight can't + # self-guard, etc. + for _gid, _seat, kind, target, _d in gs.nights: + if kind is SubmissionType.WOLF_ATTACK: + # Wolf attacks any non-wolf alive seat: 1 (villager) or 3 (knight). + assert target in {1, 3} + elif kind is SubmissionType.KNIGHT_GUARD: + # Knight guards any other alive seat: 1 (villager) or 2 (wolf). + assert target in {1, 2} + + async def test_run_night_actions_skips_seat_with_existing_action(repo: SqliteRepo) -> None: """Fix 1: night re-dispatch won't double-submit for a seat that already has a submission (unless it's in unresolved_seats for a wolf split).""" @@ -1045,18 +1261,6 @@ async def _capture_ask_system_prompt( return system_prompt -async def test_ask_system_prompt_contains_game_rules_for_any_role(repo: SqliteRepo) -> None: - """Every LLM seat, regardless of role, must receive the fixed 9-player - rules block in its system prompt (role distribution, win conditions, - candidate-token rule).""" - system_prompt = await _capture_ask_system_prompt(repo, Role.VILLAGER) - assert "人狼2" in system_prompt - assert "村人3" in system_prompt - assert "生存人狼数が 0" in system_prompt - assert "生存人狼数が生存非人狼人数以上" in system_prompt - assert "候補トークン" in system_prompt - - async def test_ask_system_prompt_contains_3_1_roller_rules_for_any_role( repo: SqliteRepo, ) -> None: @@ -1069,17 +1273,6 @@ async def test_ask_system_prompt_contains_3_1_roller_rules_for_any_role( assert "真狼狼" in system_prompt -async def test_ask_system_prompt_contains_2_2_medium_roller_rules_for_any_role( - repo: SqliteRepo, -) -> None: - """2-2 / medium-roller guidance must reach every seat via the shared rules - block.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.MEDIUM) - assert "2-2" in system_prompt - assert "霊媒ローラー" in system_prompt - assert "原則として完走" in system_prompt - - async def test_ask_system_prompt_contains_2_1_and_1_2_formations_for_any_role( repo: SqliteRepo, ) -> None: @@ -1098,102 +1291,6 @@ async def test_ask_system_prompt_contains_2_1_and_1_2_formations_for_any_role( assert "1-2" in system_prompt, f"{role.name} missed 1-2" -async def test_ask_system_prompt_contains_three_seer_co_elimination_for_any_role( - repo: SqliteRepo, -) -> None: - """3占いCO・2非狼確定で残る 1 人を確定黒級とする消去法は共通ルール経由で - 全 role の system prompt に届く。非狼確定の厳格さと前提崩壊時の解除も同様。""" - for role in ( - Role.VILLAGER, - Role.SEER, - Role.MEDIUM, - Role.KNIGHT, - Role.WEREWOLF, - Role.MADMAN, - ): - system_prompt = await _capture_ask_system_prompt(repo, role) - assert "残る 1 人の占い師 CO を固定配役上の消去法" in system_prompt, ( - f"{role.name} missed the elimination rule" - ) - assert "『白判定』と『非狼確定』を混同しない" in system_prompt, ( - f"{role.name} missed the white-vs-confirmation caveat" - ) - assert "前提が崩れた場合は確定黒扱いを解除" in system_prompt, ( - f"{role.name} missed the breakdown / re-organize clause" - ) - - -async def test_ask_system_prompt_villager_seat_includes_three_seer_co_elimination_framing( - repo: SqliteRepo, -) -> None: - """村人席は共通ルールに加え、村人視点の framing - (投票・発言・進行提案で残る占い師 CO 位置を狼として扱う) も system prompt - に届く。""" - system_prompt = await _capture_ask_system_prompt(repo, Role.VILLAGER) - assert "残る占い師 CO 位置を投票・発言・進行提案" in system_prompt - assert "前提が崩れた瞬間" in system_prompt - - -async def test_ask_system_prompt_contains_co_overflow_rule_for_any_role( - repo: SqliteRepo, -) -> None: - """3 役職横断 CO 数・対抗 CO 超過分推理は共通ルール経由で全 role の - system prompt に届く。`CO 数 - 1` の式、超過分合計 3 で非 CO が確白級、 - 超過分合計 0〜2 で非 CO 断定しない、超過分合計 4 以上で再整理という - 境界条件のすべてを LLM が読める状態にする。""" - for role in ( - Role.VILLAGER, - Role.SEER, - Role.MEDIUM, - Role.KNIGHT, - Role.WEREWOLF, - Role.MADMAN, - ): - system_prompt = await _capture_ask_system_prompt(repo, role) - assert "対抗 CO 超過分" in system_prompt, f"{role.name} missed 対抗 CO 超過分" - assert "CO 数 - 1" in system_prompt, f"{role.name} missed CO 数 - 1" - assert "超過分合計が 3 に達した場合" in system_prompt, ( - f"{role.name} missed sum-3 trigger" - ) - assert "村陣営の確白級" in system_prompt, ( - f"{role.name} missed non-CO 確白級 consequence" - ) - assert "0〜2" in system_prompt, f"{role.name} missed sum 0〜2 caveat" - assert "4 以上" in system_prompt, f"{role.name} missed sum 4+ contradiction" - assert "配役上の消去法" in system_prompt, ( - f"{role.name} missed 配役上の消去法 framing" - ) - - -async def test_ask_system_prompt_contains_co_overflow_examples_for_any_role( - repo: SqliteRepo, -) -> None: - """3-2-1 / 2-2-2 / 3-1-1 / 4-1-1 の短い例も共通ルール経由で全 role に届く。""" - for role in ( - Role.VILLAGER, - Role.SEER, - Role.MEDIUM, - Role.KNIGHT, - Role.WEREWOLF, - Role.MADMAN, - ): - system_prompt = await _capture_ask_system_prompt(repo, role) - assert "3-2-1" in system_prompt, f"{role.name} missed 3-2-1 example" - assert "2-2-2" in system_prompt, f"{role.name} missed 2-2-2 example" - assert "3-1-1" in system_prompt, f"{role.name} missed 3-1-1 example" - assert "4-1-1" in system_prompt, f"{role.name} missed 4-1-1 example" - - -async def test_ask_system_prompt_villager_strategy_includes_co_overflow_action( - repo: SqliteRepo, -) -> None: - """村人席の system prompt には、共通ルールに加え、村人視点の運用 - (CO 群と非 CO 確白を整理し投票先を CO 群に絞る) が届く。""" - system_prompt = await _capture_ask_system_prompt(repo, Role.VILLAGER) - assert "対抗 CO 超過分を毎日整理する" in system_prompt - assert "投票先を CO 群に絞る" in system_prompt - - async def test_ask_system_prompt_seer_strategy_avoids_wasting_divination( repo: SqliteRepo, ) -> None: @@ -1205,86 +1302,6 @@ async def test_ask_system_prompt_seer_strategy_avoids_wasting_divination( assert "対抗 CO 群やまだ確定しない位置を優先して占う" in system_prompt -async def test_ask_system_prompt_medium_strategy_updates_co_inference( - repo: SqliteRepo, -) -> None: - """霊媒師席の system prompt には、霊媒結果で CO 数推理を更新する運用が - 届く。霊媒白は非狼だけを示す既存ルールと整合する形 (真役職 / 狂人)。""" - system_prompt = await _capture_ask_system_prompt(repo, Role.MEDIUM) - assert "霊媒結果は対抗 CO 超過分の CO 数推理を更新する材料" in system_prompt - assert "対抗 CO 群内の狼数を絞り" in system_prompt - assert "白なら真役職または狂人の可能性を分け" in system_prompt - assert "非 CO 確白の前提が保たれるか" in system_prompt - - -async def test_ask_system_prompt_knight_strategy_protects_non_co_certified_white( - repo: SqliteRepo, -) -> None: - """騎士席の system prompt には、超過分合計 3 で生まれた非 CO 確白級と - 単独で対抗のない真寄り情報役を護衛価値が高いと扱う運用が届く。""" - system_prompt = await _capture_ask_system_prompt(repo, Role.KNIGHT) - assert "対抗 CO 超過分合計 3 で生まれた非 CO 確白級" in system_prompt - assert "単独で対抗のない真寄り情報役は護衛価値が高い" in system_prompt - - -async def test_ask_system_prompt_werewolf_strategy_acknowledges_overcounter_risk( - repo: SqliteRepo, -) -> None: - """人狼席の system prompt には、騙りすぎで非 CO が確白級になるリスクを - 超過分集計の枠組みで認識する運用が届く。相方語彙は wolf 専用として - ここに含まれてよい。""" - system_prompt = await _capture_ask_system_prompt(repo, Role.WEREWOLF) - assert "対抗 CO 超過分" in system_prompt - assert "超過分合計が 3 に達した時点で" in system_prompt - assert "処刑候補が CO 群に集中する" in system_prompt - assert "相方と整合する形で選ぶ" in system_prompt - - -async def test_ask_system_prompt_madman_co_overflow_addition_keeps_partner_isolation( - repo: SqliteRepo, -) -> None: - """狂人席の system prompt には、同じリスクを公開情報視点で認識する運用が - 届く一方、wolf-coordination 語彙 (bare `相方` / `襲撃先を揃える`) は - 引き続き混入してはならず、本物の人狼位置を知っている前提の禁止文言も - 保持される (既存 leak guard との整合)。""" - system_prompt = await _capture_ask_system_prompt(repo, Role.MADMAN) - # 新規 CO-overflow 文言は届く。 - assert "対抗 CO 超過分" in system_prompt - assert "超過分合計が 3 に達した時点で" in system_prompt - assert "処刑候補が CO 群に集中するリスクを認識する" in system_prompt - assert "公開情報の各 CO 数と残り縄から判断する" in system_prompt - # Wolf-coordination 語彙が漏れていないこと。 - assert not re.search(r"相方(?!候補)", system_prompt), ( - "bare '相方' (actor mode) leaked into madman system prompt via " - "CO-overflow addition" - ) - assert "襲撃先を揃える" not in system_prompt - # 既存 prohibition 文言が残っていること。 - assert "人狼位置を知っている前提で話してはならない" in system_prompt - - -async def test_ask_system_prompt_contains_advanced_guard_vocab_for_any_role( - repo: SqliteRepo, -) -> None: - """Advanced guard vocabulary (鉄板護衛 / 捨て護衛 / 連続護衛不可 / 護衛読み / - 護衛誘導) lives in the shared rules block so every seat — not just the - knight — can interpret these terms when they appear in the public log.""" - for role in ( - Role.VILLAGER, - Role.SEER, - Role.MEDIUM, - Role.KNIGHT, - Role.WEREWOLF, - Role.MADMAN, - ): - system_prompt = await _capture_ask_system_prompt(repo, role) - assert "鉄板護衛" in system_prompt, f"{role.name} missed 鉄板護衛" - assert "捨て護衛" in system_prompt, f"{role.name} missed 捨て護衛" - assert "連続護衛不可" in system_prompt, f"{role.name} missed 連続護衛不可" - assert "護衛読み" in system_prompt, f"{role.name} missed 護衛読み" - assert "護衛誘導" in system_prompt, f"{role.name} missed 護衛誘導" - - async def test_ask_system_prompt_contains_enthusiast_checklist_for_any_role( repo: SqliteRepo, ) -> None: @@ -1299,50 +1316,6 @@ async def test_ask_system_prompt_contains_enthusiast_checklist_for_any_role( assert "1〜2 点" in system_prompt -async def test_ask_system_prompt_contains_fake_co_legality_for_any_role( - repo: SqliteRepo, -) -> None: - """Fake-CO legality constraints live in common rules so both wolf and - madman see them, and the wolf-coordination leak guards still hold when - exercising the madman path.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.MADMAN) - assert "実ルール上あり得る内容" in system_prompt - assert "自分護衛" in system_prompt - assert "同一対象連続護衛" in system_prompt - # Leak guards must still hold even with the new common-rules additions. - # Bare 相方 (actor mode, partner-known) absent; 相方候補 (public-log - # inference noun) is allowed in the shared 2-wolf-pair-inference rules. - assert not re.search(r"相方(?!候補)", system_prompt) - assert "襲撃先を揃える" not in system_prompt - - -async def test_ask_system_prompt_contains_day1_fake_seer_must_white_for_any_role( - repo: SqliteRepo, -) -> None: - """The NIGHT_0 day-1 fake-seer-must-white rule lives in the shared rules - block so every seat sees it. Exercising via VILLAGER (no wolf-strategy - leak) doubles as a non-leak guard.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.VILLAGER) - assert "NIGHT_0" in system_prompt - assert "初回" in system_prompt - assert "day 1" in system_prompt - assert "必ず白を主張" in system_prompt - assert "day 1 で初回黒主張はしない" in system_prompt - assert "偽占い師の黒結果主張は day 2 以降" in system_prompt - # Wolf-coordination guard still holds for the villager seat. Bare 相方 - # (actor mode) absent; 相方候補 (inference) allowed. - assert not re.search(r"相方(?!候補)", system_prompt) - assert "襲撃先を揃える" not in system_prompt - - -async def test_ask_system_prompt_wolf_seat_includes_wolf_strategy(repo: SqliteRepo) -> None: - """A werewolf LLM must receive wolf-coordination tips in its system - prompt (`相方`, `襲撃先を揃える`).""" - system_prompt = await _capture_ask_system_prompt(repo, Role.WEREWOLF) - assert "相方" in system_prompt - assert "襲撃先を揃える" in system_prompt - - async def test_ask_system_prompt_non_wolf_excludes_wolf_strategy(repo: SqliteRepo) -> None: """A non-wolf LLM must NOT receive wolf-coordination tips. This guards against strategy leakage through `build_system_prompt`. Bare `相方` @@ -1373,19 +1346,6 @@ async def test_ask_system_prompt_madman_excludes_wolf_positions_assumption( assert "襲撃先を揃える" not in system_prompt -async def test_ask_system_prompt_wolf_seat_includes_attack_evaluation_axes( - repo: SqliteRepo, -) -> None: - """End-to-end: a werewolf LLM's system prompt carries the new 4-axis attack - rubric (襲撃価値 / 護衛されやすさ / 騎士候補度) and the 騎士探し approach - label via `_build_strategy_block(Role.WEREWOLF)`.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.WEREWOLF) - assert "襲撃価値" in system_prompt - assert "護衛されやすさ" in system_prompt - assert "騎士候補度" in system_prompt - assert "騎士探し" in system_prompt - - async def test_ask_system_prompt_wolf_attack_task_includes_checklist( repo: SqliteRepo, ) -> None: @@ -1491,33 +1451,6 @@ async def test_ask_system_prompt_madman_vote_task_drops_partner_token( assert "席3 X" not in system_prompt -async def test_ask_system_prompt_role_strategy_isolated_between_roles( - repo: SqliteRepo, -) -> None: - """A role's own strategy phrase appears in its system prompt; other roles' - unique phrases must not. Integration-level analog of - `test_strategy_block_no_cross_role_leak` in test_llm_prompt_builder.py.""" - unique_phrases = { - Role.WEREWOLF: "相方を露骨に庇いすぎない", - Role.MADMAN: "人狼位置を知っている前提で話してはならない", - Role.SEER: "判定履歴を時系列で一貫", - Role.MEDIUM: "処刑された相手が狂人でも", - Role.KNIGHT: "前夜と違う相手を選ぶ", - Role.VILLAGER: "CO 騙りは村陣営としては行わない", - } - for role, own_phrase in unique_phrases.items(): - system_prompt = await _capture_ask_system_prompt(repo, role) - assert own_phrase in system_prompt, ( - f"{role.name} did not see its own phrase in system prompt" - ) - for other_role, other_phrase in unique_phrases.items(): - if other_role is role: - continue - assert other_phrase not in system_prompt, ( - f"{other_role.name}'s tip leaked into {role.name}'s system prompt" - ) - - async def test_ask_system_prompt_includes_speech_profile_section(repo: SqliteRepo) -> None: """Every LLM seat's system prompt carries the new `## 話法` section with the persona's first-person and the static common rule from the template.""" @@ -1574,67 +1507,6 @@ async def test_ask_system_prompt_non_wolf_excludes_wolf_strategy_even_with_speec ) -async def test_ask_system_prompt_contains_co_evaluation_rules_for_any_role( - repo: SqliteRepo, -) -> None: - """The shared CO evaluation guidance (single-CO default-truthy, conditions - to suspect, counter-CO comparison axes) must reach every LLM seat via the - rules block. `_build_game_rules_block` is role-independent so one role - suffices to exercise the production path.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.VILLAGER) - assert "単独 CO" in system_prompt - assert "真の役職者にかなり近い" in system_prompt - assert "対抗 CO" in system_prompt - assert "噛み筋" in system_prompt - - -async def test_ask_system_prompt_distinguishes_sole_survivor_from_lone_co( - repo: SqliteRepo, -) -> None: - """Every LLM seat must receive the refinement that distinguishes a truly- - lone CO (no counter ever appeared) from a sole-survivor CO (counter was - executed or attacked). The rules block enforces: 2+ historical COs → do - not auto-trust the survivor, and dead CO holders stay in the comparison - via death-timing integrity.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.VILLAGER) - assert "2 人以上" in system_prompt - assert "自動的に真置きしない" in system_prompt - assert "死亡済み" in system_prompt - assert "死亡タイミング" in system_prompt - - -async def test_ask_system_prompt_warns_against_last_surviving_co_for_any_role( - repo: SqliteRepo, -) -> None: - """Every LLM seat must receive the conclusion-side refinement: a - last-surviving CO is not automatically true. Wolves can leave an info role - unattacked, get the counter executed, or keep a CO around for protective - cover. Sample multiple roles to exercise both wolf-faction and village- - faction paths through the shared rules block.""" - for role in (Role.VILLAGER, Role.SEER, Role.WEREWOLF, Role.MADMAN, Role.KNIGHT): - system_prompt = await _capture_ask_system_prompt(repo, role) - assert "最後まで生き残った" in system_prompt, f"{role.name} missed last-survivor warning" - assert "噛まずに残した" in system_prompt, f"{role.name} missed wolf-skip framing" - assert "単独 CO だから真" in system_prompt, f"{role.name} missed shortcut prohibition" - - -async def test_ask_system_prompt_villager_seat_prohibits_villager_co( - repo: SqliteRepo, -) -> None: - """A villager LLM's system prompt must explicitly forbid declaring - '村人CO' / '素村CO' / '普通の村人です' / '役職は村人です' as a trust-buy and - must point at the alternative '非 CO の灰' stance. The guidance reaches the - villager via `_ROLE_STRATEGIES[Role.VILLAGER]`, so this test pins the - end-to-end composition path through `build_system_prompt`.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.VILLAGER) - assert "村人CO" in system_prompt - assert "素村CO" in system_prompt - assert "普通の村人です" in system_prompt - assert "役職は村人です" in system_prompt - assert "村人は能力結果を持たない" in system_prompt - assert "非 CO の灰" in system_prompt - - @pytest.mark.parametrize("role", [Role.WEREWOLF, Role.MADMAN, Role.SEER, Role.MEDIUM, Role.KNIGHT]) async def test_ask_system_prompt_villager_co_prohibition_isolated( repo: SqliteRepo, role: Role @@ -1648,100 +1520,6 @@ async def test_ask_system_prompt_villager_co_prohibition_isolated( assert "素村CO" not in system_prompt, f"{role.name} saw '素村CO' in system prompt" -async def test_ask_system_prompt_explains_medium_white_semantics_for_any_role( - repo: SqliteRepo, -) -> None: - """Every LLM seat must receive the shared rule that medium-white means - `not a real werewolf` only — not a role-claim refutation. Sampling one - role suffices because `_build_game_rules_block` is role-independent.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.VILLAGER) - assert "本物の人狼ではない" in system_prompt - assert "真占い師だった可能性と矛盾しない" in system_prompt - assert "偽扱いしない" in system_prompt - - -async def test_ask_system_prompt_contains_advanced_terminology_for_any_role( - repo: SqliteRepo, -) -> None: - """Every LLM seat (wolf, madman, villager, info role) must receive the - advanced jinro terminology (グレラン / 縄計算 / スケール / 確白 / 確黒 / - パンダ / PP / RPP) via the shared rules block. Sampling 4 representative - roles exercises `build_system_prompt` for both wolf-faction and village- - faction paths, since the terminology is emitted independent of role.""" - for role in (Role.VILLAGER, Role.SEER, Role.WEREWOLF, Role.MADMAN): - system_prompt = await _capture_ask_system_prompt(repo, role) - assert "グレラン" in system_prompt, f"{role.name} missed グレラン" - assert "縄計算" in system_prompt, f"{role.name} missed 縄計算" - assert "スケール" in system_prompt, f"{role.name} missed スケール" - assert "確白" in system_prompt, f"{role.name} missed 確白" - assert "確黒" in system_prompt, f"{role.name} missed 確黒" - assert "パンダ" in system_prompt, f"{role.name} missed パンダ" - assert "PP" in system_prompt, f"{role.name} missed PP" - assert "RPP" in system_prompt, f"{role.name} missed RPP" - - -async def test_ask_system_prompt_medium_guards_against_seer_co_white_misread( - repo: SqliteRepo, -) -> None: - """The Medium LLM's system prompt must carry the role-specific guidance: - medium-white on an executed Seer-CO is NOT proof of a fake seer; the - Medium should separate real-seer from non-wolf-fake hypotheses.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.MEDIUM) - assert "占い師 CO 偽の証明ではない" in system_prompt - assert "真占い師だった可能性" in system_prompt - assert "狂人" in system_prompt - - -async def test_ask_system_prompt_knight_includes_protection_success_co_strategy( - repo: SqliteRepo, -) -> None: - """The knight LLM's system prompt must cover the peaceful-morning / - protection-success CO pathway with the guard target disclosure rule.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.KNIGHT) - assert "平和な朝" in system_prompt - assert "護衛成功" in system_prompt - assert "護衛先を添えて" in system_prompt - - -async def test_ask_system_prompt_seer_includes_counter_co_strategy( - repo: SqliteRepo, -) -> None: - """A true seer LLM must receive proactive-CO (when no seer CO has appeared), - counter-CO (when a fake seer appears), and black-pull CO guidance so the - true seer doesn't stay silent and cede single-truth treatment to a fake.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.SEER) - assert "まだ占い師 CO が出ていない" in system_prompt - assert "対抗 CO" in system_prompt - assert "時系列で公開" in system_prompt - assert "黒を引いた場合" in system_prompt - - -async def test_ask_system_prompt_medium_includes_counter_co_strategy( - repo: SqliteRepo, -) -> None: - """A true medium LLM must receive the post-execution result-publication - duty and the counter-CO pathway, with explicit self-roller vulnerability - framing so the medium doesn't stay silent against a fake medium.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.MEDIUM) - assert "処刑が発生した翌日" in system_prompt - assert "対抗霊媒" in system_prompt - assert "ローラー" in system_prompt - - -async def test_ask_system_prompt_knight_includes_legal_guard_history_and_endgame_co( - repo: SqliteRepo, -) -> None: - """A knight LLM's system prompt must cover endgame / about-to-be-hung CO - timing AND must constrain the guard-diary to the bot's legal guard rules - (no self-guard, no consecutive guard of the same seat, no dead-seat guard).""" - system_prompt = await _capture_ask_system_prompt(repo, Role.KNIGHT) - assert "終盤" in system_prompt - assert "吊られそう" in system_prompt - assert "護衛履歴を日付順" in system_prompt - assert "自分護衛" in system_prompt - assert "同じ相手の連続護衛" in system_prompt - - async def test_ask_system_prompt_knight_includes_advanced_guard_strategy( repo: SqliteRepo, ) -> None: @@ -1789,79 +1567,6 @@ async def test_ask_system_prompt_knight_guard_task_includes_checklist( assert "騎士探し" not in system_prompt -async def test_ask_system_prompt_wolf_seat_includes_fake_strategy(repo: SqliteRepo) -> None: - """The werewolf LLM's system prompt must carry the fake-CO playbook: - day-1 seer fake is offered as a *conditional* option (not unconditional), - day-2+ medium/knight fake, the over-fake warning, and medium-roller / - knight-legal-guard-history caveats.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.WEREWOLF) - assert "day 1" in system_prompt - assert "占い師騙り" in system_prompt - assert "霊媒師騙り" in system_prompt - assert "騎士騙り" in system_prompt - assert "6 人以上" in system_prompt - assert "騙りすぎ" in system_prompt - # Conditional framing — day-1 seer fake is no longer unconditional. - assert "無条件" in system_prompt - assert "潜伏" in system_prompt - assert "相方が危険位置" in system_prompt - # Day-1 first-result-white anchor + day-1 black prohibition + day-2+ deferral. - assert "NIGHT_0 ランダム白" in system_prompt - assert "必ず白を主張" in system_prompt - assert "初日に黒を出す主張" in system_prompt - assert "黒出しは day 2 以降" in system_prompt - assert "前夜に占ったという想定" in system_prompt - - -async def test_ask_system_prompt_madman_includes_fake_strategy_without_wolf_coordination( - repo: SqliteRepo, -) -> None: - """The madman LLM's system prompt must carry the fake-CO playbook - (day-1 seer fake, day-2+ medium/knight fake, over-fake warning) as a - *conditional* option with misfire caveats — and no wolf-coordination - vocabulary (`相方` / `襲撃先を揃える`).""" - system_prompt = await _capture_ask_system_prompt(repo, Role.MADMAN) - assert "day 1" in system_prompt - assert "占い師騙り" in system_prompt - assert "霊媒師騙り" in system_prompt - assert "騎士騙り" in system_prompt - assert "6 人以上" in system_prompt - assert "騙りすぎ" in system_prompt - # Wolf-coordination vocabulary must not appear for the madman. Bare 相方 - # (actor mode, partner-known) absent; 相方候補 (public-log inference) allowed. - assert not re.search(r"相方(?!候補)", system_prompt) - assert "襲撃先を揃える" not in system_prompt - # Existing prohibition phrase must also still be present. - assert "人狼位置を知っている前提で話してはならない" in system_prompt - # Conditional framing + misfire / white-out caveats. - assert "無条件ではなく" in system_prompt - assert "複数の占い師 CO" in system_prompt - assert "誤爆リスク" in system_prompt - assert "白先が本物の狼とは限らない" in system_prompt - # Day-1 first-result-white anchor + day-1 black prohibition + day-2+ deferral. - assert "NIGHT_0 ランダム白" in system_prompt - assert "必ず白を主張" in system_prompt - assert "初日に黒を出す主張" in system_prompt - assert "黒出しは day 2 以降" in system_prompt - assert "誤爆リスクは day 2 以降の黒出しでも常に残る" in system_prompt - - -async def test_ask_system_prompt_seer_includes_night_targeting_axes( - repo: SqliteRepo, -) -> None: - """The true seer's system prompt must carry the night-divination targeting - axes (占い価値 / 灰を狭める / 対抗 CO / 囲い候補 / 投票 / - 白でも黒でも情報が落ちる) via `_ROLE_STRATEGIES[Role.SEER]`. End-to-end - analog of the unit-level `test_seer_strategy_includes_night_divination_targeting_axes`.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.SEER) - assert "占い価値" in system_prompt - assert "灰を狭める" in system_prompt - assert "対抗 CO" in system_prompt - assert "囲い候補" in system_prompt - assert "投票" in system_prompt - assert "白でも黒でも情報が落ちる" in system_prompt - - async def test_ask_system_prompt_seer_divine_task_includes_targeting_checklist( repo: SqliteRepo, ) -> None: @@ -1886,46 +1591,6 @@ async def test_ask_system_prompt_seer_divine_task_includes_targeting_checklist( assert "騎士探し" not in task_text -async def test_ask_system_prompt_wolf_seat_includes_day2_round1_fake_publication( - repo: SqliteRepo, -) -> None: - """The werewolf LLM's system prompt must carry the day-2+ round-1 fake- - result publication imperative for seer/medium/knight fakes, with the - integration cues (相方 / 囲い / 噛み筋 / 霊媒結果 / 合法な護衛履歴).""" - system_prompt = await _capture_ask_system_prompt(repo, Role.WEREWOLF) - assert "day 2 以降" in system_prompt - assert "1 巡目" in system_prompt - assert "前夜" in system_prompt - assert "能力結果" in system_prompt - assert "合法な護衛履歴" in system_prompt - # Wolf-private integration cues must be present. - assert "相方" in system_prompt - assert "囲い" in system_prompt - assert "噛み筋" in system_prompt - assert "霊媒結果" in system_prompt - - -async def test_ask_system_prompt_madman_includes_day2_round1_fake_publication( - repo: SqliteRepo, -) -> None: - """The madman LLM's system prompt must carry the day-2+ round-1 fake- - result publication imperative with explicit misfire framing — and must - NOT carry wolf-coordination vocabulary.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.MADMAN) - assert "day 2 以降" in system_prompt - assert "1 巡目" in system_prompt - assert "前夜" in system_prompt - assert "能力結果" in system_prompt - assert "誤爆リスク" in system_prompt - assert "白先が本物の狼とは限らない" in system_prompt - assert "処刑なしの日は結果なし" in system_prompt - assert "合法な護衛履歴" in system_prompt - # Wolf-coordination vocabulary must remain absent. Bare 相方 (actor mode) - # forbidden; 相方候補 (public-log inference) allowed in pair-inference. - assert not re.search(r"相方(?!候補)", system_prompt) - assert "襲撃先を揃える" not in system_prompt - - async def test_discussion_speech_day2_round1_passes_first_round_rule( repo: SqliteRepo, ) -> None: @@ -2296,23 +1961,6 @@ async def test_ask_system_prompt_wolf_seat_includes_two_wolf_set_framing( assert "視点漏れ" in system_prompt -async def test_ask_system_prompt_madman_pair_inference_carries_misfire( - repo: SqliteRepo, -) -> None: - """The madman system prompt must carry the public-log pair-inference - framing with explicit misfire awareness — without leaking wolf-coordination - vocabulary. Bare `相方` (actor mode) must remain absent; `相方候補` is the - only allowed form. `襲撃先を揃える` and `仲間の人狼` must be absent.""" - system_prompt = await _capture_ask_system_prompt(repo, Role.MADMAN) - assert "相方候補" in system_prompt - assert "公開ログからの推定" in system_prompt - assert "誤爆" in system_prompt - # Wolf-coordination vocab forbidden for the madman. - assert not re.search(r"相方(?!候補)", system_prompt) - assert "襲撃先を揃える" not in system_prompt - assert "仲間の人狼" not in system_prompt - - async def test_ask_system_prompt_wolf_vote_task_extends_with_two_wolf_set( repo: SqliteRepo, ) -> None: @@ -2513,12 +2161,14 @@ async def test_gemini_decider_sends_response_json_schema_and_thinking_level() -> def test_make_llm_decider_branches_on_provider(monkeypatch: pytest.MonkeyPatch) -> None: """The provider-aware factory must wire the right decider class for each - LLM_PROVIDER value. Construction goes through Settings so the validator - runs end-to-end. The Gemini branch stubs `google.genai.Client` so the - test never depends on a live ADC environment.""" + GAMEPLAY_LLM_PROVIDER value. Construction goes through MasterSettings so + the validator runs end-to-end and the projection through + ``gameplay_decider_config()`` is exercised. The Gemini branch stubs + ``google.genai.Client`` so the test never depends on a live ADC + environment.""" from pydantic import SecretStr - from wolfbot.config import Settings + from wolfbot.config import MasterSettings from wolfbot.services.llm_service import ( DeepSeekLLMActionDecider, GeminiLLMActionDecider, @@ -2540,29 +2190,29 @@ def __init__(self, **kwargs: object) -> None: "MAIN_TEXT_CHANNEL_ID": 2, "MAIN_VOICE_CHANNEL_ID": 3, } - s_xai = Settings( # type: ignore[arg-type] + s_xai = MasterSettings( # type: ignore[arg-type] _env_file=None, **base_kwargs, - LLM_PROVIDER="xai", - XAI_API_KEY=SecretStr("x"), + GAMEPLAY_LLM_PROVIDER="xai", + GAMEPLAY_LLM_API_KEY=SecretStr("x"), ) - assert isinstance(make_llm_decider(s_xai), XAILLMActionDecider) + assert isinstance(make_llm_decider(s_xai.gameplay_decider_config()), XAILLMActionDecider) - s_ds = Settings( # type: ignore[arg-type] + s_ds = MasterSettings( # type: ignore[arg-type] _env_file=None, **base_kwargs, - LLM_PROVIDER="deepseek", - DEEPSEEK_API_KEY=SecretStr("d"), + GAMEPLAY_LLM_PROVIDER="deepseek", + GAMEPLAY_LLM_API_KEY=SecretStr("d"), ) - assert isinstance(make_llm_decider(s_ds), DeepSeekLLMActionDecider) + assert isinstance(make_llm_decider(s_ds.gameplay_decider_config()), DeepSeekLLMActionDecider) - s_gem = Settings( # type: ignore[arg-type] + s_gem = MasterSettings( # type: ignore[arg-type] _env_file=None, **base_kwargs, - LLM_PROVIDER="gemini", - GEMINI_VERTEX_PROJECT="my-project", + GAMEPLAY_LLM_PROVIDER="gemini", + GAMEPLAY_LLM_VERTEX_PROJECT="my-project", ) - assert isinstance(make_llm_decider(s_gem), GeminiLLMActionDecider) + assert isinstance(make_llm_decider(s_gem.gameplay_decider_config()), GeminiLLMActionDecider) def test_make_gemini_decider_constructs_vertex_ai_client( @@ -2602,3 +2252,214 @@ def __init__(self, **kwargs: object) -> None: assert decider.model == "gemini-3-flash-preview" assert decider.thinking_level == "high" assert decider.timeout == 15.0 + + +# ---------------------------------------------------------- trace integration +# +# These verify that each provider's ``decide()`` actually flushes a JSONL +# trace line carrying the right ``provider`` tag, the model name, the raw +# response text, and token usage extracted from the SDK reply. The kwargs +# tests above only check the request side; without these the trace could +# silently regress (e.g. a provider rename, missing token extraction, or a +# refactor that drops ``log_llm_call`` from one branch's ``finally``) and +# still pass CI. Also covers the error path so a failure recorded into the +# trace has ``response=None`` and a non-empty ``error`` string. + + +class _FakeChatCompletionsWithUsage: + """Like ``_FakeChatCompletions`` but the response carries ``.usage`` so + the OpenAI-shaped token extractor returns real integers.""" + + def __init__(self, content: str, usage: tuple[int, int, int] = (1500, 200, 1700)) -> None: + self._content = content + self._usage = usage + self.calls: list[dict[str, object]] = [] + + async def create(self, **kwargs: object) -> object: + from types import SimpleNamespace + + self.calls.append(kwargs) + prompt, completion, total = self._usage + return SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content=self._content))], + usage=SimpleNamespace( + prompt_tokens=prompt, + completion_tokens=completion, + total_tokens=total, + ), + ) + + +class _FakeAsyncOpenAIWithUsage: + """OpenAI-shaped fake whose responses include ``usage``.""" + + def __init__(self, content: str, usage: tuple[int, int, int] = (1500, 200, 1700)) -> None: + from types import SimpleNamespace + + self.chat = SimpleNamespace(completions=_FakeChatCompletionsWithUsage(content, usage)) + + +class _FakeGenAIModelsWithUsage: + """``generate_content`` fake whose response exposes ``usage_metadata`` + so ``extract_gemini_vertex_tokens`` returns real integers.""" + + def __init__(self, content: str, usage: tuple[int, int, int] = (2000, 300, 2300)) -> None: + self._content = content + self._usage = usage + self.calls: list[dict[str, object]] = [] + + async def generate_content(self, **kwargs: object) -> object: + from types import SimpleNamespace + + self.calls.append(kwargs) + prompt, completion, total = self._usage + return SimpleNamespace( + text=self._content, + usage_metadata=SimpleNamespace( + prompt_token_count=prompt, + candidates_token_count=completion, + total_token_count=total, + ), + ) + + +class _FakeGenAIClientWithUsage: + def __init__(self, content: str, usage: tuple[int, int, int] = (2000, 300, 2300)) -> None: + from types import SimpleNamespace + + self.aio = SimpleNamespace(models=_FakeGenAIModelsWithUsage(content, usage)) + + +def _read_trace_jsonl(trace_dir, game_id: str = "g_trace") -> list[dict]: + import json + + path = trace_dir / game_id / "gameplay.jsonl" + assert path.exists(), f"trace file not written: {path}" + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] + + +@pytest.fixture +def trace_dir(tmp_path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("WOLFBOT_LLM_TRACE_DIR", str(tmp_path)) + monkeypatch.delenv("WOLFBOT_LLM_TRACE_DISABLED", raising=False) + return tmp_path + + +async def test_xai_decider_writes_trace_with_response_and_tokens( + trace_dir, +) -> None: + from wolfbot.services.llm_service import XAILLMActionDecider + from wolfbot.services.llm_trace import trace_context + + fake = _FakeAsyncOpenAIWithUsage(_canned_action_json()) + decider = XAILLMActionDecider(client=fake, model="grok-4-1-fast") # type: ignore[arg-type] + with trace_context(game_id="g_trace", phase="DAY_DISCUSSION", day=1): + await decider.decide("sys-xai", "ctx-xai") + + rows = _read_trace_jsonl(trace_dir) + assert len(rows) == 1 + row = rows[0] + assert row["provider"] == "xai" + assert row["model"] == "grok-4-1-fast" + assert row["role"] == "gameplay" + assert row["system_prompt"] == "sys-xai" + assert row["user_prompt"] == "ctx-xai" + assert '"intent": "speak"' in row["response"] + assert row["error"] is None + assert row["tokens"] == {"prompt": 1500, "completion": 200, "total": 1700} + assert row["phase"] == "DAY_DISCUSSION" + assert row["day"] == 1 + + +async def test_deepseek_decider_writes_trace_with_thinking_metadata( + trace_dir, +) -> None: + from wolfbot.services.llm_service import DeepSeekLLMActionDecider + from wolfbot.services.llm_trace import trace_context + + fake = _FakeAsyncOpenAIWithUsage(_canned_action_json(), usage=(900, 110, 1010)) + decider = DeepSeekLLMActionDecider( + client=fake, # type: ignore[arg-type] + model="deepseek-v4-flash", + thinking="enabled", + reasoning_effort="max", + ) + with trace_context(game_id="g_trace"): + await decider.decide("sys-ds", "ctx-ds") + + rows = _read_trace_jsonl(trace_dir) + assert len(rows) == 1 + row = rows[0] + assert row["provider"] == "deepseek" + assert row["model"] == "deepseek-v4-flash" + assert row["error"] is None + assert row["tokens"] == {"prompt": 900, "completion": 110, "total": 1010} + # System prompt is the full one-with-JSON-contract, not the bare input. + assert "sys-ds" in row["system_prompt"] + assert "json" in row["system_prompt"].lower() + # DeepSeek-specific knobs surfaced via extra metadata. + assert row["metadata"]["thinking"] == "enabled" + assert row["metadata"]["reasoning_effort"] == "max" + + +async def test_gemini_decider_writes_trace_with_thinking_level(trace_dir) -> None: + from wolfbot.services.llm_service import GeminiLLMActionDecider + from wolfbot.services.llm_trace import trace_context + + fake = _FakeGenAIClientWithUsage(_canned_action_json(), usage=(1800, 220, 2020)) + decider = GeminiLLMActionDecider( + client=fake, model="gemini-3-flash-preview", thinking_level="medium" + ) + with trace_context(game_id="g_trace"): + await decider.decide("sys-gem", "ctx-gem") + + rows = _read_trace_jsonl(trace_dir) + assert len(rows) == 1 + row = rows[0] + assert row["provider"] == "gemini" + assert row["model"] == "gemini-3-flash-preview" + assert row["error"] is None + assert row["tokens"] == {"prompt": 1800, "completion": 220, "total": 2020} + assert row["system_prompt"] == "sys-gem" + assert row["user_prompt"] == "ctx-gem" + assert row["metadata"]["thinking_level"] == "medium" + + +async def test_decider_error_path_writes_trace_with_null_response( + trace_dir, +) -> None: + """When the underlying SDK call raises, the trace line records the error + string, leaves ``response`` as None, and tokens as None — so the viewer + can show that an LLM round failed without crashing on missing fields.""" + from wolfbot.services.llm_service import XAILLMActionDecider + from wolfbot.services.llm_trace import trace_context + + class _Boom: + async def create(self, **kwargs: object) -> object: + raise RuntimeError("upstream API exploded") + + class _BoomChat: + completions = _Boom() + + class _BoomClient: + chat = _BoomChat() + + # tenacity retries 4 times before giving up; suppress the final raise so + # we can inspect the trace. + decider = XAILLMActionDecider(client=_BoomClient(), model="grok-x") # type: ignore[arg-type] + with ( + trace_context(game_id="g_trace"), + pytest.raises(RuntimeError, match="exploded"), + ): + await decider.decide("sys", "ctx") + + rows = _read_trace_jsonl(trace_dir) + # 4 retry attempts → 4 trace lines (each finally fires once). + assert len(rows) == 4 + for row in rows: + assert row["provider"] == "xai" + assert row["response"] is None + assert row["tokens"] is None + assert row["error"] is not None + assert "RuntimeError" in row["error"] + assert "exploded" in row["error"] diff --git a/tests/test_llm_structured_output.py b/tests/test_llm_structured_output.py index 55ad0fb..2e4ee83 100644 --- a/tests/test_llm_structured_output.py +++ b/tests/test_llm_structured_output.py @@ -10,7 +10,8 @@ from wolfbot.domain.enums import Phase, Role, SubmissionType from wolfbot.domain.models import Game, Seat -from wolfbot.llm.personas import PERSONAS, pick_personas +from wolfbot.llm.persona_base import pick_personas +from wolfbot.npc.personas import NPC_PERSONAS from wolfbot.services.llm_service import ( RESPONSE_SCHEMA, FakeLLMActionDecider, @@ -65,13 +66,17 @@ def test_response_schema_has_required_fields() -> None: "target_name", "reason_summary", "confidence", + "co_declaration", + "claimed_seer_result", + "claimed_medium_result", + "suspicions", } assert schema["additionalProperties"] is False def test_pick_personas_returns_requested_count_and_no_duplicates() -> None: rng = random.Random(0) - picks = pick_personas(5, rng) + picks = pick_personas(NPC_PERSONAS, 5, rng) assert len(picks) == 5 assert len({p.key for p in picks}) == 5 @@ -79,7 +84,7 @@ def test_pick_personas_returns_requested_count_and_no_duplicates() -> None: def test_pick_personas_rejects_over_capacity() -> None: rng = random.Random(0) with pytest.raises(ValueError): - pick_personas(len(PERSONAS) + 1, rng) + pick_personas(NPC_PERSONAS, len(NPC_PERSONAS) + 1, rng) # ---------------------------------------------------------- LLMAdapter diff --git a/tests/test_llm_trace.py b/tests/test_llm_trace.py new file mode 100644 index 0000000..aec906d --- /dev/null +++ b/tests/test_llm_trace.py @@ -0,0 +1,307 @@ +"""Tests for `wolfbot.services.llm_trace` JSONL writer.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from wolfbot.services.llm_trace import ( + log_llm_call, + parse_day_from_phase_id, + parse_game_id_from_phase_id, + trace_context, +) + + +@pytest.fixture +def trace_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setenv("WOLFBOT_LLM_TRACE_DIR", str(tmp_path)) + monkeypatch.delenv("WOLFBOT_LLM_TRACE_DISABLED", raising=False) + return tmp_path + + +async def test_log_llm_call_writes_jsonl_line(trace_dir: Path) -> None: + with trace_context( + game_id="g_abc", + phase="DAY_DISCUSSION", + day=2, + actor="seat=4 persona=setsu", + metadata={"task": "vote"}, + ): + await log_llm_call( + role="gameplay", + provider="xai", + model="grok-4-1-fast", + system_prompt="sys", + user_prompt="usr", + response='{"intent":"vote"}', + latency_ms=123, + ) + + path = trace_dir / "g_abc" / "gameplay.jsonl" + assert path.exists(), "trace file was not created" + line = path.read_text(encoding="utf-8").strip() + entry = json.loads(line) + + assert entry["role"] == "gameplay" + assert entry["provider"] == "xai" + assert entry["model"] == "grok-4-1-fast" + assert entry["phase"] == "DAY_DISCUSSION" + assert entry["day"] == 2 + assert entry["actor"] == "seat=4 persona=setsu" + assert entry["system_prompt"] == "sys" + assert entry["user_prompt"] == "usr" + assert entry["response"] == '{"intent":"vote"}' + assert entry["latency_ms"] == 123 + assert entry["error"] is None + assert entry["metadata"] == {"task": "vote"} + assert entry["ts"].endswith("+00:00") + + +async def test_log_llm_call_appends_multiple_lines(trace_dir: Path) -> None: + with trace_context(game_id="g_one"): + for i in range(3): + await log_llm_call( + role="gameplay", + provider="gemini", + model="gemini-3-flash", + system_prompt="s", + user_prompt=f"u{i}", + response="r", + latency_ms=i, + ) + + path = trace_dir / "g_one" / "gameplay.jsonl" + lines = path.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 3 + payloads = [json.loads(line) for line in lines] + assert [p["user_prompt"] for p in payloads] == ["u0", "u1", "u2"] + + +async def test_log_llm_call_uses_no_game_when_context_missing(trace_dir: Path) -> None: + await log_llm_call( + role="voice_stt", + provider="gemini", + model="gemini-2.0-flash-lite", + system_prompt="sys", + user_prompt="[audio]", + response='{"transcript":"hi"}', + latency_ms=42, + ) + path = trace_dir / "no_game" / "voice_stt.jsonl" + assert path.exists() + + +async def test_log_llm_call_respects_file_stem_override(trace_dir: Path) -> None: + with trace_context(game_id="g_two"): + await log_llm_call( + role="npc_speech", + provider="openai-compat", + model="grok-4-1-fast", + system_prompt="s", + user_prompt="u", + response="r", + latency_ms=10, + file_stem="npc_setsu", + ) + assert (trace_dir / "g_two" / "npc_setsu.jsonl").exists() + + +async def test_log_llm_call_records_error_path(trace_dir: Path) -> None: + with trace_context(game_id="g_err"): + await log_llm_call( + role="gameplay", + provider="xai", + model="grok-4-1-fast", + system_prompt="s", + user_prompt="u", + response=None, + latency_ms=5000, + error="TimeoutError: no response", + ) + entry = json.loads( + (trace_dir / "g_err" / "gameplay.jsonl").read_text(encoding="utf-8").strip() + ) + assert entry["response"] is None + assert entry["error"] == "TimeoutError: no response" + + +async def test_disabled_via_env_writes_nothing( + trace_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("WOLFBOT_LLM_TRACE_DISABLED", "1") + with trace_context(game_id="g_disabled"): + await log_llm_call( + role="gameplay", + provider="xai", + model="grok-4-1-fast", + system_prompt="s", + user_prompt="u", + response="r", + latency_ms=1, + ) + assert not (trace_dir / "g_disabled").exists() + + +async def test_path_components_are_sanitized(trace_dir: Path) -> None: + # game_id with path-traversal-ish chars + with trace_context(game_id="../evil/../escape"): + await log_llm_call( + role="gameplay", + provider="xai", + model="grok-4-1-fast", + system_prompt="s", + user_prompt="u", + response="r", + latency_ms=1, + ) + # No directory should be created outside trace_dir. + assert not (trace_dir.parent / "evil").exists() + # The sanitized name lives inside trace_dir. + children = [p.name for p in trace_dir.iterdir() if p.is_dir()] + assert children, "expected a sanitized subdirectory" + assert all(".." not in name for name in children) + + +async def test_extra_metadata_merges_with_context(trace_dir: Path) -> None: + with trace_context(game_id="g_meta", metadata={"task": "vote"}): + await log_llm_call( + role="gameplay", + provider="deepseek", + model="deepseek-chat", + system_prompt="s", + user_prompt="u", + response="r", + latency_ms=1, + extra={"thinking": "enabled", "reasoning_effort": "max"}, + ) + entry = json.loads( + (trace_dir / "g_meta" / "gameplay.jsonl").read_text(encoding="utf-8").strip() + ) + assert entry["metadata"] == { + "task": "vote", + "thinking": "enabled", + "reasoning_effort": "max", + } + + +async def test_log_llm_call_records_tokens(trace_dir: Path) -> None: + with trace_context(game_id="g_tok"): + await log_llm_call( + role="gameplay", + provider="xai", + model="grok-4-1-fast", + system_prompt="s", + user_prompt="u", + response="r", + latency_ms=42, + tokens={"prompt": 1500, "completion": 200, "total": 1700}, + ) + entry = json.loads( + (trace_dir / "g_tok" / "gameplay.jsonl").read_text(encoding="utf-8").strip() + ) + assert entry["tokens"] == {"prompt": 1500, "completion": 200, "total": 1700} + + +def test_extract_openai_tokens() -> None: + from types import SimpleNamespace + + from wolfbot.services.llm_trace import extract_openai_tokens + + resp = SimpleNamespace( + usage=SimpleNamespace( + prompt_tokens=1500, completion_tokens=200, total_tokens=1700 + ) + ) + assert extract_openai_tokens(resp) == { + "prompt": 1500, + "completion": 200, + "total": 1700, + } + # Test fakes without `usage` must return None, never raise. + assert extract_openai_tokens(SimpleNamespace()) is None + + +def test_extract_gemini_vertex_tokens() -> None: + from types import SimpleNamespace + + from wolfbot.services.llm_trace import extract_gemini_vertex_tokens + + resp = SimpleNamespace( + usage_metadata=SimpleNamespace( + prompt_token_count=2000, + candidates_token_count=300, + total_token_count=2300, + ) + ) + assert extract_gemini_vertex_tokens(resp) == { + "prompt": 2000, + "completion": 300, + "total": 2300, + } + assert extract_gemini_vertex_tokens(SimpleNamespace()) is None + + +def test_extract_gemini_rest_tokens() -> None: + from wolfbot.services.llm_trace import extract_gemini_rest_tokens + + body = { + "candidates": [], + "usageMetadata": { + "promptTokenCount": 800, + "candidatesTokenCount": 100, + "totalTokenCount": 900, + }, + } + assert extract_gemini_rest_tokens(body) == { + "prompt": 800, + "completion": 100, + "total": 900, + } + assert extract_gemini_rest_tokens({}) is None + + +def test_parse_game_id_from_phase_id() -> None: + assert parse_game_id_from_phase_id("g_abc::day1::DAY_DISCUSSION::1") == "g_abc" + assert parse_game_id_from_phase_id(None) is None + assert parse_game_id_from_phase_id("") is None + assert parse_game_id_from_phase_id("noseparator") is None + + +def test_parse_day_from_phase_id() -> None: + assert parse_day_from_phase_id("g_abc::day1::DAY_DISCUSSION::1") == 1 + assert parse_day_from_phase_id("g_abc::day12::NIGHT::3") == 12 + assert parse_day_from_phase_id("g_abc::day0::NIGHT_0::1") == 0 + assert parse_day_from_phase_id(None) is None + assert parse_day_from_phase_id("") is None + assert parse_day_from_phase_id("g_abc") is None + assert parse_day_from_phase_id("g_abc::notaday::DAY_DISCUSSION::1") is None + assert parse_day_from_phase_id("g_abc::dayX::DAY_DISCUSSION::1") is None + + +async def test_concurrent_writes_interleave_at_line_boundaries( + trace_dir: Path, +) -> None: + import asyncio + + async def write(i: int) -> None: + with trace_context(game_id="g_concurrent"): + await log_llm_call( + role="gameplay", + provider="xai", + model="grok-4-1-fast", + system_prompt="s", + user_prompt=f"call_{i}", + response="r", + latency_ms=i, + ) + + await asyncio.gather(*(write(i) for i in range(20))) + path = trace_dir / "g_concurrent" / "gameplay.jsonl" + lines = path.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 20 + # Each line must parse as JSON — i.e. no torn writes. + for line in lines: + json.loads(line) diff --git a/tests/test_lobby_atomicity.py b/tests/test_lobby_atomicity.py index 1306c49..d721a3c 100644 --- a/tests/test_lobby_atomicity.py +++ b/tests/test_lobby_atomicity.py @@ -13,7 +13,8 @@ from wolfbot.domain.enums import Phase from wolfbot.domain.models import Game, Seat -from wolfbot.llm.personas import pick_personas +from wolfbot.llm.persona_base import pick_personas +from wolfbot.npc.personas import NPC_PERSONAS from wolfbot.persistence.sqlite_repo import ( JoinLobbyResult, LeaveLobbyResult, @@ -98,7 +99,7 @@ async def test_join_lobby_rejects_when_9_humans_present(repo: SqliteRepo) -> Non async def test_join_lobby_rejects_stale_phase_after_start(repo: SqliteRepo) -> None: """Repro for the Codex v2 High finding: stale /wolf join after /wolf start.""" game_id = await _seed_lobby_with_humans(repo, 8) - specs = [(p.display_name, p.key) for p in pick_personas(1, random.Random(0))] + specs = [(p.display_name, p.key) for p in pick_personas(NPC_PERSONAS, 1, random.Random(0))] ok = await repo.claim_start_and_backfill(game_id, expected_phase=Phase.LOBBY, llm_seats=specs) assert ok is True @@ -134,7 +135,7 @@ async def test_leave_lobby_rejects_stale_phase_after_start(repo: SqliteRepo) -> would silently drop a seat and break plan_setup's 9-seat invariant. """ game_id = await _seed_lobby_with_humans(repo, 8) - specs = [(p.display_name, p.key) for p in pick_personas(1, random.Random(0))] + specs = [(p.display_name, p.key) for p in pick_personas(NPC_PERSONAS, 1, random.Random(0))] ok = await repo.claim_start_and_backfill(game_id, expected_phase=Phase.LOBBY, llm_seats=specs) assert ok is True seats_before = await repo.load_seats(game_id) diff --git a/tests/test_master_decision_dispatcher.py b/tests/test_master_decision_dispatcher.py new file mode 100644 index 0000000..bece4f2 --- /dev/null +++ b/tests/test_master_decision_dispatcher.py @@ -0,0 +1,604 @@ +"""Master-side `NpcDecisionDispatcher` — fan-out + future resolution. + +End-to-end through `dispatch_votes` / `dispatch_night_actions`: + +* Online NPC's reply via `on_vote_decision` resolves the future. +* Offline NPC (no registry entry) → None, no WS send. +* Send failure → None, future is reaped. +* No reply within `request_ttl_ms` → None (timeout). + +The tests use an `InMemoryNpcRegistry` and a captured-`send` list so we +exercise the dispatch path without standing up a real WS. +""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import Awaitable, Callable + +from wolfbot.domain.enums import Role +from wolfbot.domain.models import Player, Seat +from wolfbot.domain.ws_messages import ( + DecideVoteRequest, + NightActionDecision, + VoteDecision, +) +from wolfbot.master.ws.decision_dispatcher import ( + DecisionDispatcherConfig, + NpcDecisionDispatcher, +) +from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry + + +def _capture_send(buf: list[str]) -> Callable[[str], Awaitable[None]]: + async def _send(msg: str) -> None: + buf.append(msg) + + return _send + + +def _seats() -> list[Seat]: + return [ + Seat(seat_no=1, display_name="Alice", is_llm=False, persona_key=None, + discord_user_id="u1"), + Seat(seat_no=2, display_name="Bob", is_llm=True, persona_key="setsu", + discord_user_id=None), + Seat(seat_no=3, display_name="Carol", is_llm=True, persona_key="gina", + discord_user_id=None), + ] + + +def _voters() -> list[Player]: + return [ + Player(seat_no=2, role=Role.VILLAGER, alive=True), + Player(seat_no=3, role=Role.WEREWOLF, alive=True), + ] + + +async def test_dispatch_votes_resolves_when_npcs_reply() -> None: + registry = InMemoryNpcRegistry() + seat2_buf: list[str] = [] + seat3_buf: list[str] = [] + registry.register( + npc_id="npc_seat2", discord_bot_user_id="bot2", + supported_voices=(), version="1", + send=_capture_send(seat2_buf), now_ms=1000, persona_key="setsu", + ) + registry.assign("npc_seat2", seat=2, game_id="g1", phase_id="g1::day1::DAY_VOTE::1") + registry.register( + npc_id="npc_seat3", discord_bot_user_id="bot3", + supported_voices=(), version="1", + send=_capture_send(seat3_buf), now_ms=1000, persona_key="gina", + ) + registry.assign("npc_seat3", seat=3, game_id="g1", phase_id="g1::day1::DAY_VOTE::1") + + dispatcher = NpcDecisionDispatcher( + registry=registry, + config=DecisionDispatcherConfig(request_ttl_ms=5_000, inter_request_stagger_ms=0), + now_ms=lambda: 2_000, + ) + + async def _drive() -> dict[int, int | None]: + return await dispatcher.dispatch_votes( + game_id="g1", day=1, round_=0, + voters=_voters(), seats=_seats(), + candidate_seats=[1, 2, 3], + ) + + task = asyncio.create_task(_drive()) + # Wait until the dispatcher has sent both requests. + for _ in range(50): + if len(seat2_buf) and len(seat3_buf): + break + await asyncio.sleep(0.01) + assert len(seat2_buf) == 1 and len(seat3_buf) == 1 + + # Reply for both seats. + for buf, target_seat, npc_id, seat_no in ( + (seat2_buf, 1, "npc_seat2", 2), + (seat3_buf, 1, "npc_seat3", 3), + ): + sent = json.loads(buf[0]) + await dispatcher.on_vote_decision( + VoteDecision( + ts=3_000, trace_id=sent["trace_id"], request_id=sent["request_id"], + npc_id=npc_id, seat_no=seat_no, target_seat=target_seat, + reason_summary="test", + ) + ) + + results = await task + assert results == {2: 1, 3: 1} + + +async def test_dispatch_votes_offline_seat_resolves_to_none() -> None: + registry = InMemoryNpcRegistry() + # Only seat 2 is online; seat 3 has no NPC. + seat2_buf: list[str] = [] + registry.register( + npc_id="npc_seat2", discord_bot_user_id="bot2", + supported_voices=(), version="1", + send=_capture_send(seat2_buf), now_ms=1000, persona_key="setsu", + ) + registry.assign("npc_seat2", seat=2, game_id="g1", phase_id="g1::day1::DAY_VOTE::1") + + dispatcher = NpcDecisionDispatcher( + registry=registry, + config=DecisionDispatcherConfig(request_ttl_ms=5_000, inter_request_stagger_ms=0), + now_ms=lambda: 2_000, + ) + + async def _drive() -> dict[int, int | None]: + return await dispatcher.dispatch_votes( + game_id="g1", day=1, round_=0, + voters=_voters(), seats=_seats(), + candidate_seats=[1, 2, 3], + ) + + task = asyncio.create_task(_drive()) + for _ in range(50): + if seat2_buf: + break + await asyncio.sleep(0.01) + assert len(seat2_buf) == 1 + + sent = json.loads(seat2_buf[0]) + await dispatcher.on_vote_decision( + VoteDecision( + ts=3_000, trace_id=sent["trace_id"], request_id=sent["request_id"], + npc_id="npc_seat2", seat_no=2, target_seat=3, + ) + ) + results = await task + assert results[2] == 3 + assert results[3] is None + + +async def test_dispatch_votes_timeout_yields_none() -> None: + registry = InMemoryNpcRegistry() + seat2_buf: list[str] = [] + registry.register( + npc_id="npc_seat2", discord_bot_user_id="bot2", + supported_voices=(), version="1", + send=_capture_send(seat2_buf), now_ms=1000, persona_key="setsu", + ) + registry.assign("npc_seat2", seat=2, game_id="g1", phase_id="g1::day1::DAY_VOTE::1") + + # Very short TTL so the test runs fast. + dispatcher = NpcDecisionDispatcher( + registry=registry, + config=DecisionDispatcherConfig(request_ttl_ms=100, inter_request_stagger_ms=0), + now_ms=lambda: 0, + ) + voter_only = [Player(seat_no=2, role=Role.VILLAGER, alive=True)] + results = await dispatcher.dispatch_votes( + game_id="g1", day=1, round_=0, + voters=voter_only, seats=_seats(), + candidate_seats=[1, 3], + ) + assert results == {2: None} + + +async def test_dispatch_votes_send_failure_yields_none() -> None: + registry = InMemoryNpcRegistry() + + async def _failing_send(_msg: str) -> None: + raise RuntimeError("ws_closed") + + registry.register( + npc_id="npc_seat2", discord_bot_user_id="bot2", + supported_voices=(), version="1", + send=_failing_send, now_ms=1000, persona_key="setsu", + ) + registry.assign("npc_seat2", seat=2, game_id="g1", phase_id="g1::day1::DAY_VOTE::1") + + dispatcher = NpcDecisionDispatcher( + registry=registry, + config=DecisionDispatcherConfig(request_ttl_ms=200, inter_request_stagger_ms=0), + now_ms=lambda: 0, + ) + voter_only = [Player(seat_no=2, role=Role.VILLAGER, alive=True)] + results = await dispatcher.dispatch_votes( + game_id="g1", day=1, round_=0, + voters=voter_only, seats=_seats(), + candidate_seats=[1, 3], + ) + assert results == {2: None} + + +async def test_dispatch_night_actions_routes_action_kind() -> None: + registry = InMemoryNpcRegistry() + seat3_buf: list[str] = [] + registry.register( + npc_id="npc_seat3", discord_bot_user_id="bot3", + supported_voices=(), version="1", + send=_capture_send(seat3_buf), now_ms=1000, persona_key="gina", + ) + registry.assign("npc_seat3", seat=3, game_id="g1", phase_id="g1::day1::NIGHT::1") + + dispatcher = NpcDecisionDispatcher( + registry=registry, + config=DecisionDispatcherConfig(request_ttl_ms=5_000, inter_request_stagger_ms=0), + now_ms=lambda: 0, + ) + actor = Player(seat_no=3, role=Role.WEREWOLF, alive=True) + + async def _drive() -> dict[int, int | None]: + return await dispatcher.dispatch_night_actions( + game_id="g1", day=1, action_kind="wolf_attack", + actors=[actor], seats=_seats(), + candidate_seats=[1], + ) + + task = asyncio.create_task(_drive()) + for _ in range(50): + if seat3_buf: + break + await asyncio.sleep(0.01) + assert len(seat3_buf) == 1 + sent = json.loads(seat3_buf[0]) + assert sent["action_kind"] == "wolf_attack" + assert sent["candidate_seats"] == [[1, "Alice"]] + + await dispatcher.on_night_action_decision( + NightActionDecision( + ts=2_000, trace_id=sent["trace_id"], request_id=sent["request_id"], + npc_id="npc_seat3", seat_no=3, action_kind="wolf_attack", + target_seat=1, + ) + ) + results = await task + assert results == {3: 1} + + +async def test_dispatch_wolf_chat_lines_resolves_sequentially() -> None: + """`dispatch_wolf_chat_lines` runs wolves sequentially so each + WolfChatRequest awaits the previous wolf's broker fan-out before the + next wolf is asked.""" + from wolfbot.domain.ws_messages import WolfChatRequest, WolfChatSend + + registry = InMemoryNpcRegistry() + seat2_buf: list[str] = [] + seat3_buf: list[str] = [] + registry.register( + npc_id="npc_w2", discord_bot_user_id="bw2", + supported_voices=(), version="1", + send=_capture_send(seat2_buf), now_ms=1000, persona_key="setsu", + ) + registry.assign("npc_w2", seat=2, game_id="g1", phase_id="g1::day1::NIGHT::1") + registry.register( + npc_id="npc_w3", discord_bot_user_id="bw3", + supported_voices=(), version="1", + send=_capture_send(seat3_buf), now_ms=1000, persona_key="gina", + ) + registry.assign("npc_w3", seat=3, game_id="g1", phase_id="g1::day1::NIGHT::1") + + dispatcher = NpcDecisionDispatcher( + registry=registry, + config=DecisionDispatcherConfig(request_ttl_ms=5_000, inter_request_stagger_ms=0), + now_ms=lambda: 1_000, + ) + wolves = [ + Player(seat_no=2, role=Role.WEREWOLF, alive=True), + Player(seat_no=3, role=Role.WEREWOLF, alive=True), + ] + + async def _drive() -> dict[int, str | None]: + return await dispatcher.dispatch_wolf_chat_lines( + game_id="g1", day=1, wolves=wolves, seats=_seats(), + candidate_seats=[1], + ) + + task = asyncio.create_task(_drive()) + # Wait for wolf 2's request (lower seat goes first because of sort). + for _ in range(50): + if seat2_buf: + break + await asyncio.sleep(0.01) + assert len(seat2_buf) == 1 + assert seat3_buf == [] # wolf 3 hasn't been asked yet + + sent2 = WolfChatRequest.model_validate_json(seat2_buf[0]) + await dispatcher.on_wolf_chat_send( + WolfChatSend( + ts=2_000, trace_id=sent2.trace_id, request_id=sent2.request_id, + npc_id="npc_w2", seat_no=2, game_id="g1", + text="席1を狙う", + ) + ) + # Now wolf 3 should be asked. + for _ in range(50): + if seat3_buf: + break + await asyncio.sleep(0.01) + assert len(seat3_buf) == 1 + sent3 = WolfChatRequest.model_validate_json(seat3_buf[0]) + await dispatcher.on_wolf_chat_send( + WolfChatSend( + ts=3_000, trace_id=sent3.trace_id, request_id=sent3.request_id, + npc_id="npc_w3", seat_no=3, game_id="g1", + text="同意", + ) + ) + results = await task + assert results == {2: "席1を狙う", 3: "同意"} + + +async def test_dispatch_wolf_chat_lines_timeout_yields_none() -> None: + registry = InMemoryNpcRegistry() + seat2_buf: list[str] = [] + registry.register( + npc_id="npc_w2", discord_bot_user_id="bw2", + supported_voices=(), version="1", + send=_capture_send(seat2_buf), now_ms=1000, persona_key="setsu", + ) + registry.assign("npc_w2", seat=2, game_id="g1", phase_id="g1::day1::NIGHT::1") + dispatcher = NpcDecisionDispatcher( + registry=registry, + config=DecisionDispatcherConfig(request_ttl_ms=100, inter_request_stagger_ms=0), + now_ms=lambda: 0, + ) + wolves = [Player(seat_no=2, role=Role.WEREWOLF, alive=True)] + results = await dispatcher.dispatch_wolf_chat_lines( + game_id="g1", day=1, wolves=wolves, seats=_seats(), + candidate_seats=[1], + ) + assert results == {2: None} + + +async def test_decide_vote_request_payload_shape() -> None: + """The wire payload carries the seat / round / candidate pairs and a + deadline so the NPC bot can build its prompt without a Master DB hit.""" + registry = InMemoryNpcRegistry() + seat2_buf: list[str] = [] + registry.register( + npc_id="npc_seat2", discord_bot_user_id="bot2", + supported_voices=(), version="1", + send=_capture_send(seat2_buf), now_ms=1000, persona_key="setsu", + ) + registry.assign("npc_seat2", seat=2, game_id="g1", phase_id="g1::day1::DAY_VOTE::1") + + dispatcher = NpcDecisionDispatcher( + registry=registry, + config=DecisionDispatcherConfig(request_ttl_ms=5_000, inter_request_stagger_ms=0), + now_ms=lambda: 1_000, + ) + + async def _drive() -> dict[int, int | None]: + return await dispatcher.dispatch_votes( + game_id="g1", day=1, round_=0, + voters=[Player(seat_no=2, role=Role.VILLAGER, alive=True)], + seats=_seats(), + candidate_seats=[1, 3], + ) + + task = asyncio.create_task(_drive()) + for _ in range(50): + if seat2_buf: + break + await asyncio.sleep(0.01) + sent = DecideVoteRequest.model_validate_json(seat2_buf[0]) + assert sent.seat_no == 2 + assert sent.round_ == 0 + assert sent.candidate_seats == ((1, "Alice"), (3, "Carol")) + assert sent.expires_at_ms == 1_000 + 5_000 + # Resolve so the test task completes cleanly. + await dispatcher.on_vote_decision( + VoteDecision( + ts=2_000, trace_id=sent.trace_id, request_id=sent.request_id, + npc_id="npc_seat2", seat_no=2, target_seat=3, + ) + ) + await task + + +async def test_cleanup_game_resolves_pending_for_target_game_only() -> None: + """`cleanup_game` is wired into `_on_reactive_game_end` so a long-lived + Master process doesn't accumulate pending decision futures across games. + Two-game scenario: dispatch one in-flight vote per game, then cleanup g1 + and verify the g1 future resolves to None while g2's is untouched. + """ + registry = InMemoryNpcRegistry() + seat2_buf: list[str] = [] + seat3_buf: list[str] = [] + registry.register( + npc_id="npc_g1_seat2", discord_bot_user_id="bot2", + supported_voices=(), version="1", + send=_capture_send(seat2_buf), now_ms=1000, persona_key="setsu", + ) + registry.assign( + "npc_g1_seat2", seat=2, game_id="g1", phase_id="g1::day1::DAY_VOTE::1" + ) + registry.register( + npc_id="npc_g2_seat3", discord_bot_user_id="bot3", + supported_voices=(), version="1", + send=_capture_send(seat3_buf), now_ms=1000, persona_key="gina", + ) + registry.assign( + "npc_g2_seat3", seat=3, game_id="g2", phase_id="g2::day1::DAY_VOTE::1" + ) + + dispatcher = NpcDecisionDispatcher( + registry=registry, + # Long TTL so the test isn't racing the timeout path; cleanup must + # win regardless. + config=DecisionDispatcherConfig(request_ttl_ms=60_000, inter_request_stagger_ms=0), + now_ms=lambda: 1_000, + ) + + async def _drive(game_id: str, voter_seat: int) -> dict[int, int | None]: + return await dispatcher.dispatch_votes( + game_id=game_id, day=1, round_=0, + voters=[Player(seat_no=voter_seat, role=Role.VILLAGER, alive=True)], + seats=_seats(), + candidate_seats=[1, 2, 3], + ) + + g1_task = asyncio.create_task(_drive("g1", 2)) + g2_task = asyncio.create_task(_drive("g2", 3)) + # Wait until both requests have been sent. + for _ in range(100): + if seat2_buf and seat3_buf: + break + await asyncio.sleep(0.01) + assert seat2_buf and seat3_buf + + assert len(dispatcher._pending) == 2 + + swept = dispatcher.cleanup_game("g1") + assert swept == 1 + assert len(dispatcher._pending) == 1 + + g1_result = await g1_task + assert g1_result == {2: None}, "g1 voter resolves to abstain after cleanup" + assert not g2_task.done(), "g2 future must not be touched by g1 cleanup" + + # Resolve g2 normally so the test exits cleanly. + sent_g2 = DecideVoteRequest.model_validate_json(seat3_buf[0]) + await dispatcher.on_vote_decision( + VoteDecision( + ts=2_000, trace_id=sent_g2.trace_id, request_id=sent_g2.request_id, + npc_id="npc_g2_seat3", seat_no=3, target_seat=1, + ) + ) + await g2_task + + +async def test_dispatch_votes_staggers_outbound_requests() -> None: + """``inter_request_stagger_ms`` spaces the per-actor LLM calls in + time so 9 NPC bots don't burst-hit the upstream provider's quota. + + Verifies: with stagger=120ms × 3 voters, the second outbound + payload arrives at least ~100ms after the first, and the third at + least ~200ms after the first. We don't assert exact wall-clock + (event-loop scheduling jitter) — the lower bound is the contract. + """ + import time + + registry = InMemoryNpcRegistry() + bufs: dict[int, list[float]] = {2: [], 3: [], 4: []} + + def _timestamping_send(seat: int) -> Callable[[str], Awaitable[None]]: + async def _send(_msg: str) -> None: + bufs[seat].append(time.monotonic()) + return _send + + for seat, persona, npc_id, bot_id in ( + (2, "setsu", "npc_seat2", "bot2"), + (3, "gina", "npc_seat3", "bot3"), + (4, "comet", "npc_seat4", "bot4"), + ): + registry.register( + npc_id=npc_id, discord_bot_user_id=bot_id, + supported_voices=(), version="1", + send=_timestamping_send(seat), now_ms=1000, persona_key=persona, + ) + registry.assign( + npc_id, seat=seat, game_id="g1", + phase_id="g1::day1::DAY_VOTE::1", + ) + + dispatcher = NpcDecisionDispatcher( + registry=registry, + config=DecisionDispatcherConfig( + request_ttl_ms=200, + inter_request_stagger_ms=120, + ), + now_ms=lambda: int(time.time() * 1000), + ) + + seats: list[Seat] = [ + Seat(seat_no=1, display_name="A", is_llm=False, persona_key=None, + discord_user_id="u1"), + Seat(seat_no=2, display_name="B", is_llm=True, persona_key="setsu", + discord_user_id=None), + Seat(seat_no=3, display_name="C", is_llm=True, persona_key="gina", + discord_user_id=None), + Seat(seat_no=4, display_name="D", is_llm=True, persona_key="comet", + discord_user_id=None), + ] + voters = [ + Player(seat_no=2, role=Role.VILLAGER, alive=True), + Player(seat_no=3, role=Role.WEREWOLF, alive=True), + Player(seat_no=4, role=Role.VILLAGER, alive=True), + ] + # Don't send any responses — let the timeout fire so the call returns. + # We're only inspecting the OUTBOUND timestamps. + await dispatcher.dispatch_votes( + game_id="g1", day=1, round_=0, + voters=voters, seats=seats, + candidate_seats=[1, 2, 3, 4], + ) + + # All three NPCs received exactly one request. + assert all(len(b) == 1 for b in bufs.values()), bufs + t2, t3, t4 = bufs[2][0], bufs[3][0], bufs[4][0] + # Lower bounds: stagger=120ms ⇒ at least ~100ms of separation + # (allow some scheduler slack). + assert t3 - t2 >= 0.10, f"seat3 fired only {t3 - t2:.3f}s after seat2" + assert t4 - t3 >= 0.10, f"seat4 fired only {t4 - t3:.3f}s after seat3" + + +async def test_dispatch_votes_stagger_zero_is_concurrent() -> None: + """With ``inter_request_stagger_ms=0`` (test-friendly mode) the + behaviour matches the historical ``asyncio.gather(*coros)`` + fan-out — all outbound requests fire within the same event-loop + tick. Guards against accidental regression where a future change + sneaks in a non-zero default into this test path.""" + import time + + registry = InMemoryNpcRegistry() + seat2_buf: list[float] = [] + seat3_buf: list[float] = [] + + def _timestamp(buf: list[float]) -> Callable[[str], Awaitable[None]]: + async def _send(_msg: str) -> None: + buf.append(time.monotonic()) + return _send + + registry.register( + npc_id="npc_seat2", discord_bot_user_id="bot2", + supported_voices=(), version="1", + send=_timestamp(seat2_buf), now_ms=1000, persona_key="setsu", + ) + registry.assign("npc_seat2", seat=2, game_id="g1", + phase_id="g1::day1::DAY_VOTE::1") + registry.register( + npc_id="npc_seat3", discord_bot_user_id="bot3", + supported_voices=(), version="1", + send=_timestamp(seat3_buf), now_ms=1000, persona_key="gina", + ) + registry.assign("npc_seat3", seat=3, game_id="g1", + phase_id="g1::day1::DAY_VOTE::1") + + dispatcher = NpcDecisionDispatcher( + registry=registry, + config=DecisionDispatcherConfig( + request_ttl_ms=100, inter_request_stagger_ms=0, + ), + now_ms=lambda: int(time.time() * 1000), + ) + + seats: list[Seat] = [ + Seat(seat_no=1, display_name="A", is_llm=False, persona_key=None, + discord_user_id="u1"), + Seat(seat_no=2, display_name="B", is_llm=True, persona_key="setsu", + discord_user_id=None), + Seat(seat_no=3, display_name="C", is_llm=True, persona_key="gina", + discord_user_id=None), + ] + voters = [ + Player(seat_no=2, role=Role.VILLAGER, alive=True), + Player(seat_no=3, role=Role.WEREWOLF, alive=True), + ] + await dispatcher.dispatch_votes( + game_id="g1", day=1, round_=0, + voters=voters, seats=seats, + candidate_seats=[1, 2, 3], + ) + # Both fired within the same scheduler tick (≪ stagger of 120ms + # we used in the staggered test). Use 50ms as a generous upper bound. + assert len(seat2_buf) == 1 and len(seat3_buf) == 1 + spread = abs(seat2_buf[0] - seat3_buf[0]) + assert spread < 0.05, f"stagger=0 should fire near-concurrently, got {spread:.3f}s" diff --git a/tests/test_master_ingest_factory.py b/tests/test_master_ingest_factory.py new file mode 100644 index 0000000..549b20d --- /dev/null +++ b/tests/test_master_ingest_factory.py @@ -0,0 +1,182 @@ +"""Coverage for ``wolfbot.master.ingest_factory``. + +The factory turns the structural pipeline switches on +:class:`MasterSettings` into ready-to-inject ``AudioAnalyzer`` / ``Stt`` +/ ``TextAnalyzer`` instances. These tests pin two contracts: + +1. **Returns ``None`` when the pipeline doesn't request the component.** + Callers branch on the return value rather than re-checking pipeline + shape; if a factory ever returns a stub instead of None, the wiring + in ``main.py`` would silently activate a path the operator opted out + of. +2. **Constructs the right concrete class for each combination.** The + pipeline shape + provider together decide which of the four concrete + analyzer/STT classes to instantiate. We don't exercise the network + path here — those are covered in the per-class test files + (``test_text_analyzer``, ``test_master_stt_groq``). +""" + +from __future__ import annotations + +from pydantic import SecretStr + +from wolfbot.config import MasterSettings +from wolfbot.master.ingest_factory import ( + build_text_analyzer, + build_voice_ingest_provider, + voice_ingest_summary, +) +from wolfbot.master.state.text_analyzer import ( + GeminiTextAnalyzer, + OpenAICompatibleTextAnalyzer, +) +from wolfbot.master.voice.stt_service import ( + GeminiAudioAnalyzer, + GroqWhisperAudioAnalyzer, +) + + +def _base_kwargs() -> dict[str, object]: + return { + "DISCORD_TOKEN": SecretStr("token"), + "DISCORD_GUILD_ID": 1, + "MAIN_TEXT_CHANNEL_ID": 2, + "MAIN_VOICE_CHANNEL_ID": 3, + "GAMEPLAY_LLM_API_KEY": SecretStr("g"), + } + + +# ───────────────────────────────────────────── build_text_analyzer +def test_text_analyzer_none_when_passthrough_and_voice_disabled() -> None: + s = MasterSettings(_env_file=None, **_base_kwargs()) # type: ignore[arg-type] + assert build_text_analyzer(s) is None + + +def test_text_analyzer_xai_default() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + TEXT_PIPELINE="text_analyzer", + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + ) + inst = build_text_analyzer(s) + assert isinstance(inst, OpenAICompatibleTextAnalyzer) + # xai default base url applied even though TEXT_ANALYZER_BASE_URL is unset. + assert inst.base_url == "https://api.x.ai/v1" + assert inst.model == "grok-4-1-fast" + + +def test_text_analyzer_deepseek_default_base_url() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + TEXT_PIPELINE="text_analyzer", + TEXT_ANALYZER_PROVIDER="deepseek", + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + ) + inst = build_text_analyzer(s) + assert isinstance(inst, OpenAICompatibleTextAnalyzer) + assert inst.base_url == "https://api.deepseek.com" + + +def test_text_analyzer_explicit_base_url_wins() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + TEXT_PIPELINE="text_analyzer", + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + TEXT_ANALYZER_BASE_URL="http://localhost:11434/v1", + ) + inst = build_text_analyzer(s) + assert isinstance(inst, OpenAICompatibleTextAnalyzer) + assert inst.base_url == "http://localhost:11434/v1" + + +def test_text_analyzer_gemini_path() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + TEXT_PIPELINE="text_analyzer", + TEXT_ANALYZER_PROVIDER="gemini", + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + TEXT_ANALYZER_MODEL="gemini-2.0-flash-lite", + ) + inst = build_text_analyzer(s) + assert isinstance(inst, GeminiTextAnalyzer) + assert inst.model == "gemini-2.0-flash-lite" + + +def test_text_analyzer_built_for_split_voice_path_even_without_text_pipeline() -> None: + """``stt_then_text_analyzer`` voice path needs the analyzer too; + factory must hand back the same kind of instance the text pipeline + would have built.""" + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + VOICE_PIPELINE="stt_then_text_analyzer", + STT_API_KEY=SecretStr("ss"), + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + ) + inst = build_text_analyzer(s) + assert isinstance(inst, OpenAICompatibleTextAnalyzer) + + +# ──────────────────────────────────────── build_voice_ingest_provider +def test_voice_provider_none_when_disabled() -> None: + s = MasterSettings(_env_file=None, **_base_kwargs()) # type: ignore[arg-type] + assert build_voice_ingest_provider(s) is None + + +def test_voice_provider_audio_analyzer() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + VOICE_PIPELINE="audio_analyzer", + AUDIO_ANALYZER_API_KEY=SecretStr("aa"), + ) + inst = build_voice_ingest_provider(s) + assert isinstance(inst, GeminiAudioAnalyzer) + + +def test_voice_provider_split_pipeline() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + VOICE_PIPELINE="stt_then_text_analyzer", + STT_API_KEY=SecretStr("ss"), + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + ) + inst = build_voice_ingest_provider(s) + assert isinstance(inst, GroqWhisperAudioAnalyzer) + + +# ──────────────────────────────────────────────── voice_ingest_summary +def test_summary_disabled() -> None: + s = MasterSettings(_env_file=None, **_base_kwargs()) # type: ignore[arg-type] + assert "disabled" in voice_ingest_summary(s) + + +def test_summary_audio_analyzer_includes_model() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + VOICE_PIPELINE="audio_analyzer", + AUDIO_ANALYZER_API_KEY=SecretStr("aa"), + AUDIO_ANALYZER_MODEL="gemini-2.5-flash", + ) + summary = voice_ingest_summary(s) + assert "audio_analyzer" in summary + assert "gemini-2.5-flash" in summary + + +def test_summary_split_pipeline_includes_both_steps() -> None: + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + VOICE_PIPELINE="stt_then_text_analyzer", + STT_API_KEY=SecretStr("ss"), + TEXT_ANALYZER_API_KEY=SecretStr("ta"), + ) + summary = voice_ingest_summary(s) + assert "whisper-large-v3-turbo" in summary + assert "grok-4-1-fast" in summary diff --git a/tests/test_master_narration.py b/tests/test_master_narration.py new file mode 100644 index 0000000..0f121ce --- /dev/null +++ b/tests/test_master_narration.py @@ -0,0 +1,311 @@ +"""Master narration templates + Levi persona consolidation. + +Pins the per-kind narration shape so future tone tweaks don't silently +strip variables that voice depends on (e.g. day_number, discussion +seconds). Also asserts the persona pool is now a single Levi entry — +the 3-persona pool was never wired anywhere, so consolidating to 1 +should not break anything else. +""" + +from __future__ import annotations + +from dataclasses import replace + +import pytest + +from wolfbot.domain.durations import ( + PhaseDurations, + reset_phase_durations_to_defaults, + set_phase_durations, +) +from wolfbot.domain.enums import Phase +from wolfbot.domain.models import LogEntry, Seat +from wolfbot.master.narration.narration import ( + NarrationContext, + NarrationOutput, + render_master_narration, +) +from wolfbot.master.personas import ( + DEFAULT_MASTER_PERSONA_KEY, + MASTER_PERSONAS, + MASTER_PERSONAS_BY_KEY, +) + + +@pytest.fixture(autouse=True) +def _reset_durations() -> None: + reset_phase_durations_to_defaults() + + +def _seats() -> dict[int, Seat]: + return { + 1: Seat(seat_no=1, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu"), + 2: Seat(seat_no=2, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina"), + 3: Seat(seat_no=3, display_name="Alice", discord_user_id="u3", is_llm=False, persona_key=None), + } + + +def _ctx(*, day: int = 1, alive: int = 9, phase: Phase = Phase.DAY_DISCUSSION) -> NarrationContext: + return NarrationContext( + day_number=day, + phase=phase, + alive_count=alive, + seats_by_no=_seats(), + ) + + +def _entry(kind: str, *, text: str = "", actor_seat: int | None = None, phase: Phase = Phase.DAY_DISCUSSION, day: int = 1) -> LogEntry: + return LogEntry( + game_id="g1", + day=day, + phase=phase, + kind=kind, + actor_seat=actor_seat, + visibility="PUBLIC", + text=text, + created_at=0, + ) + + +# ------------------------------------------------------ persona consolidation + + +def test_master_persona_pool_is_just_levi() -> None: + """The legacy 3-persona pool has been consolidated. Future code that + iterates `MASTER_PERSONAS` should still work but only see one entry.""" + assert len(MASTER_PERSONAS) == 1 + assert DEFAULT_MASTER_PERSONA_KEY == "levi" + assert "levi" in MASTER_PERSONAS_BY_KEY + assert "stoic_gm" not in MASTER_PERSONAS_BY_KEY + assert "theatrical_gm" not in MASTER_PERSONAS_BY_KEY + assert "warm_gm" not in MASTER_PERSONAS_BY_KEY + + +def test_levi_persona_polite_machine_signals() -> None: + """Sanity-check the Levi persona text carries the polite-machine + signature so a future style edit doesn't silently turn it casual.""" + levi = MASTER_PERSONAS_BY_KEY["levi"] + assert "丁寧" in levi.style_guide or "機械" in levi.style_guide + assert levi.speech_profile.first_person == "本機" + assert any("致します" in p for p in levi.speech_profile.signature_phrases) + + +# ------------------------------------------------------ phase change templates + + +def test_phase_change_to_day_discussion_day1_voices_duration() -> None: + entry = _entry("PHASE_CHANGE", text="…", phase=Phase.DAY_DISCUSSION, day=1) + out = render_master_narration(entry, _ctx(day=1)) + assert out.voice_text is not None + assert "1 日目" in out.voice_text + # Default discussion_day1 = 300 seconds. + assert "300 秒" in out.voice_text + assert out.chat_text is None + + +def test_phase_change_uses_runtime_durations() -> None: + """Embedded durations must come from the runtime singleton, not + hardcoded — otherwise `/wolf settings` changes don't reach voice.""" + set_phase_durations(replace(PhaseDurations(), discussion_day1=42)) + entry = _entry("PHASE_CHANGE", text="…", phase=Phase.DAY_DISCUSSION, day=1) + out = render_master_narration(entry, _ctx(day=1)) + assert out.voice_text is not None + assert "42 秒" in out.voice_text + + +def test_phase_change_to_day_vote_voices_explicit_end() -> None: + """User explicitly asked for a 'discussion has ended' announcement. + The DAY_VOTE entry must voice both that the discussion ended and + that voting is starting.""" + entry = _entry("PHASE_CHANGE", text="…", phase=Phase.DAY_VOTE) + out = render_master_narration(entry, _ctx()) + assert out.voice_text is not None + assert "議論時間が終了" in out.voice_text + assert "投票" in out.voice_text + + +def test_phase_change_to_night_voices_duration() -> None: + entry = _entry("PHASE_CHANGE", text="…", phase=Phase.NIGHT) + out = render_master_narration(entry, _ctx()) + assert out.voice_text is not None + assert "夜のフェイズ" in out.voice_text + assert "90 秒" in out.voice_text # default night = 90s + + +def test_phase_change_day_discussion_day_three_uses_day3plus() -> None: + set_phase_durations(replace(PhaseDurations(), discussion_day3plus=99)) + entry = _entry("PHASE_CHANGE", text="…", phase=Phase.DAY_DISCUSSION, day=3) + out = render_master_narration(entry, _ctx(day=3)) + assert out.voice_text is not None + assert "3 日目" in out.voice_text + assert "99 秒" in out.voice_text + + +# ------------------------------------------------------ morning template + + +def test_morning_with_death_names_seat_and_player() -> None: + entry = _entry("MORNING", text="夜が明け…", actor_seat=1, day=2, phase=Phase.DAY_DISCUSSION) + out = render_master_narration(entry, _ctx(day=2, alive=8)) + assert out.voice_text is not None + assert "2 日目" in out.voice_text + assert "席1" in out.voice_text + assert "セツ" in out.voice_text + assert "8 名" in out.voice_text + + +def test_morning_without_death_voices_no_casualties() -> None: + entry = _entry("MORNING", text="夜が明け…", actor_seat=None, day=2) + out = render_master_narration(entry, _ctx(day=2, alive=9)) + assert out.voice_text is not None + assert "犠牲者は出ておりません" in out.voice_text + assert "9 名" in out.voice_text + + +def test_morning_emits_chat_text_with_kill_announcement() -> None: + """Reactive_voice mode pre-fix only voiced the morning, leaving the + chat empty for the kill / no-kill information (rounds mode posted + the raw text). The narrator now emits ``chat_text`` so VC text + chat carries the same announcement, prefixed with ☀️ to match the + rounds-mode formatting. The companion survivors-roster sidecar is + posted by ``DiscordBotAdapter.post_morning`` and tested + separately. + """ + entry = _entry( + "MORNING", + text="朝になりました。犠牲者: 🎩ジョナス", + actor_seat=1, + day=2, + phase=Phase.DAY_DISCUSSION, + ) + out = render_master_narration(entry, _ctx(day=2, alive=8)) + assert out.chat_text is not None + assert out.chat_text.startswith("☀️ ") + assert "犠牲者" in out.chat_text + + +def test_morning_no_casualty_emits_peaceful_chat_text() -> None: + entry = _entry( + "MORNING", + text="平和な朝です。昨晩の犠牲者はいません。", + actor_seat=None, + day=2, + ) + out = render_master_narration(entry, _ctx(day=2, alive=9)) + assert out.chat_text is not None + assert "平和な朝" in out.chat_text + + +# ------------------------------------------------------ vote-result split + + +def test_execution_voices_only_headline_chat_keeps_tally() -> None: + """The user wants long content (vote tally) text-only, headline TTS.""" + full_text = "席1 セツ が処刑されました。\n\n席1: セツ → 席3 Alice\n席2: ジナ → 席1 セツ" + entry = _entry("EXECUTION", text=full_text, actor_seat=1) + out = render_master_narration(entry, _ctx()) + assert out.voice_text is not None + assert "処刑" in out.voice_text + assert "セツ" in out.voice_text + # Tally must NOT be in voice (long content rule). + assert "席1: セツ → 席3 Alice" not in out.voice_text + # Tally must be in chat post. + assert out.chat_text is not None + assert "席1: セツ → 席3 Alice" in out.chat_text + + +def test_execution_voice_appends_night_phase_cue_when_next_phase_is_night() -> None: + """state_machine emits no PHASE_CHANGE between EXECUTION and NIGHT entry, + so the EXECUTION narration must announce the night transition itself — + otherwise role-holders get DMs with no spoken context that night began. + """ + full_text = "席1 セツ が処刑されました。\n\n席1: セツ → 席3 Alice" + entry = _entry("EXECUTION", text=full_text, actor_seat=1) + out = render_master_narration(entry, _ctx(phase=Phase.NIGHT)) + assert out.voice_text is not None + assert "処刑が確定" in out.voice_text + assert "夜のフェイズへ移行" in out.voice_text + assert "役職を持つ参加者" in out.voice_text + + +def test_execution_voice_omits_night_cue_on_victory() -> None: + """When execution triggers victory the live game.phase has flipped to + GAME_OVER; the VICTORY narration takes over and the EXECUTION line must + NOT also announce a night transition that will never happen.""" + full_text = "席1 セツ が処刑されました。\n\n席1: セツ → 席3 Alice" + entry = _entry("EXECUTION", text=full_text, actor_seat=1) + out = render_master_narration(entry, _ctx(phase=Phase.GAME_OVER)) + assert out.voice_text is not None + assert "処刑が確定" in out.voice_text + assert "夜のフェイズへ移行" not in out.voice_text + + +def test_no_execution_with_runoff_tie_branches_voice_text() -> None: + full_text = "決選投票も同票のため、本日は処刑なしで夜を迎えます。\n\n席1: 棄権" + entry = _entry("NO_EXECUTION", text=full_text) + out = render_master_narration(entry, _ctx()) + assert out.voice_text is not None + assert "決選投票も同票" in out.voice_text + assert out.chat_text is not None + assert "席1: 棄権" in out.chat_text + + +def test_runoff_start_keeps_candidate_list_in_chat() -> None: + full_text = "同票のため決選投票に移ります。候補: セツ、ジナ\n\n席1: 1票" + entry = _entry("RUNOFF_START", text=full_text) + out = render_master_narration(entry, _ctx()) + assert out.voice_text is not None + assert "決選投票" in out.voice_text + # The candidate list and tally both go to chat. + assert out.chat_text is not None + assert "候補" in out.chat_text + assert "席1: 1票" in out.chat_text + + +# ------------------------------------------------------ victory + reveal + + +def test_victory_villager_voiced() -> None: + entry = _entry("VICTORY", text="村人陣営の勝利!") + out = render_master_narration(entry, _ctx()) + assert out.voice_text is not None + assert "村人陣営の勝利" in out.voice_text + + +def test_victory_wolf_voiced() -> None: + entry = _entry("VICTORY", text="人狼陣営の勝利!") + out = render_master_narration(entry, _ctx()) + assert out.voice_text is not None + assert "人狼陣営の勝利" in out.voice_text + + +def test_role_reveal_is_chat_only() -> None: + """ROLE_REVEAL is a 9-line table — too long for TTS by user choice.""" + full_text = ( + "席1 セツ: 占い師 (生存)\n席2 ジナ: 人狼 (生存)\n席3 Alice: 村人 (死亡)" + ) + entry = _entry("ROLE_REVEAL", text=full_text) + out = render_master_narration(entry, _ctx()) + assert out.voice_text is None + assert out.chat_text == full_text + + +# ------------------------------------------------------ setup + fallthrough + + +def test_setup_complete_voiced_with_alive_count() -> None: + entry = _entry("SETUP_COMPLETE", text="…") + out = render_master_narration(entry, _ctx(alive=9)) + assert out.voice_text is not None + assert "9 名" in out.voice_text + assert "DM" in out.voice_text + assert out.chat_text is None + + +def test_unknown_kind_returns_silent_output() -> None: + """Unknown kinds fall through silently — caller posts raw text via + legacy main-text path so nothing is lost.""" + entry = _entry("WHATEVER_NEW_KIND", text="…") + out = render_master_narration(entry, _ctx()) + assert isinstance(out, NarrationOutput) + assert out.is_silent() diff --git a/tests/test_master_phase_d_state_pusher.py b/tests/test_master_phase_d_state_pusher.py new file mode 100644 index 0000000..94b3ff1 --- /dev/null +++ b/tests/test_master_phase_d_state_pusher.py @@ -0,0 +1,252 @@ +"""Phase-D state pusher — derives PrivateStateUpdate fan-outs from the +state-machine's transition logs and sends them to NPC bots.""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable + +import pytest + +from wolfbot.domain.enums import Phase, Role, SubmissionType +from wolfbot.domain.models import ( + Game, + LogEntry, + NightAction, + Seat, +) +from wolfbot.master.state.phase_d_state_pusher import PhaseDStatePusher +from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry +from wolfbot.persistence.sqlite_repo import SqliteRepo + + +def _capture_send(buf: list[str]) -> Callable[[str], Awaitable[None]]: + async def _send(msg: str) -> None: + buf.append(msg) + + return _send + + +@pytest.fixture +async def fxt(repo: SqliteRepo) -> tuple[Game, list[Seat], InMemoryNpcRegistry, dict[int, list[str]]]: + game = Game( + id="g_pusher", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + wolves_channel_id="cw", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(game) + seats = [ + Seat(seat_no=1, display_name="Alice", is_llm=True, persona_key="setsu", + discord_user_id=None), + Seat(seat_no=2, display_name="Bob", is_llm=True, persona_key="gina", + discord_user_id=None), + Seat(seat_no=3, display_name="Carol", is_llm=True, persona_key="jonas", + discord_user_id=None), + ] + for s in seats: + await repo.insert_seat(game.id, s) + await repo.set_player_role(game.id, 1, Role.SEER) + await repo.set_player_role(game.id, 2, Role.WEREWOLF) + await repo.set_player_role(game.id, 3, Role.KNIGHT) + + registry = InMemoryNpcRegistry() + bufs: dict[int, list[str]] = {1: [], 2: [], 3: []} + for npc_id, persona, seat in ( + ("npc_alice", "setsu", 1), + ("npc_bob", "gina", 2), + ("npc_carol", "jonas", 3), + ): + registry.register( + npc_id=npc_id, discord_bot_user_id=f"bot{seat}", + supported_voices=(), version="1", + send=_capture_send(bufs[seat]), now_ms=1000, persona_key=persona, + ) + registry.assign( + npc_id, seat=seat, game_id=game.id, + phase_id="g_pusher::day1::DAY_DISCUSSION::1", + ) + return game, seats, registry, bufs + + +def _kinds_in(buf: list[str]) -> list[str]: + out: list[str] = [] + for raw in buf: + try: + d = json.loads(raw) + except json.JSONDecodeError: + continue + if d.get("type") == "private_state_update": + out.append(d["update_kind"]) + return out + + +def _payload_of(buf: list[str], kind: str) -> dict[str, object]: + for raw in buf: + d = json.loads(raw) + if d.get("type") == "private_state_update" and d["update_kind"] == kind: + return d["payload"] # type: ignore[no-any-return] + raise AssertionError(f"no {kind} update found in buf") + + +async def test_pusher_skips_for_rounds_mode( + repo: SqliteRepo, + fxt: tuple[Game, list[Seat], InMemoryNpcRegistry, dict[int, list[str]]], +) -> None: + """Rounds-mode games never see the Phase-D fan-out.""" + game, _seats, registry, bufs = fxt + # Force this game out of reactive_voice. + async with repo._db.execute( # type: ignore[attr-defined] + "UPDATE games SET discussion_mode='rounds' WHERE id=?", (game.id,), + ): + pass + await repo._db.commit() # type: ignore[attr-defined] + new_game = await repo.load_game(game.id) + assert new_game is not None and new_game.discussion_mode == "rounds" + + pusher = PhaseDStatePusher(repo=repo, registry=registry, now_ms=lambda: 5000) + await pusher.push_after_advance( + game=new_game, prev_phase=Phase.NIGHT, + private_logs=[], public_logs=[], + ) + for buf in bufs.values(): + assert buf == [] # nothing pushed + + +async def test_pusher_emits_seer_result_with_is_wolf( + repo: SqliteRepo, + fxt: tuple[Game, list[Seat], InMemoryNpcRegistry, dict[int, list[str]]], +) -> None: + game, _seats, registry, bufs = fxt + pusher = PhaseDStatePusher(repo=repo, registry=registry, now_ms=lambda: 5000) + seer_log = LogEntry( + game_id=game.id, day=1, phase=Phase.NIGHT, kind="SEER_RESULT", + actor_seat=None, audience_seat=1, visibility="PRIVATE", + text="占い結果: 席2 Bob は 人狼 です。", created_at=1, + ) + await pusher.push_after_advance( + game=game, prev_phase=Phase.NIGHT, + private_logs=[seer_log], public_logs=[], + ) + payload = _payload_of(bufs[1], "seer_result") + assert payload["target_seat"] == 2 + assert payload["target_name"] == "Bob" + assert payload["is_wolf"] is True + # Other wolves don't see the seer's private result. + assert "seer_result" not in _kinds_in(bufs[2]) + + +async def test_pusher_emits_seer_result_night0_marks_white( + repo: SqliteRepo, + fxt: tuple[Game, list[Seat], InMemoryNpcRegistry, dict[int, list[str]]], +) -> None: + """SEER_RESULT_NIGHT0 is always white by spec; the pusher overrides + the role lookup so a future ruleset change can't accidentally flip it.""" + game, _seats, registry, bufs = fxt + pusher = PhaseDStatePusher(repo=repo, registry=registry, now_ms=lambda: 5000) + night0_log = LogEntry( + game_id=game.id, day=0, phase=Phase.NIGHT_0, kind="SEER_RESULT_NIGHT0", + actor_seat=None, audience_seat=1, visibility="PRIVATE", + text="初日ランダム白: 席2 Bob は 人狼ではありません。", created_at=1, + ) + await pusher.push_after_advance( + game=game, prev_phase=Phase.NIGHT_0, + private_logs=[night0_log], public_logs=[], + ) + payload = _payload_of(bufs[1], "seer_result") + assert payload["target_seat"] == 2 + assert payload["is_wolf"] is False # NIGHT_0 result is forced white + + +async def test_pusher_emits_medium_result_with_no_execution( + repo: SqliteRepo, + fxt: tuple[Game, list[Seat], InMemoryNpcRegistry, dict[int, list[str]]], +) -> None: + game, _seats, registry, bufs = fxt + pusher = PhaseDStatePusher(repo=repo, registry=registry, now_ms=lambda: 5000) + medium_log = LogEntry( + game_id=game.id, day=1, phase=Phase.NIGHT, kind="MEDIUM_RESULT", + actor_seat=None, audience_seat=2, visibility="PRIVATE", + text="本日の霊媒結果はありません(処刑なし)。", created_at=1, + ) + await pusher.push_after_advance( + game=game, prev_phase=Phase.NIGHT, + private_logs=[medium_log], public_logs=[], + ) + payload = _payload_of(bufs[2], "medium_result") + assert payload["is_wolf"] is None + assert payload["target_seat"] == 0 + + +async def test_pusher_emits_guard_entry_when_knight_submitted( + repo: SqliteRepo, + fxt: tuple[Game, list[Seat], InMemoryNpcRegistry, dict[int, list[str]]], +) -> None: + game, _seats, registry, bufs = fxt + # Knight (seat 3) submitted a guard for seat 2 this night. + await repo.insert_night_action( + NightAction( + game_id=game.id, day=1, actor_seat=3, + kind=SubmissionType.KNIGHT_GUARD, target_seat=2, submitted_at=1, + ) + ) + pusher = PhaseDStatePusher(repo=repo, registry=registry, now_ms=lambda: 5000) + await pusher.push_after_advance( + game=game, prev_phase=Phase.NIGHT, + private_logs=[], public_logs=[], + ) + payload = _payload_of(bufs[3], "guard_entry") + assert payload["target_seat"] == 2 + assert payload["target_name"] == "Bob" + # Only the knight gets the guard entry. + assert "guard_entry" not in _kinds_in(bufs[1]) + assert "guard_entry" not in _kinds_in(bufs[2]) + + +async def test_pusher_emits_guard_resolved_on_morning( + repo: SqliteRepo, + fxt: tuple[Game, list[Seat], InMemoryNpcRegistry, dict[int, list[str]]], +) -> None: + game, _seats, registry, bufs = fxt + pusher = PhaseDStatePusher(repo=repo, registry=registry, now_ms=lambda: 5000) + # Day 2 morning, peaceful → guard worked. + morning_log = LogEntry( + game_id=game.id, day=2, phase=Phase.DAY_DISCUSSION, kind="MORNING", + actor_seat=None, visibility="PUBLIC", + text="平和な朝です。昨晩の犠牲者はいません。", created_at=1, + ) + new_game = game.model_copy(update={"day_number": 2}) + await pusher.push_after_advance( + game=new_game, prev_phase=Phase.NIGHT, + private_logs=[], public_logs=[morning_log], + ) + payload = _payload_of(bufs[3], "guard_resolved") + assert payload["peaceful_morning"] is True + assert payload["day"] == 1 # the guard was submitted on day 1 + + +async def test_pusher_fans_alive_changed_and_day_advanced_to_all_alive_llms( + repo: SqliteRepo, + fxt: tuple[Game, list[Seat], InMemoryNpcRegistry, dict[int, list[str]]], +) -> None: + game, _seats, registry, bufs = fxt + pusher = PhaseDStatePusher(repo=repo, registry=registry, now_ms=lambda: 5000) + new_game = game.model_copy(update={"day_number": 2}) + await pusher.push_after_advance( + game=new_game, prev_phase=Phase.NIGHT, + private_logs=[], public_logs=[], + ) + # Every alive LLM seat got both updates. + for seat in (1, 2, 3): + kinds = _kinds_in(bufs[seat]) + assert "alive_changed" in kinds + assert "day_advanced" in kinds + # day_advanced payload carries the new day number. + p = _payload_of(bufs[1], "day_advanced") + assert p["day_number"] == 2 diff --git a/tests/test_master_private_state.py b/tests/test_master_private_state.py new file mode 100644 index 0000000..dc6be90 --- /dev/null +++ b/tests/test_master_private_state.py @@ -0,0 +1,469 @@ +"""Master-side `PrivateStateSnapshot` / `PrivateStateUpdate` factories. + +Pure-data tests — no DB or asyncio. Verifies that the WS payloads sent to +NPC bots over Phase-D include the right per-seat shape: + +* alive/dead seat lists are correctly partitioned + sorted. +* `partner_wolves` only fills for `WEREWOLF` and excludes the recipient. +* Update factories produce well-typed `payload` dicts that the NPC + bot's `apply_update` can fold (round-trip via `state_from_snapshot` + + `apply_update` reproduces the expected state). +""" + +from __future__ import annotations + +import tempfile +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest_asyncio + +from wolfbot.domain.enums import Phase, Role, SubmissionType +from wolfbot.domain.models import Game, LogEntry, NightAction, Player, Seat +from wolfbot.master.state.private_state import ( + build_snapshot_for_seat, + load_private_state_for_seat, + make_alive_changed_update, + make_day_advanced_update, + make_guard_entry_update, + make_guard_resolved_update, + make_medium_result_update, + make_seer_result_update, + make_wolf_chat_update, +) +from wolfbot.npc.decision.game_state import apply_update, state_from_snapshot +from wolfbot.persistence.schema import migrate +from wolfbot.persistence.sqlite_repo import SqliteRepo + + +def _seats() -> list[Seat]: + return [ + Seat(seat_no=1, display_name="Alice", is_llm=False, persona_key=None, + discord_user_id="u1"), + Seat(seat_no=2, display_name="Bob", is_llm=True, persona_key="setsu", + discord_user_id=None), + Seat(seat_no=3, display_name="Carol", is_llm=True, persona_key="gina", + discord_user_id=None), + Seat(seat_no=4, display_name="Dave", is_llm=True, persona_key="jonas", + discord_user_id=None), + ] + + +def _players_with_one_wolf_dead() -> list[Player]: + """Two wolves total, one alive (seat 2), one dead (seat 3).""" + return [ + Player(seat_no=1, role=Role.VILLAGER, alive=True), + Player(seat_no=2, role=Role.WEREWOLF, alive=True), + Player(seat_no=3, role=Role.WEREWOLF, alive=False, + death_cause=None, death_day=1), + Player(seat_no=4, role=Role.SEER, alive=True), + ] + + +def test_snapshot_partitions_alive_dead_and_sorts() -> None: + snap = build_snapshot_for_seat( + npc_id="npc_setsu", + game_id="g1", + seat_no=2, + persona_key="setsu", + role=Role.WEREWOLF, + day_number=2, + players=_players_with_one_wolf_dead(), + seats=_seats(), + ts=1000, + trace_id="t", + ) + assert snap.alive_seats == ((1, "Alice"), (2, "Bob"), (4, "Dave")) + assert snap.dead_seats == ((3, "Carol"),) + assert snap.role == "WEREWOLF" + assert snap.day_number == 2 + + +def test_snapshot_partner_wolves_only_for_werewolf_excludes_self_and_dead() -> None: + # Recipient is the alive wolf (seat 2) — partner is seat 3 but dead. + snap = build_snapshot_for_seat( + npc_id="npc_setsu", + game_id="g1", + seat_no=2, + persona_key="setsu", + role=Role.WEREWOLF, + day_number=2, + players=_players_with_one_wolf_dead(), + seats=_seats(), + ts=1000, + trace_id="t", + ) + # Dead partner is excluded; recipient is excluded; nothing left. + assert snap.partner_wolves == () + + +def test_snapshot_partner_wolves_excludes_self() -> None: + players = [ + Player(seat_no=1, role=Role.VILLAGER, alive=True), + Player(seat_no=2, role=Role.WEREWOLF, alive=True), + Player(seat_no=3, role=Role.WEREWOLF, alive=True), + ] + seats = [ + Seat(seat_no=n, display_name=name, is_llm=True, persona_key="x", + discord_user_id=None) + for n, name in [(1, "Alice"), (2, "Bob"), (3, "Carol")] + ] + snap = build_snapshot_for_seat( + npc_id="npc_x", + game_id="g1", + seat_no=2, + persona_key="setsu", + role=Role.WEREWOLF, + day_number=1, + players=players, + seats=seats, + ts=1000, + trace_id="t", + ) + # Only seat 3 (the other living wolf) is in partner_wolves. + assert snap.partner_wolves == ((3, "Carol"),) + + +def test_snapshot_non_wolf_role_has_no_partners() -> None: + snap = build_snapshot_for_seat( + npc_id="npc_jonas", + game_id="g1", + seat_no=4, + persona_key="jonas", + role=Role.SEER, + day_number=2, + players=_players_with_one_wolf_dead(), + seats=_seats(), + ts=1000, + trace_id="t", + ) + assert snap.partner_wolves == () + + +def test_seer_result_update_round_trips_into_state() -> None: + snap = build_snapshot_for_seat( + npc_id="npc_jonas", game_id="g1", seat_no=4, persona_key="jonas", + role=Role.SEER, day_number=1, + players=_players_with_one_wolf_dead(), seats=_seats(), + ts=1000, trace_id="t", + ) + state = state_from_snapshot(snap) + upd = make_seer_result_update( + npc_id="npc_jonas", game_id="g1", seat_no=4, + day=1, target_seat=2, target_name="Bob", is_wolf=True, + ts=2000, trace_id="t2", + ) + apply_update(state, upd) + assert len(state.seer_results) == 1 + sr = state.seer_results[0] + assert sr.target_seat == 2 and sr.is_wolf is True + + +def test_medium_result_update_can_carry_null_is_wolf() -> None: + snap = build_snapshot_for_seat( + npc_id="npc_x", game_id="g1", seat_no=1, persona_key="x", + role=Role.MEDIUM, day_number=1, + players=[Player(seat_no=1, role=Role.MEDIUM, alive=True)], + seats=[Seat(seat_no=1, display_name="Alice", is_llm=True, persona_key="x", discord_user_id=None)], + ts=1, trace_id="t", + ) + state = state_from_snapshot(snap) + upd = make_medium_result_update( + npc_id="npc_x", game_id="g1", seat_no=1, + day=1, target_seat=2, target_name="Bob", is_wolf=None, + ts=2, trace_id="t2", + ) + apply_update(state, upd) + assert state.medium_results[0].is_wolf is None + + +def test_guard_entry_then_resolved_fills_peaceful_flag() -> None: + snap = build_snapshot_for_seat( + npc_id="npc_x", game_id="g1", seat_no=1, persona_key="x", + role=Role.KNIGHT, day_number=1, + players=[Player(seat_no=1, role=Role.KNIGHT, alive=True)], + seats=[Seat(seat_no=1, display_name="Alice", is_llm=True, persona_key="x", discord_user_id=None)], + ts=1, trace_id="t", + ) + state = state_from_snapshot(snap) + apply_update(state, make_guard_entry_update( + npc_id="npc_x", game_id="g1", seat_no=1, + day=1, target_seat=2, target_name="Bob", ts=2, trace_id="t2", + )) + apply_update(state, make_guard_resolved_update( + npc_id="npc_x", game_id="g1", seat_no=1, + day=1, peaceful_morning=True, ts=3, trace_id="t3", + )) + assert state.guard_history[0].peaceful_morning is True + + +def test_wolf_chat_update_appends() -> None: + snap = build_snapshot_for_seat( + npc_id="npc_x", game_id="g1", seat_no=2, persona_key="x", + role=Role.WEREWOLF, day_number=1, + players=_players_with_one_wolf_dead(), seats=_seats(), + ts=1, trace_id="t", + ) + state = state_from_snapshot(snap) + apply_update(state, make_wolf_chat_update( + npc_id="npc_x", game_id="g1", seat_no=2, + day=1, speaker_seat=3, speaker_name="Carol", + text="席1を狙おう", ts=2, trace_id="t2", + )) + assert state.wolf_chat_history[-1].text == "席1を狙おう" + + +def test_alive_changed_update_replaces_lists() -> None: + snap = build_snapshot_for_seat( + npc_id="npc_x", game_id="g1", seat_no=1, persona_key="x", + role=Role.VILLAGER, day_number=1, + players=_players_with_one_wolf_dead(), seats=_seats(), + ts=1, trace_id="t", + ) + state = state_from_snapshot(snap) + new_players = list(_players_with_one_wolf_dead()) + new_players[0] = Player(seat_no=1, role=Role.VILLAGER, alive=False, + death_cause=None, death_day=2) + apply_update(state, make_alive_changed_update( + npc_id="npc_x", game_id="g1", seat_no=1, + players=new_players, seats=_seats(), ts=2, trace_id="t2", + )) + alive_set = {p[0] for p in state.alive_seats} + dead_set = {p[0] for p in state.dead_seats} + assert 1 in dead_set and 1 not in alive_set + + +def test_day_advanced_update_increments_day() -> None: + snap = build_snapshot_for_seat( + npc_id="npc_x", game_id="g1", seat_no=1, persona_key="x", + role=Role.VILLAGER, day_number=1, + players=_players_with_one_wolf_dead(), seats=_seats(), + ts=1, trace_id="t", + ) + state = state_from_snapshot(snap) + apply_update(state, make_day_advanced_update( + npc_id="npc_x", game_id="g1", seat_no=1, + day_number=3, ts=2, trace_id="t2", + )) + assert state.day_number == 3 + + +# ---- DB → snapshot history loader ------------------------------------ + + +@pytest_asyncio.fixture +async def fresh_repo() -> AsyncIterator[SqliteRepo]: + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "test.db" + await migrate(db) + r = SqliteRepo(db) + await r.connect() + try: + yield r + finally: + await r.close() + + +async def _seed_game_with_seer(repo: SqliteRepo) -> Game: + """A minimal game with seat 5 = SEER and seat 9 = the night-0 + random-white target. Only enough rows for the loader to parse.""" + g = Game( + id="snap_seed", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=i, display_name=f"NPC{i}", is_llm=True, + persona_key=None, discord_user_id=None) + for i in range(1, 10) + ] + seats[8] = Seat( # seat 9 with emoji prefix to mirror live data + seat_no=9, display_name="👑ユリコ", is_llm=True, + persona_key="yuriko", discord_user_id=None, + ) + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 5, Role.SEER) + await repo.set_player_role(g.id, 9, Role.VILLAGER) + return g + + +async def test_load_private_state_seer_recovers_night0_random_white( + fresh_repo: SqliteRepo, +) -> None: + """The seer's NIGHT_0 random white must round-trip through the + snapshot loader. Pre-fix the snapshot was always built with + seer_results=() — Gina's day-1 user prompt had no `## 自分の占い結果` + block and she correctly chose not to fabricate a CO without data.""" + g = await _seed_game_with_seer(fresh_repo) + await fresh_repo.insert_log_private( + LogEntry( + game_id=g.id, day=0, phase=Phase.NIGHT_0, + kind="SEER_RESULT_NIGHT0", + actor_seat=None, audience_seat=5, visibility="PRIVATE", + text="初日ランダム白: 👑ユリコ は 人狼ではありません。", + created_at=1, + ) + ) + seers, mediums, guards, wolves, attacks = await load_private_state_for_seat( + fresh_repo, game_id=g.id, seat_no=5, role=Role.SEER, + players=await fresh_repo.load_players(g.id), + seats=await fresh_repo.load_seats(g.id), + ) + assert mediums == () and guards == () and wolves == () and attacks == () + assert len(seers) == 1 + assert seers[0].day == 0 + assert seers[0].target_seat == 9 + assert seers[0].target_name == "👑ユリコ" + assert seers[0].is_wolf is False + + +async def test_load_private_state_seer_parses_day1_black_and_white( + fresh_repo: SqliteRepo, +) -> None: + """Day 1+ SEER_RESULT log text format ('〜 は 人狼です' / '〜 は + 人狼ではありません'). Both branches resolve to the right is_wolf.""" + g = await _seed_game_with_seer(fresh_repo) + await fresh_repo.insert_log_private( + LogEntry( + game_id=g.id, day=1, phase=Phase.NIGHT, + kind="SEER_RESULT", actor_seat=None, audience_seat=5, + visibility="PRIVATE", + text="占い結果: NPC2 は 人狼 です。", created_at=10, + ) + ) + await fresh_repo.insert_log_private( + LogEntry( + game_id=g.id, day=2, phase=Phase.NIGHT, + kind="SEER_RESULT", actor_seat=None, audience_seat=5, + visibility="PRIVATE", + text="占い結果: NPC3 は 人狼ではありません。", created_at=20, + ) + ) + seers, _m, _g, _w, _a = await load_private_state_for_seat( + fresh_repo, game_id=g.id, seat_no=5, role=Role.SEER, + players=await fresh_repo.load_players(g.id), + seats=await fresh_repo.load_seats(g.id), + ) + by_day = {s.day: s for s in seers} + assert by_day[1].target_seat == 2 and by_day[1].is_wolf is True + assert by_day[2].target_seat == 3 and by_day[2].is_wolf is False + + +async def test_load_private_state_wolf_chat_history_for_wolf( + fresh_repo: SqliteRepo, +) -> None: + """Wolf NPCs see WOLF_CHAT private logs (audience_seat=NULL).""" + g = await _seed_game_with_seer(fresh_repo) + await fresh_repo.set_player_role(g.id, 1, Role.WEREWOLF) + await fresh_repo.set_player_role(g.id, 3, Role.WEREWOLF) + await fresh_repo.insert_log_private( + LogEntry( + game_id=g.id, day=0, phase=Phase.NIGHT_0, + kind="WOLF_CHAT", actor_seat=3, audience_seat=None, + visibility="PRIVATE", text="今日は席5を狙うか", created_at=5, + ) + ) + _s, _m, _gh, wolves, _a = await load_private_state_for_seat( + fresh_repo, game_id=g.id, seat_no=1, role=Role.WEREWOLF, + players=await fresh_repo.load_players(g.id), + seats=await fresh_repo.load_seats(g.id), + ) + assert len(wolves) == 1 + assert wolves[0].speaker_seat == 3 + assert wolves[0].text == "今日は席5を狙うか" + + +async def test_load_private_state_knight_guard_history_with_morning( + fresh_repo: SqliteRepo, +) -> None: + """Knight's guard history is rebuilt from night_actions, with + peaceful_morning derived from the next day's MORNING public log.""" + g = await _seed_game_with_seer(fresh_repo) + await fresh_repo.set_player_role(g.id, 7, Role.KNIGHT) + await fresh_repo.insert_night_action( + NightAction( + game_id=g.id, day=1, actor_seat=7, + kind=SubmissionType.KNIGHT_GUARD, target_seat=5, + submitted_at=100, + ) + ) + await fresh_repo.insert_log_public( + LogEntry( + game_id=g.id, day=2, phase=Phase.DAY_DISCUSSION, + kind="MORNING", actor_seat=None, visibility="PUBLIC", + text="平和な朝です。昨晩の犠牲者はいません。", created_at=200, + ) + ) + _s, _m, guards, _w, _a = await load_private_state_for_seat( + fresh_repo, game_id=g.id, seat_no=7, role=Role.KNIGHT, + players=await fresh_repo.load_players(g.id), + seats=await fresh_repo.load_seats(g.id), + ) + assert len(guards) == 1 + assert guards[0].day == 1 + assert guards[0].target_seat == 5 + assert guards[0].peaceful_morning is True + + +async def test_load_private_state_wolf_attack_history_with_morning( + fresh_repo: SqliteRepo, +) -> None: + """Wolves see their own attack history with `peaceful_morning` + derived from the matching MORNING public log so they can detect + "GJ → re-attack the same target tomorrow because the knight can't + guard consecutively". + """ + g = await _seed_game_with_seer(fresh_repo) + await fresh_repo.set_player_role(g.id, 1, Role.WEREWOLF) + await fresh_repo.set_player_role(g.id, 3, Role.WEREWOLF) + # Wolf attack on day 1 — GJ'd by knight (peaceful morning on day 1). + await fresh_repo.insert_night_action( + NightAction( + game_id=g.id, day=1, actor_seat=1, + kind=SubmissionType.WOLF_ATTACK, target_seat=9, + submitted_at=100, + ) + ) + await fresh_repo.insert_log_public( + LogEntry( + game_id=g.id, day=1, phase=Phase.DAY_DISCUSSION, + kind="MORNING", actor_seat=None, visibility="PUBLIC", + text="平和な朝です。昨晩の犠牲者はいません。", created_at=200, + ) + ) + # Wolf attack on day 2 — successful kill. + await fresh_repo.insert_night_action( + NightAction( + game_id=g.id, day=2, actor_seat=1, + kind=SubmissionType.WOLF_ATTACK, target_seat=5, + submitted_at=300, + ) + ) + await fresh_repo.insert_log_public( + LogEntry( + game_id=g.id, day=2, phase=Phase.DAY_DISCUSSION, + kind="MORNING", actor_seat=5, visibility="PUBLIC", + text="朝になりました。犠牲者: NPC5", created_at=400, + ) + ) + + _s, _m, _gh, _w, attacks = await load_private_state_for_seat( + fresh_repo, game_id=g.id, seat_no=1, role=Role.WEREWOLF, + players=await fresh_repo.load_players(g.id), + seats=await fresh_repo.load_seats(g.id), + ) + by_day = {a.day: a for a in attacks} + assert by_day[1].target_seat == 9 + assert by_day[1].peaceful_morning is True, ( + "GJ on day 1 → wolves should see peaceful_morning=True so the " + "next-night re-attack heuristic can fire" + ) + assert by_day[2].target_seat == 5 + assert by_day[2].peaceful_morning is False diff --git a/tests/test_master_public_digest.py b/tests/test_master_public_digest.py new file mode 100644 index 0000000..d128ba9 --- /dev/null +++ b/tests/test_master_public_digest.py @@ -0,0 +1,227 @@ +"""Phase-D Master public-info digest builder.""" + +from __future__ import annotations + +from wolfbot.domain.discussion import ( + CoClaim, + PublicDiscussionState, + SpeakerKind, + SpeechEvent, + SpeechSource, + make_phase_id, +) +from wolfbot.domain.enums import Phase +from wolfbot.master.arbiter.public_digest import build_public_digest + + +def _state(**overrides: object) -> PublicDiscussionState: + base: dict[str, object] = { + "game_id": "g1", + "phase_id": make_phase_id("g1", 1, Phase.DAY_DISCUSSION), + "day": 1, + "alive_seat_nos": frozenset({1, 2, 3, 4}), + "co_claims": (), + "silent_seats": frozenset(), + } + base.update(overrides) + return PublicDiscussionState(**base) # type: ignore[arg-type] + + +def _ev( + *, + speaker_seat: int, + text: str, + addressed: int | None = None, + source: SpeechSource = SpeechSource.NPC_GENERATED, +) -> SpeechEvent: + return SpeechEvent( + event_id=f"ev-{speaker_seat}-{text[:5]}", + game_id="g1", + phase_id=make_phase_id("g1", 1, Phase.DAY_DISCUSSION), + day=1, + phase=Phase.DAY_DISCUSSION, + source=source, + speaker_kind=( + SpeakerKind.HUMAN + if source == SpeechSource.VOICE_STT or source == SpeechSource.TEXT + else SpeakerKind.NPC + ), + speaker_seat=speaker_seat, + text=text, + addressed_seat_no=addressed, + created_at_ms=1000, + ) + + +def test_digest_renders_co_section_when_empty() -> None: + state = _state() + out = build_public_digest( + state=state, + recent_events=[], + seat_names={1: "Alice"}, + ) + assert "## CO 状況" in out + assert "まだ誰も CO していない" in out + + +def test_digest_renders_co_claims_with_names() -> None: + state = _state( + co_claims=( + CoClaim(seat=2, role_claim="seer", declared_at_event_id="ev1"), + CoClaim(seat=4, role_claim="medium", declared_at_event_id="ev2"), + ), + ) + out = build_public_digest( + state=state, + recent_events=[], + seat_names={1: "Alice", 2: "Bob", 4: "Dave"}, + ) + # New naming policy: digest renders by display_name only (seat + # numbers are intentionally NOT in this block — the full mapping + # lives in the user-prompt's `## 参加者` roster, not here). + assert "Bob: seer" in out + assert "Dave: medium" in out + assert "席2" not in out + assert "席4" not in out + + +def test_digest_lists_silent_seats() -> None: + state = _state(silent_seats=frozenset({3, 4})) + out = build_public_digest( + state=state, + recent_events=[], + seat_names={3: "Carol", 4: "Dave"}, + ) + assert "## 未発言の生存席" in out + assert "Carol" in out and "Dave" in out + # Seat numbers stripped — the roster header upstream is the single + # source of truth. + assert "席3" not in out and "席4" not in out + + +def test_digest_omits_addressed_counts_block() -> None: + """Regression: the `## 名指しされた回数` aggregate ranking used to bias + vote/night decision LLMs into bandwagon-style targeting of the most + -addressed seat (typically the human player, who naturally draws + more callouts as they argue). Removed so the LLM reads addressing + patterns from the raw recent-speeches block instead. See game + `ed7742ff08f8` day-1 for the original failure mode (8/8 NPCs voted + the human villager who'd been called out 5 times). + """ + state = _state() + events = [ + _ev(speaker_seat=1, text="say 1", addressed=2), + _ev(speaker_seat=3, text="say 3", addressed=2), + _ev(speaker_seat=4, text="say 4", addressed=2), + _ev(speaker_seat=1, text="more", addressed=4), + ] + out = build_public_digest( + state=state, + recent_events=events, + seat_names={2: "Bob", 4: "Dave"}, + ) + assert "## 名指しされた回数" not in out + assert "Bob: 3回" not in out + assert "Dave: 1回" not in out + + +def test_digest_renders_last_addressed_block() -> None: + state = _state( + last_addressed_seat=2, + last_addressed_speaker_seat=1, + last_addressed_text="あなたの白判定が信用できないんです", + ) + out = build_public_digest( + state=state, + recent_events=[], + seat_names={1: "Alice", 2: "Bob"}, + ) + assert "## 直近の名指し" in out + assert "Alice → Bob" in out + assert "信用できない" in out + + +def test_digest_truncates_long_addressed_snippet() -> None: + long_text = "あ" * 200 + state = _state( + last_addressed_seat=2, + last_addressed_speaker_seat=1, + last_addressed_text=long_text, + ) + out = build_public_digest( + state=state, + recent_events=[], + seat_names={1: "Alice", 2: "Bob"}, + ) + # Truncated to 120 chars + ellipsis. + assert "あ" * 120 + "…" in out + assert long_text not in out + + +def test_digest_skips_phase_baseline_in_recent_speeches() -> None: + """phase_baseline rows are internal sentinels with empty text — they + must not appear in the recent-speeches block.""" + state = _state() + events = [ + _ev(speaker_seat=1, text="", source=SpeechSource.PHASE_BASELINE), + _ev(speaker_seat=1, text="say", addressed=2), + ] + out = build_public_digest( + state=state, + recent_events=events, + seat_names={1: "Alice", 2: "Bob"}, + ) + # The non-baseline speech surfaces in the recent-speeches block. + assert "Alice" in out and "say" in out + + +def test_digest_renders_recent_speech_block_so_vote_llm_sees_seer_results() -> None: + """Regression: the vote / night decision LLM previously saw only + the CO-claims summary (席9 が seer) without any actual content. So + when ユリコ called SQ black, that fact never reached the vote + prompt and ラキオ ended up voting セツ instead of SQ. The digest + must include the recent speech text so the decision LLM can + align with what was said. + """ + state = _state( + co_claims=(CoClaim(seat=9, role_claim="seer", declared_at_event_id="ev-co1"),), + ) + events = [ + _ev(speaker_seat=9, text="この身、占い師。SQ黒。"), + _ev(speaker_seat=3, text="僕の霊媒結果はシゲミチ狼。SQ処刑しよう。"), + ] + out = build_public_digest( + state=state, + recent_events=events, + seat_names={3: "Rakio", 9: "Yuriko"}, + ) + assert "## 直近の発言 (古い順)" in out + assert "Yuriko" in out + assert "SQ黒" in out + assert "Rakio" in out + assert "シゲミチ狼" in out + # Seat numbers no longer appear in the digest body (roster-only). + assert "席9" not in out and "席3" not in out + + +def test_digest_renders_past_votes_when_provided() -> None: + """The completed-day ballot ledger lets the LLM reason about who + voted whom historically — same data the SpeakArbiter already feeds + discussion-time speeches via LogicPacket.past_votes. + """ + state = _state() + past_votes = ((1, 0, ((1, 7), (2, 7), (3, 7), (4, 1), (5, None))),) + out = build_public_digest( + state=state, + recent_events=[], + seat_names={1: "Alice", 2: "Bob", 3: "Carol", 4: "Dave", 5: "Eve", 7: "Frank"}, + past_votes=past_votes, + ) + assert "## 公開された投票履歴" in out + assert "day1 投票" in out + assert "Alice → Frank" in out + assert "Eve → 棄権" in out + # Seat numbers don't appear in the body — only the roster header + # (which `build_public_digest` doesn't render; the upstream user + # prompt does). + assert "席1" not in out and "席7" not in out diff --git a/tests/test_master_stt_groq.py b/tests/test_master_stt_groq.py new file mode 100644 index 0000000..9b2773c --- /dev/null +++ b/tests/test_master_stt_groq.py @@ -0,0 +1,635 @@ +"""Groq Whisper STT pipeline tests. + +Cover the two-step ``GroqWhisperAudioAnalyzer.transcribe`` pipeline: + +1. **Whisper success → analyzer success** — both calls 200, structured + fields propagate to ``SttResult``. +2. **Whisper success → analyzer 5xx** — soft fall-back: transcript + surfaces, structured fields are ``None`` so the discussion path can + still legacy-match CO via substring. +3. **Whisper 4xx/5xx** — hard error: ``SttProviderError`` raised, no + analyzer call attempted. +4. **Whisper 200 with empty transcript** — short-circuits the pipeline + so we don't burn an analyzer call on silence. +5. **Trace lines emitted** — both steps write under ``role=voice_stt`` + distinguished by ``metadata.step``; tokens captured for the analyzer + step when the response carries usage. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import httpx +import pytest + +from wolfbot.master.voice.stt_service import ( + GroqWhisperAudioAnalyzer, + SttProviderError, +) + + +@pytest.fixture +def trace_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setenv("WOLFBOT_LLM_TRACE_DIR", str(tmp_path)) + monkeypatch.delenv("WOLFBOT_LLM_TRACE_DISABLED", raising=False) + return tmp_path + + +def _make_wav(payload_size: int = 4_000) -> bytes: + """An opaque PCM-shaped blob. Tests don't parse the actual bytes — + the mock httpx layer just echoes a canned response. Defaults to + 4 000 zero bytes (~20 ms of Discord-native 48 kHz stereo PCM).""" + return b"\x00" * payload_size + + +def _read_voice_stt_trace(trace_dir: Path, game_id: str = "g_groq") -> list[dict]: + path = trace_dir / game_id / "voice_stt.jsonl" + if not path.exists(): + return [] + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] + + +def _httpx_mock(routes: dict[str, dict[str, Any]]) -> Any: + """Return an ``httpx.MockTransport`` that dispatches by URL prefix. + + Each route value is ``{"status": int, "json": dict}`` (or ``"text"`` / + ``"raise"`` for non-JSON paths). Multiple routes can match — we pick + the longest-prefix match to disambiguate Groq vs analyzer URLs. + """ + + def handler(request: httpx.Request) -> httpx.Response: + url = str(request.url) + # Longest-prefix wins. + candidates = [k for k in routes if url.startswith(k)] + if not candidates: + raise AssertionError(f"unexpected request: {url}") + key = max(candidates, key=len) + spec = routes[key] + if "raise" in spec: + raise spec["raise"] + if "text" in spec: + return httpx.Response(spec["status"], text=spec["text"]) + return httpx.Response(spec["status"], json=spec.get("json", {})) + + return httpx.MockTransport(handler) + + +@pytest.fixture +def patch_httpx(monkeypatch: pytest.MonkeyPatch) -> Any: + """Replace ``httpx.AsyncClient`` so each test can wire its own routes. + + Returns a setter function the test calls with the route dict. + """ + state: dict[str, Any] = {"transport": None} + + real_client = httpx.AsyncClient + + class _MockedClient(real_client): # type: ignore[misc, valid-type] + def __init__(self, *args: Any, **kwargs: Any) -> None: + kwargs.setdefault("transport", state["transport"]) + super().__init__(*args, **kwargs) + + monkeypatch.setattr("httpx.AsyncClient", _MockedClient) + + def set_routes(routes: dict[str, dict[str, Any]]) -> None: + state["transport"] = _httpx_mock(routes) + + return set_routes + + +def _analyzer_json(payload: dict[str, Any]) -> dict[str, Any]: + """Wrap a structured-analysis payload as an OpenAI-compatible response.""" + return { + "choices": [ + {"message": {"content": json.dumps(payload, ensure_ascii=False)}} + ], + "usage": { + "prompt_tokens": 80, + "completion_tokens": 30, + "total_tokens": 110, + }, + } + + +def _whisper_verbose_json(text: str, no_speech_prob: float = 0.05) -> dict[str, Any]: + """Shape Groq's ``verbose_json`` response: top-level ``text`` plus a + single ``segments`` entry carrying ``no_speech_prob``. The transcribe + code path computes ASR confidence as ``1 - max(no_speech_prob)``.""" + return { + "text": text, + "segments": [{"text": text, "no_speech_prob": no_speech_prob}], + } + + +async def test_full_pipeline_success_propagates_structured_fields( + trace_dir: Path, patch_httpx: Any +) -> None: + patch_httpx({ + "https://api.groq.com/": { + "status": 200, + "json": _whisper_verbose_json("席3が怪しいから投票する", no_speech_prob=0.05), + }, + "https://api.x.ai/": { + "status": 200, + "json": _analyzer_json({ + "summary": "席3への投票表明", + "co_claim": None, + "vote_target_seat": 3, + "stance": {"3": "negative"}, + "addressed_name": None, + }), + }, + }) + + analyzer = GroqWhisperAudioAnalyzer( + groq_api_key="g_test", + groq_model="whisper-large-v3-turbo", + analyzer_api_key="x_test", + analyzer_model="grok-4-1-fast-non-reasoning", + ) + from wolfbot.services.llm_trace import trace_context + + with trace_context(game_id="g_groq", phase="DAY_DISCUSSION", day=1): + result = await analyzer.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=10.0) + + assert result.text == "席3が怪しいから投票する" + # confidence = 1 - 0.05 = 0.95, surfaced from Whisper not the analyzer. + assert result.confidence == pytest.approx(0.95) + assert result.summary == "席3への投票表明" + assert result.co_declaration is None + assert result.addressed_name is None + # ``raw_analysis`` carries the full analyzer JSON for debug + # visibility — fields the typed result drops (vote_target_seat / + # stance) must round-trip here. + assert result.raw_analysis is not None + assert result.raw_analysis["vote_target_seat"] == 3 + assert result.raw_analysis["stance"] == {"3": "negative"} + # 4000 PCM bytes at Discord's native 48 kHz stereo 16-bit + # (192_000 bytes/sec) ≈ 20 ms. + assert 15 < result.duration_ms < 30 + + rows = _read_voice_stt_trace(trace_dir) + assert len(rows) == 2 + transcribe_row = next(r for r in rows if r["metadata"]["step"] == "transcribe") + analyze_row = next(r for r in rows if r["metadata"]["step"] == "analyze") + assert transcribe_row["provider"] == "groq" + assert transcribe_row["model"] == "whisper-large-v3-turbo" + assert transcribe_row["error"] is None + assert "席3" in transcribe_row["response"] + assert transcribe_row["metadata"]["asr_confidence"] == pytest.approx(0.95) + assert analyze_row["provider"] == "xai" + assert analyze_row["model"] == "grok-4-1-fast-non-reasoning" + assert analyze_row["tokens"] == {"prompt": 80, "completion": 30, "total": 110} + + +async def test_low_claim_confidence_does_not_drop_speech(patch_httpx: Any) -> None: + """Regression: the analyzer used to return ``confidence: 0.0`` for + greetings and short utterances ("おやすみなさい"); we previously + surfaced that as ``SttResult.confidence``, which made + ``VoiceIngestService`` drop the segment as ``stt_low_confidence`` + and the user's voice never reached the arbiter. ASR confidence + must come from Whisper's signal, not the analyzer's claim-clarity + judgement.""" + patch_httpx({ + "https://api.groq.com/": { + "status": 200, + "json": _whisper_verbose_json("おやすみなさい", no_speech_prob=0.02), + }, + # Analyzer returns no useful structured fields for a greeting, + # but that must not gate the speech event. + "https://api.x.ai/": { + "status": 200, + "json": _analyzer_json({ + "summary": "就寝の挨拶", + "co_claim": None, + "vote_target_seat": None, + "stance": {}, + "addressed_name": None, + }), + }, + }) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + r = await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + assert r.text == "おやすみなさい" + # 1 - 0.02 = 0.98; safely above any reasonable confidence_threshold. + assert r.confidence > 0.9 + + +async def test_high_no_speech_prob_lowers_confidence(patch_httpx: Any) -> None: + """When Whisper itself thinks the audio is mostly silence/noise, + confidence must drop so ``VoiceIngestService`` can filter via its + threshold. This is the legitimate use case for the gate — silence + bursts mis-detected as speech.""" + patch_httpx({ + "https://api.groq.com/": { + "status": 200, + "json": _whisper_verbose_json("ふぁ", no_speech_prob=0.85), + }, + "https://api.x.ai/": { + "status": 200, + "json": _analyzer_json({}), + }, + }) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + r = await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + # 1 - 0.85 = 0.15 + assert r.confidence == pytest.approx(0.15) + + +async def test_missing_segments_falls_back_to_full_confidence(patch_httpx: Any) -> None: + """If Groq's response omits ``segments`` (very short audio, future + response-shape change), we trust the transcript and report 1.0 — + losing data quietly is worse than over-trusting on rare edge cases.""" + patch_httpx({ + "https://api.groq.com/": { + "status": 200, + "json": {"text": "やぁ"}, # no segments key + }, + "https://api.x.ai/": { + "status": 200, + "json": _analyzer_json({}), + }, + }) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + r = await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + assert r.text == "やぁ" + assert r.confidence == pytest.approx(1.0) + + +async def test_co_declaration_normalizes_to_known_roles(patch_httpx: Any) -> None: + """Analyzer can return arbitrary strings for ``co_claim``; only the + three accepted roles surface on ``SttResult``.""" + patch_httpx({ + "https://api.groq.com/": { + "status": 200, + "json": {"text": "占いCO 席7白"}, + }, + "https://api.x.ai/": { + "status": 200, + "json": _analyzer_json({ + "summary": "占いCO", + "confidence": 0.9, + "co_claim": "seer", + "vote_target_seat": None, + "stance": {}, + "addressed_name": None, + }), + }, + }) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + r = await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + assert r.co_declaration == "seer" + + +async def test_invalid_co_value_drops_to_none(patch_httpx: Any) -> None: + patch_httpx({ + "https://api.groq.com/": {"status": 200, "json": {"text": "なにか"}}, + "https://api.x.ai/": { + "status": 200, + "json": _analyzer_json({"co_claim": "wolf"}), # not in allowed set + }, + }) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + r = await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + assert r.co_declaration is None + + +async def test_addressed_name_strips_and_normalizes(patch_httpx: Any) -> None: + patch_httpx({ + "https://api.groq.com/": {"status": 200, "json": {"text": " ジナさんどう?"}}, + "https://api.x.ai/": { + "status": 200, + "json": _analyzer_json({"addressed_name": " ジナさん "}), + }, + }) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + r = await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + assert r.addressed_name == "ジナさん" + + +async def test_empty_transcript_short_circuits_analyzer( + trace_dir: Path, patch_httpx: Any +) -> None: + """A blank transcription must NOT trigger the analyzer call — no + point burning a Grok request on silence.""" + calls: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + url = str(request.url) + calls.append(url) + if "groq" in url: + return httpx.Response(200, json={"text": ""}) + return httpx.Response(200, json=_analyzer_json({})) + + import wolfbot.master.voice.stt_service as stt_module # noqa: F401 + + transport = httpx.MockTransport(handler) + real = httpx.AsyncClient + + class _M(real): # type: ignore[misc, valid-type] + def __init__(self, *a: Any, **k: Any) -> None: + k["transport"] = transport + super().__init__(*a, **k) + + import httpx as httpx_mod + + httpx_mod.AsyncClient = _M # type: ignore[misc] + try: + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + from wolfbot.services.llm_trace import trace_context + + with trace_context(game_id="g_groq"): + r = await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + finally: + httpx_mod.AsyncClient = real # type: ignore[misc] + + assert r.text == "" + assert r.confidence == 0.0 + assert all("groq" in c for c in calls), f"analyzer was called for empty transcript: {calls}" + rows = _read_voice_stt_trace(trace_dir) + assert len(rows) == 1 + assert rows[0]["metadata"]["step"] == "transcribe" + + +async def test_whisper_429_raises_stt_provider_error(patch_httpx: Any) -> None: + """Hard failures from Whisper bubble up as ``SttProviderError`` so + voice_ingest can route through the documented failure path.""" + patch_httpx({ + "https://api.groq.com/": {"status": 429, "json": {"error": "rate"}}, + "https://api.x.ai/": {"status": 200, "json": _analyzer_json({})}, + }) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + with pytest.raises(SttProviderError) as exc_info: + await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + assert exc_info.value.failure_reason == "groq_http_429" + + +async def test_whisper_4xx_response_body_recorded_in_trace( + trace_dir: Path, patch_httpx: Any +) -> None: + """A non-200 from Groq must surface its response body in the trace + so a recurring failure mode (e.g. 'audio decode failed') is + diagnosable from the JSONL alone — the original HTTP-400 incident + only logged the status code, masking the real cause.""" + patch_httpx({ + "https://api.groq.com/": { + "status": 400, + "text": "audio decode failed: invalid wav header", + }, + "https://api.x.ai/": {"status": 200, "json": _analyzer_json({})}, + }) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + from wolfbot.services.llm_trace import trace_context + + with ( + trace_context(game_id="g_groq"), + pytest.raises(SttProviderError), + ): + await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + + rows = _read_voice_stt_trace(trace_dir) + transcribe_row = next(r for r in rows if r["metadata"]["step"] == "transcribe") + assert transcribe_row["error"] == "groq_http_400" + assert transcribe_row["response"] is not None + assert "audio decode failed" in transcribe_row["response"] + + +async def test_whisper_timeout_raises(monkeypatch: pytest.MonkeyPatch) -> None: + def raiser(request: httpx.Request) -> httpx.Response: + raise httpx.TimeoutException("slow") + + transport = httpx.MockTransport(raiser) + real = httpx.AsyncClient + + class _M(real): # type: ignore[misc, valid-type] + def __init__(self, *a: Any, **k: Any) -> None: + k["transport"] = transport + super().__init__(*a, **k) + + monkeypatch.setattr(httpx, "AsyncClient", _M) + + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + with pytest.raises(SttProviderError) as exc_info: + await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + assert exc_info.value.failure_reason == "groq_timeout" + + +async def test_analyzer_5xx_soft_fails_keeps_transcript( + trace_dir: Path, patch_httpx: Any +) -> None: + """Analyzer outage must NOT lose the transcript — the discussion path + can still derive CO via substring, and dropping the speech entirely + would silence the human in front of the bot.""" + patch_httpx({ + "https://api.groq.com/": { + "status": 200, + "json": _whisper_verbose_json("占いCO 席7白", no_speech_prob=0.1), + }, + "https://api.x.ai/": {"status": 503, "text": "upstream"}, + }) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + from wolfbot.services.llm_trace import trace_context + + with trace_context(game_id="g_groq"): + r = await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + + assert r.text == "占いCO 席7白" + # ASR confidence comes from Whisper (1 - 0.1 = 0.9), not from the + # analyzer — analyzer outage doesn't change the transcription's + # confidence. + assert r.confidence == pytest.approx(0.9) + assert r.summary is None + assert r.co_declaration is None + + rows = _read_voice_stt_trace(trace_dir) + assert len(rows) == 2 + analyze_row = next(r for r in rows if r["metadata"]["step"] == "analyze") + assert analyze_row["error"] == "analyzer_http_503" + assert analyze_row["response"] is None + + +async def test_analyzer_returns_garbage_json_soft_fails( + trace_dir: Path, patch_httpx: Any +) -> None: + """Malformed analyzer JSON must not crash the pipeline — record the + parse error in the trace and fall through with the transcript.""" + patch_httpx({ + "https://api.groq.com/": {"status": 200, "json": {"text": "やあ"}}, + "https://api.x.ai/": { + "status": 200, + "json": { + "choices": [{"message": {"content": "not-json{"}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }, + }, + }) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + from wolfbot.services.llm_trace import trace_context + + with trace_context(game_id="g_groq"): + r = await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + + assert r.text == "やあ" + assert r.summary is None + rows = _read_voice_stt_trace(trace_dir) + analyze_row = next(r for r in rows if r["metadata"]["step"] == "analyze") + assert analyze_row["error"] == "analyzer_json_parse_failed" + # Even on parse fail we record the raw response so operators can debug. + assert analyze_row["response"] == "not-json{" + + +async def test_raw_pcm_is_wrapped_to_wav_before_post(monkeypatch: pytest.MonkeyPatch) -> None: + """Regression: production sends Discord-native raw PCM. Without the + WAV wrapper, Groq returns ``HTTP 400`` because ffmpeg can't demux a + headerless byte stream — observed in the 2026-04-28 game with 15 + consecutive ``groq_http_400`` failures. Assert the multipart + payload contains a real RIFF/WAV header.""" + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + url = str(request.url) + if "groq" in url: + captured["body"] = request.read() + return httpx.Response(200, json={"text": "テスト"}) + return httpx.Response(200, json=_analyzer_json({})) + + transport = httpx.MockTransport(handler) + real = httpx.AsyncClient + + class _M(real): # type: ignore[misc, valid-type] + def __init__(self, *a: Any, **k: Any) -> None: + k["transport"] = transport + super().__init__(*a, **k) + + monkeypatch.setattr(httpx, "AsyncClient", _M) + + raw_pcm = b"\x00" * 9_600 # 50 ms of 48 kHz stereo + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", + groq_model="m", + analyzer_api_key="x", + analyzer_model="grok", + ) + await a.transcribe(audio=raw_pcm, language="ja-JP", timeout_s=5) + + body = captured["body"] + # Multipart form contains the file part; the wrapped audio MUST start + # with the standard ``RIFF`` magic and contain a ``WAVE`` marker. + assert b"RIFF" in body + assert b"WAVE" in body + # And the configured sample rate (48000 = 0x0000BB80 little-endian) + # appears in the fmt chunk. + assert b"\x80\xbb\x00\x00" in body + + +async def test_language_strips_region_for_whisper(patch_httpx: Any) -> None: + """Whisper accepts BCP-47 short codes (``ja``) not regional ones + (``ja-JP``); the adapter must strip before sending.""" + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + url = str(request.url) + if "groq" in url: + captured["body"] = request.read() + return httpx.Response(200, json={"text": "テスト"}) + return httpx.Response(200, json=_analyzer_json({})) + + transport = httpx.MockTransport(handler) + real = httpx.AsyncClient + + class _M(real): # type: ignore[misc, valid-type] + def __init__(self, *a: Any, **k: Any) -> None: + k["transport"] = transport + super().__init__(*a, **k) + + import httpx as httpx_mod + + httpx_mod.AsyncClient = _M # type: ignore[misc] + try: + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + await a.transcribe(audio=_make_wav(), language="ja-JP", timeout_s=5) + finally: + httpx_mod.AsyncClient = real # type: ignore[misc] + + body = captured["body"] + # multipart payload contains form field "language" with value "ja" + assert b'name="language"' in body + # "ja-JP" should NOT appear — we stripped to "ja". + assert b"ja-JP" not in body + + +async def test_duration_ms_estimated_from_pcm_format(patch_httpx: Any) -> None: + """``duration_ms`` is computed from the configured PCM format (default + Discord native: 48 kHz stereo 16-bit). 1 second of audio = 192 000 + bytes.""" + patch_httpx({ + "https://api.groq.com/": {"status": 200, "json": {"text": "短く"}}, + "https://api.x.ai/": {"status": 200, "json": _analyzer_json({})}, + }) + one_second_native = b"\x00" * (48_000 * 2 * 2) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + ) + r = await a.transcribe(audio=one_second_native, language="ja-JP", timeout_s=5) + assert 950 <= r.duration_ms <= 1050 + + +async def test_duration_ms_honors_overridden_pcm_format(patch_httpx: Any) -> None: + """A caller feeding 16 kHz mono PCM (e.g. a future down-mixer) + overrides the format kwargs and gets a correct duration.""" + patch_httpx({ + "https://api.groq.com/": {"status": 200, "json": {"text": "短く"}}, + "https://api.x.ai/": {"status": 200, "json": _analyzer_json({})}, + }) + one_second_16k_mono = b"\x00" * (16_000 * 2) + a = GroqWhisperAudioAnalyzer( + groq_api_key="g", groq_model="m", + analyzer_api_key="x", analyzer_model="grok", + pcm_sample_rate=16_000, + pcm_channels=1, + ) + r = await a.transcribe(audio=one_second_16k_mono, language="ja-JP", timeout_s=5) + assert 950 <= r.duration_ms <= 1050 diff --git a/tests/test_master_tts_playback.py b/tests/test_master_tts_playback.py new file mode 100644 index 0000000..fb93433 --- /dev/null +++ b/tests/test_master_tts_playback.py @@ -0,0 +1,135 @@ +"""MasterTtsPlayback — narration ↔ NPC speech serialization tests. + +Pins down two invariants: + + * While Master is mid-narration the arbiter's `_active_playback` + carries a sentinel so `try_dispatch_next` would treat the gate as + `queue_busy` and suppress new NPC dispatch. + * Before Master starts speaking it waits for any in-flight NPC + playback to drain — so an already-authorized NPC utterance never + overlaps Master's voice in the same VC. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field + +import pytest + +from wolfbot.master.voice.tts_playback import MasterTtsPlayback +from wolfbot.npc.audio.tts import FakeTtsService, TtsResult + + +@dataclass +class _MiniArbiter: + """Minimal arbiter stand-in carrying just the `_active_playback` + surface MasterTtsPlayback touches.""" + + _active_playback: set[str] = field(default_factory=set) + + +def _make_playback() -> MasterTtsPlayback: + return MasterTtsPlayback( + tts=FakeTtsService(default=TtsResult(audio=b"WAVE", duration_ms=100)), + voice_id="47", + vc_ref=[None], # No real VC — speak() falls through silently. + ) + + +async def test_suppress_npc_dispatch_holds_sentinel_during_block() -> None: + arb = _MiniArbiter() + pb = _make_playback() + async with pb.suppress_npc_dispatch(arb): + # While the with-block is open, exactly one master sentinel + # is parked on the arbiter — `is_blocked()` (the arbiter's + # public surface) would see this as queue_busy. + assert len(arb._active_playback) == 1 + only = next(iter(arb._active_playback)) + assert only.startswith("master-narration-") + # After the block, the sentinel is removed. + assert arb._active_playback == set() + + +async def test_suppress_npc_dispatch_clears_on_exception() -> None: + arb = _MiniArbiter() + pb = _make_playback() + with pytest.raises(RuntimeError): + async with pb.suppress_npc_dispatch(arb): + raise RuntimeError("kaboom") + assert arb._active_playback == set() + + +async def test_suppress_npc_dispatch_waits_for_npc_playback_drain() -> None: + """An already-authorized NPC playback must finish before Master + speaks. We seed a non-master entry in `_active_playback`, then + enter the suppress context — it must block until we clear it.""" + arb = _MiniArbiter(_active_playback={"sr_npc_pending_xyz"}) + pb = _make_playback() + entered = asyncio.Event() + finished = asyncio.Event() + + async def _enter() -> None: + async with pb.suppress_npc_dispatch(arb): + entered.set() + finished.set() + + task = asyncio.create_task(_enter()) + # Give the task a moment to start; it must NOT have entered yet. + await asyncio.sleep(0.1) + assert not entered.is_set(), "must wait for NPC playback to drain" + # Drain the NPC entry — Master should now proceed. + arb._active_playback.discard("sr_npc_pending_xyz") + await asyncio.wait_for(finished.wait(), timeout=2.0) + await task + assert arb._active_playback == set() + + +async def test_drain_wait_times_out_gracefully() -> None: + """If an NPC entry is leaked / stuck, Master narration eventually + gives up rather than deadlocking the engine.""" + arb = _MiniArbiter(_active_playback={"sr_stuck_forever"}) + pb = _make_playback() + + async def _quick_check() -> None: + # Use a short max_wait_s to keep the test fast. + await pb._wait_for_npc_playback_drain(arb, max_wait_s=0.3, poll_interval_s=0.05) + + await asyncio.wait_for(_quick_check(), timeout=2.0) + + +async def test_suppress_npc_dispatch_handles_missing_arbiter_attribute() -> None: + """If the arbiter shape ever changes and `_active_playback` is + absent, the context must not crash — narration is more important + than gate-management on a malformed arbiter.""" + + class _Broken: + pass + + pb = _make_playback() + async with pb.suppress_npc_dispatch(_Broken()): + pass # No assertion — just verify no exception. + + +async def test_speak_returns_false_when_vc_disconnected() -> None: + """speak() with no VC client wired must fail soft (return False) + rather than crash the engine.""" + pb = _make_playback() + ok = await pb.speak("テスト") + assert ok is False + + +async def test_concurrent_master_sentinels_do_not_block_each_other() -> None: + """Master narrations are serialized by the playback's own asyncio + Lock — but the drain-wait must ignore other master sentinels + (otherwise back-to-back narrations would deadlock waiting for + each other).""" + arb = _MiniArbiter(_active_playback={"master-narration-already"}) + pb = _make_playback() + + async def _quick_drain() -> None: + await pb._wait_for_npc_playback_drain(arb, max_wait_s=0.3, poll_interval_s=0.05) + + # Should return promptly; if it counted master sentinels as blockers, + # this would hit the timeout. + await asyncio.wait_for(_quick_drain(), timeout=1.0) diff --git a/tests/test_master_wolf_chat_broker.py b/tests/test_master_wolf_chat_broker.py new file mode 100644 index 0000000..6e31290 --- /dev/null +++ b/tests/test_master_wolf_chat_broker.py @@ -0,0 +1,175 @@ +"""Phase-D wolf chat broker — Master receives WolfChatSend, fans out +PrivateStateUpdate(wolf_chat) to other live wolf NPCs, and persists a +canonical WOLF_CHAT log entry.""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable + +from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.models import Game, Seat +from wolfbot.domain.ws_messages import ( + PrivateStateUpdate, + WolfChatSend, +) +from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry +from wolfbot.master.ws.wolf_chat_broker import WolfChatBroker +from wolfbot.persistence.sqlite_repo import SqliteRepo + + +def _capture_send(buf: list[str]) -> Callable[[str], Awaitable[None]]: + async def _send(msg: str) -> None: + buf.append(msg) + + return _send + + +async def _seed_3wolf_game(repo: SqliteRepo) -> Game: + game = Game( + id="g_wolf", + guild_id="gu", + host_user_id="h", + phase=Phase.NIGHT, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + wolves_channel_id="cw", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(game) + seats = [ + Seat(seat_no=1, display_name="Alice", is_llm=True, persona_key="setsu", + discord_user_id=None), + Seat(seat_no=2, display_name="Bob", is_llm=True, persona_key="gina", + discord_user_id=None), + Seat(seat_no=3, display_name="Carol", is_llm=True, persona_key="jonas", + discord_user_id=None), + ] + for s in seats: + await repo.insert_seat(game.id, s) + await repo.set_player_role(game.id, 1, Role.WEREWOLF) + await repo.set_player_role(game.id, 2, Role.WEREWOLF) + await repo.set_player_role(game.id, 3, Role.VILLAGER) + return game + + +async def test_wolf_chat_send_broadcasts_to_other_wolves(repo: SqliteRepo) -> None: + game = await _seed_3wolf_game(repo) + registry = InMemoryNpcRegistry() + seat1_buf: list[str] = [] + seat2_buf: list[str] = [] + seat3_buf: list[str] = [] + for npc_id, persona, seat, buf in ( + ("npc_alice", "setsu", 1, seat1_buf), + ("npc_bob", "gina", 2, seat2_buf), + ("npc_carol", "jonas", 3, seat3_buf), + ): + registry.register( + npc_id=npc_id, discord_bot_user_id=f"bot{seat}", + supported_voices=(), version="1", + send=_capture_send(buf), now_ms=1000, persona_key=persona, + ) + registry.assign(npc_id, seat=seat, game_id=game.id, phase_id="g_wolf::day1::NIGHT::1") + + broker = WolfChatBroker(registry=registry, repo=repo, now_ms=lambda: 5000) + msg = WolfChatSend( + ts=5000, trace_id="t", + npc_id="npc_alice", seat_no=1, game_id=game.id, + text="席3を狙おう", + ) + await broker.handle_wolf_chat_send(msg) + + # Sender (seat 1) gets nothing. + assert seat1_buf == [] + # Other live wolf (seat 2) receives a wolf_chat update. + assert len(seat2_buf) == 1 + upd = PrivateStateUpdate.model_validate_json(seat2_buf[0]) + assert upd.update_kind == "wolf_chat" + assert upd.payload["text"] == "席3を狙おう" + assert upd.payload["speaker_seat"] == 1 + assert upd.payload["speaker_name"] == "Alice" + # Non-wolf seat (3) receives nothing. + assert seat3_buf == [] + + # WOLF_CHAT log row was persisted. + # Inspect the WOLF_CHAT private log directly via raw SQL — no + # audience-seat filter needed since the broker writes the row with a + # null audience (= visible to every wolf at replay time). + async with repo._db.execute( # type: ignore[attr-defined] + "SELECT kind, text FROM logs_private WHERE game_id=? AND kind='WOLF_CHAT'", + (game.id,), + ) as cur: + wolf_chat_rows = [dict(r) for r in await cur.fetchall()] + assert len(wolf_chat_rows) == 1 + assert "席3を狙おう" in wolf_chat_rows[0]["text"] + + +async def test_wolf_chat_send_drops_non_wolf_sender(repo: SqliteRepo) -> None: + game = await _seed_3wolf_game(repo) + registry = InMemoryNpcRegistry() + seat2_buf: list[str] = [] + registry.register( + npc_id="npc_bob", discord_bot_user_id="bot2", + supported_voices=(), version="1", + send=_capture_send(seat2_buf), now_ms=1000, persona_key="gina", + ) + registry.assign("npc_bob", seat=2, game_id=game.id, phase_id="g_wolf::day1::NIGHT::1") + + broker = WolfChatBroker(registry=registry, repo=repo, now_ms=lambda: 5000) + # Seat 3 is the villager; Master must drop the impersonating message. + msg = WolfChatSend( + ts=5000, trace_id="t", + npc_id="npc_carol", seat_no=3, game_id=game.id, + text="罠を仕込む", + ) + await broker.handle_wolf_chat_send(msg) + + assert seat2_buf == [] # No broadcast. + async with repo._db.execute( # type: ignore[attr-defined] + "SELECT kind FROM logs_private WHERE game_id=? AND kind='WOLF_CHAT'", + (game.id,), + ) as cur: + rows = list(await cur.fetchall()) + assert rows == [] + + +async def test_wolf_chat_send_mirrors_to_wolves_channel_when_set( + repo: SqliteRepo, +) -> None: + game = await _seed_3wolf_game(repo) + registry = InMemoryNpcRegistry() + seat1_buf: list[str] = [] + seat2_buf: list[str] = [] + for npc_id, seat, buf in ( + ("npc_alice", 1, seat1_buf), + ("npc_bob", 2, seat2_buf), + ): + registry.register( + npc_id=npc_id, discord_bot_user_id=f"bot{seat}", + supported_voices=(), version="1", + send=_capture_send(buf), now_ms=1000, persona_key="x", + ) + registry.assign(npc_id, seat=seat, game_id=game.id, phase_id="g_wolf::day1::NIGHT::1") + + posted: list[tuple[str, str]] = [] + + async def _post_to_wolves(game_id: str, text: str) -> None: + posted.append((game_id, text)) + + broker = WolfChatBroker( + registry=registry, repo=repo, + post_to_wolves_channel=_post_to_wolves, + now_ms=lambda: 5000, + ) + msg = WolfChatSend( + ts=5000, trace_id="t", + npc_id="npc_alice", seat_no=1, game_id=game.id, + text="様子見しよう", + ) + await broker.handle_wolf_chat_send(msg) + + assert posted == [(game.id, "**Alice** (狼チャット): 様子見しよう")] + # Make sure JSON validity isn't blocked by the channel mirror. + assert "wolf_chat" in json.loads(seat2_buf[0])["update_kind"] diff --git a/tests/test_master_ws_server.py b/tests/test_master_ws_server.py new file mode 100644 index 0000000..4f1fba2 --- /dev/null +++ b/tests/test_master_ws_server.py @@ -0,0 +1,606 @@ +"""Bundle 4: master speech control surface. + +Exercises: +- WebSocket envelope dispatch with the canonical `type` field. +- NPC `npc_register` → `npc_registered` handshake updates the registry and + replies on the back-channel. +- Heartbeats refresh `last_heartbeat_ms` and revive an offline NPC. +- Voice-ingest connections receive a `registry_snapshot` on attach and a + `registry_update` on subsequent NPC registration deltas. +- `MasterIngestService` discards STT payloads whose Discord user id matches + a registered NPC bot. +- The new audit tables (`npc_speak_requests` / `npc_speak_results` / + `npc_playback_events`) round-trip through the repo helpers. + +No real WebSocket server is started — `HandlerRegistry.dispatch` is invoked +directly with synthetic JSON envelopes. +""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable + +import pytest + +from wolfbot.domain.discussion import SpeechSource, make_phase_id +from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.models import Game, Seat +from wolfbot.domain.ws_messages import ( + Heartbeat, + NpcRegister, + NpcRegistered, + RegistrySnapshot, + RegistryUpdate, + SpeakResult, + SpeechEventPayload, +) +from wolfbot.master.arbiter.ingest_service import ( + MasterIngestService, + PhaseLookup, +) +from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry +from wolfbot.master.ws.ws_server import ( + ConnectionContext, + HandlerRegistry, + MasterHandlers, +) +from wolfbot.persistence.sqlite_repo import SqliteRepo +from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, +) + + +def _make_send_capture() -> tuple[list[str], Callable[[str], Awaitable[None]]]: + """Returns (captured, send) — `send(msg)` appends to `captured`.""" + captured: list[str] = [] + + async def send(msg: str) -> None: + captured.append(msg) + + return captured, send + + +def _make_npc_ctx(*, tag: str = "npc-temp") -> tuple[ConnectionContext, list[str]]: + captured, send = _make_send_capture() + return ConnectionContext(role="npc", tag=tag, send=send), captured + + +def _make_voice_ingest_ctx() -> tuple[ConnectionContext, list[str]]: + captured, send = _make_send_capture() + return ( + ConnectionContext(role="voice-ingest", tag="voice-ingest", send=send), + captured, + ) + + +# ---------------------------------------------------------------- registration + + +async def test_npc_register_responds_with_npc_registered_and_inserts_registry() -> None: + registry = InMemoryNpcRegistry() + handlers = MasterHandlers(registry=registry, now_ms=lambda: 1000) + dispatcher = HandlerRegistry() + handlers.install(dispatcher) + ctx, captured = _make_npc_ctx() + msg = NpcRegister( + ts=999, + trace_id="t-1", + npc_id="npc_p5", + discord_bot_user_id="bot-uid-5", + supported_voices=("ja-JP-A",), + version="1.0.0", persona_key="setsu") + await dispatcher.dispatch(msg.model_dump_json(), ctx) + + assert ctx.tag == "npc_p5" + entry = registry.get("npc_p5") + assert entry is not None + assert entry.discord_bot_user_id == "bot-uid-5" + assert entry.last_heartbeat_ms == 1000 + assert entry.is_online is True + assert len(captured) == 1 + reply = NpcRegistered.model_validate_json(captured[0]) + assert reply.npc_id == "npc_p5" + assert reply.assigned_seat is None # not assigned yet + + +async def test_heartbeat_refreshes_last_heartbeat_and_revives_offline_npc() -> None: + registry = InMemoryNpcRegistry() + times = iter([1000, 2000, 9000]) + handlers = MasterHandlers(registry=registry, now_ms=lambda: next(times)) + dispatcher = HandlerRegistry() + handlers.install(dispatcher) + ctx, _ = _make_npc_ctx() + + await dispatcher.dispatch( + NpcRegister( + ts=900, trace_id="t", npc_id="npc_p5", discord_bot_user_id="b5", persona_key="setsu").model_dump_json(), + ctx, + ) + # Force offline. + registry.prune_offline(now_ms=10_000, timeout_ms=1) + entry = registry.get("npc_p5") + assert entry is not None and entry.is_online is False + + # Heartbeat with a fresh ts re-flips online. + await dispatcher.dispatch( + Heartbeat(ts=8000, trace_id="t", npc_id="npc_p5").model_dump_json(), + ctx, + ) + entry = registry.get("npc_p5") + assert entry is not None and entry.is_online is True + assert entry.last_heartbeat_ms == 9000 + + +async def test_unknown_message_type_is_logged_not_raised() -> None: + registry = InMemoryNpcRegistry() + handlers = MasterHandlers(registry=registry, now_ms=lambda: 1) + dispatcher = HandlerRegistry() + handlers.install(dispatcher) + ctx, _ = _make_npc_ctx() + # Should not raise. + await dispatcher.dispatch( + json.dumps({"type": "definitely_not_a_real_type", "ts": 1, "trace_id": "x"}), + ctx, + ) + + +async def test_invalid_json_is_logged_not_raised() -> None: + registry = InMemoryNpcRegistry() + handlers = MasterHandlers(registry=registry, now_ms=lambda: 1) + dispatcher = HandlerRegistry() + handlers.install(dispatcher) + ctx, _ = _make_npc_ctx() + await dispatcher.dispatch("{not-json", ctx) + + +async def test_speak_result_dispatches_to_handler() -> None: + registry = InMemoryNpcRegistry() + received: list[SpeakResult] = [] + + async def on_result(msg: SpeakResult, _ctx: ConnectionContext) -> None: + received.append(msg) + + handlers = MasterHandlers(registry=registry, on_speak_result=on_result, now_ms=lambda: 1) + dispatcher = HandlerRegistry() + handlers.install(dispatcher) + ctx, _ = _make_npc_ctx(tag="npc_p1") + msg = SpeakResult( + ts=1, + trace_id="t", + request_id="r1", + npc_id="npc_p1", + phase_id="p", + status="accepted", + text="やあ", + ) + await dispatcher.dispatch(msg.model_dump_json(), ctx) + assert len(received) == 1 + assert received[0].request_id == "r1" + + +# ---------------------------------------------------------------- registry listener + + +async def test_registry_listener_emits_added_when_new_npc_registers() -> None: + registry = InMemoryNpcRegistry() + deltas: list[tuple[set[str], set[str]]] = [] + + async def listener(added: set[str], removed: set[str]) -> None: + deltas.append((added, removed)) + + registry.add_listener(listener) + handlers = MasterHandlers(registry=registry, now_ms=lambda: 1) + dispatcher = HandlerRegistry() + handlers.install(dispatcher) + ctx, _ = _make_npc_ctx() + await dispatcher.dispatch( + NpcRegister( + ts=1, trace_id="t", npc_id="npc_a", discord_bot_user_id="botA", persona_key="setsu").model_dump_json(), + ctx, + ) + # Allow the scheduled listener task to run. + import asyncio + + await asyncio.sleep(0) + assert deltas == [({"botA"}, set())] + + +async def test_registry_unregister_emits_removed() -> None: + registry = InMemoryNpcRegistry() + deltas: list[tuple[set[str], set[str]]] = [] + + async def listener(added: set[str], removed: set[str]) -> None: + deltas.append((added, removed)) + + registry.register( + npc_id="npc_b", + discord_bot_user_id="botB", + supported_voices=(), + version="0.0.1", + send=None, + now_ms=1, persona_key="setsu") + registry.add_listener(listener) + registry.unregister("npc_b", reason="ws_closed") + import asyncio + + await asyncio.sleep(0) + assert deltas == [(set(), {"botB"})] + + +async def test_registry_snapshot_message_serializable_round_trip() -> None: + msg = RegistrySnapshot(ts=1, trace_id="t", npc_user_ids=("a", "b")) + out = RegistrySnapshot.model_validate_json(msg.model_dump_json()) + assert out.npc_user_ids == ("a", "b") + + +async def test_registry_update_message_serializable_round_trip() -> None: + msg = RegistryUpdate(ts=1, trace_id="t", added=("x",), removed=("y",)) + out = RegistryUpdate.model_validate_json(msg.model_dump_json()) + assert out.added == ("x",) and out.removed == ("y",) + + +# ---------------------------------------------------------------- ingest service + + +class _StubPhaseLookup: + def __init__( + self, + mapping: dict[str, tuple[Phase, int]], + alive_seats: dict[str, list[int]] | None = None, + addressed: dict[tuple[str, str], int] | None = None, + ) -> None: + self._mapping = mapping + self._alive = alive_seats or {} + self._addressed = addressed or {} + + async def get_phase(self, game_id: str) -> tuple[Phase, int] | None: + return self._mapping.get(game_id) + + async def get_alive_seat_nos(self, game_id: str) -> list[int]: + return self._alive.get(game_id, []) + + async def resolve_addressed_seat( + self, game_id: str, addressed_name: str + ) -> int | None: + return self._addressed.get((game_id, addressed_name)) + + +async def test_master_ingest_discards_npc_speaker(repo: SqliteRepo) -> None: + registry = InMemoryNpcRegistry() + registry.register( + npc_id="npc1", + discord_bot_user_id="bot1", + supported_voices=(), + version="1", + send=None, + now_ms=1, persona_key="setsu") + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + lookup = _StubPhaseLookup({"g1": (Phase.DAY_DISCUSSION, 1)}) + svc = MasterIngestService(registry=registry, discussion=discussion, phase_lookup=lookup) + payload = SpeechEventPayload( + ts=1, + trace_id="t", + game_id="g1", + phase_id="p", + seat_no=4, + speaker_discord_user_id="bot1", + segment_id="s1", + text="勝手にtts", + confidence=0.9, + duration_ms=500, + audio_start_ms=0, + audio_end_ms=500, + ) + event, reason = await svc.ingest_voice(payload) + assert event is None + assert reason == "npc_stt_discarded" + rows = await store.load_for_game("g1") + assert rows == [] + + +async def test_master_ingest_accepts_human_speaker(repo: SqliteRepo) -> None: + # Seed a real game so the foreign key on speech_events.game_id holds. + g = Game( + id="g1", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + ) + await repo.create_game(g) + await repo.insert_seat( + "g1", + Seat(seat_no=4, display_name="Hu", discord_user_id="u4", is_llm=False, persona_key=None), + ) + await repo.set_player_role("g1", 4, Role.VILLAGER) + registry = InMemoryNpcRegistry() + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + lookup = _StubPhaseLookup( + {"g1": (Phase.DAY_DISCUSSION, 1)}, + alive_seats={"g1": [4]}, + ) + svc = MasterIngestService(registry=registry, discussion=discussion, phase_lookup=lookup) + canonical_phase_id = make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + payload = SpeechEventPayload( + ts=1, + trace_id="t", + game_id="g1", + phase_id=canonical_phase_id, + seat_no=4, + speaker_discord_user_id="u4", + segment_id="s1", + text="人間のはずだよ", + confidence=0.9, + duration_ms=500, + audio_start_ms=0, + audio_end_ms=500, + ) + event, reason = await svc.ingest_voice(payload) + assert reason is None + assert event is not None + assert event.source == SpeechSource.VOICE_STT + # The event must use the canonical phase_id computed on Master. + assert event.phase_id == canonical_phase_id + rows = await store.load_phase("g1", canonical_phase_id) + assert any(r.text == "人間のはずだよ" for r in rows) + # A phase_baseline sentinel must have been seeded. + baselines = [r for r in rows if r.source == SpeechSource.PHASE_BASELINE] + assert len(baselines) == 1 + + +async def test_master_ingest_drops_dead_speaker_stt(repo: SqliteRepo) -> None: + """Defensive alive check: a delayed STT result for a player who + died mid-segment must not produce a SpeechEvent. The voice-ingest + VAD entry already filters dead audio, but a slow Gemini response + could land after death — Master is the last line of defense.""" + g = Game( + id="g_dead", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + ) + await repo.create_game(g) + await repo.insert_seat( + "g_dead", + Seat(seat_no=4, display_name="Dead", discord_user_id="u4", is_llm=False, persona_key=None), + ) + await repo.set_player_role("g_dead", 4, Role.VILLAGER) + registry = InMemoryNpcRegistry() + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + # alive_seats does NOT include seat 4 — i.e. they're already dead. + lookup = _StubPhaseLookup( + {"g_dead": (Phase.DAY_DISCUSSION, 1)}, + alive_seats={"g_dead": []}, + ) + svc = MasterIngestService( + registry=registry, discussion=discussion, phase_lookup=lookup + ) + payload = SpeechEventPayload( + ts=1, + trace_id="t", + game_id="g_dead", + phase_id=make_phase_id("g_dead", 1, Phase.DAY_DISCUSSION), + seat_no=4, + speaker_discord_user_id="u4", + segment_id="s_late", + text="まだ生きてる…", + confidence=0.9, + duration_ms=500, + audio_start_ms=0, + audio_end_ms=500, + ) + event, reason = await svc.ingest_voice(payload) + assert event is None + assert reason == "dead_speaker_discarded" + # Critically: NO SpeechEvent row got persisted (not even baseline). + rows = await store.load_for_game("g_dead") + assert rows == [] + + +async def test_master_ingest_ignores_caller_phase_id(repo: SqliteRepo) -> None: + """MasterIngestService must compute the canonical phase_id on Master, + not trust the caller-supplied value from the voice-ingest worker.""" + g = Game( + id="gX", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=2, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + ) + await repo.create_game(g) + await repo.insert_seat( + "gX", + Seat(seat_no=3, display_name="Hu", discord_user_id="u3", is_llm=False, persona_key=None), + ) + await repo.set_player_role("gX", 3, Role.VILLAGER) + registry = InMemoryNpcRegistry() + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + lookup = _StubPhaseLookup( + {"gX": (Phase.DAY_DISCUSSION, 2)}, + alive_seats={"gX": [3]}, + ) + svc = MasterIngestService(registry=registry, discussion=discussion, phase_lookup=lookup) + + # Caller supplies a stale phase_id from day 1 — Master must override. + stale_phase_id = make_phase_id("gX", 1, Phase.DAY_DISCUSSION) + canonical_phase_id = make_phase_id("gX", 2, Phase.DAY_DISCUSSION) + payload = SpeechEventPayload( + ts=1, + trace_id="t", + game_id="gX", + phase_id=stale_phase_id, + seat_no=3, + speaker_discord_user_id="u3", + segment_id="s2", + text="テスト", + confidence=0.9, + duration_ms=300, + audio_start_ms=0, + audio_end_ms=300, + ) + event, reason = await svc.ingest_voice(payload) + assert reason is None + assert event is not None + assert event.phase_id == canonical_phase_id + assert event.phase_id != stale_phase_id + + +# ---------------------------------------------------------------- audit tables + + +async def test_audit_tables_round_trip(repo: SqliteRepo) -> None: + g = Game( + id="ga", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + ) + await repo.create_game(g) + + await repo.insert_npc_speak_request( + request_id="r1", + game_id="ga", + phase_id="ph", + npc_id="np", + seat_no=2, + logic_packet_id="lp", + suggested_intent="counter", + max_chars=80, + max_duration_ms=8000, + priority=1, + expires_at_ms=10_000, + created_at_ms=1000, + ) + open_reqs = await repo.load_open_npc_speak_requests("ga") + assert len(open_reqs) == 1 and open_reqs[0]["request_id"] == "r1" + + await repo.insert_npc_speak_result( + request_id="r1", + game_id="ga", + phase_id="ph", + npc_id="np", + status="accepted", + text="それは違うよ", + used_logic_ids=["c1", "c2"], + intent="counter", + estimated_duration_ms=2500, + failure_reason=None, + received_at_ms=1100, + ) + open_reqs = await repo.load_open_npc_speak_requests("ga") + assert open_reqs == [] # closed by result + + await repo.open_npc_playback( + request_id="r1", + game_id="ga", + phase_id="ph", + npc_id="np", + speech_event_id="ev1", + authorized_at_ms=1200, + playback_deadline_ms=11_000, + ) + open_play = await repo.load_open_npc_playback("ga") + assert len(open_play) == 1 + + await repo.update_npc_playback_tts( + "r1", outcome="success", duration_ms=500, failure_reason=None + ) + await repo.close_npc_playback( + "r1", finished_at_ms=2000, outcome="succeeded", failure_reason=None + ) + open_play = await repo.load_open_npc_playback("ga") + assert open_play == [] + + +async def test_close_npc_playback_only_affects_open_rows(repo: SqliteRepo) -> None: + """A second close_npc_playback after the row is already closed must not + overwrite the original outcome.""" + g = Game( + id="gb", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + ) + await repo.create_game(g) + await repo.open_npc_playback( + request_id="r2", + game_id="gb", + phase_id="ph", + npc_id="np", + speech_event_id="ev1", + authorized_at_ms=1, + playback_deadline_ms=2, + ) + await repo.close_npc_playback("r2", finished_at_ms=3, outcome="succeeded", failure_reason=None) + # Second close is a no-op. + await repo.close_npc_playback("r2", finished_at_ms=99, outcome="failed", failure_reason="oops") + open_play = await repo.load_open_npc_playback("gb") + assert open_play == [] + + +# ---------------------------------------------------------------- protocol smoke + + +def test_phase_lookup_protocol_runtime_check() -> None: + """A class with `get_phase`, `get_alive_seat_nos`, and + `resolve_addressed_seat` satisfies PhaseLookup.""" + + class Impl: + async def get_phase(self, game_id: str) -> tuple[Phase, int] | None: + return None + + async def get_alive_seat_nos(self, game_id: str) -> list[int]: + return [] + + async def resolve_addressed_seat( + self, game_id: str, addressed_name: str + ) -> int | None: + return None + + obj = Impl() + # Use the runtime-checkable protocol; this asserts the structural match. + assert isinstance(obj, PhaseLookup) + + +@pytest.mark.parametrize("psk_match", [True, False]) +def test_websockets_master_ws_server_constructs_without_starting( + psk_match: bool, +) -> None: + """Constructor wiring smoke — no actual socket bind.""" + from wolfbot.master.ws.ws_server import WebsocketsMasterWsServer + + registry = InMemoryNpcRegistry() + handlers = MasterHandlers(registry=registry, now_ms=lambda: 0) + server = WebsocketsMasterWsServer( + host="127.0.0.1", + port=8888, + psk="secret" if psk_match else "other", + registry=registry, + handlers=handlers, + ) + assert server.psk == ("secret" if psk_match else "other") + assert server.host == "127.0.0.1" diff --git a/tests/test_npc_config.py b/tests/test_npc_config.py new file mode 100644 index 0000000..bfaa09d --- /dev/null +++ b/tests/test_npc_config.py @@ -0,0 +1,107 @@ +"""NPC settings provider-switch tests. + +The NPC namespace mirrors GAMEPLAY_LLM_*; this file asserts the same +validator semantics under the NPC_LLM_* prefix. +""" + +from __future__ import annotations + +import pytest +from pydantic import SecretStr, ValidationError + +from wolfbot.npc.runtime.config import NpcSettings + + +def _base_kwargs() -> dict[str, object]: + return { + "NPC_ID": "npc_setsu", + "NPC_DISCORD_TOKEN": SecretStr("t"), + "DISCORD_GUILD_ID": 1, + "MAIN_VOICE_CHANNEL_ID": 2, + "NPC_PERSONA_KEY": "setsu", + "MASTER_WS_URL": "ws://127.0.0.1:8800", + "MASTER_NPC_PSK": SecretStr("psk"), + } + + +def test_default_provider_is_xai_and_requires_api_key() -> None: + with pytest.raises(ValidationError): + NpcSettings(_env_file=None, **_base_kwargs()) # type: ignore[arg-type] + + +def test_xai_provider_with_key_constructs() -> None: + s = NpcSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + NPC_LLM_API_KEY=SecretStr("k"), + ) + assert s.NPC_LLM_PROVIDER == "xai" + assert s.NPC_LLM_MODEL == "grok-4-1-fast" + + +def test_deepseek_provider_requires_api_key() -> None: + with pytest.raises(ValidationError): + NpcSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + NPC_LLM_PROVIDER="deepseek", + ) + + +def test_gemini_provider_requires_vertex_project() -> None: + with pytest.raises(ValidationError): + NpcSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + NPC_LLM_PROVIDER="gemini", + ) + + +def test_gemini_provider_does_not_require_api_key() -> None: + s = NpcSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + NPC_LLM_PROVIDER="gemini", + NPC_LLM_VERTEX_PROJECT="proj", + ) + assert s.NPC_LLM_API_KEY is None + assert s.NPC_LLM_VERTEX_PROJECT == "proj" + + +def test_npc_decider_config_round_trips_deepseek() -> None: + s = NpcSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + NPC_LLM_PROVIDER="deepseek", + NPC_LLM_API_KEY=SecretStr("d"), + ) + cfg = s.npc_decider_config() + assert cfg.provider == "deepseek" + assert cfg.api_key is not None + assert cfg.api_key.get_secret_value() == "d" + assert cfg.thinking == "enabled" + assert cfg.reasoning_effort == "max" + + +def test_mock_provider_does_not_require_api_key_or_vertex_project() -> None: + """``NPC_LLM_PROVIDER=mock`` is for offline integration tests — + no credentials should be required.""" + s = NpcSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + NPC_LLM_PROVIDER="mock", + ) + assert s.NPC_LLM_PROVIDER == "mock" + assert s.NPC_LLM_API_KEY is None + assert s.NPC_LLM_VERTEX_PROJECT is None + + +def test_npc_decider_config_round_trips_mock() -> None: + s = NpcSettings( # type: ignore[arg-type] + _env_file=None, + **_base_kwargs(), + NPC_LLM_PROVIDER="mock", + ) + cfg = s.npc_decider_config() + assert cfg.provider == "mock" + assert cfg.api_key is None diff --git a/tests/test_npc_decision_service.py b/tests/test_npc_decision_service.py new file mode 100644 index 0000000..baae993 --- /dev/null +++ b/tests/test_npc_decision_service.py @@ -0,0 +1,181 @@ +"""Phase-D NPC decision LLM helpers — prompt builders + parser. + +The helpers are pure functions of state + persona + request so they're +easy to unit-test without standing up an LLM client. Coverage: + +* `build_vote_prompt` mentions seat, role, candidates, persona name. +* `build_night_prompt` carries the right action label and candidate set. +* `parse_decision` is robust against bad JSON / illegal targets / type + abuses (boolean masquerading as int). +""" + +from __future__ import annotations + +from wolfbot.domain.ws_messages import ( + DecideNightActionRequest, + DecideVoteRequest, +) +from wolfbot.npc.decision.decision_service import ( + build_night_prompt, + build_vote_prompt, + parse_decision, +) +from wolfbot.npc.decision.game_state import NpcGameState +from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY + + +def _state(role: str = "WEREWOLF") -> NpcGameState: + return NpcGameState( + game_id="g1", + seat_no=3, + persona_key="setsu", + role=role, + day_number=1, + alive_seats=[(1, "Alice"), (3, "セツ"), (5, "Bob")], + partner_wolves=[(5, "Bob")] if role == "WEREWOLF" else [], + ) + + +def test_build_vote_prompt_includes_state_and_candidates() -> None: + persona = NPC_PERSONAS_BY_KEY["setsu"] + req = DecideVoteRequest( + ts=1, + trace_id="t", + request_id="rv1", + npc_id="npc_setsu", + seat_no=3, + game_id="g1", + phase_id="g1::day1::DAY_VOTE::1", + round_=0, + candidate_seats=((1, "Alice"), (5, "Bob")), + public_state_summary="phase=DAY_VOTE day=1 co_claims=[(none)]", + expires_at_ms=10_000, + ) + system, user = build_vote_prompt(state=_state(), persona=persona, request=req) + assert "JSON" in system + # Own identity now leads with display_name; seat number is + # appended in parens so the LLM still has the data-layer mapping. + assert "あなた: セツ (席3)" in user + assert "あなたの役職: WEREWOLF" in user + # Roster header is the only block where 席N appears alongside the + # name; the candidate list keeps 席N because the LLM emits a + # numeric `target_seat`. + assert "## 参加者 (席番号 → 名前)" in user + assert "席1 Alice" in user and "席5 Bob" in user + assert "通常投票" in user + # Persona name surfaces verbatim so the LLM stays in character. + assert persona.display_name in user + # Wolf partner block (private) appears for werewolf seats. + assert "仲間の人狼" in user + + +def test_build_vote_prompt_runoff_label() -> None: + persona = NPC_PERSONAS_BY_KEY["jonas"] + req = DecideVoteRequest( + ts=1, + trace_id="t", + request_id="rv2", + npc_id="npc_jonas", + seat_no=4, + game_id="g1", + phase_id="g1::day1::DAY_RUNOFF::1", + round_=1, + candidate_seats=((1, "Alice"),), + expires_at_ms=10_000, + ) + _system, user = build_vote_prompt(state=_state("SEER"), persona=persona, request=req) + assert "決選投票" in user + + +def test_build_night_prompt_per_action_label() -> None: + persona = NPC_PERSONAS_BY_KEY["setsu"] + base_kwargs: dict = dict( # type: ignore[type-arg] + ts=1, + trace_id="t", + request_id="rn", + npc_id="npc_setsu", + seat_no=3, + game_id="g1", + phase_id="g1::day1::NIGHT::1", + candidate_seats=((1, "Alice"),), + expires_at_ms=10_000, + ) + for kind, label in ( + ("wolf_attack", "人狼の襲撃"), + ("seer_divine", "占い師の占い"), + ("knight_guard", "騎士の護衛"), + ): + req = DecideNightActionRequest(**{**base_kwargs, "action_kind": kind}) + _system, user = build_night_prompt(state=_state(), persona=persona, request=req) + assert label in user, f"missing label for {kind}" + + +def test_parse_decision_returns_target_when_legal() -> None: + raw = '{"target_seat": 5, "reason": "怪しい"}' + result = parse_decision(raw, legal_seats=frozenset({1, 5})) + assert result.target_seat == 5 + assert result.reason_summary == "怪しい" + + +def test_parse_decision_falls_back_on_illegal_target() -> None: + raw = '{"target_seat": 9, "reason": "?"}' + result = parse_decision(raw, legal_seats=frozenset({1, 5})) + assert result.target_seat is None + assert result.reason_summary == "illegal_target" + + +def test_parse_decision_handles_null_target_as_abstain() -> None: + raw = '{"target_seat": null, "reason": "迷う"}' + result = parse_decision(raw, legal_seats=frozenset({1, 5})) + assert result.target_seat is None + assert result.reason_summary == "迷う" + + +def test_parse_decision_rejects_boolean_masquerading_as_int() -> None: + # In Python, ``isinstance(True, int)`` is True. The parser must + # treat True / False as non-int so the model can't accidentally + # vote "True". + raw = '{"target_seat": true, "reason": "x"}' + result = parse_decision(raw, legal_seats=frozenset({1, 5})) + assert result.target_seat is None + assert result.reason_summary == "non_int_target" + + +def test_parse_decision_drops_malformed_json() -> None: + result = parse_decision("not json", legal_seats=frozenset({1, 5})) + assert result.target_seat is None + assert result.reason_summary == "parse_failed" + + +def test_parse_decision_drops_top_level_array() -> None: + result = parse_decision("[1, 2]", legal_seats=frozenset({1, 5})) + assert result.target_seat is None + assert result.reason_summary == "not_object" + + +def test_build_wolf_chat_prompt_includes_role_strategy_block() -> None: + """Wolf chat must include the WEREWOLF role-strategy block so the + chat-side decision sees the master tactical rules (multi-CO attack + avoidance, GJ rebite, info-role priority, knight-candidate scoring). + + Game `38627df1ade1` had wolves agree in chat to attack a seer in a + 3-CO board because the chat prompt was missing the strategy block. + The night-action prompt does carry it, but by then `wolf_chat_history` + already locks the wolves to the chat-time consensus. + """ + from wolfbot.npc.decision.decision_service import build_wolf_chat_prompt + + persona = NPC_PERSONAS_BY_KEY["gina"] + _system, user = build_wolf_chat_prompt( + state=_state(role="WEREWOLF"), + persona=persona, + candidates=((1, "Alice"), (5, "Bob")), + public_state_summary="phase=NIGHT day=1", + ) + # The role-strategy header marks the block's presence. + assert "## 役職別の戦術ヒント" in user + # Spot-check that the multi-CO attack-avoidance line from + # `_ROLE_STRATEGIES[WEREWOLF]` made it through. + assert "対抗 CO ありの役職" in user + # And the no-4th-seer-CO rule too (same block). + assert "占 3 / 霊 2 / 騎 2 の上限を超える追加 CO は出さない" in user diff --git a/tests/test_npc_game_state.py b/tests/test_npc_game_state.py new file mode 100644 index 0000000..bce2c88 --- /dev/null +++ b/tests/test_npc_game_state.py @@ -0,0 +1,539 @@ +"""Phase-D NPC bot per-game state — snapshot rebuild + update fold. + +Covers: +* `state_from_snapshot` rebuilds an `NpcGameState` 1:1 from a snapshot. +* `apply_update` correctly mutates state for each `update_kind`. +* Malformed payloads are dropped without crashing. +* `NpcClient` routes the new WS message types to the state mirror, and + the decision request handlers reply with the stub abstain/skip + decisions used until the LLM-backed logic lands. +""" + +from __future__ import annotations + +import pytest + +from wolfbot.domain.ws_messages import ( + DecideNightActionRequest, + DecideVoteRequest, + GuardEntry, + MediumResult, + NightActionDecision, + PrivateStateSnapshot, + PrivateStateUpdate, + SeerResult, + VoteDecision, + WolfChatLine, +) +from wolfbot.npc.decision.game_state import ( + NpcGameState, + apply_update, + state_from_snapshot, +) +from wolfbot.npc.runtime.client import NpcClient, NpcClientConfig + + +def _snapshot(**overrides: object) -> PrivateStateSnapshot: + base: dict[str, object] = { + "ts": 1000, + "trace_id": "snap-1", + "npc_id": "npc_setsu", + "game_id": "g1", + "seat_no": 3, + "persona_key": "setsu", + "role": "WEREWOLF", + "day_number": 1, + "alive_seats": ((1, "Alice"), (3, "セツ"), (5, "Bob")), + "dead_seats": (), + "partner_wolves": ((5, "Bob"),), + "seer_results": (), + "medium_results": (), + "guard_history": (), + "wolf_chat_history": ( + WolfChatLine( + day=0, speaker_seat=5, speaker_name="Bob", text="夜は静かだな" + ), + ), + } + base.update(overrides) + return PrivateStateSnapshot(**base) # type: ignore[arg-type] + + +def test_state_from_snapshot_round_trips_fields() -> None: + snap = _snapshot() + state = state_from_snapshot(snap) + assert state.game_id == "g1" + assert state.seat_no == 3 + assert state.role == "WEREWOLF" + assert state.persona_key == "setsu" + assert state.day_number == 1 + assert state.alive_seats == [(1, "Alice"), (3, "セツ"), (5, "Bob")] + assert state.partner_wolves == [(5, "Bob")] + assert len(state.wolf_chat_history) == 1 + assert state.wolf_chat_history[0].text == "夜は静かだな" + + +def test_apply_update_seer_result_appends() -> None: + state = state_from_snapshot(_snapshot(role="SEER", partner_wolves=())) + upd = PrivateStateUpdate( + ts=2000, + trace_id="upd-1", + npc_id="npc_setsu", + game_id="g1", + seat_no=3, + update_kind="seer_result", + payload={ + "day": 1, "target_seat": 5, "target_name": "Bob", "is_wolf": True, + }, + ) + apply_update(state, upd) + assert state.seer_results == [ + SeerResult(day=1, target_seat=5, target_name="Bob", is_wolf=True) + ] + + +def test_apply_update_medium_result_handles_null_is_wolf() -> None: + state = state_from_snapshot(_snapshot(role="MEDIUM", partner_wolves=())) + upd = PrivateStateUpdate( + ts=3000, trace_id="t", npc_id="npc_setsu", game_id="g1", seat_no=3, + update_kind="medium_result", + payload={"day": 1, "target_seat": 7, "target_name": "Carol", "is_wolf": None}, + ) + apply_update(state, upd) + assert state.medium_results == [ + MediumResult(day=1, target_seat=7, target_name="Carol", is_wolf=None) + ] + + +def test_apply_update_guard_resolved_fills_peaceful_flag() -> None: + state = state_from_snapshot( + _snapshot( + role="KNIGHT", + partner_wolves=(), + guard_history=( + GuardEntry(day=1, target_seat=5, target_name="Bob"), + ), + ) + ) + upd = PrivateStateUpdate( + ts=4000, trace_id="t", npc_id="npc_setsu", game_id="g1", seat_no=3, + update_kind="guard_resolved", + payload={"day": 1, "peaceful_morning": True}, + ) + apply_update(state, upd) + assert state.guard_history[0].peaceful_morning is True + + +def test_apply_update_wolf_chat_appends() -> None: + state = state_from_snapshot(_snapshot()) + upd = PrivateStateUpdate( + ts=5000, trace_id="t", npc_id="npc_setsu", game_id="g1", seat_no=3, + update_kind="wolf_chat", + payload={ + "day": 1, "speaker_seat": 5, "speaker_name": "Bob", "text": "席1を狙おう", + }, + ) + apply_update(state, upd) + assert state.wolf_chat_history[-1].text == "席1を狙おう" + + +def test_apply_update_alive_changed_replaces_lists() -> None: + state = state_from_snapshot(_snapshot()) + upd = PrivateStateUpdate( + ts=6000, trace_id="t", npc_id="npc_setsu", game_id="g1", seat_no=3, + update_kind="alive_changed", + payload={ + "alive_seats": [[1, "Alice"], [3, "セツ"]], + "dead_seats": [[5, "Bob"]], + }, + ) + apply_update(state, upd) + assert state.alive_seats == [(1, "Alice"), (3, "セツ")] + assert state.dead_seats == [(5, "Bob")] + + +def test_apply_update_day_advanced_updates_counter() -> None: + state = state_from_snapshot(_snapshot()) + upd = PrivateStateUpdate( + ts=7000, trace_id="t", npc_id="npc_setsu", game_id="g1", seat_no=3, + update_kind="day_advanced", payload={"day_number": 2}, + ) + apply_update(state, upd) + assert state.day_number == 2 + + +def test_apply_update_malformed_payload_drops_silently() -> None: + state = state_from_snapshot(_snapshot()) + before = list(state.seer_results) + upd = PrivateStateUpdate( + ts=8000, trace_id="t", npc_id="npc_setsu", game_id="g1", seat_no=3, + update_kind="seer_result", + payload={"day": "not-a-number", "target_seat": 5}, + ) + apply_update(state, upd) + assert state.seer_results == before + + +def test_apply_update_unknown_kind_is_noop() -> None: + state = state_from_snapshot(_snapshot()) + snapshot_before = ( + list(state.seer_results), + list(state.wolf_chat_history), + state.day_number, + ) + # Bypass Pydantic's Literal validation by reaching for `model_construct` + # — emulates a Master that emits a future ``update_kind`` we don't know. + upd = PrivateStateUpdate.model_construct( + ts=9000, trace_id="t", type="private_state_update", + npc_id="npc_setsu", game_id="g1", seat_no=3, + update_kind="future_kind_not_yet_defined", # type: ignore[arg-type] + payload={"foo": "bar"}, + ) + apply_update(state, upd) + assert ( + list(state.seer_results), + list(state.wolf_chat_history), + state.day_number, + ) == snapshot_before + + +# --------------------------------------------- NpcClient WS routing + + +def _make_client_with_capture() -> tuple[NpcClient, list[str]]: + """Lightweight NpcClient + outbound capture buffer. + + The phase-D message paths only use `send`, `now_ms`, and `config.npc_id`, + so the heavier collaborators (TTS, playback, speech service) can be + minimal stubs — they're never exercised by these tests. + """ + sent: list[str] = [] + + async def _send(msg: str) -> None: + sent.append(msg) + + class _StubSpeech: + async def respond(self, **kwargs: object) -> None: # pragma: no cover + raise AssertionError("speech service should not be invoked") + + class _StubTts: + async def synthesize(self, *args: object, **kwargs: object) -> None: # pragma: no cover + raise AssertionError("TTS should not be invoked") + + class _StubPlayback: + async def play(self, **kwargs: object) -> tuple[int, int]: # pragma: no cover + raise AssertionError("playback should not be invoked") + + client = NpcClient( + config=NpcClientConfig( + npc_id="npc_setsu", + discord_bot_user_id="bot1", + persona_key="setsu", + voice_id="2", + ), + speech=_StubSpeech(), # type: ignore[arg-type] + tts=_StubTts(), # type: ignore[arg-type] + playback=_StubPlayback(), # type: ignore[arg-type] + send=_send, + now_ms=lambda: 999, + ) + return client, sent + + +@pytest.mark.asyncio +async def test_client_processes_snapshot_and_update_and_decision_requests() -> None: + client, sent = _make_client_with_capture() + + await client.process_message(_snapshot().model_dump_json()) + assert "g1" in client.game_states + assert client.game_states["g1"].role == "WEREWOLF" + + upd = PrivateStateUpdate( + ts=2000, trace_id="t", npc_id="npc_setsu", game_id="g1", seat_no=3, + update_kind="day_advanced", payload={"day_number": 4}, + ) + await client.process_message(upd.model_dump_json()) + assert client.game_states["g1"].day_number == 4 + + vote_req = DecideVoteRequest( + ts=3000, trace_id="t-vote", + request_id="rv1", npc_id="npc_setsu", seat_no=3, + game_id="g1", phase_id="g1::day1::DAY_VOTE::1", + candidate_seats=((1, "Alice"), (5, "Bob")), + expires_at_ms=10_000, + ) + await client.process_message(vote_req.model_dump_json()) + decisions = [VoteDecision.model_validate_json(m) for m in sent if '"vote_decision"' in m] + assert len(decisions) == 1 + assert decisions[0].request_id == "rv1" + assert decisions[0].target_seat is None # phase-D stub abstains for now + + night_req = DecideNightActionRequest( + ts=4000, trace_id="t-night", + request_id="rn1", npc_id="npc_setsu", seat_no=3, + game_id="g1", phase_id="g1::day1::NIGHT::1", + action_kind="wolf_attack", + candidate_seats=((1, "Alice"),), + expires_at_ms=20_000, + ) + await client.process_message(night_req.model_dump_json()) + night_decisions = [ + NightActionDecision.model_validate_json(m) for m in sent if '"night_action_decision"' in m + ] + assert len(night_decisions) == 1 + assert night_decisions[0].request_id == "rn1" + assert night_decisions[0].target_seat is None + assert night_decisions[0].action_kind == "wolf_attack" + + +@pytest.mark.asyncio +async def test_client_drops_misrouted_state_messages() -> None: + client, _sent = _make_client_with_capture() + other = _snapshot(npc_id="npc_other") + await client.process_message(other.model_dump_json()) + assert "g1" not in client.game_states + + +@pytest.mark.asyncio +async def test_client_drops_update_when_no_snapshot_seen() -> None: + client, _sent = _make_client_with_capture() + upd = PrivateStateUpdate( + ts=5000, trace_id="t", npc_id="npc_setsu", game_id="never_snapshotted", + seat_no=3, update_kind="day_advanced", payload={"day_number": 9}, + ) + await client.process_message(upd.model_dump_json()) + assert "never_snapshotted" not in client.game_states + + +@pytest.mark.asyncio +async def test_vote_target_falls_back_to_random_when_llm_returns_null() -> None: + """Even though the schema forbids null, defensive coverage: if the + decision LLM returns ``target_seat=None`` (parse error, persona + inertia, etc.) the NPC must still vote — pick a deterministic-but- + pseudo-random candidate from the legal set, never abstain. + """ + client, sent = _make_client_with_capture() + await client.process_message(_snapshot().model_dump_json()) + + class _StubDecisionLLM: + async def decide_json( + self, *, system_prompt: str, user_prompt: str, + schema: dict[str, object], + ) -> str: + # Simulate an abstain response slipping through. + return '{"target_seat": null, "reason": "情報不足"}' + + client.decision_llm = _StubDecisionLLM() # type: ignore[assignment] + + vote_req = DecideVoteRequest( + ts=3000, trace_id="t-vote", + request_id="rv-fallback", npc_id="npc_setsu", seat_no=3, + game_id="g1", phase_id="g1::day1::DAY_VOTE::1", + candidate_seats=((1, "Alice"), (5, "Bob")), + expires_at_ms=10_000, + ) + await client.process_message(vote_req.model_dump_json()) + decisions = [VoteDecision.model_validate_json(m) for m in sent if '"vote_decision"' in m] + assert len(decisions) == 1 + assert decisions[0].target_seat in {1, 5}, ( + "abstain must be replaced by a legal candidate via the fallback" + ) + assert decisions[0].reason_summary is not None + assert "abstain_fallback" in decisions[0].reason_summary + + +@pytest.mark.asyncio +async def test_night_target_falls_back_to_random_when_llm_returns_null() -> None: + """Master rejects night actions with `target_seat=None` (ILLEGAL_TARGET), + and the missing seat deadlocks the NIGHT phase via pending_decisions. + Live game stalled when the knight chose to skip the day-1 guard + ("GJ リスク回避"). Force a legal pick so the phase always advances. + """ + client, sent = _make_client_with_capture() + await client.process_message(_snapshot(role="KNIGHT").model_dump_json()) + + class _StubDecisionLLM: + async def decide_json( + self, *, system_prompt: str, user_prompt: str, + schema: dict[str, object], + ) -> str: + # Knight tries to skip — must NOT be allowed through. + return '{"target_seat": null, "reason": "情報不足のため次夜余地残す"}' + + client.decision_llm = _StubDecisionLLM() # type: ignore[assignment] + + night_req = DecideNightActionRequest( + ts=4000, trace_id="t-night", + request_id="rn-fallback", npc_id="npc_setsu", seat_no=3, + game_id="g1", phase_id="g1::day1::NIGHT::1", + action_kind="knight_guard", + candidate_seats=((1, "Alice"), (5, "Bob")), + expires_at_ms=20_000, + ) + await client.process_message(night_req.model_dump_json()) + decisions = [ + NightActionDecision.model_validate_json(m) + for m in sent if '"night_action_decision"' in m + ] + assert len(decisions) == 1 + assert decisions[0].target_seat in {1, 5}, ( + "skip must be replaced by a legal candidate via the fallback" + ) + assert decisions[0].reason_summary is not None + assert "abstain_fallback" in decisions[0].reason_summary + + +@pytest.mark.asyncio +async def test_alive_changed_update_carries_dead_seat_causes() -> None: + """`alive_changed` payload now propagates per-seat death cause so + the NPC prompt can label dead seats as 処刑/襲撃.""" + client, _sent = _make_client_with_capture() + await client.process_message(_snapshot().model_dump_json()) + upd = PrivateStateUpdate( + ts=2000, trace_id="t", npc_id="npc_setsu", game_id="g1", seat_no=3, + update_kind="alive_changed", + payload={ + "alive_seats": [[1, "Alice"], [3, "セツ"]], + "dead_seats": [[5, "Bob"], [8, "Stella"]], + "dead_seat_causes": [[5, "EXECUTION"], [8, "ATTACK"]], + }, + ) + await client.process_message(upd.model_dump_json()) + state = client.game_states["g1"] + assert state.dead_seat_causes == {5: "EXECUTION", 8: "ATTACK"} + + +@pytest.mark.asyncio +async def test_seat_released_drops_per_game_state_and_logic_cache() -> None: + """`_on_seat_released` is the long-term cleanup hook for an NPC bot + that plays many games in one process. Without it `game_states` and + the LogicPacket cache grow unbounded across games. + + This exercises both: + - `game_states[game_id]` is popped (but other games stay). + - `_logic_cache` entries whose phase_id starts with the released + game_id are dropped (other games preserved). + """ + from wolfbot.domain.ws_messages import LogicPacket, SeatReleased + + client, _sent = _make_client_with_capture() + # Seed state for two games and two logic packets. + await client.process_message(_snapshot(game_id="g1").model_dump_json()) + await client.process_message(_snapshot(game_id="g2").model_dump_json()) + assert "g1" in client.game_states and "g2" in client.game_states + + pkt_g1 = LogicPacket( + ts=1, trace_id="t", + packet_id="lp_aaa", phase_id="g1::day1::DAY_DISCUSSION::1", + recipient_npc_id="npc_setsu", + public_state_summary="(d)", expires_at_ms=9999, + ) + pkt_g2 = LogicPacket( + ts=1, trace_id="t", + packet_id="lp_bbb", phase_id="g2::day1::DAY_DISCUSSION::1", + recipient_npc_id="npc_setsu", + public_state_summary="(d)", expires_at_ms=9999, + ) + client._logic_cache[pkt_g1.packet_id] = pkt_g1 + client._logic_cache[pkt_g2.packet_id] = pkt_g2 + + # Release g1. + msg = SeatReleased( + ts=2000, trace_id="rel-g1", + npc_id="npc_setsu", game_id="g1", reason="game_ended", + ) + await client.process_message(msg.model_dump_json()) + + assert "g1" not in client.game_states, "released game state must be dropped" + assert "g2" in client.game_states, "other-game state must be preserved" + assert "lp_aaa" not in client._logic_cache + assert "lp_bbb" in client._logic_cache + + +def test_npcgamestate_constructs_with_defaults() -> None: + """Sanity: the dataclass instantiates with defaults so test fixtures + can build empty state without going through a snapshot.""" + state = NpcGameState( + game_id="g", seat_no=1, persona_key="setsu", role="VILLAGER" + ) + assert state.game_id == "g" + assert state.seer_results == [] + + +@pytest.mark.asyncio +async def test_vote_falls_back_when_llm_raises_exception() -> None: + """Vertex AI sometimes returns 504 DEADLINE_EXCEEDED / 429 + RESOURCE_EXHAUSTED. The previous behaviour returned None on + exception, bypassing the abstain fallback and silently dropping + the ballot. Force a legal pick so the seat doesn't end up in the + silent-abstain bucket on a transient LLM failure. + """ + client, sent = _make_client_with_capture() + await client.process_message(_snapshot().model_dump_json()) + + class _RaisingLLM: + async def decide_json( + self, *, system_prompt: str, user_prompt: str, + schema: dict[str, object], + ) -> str: + raise RuntimeError("simulated 504 DEADLINE_EXCEEDED") + + client.decision_llm = _RaisingLLM() # type: ignore[assignment] + + vote_req = DecideVoteRequest( + ts=3000, trace_id="t-vote", + request_id="rv-llm-err", npc_id="npc_setsu", seat_no=3, + game_id="g1", phase_id="g1::day1::DAY_VOTE::1", + candidate_seats=((1, "Alice"), (5, "Bob")), + expires_at_ms=10_000, + ) + await client.process_message(vote_req.model_dump_json()) + decisions = [VoteDecision.model_validate_json(m) for m in sent if '"vote_decision"' in m] + assert len(decisions) == 1 + assert decisions[0].target_seat in {1, 5}, ( + "LLM exception must trigger abstain fallback, not silent abstain" + ) + assert decisions[0].reason_summary is not None + assert "abstain_fallback" in decisions[0].reason_summary + + +@pytest.mark.asyncio +async def test_night_target_falls_back_when_llm_raises_exception() -> None: + """Reproduces game `06c38cd43494` NIGHT_1 stall: Vertex AI returned + 504 DEADLINE_EXCEEDED on the seer's divine call → previous code + short-circuited with `None, "llm_error"` → Master's + `pending_decisions` retained `missing_seats=[seer]` past the + deadline → game parked in WAITING_HOST_DECISION. Now the random- + legal fallback covers transport errors too. + """ + client, sent = _make_client_with_capture() + await client.process_message(_snapshot(role="SEER").model_dump_json()) + + class _RaisingLLM: + async def decide_json( + self, *, system_prompt: str, user_prompt: str, + schema: dict[str, object], + ) -> str: + raise RuntimeError("simulated 504 DEADLINE_EXCEEDED") + + client.decision_llm = _RaisingLLM() # type: ignore[assignment] + + night_req = DecideNightActionRequest( + ts=4000, trace_id="t-night", + request_id="rn-llm-err", npc_id="npc_setsu", seat_no=3, + game_id="g1", phase_id="g1::day1::NIGHT::1", + action_kind="seer_divine", + candidate_seats=((1, "Alice"), (5, "Bob")), + expires_at_ms=20_000, + ) + await client.process_message(night_req.model_dump_json()) + decisions = [ + NightActionDecision.model_validate_json(m) + for m in sent if '"night_action_decision"' in m + ] + assert len(decisions) == 1 + assert decisions[0].target_seat in {1, 5}, ( + "LLM exception must trigger abstain fallback for night actions too" + ) + assert decisions[0].reason_summary is not None + assert "abstain_fallback" in decisions[0].reason_summary diff --git a/tests/test_npc_mock_generator.py b/tests/test_npc_mock_generator.py new file mode 100644 index 0000000..c6d355d --- /dev/null +++ b/tests/test_npc_mock_generator.py @@ -0,0 +1,88 @@ +"""MockNpcGenerator behavior tests. + +Used when ``NPC_LLM_PROVIDER=mock``. Asserts that the generator returns +persona-appropriate canned phrases via the ``set_persona`` + +``generate`` Protocol that ``make_npc_generator`` expects. +""" + +from __future__ import annotations + +from wolfbot.domain.ws_messages import LogicPacket, SpeakRequest +from wolfbot.llm.decider_config import LLMDeciderConfig +from wolfbot.npc.speech.generator_factory import make_npc_generator +from wolfbot.npc.speech.mock_generator import MockNpcGenerator + + +def _logic_packet() -> LogicPacket: + return LogicPacket( + ts=1, + trace_id="t", + packet_id="lp1", + phase_id="g::day1::DAY_DISCUSSION::1", + recipient_npc_id="npc_setsu", + public_state_summary="phase_id=foo day=1 co_claims=[(none)] silent_seats=[]", + logic_candidates=(), + expires_at_ms=2, + ) + + +def _speak_request() -> SpeakRequest: + return SpeakRequest( + ts=1, + trace_id="t", + request_id="sr1", + phase_id="g::day1::DAY_DISCUSSION::1", + npc_id="npc_setsu", + seat_no=2, + logic_packet_id="lp1", + suggested_intent="speak", + max_chars=80, + max_duration_ms=8000, + priority=0, + expires_at_ms=2, + ) + + +async def test_mock_generator_returns_setsu_specific_phrase_after_set_persona() -> None: + gen = MockNpcGenerator() + gen.set_persona("setsu") + speech = await gen.generate(logic=_logic_packet(), request=_speak_request()) + assert speech is not None + assert speech.intent == "speak" + # The first phrase in the setsu pool starts with the polite greeting. + assert "おはよう" in speech.text + + +async def test_mock_generator_unknown_persona_falls_back_to_generic_pool() -> None: + gen = MockNpcGenerator() + gen.set_persona("nonexistent_persona") + speech = await gen.generate(logic=_logic_packet(), request=_speak_request()) + assert speech is not None + assert speech.text # non-empty + + +async def test_mock_generator_round_robins_within_persona_pool() -> None: + gen = MockNpcGenerator(scripts={"setsu": ("a", "b", "c")}) + gen.set_persona("setsu") + out = [] + for _ in range(5): + speech = await gen.generate(logic=_logic_packet(), request=_speak_request()) + assert speech is not None + out.append(speech.text) + assert out == ["a", "b", "c", "a", "b"] + + +async def test_mock_generator_truncates_text_exceeding_max_chars() -> None: + long = "あ" * 200 + gen = MockNpcGenerator(scripts={"setsu": (long,)}) + gen.set_persona("setsu") + req = _speak_request() + speech = await gen.generate(logic=_logic_packet(), request=req) + assert speech is not None + assert len(speech.text) == req.max_chars + + +def test_make_npc_generator_returns_mock_when_provider_is_mock() -> None: + cfg = LLMDeciderConfig(provider="mock") + gen = make_npc_generator(cfg, persona_key="setsu") + assert isinstance(gen, MockNpcGenerator) diff --git a/tests/test_npc_seat_assignment.py b/tests/test_npc_seat_assignment.py new file mode 100644 index 0000000..2b29042 --- /dev/null +++ b/tests/test_npc_seat_assignment.py @@ -0,0 +1,617 @@ +"""NPC seat assignment + OpenAICompatibleNpcGenerator prompt-building tests. + +Verifies: +- _on_reactive_phase_enter assigns online NPCs to LLM seats. +- Duplicate assignments are avoided (idempotent on re-enter). +- Fewer NPCs than seats → partial assignment. +- Fewer seats than NPCs → capped to seat count. +- OpenAICompatibleNpcGenerator prompt construction (system + user messages). +- OpenAICompatibleNpcGenerator skip intent returns None. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from wolfbot.domain.discussion import make_phase_id +from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.models import Game, Seat +from wolfbot.domain.ws_messages import LogicCandidate, LogicPacket, SpeakRequest +from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry +from wolfbot.npc.personas import NPC_PERSONAS_BY_KEY as PERSONAS_BY_KEY +from wolfbot.npc.speech.openai_compatible_generator import ( + _build_system, + _build_user, + _format_candidate, +) +from wolfbot.persistence.sqlite_repo import SqliteRepo + + +def _noop_send(buf: list[str]) -> Callable[[str], Awaitable[None]]: + async def send(msg: str) -> None: + buf.append(msg) + return send + + +async def _seed_game_3llm(repo: SqliteRepo) -> tuple[Game, list[Seat]]: + g = Game( + id="sa1", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="Alice", + discord_user_id="u1", is_llm=False, persona_key=None), + Seat(seat_no=2, display_name="🌙 セツ", discord_user_id=None, + is_llm=True, persona_key="setsu"), + Seat(seat_no=3, display_name="🔴 ジナ", discord_user_id=None, + is_llm=True, persona_key="gina"), + Seat(seat_no=4, display_name="🟦 SQ", + discord_user_id=None, is_llm=True, persona_key="sq"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + for s in seats: + await repo.set_player_role(g.id, s.seat_no, Role.VILLAGER) + return g, seats + + +# ---- Seat assignment logic (mirrors _on_reactive_phase_enter in main.py) ---- + +async def _run_assignment( + repo: SqliteRepo, + registry: InMemoryNpcRegistry, + game_id: str, + phase_id: str, +) -> None: + """Reproduce the assignment logic from main._on_reactive_phase_enter.""" + seats = await repo.load_seats(game_id) + llm_seats = [s for s in seats if s.is_llm] + online = registry.all_online() + assigned_npc_ids = { + e.npc_id for e in online + if e.assigned_seat is not None and e.game_id == game_id + } + unassigned_npcs = [e for e in online if e.npc_id not in assigned_npc_ids] + unassigned_seats = [ + s for s in llm_seats + if not any(e.assigned_seat == s.seat_no and e.game_id == game_id for e in online) + ] + for npc_entry, seat in zip(unassigned_npcs, unassigned_seats, strict=False): + registry.assign( + npc_entry.npc_id, + seat=seat.seat_no, + game_id=game_id, + phase_id=phase_id, + ) + + +async def test_assigns_online_npcs_to_llm_seats(repo: SqliteRepo) -> None: + game, _seats = await _seed_game_3llm(repo) + registry = InMemoryNpcRegistry() + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + + for i, npc_id in enumerate(["npc_a", "npc_b", "npc_c"]): + registry.register( + npc_id=npc_id, + discord_bot_user_id=f"bot{i}", + supported_voices=(), + version="1", + send=_noop_send([]), + now_ms=1000, persona_key="setsu") + + await _run_assignment(repo, registry, game.id, phase_id) + + online = registry.all_online() + assigned = { + e.npc_id: e.assigned_seat for e in online if e.assigned_seat is not None} + assert len(assigned) == 3 + assert set(assigned.values()) == {2, 3, 4} + + +async def test_idempotent_reenter_does_not_reassign(repo: SqliteRepo) -> None: + game, _ = await _seed_game_3llm(repo) + registry = InMemoryNpcRegistry() + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + + registry.register( + npc_id="npc_x", + discord_bot_user_id="botx", + supported_voices=(), + version="1", + send=_noop_send([]), + now_ms=1000, persona_key="setsu") + + await _run_assignment(repo, registry, game.id, phase_id) + entry = registry.get("npc_x") + assert entry is not None and entry.assigned_seat is not None + first_seat = entry.assigned_seat + + # Re-run: should NOT change the assignment. + await _run_assignment(repo, registry, game.id, phase_id) + assert entry.assigned_seat == first_seat + + +async def test_fewer_npcs_than_seats_partial_assignment(repo: SqliteRepo) -> None: + game, _ = await _seed_game_3llm(repo) + registry = InMemoryNpcRegistry() + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + + # Only 1 NPC for 3 LLM seats + registry.register( + npc_id="npc_only", + discord_bot_user_id="bot_only", + supported_voices=(), + version="1", + send=_noop_send([]), + now_ms=1000, persona_key="setsu") + + await _run_assignment(repo, registry, game.id, phase_id) + entry = registry.get("npc_only") + assert entry is not None and entry.assigned_seat is not None + # Only 1 assignment total + assigned_count = sum( + 1 for e in registry.all_online() if e.assigned_seat is not None + ) + assert assigned_count == 1 + + +async def test_more_npcs_than_seats_capped(repo: SqliteRepo) -> None: + game, _ = await _seed_game_3llm(repo) + registry = InMemoryNpcRegistry() + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + + # 5 NPCs for 3 LLM seats + for i in range(5): + registry.register( + npc_id=f"npc_{i}", + discord_bot_user_id=f"bot_{i}", + supported_voices=(), + version="1", + send=_noop_send([]), + now_ms=1000, persona_key="setsu") + + await _run_assignment(repo, registry, game.id, phase_id) + assigned_count = sum( + 1 for e in registry.all_online() if e.assigned_seat is not None + ) + assert assigned_count == 3 # capped at LLM seat count + + +# ---- OpenAICompatibleNpcGenerator prompt-building unit tests ---- + + +def test_build_system_prompt_contains_persona_fields() -> None: + persona = PERSONAS_BY_KEY["setsu"] + sys_msg = _build_system(persona, max_chars=80) + assert persona.display_name in sys_msg + assert persona.speech_profile.first_person in sys_msg + assert "80" in sys_msg + assert "日本語" in sys_msg + + +def test_build_user_prompt_includes_logic_candidates() -> None: + logic = LogicPacket( + ts=1, + trace_id="t", + packet_id="lp", + phase_id="ph", + recipient_npc_id="npc_1", + public_state_summary="alive=[1,2,3]", + logic_candidates=( + LogicCandidate( + id="c1", + claim="席2が占い師COした", + support=("初日白出し",), + counter=("対抗なし",), + ), + ), + pressure={"1": 0.3, "3": 0.7}, + expires_at_ms=9999, + ) + request = SpeakRequest( + ts=1, + trace_id="t", + request_id="sr1", + npc_id="npc_1", + phase_id="ph", + seat_no=2, + logic_packet_id="lp", + suggested_intent="speak", + max_chars=80, + expires_at_ms=5000, + ) + user_msg = _build_user(logic, request) + assert "占い師CO" in user_msg + assert "初日白出し" in user_msg + assert "対抗なし" in user_msg + assert "alive=[1,2,3]" in user_msg + assert "圧力マップ" in user_msg + + +def test_format_candidate_with_all_fields() -> None: + c = LogicCandidate( + id="c2", + claim="主張テスト", + support=("根拠A", "根拠B"), + counter=("反論X",), + ) + out = _format_candidate(c) + assert "[c2]" in out + assert "主張テスト" in out + assert "根拠A" in out + assert "反論X" in out + + +def test_build_system_prompt_includes_full_persona_and_optional_role() -> None: + """The reactive_voice NPC system prompt mirrors rounds-mode by rendering + the full speech_profile + judgment_profile for the persona, and surfaces + the role + role_strategy when Master sends them on the SpeakRequest. + """ + persona = PERSONAS_BY_KEY["setsu"] + + # Without role/role_strategy: still has full persona blocks. + sys_no_role = _build_system(persona, max_chars=80) + sp = persona.speech_profile + assert sp.address_style in sys_no_role + assert "判断のクセ" in sys_no_role + # Bands rendered from judgment_profile axes: + assert "攻撃性" in sys_no_role and "流れへの追従度" in sys_no_role + assert "あなたの役職" not in sys_no_role + assert "戦術ヒント" not in sys_no_role + + # With role + strategy. + sys_with_role = _build_system( + persona, + max_chars=80, + role="SEER", + role_strategy="占い師の戦術メモ\n- 結果は出す", + ) + assert "SEER" in sys_with_role + assert "占い師の戦術メモ" in sys_with_role + + +def test_build_system_prompt_silent_gesture_persona() -> None: + """Kukrushka's `narration_mode=silent_gesture` must round-trip through + the NPC system prompt — previously dropped because the historical + `_build_system` only handled the standard speech profile fields. + """ + persona = PERSONAS_BY_KEY["kukrushka"] + sys_msg = _build_system(persona, max_chars=80) + # The silent_gesture branch in build_speech_profile_block emits this + # exact label; if it's missing the NPC will speak normally. + assert "ほぼ無言" in sys_msg or "叙述モード" in sys_msg + + +def test_build_user_prompt_renders_recent_speeches_and_seats() -> None: + """Recent speeches + alive/dead seats from the SpeakRequest land in + the user prompt with the speaker's display name attached.""" + from wolfbot.domain.ws_messages import RecentSpeech + + logic = LogicPacket( + ts=1, + trace_id="t", + packet_id="lp", + phase_id="ph", + recipient_npc_id="npc_1", + public_state_summary="alive=[1,2,3]", + recent_speeches=( + RecentSpeech( + seat_no=1, + display_name="Alice", + source="text", + text="占いの結果が気になる", + ), + RecentSpeech( + seat_no=3, + display_name="🌙セツ", + source="npc_generated", + text="まだ静かですね", + ), + ), + expires_at_ms=9999, + ) + request = SpeakRequest( + ts=1, + trace_id="t", + request_id="sr", + npc_id="npc_1", + phase_id="ph", + seat_no=2, + logic_packet_id="lp", + suggested_intent="speak", + max_chars=80, + expires_at_ms=5000, + alive_seats=((1, "Alice"), (2, "Bob"), (3, "🌙セツ")), + dead_seats=((4, "故人"),), + ) + user_msg = _build_user(logic, request) + # Recent speeches now render by display_name only — seat numbers + # live exclusively in the `## 参加者` roster header upstream. + assert "## 直近の発言" in user_msg + assert "Alice" in user_msg and "占いの結果が気になる" in user_msg + assert "🌙セツ" in user_msg and "まだ静かですね" in user_msg + assert "[テキスト]" in user_msg and "[NPC発話]" in user_msg + # Roster header is the single source of seat→name mapping. + assert "## 参加者 (席番号 → 名前)" in user_msg + assert "席1 Alice" in user_msg and "席2 Bob" in user_msg + assert "席3 🌙セツ" in user_msg + assert "席4 故人" in user_msg + # The `生存者` / `死亡者` blocks were folded into the unified + # roster — assert recent-speech lines no longer carry the seat + # prefix that used to leak into NPC output ("席4と席1の占い…"). + assert "席1 Alice [テキスト]" not in user_msg + + +def test_build_user_prompt_runoff_speech_block_appears_only_in_runoff_phase() -> None: + """Tied candidates speaking in DAY_RUNOFF_SPEECH need a phase-specific + nudge to (1) name a suspect and (2) cite concrete evidence — not just + re-cite their CO/seer result. The nudge must NOT appear in + DAY_DISCUSSION (where the rotation is broader and the LLM speech is + already conversational). Regression for game 7faf339713cf where ジナ + only ever re-cited her seer result during runoff and got + fabrication-capped trying to invent a 2nd same-day claim. + """ + logic = LogicPacket( + ts=1, + trace_id="t", + packet_id="lp", + phase_id="g1::day1::DAY_RUNOFF_SPEECH::1", + recipient_npc_id="npc_1", + public_state_summary="alive=[1,2,3]", + expires_at_ms=9999, + ) + runoff_request = SpeakRequest( + ts=1, + trace_id="t", + request_id="sr", + npc_id="npc_1", + phase_id="g1::day1::DAY_RUNOFF_SPEECH::1", + seat_no=2, + logic_packet_id="lp", + suggested_intent="speak", + max_chars=140, + expires_at_ms=5000, + alive_seats=((1, "Alice"), (2, "Bob"), (3, "🌙セツ")), + ) + user_msg = _build_user(logic, runoff_request) + # Runoff guidance must surface the two required content axes. + assert "決選投票 直前の最終演説" in user_msg + assert "誰を最も怪しいと思っているか" in user_msg + assert "その根拠" in user_msg + # And reinforce the validator's "no new same-day result" rule so the + # LLM doesn't try to invent a 2nd day-1 divination target. + assert "新しい占い/霊媒結果は出せない" in user_msg + + # DAY_DISCUSSION (different phase id) must NOT include the runoff + # block — it would distort regular discussion into pre-vote + # accusations. + discuss_request = runoff_request.model_copy( + update={"phase_id": "g1::day1::DAY_DISCUSSION::1"} + ) + discuss_msg = _build_user(logic, discuss_request) + assert "決選投票 直前の最終演説" not in discuss_msg + + +def test_build_user_prompt_uses_npc_state_over_request_fields() -> None: + """Phase-D: when an `NpcGameState` is supplied, the speech user prompt + pulls alive/dead/private info from it rather than the SpeakRequest. + The state's role-specific results, partner wolves, and wolf chat + history all surface in the prompt.""" + from wolfbot.domain.ws_messages import ( + GuardEntry, + MediumResult, + SeerResult, + WolfChatLine, + ) + from wolfbot.npc.decision.game_state import NpcGameState + + logic = LogicPacket( + ts=1, trace_id="t", packet_id="lp", phase_id="ph", + recipient_npc_id="npc_1", + public_state_summary="(no digest)", + recent_speeches=(), + expires_at_ms=9999, + ) + request = SpeakRequest( + ts=1, trace_id="t", request_id="sr", npc_id="npc_1", + phase_id="ph", seat_no=2, logic_packet_id="lp", + suggested_intent="speak", max_chars=80, expires_at_ms=5000, + # Stale fields a Master might still be sending — state should + # override these. + alive_seats=((9, "stale_alive"),), + dead_seats=(), + ) + state = NpcGameState( + game_id="g1", seat_no=2, persona_key="setsu", role="WEREWOLF", + day_number=1, + alive_seats=[(1, "Alice"), (2, "セツ"), (5, "Bob")], + dead_seats=[(3, "fallen")], + partner_wolves=[(5, "Bob")], + seer_results=[SeerResult(day=1, target_seat=1, target_name="Alice", is_wolf=False)], + medium_results=[ + MediumResult(day=1, target_seat=3, target_name="fallen", is_wolf=False) + ], + guard_history=[ + GuardEntry(day=1, target_seat=2, target_name="セツ", peaceful_morning=True) + ], + wolf_chat_history=[ + WolfChatLine(day=1, speaker_seat=5, speaker_name="Bob", text="席1を狙う") + ], + ) + out = _build_user(logic, request, state) + # Alive list comes from state, NOT from the stale request fields. + assert "席1 Alice" in out and "席2 セツ" in out and "席5 Bob" in out + assert "stale_alive" not in out + # Dead list comes from state. + assert "席3 fallen" in out + # Private blocks all surface. + assert "## 仲間の人狼" in out and "席5 Bob" in out + assert "## 自分の占い結果" in out + assert "## 自分の霊媒結果" in out + assert "## 自分の護衛履歴" in out and "(平和な朝)" in out + assert "## 人狼チャット履歴" in out and "席1を狙う" in out + + +def test_build_user_prompt_falls_back_to_request_when_state_none() -> None: + """Without state, the prompt still works using SpeakRequest fields + (back-compat with older Master builds that don't push snapshots).""" + logic = LogicPacket( + ts=1, trace_id="t", packet_id="lp", phase_id="ph", + recipient_npc_id="npc_1", + public_state_summary="(d)", expires_at_ms=9999, + ) + request = SpeakRequest( + ts=1, trace_id="t", request_id="sr", npc_id="npc_1", + phase_id="ph", seat_no=2, logic_packet_id="lp", + suggested_intent="speak", max_chars=80, expires_at_ms=5000, + alive_seats=((1, "Alice"), (2, "Bob")), + dead_seats=((3, "Dead"),), + ) + out = _build_user(logic, request, None) + assert "席1 Alice" in out and "席2 Bob" in out + assert "席3 Dead" in out + # No private state when state is None. + assert "## 仲間の人狼" not in out + assert "## 自分の占い結果" not in out + + +def test_build_user_prompt_tags_dead_seats_with_death_cause() -> None: + """Dead seat list must distinguish executions from attacks so the + NPC stops calling yesterday's executed player "昨夜の犠牲者".""" + from wolfbot.npc.decision.game_state import NpcGameState + + logic = LogicPacket( + ts=1, trace_id="t", packet_id="lp", phase_id="ph", + recipient_npc_id="npc_1", + public_state_summary="(d)", expires_at_ms=9999, + ) + request = SpeakRequest( + ts=1, trace_id="t", request_id="sr", npc_id="npc_1", + phase_id="ph", seat_no=2, logic_packet_id="lp", + suggested_intent="speak", max_chars=80, expires_at_ms=5000, + ) + state = NpcGameState( + game_id="g1", seat_no=2, persona_key="setsu", role="VILLAGER", + day_number=2, + alive_seats=[(1, "Alice"), (2, "セツ")], + dead_seats=[(3, "コメット"), (8, "ステラ")], + dead_seat_causes={3: "EXECUTION", 8: "ATTACK"}, + ) + out = _build_user(logic, request, state) + assert "席3 コメット (処刑)" in out + assert "席8 ステラ (襲撃)" in out + + +def test_system_prompt_forbids_seat_numbers_in_text() -> None: + """The NPC speech rule must explicitly forbid 席N references in the + free-text payload — otherwise the model leaks "席3はどう?" patterns.""" + persona = PERSONAS_BY_KEY["setsu"] + sys = _build_system(persona, max_chars=80) + assert "席番号" in sys + assert "display_name" in sys.lower() or "display_name" in sys + + +def test_build_user_prompt_renders_past_votes_with_names() -> None: + """Past-day votes must surface in the prompt with seat + display name + so NPCs can reference 「ジナ → セツ」 instead of fabricating their + own ballot.""" + logic = LogicPacket( + ts=1, trace_id="t", packet_id="lp", phase_id="ph", + recipient_npc_id="npc_1", + public_state_summary="(d)", expires_at_ms=9999, + past_votes=( + (1, 0, ((1, 3), (2, 4), (3, 1), (4, 1))), + ), + ) + request = SpeakRequest( + ts=1, trace_id="t", request_id="sr", npc_id="npc_1", + phase_id="ph", seat_no=2, logic_packet_id="lp", + suggested_intent="speak", max_chars=80, expires_at_ms=5000, + alive_seats=((1, "Alice"), (2, "ジナ"), (3, "ラキオ"), (4, "セツ")), + dead_seats=(), + ) + out = _build_user(logic, request) + assert "## 公開された投票履歴" in out + assert "day1 投票" in out + # New naming policy: ballot ledger uses display_name only. + assert "Alice → ラキオ" in out + assert "ジナ → セツ" in out + + +def test_build_user_prompt_handles_past_vote_abstain() -> None: + """target=None must render as 棄権.""" + logic = LogicPacket( + ts=1, trace_id="t", packet_id="lp", phase_id="ph", + recipient_npc_id="npc_1", + public_state_summary="(d)", expires_at_ms=9999, + past_votes=((1, 0, ((1, None), (2, 1))),), + ) + request = SpeakRequest( + ts=1, trace_id="t", request_id="sr", npc_id="npc_1", + phase_id="ph", seat_no=2, logic_packet_id="lp", + suggested_intent="speak", max_chars=80, expires_at_ms=5000, + alive_seats=((1, "Alice"), (2, "ジナ")), + dead_seats=(), + ) + out = _build_user(logic, request) + assert "Alice → 棄権" in out + + +def test_build_user_prompt_renders_pending_role_callouts() -> None: + """Outstanding role-callouts must surface as a dedicated section in + the NPC prompt so real role holders take it as a CO trigger and + wolf-side NPCs can decide to fake CO.""" + logic = LogicPacket( + ts=1, trace_id="t", packet_id="lp", phase_id="ph", + recipient_npc_id="npc_1", + public_state_summary="(d)", expires_at_ms=9999, + pending_role_callouts=("seer",), + ) + request = SpeakRequest( + ts=1, trace_id="t", request_id="sr", npc_id="npc_1", + phase_id="ph", seat_no=2, logic_packet_id="lp", + suggested_intent="speak", max_chars=80, expires_at_ms=5000, + alive_seats=((1, "Alice"), (2, "ジナ"), (3, "ラキオ")), + dead_seats=(), + ) + out = _build_user(logic, request) + assert "## 未回答の役職呼びかけ" in out + assert "占い師" in out + assert "(seer)" in out + + +def test_build_user_prompt_no_candidates_no_pressure() -> None: + logic = LogicPacket( + ts=1, + trace_id="t", + packet_id="lp2", + phase_id="ph", + recipient_npc_id="npc_1", + public_state_summary="quiet", + logic_candidates=(), + pressure={}, + expires_at_ms=9999, + ) + request = SpeakRequest( + ts=1, + trace_id="t", + request_id="sr2", + npc_id="npc_1", + phase_id="ph", + seat_no=2, + logic_packet_id="lp2", + suggested_intent="question", + max_chars=80, + expires_at_ms=5000, + ) + user_msg = _build_user(logic, request) + assert "quiet" in user_msg + assert "論点候補" not in user_msg + assert "圧力マップ" not in user_msg diff --git a/tests/test_npc_seat_lifecycle.py b/tests/test_npc_seat_lifecycle.py new file mode 100644 index 0000000..d8ca4c5 --- /dev/null +++ b/tests/test_npc_seat_lifecycle.py @@ -0,0 +1,304 @@ +"""NPC bot seat lifecycle — verify the assignment / release plumbing. + +Goal: NPC bots no longer auto-join VC at startup. They idle (Discord + +WS connected) until Master picks them via `/wolf start` and sends a +`seat_assigned`; at game end Master sends `seat_released` and the bot +leaves VC. This file pins down each piece of that contract. + +Covers: +- `NpcClient` invokes its `on_vc_join` callback when Master sends + `seat_assigned`, and `on_vc_leave` when `seat_released` arrives. +- A reconnecting NPC whose registration reply already names a seat + still triggers `on_vc_join` (recovery path). +- `npc_registered` with `assigned_seat=None` does NOT trigger join. +- `NpcRegistry.unassign` clears the entry's assignment fields. +- `NpcRegistry.assigned_to_game` returns only entries currently + assigned to the given game id (used by Master's release sweep). +- `seat_assigned` / `seat_released` round-trip through the + Pydantic envelope. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from wolfbot.domain.ws_messages import ( + NpcRegistered, + SeatAssigned, + SeatReleased, + SetMuteState, +) +from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry +from wolfbot.npc.audio.playback import FakeVoicePlayback +from wolfbot.npc.audio.tts import FakeTtsService +from wolfbot.npc.runtime.client import NpcClient, NpcClientConfig +from wolfbot.npc.speech.speech_service import FakeNpcGenerator, NpcSpeechService + + +@dataclass +class _VcCallbackProbe: + join_calls: int = 0 + leave_calls: int = 0 + join_log: list[str] = field(default_factory=list) + leave_log: list[str] = field(default_factory=list) + + async def on_join(self) -> None: + self.join_calls += 1 + self.join_log.append("join") + + async def on_leave(self) -> None: + self.leave_calls += 1 + self.leave_log.append("leave") + + +def _make_client(probe: _VcCallbackProbe) -> NpcClient: + captured: list[str] = [] + + async def send(msg: str) -> None: + captured.append(msg) + + return NpcClient( + config=NpcClientConfig( + npc_id="npc_setsu", + discord_bot_user_id="bot2", + persona_key="setsu", + voice_id="vox-1", + ), + speech=NpcSpeechService(FakeNpcGenerator()), + tts=FakeTtsService(), + playback=FakeVoicePlayback(), + send=send, + now_ms=lambda: 1, + on_vc_join=probe.on_join, + on_vc_leave=probe.on_leave, + ) + + +# ------------------------------------------------------ NpcClient lifecycle + + +async def test_seat_assigned_triggers_vc_join() -> None: + probe = _VcCallbackProbe() + client = _make_client(probe) + msg = SeatAssigned( + ts=1, + trace_id="t", + npc_id="npc_setsu", + seat_no=2, + game_id="g1", + phase_id="g1::day1::DAY_DISCUSSION::1", + ) + await client.process_message(msg.model_dump_json()) + assert probe.join_calls == 1 + assert probe.leave_calls == 0 + assert client.assigned_seat == 2 + assert client.assigned_game_id == "g1" + + +async def test_seat_released_triggers_vc_leave_and_clears_state() -> None: + probe = _VcCallbackProbe() + client = _make_client(probe) + # Pre-assign so leave has something to clear. + await client.process_message( + SeatAssigned( + ts=1, trace_id="t", npc_id="npc_setsu", seat_no=2, + game_id="g1", phase_id="g1::day1::DAY_DISCUSSION::1", + ).model_dump_json() + ) + assert client.assigned_seat == 2 + + await client.process_message( + SeatReleased( + ts=2, trace_id="t", npc_id="npc_setsu", game_id="g1", + reason="game_ended", + ).model_dump_json() + ) + assert probe.leave_calls == 1 + assert client.assigned_seat is None + assert client.assigned_game_id is None + assert client.assigned_phase_id is None + + +async def test_npc_registered_without_seat_does_not_join_vc() -> None: + """Default startup case: NPC bot registers, Master replies with no seat. + The bot must NOT auto-join VC — it idles until /wolf start picks it.""" + probe = _VcCallbackProbe() + client = _make_client(probe) + msg = NpcRegistered(ts=1, trace_id="t", npc_id="npc_setsu") + await client.process_message(msg.model_dump_json()) + assert probe.join_calls == 0 + assert probe.leave_calls == 0 + assert client.registered is True + assert client.assigned_seat is None + + +async def test_npc_registered_with_assigned_seat_triggers_recovery_join() -> None: + """Reconnect mid-game: Master tells the bot it's already assigned, and + the bot rejoins VC without waiting for a fresh seat_assigned.""" + probe = _VcCallbackProbe() + client = _make_client(probe) + msg = NpcRegistered( + ts=1, + trace_id="t", + npc_id="npc_setsu", + assigned_seat=3, + game_id="g1", + phase_id="g1::day1::DAY_DISCUSSION::1", + ) + await client.process_message(msg.model_dump_json()) + assert probe.join_calls == 1 + assert client.assigned_seat == 3 + + +async def test_set_mute_state_invokes_on_set_mute_for_self() -> None: + """`set_mute_state` flips the bot's voice self-mute via the + `on_set_mute` callback. Mismatched npc_id is ignored.""" + captured: list[bool] = [] + + async def on_set_mute(self_mute: bool) -> None: + captured.append(self_mute) + + probe = _VcCallbackProbe() + client = _make_client(probe) + client.on_set_mute = on_set_mute + + await client.process_message( + SetMuteState( + ts=1, trace_id="t", npc_id="npc_setsu", self_mute=True + ).model_dump_json() + ) + await client.process_message( + SetMuteState( + ts=2, trace_id="t", npc_id="npc_setsu", self_mute=False + ).model_dump_json() + ) + # Mismatched npc_id — must be a no-op. + await client.process_message( + SetMuteState( + ts=3, trace_id="t", npc_id="someone_else", self_mute=True + ).model_dump_json() + ) + assert captured == [True, False] + + +async def test_set_mute_state_callback_failure_does_not_propagate() -> None: + async def boom(_self_mute: bool) -> None: + raise RuntimeError("voice gateway unavailable") + + probe = _VcCallbackProbe() + client = _make_client(probe) + client.on_set_mute = boom + + # Must NOT raise. + await client.process_message( + SetMuteState( + ts=1, trace_id="t", npc_id="npc_setsu", self_mute=True + ).model_dump_json() + ) + + +async def test_seat_assigned_join_failure_does_not_propagate() -> None: + """A discord.connect() failure must be logged but never crash the + message loop — the WS stays alive so Master can retry.""" + + class _Boom(_VcCallbackProbe): + async def on_join(self) -> None: + self.join_calls += 1 + raise RuntimeError("vc unavailable") + + probe = _Boom() + client = _make_client(probe) + msg = SeatAssigned( + ts=1, trace_id="t", npc_id="npc_setsu", seat_no=2, + game_id="g1", phase_id="g1::day1::DAY_DISCUSSION::1", + ) + # Must NOT raise. + await client.process_message(msg.model_dump_json()) + assert probe.join_calls == 1 + # State still updated even though VC join failed — Master treats the + # bot as assigned and the next reconnect will retry the join via + # the recovery path. + assert client.assigned_seat == 2 + + +# ------------------------------------------------------ NpcRegistry helpers + + +def test_registry_unassign_clears_fields() -> None: + reg = InMemoryNpcRegistry() + reg.register( + npc_id="npc_setsu", + discord_bot_user_id="bot2", + persona_key="setsu", + supported_voices=(), + version="1", + send=None, + now_ms=1, + ) + reg.assign("npc_setsu", seat=2, game_id="g1", phase_id="ph1") + entry = reg.get("npc_setsu") + assert entry is not None and entry.assigned_seat == 2 + + reg.unassign("npc_setsu") + entry = reg.get("npc_setsu") + assert entry is not None + assert entry.assigned_seat is None + assert entry.game_id is None + assert entry.phase_id is None + + +def test_registry_assigned_to_game_filters_correctly() -> None: + reg = InMemoryNpcRegistry() + for idx, key in enumerate(["setsu", "gina", "sq"], start=1): + reg.register( + npc_id=f"npc_{key}", + discord_bot_user_id=f"bot{idx}", + persona_key=key, + supported_voices=(), + version="1", + send=None, + now_ms=1, + ) + reg.assign("npc_setsu", seat=2, game_id="g1", phase_id="ph1") + reg.assign("npc_gina", seat=3, game_id="g1", phase_id="ph1") + reg.assign("npc_sq", seat=4, game_id="g2", phase_id="ph2") + + g1 = {e.npc_id for e in reg.assigned_to_game("g1")} + g2 = {e.npc_id for e in reg.assigned_to_game("g2")} + g3 = list(reg.assigned_to_game("g3-nonexistent")) + + assert g1 == {"npc_setsu", "npc_gina"} + assert g2 == {"npc_sq"} + assert g3 == [] + + +def test_registry_unassign_unknown_id_is_silent() -> None: + reg = InMemoryNpcRegistry() + # Must not raise on a missing id. + reg.unassign("never-registered") + + +# ------------------------------------------------------ wire round-trip + + +def test_seat_assigned_pydantic_round_trip() -> None: + msg = SeatAssigned( + ts=1, + trace_id="t", + npc_id="npc_setsu", + seat_no=2, + game_id="g1", + phase_id="ph", + ) + out = SeatAssigned.model_validate_json(msg.model_dump_json()) + assert out.npc_id == "npc_setsu" + assert out.seat_no == 2 + assert out.game_id == "g1" + + +def test_seat_released_pydantic_round_trip_default_reason() -> None: + msg = SeatReleased(ts=1, trace_id="t", npc_id="npc_setsu") + out = SeatReleased.model_validate_json(msg.model_dump_json()) + assert out.npc_id == "npc_setsu" + assert out.reason == "game_ended" + assert out.game_id is None diff --git a/tests/test_npc_voice_worker.py b/tests/test_npc_voice_worker.py new file mode 100644 index 0000000..a1477f0 --- /dev/null +++ b/tests/test_npc_voice_worker.py @@ -0,0 +1,488 @@ +"""Bundle 7: NPC voice worker — integration coverage. + +Drives the `NpcClient` directly with synthetic JSON envelopes. Substitutes +`FakeNpcGenerator`, `FakeTtsService`, and `FakeVoicePlayback`. Verifies: + +- Successful end-to-end: SpeakRequest → SpeakResult → PlaybackAuthorized → + TTS synthesis → playback → tts_finished + playback_finished. +- Decline path: empty generator output → SpeakResult(declined). +- Pre-authorization invariant: no audio is played before + PlaybackAuthorized. +- Unauthorized: PlaybackRejected drops the queued utterance silently. +- TTS error: tts_failed message; no playback. +- Playback error: playback_failed message after tts_finished. +- TtsCache: identical text re-uses synthesized audio. +- Logic packet routing: SpeakRequest with unknown packet still produces a + SpeakResult (best-effort). +""" + +from __future__ import annotations + +import json + +from wolfbot.domain.ws_messages import ( + LogicPacket, + NpcRegistered, + PlaybackAuthorized, + PlaybackFailed, + PlaybackFinished, + PlaybackRejected, + SpeakRequest, + SpeakResult, + TtsFinished, +) +from wolfbot.npc.audio.playback import ( + FakeVoicePlayback, + VoicePlaybackError, +) +from wolfbot.npc.audio.tts import ( + FakeTtsService, + InMemoryTtsCache, + TtsProviderError, + TtsResult, +) +from wolfbot.npc.runtime.client import NpcClient, NpcClientConfig +from wolfbot.npc.speech.speech_service import ( + FakeNpcGenerator, + NpcGeneratedSpeech, + NpcSpeechService, +) + + +def _make_client( + *, + generator: FakeNpcGenerator, + tts: FakeTtsService, + playback: FakeVoicePlayback, + captured: list[str] | None = None, + cache: InMemoryTtsCache | None = None, +) -> tuple[NpcClient, list[str]]: + out = captured if captured is not None else [] + + async def send(msg: str) -> None: + out.append(msg) + + speech = NpcSpeechService(generator) + client = NpcClient( + config=NpcClientConfig( + npc_id="npc_p2", + discord_bot_user_id="bot2", + persona_key="setsu", + voice_id="ja-Standard-A", + ), + speech=speech, + tts=tts, + playback=playback, + send=send, + now_ms=lambda: 1000, + cache=cache or InMemoryTtsCache(max_entries=4), + ) + return client, out + + +def _make_logic(packet_id: str = "lp1", phase_id: str = "ph") -> LogicPacket: + return LogicPacket( + ts=1, + trace_id="t", + packet_id=packet_id, + phase_id=phase_id, + recipient_npc_id="npc_p2", + public_state_summary="silent_seats=[]", + logic_candidates=(), + pressure={}, + expires_at_ms=2000, + ) + + +def _make_request( + *, request_id: str = "sr1", logic_packet_id: str = "lp1", phase_id: str = "ph" +) -> SpeakRequest: + return SpeakRequest( + ts=1, + trace_id="t", + request_id=request_id, + phase_id=phase_id, + npc_id="npc_p2", + seat_no=2, + logic_packet_id=logic_packet_id, + suggested_intent="speak", + max_chars=80, + max_duration_ms=8000, + priority=0, + expires_at_ms=2000, + ) + + +async def test_end_to_end_authorized_playback_finishes() -> None: + gen = FakeNpcGenerator( + scripted=[ + NpcGeneratedSpeech( + text="まあそれは違うよ", + intent="counter", + used_logic_ids=("c1",), + estimated_duration_ms=2000, + ) + ] + ) + tts = FakeTtsService(scripted=[TtsResult(audio=b"audio", duration_ms=900)]) + playback = FakeVoicePlayback(started_at_ms=2000, finished_at_ms=2900) + client, captured = _make_client(generator=gen, tts=tts, playback=playback) + + # Logic packet → speak_request → speak_result. + await client.process_message(_make_logic().model_dump_json()) + await client.process_message(_make_request().model_dump_json()) + result = SpeakResult.model_validate_json(captured[-1]) + assert result.status == "accepted" and result.text == "まあそれは違うよ" + + # No playback yet. + assert playback.plays == [] + + # Authorize. + auth = PlaybackAuthorized( + ts=2, + trace_id="t", + request_id="sr1", + npc_id="npc_p2", + speech_event_id="ev1", + playback_deadline_ms=10_000, + ) + await client.process_message(auth.model_dump_json()) + # tts_finished + playback_finished sent in order. + assert any(json.loads(m).get("type") == "tts_finished" for m in captured) + assert any(json.loads(m).get("type") == "playback_finished" for m in captured) + finished = next( + PlaybackFinished.model_validate_json(m) + for m in captured + if json.loads(m).get("type") == "playback_finished" + ) + assert finished.started_at_ms == 2000 + assert finished.finished_at_ms == 2900 + assert playback.plays == [(b"audio", 48_000)] + + +async def test_decline_when_generator_returns_none() -> None: + gen = FakeNpcGenerator(scripted=[None]) + tts = FakeTtsService() + playback = FakeVoicePlayback() + client, captured = _make_client(generator=gen, tts=tts, playback=playback) + await client.process_message(_make_logic().model_dump_json()) + await client.process_message(_make_request().model_dump_json()) + result = SpeakResult.model_validate_json(captured[-1]) + assert result.status == "declined" + + +async def test_no_playback_before_authorization() -> None: + """Without a PlaybackAuthorized, no playback / TTS event is emitted — + matching the discord-integration spec.""" + gen = FakeNpcGenerator( + scripted=[ + NpcGeneratedSpeech( + text="こんにちは", intent="greeting", used_logic_ids=(), estimated_duration_ms=500 + ) + ] + ) + tts = FakeTtsService(scripted=[TtsResult(audio=b"abc", duration_ms=500)]) + playback = FakeVoicePlayback() + client, captured = _make_client(generator=gen, tts=tts, playback=playback) + await client.process_message(_make_logic().model_dump_json()) + await client.process_message(_make_request().model_dump_json()) + # No tts_finished / playback_finished yet. + assert playback.plays == [] + types = [json.loads(m).get("type") for m in captured] + assert "tts_finished" not in types + assert "playback_finished" not in types + + +async def test_playback_rejected_drops_pending_silently() -> None: + gen = FakeNpcGenerator( + scripted=[ + NpcGeneratedSpeech( + text="占いCO", intent="co", used_logic_ids=(), estimated_duration_ms=800 + ) + ] + ) + tts = FakeTtsService(scripted=[TtsResult(audio=b"abc", duration_ms=500)]) + playback = FakeVoicePlayback() + client, _ = _make_client(generator=gen, tts=tts, playback=playback) + await client.process_message(_make_logic().model_dump_json()) + await client.process_message(_make_request().model_dump_json()) + rej = PlaybackRejected( + ts=2, + trace_id="t", + request_id="sr1", + npc_id="npc_p2", + failure_reason="utterance_too_long", + ) + await client.process_message(rej.model_dump_json()) + # Subsequent authorization MUST NOT replay (because the result was rejected). + auth = PlaybackAuthorized( + ts=3, + trace_id="t", + request_id="sr1", + npc_id="npc_p2", + speech_event_id="ev1", + playback_deadline_ms=10_000, + ) + await client.process_message(auth.model_dump_json()) + assert playback.plays == [] + + +async def test_tts_failure_emits_tts_failed_and_no_playback() -> None: + gen = FakeNpcGenerator( + scripted=[ + NpcGeneratedSpeech( + text="やあ", intent="greeting", used_logic_ids=(), estimated_duration_ms=500 + ) + ] + ) + tts = FakeTtsService(scripted=[TtsProviderError("tts_provider_error")]) + playback = FakeVoicePlayback() + client, captured = _make_client(generator=gen, tts=tts, playback=playback) + await client.process_message(_make_logic().model_dump_json()) + await client.process_message(_make_request().model_dump_json()) + auth = PlaybackAuthorized( + ts=2, + trace_id="t", + request_id="sr1", + npc_id="npc_p2", + speech_event_id="ev1", + playback_deadline_ms=10_000, + ) + await client.process_message(auth.model_dump_json()) + types = [json.loads(m).get("type") for m in captured] + assert "tts_failed" in types + assert "tts_finished" not in types + assert playback.plays == [] + + +async def test_playback_error_emits_playback_failed() -> None: + gen = FakeNpcGenerator( + scripted=[ + NpcGeneratedSpeech( + text="やあ", intent="greeting", used_logic_ids=(), estimated_duration_ms=500 + ) + ] + ) + tts = FakeTtsService(scripted=[TtsResult(audio=b"abc", duration_ms=500)]) + playback = FakeVoicePlayback( + raise_for_audio=VoicePlaybackError("discord_playback_error"), + ) + client, captured = _make_client(generator=gen, tts=tts, playback=playback) + await client.process_message(_make_logic().model_dump_json()) + await client.process_message(_make_request().model_dump_json()) + auth = PlaybackAuthorized( + ts=2, + trace_id="t", + request_id="sr1", + npc_id="npc_p2", + speech_event_id="ev1", + playback_deadline_ms=10_000, + ) + await client.process_message(auth.model_dump_json()) + failed = next( + PlaybackFailed.model_validate_json(m) + for m in captured + if json.loads(m).get("type") == "playback_failed" + ) + assert failed.failure_reason == "discord_playback_error" + + +async def test_tts_cache_avoids_resynth() -> None: + gen = FakeNpcGenerator( + scripted=[ + NpcGeneratedSpeech( + text="同じ", intent="speak", used_logic_ids=(), estimated_duration_ms=400 + ), + NpcGeneratedSpeech( + text="同じ", intent="speak", used_logic_ids=(), estimated_duration_ms=400 + ), + ] + ) + tts = FakeTtsService(default=TtsResult(audio=b"a", duration_ms=400)) + playback = FakeVoicePlayback(started_at_ms=1, finished_at_ms=2) + cache = InMemoryTtsCache(max_entries=4) + client, _captured = _make_client(generator=gen, tts=tts, playback=playback, cache=cache) + await client.process_message(_make_logic().model_dump_json()) + await client.process_message(_make_request(request_id="r1").model_dump_json()) + await client.process_message( + PlaybackAuthorized( + ts=1, + trace_id="t", + request_id="r1", + npc_id="npc_p2", + speech_event_id="ev1", + playback_deadline_ms=10_000, + ).model_dump_json() + ) + await client.process_message(_make_request(request_id="r2").model_dump_json()) + await client.process_message( + PlaybackAuthorized( + ts=1, + trace_id="t", + request_id="r2", + npc_id="npc_p2", + speech_event_id="ev2", + playback_deadline_ms=10_000, + ).model_dump_json() + ) + # First synth uncached; second synth must hit the cache. + assert cache.hits == 1 + assert cache.misses == 1 + + +async def test_npc_registered_marks_client_registered() -> None: + gen = FakeNpcGenerator() + tts = FakeTtsService() + playback = FakeVoicePlayback() + client, _ = _make_client(generator=gen, tts=tts, playback=playback) + assert client.registered is False + msg = NpcRegistered( + ts=1, trace_id="t", npc_id="npc_p2", assigned_seat=2, game_id="g", phase_id="ph" + ) + await client.process_message(msg.model_dump_json()) + assert client.registered is True + + +async def test_unknown_logic_packet_uses_empty_logic_in_speak_result() -> None: + """If a SpeakRequest references an unseen `logic_packet_id`, the NPC + still produces a SpeakResult — degraded, but not silent.""" + gen = FakeNpcGenerator( + scripted=[ + NpcGeneratedSpeech( + text="ええと", intent="speak", used_logic_ids=(), estimated_duration_ms=300 + ) + ] + ) + tts = FakeTtsService() + playback = FakeVoicePlayback() + client, captured = _make_client(generator=gen, tts=tts, playback=playback) + await client.process_message(_make_request(logic_packet_id="never-seen").model_dump_json()) + result = SpeakResult.model_validate_json(captured[-1]) + assert result.status == "accepted" + + +async def test_invalid_inbound_json_is_logged_not_raised() -> None: + gen = FakeNpcGenerator() + tts = FakeTtsService() + playback = FakeVoicePlayback() + client, _ = _make_client(generator=gen, tts=tts, playback=playback) + await client.process_message("{not-json") + await client.process_message(json.dumps({"no_type_here": True})) + # No exception — handler must be tolerant. + + +def test_npc_bot_main_module_loads() -> None: + """Smoke-load the entrypoint module to catch import-time regressions.""" + import importlib + + mod = importlib.import_module("wolfbot.npc.runtime.main") + assert hasattr(mod, "main") + + +async def test_on_post_chat_invoked_with_spoken_text() -> None: + """When TTS is authorized, NpcClient must hand the spoken text to + `on_post_chat` so each NPC posts its own line to VC chat (instead + of Master mirroring everyone's speech).""" + gen = FakeNpcGenerator( + scripted=[ + NpcGeneratedSpeech( + text="そうかもしれないね", + intent="speak", + used_logic_ids=(), + estimated_duration_ms=900, + ) + ] + ) + tts = FakeTtsService(scripted=[TtsResult(audio=b"audio", duration_ms=900)]) + playback = FakeVoicePlayback(started_at_ms=10, finished_at_ms=910) + client, _captured = _make_client(generator=gen, tts=tts, playback=playback) + + posted: list[str] = [] + + async def on_post_chat(text: str) -> None: + posted.append(text) + + client.on_post_chat = on_post_chat + + await client.process_message(_make_logic().model_dump_json()) + await client.process_message(_make_request().model_dump_json()) + await client.process_message( + PlaybackAuthorized( + ts=2, + trace_id="t", + request_id="sr1", + npc_id="npc_p2", + speech_event_id="ev1", + playback_deadline_ms=10_000, + ).model_dump_json() + ) + assert posted == ["そうかもしれないね"] + # Playback still ran — chat post is best-effort, not gating. + assert playback.plays == [(b"audio", 48_000)] + + +async def test_on_post_chat_failure_does_not_block_playback() -> None: + gen = FakeNpcGenerator( + scripted=[ + NpcGeneratedSpeech( + text="えっと", + intent="speak", + used_logic_ids=(), + estimated_duration_ms=200, + ) + ] + ) + tts = FakeTtsService(scripted=[TtsResult(audio=b"a", duration_ms=200)]) + playback = FakeVoicePlayback(started_at_ms=10, finished_at_ms=210) + client, _captured = _make_client(generator=gen, tts=tts, playback=playback) + + async def boom(_text: str) -> None: + raise RuntimeError("vc chat unreachable") + + client.on_post_chat = boom + + await client.process_message(_make_logic().model_dump_json()) + await client.process_message(_make_request().model_dump_json()) + # Must NOT raise; playback proceeds. + await client.process_message( + PlaybackAuthorized( + ts=2, + trace_id="t", + request_id="sr1", + npc_id="npc_p2", + speech_event_id="ev1", + playback_deadline_ms=10_000, + ).model_dump_json() + ) + assert playback.plays == [(b"a", 48_000)] + + +async def test_tts_finished_includes_correct_duration() -> None: + gen = FakeNpcGenerator( + scripted=[ + NpcGeneratedSpeech( + text="ね", intent="speak", used_logic_ids=(), estimated_duration_ms=10 + ) + ] + ) + tts = FakeTtsService(scripted=[TtsResult(audio=b"a", duration_ms=750)]) + playback = FakeVoicePlayback(started_at_ms=1, finished_at_ms=2) + client, captured = _make_client(generator=gen, tts=tts, playback=playback) + await client.process_message(_make_logic().model_dump_json()) + await client.process_message(_make_request().model_dump_json()) + await client.process_message( + PlaybackAuthorized( + ts=2, + trace_id="t", + request_id="sr1", + npc_id="npc_p2", + speech_event_id="ev1", + playback_deadline_ms=10_000, + ).model_dump_json() + ) + finished = next( + TtsFinished.model_validate_json(m) + for m in captured + if json.loads(m).get("type") == "tts_finished" + ) + assert finished.tts_duration_ms == 750 diff --git a/tests/test_observability.py b/tests/test_observability.py new file mode 100644 index 0000000..d52fd85 --- /dev/null +++ b/tests/test_observability.py @@ -0,0 +1,319 @@ +"""Bundle 9: cross-component observability. + +Verifies: +- `emit_event` produces a structured payload with the canonical envelope. +- `CapturingHandler` records and filters events by name + fields. +- `emit_phase_summary` sums human / NPC speech events from `speech_events` + and reactive_voice telemetry from `npc_speak_*` / `npc_playback_events`. +- The summary mode field reflects the game's discussion_mode (rounds vs + reactive_voice). +""" + +from __future__ import annotations + +import logging + +from wolfbot.domain.discussion import ( + SpeechSource, + make_phase_id, +) +from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.models import Game, Seat +from wolfbot.persistence.sqlite_repo import SqliteRepo +from wolfbot.services.discussion_phase_summary import emit_phase_summary +from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, + make_human_text_event, + make_npc_generated_event, + make_phase_baseline, +) +from wolfbot.services.structured_logging import ( + COMPONENT_MASTER, + CapturingHandler, + build_discussion_phase_summary, + emit_event, +) + + +async def _seed_phase_with_speech( + repo: SqliteRepo, *, mode: str = "rounds" +) -> tuple[Game, str, DiscussionService]: + g = Game( + id="ob1", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode=mode, + ) + await repo.create_game(g) + await repo.insert_seat( + g.id, + Seat(seat_no=1, display_name="Alice", discord_user_id="u1", is_llm=False, persona_key=None), + ) + await repo.insert_seat( + g.id, + Seat( + seat_no=2, display_name="セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + ) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.SEER) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2], + created_at_ms=1, + ) + ) + await store.insert( + make_human_text_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="人間です", + created_at_ms=2, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, + text="占いCO", + created_at_ms=3, + ) + ) + return g, phase_id, discussion + + +def test_emit_event_records_envelope_fields() -> None: + handler = CapturingHandler() + test_logger = logging.getLogger("wolfbot.observability.unit-test") + test_logger.setLevel(logging.DEBUG) + test_logger.addHandler(handler) + try: + emit_event( + test_logger, + component="master", + event="speak_request_suppressed", + game_id="g1", + phase_id="ph", + failure_reason="human_currently_speaking", + ) + events = list(handler.events_with(name="speak_request_suppressed", game_id="g1")) + assert len(events) == 1 + ev = events[0] + assert ev.payload["component"] == "master" + assert ev.payload["failure_reason"] == "human_currently_speaking" + assert "ts" in ev.payload + finally: + test_logger.removeHandler(handler) + + +def test_capturing_handler_filters_by_name_and_fields() -> None: + handler = CapturingHandler() + test_logger = logging.getLogger("wolfbot.observability.filter-test") + test_logger.setLevel(logging.DEBUG) + test_logger.addHandler(handler) + try: + emit_event(test_logger, component="master", event="a", game_id="g1") + emit_event(test_logger, component="master", event="a", game_id="g2") + emit_event(test_logger, component="master", event="b", game_id="g1") + a_g1 = list(handler.events_with(name="a", game_id="g1")) + assert len(a_g1) == 1 + all_a = list(handler.events_with(name="a")) + assert len(all_a) == 2 + finally: + test_logger.removeHandler(handler) + + +def test_build_discussion_phase_summary_includes_required_fields() -> None: + payload = build_discussion_phase_summary( + game_id="g1", + phase_id="ph", + mode="rounds", + speech_events_total=4, + human_speech_events=1, + npc_speech_events=3, + ) + for key in ( + "game_id", + "phase_id", + "mode", + "speech_events_total", + "human_speech_events", + "npc_speech_events", + "stt_success", + "stt_failed", + "logic_packets_built", + "speak_requests_sent", + "speak_results_accepted", + "speak_results_rejected", + "playback_authorized", + "tts_success", + "tts_failed", + "playback_success", + "playback_failed", + "stale_dropped", + ): + assert key in payload + + +async def test_emit_phase_summary_counts_speech_events_in_rounds_mode( + repo: SqliteRepo, +) -> None: + handler = CapturingHandler() + wolfbot_logger = logging.getLogger("wolfbot") + prior_level = wolfbot_logger.level + wolfbot_logger.setLevel(logging.DEBUG) + wolfbot_logger.addHandler(handler) + try: + g, phase_id, discussion = await _seed_phase_with_speech(repo, mode="rounds") + counts = await emit_phase_summary( + repo=repo, + discussion=discussion, + game_id=g.id, + phase_id=phase_id, + mode="rounds", + ) + assert counts["speech_events_total"] == 2 + assert counts["human_speech_events"] == 1 + assert counts["npc_speech_events"] == 1 + # The reactive_voice telemetry is zero in rounds mode (no audit rows). + assert counts["speak_requests_sent"] == 0 + assert counts["playback_authorized"] == 0 + events = list(handler.events_with(name="discussion_phase_summary", game_id=g.id)) + assert len(events) == 1 + assert events[0].payload["mode"] == "rounds" + assert events[0].payload["component"] == COMPONENT_MASTER + finally: + wolfbot_logger.removeHandler(handler) + wolfbot_logger.setLevel(prior_level) + + +async def test_emit_phase_summary_includes_audit_rows_in_reactive_voice_mode( + repo: SqliteRepo, +) -> None: + handler = CapturingHandler() + wolfbot_logger = logging.getLogger("wolfbot") + prior_level = wolfbot_logger.level + wolfbot_logger.setLevel(logging.DEBUG) + wolfbot_logger.addHandler(handler) + try: + g, phase_id, discussion = await _seed_phase_with_speech(repo, mode="reactive_voice") + # Seed two requests: one accepted+played, one rejected. + await repo.insert_npc_speak_request( + request_id="r1", + game_id=g.id, + phase_id=phase_id, + npc_id="n2", + seat_no=2, + logic_packet_id="lp1", + suggested_intent="speak", + max_chars=80, + max_duration_ms=8000, + priority=0, + expires_at_ms=10_000, + created_at_ms=1, + ) + await repo.insert_npc_speak_result( + request_id="r1", + game_id=g.id, + phase_id=phase_id, + npc_id="n2", + status="accepted", + text="占いCO", + used_logic_ids=["c1"], + intent="co", + estimated_duration_ms=1500, + failure_reason=None, + received_at_ms=2, + ) + await repo.open_npc_playback( + request_id="r1", + game_id=g.id, + phase_id=phase_id, + npc_id="n2", + speech_event_id="ev1", + authorized_at_ms=2, + playback_deadline_ms=10_000, + ) + await repo.update_npc_playback_tts( + "r1", outcome="success", duration_ms=400, failure_reason=None + ) + await repo.close_npc_playback( + "r1", finished_at_ms=3, outcome="succeeded", failure_reason=None + ) + await repo.insert_npc_speak_request( + request_id="r2", + game_id=g.id, + phase_id=phase_id, + npc_id="n2", + seat_no=2, + logic_packet_id="lp2", + suggested_intent="speak", + max_chars=80, + max_duration_ms=8000, + priority=0, + expires_at_ms=10_000, + created_at_ms=4, + ) + await repo.insert_npc_speak_result( + request_id="r2", + game_id=g.id, + phase_id=phase_id, + npc_id="n2", + status="rejected", + text=None, + used_logic_ids=None, + intent=None, + estimated_duration_ms=None, + failure_reason="stale_phase", + received_at_ms=5, + ) + + counts = await emit_phase_summary( + repo=repo, + discussion=discussion, + game_id=g.id, + phase_id=phase_id, + mode="reactive_voice", + ) + assert counts["speak_requests_sent"] == 2 + assert counts["speak_results_accepted"] == 1 + assert counts["speak_results_rejected"] == 1 + assert counts["tts_success"] == 1 + assert counts["playback_success"] == 1 + assert counts["stale_dropped"] == 1 + events = list( + handler.events_with( + name="discussion_phase_summary", game_id=g.id, mode="reactive_voice" + ) + ) + assert len(events) == 1 + finally: + wolfbot_logger.removeHandler(handler) + wolfbot_logger.setLevel(prior_level) + + +def test_speech_source_filter_excludes_phase_baseline() -> None: + """The summary's `speech_events_total` must NOT count phase_baseline.""" + # Sanity check via SpeechSource. + assert SpeechSource.PHASE_BASELINE != SpeechSource.TEXT + assert SpeechSource.PHASE_BASELINE != SpeechSource.VOICE_STT + assert SpeechSource.PHASE_BASELINE != SpeechSource.NPC_GENERATED diff --git a/tests/test_permission_manager.py b/tests/test_permission_manager.py index 984d01b..9d476ea 100644 --- a/tests/test_permission_manager.py +++ b/tests/test_permission_manager.py @@ -113,9 +113,11 @@ def _setup_world() -> tuple[FakeBot, FakeGuild, dict[str, FakeChannel], Game]: main = FakeChannel(id=1001, guild=guild) heaven = FakeChannel(id=1002, guild=guild) wolves = FakeChannel(id=1003, guild=guild) + vc = FakeChannel(id=9999, guild=guild) guild._channels[1001] = main guild._channels[1002] = heaven guild._channels[1003] = wolves + guild._channels[9999] = vc bot = FakeBot(_guilds={1: guild}) game = Game( id="g", @@ -129,7 +131,7 @@ def _setup_world() -> tuple[FakeBot, FakeGuild, dict[str, FakeChannel], Game]: wolves_channel_id="1003", created_at=0, ) - return bot, guild, {"main": main, "heaven": heaven, "wolves": wolves}, game + return bot, guild, {"main": main, "heaven": heaven, "wolves": wolves, "vc": vc}, game async def test_apply_day_grants_alive_send_dead_read_only() -> None: @@ -228,6 +230,85 @@ async def test_kill_flips_main_text_and_grants_heaven() -> None: assert 100 + 5 not in dict(ch["wolves"]._perm_calls) +async def test_kill_revokes_vc_speak_permission() -> None: + """Dead players must not be able to speak in VC. We use channel + overrides (`speak=False, use_voice_activation=False`) rather than + server-mute so the override is scoped to this game's VC.""" + bot, _, ch, game = _setup_world() + pm = PermissionManager(bot=bot) + seats = _nine_seats() + + await pm.kill(game, seats, seat_no=5, was_wolf=False) + + vc_calls = dict(ch["vc"]._perm_calls) + assert vc_calls[100 + 5]["speak"] is False + assert vc_calls[100 + 5]["use_voice_activation"] is False + + +async def test_apply_reconciles_vc_speak_for_dead_seats() -> None: + """`apply` is the recovery path. After Master restart, dead seats + must still be VC-muted via channel overrides.""" + bot, _, ch, game = _setup_world() + pm = PermissionManager(bot=bot) + seats = _nine_seats() + alive_flags = [True, True, True, False, True, True, True, True, True] + players = _players(ROLES, alive=alive_flags) + + await pm.apply(game, seats, players) + + vc_calls = dict(ch["vc"]._perm_calls) + # Dead seat 4 → muted + assert vc_calls[100 + 4]["speak"] is False + # Alive seats during DAY_DISCUSSION → speak permitted + assert vc_calls[100 + 1]["speak"] is True + + +async def test_apply_mutes_alive_humans_outside_discussion() -> None: + """Humans must only speak during DAY_DISCUSSION. Vote / runoff / + night phases keep everyone muted so chatter doesn't bleed across + phase boundaries.""" + for muted_phase in ( + Phase.DAY_VOTE, + Phase.DAY_RUNOFF, + Phase.DAY_RUNOFF_SPEECH, + Phase.NIGHT, + ): + bot, _, ch, game = _setup_world() + game.phase = muted_phase + pm = PermissionManager(bot=bot) + seats = _nine_seats() + players = _players(ROLES) # all alive + + await pm.apply(game, seats, players) + + vc_calls = dict(ch["vc"]._perm_calls) + # Every alive human seat is muted in non-discussion phases. + for seat_no in range(1, 10): + assert vc_calls[100 + seat_no]["speak"] is False, ( + f"phase={muted_phase} seat={seat_no} should be muted" + ) + assert vc_calls[100 + seat_no]["use_voice_activation"] is False + + +async def test_on_game_end_clears_vc_overrides() -> None: + """VC is a persistent channel; per-member overrides must be cleared + on game end so the next game starts from a clean baseline.""" + bot, _, ch, game = _setup_world() + pm = PermissionManager(bot=bot) + seats = _nine_seats() + + # Pre-seed an override on the VC for seat 1 to make sure clear runs. + ch["vc"]._overwrites[101] = discord.PermissionOverwrite(speak=False) + + await pm.on_game_end(game, seats) + + # `_clear_perms` calls set_permissions with overwrite=None. + cleared = [ + uid for uid, kw in ch["vc"]._perm_calls if kw.get("overwrite") is None + ] + assert 101 in cleared + + async def test_kill_wolf_revokes_wolves_chat_access() -> None: bot, _, ch, game = _setup_world() pm = PermissionManager(bot=bot) diff --git a/tests/test_phase_durations.py b/tests/test_phase_durations.py new file mode 100644 index 0000000..6b4ce99 --- /dev/null +++ b/tests/test_phase_durations.py @@ -0,0 +1,191 @@ +"""PhaseDurations singleton + env loader tests. + +Covers: + +- Default values match the historical hardcoded constants (so existing + test suite assertions like ``1000 + NIGHT_DURATION == 1000 + 90`` + remain valid). +- ``with_factor`` scales correctly and clamps to a 1-second floor for + fast-test mode. +- ``from_env`` honors both the global factor and per-phase absolute + overrides, with the per-phase override winning when both are set. +- ``set_phase_durations`` flips the singleton process-wide; the + module ``__getattr__`` aliases on ``state_machine`` reflect the new + value (the dynamic-attribute path the future UI will rely on). +- Each test resets the singleton on teardown so leakage between tests + is impossible. +""" + +from __future__ import annotations + +import pytest + +from wolfbot.domain import state_machine +from wolfbot.domain.durations import ( + PhaseDurations, + current_phase_durations, + reset_phase_durations_to_defaults, + set_phase_durations, +) +from wolfbot.domain.rules import day_discussion_duration + + +@pytest.fixture(autouse=True) +def _restore_singleton() -> None: + yield + reset_phase_durations_to_defaults() + + +def test_defaults_match_historical_hardcoded_values() -> None: + d = PhaseDurations() + assert d.vote == 60 + assert d.runoff == 60 + assert d.night == 90 + assert d.day_discussion_grace == 30 + assert d.runoff_speech_grace == 30 + assert d.discussion_day1 == 300 + assert d.discussion_day2 == 240 + assert d.discussion_day3plus == 180 + + +def test_discussion_for_day_matches_historical_function() -> None: + d = PhaseDurations() + assert d.discussion_for_day(1) == 300 + assert d.discussion_for_day(2) == 240 + assert d.discussion_for_day(3) == 180 + assert d.discussion_for_day(10) == 180 + + +def test_discussion_for_day_rejects_non_positive_day() -> None: + d = PhaseDurations() + with pytest.raises(ValueError): + d.discussion_for_day(0) + with pytest.raises(ValueError): + d.discussion_for_day(-1) + + +def test_with_factor_scales_all_fields_proportionally() -> None: + d = PhaseDurations().with_factor(0.5) + assert d.vote == 30 + assert d.night == 45 + assert d.discussion_day1 == 150 + + +def test_with_factor_clamps_to_one_second_floor() -> None: + """Tiny factors mustn't produce a 0-second deadline; the engine's + deadline-watcher would advance immediately, sometimes before the + LLM submission loop has even fired.""" + d = PhaseDurations().with_factor(0.001) + assert d.vote >= 1 + assert d.night >= 1 + assert d.discussion_day1 >= 1 + + +def test_with_factor_rejects_non_positive() -> None: + with pytest.raises(ValueError): + PhaseDurations().with_factor(0) + with pytest.raises(ValueError): + PhaseDurations().with_factor(-1) + + +def test_from_env_with_no_overrides_returns_defaults() -> None: + d = PhaseDurations.from_env(env={}) + assert d == PhaseDurations() + + +def test_from_env_factor_scales_all_phases() -> None: + d = PhaseDurations.from_env(env={"WOLFBOT_PHASE_DURATION_FACTOR": "0.1"}) + # 60 * 0.1 = 6 + assert d.vote == 6 + # 90 * 0.1 = 9 + assert d.night == 9 + + +def test_from_env_per_phase_override_beats_factor() -> None: + d = PhaseDurations.from_env( + env={ + "WOLFBOT_PHASE_DURATION_FACTOR": "0.1", + "WOLFBOT_VOTE_DURATION": "42", + } + ) + # vote was overridden to 42 absolute, but night still gets factor (90*0.1=9). + assert d.vote == 42 + assert d.night == 9 + + +def test_from_env_rejects_unparseable_factor() -> None: + with pytest.raises(ValueError, match="WOLFBOT_PHASE_DURATION_FACTOR"): + PhaseDurations.from_env(env={"WOLFBOT_PHASE_DURATION_FACTOR": "fast"}) + + +def test_from_env_rejects_unparseable_override() -> None: + with pytest.raises(ValueError, match="WOLFBOT_NIGHT_DURATION"): + PhaseDurations.from_env(env={"WOLFBOT_NIGHT_DURATION": "long"}) + + +def test_from_env_rejects_non_positive_override() -> None: + with pytest.raises(ValueError, match="must be > 0"): + PhaseDurations.from_env(env={"WOLFBOT_VOTE_DURATION": "0"}) + with pytest.raises(ValueError, match="must be > 0"): + PhaseDurations.from_env(env={"WOLFBOT_VOTE_DURATION": "-5"}) + + +def test_from_env_ignores_blank_values() -> None: + """Empty strings in .env files (e.g. ``WOLFBOT_VOTE_DURATION=``) must + not be treated as zero — that's the standard "I left this blank, use + the default" pattern in this repo's env files.""" + d = PhaseDurations.from_env(env={"WOLFBOT_VOTE_DURATION": " "}) + assert d.vote == 60 + + +def test_set_phase_durations_swaps_singleton_globally() -> None: + set_phase_durations(PhaseDurations(vote=5)) + assert current_phase_durations().vote == 5 + assert current_phase_durations().night == 90 # unchanged + + +def test_state_machine_lazy_alias_reflects_singleton() -> None: + """``state_machine.NIGHT_DURATION`` must return the current + singleton value, not a snapshot from import time. This is the + dynamic-attribute path a future UI command will rely on for + third-party callers that already imported the module.""" + assert state_machine.NIGHT_DURATION == 90 + set_phase_durations(PhaseDurations(night=7)) + assert state_machine.NIGHT_DURATION == 7 + + +def test_state_machine_unknown_attribute_raises() -> None: + with pytest.raises(AttributeError): + _ = state_machine.NOT_A_REAL_DURATION # type: ignore[attr-defined] + + +def test_day_discussion_duration_function_uses_singleton() -> None: + assert day_discussion_duration(1) == 300 + set_phase_durations(PhaseDurations(discussion_day1=11)) + assert day_discussion_duration(1) == 11 + # day 2 unchanged + assert day_discussion_duration(2) == 240 + + +def test_master_settings_apply_phase_durations_loads_from_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The Settings boot hook must populate the singleton end-to-end.""" + from pydantic import SecretStr + + from wolfbot.config import MasterSettings + + monkeypatch.setenv("WOLFBOT_PHASE_DURATION_FACTOR", "0.5") + monkeypatch.setenv("WOLFBOT_NIGHT_DURATION", "13") + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + DISCORD_TOKEN=SecretStr("token"), + DISCORD_GUILD_ID=1, + MAIN_TEXT_CHANNEL_ID=2, + MAIN_VOICE_CHANNEL_ID=3, + GAMEPLAY_LLM_PROVIDER="mock", + ) + s.apply_phase_durations() + d = current_phase_durations() + assert d.vote == 30 # 60 * 0.5 + assert d.night == 13 # absolute override diff --git a/tests/test_public_discussion_state.py b/tests/test_public_discussion_state.py new file mode 100644 index 0000000..9af0382 --- /dev/null +++ b/tests/test_public_discussion_state.py @@ -0,0 +1,334 @@ +"""Bundle 2 tests — PublicDiscussionState fold semantics. + +These tests treat `apply_speech_event` and `rebuild_public_state_from_events` +as a pure pair: a `reduce` over the event sequence must produce the same +state as a single `rebuild` call. They also verify the deterministic rules +called out in the spec delta: + + * `alive_seat_nos` comes from the sentinel — dead seats are excluded by + construction (they were never in the baseline). + * `silent_seats` = `alive_seat_nos` minus seats with ≥1 non-sentinel event. + * `co_claims` are recorded in arrival order, deduped per (seat, role). + * `recent_speech_event_ids` keeps at most 10 ids in arrival order. +""" + +from __future__ import annotations + +from functools import reduce + +from wolfbot.domain.discussion import SpeechSource +from wolfbot.domain.enums import Phase +from wolfbot.services.discussion_service import ( + apply_speech_event, + make_human_text_event, + make_npc_generated_event, + make_phase_baseline, + make_phase_id, + rebuild_public_state_from_events, +) + + +def _seed(alive: list[int], events_payload: list[tuple[int, str]], game_id: str = "g1") -> list: + phase_id = make_phase_id(game_id, 1, Phase.DAY_DISCUSSION) + sentinel = make_phase_baseline( + game_id=game_id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=alive, + created_at_ms=0, + ) + seq = [sentinel] + for i, (seat, text) in enumerate(events_payload, start=1): + seq.append( + make_human_text_event( + game_id=game_id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=seat, + text=text, + created_at_ms=i, + ) + ) + return seq + + +def test_apply_then_rebuild_agree() -> None: + events = _seed( + alive=[1, 2, 3, 4, 5, 6, 7, 8, 9], + events_payload=[ + (1, "占いCOします"), + (3, "情報整理しましょう"), + (3, "P1のCOは普通"), + (7, "霊媒COです"), + ], + ) + + folded = reduce(apply_speech_event, events, None) + rebuilt = rebuild_public_state_from_events(events) + + assert folded is not None and rebuilt is not None + assert folded.alive_seat_nos == rebuilt.alive_seat_nos + assert folded.co_claims == rebuilt.co_claims + assert folded.silent_seats == rebuilt.silent_seats + assert folded.recent_speech_event_ids == rebuilt.recent_speech_event_ids + assert folded.speech_counts == rebuilt.speech_counts + + +def test_silent_seats_excludes_dead_seats() -> None: + # alive baseline omits seat 5 (dead); so even if "no event for seat 5", it is NOT silent + events = _seed( + alive=[1, 2, 3, 4, 6, 7, 8, 9], + events_payload=[(1, "おはよう"), (2, "はい"), (4, "黙ってる人気になる")], + ) + state = rebuild_public_state_from_events(events) + assert state is not None + assert 5 not in state.alive_seat_nos + assert 5 not in state.silent_seats + assert state.silent_seats == frozenset({3, 6, 7, 8, 9}) + + +def test_co_claims_dedup_and_preserve_order() -> None: + events = _seed( + alive=list(range(1, 10)), + events_payload=[ + (4, "占いCO 結果は1白"), + (4, "占いCO(再)"), + (7, "霊媒CO"), + (7, "霊媒CO 続報"), + (1, "占いCO 対抗"), + ], + ) + state = rebuild_public_state_from_events(events) + assert state is not None + seq = [(c.seat, c.role_claim) for c in state.co_claims] + assert seq == [(4, "seer"), (7, "medium"), (1, "seer")] + + +def test_recent_speech_event_ids_caps_at_10() -> None: + events = _seed( + alive=list(range(1, 10)), + events_payload=[(((i % 9) + 1), f"発言{i}") for i in range(1, 16)], + ) + state = rebuild_public_state_from_events(events) + assert state is not None + assert len(state.recent_speech_event_ids) == 10 + # the cap keeps the LAST 10 by arrival order + expected_last_10 = [e.event_id for e in events if e.source != SpeechSource.PHASE_BASELINE][-10:] + assert list(state.recent_speech_event_ids) == expected_last_10 + + +def test_apply_with_only_npc_events_still_silences_alive_baseline() -> None: + # Even without any human events, an alive seat with zero non-sentinel events stays silent. + pid = make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + sentinel = make_phase_baseline( + game_id="g1", + phase_id=pid, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=0, + ) + npc = make_npc_generated_event( + game_id="g1", + phase_id=pid, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="hi", + created_at_ms=1, + ) + + state = rebuild_public_state_from_events([sentinel, npc]) + assert state is not None + assert state.alive_seat_nos == frozenset({1, 2, 3}) + assert state.silent_seats == frozenset({2, 3}) + + +def test_apply_speech_event_returns_none_without_prior_baseline() -> None: + # apply_speech_event(None, non_sentinel) must return None — caller hasn't seeded a state yet + pid = make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + e = make_human_text_event( + game_id="g1", + phase_id=pid, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="hi", + created_at_ms=1, + ) + assert apply_speech_event(None, e) is None + + +def test_rebuild_is_independent_of_caller_supplied_seed() -> None: + # The rebuild path is documented as needing only `events`; calling it twice + # on the same input should yield identical states (no shared mutable seed). + events = _seed(alive=[1, 2, 3], events_payload=[(1, "占いCO"), (2, "霊媒CO")]) + s1 = rebuild_public_state_from_events(events) + s2 = rebuild_public_state_from_events(events) + assert s1 is not None and s2 is not None + assert s1 == s2 + + +def test_co_claim_picks_up_structured_co_declaration_field() -> None: + """Natural-language utterances ('実は私、占い師なんだ') don't contain + the legacy '占いCO' marker, but the structured `co_declaration` field + is authoritative — `_resolve_co_role` prefers it over substring scan + so NPC speech with no jargon still produces a CoClaim. + """ + pid = make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + sentinel = make_phase_baseline( + game_id="g1", + phase_id=pid, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=0, + ) + natural_co = make_npc_generated_event( + game_id="g1", + phase_id=pid, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, + text="実は私、占い師なんだ。昨夜は3番を見たけど、白だった。", + co_declaration="seer", + created_at_ms=1, + ) + state = rebuild_public_state_from_events([sentinel, natural_co]) + assert state is not None + assert [(c.seat, c.role_claim) for c in state.co_claims] == [(2, "seer")] + + +def test_co_claim_topical_mention_without_structured_field_is_ignored() -> None: + """A non-CO event that mentions 'CO' textually but has co_declaration=None + must NOT produce a CoClaim — that's the whole point of the structured + field replacing fuzzy substring matching for NPC text. Substring + fallback is reserved for legacy events without the field set.""" + pid = make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + sentinel = make_phase_baseline( + game_id="g1", + phase_id=pid, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=0, + ) + topical = make_npc_generated_event( + game_id="g1", + phase_id=pid, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, + text="他に占い師として名乗る人はいる?", + co_declaration=None, + created_at_ms=1, + ) + state = rebuild_public_state_from_events([sentinel, topical]) + assert state is not None + assert state.co_claims == () + + +def test_co_claim_legacy_substring_fallback_still_works_when_field_absent() -> None: + """Backwards compat: old events written before the structured field are + still parsed via `_CO_MARKERS` substring scan when co_declaration=None. + """ + events = _seed(alive=[1, 2, 3], events_payload=[(1, "占いCOします")]) + state = rebuild_public_state_from_events(events) + assert state is not None + assert [(c.seat, c.role_claim) for c in state.co_claims] == [(1, "seer")] + + +def test_speech_counts_increment_per_speaker() -> None: + """`speech_counts` records how many non-baseline events each seat has + produced this phase. The arbiter's pick logic reads this so a + talkative NPC drops below quieter ones once everyone has spoken at + least once. + """ + events = _seed( + alive=[1, 2, 3], + events_payload=[ + (1, "ラキオ1"), + (2, "セツ1"), + (1, "ラキオ2"), + (1, "ラキオ3"), + ], + ) + state = rebuild_public_state_from_events(events) + assert state is not None + assert state.speech_counts == {1: 3, 2: 1} + # silent_seats stays binary — seat 3 never spoke. The new field is + # additive and complements (not replaces) the silent baseline. + assert state.silent_seats == frozenset({3}) + + +def test_speech_counts_excludes_baseline_sentinel() -> None: + """The `phase_baseline` sentinel has speaker_seat=None, so it must + not bump any seat's count. + """ + events = _seed(alive=[1, 2, 3], events_payload=[]) + state = rebuild_public_state_from_events(events) + assert state is not None + assert state.speech_counts == {} + + +def test_multi_addressed_seats_populate_set_and_consume_per_responder() -> None: + """An NPC addressing multiple seats puts ALL of them in + ``last_addressed_seats``. When one of them replies, only that + addressee is removed; the others remain prioritised so the next + dispatch still favours an unanswered addressee. + """ + pid = make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + sentinel = make_phase_baseline( + game_id="g1", phase_id=pid, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3, 4], created_at_ms=0, + ) + multi_addr = make_npc_generated_event( + game_id="g1", phase_id=pid, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="セツとジナはどう?", + addressed_seat_nos=(2, 3), + created_at_ms=10, + ) + state_after_addr = rebuild_public_state_from_events([sentinel, multi_addr]) + assert state_after_addr is not None + assert state_after_addr.last_addressed_seats == frozenset({2, 3}) + + # Setsu (seat 2) replies. Gina (seat 3) should still be in the set. + setsu_reply = make_npc_generated_event( + game_id="g1", phase_id=pid, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, text="うーん、少し迷うところがあります。", + created_at_ms=20, + ) + state_after_reply = rebuild_public_state_from_events( + [sentinel, multi_addr, setsu_reply] + ) + assert state_after_reply is not None + assert state_after_reply.last_addressed_seats == frozenset({3}) + + +def test_multi_address_back_compat_singular_field_promoted() -> None: + """A SpeechEvent that only sets the legacy ``addressed_seat_no`` (no + list field) must still appear in ``last_addressed_seats`` so older + fixtures and pre-multi-address persisted rows keep working. + """ + pid = make_phase_id("g1", 1, Phase.DAY_DISCUSSION) + sentinel = make_phase_baseline( + game_id="g1", phase_id=pid, day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], created_at_ms=0, + ) + legacy_event = make_npc_generated_event( + game_id="g1", phase_id=pid, day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, text="セツさん、どう?", + addressed_seat_no=2, # singular only + created_at_ms=10, + ) + state = rebuild_public_state_from_events([sentinel, legacy_event]) + assert state is not None + assert state.last_addressed_seats == frozenset({2}) + assert state.last_addressed_seat == 2 diff --git a/tests/test_reactive_voice_master.py b/tests/test_reactive_voice_master.py new file mode 100644 index 0000000..b9d54b5 --- /dev/null +++ b/tests/test_reactive_voice_master.py @@ -0,0 +1,5067 @@ +"""Bundle 5: master arbitration + recovery — protocol-level coverage. + +Verifies the SpeakArbiter logic without standing up a real Master. Each +test exercises one branch of the arbitration flow: + +- Successful dispatch + accepted SpeakResult → SpeechEvent + PlaybackAuthorized. +- Stale phase / expired request / over-length text → rejection paths. +- Serial-speech gate blocks while a playback is open and while a human is + speaking. +- Offline NPC is skipped without sending a SpeakRequest. +- Recovery sweep closes in-flight rows with master_restart. +- Master restart rebuilds PublicDiscussionState from speech_events. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from wolfbot.domain.discussion import ( + PublicDiscussionState, + SpeechSource, + make_phase_id, +) +from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.models import Game, Seat +from wolfbot.domain.ws_messages import ( + PlaybackAuthorized, + PlaybackFinished, + PlaybackRejected, + SpeakResult, +) +from wolfbot.master.arbiter.logic_service import build_logic_packet +from wolfbot.master.arbiter.speak_arbiter import SpeakArbiter, SpeakArbiterConfig +from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry +from wolfbot.persistence.sqlite_repo import SqliteRepo +from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, + make_phase_baseline, +) + + +def _captured_send(buf: list[str]) -> Callable[[str], Awaitable[None]]: + async def send(msg: str) -> None: + buf.append(msg) + + return send + + +async def _seed_game(repo: SqliteRepo) -> tuple[Game, list[Seat]]: + g = Game( + id="rv1", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="Alice", discord_user_id="u1", is_llm=False, persona_key=None), + Seat( + seat_no=2, + display_name="セツ", + discord_user_id=None, + is_llm=True, + persona_key="setsu", + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.SEER) + return g, seats + + +def _seed_state(game_id: str, day: int = 1) -> PublicDiscussionState: + phase_id = make_phase_id(game_id, day, Phase.DAY_DISCUSSION) + return PublicDiscussionState( + game_id=game_id, + phase_id=phase_id, + day=day, + alive_seat_nos=frozenset({1, 2}), + silent_seats=frozenset({1, 2}), + ) + + +async def test_successful_dispatch_emits_logic_packet_and_speak_request( + repo: SqliteRepo, +) -> None: + game, _seats = await _seed_game(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + persona_key="setsu", + ) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 1500, + ) + state = _seed_state(game.id) + + request, reason = await arb.dispatch_request( + state=state, + candidate_npc_id="npc_p2", + seat_no=2, + game_id=game.id, + ) + assert reason is None and request is not None + # Two messages on the back-channel: LogicPacket then SpeakRequest. + assert len(npc_buf) == 2 + assert '"type":"logic_packet"' in npc_buf[0] + assert '"type":"speak_request"' in npc_buf[1] + + +async def test_dispatch_records_request_in_audit_table(repo: SqliteRepo) -> None: + game, _ = await _seed_game(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + persona_key="setsu", + ) + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter(repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500) + state = _seed_state(game.id) + request, _ = await arb.dispatch_request( + state=state, candidate_npc_id="npc_p2", seat_no=2, game_id=game.id + ) + assert request is not None + rows = await repo.load_open_npc_speak_requests(game.id) + assert any(r["request_id"] == request.request_id for r in rows) + + +async def test_offline_candidate_is_skipped(repo: SqliteRepo) -> None: + game, _ = await _seed_game(repo) + registry = InMemoryNpcRegistry() + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter(repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1) + state = _seed_state(game.id) + req, reason = await arb.dispatch_request( + state=state, candidate_npc_id="never_registered", seat_no=2, game_id=game.id + ) + assert req is None and reason == "npc_offline" + + +async def test_human_speaking_blocks_dispatch(repo: SqliteRepo) -> None: + game, _ = await _seed_game(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + persona_key="setsu", + ) + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter(repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500) + arb.mark_human_speaking("seg-1") + req, reason = await arb.dispatch_request( + state=_seed_state(game.id), candidate_npc_id="npc_p2", seat_no=2, game_id=game.id + ) + assert req is None and reason == "human_currently_speaking" + arb.clear_human_speaking("seg-1") + req, reason = await arb.dispatch_request( + state=_seed_state(game.id), candidate_npc_id="npc_p2", seat_no=2, game_id=game.id + ) + assert req is not None and reason is None + + +async def test_dispatch_attaches_context_to_logic_packet_and_speak_request( + repo: SqliteRepo, +) -> None: + """SpeakArbiter loads recent speeches, alive/dead lists, and the seat's + role+strategy and forwards them on the WS payload so the NPC's prompt + builder has the same context that rounds-mode build_user_context does. + """ + from wolfbot.domain.discussion import ( + SpeakerKind, + SpeechEvent, + make_phase_id, + ) + from wolfbot.domain.ws_messages import LogicPacket, SpeakRequest + from wolfbot.services.discussion_service import ( + new_event_id, + ) + from wolfbot.services.discussion_service import ( + now_ms as discussion_now_ms, + ) + + game, _seats = await _seed_game(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + persona_key="setsu", + ) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + await discussion.record( + make_phase_baseline( + game_id=game.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2], + ) + ) + await discussion.record( + SpeechEvent( + event_id=new_event_id(), + game_id=game.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + source=SpeechSource.TEXT, + speaker_kind=SpeakerKind.HUMAN, + speaker_seat=1, + text="占いの結果が気になる", + created_at_ms=discussion_now_ms(), + ) + ) + + arb = SpeakArbiter(repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500) + state = _seed_state(game.id) + request, reason = await arb.dispatch_request( + state=state, candidate_npc_id="npc_p2", seat_no=2, game_id=game.id + ) + assert reason is None and request is not None + + packet = LogicPacket.model_validate_json(npc_buf[0]) + speak = SpeakRequest.model_validate_json(npc_buf[1]) + + # Recent speech surfaces with the speaker's display name attached. + assert len(packet.recent_speeches) == 1 + assert packet.recent_speeches[0].seat_no == 1 + assert packet.recent_speeches[0].display_name == "Alice" + assert packet.recent_speeches[0].source == "text" + assert "占いの結果が気になる" in packet.recent_speeches[0].text + + # Role + role_strategy of seat 2 (SEER per _seed_game) is forwarded. + assert speak.role == "SEER" + assert speak.role_strategy is not None and len(speak.role_strategy) > 0 + + # Both seats alive at this point — alive_seats has both, dead_seats empty. + assert speak.alive_seats == ((1, "Alice"), (2, "セツ")) + assert speak.dead_seats == () + + +async def test_speak_result_accepted_emits_authorized_and_writes_speech_event( + repo: SqliteRepo, +) -> None: + game, _ = await _seed_game(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + persona_key="setsu", + ) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + arb = SpeakArbiter(repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500) + state = _seed_state(game.id) + req, _ = await arb.dispatch_request( + state=state, candidate_npc_id="npc_p2", seat_no=2, game_id=game.id + ) + assert req is not None + npc_buf.clear() + + result = SpeakResult( + ts=1600, + trace_id="t", + request_id=req.request_id, + npc_id="npc_p2", + phase_id=req.phase_id, + status="accepted", + text="うーん怪しいかも", + ) + ok, reason = await arb.handle_speak_result( + result, current_phase_id=req.phase_id, day=1, phase=Phase.DAY_DISCUSSION + ) + assert ok and reason is None + assert any('"type":"playback_authorized"' in m for m in npc_buf) + auth = next( + PlaybackAuthorized.model_validate_json(m) for m in npc_buf if '"playback_authorized"' in m + ) + assert auth.npc_id == "npc_p2" + rows = await store.load_phase(game.id, req.phase_id) + assert any( + r.source == SpeechSource.NPC_GENERATED and r.text == "うーん怪しいかも" for r in rows + ) + + +async def test_speak_result_over_length_rejected(repo: SqliteRepo) -> None: + game, _ = await _seed_game(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + persona_key="setsu", + ) + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + config=SpeakArbiterConfig(max_chars_reactive=10), + now_ms=lambda: 1500, + ) + state = _seed_state(game.id) + req, _ = await arb.dispatch_request( + state=state, candidate_npc_id="npc_p2", seat_no=2, game_id=game.id + ) + assert req is not None + npc_buf.clear() + result = SpeakResult( + ts=1600, + trace_id="t", + request_id=req.request_id, + npc_id="npc_p2", + phase_id=req.phase_id, + status="accepted", + text="this exceeds the cap definitely", + ) + ok, reason = await arb.handle_speak_result( + result, current_phase_id=req.phase_id, day=1, phase=Phase.DAY_DISCUSSION + ) + assert not ok and reason == "utterance_too_long" + rejection = next( + PlaybackRejected.model_validate_json(m) for m in npc_buf if '"playback_rejected"' in m + ) + assert rejection.failure_reason == "utterance_too_long" + + +async def test_role_callout_pool_prioritizes_real_role_and_wolf_side( + repo: SqliteRepo, +) -> None: + """When `pending_role_callouts` has 'seer' and no seer has CO'd yet, + the next dispatch must come from the **callout pool**: real seer + + every wolf-side seat (人狼/狂人) that hasn't CO'd as any info role. + Pure villagers must NOT be picked while the pool has eligible + candidates — this is the user's day-1 priority spec. + """ + g = Game( + id="rv-callout-pool", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + # Seats: 1=ジョナス WEREWOLF, 2=ジナ VILLAGER, 3=ラキオ MADMAN, + # 4=コメット VILLAGER, 5=セツ SEER (real), 6=シゲミチ VILLAGER (caller). + seats = [ + Seat( + seat_no=1, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), + Seat( + seat_no=2, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), + Seat( + seat_no=3, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=4, + display_name="☄️コメット", + discord_user_id=None, + is_llm=True, + persona_key="comet", + ), + Seat( + seat_no=5, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=6, + display_name="👽シゲミチ", + discord_user_id=None, + is_llm=True, + persona_key="shigemichi", + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in ( + (1, Role.WEREWOLF), + (2, Role.VILLAGER), + (3, Role.MADMAN), + (4, Role.VILLAGER), + (5, Role.SEER), + (6, Role.VILLAGER), + ): + await repo.set_player_role(g.id, sn, role) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3, 4, 5, 6], + created_at_ms=1, + ) + ) + # シゲミチ asks "誰か占い師?" — the analyzer would tag this as + # role_callout="seer". Insert a synthetic NPC speech event with the + # callout to simulate that. + from wolfbot.services.discussion_service import make_npc_generated_event + + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=6, + text="誰か占い師、名乗ってくれ!", + role_callout="seer", + created_at_ms=10, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[int, list[str]] = {n: [] for n in (1, 2, 3, 4, 5, 6)} + persona_by_seat = { + 1: "jonas", + 2: "gina", + 3: "raqio", + 4: "comet", + 5: "setsu", + 6: "shigemichi", + } + for seat_no, persona in persona_by_seat.items(): + registry.register( + npc_id=f"npc_{persona}", + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[seat_no]), + now_ms=2000, + persona_key=persona, + ) + registry.assign(f"npc_{persona}", seat=seat_no, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 3000, + ) + + # Dispatch — must go to one of the pool members: + # real seer (5) + wolves+madman (1, 3). Pure villagers (2, 4, 6) must + # NOT be picked. + await arb.try_dispatch_next(g.id) + pool_seats = {1, 3, 5} + non_pool_seats = {2, 4, 6} + picked = next((s for s, b in bufs.items() if any('"speak_request"' in m for m in b)), None) + assert picked is not None, "must dispatch someone" + assert picked in pool_seats, ( + f"picked seat {picked} must be in callout pool {pool_seats} " + f"(real seer + wolves + madman, all uncpd). " + f"villagers {non_pool_seats} should NOT be picked while pool is non-empty." + ) + + _, reason = await _fetch_selection_reason(repo, g.id) + assert reason == "role_callout_pool" + + +async def test_first_seer_co_fires_counter_co_pool( + repo: SqliteRepo, +) -> None: + """When the real seer is the first to CO with no prior callout, + the next dispatch must still come from the pool — pool = uncpd + wolf-side (the real seer is excluded because they just CO'd). + + This is the user's 2026-05-01 spec: a single-CO situation should + open a guaranteed counter-CO opportunity window so a fake CO from + a wolf has a chance to surface, instead of the village-side + accidentally treating an unchallenged CO as gospel. + """ + g = Game( + id="rv-first-co", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat( + seat_no=1, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), + Seat( + seat_no=2, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), + Seat( + seat_no=3, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=4, + display_name="☄️コメット", + discord_user_id=None, + is_llm=True, + persona_key="comet", + ), + Seat( + seat_no=5, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=6, + display_name="👽シゲミチ", + discord_user_id=None, + is_llm=True, + persona_key="shigemichi", + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in ( + (1, Role.WEREWOLF), + (2, Role.VILLAGER), + (3, Role.MADMAN), + (4, Role.VILLAGER), + (5, Role.SEER), + (6, Role.VILLAGER), + ): + await repo.set_player_role(g.id, sn, role) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3, 4, 5, 6], + created_at_ms=1, + ) + ) + # Real seer (seat 5) declares first. No prior callout — the + # window is opened purely by the new ``pending_co_response`` + # mechanism. + from wolfbot.services.discussion_service import make_npc_generated_event + + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=5, + text="実は私、占い師なのです。昨夜、ジョナスを占いました。", + co_declaration="seer", + created_at_ms=10, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[int, list[str]] = {n: [] for n in (1, 2, 3, 4, 5, 6)} + persona_by_seat = { + 1: "jonas", + 2: "gina", + 3: "raqio", + 4: "comet", + 5: "setsu", + 6: "shigemichi", + } + for seat_no, persona in persona_by_seat.items(): + registry.register( + npc_id=f"npc_{persona}", + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[seat_no]), + now_ms=2000, + persona_key=persona, + ) + registry.assign( + f"npc_{persona}", + seat=seat_no, + game_id=g.id, + phase_id=phase_id, + ) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 3000, + ) + + await arb.try_dispatch_next(g.id) + + # Pool: real seer (5) is now CO'd → excluded. Uncpd wolf-side = + # ジョナス (1) + ラキオ (3). Villagers 2/4/6 must stay out. + pool_seats = {1, 3} + non_pool_seats = {2, 4, 5, 6} + picked = next( + (s for s, b in bufs.items() if any('"speak_request"' in m for m in b)), + None, + ) + assert picked is not None, "must dispatch someone" + assert picked in pool_seats, ( + f"picked seat {picked} must be in counter-CO pool {pool_seats} " + f"(uncpd wolf-side after the real seer's first CO). " + f"Non-pool seats {non_pool_seats} (villagers + the CO'er) " + f"should NOT be picked." + ) + _, reason = await _fetch_selection_reason(repo, g.id) + assert reason == "role_callout_pool" + + +async def test_first_co_pool_skips_text_mismatch_co( + repo: SqliteRepo, +) -> None: + """A SpeechEvent with structured ``co_declaration='seer'`` whose + text is a counter-CO request rather than a self-declaration must + NOT open the counter-CO pool — the text-vs-structured guard drops + the leaked structured flag, so ``pending_co_response`` stays + empty and dispatch falls through to normal priority. + + Reproduces game ``98e5a083b5ff`` day 1 ラキオの誤検知. + """ + g = Game( + id="rv-first-co-mismatch", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.MADMAN) + await repo.set_player_role(g.id, 2, Role.SEER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2], + created_at_ms=1, + ) + ) + # Leaked structured flag: text is a counter-CO request, not a + # declaration. The guard must drop the flag → no CO recorded → + # pool stays inactive. + from wolfbot.services.discussion_service import make_npc_generated_event + + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="ステラ、対抗占い師は出ないのか?早く名乗りなさい。", + co_declaration="seer", + created_at_ms=10, + ) + ) + + # Rebuild via the canonical fold path (= what restart recovery / + # arbiter prompt build use). ``pending_co_response`` should be + # empty, signalling the guard worked. + from wolfbot.services.discussion_service import ( + rebuild_public_state_from_events, + ) + + events = await store.load_phase(g.id, phase_id) + state = rebuild_public_state_from_events(events) + assert state is not None + assert state.pending_co_response == frozenset() + assert state.co_claims == () + + +async def test_info_request_callout_expands_pool_to_all_info_roles( + repo: SqliteRepo, +) -> None: + """A generic info-seeking speech (「誰か怪しい人?」「みんな意見聞かせて」) + is tagged role_callout='info_request' by the analyzer. The arbiter + must treat this like a callout for ALL info roles simultaneously: + pool = real seer + real medium + real knight + every uncpd wolf-side. + Pure villagers stay out of the pool.""" + g = Game( + id="rv-info-request", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat( + seat_no=1, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), # WEREWOLF + Seat( + seat_no=2, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), # MEDIUM + Seat( + seat_no=3, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), # VILLAGER (caller) + Seat( + seat_no=4, + display_name="☄️コメット", + discord_user_id=None, + is_llm=True, + persona_key="comet", + ), # VILLAGER + Seat( + seat_no=5, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), # MADMAN + Seat( + seat_no=6, + display_name="👽シゲミチ", + discord_user_id=None, + is_llm=True, + persona_key="shigemichi", + ), # SEER + Seat( + seat_no=7, display_name="🍎SQ", discord_user_id=None, is_llm=True, persona_key="sq" + ), # KNIGHT + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in ( + (1, Role.WEREWOLF), + (2, Role.MEDIUM), + (3, Role.VILLAGER), + (4, Role.VILLAGER), + (5, Role.MADMAN), + (6, Role.SEER), + (7, Role.KNIGHT), + ): + await repo.set_player_role(g.id, sn, role) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3, 4, 5, 6, 7], + created_at_ms=1, + ) + ) + # Caller (ラキオ, villager): 「誰か怪しい人挙げて」 → role_callout='info_request' + from wolfbot.services.discussion_service import make_npc_generated_event + + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=3, + text="誰か怪しい人挙げてくれ!", + role_callout="info_request", + created_at_ms=10, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[int, list[str]] = {n: [] for n in range(1, 8)} + persona_by_seat = { + 1: "jonas", + 2: "gina", + 3: "raqio", + 4: "comet", + 5: "setsu", + 6: "shigemichi", + 7: "sq", + } + for seat_no, persona in persona_by_seat.items(): + registry.register( + npc_id=f"npc_{persona}", + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[seat_no]), + now_ms=2000, + persona_key=persona, + ) + registry.assign(f"npc_{persona}", seat=seat_no, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 3000, + ) + await arb.try_dispatch_next(g.id) + + # Pool: seats 1 (wolf), 2 (medium), 5 (madman), 6 (seer), 7 (knight). + # Pure villagers 3, 4 must NOT be picked. + pool_seats = {1, 2, 5, 6, 7} + villager_seats = {3, 4} + picked = next( + (s for s, b in bufs.items() if any('"speak_request"' in m for m in b)), + None, + ) + assert picked is not None, "must dispatch from the info_request pool" + assert picked in pool_seats, ( + f"info_request pool must include real info roles + wolf-side. " + f"picked={picked} pool_seats={pool_seats}, villagers {villager_seats} " + f"should never be picked while pool is non-empty." + ) + _, reason = await _fetch_selection_reason(repo, g.id) + assert reason == "role_callout_pool" + + +async def test_info_request_consumed_when_any_info_role_cod( + repo: SqliteRepo, +) -> None: + """info_request is a generic callout; once anyone CO's any info role + (seer/medium/knight), the priority pool steps down and normal + rotation resumes (villagers can be picked again).""" + g = Game( + id="rv-info-request-consume", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat( + seat_no=1, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), # SEER (will CO) + Seat( + seat_no=2, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), # VILLAGER + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.SEER) + await repo.set_player_role(g.id, 2, Role.VILLAGER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2], + created_at_ms=1, + ) + ) + from wolfbot.services.discussion_service import make_npc_generated_event + + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, + text="誰か怪しい人挙げて", + role_callout="info_request", + created_at_ms=10, + ) + ) + # Real seer answers — co_declaration='seer'. + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="私が占い師だ", + co_declaration="seer", + created_at_ms=20, + ) + ) + + # Now rebuild state — info_request must have been consumed by the + # seer CO. pending_role_callouts should NOT contain "info_request". + from wolfbot.services.discussion_service import ( + rebuild_public_state_from_events, + ) + + events = await store.load_phase(g.id, phase_id) + state = rebuild_public_state_from_events(events) + assert state is not None + assert "info_request" not in state.pending_role_callouts, ( + "any info-role CO must consume the info_request callout" + ) + + +async def test_role_callout_pool_excludes_seats_that_already_cod( + repo: SqliteRepo, +) -> None: + """A wolf who already fake-CO'd as seer must be excluded from the pool + (no double-counting). The pool only contains uncpd info-role candidates. + """ + g = Game( + id="rv-callout-pool-exclude", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat( + seat_no=1, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), # WEREWOLF — fake-CO'd already + Seat( + seat_no=2, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), # MADMAN — still in pool + Seat( + seat_no=3, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), # SEER (real) — still in pool + Seat( + seat_no=4, + display_name="☄️コメット", + discord_user_id=None, + is_llm=True, + persona_key="comet", + ), # VILLAGER — never in pool + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in ( + (1, Role.WEREWOLF), + (2, Role.MADMAN), + (3, Role.SEER), + (4, Role.VILLAGER), + ): + await repo.set_player_role(g.id, sn, role) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3, 4], + created_at_ms=1, + ) + ) + from wolfbot.services.discussion_service import make_npc_generated_event + + # Wolf already fake-CO'd as seer (consumes the original callout). + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="私が占い師だ", + co_declaration="seer", + created_at_ms=10, + ) + ) + # Then someone else asks "他に占い師は?" — re-fires the seer callout. + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=4, + text="他に占い師の方は?", + role_callout="seer", + created_at_ms=20, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[int, list[str]] = {n: [] for n in (1, 2, 3, 4)} + persona_by_seat = {1: "jonas", 2: "gina", 3: "setsu", 4: "comet"} + for seat_no, persona in persona_by_seat.items(): + registry.register( + npc_id=f"npc_{persona}", + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[seat_no]), + now_ms=2000, + persona_key=persona, + ) + registry.assign(f"npc_{persona}", seat=seat_no, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 3000, + ) + + await arb.try_dispatch_next(g.id) + + # Pool should contain seat 2 (madman, no CO) and seat 3 (real seer). + # Seat 1 already CO'd → excluded. Seat 4 villager → never in pool. + picked = next((s for s, b in bufs.items() if any('"speak_request"' in m for m in b)), None) + assert picked is not None + assert picked in {2, 3}, ( + f"picked seat {picked} must be madman (2) or real seer (3); " + f"wolf (1) is already CO'd, villager (4) never in pool." + ) + + +async def test_role_callout_pool_asked_tracker_avoids_repick( + repo: SqliteRepo, +) -> None: + """If a pool member declines, the asked-tracker must prevent the picker + from looping on the same seat. Subsequent dispatches go to other pool + members until everyone has had one chance.""" + g = Game( + id="rv-callout-asked", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat( + seat_no=1, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), + Seat( + seat_no=2, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.SEER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2], + created_at_ms=1, + ) + ) + from wolfbot.services.discussion_service import make_npc_generated_event + + # External callout — fired by no-one in the test (we add a fake event + # with a non-pool speaker_seat, but to avoid violating the alive-set + # we attribute it to seat 1 with role_callout but no co_declaration). + # Actually simpler: use seat 2 itself but set co_declaration=None; + # then the callout fires without consuming itself. + # We need a third seat to act as the speaker — let's add one as + # already-dead and not part of the pool, but the model requires + # alive=True for speakers to be valid... use a knight-like spectator. + # Workaround: use seat 1 (wolf) as the question asker. Their event + # only flags role_callout=seer; the wolf itself stays in pool because + # they didn't co_declare. + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="占い師は誰だ?", + role_callout="seer", + created_at_ms=10, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[int, list[str]] = {1: [], 2: []} + persona_by_seat = {1: "jonas", 2: "setsu"} + for seat_no, persona in persona_by_seat.items(): + registry.register( + npc_id=f"npc_{persona}", + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[seat_no]), + now_ms=2000, + persona_key=persona, + ) + registry.assign(f"npc_{persona}", seat=seat_no, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 3000, + ) + + # First dispatch from pool — pick is random between 1 and 2. + await arb.try_dispatch_next(g.id) + first_picked = next( + (s for s, b in bufs.items() if any('"speak_request"' in m for m in b)), None + ) + assert first_picked in {1, 2} + + # The picked seat is now in `_callout_pool_asked`. Second dispatch + # MUST go to the other pool member (asked tracker excludes the first). + bufs[1].clear() + bufs[2].clear() + # Need to clear _pending so dispatch can proceed (no playback yet). + arb._pending.clear() + await arb.try_dispatch_next(g.id) + second_picked = next( + (s for s, b in bufs.items() if any('"speak_request"' in m for m in b)), None + ) + assert second_picked is not None + assert second_picked != first_picked, ( + f"asked tracker must steer to a different pool member: " + f"first={first_picked}, second={second_picked}" + ) + + +async def test_speak_result_rejection_clears_pending_and_redispatches( + repo: SqliteRepo, +) -> None: + """Regression for game eab1f9514a10 day 4: SQ finished, ユリコ was + dispatched but took 10s to respond (TTL=8s) → `expired_request` + rejection. The old rejection path neither popped `_pending` nor called + `try_dispatch_next`, so the day stalled silently for 2.5 minutes. + + After the fix, every valid-pending rejection (expired/stale/declined/ + too-long) must: + 1. Pop _pending so the slot is freed. + 2. Call try_dispatch_next so another candidate gets a chance. + """ + g = Game( + id="rv-reject-redispatch", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.SEER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2], + created_at_ms=1, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"raqio": [], "setsu": []} + for npc_id, persona, seat_no in ( + ("npc_raqio", "raqio", 1), + ("npc_setsu", "setsu", 2), + ): + registry.register( + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, + persona_key=persona, + ) + registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) + + # Step 1: dispatch seat 1 (ラキオ). Use a permissive heartbeat_timeout + # since the test artificially advances `now` past the request TTL — in + # production the NPC sends heartbeats continuously so stale clock vs + # heartbeat doesn't happen. + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 2000, + config=SpeakArbiterConfig(heartbeat_timeout_ms=60_000), + ) + state = await arb.rebuild_public_state( + game_id=g.id, + day=1, + phase=Phase.DAY_DISCUSSION, + ) + assert state is not None + req, _ = await arb.dispatch_request( + state=state, candidate_npc_id="npc_raqio", seat_no=1, game_id=g.id + ) + assert req is not None + assert req.request_id in arb._pending + + # Step 2: simulate now > expires_at_ms by advancing the arbiter's clock + # past the request's TTL. + arb._now_ms = lambda: req.expires_at_ms + 100 # type: ignore[method-assign] + + bufs["raqio"].clear() + bufs["setsu"].clear() + + # Step 3: SpeakResult arrives late → expired_request rejection. + result = SpeakResult( + ts=req.expires_at_ms + 100, + trace_id="t", + request_id=req.request_id, + npc_id="npc_raqio", + phase_id=phase_id, + status="accepted", + text="late response", + ) + ok, reason = await arb.handle_speak_result( + result, + current_phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + ) + assert not ok and reason == "expired_request" + + # Verify _pending was popped (slot freed). + assert req.request_id not in arb._pending, ( + "_pending must be popped on rejection so the slot is freed" + ) + + # Verify try_dispatch_next was called → another seat got a SpeakRequest. + # Without the fix, both bufs would be empty and the day would stall. + # (The picker may choose either seat 1 or seat 2 depending on RNG — + # both have count=0 and no addressed bias.) + new_request_msgs = [m for m in (*bufs["raqio"], *bufs["setsu"]) if '"speak_request"' in m] + assert new_request_msgs, ( + "rejection must trigger try_dispatch_next; another candidate must be picked. " + f"raqio buf: {bufs['raqio']!r}, setsu buf: {bufs['setsu']!r}" + ) + + +async def test_speak_result_stale_phase_rejected(repo: SqliteRepo) -> None: + game, _ = await _seed_game(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + persona_key="setsu", + ) + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter(repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500) + state = _seed_state(game.id) + req, _ = await arb.dispatch_request( + state=state, candidate_npc_id="npc_p2", seat_no=2, game_id=game.id + ) + assert req is not None + npc_buf.clear() + result = SpeakResult( + ts=1600, + trace_id="t", + request_id=req.request_id, + npc_id="npc_p2", + phase_id=req.phase_id, # stays the same on the result + status="accepted", + text="ok", + ) + ok, reason = await arb.handle_speak_result( + result, + current_phase_id="some-other-phase", # arbiter sees a different current phase + day=1, + phase=Phase.DAY_DISCUSSION, + ) + assert not ok and reason == "stale_phase" + + +async def test_serial_speech_blocks_after_authorize_until_finished( + repo: SqliteRepo, +) -> None: + game, _ = await _seed_game(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + persona_key="setsu", + ) + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter(repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500) + state = _seed_state(game.id) + req, _ = await arb.dispatch_request( + state=state, candidate_npc_id="npc_p2", seat_no=2, game_id=game.id + ) + assert req is not None + await arb.handle_speak_result( + SpeakResult( + ts=1600, + trace_id="t", + request_id=req.request_id, + npc_id="npc_p2", + phase_id=req.phase_id, + status="accepted", + text="まあね", + ), + current_phase_id=req.phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + ) + # Now block: next dispatch should fail with queue_busy. + req2, reason = await arb.dispatch_request( + state=state, candidate_npc_id="npc_p2", seat_no=2, game_id=game.id + ) + assert req2 is None and reason == "queue_busy" + # Close the playback — the gate should release. + await arb.handle_playback_finished( + PlaybackFinished( + ts=1700, + trace_id="t", + request_id=req.request_id, + npc_id="npc_p2", + started_at_ms=1600, + finished_at_ms=1700, + ) + ) + req3, reason = await arb.dispatch_request( + state=state, candidate_npc_id="npc_p2", seat_no=2, game_id=game.id + ) + assert req3 is not None and reason is None + + +async def test_recovery_sweep_marks_in_flight_rows_master_restart( + repo: SqliteRepo, +) -> None: + game, _ = await _seed_game(repo) + registry = InMemoryNpcRegistry() + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter(repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 9999) + # Manually seed one open request and one open playback (without going + # through dispatch — Master crashed mid-flight). + await repo.insert_npc_speak_request( + request_id="open-req-1", + game_id=game.id, + phase_id="ph", + npc_id="npc_p2", + seat_no=2, + logic_packet_id="lp", + suggested_intent="x", + max_chars=80, + max_duration_ms=1000, + priority=0, + expires_at_ms=10000, + created_at_ms=8000, + ) + await repo.open_npc_playback( + request_id="open-pb-1", + game_id=game.id, + phase_id="ph", + npc_id="npc_p2", + speech_event_id="ev1", + authorized_at_ms=8000, + playback_deadline_ms=12000, + ) + await arb.reactive_voice_recovery_sweep(game.id) + open_reqs = await repo.load_open_npc_speak_requests(game.id) + open_play = await repo.load_open_npc_playback(game.id) + assert open_reqs == [] + assert open_play == [] + + +async def test_rebuild_public_state_from_master_restart(repo: SqliteRepo) -> None: + game, _ = await _seed_game(repo) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + sentinel = make_phase_baseline( + game_id=game.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2], + created_at_ms=1, + ) + await store.insert(sentinel) + # Simulate a recovered SpeakArbiter on a fresh process. + arb = SpeakArbiter( + repo=repo, + registry=InMemoryNpcRegistry(), + discussion=discussion, + now_ms=lambda: 5, + ) + state = await arb.rebuild_public_state(game_id=game.id, day=1, phase=Phase.DAY_DISCUSSION) + assert state is not None + assert state.alive_seat_nos == frozenset({1, 2}) + + +async def test_try_dispatch_next_prefers_silent_seat_over_lowest( + repo: SqliteRepo, +) -> None: + """In pure-NPC games the only rotation signal is `silent_seats`. Without + silent-first ordering, `try_dispatch_next` would always pick the lowest + `assigned_seat` and the NPC at seat 2 would monopolize. The picker must + prefer NPCs whose seats are still in `silent_seats`. + """ + g = Game( + id="rv-pick", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="Alice", discord_user_id="u1", is_llm=False, persona_key=None), + Seat( + seat_no=2, display_name="セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=3, display_name="ジーナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.SEER) + await repo.set_player_role(g.id, 3, Role.VILLAGER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + # Seed phase baseline so PublicDiscussionState has alive_seat_nos {1,2,3}. + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + # Seat 2 has already spoken — silent_seats is now {1, 3}. Seat 1 is human + # (no assigned_seat in registry), so the only silent NPC seat is 3. + from wolfbot.services.discussion_service import make_npc_generated_event + + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, + text="seat 2 already spoke", + created_at_ms=2, + ) + ) + + registry = InMemoryNpcRegistry() + buf2: list[str] = [] + buf3: list[str] = [] + registry.register( + npc_id="npc_setsu", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(buf2), + now_ms=1000, + persona_key="setsu", + ) + registry.register( + npc_id="npc_gina", + discord_bot_user_id="bot3", + supported_voices=(), + version="1", + send=_captured_send(buf3), + now_ms=1000, + persona_key="gina", + ) + registry.assign("npc_setsu", seat=2, game_id=g.id, phase_id=phase_id) + registry.assign("npc_gina", seat=3, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 1500, + ) + await arb.try_dispatch_next(g.id) + + # Without the silent-first fix, the lowest-seat NPC (seat 2) would have + # received the SpeakRequest. With the fix, only seat 3 (the silent NPC) + # gets it. + assert buf3, "silent NPC at seat 3 must be picked" + assert not buf2, "non-silent NPC at seat 2 must not be picked" + + +async def _fetch_selection_reason(repo: SqliteRepo, game_id: str) -> tuple[str | None, str | None]: + """Pull (seat_no_repr, reason) for the most recent dispatched request. + + Used by the selection_reason classification tests; the column is + only populated by the `try_dispatch_next` path, so a test that uses + the lower-level `dispatch_request` directly will see ``None``. + """ + async with repo._conn.execute( # type: ignore[attr-defined] + """ + SELECT seat_no, selection_reason, public_state_snapshot_json + FROM npc_speak_requests + WHERE game_id = ? + ORDER BY created_at_ms DESC + LIMIT 1 + """, + (game_id,), + ) as cur: + row = await cur.fetchone() + if row is None: + return (None, None) + return (str(row[0]), row[1]) + + +async def test_try_dispatch_next_records_selection_reason_addressed( + repo: SqliteRepo, +) -> None: + """When state.last_addressed_seat matches an online NPC seat, the + arbiter records ``selection_reason='addressed'`` and snapshots the + public-state context (silent_seats, alive_seat_nos, online_npc_seats) + onto the persisted row so the viewer can render the "why". + """ + g = Game( + id="rv-reason-addr", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="Alice", discord_user_id="u1", is_llm=False, persona_key=None), + Seat( + seat_no=2, display_name="セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=3, display_name="ジーナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.SEER) + await repo.set_player_role(g.id, 3, Role.VILLAGER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + # Human at seat 1 addresses NPC at seat 3 — this should override + # silent-rotation ordering (seat 2 has lower number but isn't addressed). + from wolfbot.services.discussion_service import make_voice_stt_event + + await store.insert( + make_voice_stt_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="ジーナどう思う?", + stt_confidence=0.9, + audio_start_ms=10, + audio_end_ms=20, + addressed_seat_no=3, + created_at_ms=2, + ) + ) + + registry = InMemoryNpcRegistry() + buf2: list[str] = [] + buf3: list[str] = [] + registry.register( + npc_id="npc_setsu", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(buf2), + now_ms=1000, + persona_key="setsu", + ) + registry.register( + npc_id="npc_gina", + discord_bot_user_id="bot3", + supported_voices=(), + version="1", + send=_captured_send(buf3), + now_ms=1000, + persona_key="gina", + ) + registry.assign("npc_setsu", seat=2, game_id=g.id, phase_id=phase_id) + registry.assign("npc_gina", seat=3, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 1500, + ) + await arb.try_dispatch_next(g.id) + + assert buf3, "addressed NPC at seat 3 must receive the SpeakRequest" + seat_repr, reason = await _fetch_selection_reason(repo, g.id) + assert seat_repr == "3" + assert reason == "addressed" + + +async def test_try_dispatch_next_speech_count_beats_addressed( + repo: SqliteRepo, +) -> None: + """A silent NPC (count=0) wins over an addressed NPC who already + spoke (count=1). Regression for the "addressed always wins" bias + that caused day-1 灰位置 silence in game c99ecf313f96 — occult-CO + seats addressed each other and monopolised the floor while + non-addressed greys stayed at 0 turns. New picker promotes + speech_count above addressed so the silent grey breaks in first. + """ + g = Game( + id="rv-count-vs-addr", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat( + seat_no=1, display_name="Alice", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=2, display_name="Bob", discord_user_id=None, is_llm=True, persona_key="gina" + ), + Seat( + seat_no=3, display_name="Carol", discord_user_id=None, is_llm=True, persona_key="raqio" + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in ((1, Role.SEER), (2, Role.WEREWOLF), (3, Role.VILLAGER)): + await repo.set_player_role(g.id, sn, role) + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + # seat 2 has already spoken once. seat 1's most recent utterance + # addresses seat 2 again. seat 3 is silent (count=0). New picker → + # seat 3 wins on count even though seat 2 is the addressed one. + from wolfbot.services.discussion_service import make_npc_generated_event + + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, + text="意見その1。", + created_at_ms=8, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="Bob、もう少し詳しく?", + addressed_seat_no=2, + created_at_ms=10, + ) + ) + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"setsu": [], "gina": [], "raqio": []} + for npc_id, persona, seat in ( + ("npc_setsu", "setsu", 1), + ("npc_gina", "gina", 2), + ("npc_raqio", "raqio", 3), + ): + registry.register( + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, + persona_key=persona, + ) + registry.assign(npc_id, seat=seat, game_id=g.id, phase_id=phase_id) + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 2000 + ) + await arb.try_dispatch_next(g.id) + assert bufs["raqio"], "silent seat 3 should be picked over addressed seat 2" + assert not bufs["gina"], "addressed seat 2 must NOT win when count is non-tie" + seat_repr, reason = await _fetch_selection_reason(repo, g.id) + assert seat_repr == "3" + assert reason == "silent_rotation" + + +async def test_try_dispatch_next_pending_seer_result_beats_addressed( + repo: SqliteRepo, +) -> None: + """A seer-CO seat with an unpublished day-N divine result is + promoted above an addressed seat. Regression for game c99ecf313f96 + day 2 ラキオ占いCO never spoke — the picker had no way to surface + "this CO holder owes a result" so ラキオ stayed silent the entire + day while ジョナス + ユリコ + セツ recycled the floor. + """ + g = Game( + id="rv-pending-result", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=2, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat( + seat_no=1, display_name="Alice", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=2, display_name="Bob", discord_user_id=None, is_llm=True, persona_key="gina" + ), + Seat( + seat_no=3, display_name="Carol", discord_user_id=None, is_llm=True, persona_key="raqio" + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in ((1, Role.SEER), (2, Role.VILLAGER), (3, Role.MADMAN)): + await repo.set_player_role(g.id, sn, role) + + phase_id_d1 = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + phase_id_d2 = make_phase_id(g.id, 2, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + + # Day-1 ledger: seat 1 (Alice) and seat 3 (Carol) both seer-CO'd. + # Seat 1 announced day-1 result (1/1). Seat 3 announced day-1 result. + from wolfbot.services.discussion_service import make_npc_generated_event + + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id_d1, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id_d1, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="占い師。Bob は白。", + co_declaration="seer", + claimed_seer_target_seat=2, + claimed_seer_is_wolf=False, + created_at_ms=10, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id_d1, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=3, + text="僕も占い師。Alice は白。", + co_declaration="seer", + claimed_seer_target_seat=1, + claimed_seer_is_wolf=False, + created_at_ms=20, + ) + ) + # Day-2: seat 1 already published the day-2 result (2/2). Seat 3 + # has spoken on day-2 but NOT yet announced today's divine + # (1/2 announced) — expected pending. Seat 2 (no role CO) has + # spoken once and addresses seat 1. + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id_d2, + day=2, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=100, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id_d2, + day=2, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="昨夜は Carol を占って白。", + co_declaration="seer", + claimed_seer_target_seat=3, + claimed_seer_is_wolf=False, + created_at_ms=110, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id_d2, + day=2, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, + text="Alice さんどう?", + addressed_seat_no=1, + created_at_ms=120, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id_d2, + day=2, + phase=Phase.DAY_DISCUSSION, + speaker_seat=3, + text="まだ見定め中だ。", + created_at_ms=130, + ) + ) + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"setsu": [], "gina": [], "raqio": []} + for npc_id, persona, seat in ( + ("npc_setsu", "setsu", 1), + ("npc_gina", "gina", 2), + ("npc_raqio", "raqio", 3), + ): + registry.register( + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, + persona_key=persona, + ) + registry.assign(npc_id, seat=seat, game_id=g.id, phase_id=phase_id_d2) + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 3000 + ) + await arb.try_dispatch_next(g.id) + # Carol (raqio, seat 3) is the only seer-CO holder with an unpublished + # day-2 result (1 announced, 2 expected). Picker must surface her + # ahead of seat 1 (addressed by seat 2's last utterance) even though + # both have count=1 on day-2. Without the pending bucket, the picker + # would have rotated to seat 1 (addressed) or seat 2 (count=1, lru). + assert bufs["raqio"], "pending-seer-result seat 3 must win" + seat_repr, reason = await _fetch_selection_reason(repo, g.id) + assert seat_repr == "3" + assert reason == "pending_role_result" + + +async def test_try_dispatch_next_records_selection_reason_silent_rotation( + repo: SqliteRepo, +) -> None: + """No addressed seat → silent NPC wins; reason='silent_rotation'.""" + g = Game( + id="rv-reason-silent", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="Alice", discord_user_id="u1", is_llm=False, persona_key=None), + Seat( + seat_no=2, display_name="セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=3, display_name="ジーナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.SEER) + await repo.set_player_role(g.id, 3, Role.VILLAGER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + # Seat 2 already spoke. silent_seats={1,3}; seat 1 is human (no NPC), + # so the only silent NPC is seat 3 → reason should be silent_rotation. + from wolfbot.services.discussion_service import make_npc_generated_event + + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=2, + text="seat 2 already spoke", + created_at_ms=2, + ) + ) + + registry = InMemoryNpcRegistry() + buf2: list[str] = [] + buf3: list[str] = [] + registry.register( + npc_id="npc_setsu", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(buf2), + now_ms=1000, + persona_key="setsu", + ) + registry.register( + npc_id="npc_gina", + discord_bot_user_id="bot3", + supported_voices=(), + version="1", + send=_captured_send(buf3), + now_ms=1000, + persona_key="gina", + ) + registry.assign("npc_setsu", seat=2, game_id=g.id, phase_id=phase_id) + registry.assign("npc_gina", seat=3, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 1500, + ) + await arb.try_dispatch_next(g.id) + + seat_repr, reason = await _fetch_selection_reason(repo, g.id) + assert seat_repr == "3" + assert reason == "silent_rotation" + + +async def test_try_dispatch_next_avoids_immediate_repeat_after_first_round( + repo: SqliteRepo, +) -> None: + """Once `silent_seats` is empty (every alive NPC spoke once), the + arbiter must not re-pick the most recent speaker. Without this guard + the lowest-seat NPC monopolizes the rest of the phase — observed in + the wild where seat 1 (Raqio) spoke 8 times in a row after the + rotation. + """ + g = Game( + id="rv-lru", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=3, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.SEER) + await repo.set_player_role(g.id, 3, Role.VILLAGER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + # Simulate a complete first round: every alive NPC spoke once. Last + # speaker is seat 1 (Raqio). silent_seats becomes empty. + from wolfbot.services.discussion_service import make_npc_generated_event + + for ts, seat_no, text in ( + (10, 2, "セツ first"), + (20, 3, "ジナ first"), + (30, 1, "ラキオ first"), # ラキオ is the most recent speaker + ): + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=seat_no, + text=text, + created_at_ms=ts, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"raqio": [], "setsu": [], "gina": []} + for npc_id, persona, seat_no in ( + ("npc_raqio", "raqio", 1), + ("npc_setsu", "setsu", 2), + ("npc_gina", "gina", 3), + ): + registry.register( + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, + persona_key=persona, + ) + registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) + + import random as _random + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 2000, + rng=_random.Random(0), + ) + await arb.try_dispatch_next(g.id) + + assert not bufs["raqio"], "ラキオ was the immediate previous speaker — must NOT be re-picked" + # Equal-priority tiebreak is randomised — seat 2 OR seat 3 may win, + # but never seat 1 (the just-spoken seat). Seeded RNG keeps the + # specific winner deterministic for the test (currently seat 2). + assert bufs["setsu"] or bufs["gina"] + if bufs["setsu"]: + assert not bufs["gina"] + else: + assert not bufs["setsu"] + + +async def test_try_dispatch_next_prefers_lower_speech_count( + repo: SqliteRepo, +) -> None: + """The seat with the lowest phase-wide speech_count wins, even when + every alive NPC has spoken at least once (so silent_seats is empty). + + Reproduces the in-the-wild imbalance where the lowest-seat-number NPC + monopolised once the silent rotation completed: with the binary + silent_seats only, seat 1 with count=4 still tied with seat 3 at + count=1 on the silent axis and won by seat tiebreak. The new sort + treats count as a continuous signal so seat 3 is preferred. + """ + g = Game( + id="rv-count", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=3, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.MADMAN) + await repo.set_player_role(g.id, 3, Role.SEER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + from wolfbot.services.discussion_service import make_npc_generated_event + + # Counts after seeding: seat1=4, seat2=4, seat3=1. Seat 3 also speaks + # in the recent window (event ts=60) so the pair-volley demotion + # gate stays quiet — the only differentiator left is the per-seat + # speech_count. Without that axis seat 1 wins by lowest seat tie- + # break; with it seat 3 beats both 4-counts. + # + # No CO is emitted in this fixture because a first-CO would fire + # the counter-CO opportunity pool and override low-count rotation + # — that pool path is exercised separately. Here we want to pin the + # bare ``low_count_rotation`` reason without crossing pool lines. + payload = [ + (10, 1, "ラキオ1巡目"), # 1:1 + (20, 2, "セツの所感"), # 2:1 + (30, 1, "ラキオ反論1"), # 1:2 + (40, 2, "セツ反応1"), # 2:2 + (50, 1, "ラキオ反論2"), # 1:3 + (55, 2, "セツ反応2"), # 2:3 + (60, 3, "ジナの差し込み"), # 3:1 (breaks pair window) + (70, 1, "ラキオ反論3"), # 1:4 + (80, 2, "セツ反応3"), # 2:4 + ] + for ts, seat, text in payload: + kwargs: dict[str, object] = dict( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=seat, + text=text, + created_at_ms=ts, + ) + await store.insert(make_npc_generated_event(**kwargs)) # type: ignore[arg-type] + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"raqio": [], "setsu": [], "gina": []} + for npc_id, persona, seat_no in ( + ("npc_raqio", "raqio", 1), + ("npc_setsu", "setsu", 2), + ("npc_gina", "gina", 3), + ): + registry.register( + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, + persona_key=persona, + ) + registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 2000, + ) + await arb.try_dispatch_next(g.id) + + assert bufs["gina"], "ジナ (count=1) must outrank counts of 4" + assert not bufs["raqio"] + assert not bufs["setsu"] + + seat_repr, reason = await _fetch_selection_reason(repo, g.id) + assert seat_repr == "3" + assert reason == "low_count_rotation" + + +async def test_try_dispatch_next_lru_when_speech_counts_tied( + repo: SqliteRepo, +) -> None: + """Two NPCs at equal speech_count — the just-spoke seat is demoted + (LRU), and the remaining seat wins. The reason is ``lru_rotation``, + not ``low_count_rotation`` (no count differential to leverage). + """ + g = Game( + id="rv-lru-tied", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.SEER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2], + created_at_ms=1, + ) + ) + from wolfbot.services.discussion_service import make_npc_generated_event + + # seat 2 occupies the just-spoke slot at the end so LRU pushes us + # back to seat 1. Both seats have count=2 so the count axis is + # neutral. + # + # Seat 1's first event carries a knight CO with valid self-decl + # text — chosen because (a) the post-2026-05-01 ``co_declaration`` + # consistency guard accepts it (text + structured field agree), + # (b) the game has no real knight role, so the resulting first-CO + # counter-CO pool is empty (no real role-holder, no other + # uncommitted wolf-side seat), keeping LRU as the operative axis. + # This pads the recent-speech-summary with one ``has_info=True`` + # entry, which silences the pair-volley demotion gate that would + # otherwise demote both seats and reroute through the + # ``all_demoted_fallback`` path. + co_setup = {(10, 1): ("実は私、騎士なんだ。", "knight")} + for ts, seat in ((10, 1), (20, 2), (30, 1), (40, 2)): + text, co = co_setup.get((ts, seat), (f"seat {seat} ts={ts}", None)) + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=seat, + text=text, + co_declaration=co, + created_at_ms=ts, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"raqio": [], "setsu": []} + for npc_id, persona, seat_no in ( + ("npc_raqio", "raqio", 1), + ("npc_setsu", "setsu", 2), + ): + registry.register( + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, + persona_key=persona, + ) + registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 2000, + ) + await arb.try_dispatch_next(g.id) + + assert bufs["raqio"] + assert not bufs["setsu"] + seat_repr, reason = await _fetch_selection_reason(repo, g.id) + assert seat_repr == "1" + assert reason == "lru_rotation" + + +async def test_pair_volley_demotion_fires_after_4_low_info_speeches( + repo: SqliteRepo, +) -> None: + """A→B→A→B with no CO declared = 4 low-info speeches between 2 seats. + `_compute_demoted_seats` must mark BOTH seats so the next arbiter + pick picks a 3rd NPC (e.g., 席3 ジナ) instead of one of the two stuck + in the volley. Reproduces the production loop where ラキオ ↔ ジョナス + spoke alternately for the entire phase. + """ + from wolfbot.master.arbiter.speak_arbiter import _compute_demoted_seats + + # 4-event window: 1, 2, 1, 2 (no info) + summary = ((1, False), (2, False), (1, False), (2, False)) + demoted = _compute_demoted_seats(summary) + assert demoted == frozenset({1, 2}) + + +async def test_pair_volley_resets_when_co_declared() -> None: + """A *fresh* CO declaration in the window flips ``has_info=True`` for + that event → the gate must NOT fire. Otherwise legitimate dramatic + moments (e.g. seer CO in the middle of a heated exchange) would be + punished. Note: only the first CO per (seat, role) sets has_info — + the fold dedups so re-declaring an existing CO doesn't bypass. + """ + from wolfbot.master.arbiter.speak_arbiter import _compute_demoted_seats + + summary = ((1, False), (2, False), (1, True), (2, False)) + assert _compute_demoted_seats(summary) == frozenset() + + +async def test_repeated_co_from_same_seat_does_not_bypass_gate( + repo: SqliteRepo, +) -> None: + """The day-1 ジョナス↔ラキオ ping-pong escaped the gate because + Raqio kept emitting the same `co_declaration='seer'` flag on every + speech, making each event look like new info. Dedup CO at fold + time so the gate fires when the only "info" is a repeated CO. + """ + g = Game( + id="rv-co-repeat", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="Alice", discord_user_id="u1", is_llm=False, persona_key=None), + Seat( + seat_no=2, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), + Seat( + seat_no=3, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.WEREWOLF) + await repo.set_player_role(g.id, 2, Role.WEREWOLF) + await repo.set_player_role(g.id, 3, Role.SEER) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + from wolfbot.services.discussion_service import ( + make_npc_generated_event, + rebuild_public_state_from_events, + ) + + # First CO at ts=5 is genuine; the next 4 events form the volley + # window. Raqio re-emits `co_declaration='seer'` on every reply but + # the dedup makes those repeats has_info=False, so the window's last + # 4 entries are all (seat, False) → gate fires. + events_seq = [ + (5, 3, "seer"), # first CO (outside the last-4 window) + (10, 2, None), # window start + (20, 3, "seer"), # repeat — should NOT count as info + (30, 2, None), + (40, 3, "seer"), # another repeat + ] + for ts, seat, co in events_seq: + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=seat, + text=f"seat {seat} ts {ts}", + co_declaration=co, + created_at_ms=ts, + ) + ) + events = await store.load_phase(g.id, phase_id) + state = rebuild_public_state_from_events(events) + assert state is not None + # 5 events → summary is capped at 6 → all 5 retained. + # Last 4 entries: (2, False), (3, False), (2, False), (3, False) + # Pair volley: 2 distinct seats {2,3}, no has_info in window → demote. + from wolfbot.master.arbiter.speak_arbiter import _compute_demoted_seats + + assert _compute_demoted_seats(state.recent_speech_summary) == frozenset({2, 3}) + + +async def test_consecutive_cap_demotion_after_3_same_seat() -> None: + """Same seat speaking 3 in a row → demote that seat. Mostly fires + when a human keeps re-addressing the same NPC, but defensive against + any future bug that lets the same seat dispatch repeatedly.""" + from wolfbot.master.arbiter.speak_arbiter import _compute_demoted_seats + + summary = ((5, False), (5, False), (5, False)) + assert _compute_demoted_seats(summary) == frozenset({5}) + + +async def test_compute_demoted_seats_no_op_for_short_window() -> None: + """Window shorter than the gate thresholds yields an empty set.""" + from wolfbot.master.arbiter.speak_arbiter import _compute_demoted_seats + + assert _compute_demoted_seats(()) == frozenset() + assert _compute_demoted_seats(((1, False), (2, False))) == frozenset() + + +async def test_try_dispatch_next_diverts_around_pair_volley( + repo: SqliteRepo, +) -> None: + """End-to-end: simulate ラキオ (1) ↔ ジョナス (2) ping-pong with no CO, + then verify the next dispatch targets ジナ (3) — the only third + online NPC — and the persisted selection_reason is ``low_info_diversion``. + """ + g = Game( + id="rv-divert", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), + Seat( + seat_no=3, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in ((1, Role.WEREWOLF), (2, Role.MADMAN), (3, Role.SEER)): + await repo.set_player_role(g.id, sn, role) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + from wolfbot.services.discussion_service import make_npc_generated_event + + # First, seed a single seat-3 speech so silent_seats becomes empty. + # Then 4 alternating no-info speeches between seats 1 and 2 — the + # last 4 events form the pair-volley window. Without the demotion + # gate, seat 1 would win as lowest non-last-speaker (LRU). With it, + # seat 3 is the only non-demoted candidate. + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=3, + text="ジナの開始発言", + created_at_ms=5, + ) + ) + for ts, seat in ((10, 1), (20, 2), (30, 1), (40, 2)): + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=seat, + text=f"seat {seat}", + created_at_ms=ts, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"raqio": [], "jonas": [], "gina": []} + for npc_id, persona, seat in ( + ("npc_raqio", "raqio", 1), + ("npc_jonas", "jonas", 2), + ("npc_gina", "gina", 3), + ): + registry.register( + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, + persona_key=persona, + ) + registry.assign(npc_id, seat=seat, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 2000, + ) + await arb.try_dispatch_next(g.id) + + assert bufs["gina"], "third party (席3 ジナ) must break the volley" + assert not bufs["raqio"] + assert not bufs["jonas"] + + seat_repr, reason = await _fetch_selection_reason(repo, g.id) + assert seat_repr == "3" + assert reason == "low_info_diversion" + + +async def test_repeated_co_across_days_does_not_block_volley_demotion( + repo: SqliteRepo, +) -> None: + """Game a701a7531dca day 2 escape hatch: a seat that CO'd on day 1 + re-asserts the same CO on day 2; the per-phase rebuild must NOT treat + the day-2 re-assertion as fresh info, otherwise the ジョナス↔ユリコ + ping-pong escapes the `_PAIR_VOLLEY_WINDOW` demotion gate. + + Setup: + day 1 phase: seat 1 CO seer (recorded in game-wide history). + day 2 phase: seat 1 re-asserts CO seer, then 1↔9 alternate 4 times. + Expected: third party (seat 3) wins the next dispatch — `is_new_co` + must be False for the day-2 re-CO so all 4 window entries are low-info. + """ + g = Game( + id="rv-cross-day-co", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=2, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat( + seat_no=1, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), + Seat( + seat_no=3, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), + Seat( + seat_no=9, + display_name="👑ユリコ", + discord_user_id=None, + is_llm=True, + persona_key="yuriko", + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in ((1, Role.SEER), (3, Role.VILLAGER), (9, Role.VILLAGER)): + await repo.set_player_role(g.id, sn, role) + + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + from wolfbot.services.discussion_service import make_npc_generated_event + + # Day 1 phase: seat 1 declares seer once. + day1_phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=day1_phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 3, 9], + created_at_ms=1, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=day1_phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="私が占い師だ", + co_declaration="seer", + created_at_ms=10, + ) + ) + + # Day 2 phase: seat 3 opens (so silent_seats is empty), then 1<->9 + # alternate. Seat 1's day-2 utterance carries co_declaration="seer" + # again — must be treated as a re-assertion (no fresh info). + day2_phase_id = make_phase_id(g.id, 2, Phase.DAY_DISCUSSION) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=day2_phase_id, + day=2, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 3, 9], + created_at_ms=1000, + ) + ) + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=day2_phase_id, + day=2, + phase=Phase.DAY_DISCUSSION, + speaker_seat=3, + text="day2 開始発言", + created_at_ms=1005, + ) + ) + for ts, seat in ((1010, 9), (1020, 1), (1030, 9), (1040, 1)): + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=day2_phase_id, + day=2, + phase=Phase.DAY_DISCUSSION, + speaker_seat=seat, + text=f"seat {seat} day2", + co_declaration="seer" if seat == 1 else None, + created_at_ms=ts, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"jonas": [], "gina": [], "yuriko": []} + for npc_id, persona, seat_no in ( + ("npc_jonas", "jonas", 1), + ("npc_gina", "gina", 3), + ("npc_yuriko", "yuriko", 9), + ): + registry.register( + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[persona]), + now_ms=2000, + persona_key=persona, + ) + registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=day2_phase_id) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 3000, + ) + await arb.try_dispatch_next(g.id) + + assert bufs["gina"], "third party seat 3 must break the volley" + assert not bufs["jonas"], "seat 1 must be demoted (cross-phase re-CO is not info)" + assert not bufs["yuriko"], "seat 9 must be demoted" + + seat_repr, reason = await _fetch_selection_reason(repo, g.id) + assert seat_repr == "3" + assert reason == "low_info_diversion" + + +async def test_handle_tts_failed_pops_pending_before_returning( + repo: SqliteRepo, +) -> None: + """`handle_tts_failed` releases the playback gate AND removes the + `_pending` row in the same call. The wiring in `main._on_tts_failed` + therefore has to read game_id BEFORE invoking the handler — production + bug had the lookup *after* the call, returning None, and the next + NPC was never dispatched (silent stall after a VOICEVOX timeout). + + This test documents the contract so future refactors keep the + "capture game_id first" invariant. + """ + from wolfbot.domain.ws_messages import TtsFailed + from wolfbot.master.arbiter.speak_arbiter import _PendingRequest + + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter( + repo=repo, + registry=InMemoryNpcRegistry(), + discussion=discussion, + now_ms=lambda: 1_000, + ) + g = Game( + id="rv-tts-fail", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + arb._pending["sr-x"] = _PendingRequest( + request_id="sr-x", + npc_id="npc1", + seat_no=2, + phase_id="rv-tts-fail::day1::DAY_DISCUSSION::1", + game_id=g.id, + expires_at_ms=10_000, + ) + arb._active_playback.add("sr-x") + arb._playback_deadlines["sr-x"] = 5_000 + # Open the playback row so handle_tts_failed has something to close. + await repo.open_npc_playback( + request_id="sr-x", + game_id=g.id, + phase_id="rv-tts-fail::day1::DAY_DISCUSSION::1", + npc_id="npc1", + speech_event_id="se-x", + authorized_at_ms=1_000, + playback_deadline_ms=5_000, + ) + + captured_game_id = arb._pending["sr-x"].game_id + + msg = TtsFailed( + ts=2_000, + trace_id="t-x", + request_id="sr-x", + npc_id="npc1", + failure_reason="voicevox_timeout", + ) + await arb.handle_tts_failed(msg) + + # Contract: post-call, _pending no longer has the entry. If the + # main.py wiring reads game_id AFTER calling handle_tts_failed, it + # gets None and the dispatch chain stalls. + assert "sr-x" not in arb._pending + assert "sr-x" not in arb._active_playback + assert captured_game_id == g.id # what the wiring should have captured pre-call + + +async def test_arbiter_cleanup_game_drops_only_target_game(repo: SqliteRepo) -> None: + """`cleanup_game` is wired into `_on_reactive_game_end` so a long-lived + Master process doesn't carry stale `_pending` / `_active_playback` / + `_playback_deadlines` across games. Two-game scenario: seed pending + state for both, sweep g1, verify g2 untouched. + """ + from wolfbot.master.arbiter.speak_arbiter import _PendingRequest + + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter( + repo=repo, + registry=InMemoryNpcRegistry(), + discussion=discussion, + now_ms=lambda: 1_000, + ) + # Seed: two pending requests in g1, one in g2 — plus mirroring entries + # in active_playback / playback_deadlines (the gates the user-facing + # serial-speech check inspects). + arb._pending["req-g1-a"] = _PendingRequest( + request_id="req-g1-a", + npc_id="npc1", + seat_no=2, + phase_id="g1::day1::DAY_DISCUSSION::1", + game_id="g1", + expires_at_ms=10_000, + ) + arb._pending["req-g1-b"] = _PendingRequest( + request_id="req-g1-b", + npc_id="npc2", + seat_no=3, + phase_id="g1::day1::DAY_DISCUSSION::1", + game_id="g1", + expires_at_ms=10_000, + ) + arb._pending["req-g2-c"] = _PendingRequest( + request_id="req-g2-c", + npc_id="npc3", + seat_no=4, + phase_id="g2::day1::DAY_DISCUSSION::1", + game_id="g2", + expires_at_ms=10_000, + ) + arb._active_playback.update({"req-g1-a", "req-g2-c"}) + arb._playback_deadlines.update({"req-g1-a": 5_000, "req-g2-c": 5_000}) + + swept = arb.cleanup_game("g1") + assert swept == 2 + # g1 entries gone everywhere. + assert "req-g1-a" not in arb._pending + assert "req-g1-b" not in arb._pending + assert "req-g1-a" not in arb._active_playback + assert "req-g1-a" not in arb._playback_deadlines + # g2 entries preserved. + assert "req-g2-c" in arb._pending + assert "req-g2-c" in arb._active_playback + assert arb._playback_deadlines["req-g2-c"] == 5_000 + + +async def test_multi_addressed_seats_both_get_priority( + repo: SqliteRepo, +) -> None: + """When an utterance addresses multiple seats (e.g. + ``addressed_seat_nos=[2, 3]`` for 「セツとジナはどう?」), both seats + win the addressed-priority axis on the next dispatch. Picking 2 + consumes only its slot, leaving 3 still in the addressed set so the + follow-up dispatch picks 3 next over a non-addressed candidate. + """ + import random as _random + + g = Game( + id="rv-multi-addr", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=3, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), + Seat( + seat_no=4, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in ( + (1, Role.WEREWOLF), + (2, Role.SEER), + (3, Role.MEDIUM), + (4, Role.VILLAGER), + ): + await repo.set_player_role(g.id, sn, role) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=[1, 2, 3, 4], + created_at_ms=1, + ) + ) + # Raqio (seat 1) addresses BOTH Setsu (2) and Gina (3) in one breath. + from wolfbot.services.discussion_service import make_npc_generated_event + + await store.insert( + make_npc_generated_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="セツとジナはどう思う?", + addressed_seat_nos=(2, 3), + created_at_ms=10, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = { + "raqio": [], + "setsu": [], + "gina": [], + "jonas": [], + } + for npc_id, persona, seat_no in ( + ("npc_raqio", "raqio", 1), + ("npc_setsu", "setsu", 2), + ("npc_gina", "gina", 3), + ("npc_jonas", "jonas", 4), + ): + registry.register( + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, + persona_key=persona, + ) + registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 2000, + rng=_random.Random(0), + ) + await arb.try_dispatch_next(g.id) + + # Either seat 2 or seat 3 wins (random tiebreak), but NOT seat 4 + # (not addressed) and NOT seat 1 (just spoke). + first_winner: int | None = None + if bufs["setsu"] and not bufs["gina"]: + first_winner = 2 + elif bufs["gina"] and not bufs["setsu"]: + first_winner = 3 + assert first_winner in (2, 3) + assert not bufs["raqio"], "ラキオ just spoke — never picked" + assert not bufs["jonas"], "ジョナス wasn't addressed — must lose to addressed pair" + + +async def test_runoff_dispatch_picks_tied_llm_candidates_in_order( + repo: SqliteRepo, +) -> None: + """In DAY_RUNOFF_SPEECH the arbiter ignores the regular speech_count + rotation and dispatches to **tied** LLM candidates, in seat-no order, + one at a time. After each accepted SpeakResult, ``runoff_speech_done`` + is flipped so the engine's `plan_runoff_speech_to_runoff` can advance + once the last tied candidate finishes. Reproduces the production bug + where Master's rounds-mode batch silently ran in reactive_voice mode + and TTS never played. + """ + from wolfbot.domain.models import Vote + + g = Game( + id="rv-runoff", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_RUNOFF_SPEECH, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=3, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in ((1, Role.WEREWOLF), (2, Role.SEER), (3, Role.MEDIUM)): + await repo.set_player_role(g.id, sn, role) + + # Round-0 votes that produce tied=(1, 2). Voter 3 abstains so the + # tally is 1=1, 2=1 — clean two-way tie. + for voter, target in ((1, 2), (2, 1), (3, None)): + await repo.insert_vote( + Vote(game_id=g.id, day=1, round=0, voter_seat=voter, target_seat=target, submitted_at=1) + ) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_RUNOFF_SPEECH) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_RUNOFF_SPEECH, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"raqio": [], "setsu": [], "gina": []} + for npc_id, persona, seat_no in ( + ("npc_raqio", "raqio", 1), + ("npc_setsu", "setsu", 2), + ("npc_gina", "gina", 3), + ): + registry.register( + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, + persona_key=persona, + ) + registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) + + intros: list[int] = [] # captured runoff_announce calls + + async def _intro(seat: Seat) -> None: + intros.append(seat.seat_no) + + wakes: list[str] = [] + + def _wake(game_id: str) -> None: + wakes.append(game_id) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 2000, + runoff_announce=_intro, + runoff_wake=_wake, + ) + + # First dispatch: seat 1 (lowest tied seat-no). + await arb.try_dispatch_next(g.id) + assert intros == [1], "Master must voice the candidate intro before TTS" + assert bufs["raqio"], "席1 ラキオ must receive the SpeakRequest" + assert not bufs["setsu"] + assert not bufs["gina"], "席3 ジナ is not a tied candidate — never picked" + + seat_repr, reason = await _fetch_selection_reason(repo, g.id) + assert seat_repr == "1" + assert reason == "runoff_candidate" + + # NPC accepts → engine wakes, runoff_speech_done flips. + req_msg = next(m for m in bufs["raqio"] if '"speak_request"' in m) + import json as _json + + req_payload = _json.loads(req_msg) + request_id = req_payload["request_id"] + result = SpeakResult( + ts=2100, + trace_id="t", + request_id=request_id, + npc_id="npc_raqio", + phase_id=phase_id, + status="accepted", + text="私は皆さんの推理に矛盾を感じています。投票先を見直してほしいです。", + ) + ok, _ = await arb.handle_speak_result( + result, + current_phase_id=phase_id, + day=1, + phase=Phase.DAY_RUNOFF_SPEECH, + ) + assert ok + # Bug #3 fix: `runoff_speech_done` is intentionally NOT flipped on + # accept_speak_result anymore — the audio hasn't started playing in + # VC at that point, so flipping early would let the runoff vote + # open while the candidate's speech is still being read out loud. + # The flag now flips on PlaybackFinished (below). + progress_seat1_pre = await repo.load_llm_speech_progress(g.id, 1, 1) + assert progress_seat1_pre[4] is False, ( + "runoff_speech_done must NOT flip on accept — wait for playback" + ) + + # Simulate playback completion so the serial-speech gate releases — + # the live wiring kicks try_dispatch_next on playback_finished and + # this is also where `runoff_speech_done` is flipped now. + await arb.handle_playback_finished( + PlaybackFinished( + ts=2200, + trace_id="t", + request_id=request_id, + npc_id="npc_raqio", + started_at_ms=2150, + finished_at_ms=2200, + ) + ) + progress_seat1 = await repo.load_llm_speech_progress(g.id, 1, 1) + assert progress_seat1[4] is True, "runoff_speech_done must flip on playback_finished" + assert g.id in wakes, "engine must wake so the phase can advance" + + # Second dispatch: seat 2 (next tied seat-no). + bufs["raqio"].clear() + bufs["setsu"].clear() + bufs["gina"].clear() + intros.clear() + await arb.try_dispatch_next(g.id) + assert intros == [2] + assert bufs["setsu"], "席2 セツ must receive the SpeakRequest second" + assert not bufs["raqio"], "席1 already done — must not be re-picked" + assert not bufs["gina"] + + +async def test_runoff_dispatch_skips_when_seat_already_pending( + repo: SqliteRepo, +) -> None: + """Two sequential `try_dispatch_next` invocations on phase entry into + DAY_RUNOFF_SPEECH (post-PHASE_CHANGE-narration kick + the + `_on_reactive_phase_enter` callback) must NOT both run the Master + candidate intro. Between the first call's `dispatch_request` (which + only adds to `_pending`) and the NPC's SpeakResult arriving (which + flips `_active_playback`), the gate is open — without the + re-entrancy guard the second call picks the same chosen seat and + speaks the intro twice. Regression for the user-reported + "決選投票で最初の候補者の発言を促す案内が2回読み上げられる" bug. + """ + from wolfbot.domain.models import Vote + + g = Game( + id="rv-runoff-double-kick", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_RUNOFF_SPEECH, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + for seat in ( + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, + display_name="🌙セツ", + discord_user_id=None, + is_llm=True, + persona_key="setsu", + ), + Seat( + seat_no=3, + display_name="🟣ジナ", + discord_user_id=None, + is_llm=True, + persona_key="gina", + ), + ): + await repo.insert_seat(g.id, seat) + for sn, role in ((1, Role.WEREWOLF), (2, Role.SEER), (3, Role.MEDIUM)): + await repo.set_player_role(g.id, sn, role) + # Tie: seats 1 and 2 (voter 3 abstains). + for voter, target in ((1, 2), (2, 1), (3, None)): + await repo.insert_vote( + Vote( + game_id=g.id, + day=1, + round=0, + voter_seat=voter, + target_seat=target, + submitted_at=1, + ) + ) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_RUNOFF_SPEECH) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_RUNOFF_SPEECH, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"raqio": [], "setsu": [], "gina": []} + for npc_id, persona, seat_no in ( + ("npc_raqio", "raqio", 1), + ("npc_setsu", "setsu", 2), + ("npc_gina", "gina", 3), + ): + registry.register( + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, + persona_key=persona, + ) + registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) + + intros: list[int] = [] + + async def _intro(seat: Seat) -> None: + intros.append(seat.seat_no) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 2000, + runoff_announce=_intro, + ) + + # Two sequential kicks — mirrors the prod ordering where + # `_master_narrate`'s post-PHASE_CHANGE kick runs before + # `_on_reactive_phase_enter` fires from `_dispatch_submissions`. + # Between them, the first dispatch's SpeakRequest is in + # `_pending` but no SpeakResult has arrived yet so + # `_active_playback` is empty. + await arb.try_dispatch_next(g.id) + await arb.try_dispatch_next(g.id) + + # The intro must fire exactly once, not twice. Seat 1 (lowest + # tied seat-no) is the chosen candidate; the second kick sees + # the pending SpeakRequest and bails out. + assert intros == [1], ( + "candidate intro must be voiced exactly once even on a " + f"double phase-entry kick (got intros={intros})" + ) + # Exactly one SpeakRequest was emitted. + raqio_speak_requests = [m for m in bufs["raqio"] if '"speak_request"' in m] + assert len(raqio_speak_requests) == 1, ( + "exactly one SpeakRequest must be emitted to the chosen NPC " + f"(got {len(raqio_speak_requests)})" + ) + # The other tied seat (席2 セツ) must not be picked while seat 1 + # is still in flight. + assert not any('"speak_request"' in m for m in bufs["setsu"]) + + +async def test_runoff_dispatch_concurrent_kicks_announce_once( + repo: SqliteRepo, +) -> None: + """Two concurrent `try_dispatch_next` invocations during phase entry + into DAY_RUNOFF_SPEECH must NOT each play the Master candidate intro + even when they overlap in the await on `_runoff_announce`. + + Regression for the user-reported bug: "決選投票の最初の候補者の案内が2連続で + 読み上げられてからNPCが喋る". The pre-existing `_pending` re-entrancy + guard only caught re-entries AFTER `dispatch_request` completed; the + full announce-await window (Levi TTS, several seconds) was unguarded. + The new `_runoff_in_flight` set is added BEFORE the announce await. + """ + import asyncio + + from wolfbot.domain.models import Vote + + g = Game( + id="rv-runoff-concurrent", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_RUNOFF_SPEECH, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + for seat in ( + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, + display_name="🌙セツ", + discord_user_id=None, + is_llm=True, + persona_key="setsu", + ), + ): + await repo.insert_seat(g.id, seat) + for sn, role in ((1, Role.WEREWOLF), (2, Role.SEER)): + await repo.set_player_role(g.id, sn, role) + # Tie 1-2. + for voter, target in ((1, 2), (2, 1)): + await repo.insert_vote( + Vote( + game_id=g.id, day=1, round=0, + voter_seat=voter, target_seat=target, submitted_at=1, + ) + ) + phase_id = make_phase_id(g.id, 1, Phase.DAY_RUNOFF_SPEECH) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_RUNOFF_SPEECH, + alive_seat_nos=[1, 2], + created_at_ms=1, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"raqio": [], "setsu": []} + for npc_id, persona, seat_no in ( + ("npc_raqio", "raqio", 1), + ("npc_setsu", "setsu", 2), + ): + registry.register( + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, + persona_key=persona, + ) + registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) + + intros: list[int] = [] + intro_started = asyncio.Event() + intro_release = asyncio.Event() + + async def _intro(seat: Seat) -> None: + intros.append(seat.seat_no) + intro_started.set() + # Mimic Levi TTS taking time — block until released so the + # second concurrent kick has the chance to enter + # `_dispatch_runoff_next` before this announce completes. + await intro_release.wait() + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 2000, + runoff_announce=_intro, + ) + + # Fire both kicks concurrently. The first one wins the + # `_runoff_in_flight` slot and proceeds into _intro; the second one + # observes the slot is taken and bails before announcing. + task1 = asyncio.create_task(arb.try_dispatch_next(g.id)) + await intro_started.wait() # Path A is now blocked inside _intro + task2 = asyncio.create_task(arb.try_dispatch_next(g.id)) + # Give Path B a chance to enter and bail. + await asyncio.sleep(0.05) + intro_release.set() + await task1 + await task2 + + assert intros == [1], ( + "candidate intro must fire exactly once even on a concurrent " + f"double phase-entry kick (got intros={intros})" + ) + raqio_speak_requests = [m for m in bufs["raqio"] if '"speak_request"' in m] + assert len(raqio_speak_requests) == 1, ( + "exactly one SpeakRequest must reach the chosen NPC " + f"(got {len(raqio_speak_requests)})" + ) + + +async def test_runoff_dispatch_announces_human_candidate( + repo: SqliteRepo, +) -> None: + """Human runoff candidates get the same Levi turn-announcement as + LLM candidates. Regression for the user-reported bug where sakura + (a tied human candidate) was never announced and never given a + chance to speak before the runoff vote opened. + """ + from wolfbot.domain.models import Vote + + g = Game( + id="rv-runoff-human", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_RUNOFF_SPEECH, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + for seat in ( + Seat( + seat_no=1, display_name="sakura", discord_user_id="u1", + is_llm=False, persona_key=None, + ), + Seat( + seat_no=5, display_name="🎩ジョナス", discord_user_id=None, + is_llm=True, persona_key="jonas", + ), + ): + await repo.insert_seat(g.id, seat) + for sn, role in ((1, Role.MEDIUM), (5, Role.KNIGHT)): + await repo.set_player_role(g.id, sn, role) + for voter, target in ((1, 5), (5, 1)): + await repo.insert_vote( + Vote( + game_id=g.id, day=1, round=0, + voter_seat=voter, target_seat=target, submitted_at=1, + ) + ) + phase_id = make_phase_id(g.id, 1, Phase.DAY_RUNOFF_SPEECH) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_RUNOFF_SPEECH, + alive_seat_nos=[1, 5], + created_at_ms=1, + ) + ) + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"jonas": []} + registry.register( + npc_id="npc_jonas", + discord_bot_user_id="bot_jonas", + supported_voices=(), + version="1", + send=_captured_send(bufs["jonas"]), + now_ms=1000, + persona_key="jonas", + ) + registry.assign("npc_jonas", seat=5, game_id=g.id, phase_id=phase_id) + + intros: list[int] = [] + + async def _intro(seat: Seat) -> None: + intros.append(seat.seat_no) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 2000, + runoff_announce=_intro, + ) + + # First dispatch: human (席1 sakura) is announced first by seat-no order. + await arb.try_dispatch_next(g.id) + assert intros == [1], ( + "human runoff candidate must receive a Levi intro " + f"(got intros={intros})" + ) + # No SpeakRequest is sent for a human candidate. + assert not any('"speak_request"' in m for m in bufs["jonas"]), ( + "no NPC SpeakRequest must be dispatched for a human candidate" + ) + # Human's `runoff_speech_done` flag stays False until they speak + # (or the grace watchdog fires). + progress = await repo.load_llm_speech_progress(g.id, 1, 1) + assert progress[4] is False + + +async def test_runoff_human_speech_event_auto_marks_done( + repo: SqliteRepo, +) -> None: + """When a human runoff candidate speaks (voice or text), the + SpeechEvent record is detected on the next `try_dispatch_next` and + `runoff_speech_done` is auto-flipped so the engine can advance. + """ + from wolfbot.domain.models import Vote + from wolfbot.services.discussion_service import make_human_text_event + + g = Game( + id="rv-runoff-human-spoke", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_RUNOFF_SPEECH, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + for seat in ( + Seat( + seat_no=1, display_name="sakura", discord_user_id="u1", + is_llm=False, persona_key=None, + ), + Seat( + seat_no=5, display_name="🎩ジョナス", discord_user_id=None, + is_llm=True, persona_key="jonas", + ), + ): + await repo.insert_seat(g.id, seat) + for sn, role in ((1, Role.MEDIUM), (5, Role.KNIGHT)): + await repo.set_player_role(g.id, sn, role) + for voter, target in ((1, 5), (5, 1)): + await repo.insert_vote( + Vote( + game_id=g.id, day=1, round=0, + voter_seat=voter, target_seat=target, submitted_at=1, + ) + ) + phase_id = make_phase_id(g.id, 1, Phase.DAY_RUNOFF_SPEECH) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_RUNOFF_SPEECH, + alive_seat_nos=[1, 5], + created_at_ms=1, + ) + ) + + # Pre-record the human's speech event (mimics what + # `discord_service.on_message` writes after sakura types her + # final speech in the VC chat). + human_event = make_human_text_event( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_RUNOFF_SPEECH, + speaker_seat=1, + text="どうか霊媒師である私を残してください。", + ) + await discussion.record(human_event) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"jonas": []} + registry.register( + npc_id="npc_jonas", + discord_bot_user_id="bot_jonas", + supported_voices=(), + version="1", + send=_captured_send(bufs["jonas"]), + now_ms=1000, + persona_key="jonas", + ) + registry.assign("npc_jonas", seat=5, game_id=g.id, phase_id=phase_id) + + intros: list[int] = [] + + async def _intro(seat: Seat) -> None: + intros.append(seat.seat_no) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 2000, + runoff_announce=_intro, + ) + + # First dispatch: scans the speech_events table, sees seat 1's + # SpeechEvent, auto-marks `runoff_speech_done`, then continues + # with the next eligible candidate (jonas, LLM). + await arb.try_dispatch_next(g.id) + + progress_human = await repo.load_llm_speech_progress(g.id, 1, 1) + assert progress_human[4] is True, ( + "tied human candidate's `runoff_speech_done` must auto-flip " + "when their SpeechEvent is already recorded" + ) + # Human is already done — sakura's intro is NOT replayed; jonas + # (the next eligible) gets the announcement. + assert intros == [5], ( + "after auto-marking the spoken human, the next eligible LLM " + f"candidate must be picked (got intros={intros})" + ) + + +async def test_runoff_dispatch_marks_done_on_offline_npc( + repo: SqliteRepo, +) -> None: + """A tied candidate whose NPC bot is offline must be marked done so + the phase advances rather than stalling on a permanently-silent seat. + """ + from wolfbot.domain.models import Vote + + g = Game( + id="rv-runoff-offline", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_RUNOFF_SPEECH, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + for seat in ( + Seat( + seat_no=1, + display_name="🦋ラキオ", + discord_user_id=None, + is_llm=True, + persona_key="raqio", + ), + Seat( + seat_no=2, display_name="🌙セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=3, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), + ): + await repo.insert_seat(g.id, seat) + for sn, role in ((1, Role.WEREWOLF), (2, Role.SEER), (3, Role.MEDIUM)): + await repo.set_player_role(g.id, sn, role) + # Tie: seats 1 and 2 (voter 3 abstains). + for voter, target in ((1, 2), (2, 1), (3, None)): + await repo.insert_vote( + Vote(game_id=g.id, day=1, round=0, voter_seat=voter, target_seat=target, submitted_at=1) + ) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_RUNOFF_SPEECH) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_RUNOFF_SPEECH, + alive_seat_nos=[1, 2, 3], + created_at_ms=1, + ) + ) + + # ONLY seat 2's NPC bot is online. Seat 1 (also tied) has no NPC. + registry = InMemoryNpcRegistry() + buf2: list[str] = [] + registry.register( + npc_id="npc_setsu", + discord_bot_user_id="bot_setsu", + supported_voices=(), + version="1", + send=_captured_send(buf2), + now_ms=1000, + persona_key="setsu", + ) + registry.assign("npc_setsu", seat=2, game_id=g.id, phase_id=phase_id) + + intros: list[int] = [] + + async def _intro(seat: Seat) -> None: + intros.append(seat.seat_no) + + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 2000, + runoff_announce=_intro, + ) + + await arb.try_dispatch_next(g.id) + + # Seat 1 had no online NPC → marked done. Seat 2 is online → got + # the SpeakRequest with intro voiced. + progress_seat1 = await repo.load_llm_speech_progress(g.id, 1, 1) + assert progress_seat1[4] is True, "offline candidate must be marked done" + assert intros == [2] + assert buf2, "online tied candidate must receive the SpeakRequest" + + +async def test_runoff_watchdog_does_not_pop_pending_after_speak_result_accepted( + repo: SqliteRepo, +) -> None: + """Regression for game d57c5d83ed4a day 2: ジョナス returned a SpeakResult + in 5s but her TTS playback ran 11.6s — longer than the 8s watchdog TTL. + The watchdog spuriously popped _pending while playback was still active. + Then `_on_playback_finished` lost the game_id lookup (it depends on + _pending) and never called try_dispatch_next, so シゲミチ (the other + tied candidate) was never dispatched and the runoff stalled forever. + + Fix: watchdog must early-exit when the request is in _active_playback + (= SpeakResult was accepted; playback handler will re-dispatch on finish). + """ + import asyncio + + from wolfbot.domain.models import Vote + + g = Game( + id="rv-runoff-watchdog", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_RUNOFF_SPEECH, + day_number=1, + deadline_epoch=10**12, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + for seat in ( + Seat( + seat_no=1, + display_name="🎩ジョナス", + discord_user_id=None, + is_llm=True, + persona_key="jonas", + ), + Seat( + seat_no=6, + display_name="👽シゲミチ", + discord_user_id=None, + is_llm=True, + persona_key="shigemichi", + ), + Seat( + seat_no=2, display_name="🟣ジナ", discord_user_id=None, is_llm=True, persona_key="gina" + ), + ): + await repo.insert_seat(g.id, seat) + for sn, role in ((1, Role.WEREWOLF), (6, Role.VILLAGER), (2, Role.KNIGHT)): + await repo.set_player_role(g.id, sn, role) + # Tie between 1 and 6. + for voter, target in ((1, 6), (6, 1), (2, None)): + await repo.insert_vote( + Vote(game_id=g.id, day=1, round=0, voter_seat=voter, target_seat=target, submitted_at=1) + ) + + phase_id = make_phase_id(g.id, 1, Phase.DAY_RUNOFF_SPEECH) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=1, + phase=Phase.DAY_RUNOFF_SPEECH, + alive_seat_nos=[1, 2, 6], + created_at_ms=1, + ) + ) + + registry = InMemoryNpcRegistry() + bufs: dict[str, list[str]] = {"jonas": [], "shigemichi": []} + for npc_id, persona, seat_no in ( + ("npc_jonas", "jonas", 1), + ("npc_shigemichi", "shigemichi", 6), + ): + registry.register( + npc_id=npc_id, + discord_bot_user_id=f"bot_{persona}", + supported_voices=(), + version="1", + send=_captured_send(bufs[persona]), + now_ms=1000, + persona_key=persona, + ) + registry.assign(npc_id, seat=seat_no, game_id=g.id, phase_id=phase_id) + + intros: list[int] = [] + + async def _intro(seat: Seat) -> None: + intros.append(seat.seat_no) + + # Very short TTL so the watchdog fires within the test window without + # asyncio.sleep(8). 50ms is enough to let _watchdog wake up. + cfg = SpeakArbiterConfig(request_ttl_ms=50) + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 2000, + runoff_announce=_intro, + config=cfg, + ) + + # Step 1: dispatch first candidate (ジョナス). + await arb.try_dispatch_next(g.id) + assert intros == [1] + assert bufs["jonas"], "first tied candidate must receive SpeakRequest" + + req_msg = next(m for m in bufs["jonas"] if '"speak_request"' in m) + import json as _json + + request_id = _json.loads(req_msg)["request_id"] + + # Step 2: NPC accepts SpeakResult (adds to _active_playback, leaves + # _pending populated until playback finishes). + result = SpeakResult( + ts=2050, + trace_id="t", + request_id=request_id, + npc_id="npc_jonas", + phase_id=phase_id, + status="accepted", + text="諸君……ラキオの占い師主張など、笑止千万!", + ) + ok, _ = await arb.handle_speak_result( + result, + current_phase_id=phase_id, + day=1, + phase=Phase.DAY_RUNOFF_SPEECH, + ) + assert ok + assert request_id in arb._active_playback + assert request_id in arb._pending # not popped yet — playback ongoing + + # Step 3: wait long enough for the watchdog to wake (TTL=50ms + + # scheduling slack). The fix's early-exit guard must prevent the + # watchdog from popping _pending. + await asyncio.sleep(0.2) + + assert request_id in arb._pending, ( + "watchdog must NOT pop _pending while SpeakResult is still in playback. " + "If this fails, _on_playback_finished will lose game_id lookup and " + "the runoff will stall (see game d57c5d83ed4a day 2)." + ) + + # Step 4: simulate playback completion. _pending lookup must still work, + # so the next candidate gets dispatched. + bufs["jonas"].clear() + bufs["shigemichi"].clear() + intros.clear() + await arb.handle_playback_finished( + PlaybackFinished( + ts=2200, + trace_id="t", + request_id=request_id, + npc_id="npc_jonas", + started_at_ms=2050, + finished_at_ms=2200, + ) + ) + # In production this is wrapped by main.py's _on_playback_finished + # which calls try_dispatch_next — simulate that here. + await arb.try_dispatch_next(g.id) + assert intros == [6], "second tied candidate must be dispatched after first finishes" + assert bufs["shigemichi"], "席6 シゲミチ must receive the second SpeakRequest" + + +def test_logic_packet_builder_includes_co_claims_in_summary() -> None: + state = PublicDiscussionState( + game_id="g", + phase_id="g::day1::DAY_DISCUSSION::1", + day=1, + alive_seat_nos=frozenset({1, 2, 3}), + ) + # CoClaim is normally derived; build one manually for the unit test. + from wolfbot.domain.discussion import CoClaim + + state.co_claims = (CoClaim(seat=2, role_claim="seer", declared_at_event_id="e1"),) + state.silent_seats = frozenset({3}) + packet = build_logic_packet( + state=state, + recipient_npc_id="npc_p3", + expires_at_ms=2000, + now_ms=1500, + seat_names={1: "Alice", 2: "Bob", 3: "Carol"}, + ) + assert packet.recipient_npc_id == "npc_p3" + assert packet.expires_at_ms == 2000 + assert any(c.id == "co-2-seer" for c in packet.logic_candidates) + # Naming policy: summary renders by display_name when ``seat_names`` + # is supplied (production wiring always supplies it). + assert "Bob=seer" in packet.public_state_summary + assert "silent_seats=[Carol]" in packet.public_state_summary + # Without `seat_names` (legacy / unit-test fallback) the summary + # falls back to 席N — covered by the next test. + + +def test_logic_packet_wolf_decision_block_fires_for_active_callout() -> None: + """When the recipient is a werewolf/madman without an info CO and an + info-role callout is active, the LogicPacket's public_state_summary + must carry the explicit 「対抗 CO のチャンス」 decision block. This + is the wolf-side prompt extension the user requested: instead of + leaving the wolf to drift into generic chat, frame the turn as a + binary "fake CO or skip" decision with role-specific tactical hints. + """ + from wolfbot.domain.discussion import CoClaim + from wolfbot.domain.enums import Role + + state = PublicDiscussionState( + game_id="g", + phase_id="g::day1::DAY_DISCUSSION::1", + day=1, + alive_seat_nos=frozenset({1, 2, 3, 4, 5}), + ) + # Real seer at seat 2 has CO'd; pending_role_callouts captures the + # counter-CO opportunity for other info roles. + state.co_claims = (CoClaim(seat=2, role_claim="seer", declared_at_event_id="e1"),) + state.pending_co_response = frozenset({"seer"}) + + packet = build_logic_packet( + state=state, + recipient_npc_id="npc_wolf", + recipient_seat_no=4, + recipient_role=Role.WEREWOLF, + expires_at_ms=2000, + now_ms=1500, + seat_names={1: "Alice", 2: "Bob", 3: "Carol", 4: "Dave", 5: "Eve"}, + ) + summary = packet.public_state_summary + assert "対抗 CO のチャンス" in summary, ( + "wolf-side summary must surface the explicit fake-CO decision block" + ) + assert "占い師" in summary + assert "co_declaration" in summary, "must mention the structured field" + + +def test_logic_packet_wolf_decision_block_skipped_when_already_co() -> None: + """A wolf that has already CO'd as an info role must NOT see the + decision block again — they're already committed.""" + from wolfbot.domain.discussion import CoClaim + from wolfbot.domain.enums import Role + + state = PublicDiscussionState( + game_id="g", + phase_id="g::day1::DAY_DISCUSSION::1", + day=1, + alive_seat_nos=frozenset({1, 2, 3, 4}), + ) + state.co_claims = ( + CoClaim(seat=2, role_claim="seer", declared_at_event_id="e1"), + CoClaim(seat=4, role_claim="seer", declared_at_event_id="e2"), + ) + state.pending_co_response = frozenset({"seer"}) + + packet = build_logic_packet( + state=state, + recipient_npc_id="npc_wolf", + recipient_seat_no=4, + recipient_role=Role.WEREWOLF, + expires_at_ms=2000, + now_ms=1500, + seat_names={1: "Alice", 2: "Bob", 3: "Carol", 4: "Dave"}, + ) + assert "対抗 CO のチャンス" not in packet.public_state_summary, ( + "wolf already CO'd as seer must not be prompted to CO again" + ) + + +def test_logic_packet_wolf_decision_block_skipped_for_villager() -> None: + """Real village-side recipients (villager / seer / medium / knight) + must never see the wolf decision block, even when the same callout + is active.""" + from wolfbot.domain.discussion import CoClaim + from wolfbot.domain.enums import Role + + state = PublicDiscussionState( + game_id="g", + phase_id="g::day1::DAY_DISCUSSION::1", + day=1, + alive_seat_nos=frozenset({1, 2, 3}), + ) + state.co_claims = (CoClaim(seat=2, role_claim="seer", declared_at_event_id="e1"),) + state.pending_co_response = frozenset({"seer"}) + + packet = build_logic_packet( + state=state, + recipient_npc_id="npc_villager", + recipient_seat_no=3, + recipient_role=Role.VILLAGER, + expires_at_ms=2000, + now_ms=1500, + seat_names={1: "Alice", 2: "Bob", 3: "Carol"}, + ) + assert "対抗 CO のチャンス" not in packet.public_state_summary + + +def test_logic_packet_summary_falls_back_to_seat_when_no_names_supplied() -> None: + state = PublicDiscussionState( + game_id="g", + phase_id="g::day1::DAY_DISCUSSION::1", + day=1, + alive_seat_nos=frozenset({1, 2, 3}), + ) + from wolfbot.domain.discussion import CoClaim + + state.co_claims = (CoClaim(seat=2, role_claim="seer", declared_at_event_id="e1"),) + state.silent_seats = frozenset({3}) + packet = build_logic_packet( + state=state, + recipient_npc_id="npc_p3", + expires_at_ms=2000, + now_ms=1500, + ) + # No seat_names → legacy 席N rendering preserved for back-compat. + assert "席2=seer" in packet.public_state_summary + assert "silent_seats=[席3]" in packet.public_state_summary + + +# ─── fabrication-retry path ────────────────────────────────────────── + + +async def _seed_seer_with_divine(repo: SqliteRepo) -> Game: + """Seed a game where seat 2 is real seer with NIGHT_0 random white on seat 1. + + Seat 1 is villager so seat 2's NIGHT_0 random white pointing at seat 1 + is internally consistent (the system never picks a wolf for the random + white). We seed the result via a ``SEER_RESULT_NIGHT0`` row in + ``logs_private`` so the validator's ``_load_actual_seer_history`` + (which goes through ``load_private_state_for_seat``) picks it up the + same way the NPC's ``自分の占い結果`` prompt section does. + + The earlier version of this helper inserted into ``night_actions``, + which the NPC's prompt builder ignores for NIGHT_0; the validator + then read truth from a different source than the NPC and bounced + every legitimate Setsu claim. Game ``6a0dd72d63e3`` reproduced the + bug: real seer Setsu hit the 5-retry cap on her FIRST legitimate + Jonas白 claim because the validator couldn't find Jonas in her + actual history. + """ + from wolfbot.domain.models import LogEntry + + g = Game( + id="rv_fab", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="Alice", discord_user_id="u1", is_llm=False, persona_key=None), + Seat( + seat_no=2, + display_name="セツ", + discord_user_id=None, + is_llm=True, + persona_key="setsu", + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + # Seat 1 must NOT be a wolf — NIGHT_0 random white only ever picks + # non-wolf targets, so this keeps the seed internally consistent + # with the seer's recorded result. + await repo.set_player_role(g.id, 1, Role.VILLAGER) + await repo.set_player_role(g.id, 2, Role.SEER) + # NIGHT_0 random white on seat 1 (Alice). Color = white (= is_wolf False). + # The text format must match `private_state._SEER_NIGHT0_RE` so the + # validator's `load_private_state_for_seat` parses it correctly. + await repo.insert_log_private(LogEntry( + game_id=g.id, + day=0, + phase=Phase.NIGHT_0, + kind="SEER_RESULT_NIGHT0", + actor_seat=2, + visibility="PRIVATE", + audience_seat=2, + text="初日ランダム白: Alice は 人狼ではありません。", + created_at=0, + )) + # Seed phase_baseline so rebuild_public_state returns non-None. + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=make_phase_id(g.id, 1, Phase.DAY_DISCUSSION), + day=1, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=(1, 2), + created_at_ms=900, + ) + ) + return g + + +async def test_real_seer_legal_claim_passes(repo: SqliteRepo) -> None: + """Real seer claiming the actual NIGHT_0 target+color is accepted.""" + from wolfbot.domain.ws_messages import ClaimedSeerResult + + g = await _seed_seer_with_divine(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + persona_key="setsu", + ) + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 1500, + ) + state = _seed_state(g.id) + req, _ = await arb.dispatch_request( + state=state, + candidate_npc_id="npc_p2", + seat_no=2, + game_id=g.id, + ) + assert req is not None + npc_buf.clear() + result = SpeakResult( + ts=1600, + trace_id="t", + request_id=req.request_id, + npc_id="npc_p2", + phase_id=req.phase_id, + status="accepted", + text="私は占い師。Aliceを占ったら白。", + co_declaration="seer", + # Real seer's NIGHT_0 random white is on seat 1 (Alice, villager). + # Matches the seeded SEER_RESULT_NIGHT0 entry exactly. + claimed_seer_result=ClaimedSeerResult(target_seat=1, is_wolf=False), + ) + ok, reason = await arb.handle_speak_result( + result, + current_phase_id=req.phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + ) + assert ok and reason is None, f"expected accept, got reason={reason}" + # PlaybackAuthorized should be on the back-channel. + assert any('"playback_authorized"' in m for m in npc_buf) + + +async def test_real_seer_fabricated_target_triggers_retry( + repo: SqliteRepo, +) -> None: + """Real seer claiming an unrecorded target gets PlaybackRejected and + is re-dispatched to the same NPC with retry_feedback embedded.""" + from wolfbot.domain.ws_messages import ClaimedSeerResult, SpeakRequest + + g = await _seed_seer_with_divine(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + persona_key="setsu", + ) + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 1500, + ) + state = _seed_state(g.id) + req, _ = await arb.dispatch_request( + state=state, + candidate_npc_id="npc_p2", + seat_no=2, + game_id=g.id, + ) + assert req is not None + npc_buf.clear() + # The seeded NIGHT_0 result is `target=1, is_wolf=False`. Flipping + # the verdict to is_wolf=True for the same target triggers + # REASON_SEER_WRONG_VERDICT (one of FABRICATION_REASONS). + bad = SpeakResult( + ts=1600, + trace_id="t", + request_id=req.request_id, + npc_id="npc_p2", + phase_id=req.phase_id, + status="accepted", + text="Aliceを占ったら黒だった", + co_declaration="seer", + claimed_seer_result=ClaimedSeerResult(target_seat=1, is_wolf=True), + ) + ok, reason = await arb.handle_speak_result( + bad, + current_phase_id=req.phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + ) + assert not ok + assert reason == "fabricated_seer_verdict" + # 1) PlaybackRejected was sent. + rejections = [m for m in npc_buf if '"playback_rejected"' in m] + assert len(rejections) == 1 + rej = PlaybackRejected.model_validate_json(rejections[0]) + assert rej.failure_reason == "fabricated_seer_verdict" + # 2) A new SpeakRequest was dispatched to the same NPC. + speak_requests = [m for m in npc_buf if '"speak_request"' in m] + assert len(speak_requests) == 1 + retry = SpeakRequest.model_validate_json(speak_requests[0]) + assert retry.npc_id == "npc_p2" + assert retry.seat_no == 2 + # 3) retry_feedback non-empty and references the correction. + assert retry.retry_feedback is not None + assert "claimed_seer_result" in retry.retry_feedback + + +async def test_fabrication_retries_capped_then_falls_back( + repo: SqliteRepo, +) -> None: + """After 5 consecutive fabrications the same NPC is no longer + re-dispatched — the rejection becomes a normal _reject_and_advance.""" + from wolfbot.domain.ws_messages import ClaimedSeerResult + + g = await _seed_seer_with_divine(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + persona_key="setsu", + ) + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 1500, + ) + state = _seed_state(g.id) + # Wrong verdict for the seeded target (real history has is_wolf=False). + bad_claim = ClaimedSeerResult(target_seat=1, is_wolf=True) + last_reason = None + last_phase_id = None + # 5 attempts of fabrication. Each loop dispatches a fresh request, + # then feeds a fabricated SpeakResult back to the arbiter. + for i in range(5): + req, _ = await arb.dispatch_request( + state=state, + candidate_npc_id="npc_p2", + seat_no=2, + game_id=g.id, + ) + assert req is not None, f"dispatch failed at attempt {i}" + last_phase_id = req.phase_id + bad = SpeakResult( + ts=1600 + i, + trace_id="t", + request_id=req.request_id, + npc_id="npc_p2", + phase_id=req.phase_id, + status="accepted", + text=f"attempt {i} bogus claim", + co_declaration="seer", + claimed_seer_result=bad_claim, + ) + ok, reason = await arb.handle_speak_result( + bad, + current_phase_id=req.phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + ) + assert not ok + last_reason = reason + # By now _fabrication_retries[(g.id, phase_id, npc_p2)] == 5; cap is 5 + # so the next fabrication should bail to normal rotation (no retry + # dispatch from `_reject_and_retry_same_npc`). + npc_buf.clear() + # After 5 attempts the counter reached the cap. The arbiter no + # longer re-dispatches the same NPC; the rejection path went through + # `_reject_and_advance` (which calls try_dispatch_next, but with + # only 1 NPC online and that NPC just declined, nothing new dispatches). + assert last_reason == "fabricated_seer_verdict" + counter = arb._fabrication_retries.get( # type: ignore[attr-defined] + (g.id, last_phase_id, "npc_p2") + ) + assert counter == 5, f"expected 5 fabrications recorded, got {counter}" + + +async def test_accept_resets_fabrication_counter(repo: SqliteRepo) -> None: + """A successful accept clears the per-(game, phase, npc) counter so + the cap doesn't carry over from earlier fabrications.""" + from wolfbot.domain.ws_messages import ClaimedSeerResult + + g = await _seed_seer_with_divine(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + persona_key="setsu", + ) + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 1500, + ) + state = _seed_state(g.id) + # Attempt 1: fabrication. + req1, _ = await arb.dispatch_request( + state=state, + candidate_npc_id="npc_p2", + seat_no=2, + game_id=g.id, + ) + assert req1 is not None + bad = SpeakResult( + ts=1600, + trace_id="t", + request_id=req1.request_id, + npc_id="npc_p2", + phase_id=req1.phase_id, + status="accepted", + text="間違い", + co_declaration="seer", + # Wrong verdict (truth is is_wolf=False on seat 1). + claimed_seer_result=ClaimedSeerResult(target_seat=1, is_wolf=True), + ) + await arb.handle_speak_result( + bad, + current_phase_id=req1.phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + ) + key = (g.id, req1.phase_id, "npc_p2") + assert arb._fabrication_retries.get(key) == 1 # type: ignore[attr-defined] + # The retry path issued a new SpeakRequest. Pick it up via npc_buf. + speak_reqs = [m for m in npc_buf if '"speak_request"' in m] + from wolfbot.domain.ws_messages import SpeakRequest as SR + + retry_req = SR.model_validate_json(speak_reqs[-1]) + # Attempt 2: correct claim (matches seeded NIGHT_0 white). + good = SpeakResult( + ts=1700, + trace_id="t", + request_id=retry_req.request_id, + npc_id="npc_p2", + phase_id=retry_req.phase_id, + status="accepted", + text="Aliceは白だった", + co_declaration="seer", + claimed_seer_result=ClaimedSeerResult(target_seat=1, is_wolf=False), + ) + ok, _ = await arb.handle_speak_result( + good, + current_phase_id=retry_req.phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + ) + assert ok + # Counter cleared. + assert key not in arb._fabrication_retries # type: ignore[attr-defined] + + +async def test_fake_seer_target_swap_triggers_retry(repo: SqliteRepo) -> None: + """Fake seer (wolf) who claimed Bob白 then tries to claim Alice白 in + the same morning gets rejected with retry feedback.""" + from wolfbot.domain.ws_messages import ClaimedSeerResult, SpeakRequest + + g = Game( + id="rv_fake", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=2, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="Alice", discord_user_id="u1", is_llm=False, persona_key=None), + Seat( + seat_no=2, display_name="セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat( + seat_no=3, + display_name="ユリコ", + discord_user_id=None, + is_llm=True, + persona_key="yuriko", + ), + ] + for s in seats: + await repo.insert_seat(g.id, s) + await repo.set_player_role(g.id, 1, Role.VILLAGER) + await repo.set_player_role(g.id, 2, Role.SEER) + await repo.set_player_role(g.id, 3, Role.WEREWOLF) + # Seed a prior public claim from seat 3 (wolf) on day 2: target=1 white. + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + phase_id = make_phase_id(g.id, 2, Phase.DAY_DISCUSSION) + await store.insert( + make_phase_baseline( + game_id=g.id, + phase_id=phase_id, + day=2, + phase=Phase.DAY_DISCUSSION, + alive_seat_nos=(1, 2, 3), + created_at_ms=900, + ) + ) + from wolfbot.domain.discussion import ( + SpeakerKind as SK, + ) + from wolfbot.domain.discussion import ( + SpeechEvent as SE, + ) + from wolfbot.domain.discussion import ( + SpeechSource as SS, + ) + + await store.insert( + SE( + event_id="e_prior_claim", + game_id=g.id, + phase_id=phase_id, + day=2, + phase=Phase.DAY_DISCUSSION, + source=SS.NPC_GENERATED, + speaker_kind=SK.NPC, + speaker_seat=3, + text="私が占い師。Alice白", + co_declaration="seer", + claimed_seer_target_seat=1, + claimed_seer_is_wolf=False, + created_at_ms=1000, + ) + ) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p3", + discord_bot_user_id="bot3", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + persona_key="yuriko", + ) + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 1500, + ) + state = PublicDiscussionState( + game_id=g.id, + phase_id=phase_id, + day=2, + alive_seat_nos=frozenset({1, 2, 3}), + silent_seats=frozenset({1, 2}), + ) + req, _ = await arb.dispatch_request( + state=state, + candidate_npc_id="npc_p3", + seat_no=3, + game_id=g.id, + ) + assert req is not None + npc_buf.clear() + # Wolf tries to swap day-2 target to seat 2. + swap = SpeakResult( + ts=1600, + trace_id="t", + request_id=req.request_id, + npc_id="npc_p3", + phase_id=req.phase_id, + status="accepted", + text="セツを占ったら白", + co_declaration="seer", + claimed_seer_result=ClaimedSeerResult(target_seat=2, is_wolf=False), + ) + ok, reason = await arb.handle_speak_result( + swap, + current_phase_id=req.phase_id, + day=2, + phase=Phase.DAY_DISCUSSION, + ) + assert not ok + assert reason == "seer_target_swap" + # Retry was dispatched. + speak_reqs = [m for m in npc_buf if '"speak_request"' in m] + assert len(speak_reqs) == 1 + retry = SpeakRequest.model_validate_json(speak_reqs[0]) + assert retry.npc_id == "npc_p3" + assert retry.retry_feedback is not None and "席1" in retry.retry_feedback + + +async def test_null_claim_passes_validator(repo: SqliteRepo) -> None: + """Utterance with claimed_*_result=None bypasses validation entirely + even from the real seer (general talk should never be rejected).""" + g = await _seed_seer_with_divine(repo) + registry = InMemoryNpcRegistry() + npc_buf: list[str] = [] + registry.register( + npc_id="npc_p2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_captured_send(npc_buf), + now_ms=1000, + persona_key="setsu", + ) + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter( + repo=repo, + registry=registry, + discussion=discussion, + now_ms=lambda: 1500, + ) + state = _seed_state(g.id) + req, _ = await arb.dispatch_request( + state=state, + candidate_npc_id="npc_p2", + seat_no=2, + game_id=g.id, + ) + assert req is not None + npc_buf.clear() + result = SpeakResult( + ts=1600, + trace_id="t", + request_id=req.request_id, + npc_id="npc_p2", + phase_id=req.phase_id, + status="accepted", + text="うーん怪しいかも", + ) + ok, reason = await arb.handle_speak_result( + result, + current_phase_id=req.phase_id, + day=1, + phase=Phase.DAY_DISCUSSION, + ) + assert ok and reason is None + + +async def test_fabrication_cap_blocks_seat_from_re_selection( + repo: SqliteRepo, +) -> None: + """After a seat hits ``_MAX_FABRICATION_RETRIES``, the picker must + NOT re-select that seat for the rest of the phase. Without this + block, normal rotation re-picks the same NPC immediately (especially + when they're in the seer-callout pool), the retry counter increments + past the cap (observed in game ``6366cb014a0a``: attempt=6/5, 7/5, + 8/5, 9/5...), and the phase stalls because no other NPC ever gets + dispatched. + + Setup: 2 LLM seats online, both online for the same phase. NPC A + fabricates 5 times in a row → cap fires → A is blocked. The + arbiter's ``try_dispatch_next`` should now pick NPC B. + """ + from wolfbot.domain.ws_messages import ClaimedSeerResult + + g = await _seed_seer_with_divine(repo) + # Add a 3rd LLM seat and overwrite the phase_baseline so the alive + # set in `rebuild_public_state` includes seat 3 as a dispatchable + # candidate. The fold uses the FIRST baseline it sees, so we must + # delete the original (alive=(1,2)) before inserting the new one. + third = Seat( + seat_no=3, display_name="Charlie", + discord_user_id=None, is_llm=True, persona_key="raqio", + ) + await repo.insert_seat(g.id, third) + await repo.set_player_role(g.id, 3, Role.VILLAGER) + await repo._conn.execute( # type: ignore[attr-defined] + "DELETE FROM speech_events WHERE game_id=? AND source='phase_baseline'", + (g.id,), + ) + await repo._conn.commit() # type: ignore[attr-defined] + store_extra = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + await store_extra.insert(make_phase_baseline( + game_id=g.id, + phase_id=make_phase_id(g.id, 1, Phase.DAY_DISCUSSION), + day=1, phase=Phase.DAY_DISCUSSION, + alive_seat_nos=(1, 2, 3), created_at_ms=950, + )) + + registry = InMemoryNpcRegistry() + a_buf: list[str] = [] + b_buf: list[str] = [] + registry.register( + npc_id="npc_p2", discord_bot_user_id="bot2", supported_voices=(), + version="1", send=_captured_send(a_buf), now_ms=1000, + persona_key="setsu", + ) + registry.register( + npc_id="npc_p3", discord_bot_user_id="bot3", supported_voices=(), + version="1", send=_captured_send(b_buf), now_ms=1000, + persona_key="raqio", + ) + registry.assign("npc_p2", seat=2, game_id=g.id, + phase_id=make_phase_id(g.id, 1, Phase.DAY_DISCUSSION)) + registry.assign("npc_p3", seat=3, game_id=g.id, + phase_id=make_phase_id(g.id, 1, Phase.DAY_DISCUSSION)) + + discussion = DiscussionService( + store=SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + ) + arb = SpeakArbiter( + repo=repo, registry=registry, discussion=discussion, + now_ms=lambda: 1500, + ) + bad_claim = ClaimedSeerResult(target_seat=1, is_wolf=True) + + last_phase_id = None + # 5 fabrication attempts on seat 2 → cap. + for i in range(5): + req, _ = await arb.dispatch_request( + state=_seed_state(g.id), candidate_npc_id="npc_p2", + seat_no=2, game_id=g.id, + ) + assert req is not None + last_phase_id = req.phase_id + bad = SpeakResult( + ts=1600 + i, trace_id="t", request_id=req.request_id, + npc_id="npc_p2", phase_id=req.phase_id, status="accepted", + text=f"attempt {i}", co_declaration="seer", + claimed_seer_result=bad_claim, + ) + await arb.handle_speak_result( + bad, current_phase_id=req.phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + ) + + # Seat 2 must now be in the cap-hit set. + assert (g.id, last_phase_id) in arb._fabrication_capped # type: ignore[attr-defined] + capped = arb._fabrication_capped[(g.id, last_phase_id)] # type: ignore[attr-defined] + assert 2 in capped + assert 3 not in capped + + # When the picker runs `try_dispatch_next`, it must skip seat 2 + # and dispatch to seat 3 instead. We exercise this by calling + # `try_dispatch_next` directly and checking that npc_p3's send buffer + # received a SpeakRequest while npc_p2's didn't get a fresh one. + a_buf.clear() + b_buf.clear() + await arb.try_dispatch_next(g.id) + # Seat 2 (capped) should NOT have been dispatched. + assert not any('"speak_request"' in m for m in a_buf), ( + "capped seat 2 should be skipped, but got: " + str(a_buf) + ) + # Seat 3 (not capped) should have been dispatched. + assert any('"speak_request"' in m for m in b_buf), ( + "seat 3 should have been picked, got buf: " + str(b_buf) + ) + + +async def test_co_cap_exceeded_one_shot_skip_no_retry(repo: SqliteRepo) -> None: + """A 4th seat trying to seer-CO when the ledger already has 3 + distinct seers gets PlaybackRejected on this dispatch — but the + fabrication retry counter stays 0 and the seat is NOT blocked + from future picks. The cap is a structural rule, not a self- + correction problem; retrying the same NPC with feedback won't + help (the model would have to abandon CO entirely, which is + cleaner to enforce by simply not re-dispatching this attempt).""" + from wolfbot.domain.discussion import ( + SpeakerKind as SK, + ) + from wolfbot.domain.discussion import ( + SpeechEvent as SE, + ) + from wolfbot.domain.discussion import ( + SpeechSource as SS, + ) + from wolfbot.domain.ws_messages import ClaimedSeerResult + + g = Game( + id="rv_cocap", guild_id="gu", host_user_id="h", + phase=Phase.DAY_DISCUSSION, day_number=1, + main_text_channel_id="c1", main_vc_channel_id="c2", + created_at=0, discussion_mode="reactive_voice", + ) + await repo.create_game(g) + seats = [ + Seat(seat_no=1, display_name="A", discord_user_id=None, is_llm=True, persona_key="setsu"), + Seat(seat_no=2, display_name="B", discord_user_id=None, is_llm=True, persona_key="gina"), + Seat(seat_no=3, display_name="C", discord_user_id=None, is_llm=True, persona_key="raqio"), + Seat(seat_no=4, display_name="D", discord_user_id=None, is_llm=True, persona_key="comet"), + ] + for s in seats: + await repo.insert_seat(g.id, s) + for sn, role in [(1, Role.SEER), (2, Role.WEREWOLF), (3, Role.MADMAN), (4, Role.VILLAGER)]: + await repo.set_player_role(g.id, sn, role) + phase_id = make_phase_id(g.id, 1, Phase.DAY_DISCUSSION) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + await store.insert(make_phase_baseline( + game_id=g.id, phase_id=phase_id, day=1, phase=Phase.DAY_DISCUSSION, + alive_seat_nos=(1, 2, 3, 4), created_at_ms=900, + )) + # Seed 3 prior seer COs in speech_events from seats 1, 2, 3. + for i, sn in enumerate([1, 2, 3]): + await store.insert(SE( + event_id=f"e_co_{sn}", game_id=g.id, phase_id=phase_id, + day=1, phase=Phase.DAY_DISCUSSION, + source=SS.NPC_GENERATED, speaker_kind=SK.NPC, + speaker_seat=sn, text=f"占い師CO from seat {sn}", + co_declaration="seer", + claimed_seer_target_seat=sn % 4 + 1, + claimed_seer_is_wolf=False, + created_at_ms=1000 + i, + )) + registry = InMemoryNpcRegistry() + npc4_buf: list[str] = [] + registry.register( + npc_id="npc_p4", discord_bot_user_id="bot4", supported_voices=(), + version="1", send=_captured_send(npc4_buf), now_ms=1000, + persona_key="comet", + ) + registry.assign("npc_p4", seat=4, game_id=g.id, phase_id=phase_id) + discussion = DiscussionService(store=store) + arb = SpeakArbiter(repo=repo, registry=registry, discussion=discussion, now_ms=lambda: 1500) + + state = PublicDiscussionState( + game_id=g.id, phase_id=phase_id, day=1, + alive_seat_nos=frozenset({1, 2, 3, 4}), + silent_seats=frozenset({4}), + ) + req, _ = await arb.dispatch_request( + state=state, candidate_npc_id="npc_p4", seat_no=4, game_id=g.id, + ) + assert req is not None + npc4_buf.clear() + + # Seat 4 (villager) tries to CO seer despite 3 already out. + bad = SpeakResult( + ts=1600, trace_id="t", request_id=req.request_id, + npc_id="npc_p4", phase_id=req.phase_id, status="accepted", + text="僕も占い師だよ", co_declaration="seer", + claimed_seer_result=ClaimedSeerResult(target_seat=1, is_wolf=False), + ) + ok, reason = await arb.handle_speak_result( + bad, current_phase_id=req.phase_id, day=1, + phase=Phase.DAY_DISCUSSION, + ) + assert not ok + assert reason == "seer_co_cap_exceeded" + # PlaybackRejected was sent. + assert any('"playback_rejected"' in m for m in npc4_buf) + # Fabrication retry counter stayed 0 (cap is NOT a fabrication). + assert (g.id, req.phase_id, "npc_p4") not in arb._fabrication_retries # type: ignore[attr-defined] + # Seat is NOT in the cap-block set (= remains eligible for future picks). + assert 4 not in arb._fabrication_capped.get((g.id, req.phase_id), set()) # type: ignore[attr-defined] diff --git a/tests/test_reactive_voice_mode.py b/tests/test_reactive_voice_mode.py new file mode 100644 index 0000000..1fb612f --- /dev/null +++ b/tests/test_reactive_voice_mode.py @@ -0,0 +1,1203 @@ +"""Bundle 8: reactive_voice mode plumbing. + +Verifies the mode-fixed-per-game contract: + +- A game created with `discussion_mode="reactive_voice"` keeps that mode + across reload — env changes do not retro-rewrite the column. +- Default mode is `rounds`. +- The schema migration is idempotent on existing DBs. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any +from unittest.mock import MagicMock + +from wolfbot.domain.enums import Phase +from wolfbot.domain.models import Game +from wolfbot.persistence.sqlite_repo import SqliteRepo +from wolfbot.services.discord_service import WolfCog +from wolfbot.services.game_service import new_game_id + + +async def test_default_mode_is_rounds(repo: SqliteRepo) -> None: + g = Game( + id=new_game_id(), + guild_id=f"g-{new_game_id()}", + host_user_id="h", + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + ) + await repo.create_game(g) + reloaded = await repo.load_game(g.id) + assert reloaded is not None + assert reloaded.discussion_mode == "rounds" + + +async def test_reactive_voice_mode_persisted_across_reload(repo: SqliteRepo) -> None: + g = Game( + id=new_game_id(), + guild_id=f"g-{new_game_id()}", + host_user_id="h", + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(g) + reloaded = await repo.load_game(g.id) + assert reloaded is not None + assert reloaded.discussion_mode == "reactive_voice" + + +async def test_phase_advance_under_reactive_voice_skips_round_gate() -> None: + """In reactive_voice mode, _plan_next at DAY_DISCUSSION advances when the + deadline passes regardless of llm_speech_counts.""" + from wolfbot.domain.enums import Phase as PhaseEnum + from wolfbot.domain.state_machine import plan_day_discussion_to_vote + + game = Game( + id="rg", + guild_id="gu", + host_user_id="h", + phase=PhaseEnum.DAY_DISCUSSION, + day_number=1, + deadline_epoch=100, + main_text_channel_id="c1", + main_vc_channel_id="c2", + created_at=0, + discussion_mode="reactive_voice", + ) + transition = plan_day_discussion_to_vote(game, 200) + assert transition.next_phase is PhaseEnum.DAY_VOTE + + +async def test_settings_loads_default_discussion_mode() -> None: + """The Settings object exposes LLM_DISCUSSION_MODE with rounds default. + + `_env_file=None` keeps the developer's local ``.env.master`` (which may + override LLM_DISCUSSION_MODE for actual runs) from leaking into the + test and masking the default. + """ + from pydantic import SecretStr + + from wolfbot.config import MasterSettings + + s = MasterSettings( # type: ignore[arg-type] + _env_file=None, + DISCORD_TOKEN=SecretStr("dummy"), + DISCORD_GUILD_ID=1, + MAIN_TEXT_CHANNEL_ID=1, + MAIN_VOICE_CHANNEL_ID=1, + GAMEPLAY_LLM_API_KEY=SecretStr("dummy"), + ) + assert s.LLM_DISCUSSION_MODE == "rounds" + + +def test_phase_module_imports_reactive_voice_keep_existing_phases() -> None: + """Sanity: adding discussion_mode does not change the Phase enum.""" + assert Phase.DAY_DISCUSSION.value == "DAY_DISCUSSION" + + +# --------------------------------------------------------------------------- +# Integration tests: /wolf create captures discussion_mode, on_message +# produces speech_events, and the boot path wires DiscussionService. +# --------------------------------------------------------------------------- + + +@dataclass +class _FakeChannel: + id: int + deleted: bool = False + + async def delete(self, reason: str = "") -> None: + self.deleted = True + + +@dataclass +class _FakeResponse: + deferred: bool = False + ephemerals: list[str] = field(default_factory=list) + + async def send_message(self, content: str, ephemeral: bool = False) -> None: + if ephemeral: + self.ephemerals.append(content) + + async def defer(self, thinking: bool = False) -> None: + self.deferred = True + + +@dataclass +class _FakeFollowup: + messages: list[str] = field(default_factory=list) + + async def send(self, content: str) -> None: + self.messages.append(content) + + +class _FakeGuild: + def __init__(self, guild_id: int) -> None: + self.id = guild_id + + +class _FakeUser: + def __init__(self, user_id: int = 777) -> None: + self.id = user_id + + +class _FakeInteraction: + def __init__(self, guild_id: int, user_id: int = 777) -> None: + self.guild: Any = _FakeGuild(guild_id) + self.guild_id = guild_id + self.user = _FakeUser(user_id) + self.response = _FakeResponse() + self.followup = _FakeFollowup() + + +def _build_cog_with_settings(repo: Any, *, discussion_mode: str = "rounds") -> WolfCog: + settings = MagicMock() + settings.MAIN_TEXT_CHANNEL_ID = 100 + settings.MAIN_VOICE_CHANNEL_ID = 200 + settings.LLM_DISCUSSION_MODE = discussion_mode + return WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=settings, + ) + + +async def test_create_game_captures_rounds_mode(repo: SqliteRepo) -> None: + """/wolf create persists discussion_mode='rounds' from Settings.""" + cog = _build_cog_with_settings(repo, discussion_mode="rounds") + + async def fake_create(guild: Any, name: str, *, safe_to_delete_ids: set[str]) -> _FakeChannel: + return _FakeChannel(id=hash(name) % 10000) + + cog._create_private_channel = fake_create # type: ignore[method-assign] + interaction = _FakeInteraction(guild_id=99) + await WolfCog.create.callback(cog, interaction) # type: ignore[arg-type] + + game = await repo.load_active_game_for_guild("99") + assert game is not None + assert game.discussion_mode == "rounds" + + +async def test_create_game_captures_reactive_voice_mode(repo: SqliteRepo) -> None: + """/wolf create persists discussion_mode='reactive_voice' from Settings.""" + cog = _build_cog_with_settings(repo, discussion_mode="reactive_voice") + + async def fake_create(guild: Any, name: str, *, safe_to_delete_ids: set[str]) -> _FakeChannel: + return _FakeChannel(id=hash(name) % 10000) + + cog._create_private_channel = fake_create # type: ignore[method-assign] + interaction = _FakeInteraction(guild_id=98) + await WolfCog.create.callback(cog, interaction) # type: ignore[arg-type] + + game = await repo.load_active_game_for_guild("98") + assert game is not None + assert game.discussion_mode == "reactive_voice" + + +async def test_create_game_falls_back_on_invalid_mode(repo: SqliteRepo) -> None: + """/wolf create falls back to 'rounds' when given an invalid mode.""" + cog = _build_cog_with_settings(repo, discussion_mode="invalid_mode") + + async def fake_create(guild: Any, name: str, *, safe_to_delete_ids: set[str]) -> _FakeChannel: + return _FakeChannel(id=hash(name) % 10000) + + cog._create_private_channel = fake_create # type: ignore[method-assign] + interaction = _FakeInteraction(guild_id=97) + await WolfCog.create.callback(cog, interaction) # type: ignore[arg-type] + + game = await repo.load_active_game_for_guild("97") + assert game is not None + assert game.discussion_mode == "rounds" + + +async def _make_discussion_game( + repo: SqliteRepo, + *, + guild_id: str | None = None, + discussion_mode: str = "rounds", + human_seats: list[int] | None = None, + llm_seats: list[int] | None = None, +) -> tuple[Game, Any, Any]: + """Helper: create a DAY_DISCUSSION game with seats and a wired DiscussionService.""" + from wolfbot.domain.models import Seat + from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, + ) + + gid = guild_id or f"g-{new_game_id()}" + game = Game( + id=new_game_id(), + guild_id=gid, + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=9999999999, + main_text_channel_id="100", + main_vc_channel_id="200", + created_at=0, + discussion_mode=discussion_mode, + ) + await repo.create_game(game) + for sno in human_seats or [1]: + await repo.insert_seat( + game.id, + Seat( + seat_no=sno, + display_name=f"H{sno}", + discord_user_id=f"user{sno}", + is_llm=False, + persona_key=None, + ), + ) + for sno in llm_seats or []: + await repo.insert_seat( + game.id, + Seat( + seat_no=sno, + display_name=f"NPC{sno}", + discord_user_id=None, + is_llm=True, + persona_key=f"persona{sno}", + ), + ) + + store = SqliteSpeechEventStore(repo._db) + ds = DiscussionService(store=store, log_sink=repo) + return game, store, ds + + +async def test_on_message_writes_speech_event_for_human_text(repo: SqliteRepo) -> None: + """Main-channel text during DAY_DISCUSSION writes a SpeechEvent row.""" + from wolfbot.domain.discussion import SpeechSource, make_phase_id + + game, store, ds = await _make_discussion_game(repo, human_seats=[1]) + + cog = WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=MagicMock(MAIN_TEXT_CHANNEL_ID=100, MAIN_VOICE_CHANNEL_ID=200), + discussion_service=ds, + ) + + msg = MagicMock() + msg.author.bot = False + msg.author.id = "user1" + msg.guild.id = game.guild_id + msg.channel.id = 100 + msg.content = "占いCO!私は占い師です" + + await WolfCog.on_message(cog, msg) + + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(game.id, phase_id) + # Expect baseline sentinel + the text event. + non_baseline = [e for e in events if e.source != SpeechSource.PHASE_BASELINE] + assert len(non_baseline) == 1 + assert non_baseline[0].source == SpeechSource.TEXT + assert non_baseline[0].speaker_seat == 1 + assert non_baseline[0].text == "占いCO!私は占い師です" + + +async def test_on_message_seeds_phase_baseline_before_text_event(repo: SqliteRepo) -> None: + """on_message must seed the phase_baseline sentinel so PublicDiscussionState + rebuild works even in an all-human game with no LLM dispatch.""" + from wolfbot.domain.discussion import SpeechSource, make_phase_id + + game, store, ds = await _make_discussion_game(repo, human_seats=[1, 2]) + + cog = WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=MagicMock(MAIN_TEXT_CHANNEL_ID=100, MAIN_VOICE_CHANNEL_ID=200), + discussion_service=ds, + ) + + msg = MagicMock() + msg.author.bot = False + msg.author.id = "user1" + msg.guild.id = game.guild_id + msg.channel.id = 100 + msg.content = "こんにちは" + + await WolfCog.on_message(cog, msg) + + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(game.id, phase_id) + baselines = [e for e in events if e.source == SpeechSource.PHASE_BASELINE] + assert len(baselines) == 1, "on_message should seed exactly one phase_baseline" + + # Second message from a different user should NOT duplicate the baseline. + msg2 = MagicMock() + msg2.author.bot = False + msg2.author.id = "user2" + msg2.guild.id = game.guild_id + msg2.channel.id = 100 + msg2.content = "了解" + + await WolfCog.on_message(cog, msg2) + + events = await store.load_phase(game.id, phase_id) + baselines = [e for e in events if e.source == SpeechSource.PHASE_BASELINE] + assert len(baselines) == 1, "baseline must be idempotent across multiple messages" + non_baseline = [e for e in events if e.source != SpeechSource.PHASE_BASELINE] + assert len(non_baseline) == 2 + + +async def test_on_message_accepts_vc_chat_in_reactive_voice(repo: SqliteRepo) -> None: + """In reactive_voice mode, Master narration goes to the VC's + attached text chat (`main_vc_channel_id`), so players naturally + type replies in that channel rather than the main text channel. + `on_message` must accept those messages and feed them through the + SpeechEvent pipeline — otherwise the human's typed input is dropped + silently and NPCs never react. Regression: in the live test the + user's typed message produced zero PLAYER_SPEECH log entries. + """ + from wolfbot.domain.discussion import SpeechSource, make_phase_id + + game, store, ds = await _make_discussion_game( + repo, human_seats=[1], discussion_mode="reactive_voice" + ) + + cog = WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=MagicMock(MAIN_TEXT_CHANNEL_ID=100, MAIN_VOICE_CHANNEL_ID=200), + discussion_service=ds, + ) + + msg = MagicMock() + msg.author.bot = False + msg.author.id = "user1" + msg.guild.id = game.guild_id + # The user typed in the VC's attached text chat — same channel id + # as `main_vc_channel_id`, NOT `main_text_channel_id`. + msg.channel.id = 200 + msg.content = "私は占い師です" + + await WolfCog.on_message(cog, msg) + + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(game.id, phase_id) + non_baseline = [e for e in events if e.source != SpeechSource.PHASE_BASELINE] + assert len(non_baseline) == 1, ( + "VC-chat input in reactive_voice mode must produce a SpeechEvent" + ) + assert non_baseline[0].source == SpeechSource.TEXT + assert non_baseline[0].speaker_seat == 1 + assert non_baseline[0].text == "私は占い師です" + + +async def test_on_message_rejects_vc_chat_in_rounds_mode(repo: SqliteRepo) -> None: + """Rounds mode keeps the legacy gating: VC-chat is NOT a speech + surface, only the main text channel is. The new VC-chat acceptance + is reactive_voice-only so existing rounds-mode games don't change + behavior. + """ + from wolfbot.domain.discussion import SpeechSource, make_phase_id + + game, store, ds = await _make_discussion_game( + repo, human_seats=[1], discussion_mode="rounds" + ) + + cog = WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=MagicMock(MAIN_TEXT_CHANNEL_ID=100, MAIN_VOICE_CHANNEL_ID=200), + discussion_service=ds, + ) + + msg = MagicMock() + msg.author.bot = False + msg.author.id = "user1" + msg.guild.id = game.guild_id + msg.channel.id = 200 # VC text chat + msg.content = "ignored in rounds mode" + + await WolfCog.on_message(cog, msg) + + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(game.id, phase_id) + non_baseline = [e for e in events if e.source != SpeechSource.PHASE_BASELINE] + assert non_baseline == [], ( + "rounds-mode VC-chat must remain non-speech (main-text only)" + ) + + +async def test_llm_adapter_seeds_baseline_for_all_human_game(repo: SqliteRepo) -> None: + """submit_llm_discussion_rounds must seed the phase baseline even when there + are zero LLM seats so an all-human game gets a sentinel row.""" + from wolfbot.domain.discussion import SpeechSource, make_phase_id + from wolfbot.services.llm_service import LLMAdapter + + game, store, ds = await _make_discussion_game( + repo, + human_seats=[1, 2, 3], + llm_seats=[], + ) + players = await repo.load_players(game.id) + seats = await repo.load_seats(game.id) + + adapter = LLMAdapter( + repo=repo, + decider=MagicMock(), + message_poster=MagicMock(), + discussion_service=ds, + ) + + await adapter.submit_llm_discussion_rounds(game, players, seats) + + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(game.id, phase_id) + baselines = [e for e in events if e.source == SpeechSource.PHASE_BASELINE] + assert len(baselines) == 1, "baseline must be seeded even with no LLM seats" + + +async def test_main_py_wires_discussion_service() -> None: + """Verify the main module imports and instantiates DiscussionService correctly. + + We do not start the full bot — just verify the import chain doesn't break + and the LLMAdapter constructor accepts discussion_service. + """ + import inspect + + from wolfbot.services.discussion_service import DiscussionService, SqliteSpeechEventStore + from wolfbot.services.llm_service import LLMAdapter + + # Verify LLMAdapter accepts discussion_service kwarg. + + sig = inspect.signature(LLMAdapter.__init__) + assert "discussion_service" in sig.parameters + + # Verify WolfCog accepts discussion_service kwarg. + sig = inspect.signature(WolfCog.__init__) + assert "discussion_service" in sig.parameters + + # Verify SqliteSpeechEventStore exists and is importable. + assert callable(SqliteSpeechEventStore) + assert callable(DiscussionService) + + +async def test_on_message_record_emits_player_speech_log(repo: SqliteRepo) -> None: + """When discussion_service is wired, on_message should emit the PLAYER_SPEECH + LogEntry via record() — not via the legacy direct insert_log_public path. + This ensures the SpeechEvent and the LogEntry are produced atomically.""" + game, _store, ds = await _make_discussion_game(repo, human_seats=[1]) + + cog = WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=MagicMock(MAIN_TEXT_CHANNEL_ID=100, MAIN_VOICE_CHANNEL_ID=200), + discussion_service=ds, + ) + + msg = MagicMock() + msg.author.bot = False + msg.author.id = "user1" + msg.guild.id = game.guild_id + msg.channel.id = 100 + msg.content = "テスト発言" + + await WolfCog.on_message(cog, msg) + + # Verify PLAYER_SPEECH log was written (via record()'s log_sink hook). + logs = await repo.load_public_logs(game.id) + player_speeches = [lg for lg in logs if lg["kind"] == "PLAYER_SPEECH"] + assert len(player_speeches) == 1 + assert player_speeches[0]["text"] == "テスト発言" + assert player_speeches[0]["actor_seat"] == 1 + + +async def test_on_message_during_runoff_speech_writes_speech_event(repo: SqliteRepo) -> None: + """Main-channel text during DAY_RUNOFF_SPEECH also writes a SpeechEvent.""" + from wolfbot.domain.discussion import SpeechSource, make_phase_id + from wolfbot.domain.models import Seat + from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, + ) + + game = Game( + id=new_game_id(), + guild_id=f"g-{new_game_id()}", + host_user_id="h", + phase=Phase.DAY_RUNOFF_SPEECH, + day_number=1, + deadline_epoch=9999999999, + main_text_channel_id="100", + main_vc_channel_id="200", + created_at=0, + ) + await repo.create_game(game) + await repo.insert_seat( + game.id, + Seat(seat_no=1, display_name="H1", discord_user_id="user1", is_llm=False, persona_key=None), + ) + store = SqliteSpeechEventStore(repo._db) + ds = DiscussionService(store=store, log_sink=repo) + + cog = WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=MagicMock(MAIN_TEXT_CHANNEL_ID=100, MAIN_VOICE_CHANNEL_ID=200), + discussion_service=ds, + ) + + msg = MagicMock() + msg.author.bot = False + msg.author.id = "user1" + msg.guild.id = game.guild_id + msg.channel.id = 100 + msg.content = "決選弁論です" + + await WolfCog.on_message(cog, msg) + + phase_id = make_phase_id(game.id, 1, Phase.DAY_RUNOFF_SPEECH) + events = await store.load_phase(game.id, phase_id) + non_baseline = [e for e in events if e.source != SpeechSource.PHASE_BASELINE] + assert len(non_baseline) == 1 + assert non_baseline[0].source == SpeechSource.TEXT + assert non_baseline[0].text == "決選弁論です" + + +async def test_recovery_skips_rounds_resume_for_reactive_voice(repo: SqliteRepo) -> None: + """resume_llm_speech_progress must no-op for reactive_voice games so the + legacy two-round batch is never spawned after a restart.""" + from wolfbot.domain.models import Seat + from wolfbot.services.game_service import GameService + + game = Game( + id=new_game_id(), + guild_id=f"g-{new_game_id()}", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=9999999999, + main_text_channel_id="100", + main_vc_channel_id="200", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(game) + await repo.insert_seat( + game.id, + Seat(seat_no=1, display_name="NPC1", discord_user_id=None, is_llm=True, persona_key="p1"), + ) + + mock_discord = MagicMock() + mock_llm = MagicMock() + mock_llm.submit_llm_discussion_rounds = MagicMock() + mock_wake = MagicMock() + + service = GameService(repo=repo, discord=mock_discord, llm=mock_llm, wake=mock_wake) + + await service.resume_llm_speech_progress(game.id) + + # The LLM adapter's submit_llm_discussion_rounds must NOT have been called. + mock_llm.submit_llm_discussion_rounds.assert_not_called() + + +async def test_main_py_wires_reactive_voice_pipeline_services() -> None: + """Verify the reactive_voice pipeline modules are importable and the + WebsocketsMasterWsServer / SpeakArbiter / NpcRegistry / MasterIngestService + constructors accept the expected parameters.""" + import inspect + + from wolfbot.master.arbiter.ingest_service import MasterIngestService + from wolfbot.master.arbiter.speak_arbiter import SpeakArbiter + from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry + from wolfbot.master.ws.ws_server import ( + MasterHandlers, + WebsocketsMasterWsServer, + ) + + sig = inspect.signature(WebsocketsMasterWsServer.__init__) + assert "host" in sig.parameters + assert "psk" in sig.parameters + assert "registry" in sig.parameters + assert "handlers" in sig.parameters + + sig = inspect.signature(SpeakArbiter.__init__) + assert "repo" in sig.parameters + assert "registry" in sig.parameters + assert "discussion" in sig.parameters + + sig = inspect.signature(MasterIngestService.__init__) + assert "registry" in sig.parameters + assert "discussion" in sig.parameters + assert "phase_lookup" in sig.parameters + + assert callable(InMemoryNpcRegistry) + assert callable(MasterHandlers) + + +async def test_discussion_service_record_posts_voice_stt_to_channel(repo: SqliteRepo) -> None: + """DiscussionService.record() must invoke message_poster.post_public for + voice_stt events so the utterance is visible to text-only observers.""" + from wolfbot.services.discussion_service import DiscussionService + + game, store, _ = await _make_discussion_game(repo, human_seats=[1]) + + posted: list[tuple[str, str, str]] = [] + + class _FakePoster: + async def post_public(self, game_id: str, text: str, kind: str) -> None: + posted.append((game_id, text, kind)) + + ds = DiscussionService(store=store, log_sink=repo, message_poster=_FakePoster()) + + from wolfbot.services.discussion_service import make_voice_stt_event + + event = make_voice_stt_event( + game_id=game.id, + phase_id="test-phase", + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="声の発言テスト", + stt_confidence=0.9, + audio_start_ms=0, + audio_end_ms=500, + ) + await ds.record(event) + + assert len(posted) == 1 + assert posted[0][0] == game.id + assert posted[0][1] == "声の発言テスト" + assert posted[0][2] == "PLAYER_SPEECH" + + +async def test_discussion_service_record_skips_channel_post_for_text(repo: SqliteRepo) -> None: + """DiscussionService.record() must NOT post to channel for source=text + since the original Discord message is already visible.""" + from wolfbot.services.discussion_service import ( + DiscussionService, + make_human_text_event, + ) + + game, store, _ = await _make_discussion_game(repo, human_seats=[1]) + + posted: list[tuple[str, str, str]] = [] + + class _FakePoster: + async def post_public(self, game_id: str, text: str, kind: str) -> None: + posted.append((game_id, text, kind)) + + ds = DiscussionService(store=store, log_sink=repo, message_poster=_FakePoster()) + + event = make_human_text_event( + game_id=game.id, + phase_id="test-phase", + day=1, + phase=Phase.DAY_DISCUSSION, + speaker_seat=1, + text="テキスト発言", + ) + await ds.record(event) + + assert posted == [], "source=text events must not duplicate the channel post" + + +async def test_arbiter_try_dispatch_next_triggers_on_reactive_voice(repo: SqliteRepo) -> None: + """SpeakArbiter.try_dispatch_next dispatches a SpeakRequest when a + reactive_voice game has an online NPC in a discussion phase.""" + from wolfbot.domain.models import Seat + from wolfbot.master.arbiter.speak_arbiter import SpeakArbiter + from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry + from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, + ) + + game = Game( + id=new_game_id(), + guild_id=f"g-{new_game_id()}", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=9999999999, + main_text_channel_id="100", + main_vc_channel_id="200", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(game) + await repo.insert_seat( + game.id, + Seat(seat_no=1, display_name="H1", discord_user_id="user1", is_llm=False, persona_key=None), + ) + await repo.insert_seat( + game.id, + Seat(seat_no=2, display_name="NPC2", discord_user_id=None, is_llm=True, persona_key="p2"), + ) + + store = SqliteSpeechEventStore(repo._db) + ds = DiscussionService(store=store, log_sink=repo) + registry = InMemoryNpcRegistry() + + sent_messages: list[str] = [] + + async def _fake_send(msg: str) -> None: + sent_messages.append(msg) + + registry.register( + npc_id="npc2", + discord_bot_user_id="bot2", + supported_voices=(), + version="1", + send=_fake_send, + now_ms=1000, persona_key="setsu") + registry.assign("npc2", seat=2, game_id=game.id, phase_id="test") + + arbiter = SpeakArbiter( + repo=repo, + registry=registry, + discussion=ds, + now_ms=lambda: 2000, + ) + + # Seed the phase baseline so rebuild_public_state works. + await ds.begin_phase( + game_id=game.id, day=1, phase=Phase.DAY_DISCUSSION, alive_seat_nos=[1, 2] + ) + + await arbiter.try_dispatch_next(game.id) + + # The arbiter should have sent a LogicPacket + SpeakRequest to the NPC. + assert len(sent_messages) >= 2, f"Expected LogicPacket + SpeakRequest, got {len(sent_messages)}" + import json as _json + + types = [_json.loads(m).get("type") for m in sent_messages] + assert "logic_packet" in types + assert "speak_request" in types + + +async def test_arbiter_try_dispatch_next_noop_for_rounds(repo: SqliteRepo) -> None: + """try_dispatch_next must no-op for rounds-mode games.""" + from wolfbot.master.arbiter.speak_arbiter import SpeakArbiter + from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry + from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, + ) + + game = Game( + id=new_game_id(), + guild_id=f"g-{new_game_id()}", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=9999999999, + main_text_channel_id="100", + main_vc_channel_id="200", + created_at=0, + discussion_mode="rounds", + ) + await repo.create_game(game) + + store = SqliteSpeechEventStore(repo._db) + ds = DiscussionService(store=store, log_sink=repo) + registry = InMemoryNpcRegistry() + arbiter = SpeakArbiter(repo=repo, registry=registry, discussion=ds) + + # Should be a no-op — no errors, no dispatch. + await arbiter.try_dispatch_next(game.id) + + +async def test_recovery_sweep_closes_open_speak_requests(repo: SqliteRepo) -> None: + """reactive_voice_recovery_sweep must close open npc_speak_requests and + npc_playback_events with failure_reason=master_restart.""" + from wolfbot.master.arbiter.speak_arbiter import SpeakArbiter + from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry + from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, + ) + + game = Game( + id=new_game_id(), + guild_id=f"g-{new_game_id()}", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=9999999999, + main_text_channel_id="100", + main_vc_channel_id="200", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(game) + + # Insert an open speak request (no matching result). + await repo.insert_npc_speak_request( + request_id="sr_test1", + game_id=game.id, + phase_id="phase1", + npc_id="npc1", + seat_no=2, + logic_packet_id="lp1", + suggested_intent="speak", + max_chars=80, + max_duration_ms=12000, + priority=0, + expires_at_ms=99999, + created_at_ms=1000, + ) + # Insert an open playback event. + await repo.open_npc_playback( + request_id="sr_test2", + game_id=game.id, + phase_id="phase1", + npc_id="npc1", + speech_event_id="se1", + authorized_at_ms=1000, + playback_deadline_ms=13000, + ) + + store = SqliteSpeechEventStore(repo._db) + ds = DiscussionService(store=store, log_sink=repo) + registry = InMemoryNpcRegistry() + arbiter = SpeakArbiter(repo=repo, registry=registry, discussion=ds, now_ms=lambda: 5000) + + await arbiter.reactive_voice_recovery_sweep(game.id) + + # Verify open speak request was closed. + open_reqs = await repo.load_open_npc_speak_requests(game.id) + assert open_reqs == [], "All open speak requests should be closed" + + # Verify open playback was closed. + open_play = await repo.load_open_npc_playback(game.id) + assert open_play == [], "All open playback events should be closed" + + +async def test_recovery_service_calls_sweep_for_reactive_voice(repo: SqliteRepo) -> None: + """RecoveryService._recover_one must call the reactive_voice sweep for + reactive_voice games when the sweep callback is wired.""" + from wolfbot.domain.models import Seat + from wolfbot.services.recovery_service import RecoveryService + from wolfbot.services.timer_service import EngineRegistry + + game = Game( + id=new_game_id(), + guild_id=f"g-{new_game_id()}", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=9999999999, + main_text_channel_id="100", + main_vc_channel_id="200", + created_at=0, + discussion_mode="reactive_voice", + ) + await repo.create_game(game) + await repo.insert_seat( + game.id, + Seat(seat_no=1, display_name="H1", discord_user_id="u1", is_llm=False, persona_key=None), + ) + + swept_games: list[str] = [] + + async def _sweep(game_id: str) -> None: + swept_games.append(game_id) + + mock_discord = MagicMock() + mock_discord.reconcile = MagicMock(return_value=None) + mock_discord.announce_recovery = MagicMock(return_value=None) + + async def noop(*_a: Any, **_k: Any) -> None: + pass + + mock_discord.reconcile = noop + mock_discord.announce_recovery = noop + + from unittest.mock import AsyncMock + + mock_gs = MagicMock() + mock_gs.advance = AsyncMock() + mock_gs.resend_pending_dms = AsyncMock() + mock_gs.resume_llm_speech_progress = AsyncMock() + + registry = EngineRegistry() + svc = RecoveryService( + repo=repo, + game_service=mock_gs, + registry=registry, + discord=mock_discord, + reactive_voice_sweep=_sweep, + ) + + recovered = await svc.recover_all() + assert game.id in recovered + assert game.id in swept_games, "Sweep must be called for reactive_voice games" + + +async def test_game_service_emits_phase_summary_on_discussion_exit(repo: SqliteRepo) -> None: + """GameService.advance must emit discussion_phase_summary when leaving + DAY_DISCUSSION phase.""" + from wolfbot.domain.models import Seat + from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, + ) + from wolfbot.services.game_service import GameService + + game = Game( + id=new_game_id(), + guild_id=f"g-{new_game_id()}", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + deadline_epoch=100, + main_text_channel_id="100", + main_vc_channel_id="200", + created_at=0, + discussion_mode="rounds", + ) + await repo.create_game(game) + from wolfbot.domain.enums import Role + + for sno in range(1, 10): + await repo.insert_seat( + game.id, + Seat( + seat_no=sno, + display_name=f"P{sno}", + discord_user_id=f"u{sno}", + is_llm=False, + persona_key=None, + ), + ) + await repo.set_player_role(game.id, sno, Role.VILLAGER) + + store = SqliteSpeechEventStore(repo._db) + ds = DiscussionService(store=store, log_sink=repo) + + mock_discord = MagicMock() + + async def noop_apply(*_a: Any, **_k: Any) -> None: + pass + + mock_discord.apply_permissions = noop_apply + mock_discord.kill_permissions = noop_apply + mock_discord.post_public = noop_apply + mock_discord.post_morning = noop_apply + mock_discord.send_private = noop_apply + mock_discord.send_vote_dms = noop_apply + mock_discord.send_night_action_dms = noop_apply + mock_discord.announce_waiting = noop_apply + mock_discord.on_game_end = noop_apply + mock_discord.reconcile = noop_apply + mock_discord.post_wolves_chat = noop_apply + + mock_llm = MagicMock() + mock_llm.submit_llm_votes = noop_apply + mock_llm.submit_llm_discussion_rounds = noop_apply + mock_llm.submit_llm_runoff_candidate_speeches = noop_apply + mock_llm.submit_llm_night_actions = noop_apply + mock_llm.discussion_service = ds + + mock_wake = MagicMock() + mock_wake.wake = MagicMock() + + service = GameService( + repo=repo, discord=mock_discord, llm=mock_llm, wake=mock_wake, clock=lambda: 200 + ) + + # The advance should transition from DAY_DISCUSSION to DAY_VOTE and + # emit the phase summary. We just need it to not crash. + await service.advance(game.id) + + # Verify the game moved to DAY_VOTE. + updated = await repo.load_game(game.id) + assert updated is not None + assert updated.phase == Phase.DAY_VOTE + + +async def test_ws_authenticate_reads_request_path() -> None: + """WebsocketsMasterWsServer._authenticate must read ws.request.path + (websockets 16.0) rather than the legacy ws.path.""" + from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry + from wolfbot.master.ws.ws_server import MasterHandlers, WebsocketsMasterWsServer + + registry = InMemoryNpcRegistry() + handlers = MasterHandlers(registry=registry, now_ms=lambda: 0) + server = WebsocketsMasterWsServer( + host="127.0.0.1", + port=8899, + psk="testpsk", + registry=registry, + handlers=handlers, + ) + + # Simulate websockets 16.0 connection object with ws.request.path + class _FakeRequest: + path = "/?role=npc&psk=testpsk" + + class _FakeWs: + request = _FakeRequest() + # No .path attribute — websockets 16.0 style + + async def send(self, data: str) -> None: + pass + + async def close(self, code: int = 1000, reason: str = "") -> None: + pass + + ctx = await server._authenticate(_FakeWs()) + assert ctx is not None + assert ctx.role == "npc" + + +async def test_ws_authenticate_rejects_bad_psk() -> None: + """Auth must reject when psk doesn't match.""" + from wolfbot.master.ws.npc_registry import InMemoryNpcRegistry + from wolfbot.master.ws.ws_server import MasterHandlers, WebsocketsMasterWsServer + + registry = InMemoryNpcRegistry() + handlers = MasterHandlers(registry=registry, now_ms=lambda: 0) + server = WebsocketsMasterWsServer( + host="127.0.0.1", + port=8899, + psk="testpsk", + registry=registry, + handlers=handlers, + ) + + class _FakeRequest: + path = "/?role=npc&psk=wrong" + + closed_with: list[int] = [] + + class _FakeWs: + request = _FakeRequest() + + async def send(self, data: str) -> None: + pass + + async def close(self, code: int = 1000, reason: str = "") -> None: + closed_with.append(code) + + ctx = await server._authenticate(_FakeWs()) + assert ctx is None + assert closed_with == [4401] + + +# --------------------------------------------------------------------------- +# R1-F04 / R4-F10: on_message text path triggers arbiter dispatch callback +# --------------------------------------------------------------------------- + + +async def test_on_message_text_triggers_speech_recorded_callback(repo: SqliteRepo) -> None: + """After recording a text SpeechEvent, on_message must call the + on_speech_recorded callback so the arbiter can dispatch an NPC reply.""" + game, _store, ds = await _make_discussion_game( + repo, human_seats=[1], discussion_mode="reactive_voice" + ) + + dispatched_game_ids: list[str] = [] + + async def fake_on_speech_recorded(game_id: str) -> None: + dispatched_game_ids.append(game_id) + + cog = WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=MagicMock(MAIN_TEXT_CHANNEL_ID=100, MAIN_VOICE_CHANNEL_ID=200), + discussion_service=ds, + on_speech_recorded=fake_on_speech_recorded, + ) + + msg = MagicMock() + msg.author.bot = False + msg.author.id = "user1" + msg.guild.id = game.guild_id + msg.channel.id = 100 + msg.content = "テスト発言" + + await WolfCog.on_message(cog, msg) + + assert dispatched_game_ids == [game.id], ( + "on_speech_recorded must be called with the game_id after text event" + ) + + +async def test_on_message_text_no_callback_when_none(repo: SqliteRepo) -> None: + """When on_speech_recorded is None (no arbiter wired), on_message still + records the SpeechEvent without error.""" + from wolfbot.domain.discussion import SpeechSource, make_phase_id + + game, store, ds = await _make_discussion_game(repo, human_seats=[1]) + + cog = WolfCog( + bot=MagicMock(), + repo=repo, + game_service=MagicMock(), + discord_adapter=MagicMock(), + llm_adapter=MagicMock(), + registry=MagicMock(), + settings=MagicMock(MAIN_TEXT_CHANNEL_ID=100, MAIN_VOICE_CHANNEL_ID=200), + discussion_service=ds, + on_speech_recorded=None, + ) + + msg = MagicMock() + msg.author.bot = False + msg.author.id = "user1" + msg.guild.id = game.guild_id + msg.channel.id = 100 + msg.content = "発言テスト" + + await WolfCog.on_message(cog, msg) + + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(game.id, phase_id) + non_baseline = [e for e in events if e.source != SpeechSource.PHASE_BASELINE] + assert len(non_baseline) == 1 + + +async def test_main_wires_on_speech_recorded_to_cog() -> None: + """Verify the main module wiring passes on_speech_recorded to WolfCog.""" + import inspect + + sig = inspect.signature(WolfCog.__init__) + assert "on_speech_recorded" in sig.parameters, ( + "WolfCog must accept on_speech_recorded callback" + ) diff --git a/tests/test_rounds_speech_event_backfill.py b/tests/test_rounds_speech_event_backfill.py new file mode 100644 index 0000000..7028553 --- /dev/null +++ b/tests/test_rounds_speech_event_backfill.py @@ -0,0 +1,224 @@ +"""Bundle 3: rounds-mode SpeechEvent backfill — regression coverage. + +Asserts that wiring a `DiscussionService` into `LLMAdapter` does NOT change +existing rounds-mode observable behavior (post counts, kind, log entries, +discussion_rounds_done progress) AND that one SpeechEvent(source=npc_generated) +is written per accepted utterance, plus exactly one phase_baseline sentinel +per phase entry. The pre-bundle behavior (no DiscussionService wired) must +remain bitwise unchanged. +""" + +from __future__ import annotations + +import asyncio +import random +from dataclasses import dataclass, field +from typing import Any + +from wolfbot.domain.discussion import SpeechSource, make_phase_id +from wolfbot.domain.enums import Phase, Role +from wolfbot.domain.models import Game, Seat +from wolfbot.persistence.sqlite_repo import SqliteRepo +from wolfbot.services.discussion_service import ( + DiscussionService, + SqliteSpeechEventStore, +) +from wolfbot.services.llm_service import ( + FakeLLMActionDecider, + LLMAction, + LLMAdapter, +) + + +@dataclass +class FakePoster: + messages: list[tuple[str, str]] = field(default_factory=list) + + async def post_public(self, game: Any, text: str, kind: str) -> None: + self.messages.append((text, kind)) + + async def post_wolves_chat(self, game: Any, text: str, kind: str) -> None: + pass + + +@dataclass +class WakeRecorder: + waked: list[str] = field(default_factory=list) + + def wake(self, game_id: str) -> None: + self.waked.append(game_id) + + +class FakeGS: + def __init__(self) -> None: + self.wake = WakeRecorder() + + +async def _seed(repo: SqliteRepo) -> tuple[Game, list[Seat]]: + game = Game( + id="g-be", + guild_id="gu", + host_user_id="h", + phase=Phase.DAY_DISCUSSION, + day_number=1, + main_text_channel_id="c1", + main_vc_channel_id="c2", + heaven_channel_id="h1", + wolves_channel_id="w1", + created_at=0, + ) + await repo.create_game(game) + seats = [ + Seat(seat_no=1, display_name="Alice", discord_user_id="u1", is_llm=False, persona_key=None), + Seat( + seat_no=2, display_name="セツ", discord_user_id=None, is_llm=True, persona_key="setsu" + ), + Seat(seat_no=3, display_name="ジナ", discord_user_id=None, is_llm=True, persona_key="gina"), + ] + for s in seats: + await repo.insert_seat(game.id, s) + await repo.set_player_role(game.id, 1, Role.WEREWOLF) + await repo.set_player_role(game.id, 2, Role.SEER) + await repo.set_player_role(game.id, 3, Role.VILLAGER) + return game, seats + + +async def _no_sleep_block(coro_factory: Any) -> Any: + import wolfbot.services.llm_service as svc + + original_sleep = asyncio.sleep + + async def no_sleep(_secs: float) -> None: + await original_sleep(0) + + svc.asyncio.sleep = no_sleep # type: ignore[attr-defined] + try: + return await coro_factory() + finally: + svc.asyncio.sleep = original_sleep # type: ignore[attr-defined] + + +async def _drain(adapter: LLMAdapter) -> None: + await asyncio.sleep(0.05) + for t in list(adapter._background_tasks): + await t + + +async def test_rounds_mode_emits_speech_events_when_discussion_service_wired( + repo: SqliteRepo, +) -> None: + game, seats = await _seed(repo) + poster = FakePoster() + decider = FakeLLMActionDecider( + default=LLMAction(intent="speak", public_message="占いCO", reason_summary="r"), + ) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + adapter = LLMAdapter( + repo=repo, + decider=decider, + message_poster=poster, + rng=random.Random(0), + clock=lambda: 1000, + discussion_service=discussion, + ) + adapter.set_game_service(FakeGS()) # type: ignore[arg-type] + players = await repo.load_players(game.id) + + async def run() -> None: + await adapter.submit_llm_discussion_rounds(game, players, seats) + await _drain(adapter) + + await _no_sleep_block(run) + + # Existing rounds behavior preserved. + assert len(poster.messages) == 4 + assert all(kind == "LLM_SPEAK" for _, kind in poster.messages) + for seat_no in (2, 3): + progress = await repo.load_llm_speech_progress(game.id, day=1, seat_no=seat_no) + assert progress[3] == 2 + + # Speech events: one phase_baseline sentinel + 4 npc_generated rows. + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(game.id, phase_id) + sources = [e.source for e in events] + assert sources.count(SpeechSource.PHASE_BASELINE) == 1 + assert sources.count(SpeechSource.NPC_GENERATED) == 4 + npc_events = [e for e in events if e.source == SpeechSource.NPC_GENERATED] + assert sorted(e.speaker_seat for e in npc_events) == [2, 2, 3, 3] + assert all(e.text == "占いCO" for e in npc_events) + + +async def test_rounds_mode_without_discussion_service_unchanged( + repo: SqliteRepo, +) -> None: + """Smoke: when no DiscussionService is wired, no speech_events rows are + written and the existing test_llm_trigger guarantees still hold.""" + game, seats = await _seed(repo) + poster = FakePoster() + decider = FakeLLMActionDecider( + default=LLMAction(intent="speak", public_message="発言", reason_summary="r"), + ) + adapter = LLMAdapter( + repo=repo, + decider=decider, + message_poster=poster, + rng=random.Random(0), + clock=lambda: 1000, + discussion_service=None, + ) + adapter.set_game_service(FakeGS()) # type: ignore[arg-type] + players = await repo.load_players(game.id) + + async def run() -> None: + await adapter.submit_llm_discussion_rounds(game, players, seats) + await _drain(adapter) + + await _no_sleep_block(run) + + assert len(poster.messages) == 4 + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(game.id, phase_id) + assert events == [] + + +async def test_rounds_mode_co_claims_visible_via_rebuild(repo: SqliteRepo) -> None: + """End-to-end: backfilled SpeechEvents fold into a PublicDiscussionState + whose `co_claims` extracts the 占いCO marker from the LLM utterance.""" + from wolfbot.services.discussion_service import rebuild_public_state_from_events + + game, seats = await _seed(repo) + poster = FakePoster() + decider = FakeLLMActionDecider( + default=LLMAction(intent="speak", public_message="占いCO", reason_summary="r"), + ) + store = SqliteSpeechEventStore(repo._conn) # type: ignore[attr-defined] + discussion = DiscussionService(store=store) + adapter = LLMAdapter( + repo=repo, + decider=decider, + message_poster=poster, + rng=random.Random(0), + clock=lambda: 1000, + discussion_service=discussion, + ) + adapter.set_game_service(FakeGS()) # type: ignore[arg-type] + players = await repo.load_players(game.id) + + async def run() -> None: + await adapter.submit_llm_discussion_rounds(game, players, seats) + await _drain(adapter) + + await _no_sleep_block(run) + + phase_id = make_phase_id(game.id, 1, Phase.DAY_DISCUSSION) + events = await store.load_phase(game.id, phase_id) + state = rebuild_public_state_from_events(events) + assert state is not None + assert state.alive_seat_nos == frozenset({1, 2, 3}) + # Both LLM seats CO'd; the silent set should be just the human seat. + assert state.silent_seats == frozenset({1}) + seat_set = {c.seat for c in state.co_claims} + assert seat_set == {2, 3} + assert all(c.role_claim == "seer" for c in state.co_claims) diff --git a/tests/test_rules_night_targets.py b/tests/test_rules_night_targets.py index 1201d5d..81caa1b 100644 --- a/tests/test_rules_night_targets.py +++ b/tests/test_rules_night_targets.py @@ -223,6 +223,107 @@ def test_resolve_wolf_attack_human_missing_does_not_invoke_priority() -> None: assert r.split is False +# ---------------------------------- random split resolution (rng=Random()) +def test_resolve_wolf_attack_rng_resolves_all_llm_split() -> None: + """Two LLM wolves split; with an RNG provided Master picks one of the + two targets at random instead of returning ``split=True``. This is the + fix for game ``98e5a083b5ff`` day 2 (SQ→コメット, ユリコ→セツ → + indefinite WAITING_HOST_DECISION pause).""" + from random import Random + + actions = [_attack(1, 5), _attack(2, 6)] + r = resolve_wolf_attack( + actions, + alive_wolf_seats=[1, 2], + force_skip=False, + rng=Random(42), + ) + assert r.target_seat in (5, 6) + assert r.split is False + + +def test_resolve_wolf_attack_rng_picks_each_target_under_repeated_seeds() -> None: + """Sanity-check the random pick actually exercises both branches over + many seeds — guards against a copy-paste regression that always + returned the first wolf's pick.""" + from random import Random + + actions = [_attack(1, 5), _attack(2, 6)] + chosen: set[int | None] = set() + for seed in range(50): + r = resolve_wolf_attack( + actions, + alive_wolf_seats=[1, 2], + force_skip=False, + rng=Random(seed), + ) + chosen.add(r.target_seat) + assert chosen == {5, 6} + + +def test_resolve_wolf_attack_rng_does_not_override_human_priority() -> None: + """Human-wolf priority (1H + 1L disagreement) wins before the random + fallback runs — otherwise a 50/50 coin flip would silently override + the human player's deliberate choice.""" + from random import Random + + actions = [_attack(1, 5), _attack(2, 6)] + r = resolve_wolf_attack( + actions, + alive_wolf_seats=[1, 2], + force_skip=False, + human_wolf_seats=[1], + rng=Random(0), + ) + assert r.target_seat == 5 # human's pick, not RNG'd + assert r.split is False + + +def test_resolve_wolf_attack_rng_handles_partial_none_pick() -> None: + """One wolf abstained (target=None), the other named a target. With + RNG, the concrete target is chosen rather than mixing None into the + random pool (which would produce a 50% no-attack — equivalent to the + old split semantics).""" + from random import Random + + actions = [_attack(1, None), _attack(2, 5)] + r = resolve_wolf_attack( + actions, + alive_wolf_seats=[1, 2], + force_skip=False, + rng=Random(0), + ) + assert r.target_seat == 5 + assert r.split is False + + +def test_resolve_wolf_attack_rng_no_concrete_target_keeps_no_attack() -> None: + """Both wolves abstained (None, None). The RNG has nothing concrete + to pick from — the result stays "no attack" rather than the function + fabricating a target.""" + from random import Random + + actions = [_attack(1, None), _attack(2, None)] + r = resolve_wolf_attack( + actions, + alive_wolf_seats=[1, 2], + force_skip=False, + rng=Random(0), + ) + assert r.target_seat is None + assert r.split is False # not a "split" either — both genuinely abstained + + +def test_resolve_wolf_attack_no_rng_keeps_legacy_split_for_back_compat() -> None: + """When no RNG is threaded, the legacy ``split=True`` shape is + preserved so unit tests / external callers that haven't migrated + keep observing the historical behaviour.""" + actions = [_attack(1, 5), _attack(2, 6)] + r = resolve_wolf_attack(actions, alive_wolf_seats=[1, 2], force_skip=False) + assert r.split is True + assert r.target_seat is None + + def test_random_white_raises_when_pool_empty() -> None: # All non-seer non-wolves are dead (contrived case) players = _players() diff --git a/tests/test_settings_view.py b/tests/test_settings_view.py new file mode 100644 index 0000000..b13ca56 --- /dev/null +++ b/tests/test_settings_view.py @@ -0,0 +1,179 @@ +"""`/wolf settings` interactive view — unit coverage. + +The view itself is a discord.ui composition; we exercise the embed + view +construction, the duration mutation logic, and the interaction guard. We +mock just enough of `discord.Interaction` to test the logic without +spinning up the bot. +""" + +from __future__ import annotations + +from dataclasses import replace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from wolfbot.domain.durations import ( + PhaseDurations, + current_phase_durations, + reset_phase_durations_to_defaults, + set_phase_durations, +) +from wolfbot.ui.settings_view import ( + _DURATION_FIELD_LABELS, + PhaseDurationsView, + render_initial_message, +) + + +@pytest.fixture(autouse=True) +def _reset_durations() -> None: + """Each test starts from clean defaults — the singleton is process- + global, so leakage across tests would otherwise compound.""" + reset_phase_durations_to_defaults() + + +def test_render_initial_message_lists_all_fields() -> None: + embed, view = render_initial_message(host_user_id="host1") + assert isinstance(view, PhaseDurationsView) + field_names = {f.name for f in embed.fields} + for fn, label in _DURATION_FIELD_LABELS: + # Each line is "label (field_name)" — both substrings must appear. + assert any(label in name and fn in name for name in field_names), fn + + +def test_render_initial_message_reflects_current_singleton() -> None: + set_phase_durations(replace(current_phase_durations(), vote=42)) + embed, _view = render_initial_message(host_user_id="host1") + vote_field = next( + f for f in embed.fields if "vote" in f.name + ) + assert "42" in vote_field.value + + +async def test_interaction_check_rejects_non_host() -> None: + view = PhaseDurationsView(host_user_id="host1") + interaction = MagicMock() + interaction.user.id = 9999 # not the host + interaction.response.send_message = AsyncMock() + ok = await view.interaction_check(interaction) + assert ok is False + interaction.response.send_message.assert_called_once() + _args, kwargs = interaction.response.send_message.call_args + assert kwargs.get("ephemeral") is True + + +async def test_interaction_check_accepts_host() -> None: + view = PhaseDurationsView(host_user_id="host1") + interaction = MagicMock() + interaction.user.id = "host1" # may arrive as int or str depending on call site + interaction.response.send_message = AsyncMock() + ok = await view.interaction_check(interaction) + assert ok is True + interaction.response.send_message.assert_not_called() + + +async def test_refresh_updates_singleton_via_modal_submit() -> None: + """Submitting an integer through the duration edit modal must swap + the singleton and re-render the embed.""" + from wolfbot.ui.settings_view import _DurationEditModal + + view = PhaseDurationsView(host_user_id="host1") + modal = _DurationEditModal( + field_name="vote", + label="投票", + current_value=current_phase_durations().vote, + parent=view, + ) + modal.value_input = MagicMock() + modal.value_input.value = "45" + interaction = MagicMock() + interaction.user.id = "host1" + interaction.response.send_message = AsyncMock() + interaction.response.edit_message = AsyncMock() + await modal.on_submit(interaction) + assert current_phase_durations().vote == 45 + interaction.response.edit_message.assert_called_once() + + +async def test_refresh_rejects_non_integer() -> None: + from wolfbot.ui.settings_view import _DurationEditModal + + view = PhaseDurationsView(host_user_id="host1") + modal = _DurationEditModal( + field_name="vote", + label="投票", + current_value=60, + parent=view, + ) + modal.value_input = MagicMock() + modal.value_input.value = "not-a-number" + interaction = MagicMock() + interaction.user.id = "host1" + interaction.response.send_message = AsyncMock() + interaction.response.edit_message = AsyncMock() + await modal.on_submit(interaction) + # Singleton must not have changed. + assert current_phase_durations().vote == 60 + interaction.response.send_message.assert_called_once() + interaction.response.edit_message.assert_not_called() + + +async def test_refresh_rejects_zero_or_negative() -> None: + from wolfbot.ui.settings_view import _DurationEditModal + + view = PhaseDurationsView(host_user_id="host1") + modal = _DurationEditModal( + field_name="vote", + label="投票", + current_value=60, + parent=view, + ) + modal.value_input = MagicMock() + modal.value_input.value = "0" + interaction = MagicMock() + interaction.user.id = "host1" + interaction.response.send_message = AsyncMock() + interaction.response.edit_message = AsyncMock() + await modal.on_submit(interaction) + assert current_phase_durations().vote == 60 + interaction.response.send_message.assert_called_once() + + +async def test_factor_modal_scales_all_fields() -> None: + from wolfbot.ui.settings_view import _FactorModal + + set_phase_durations(PhaseDurations()) # known starting point + before = current_phase_durations() + view = PhaseDurationsView(host_user_id="host1") + modal = _FactorModal(parent=view) + modal.factor_input = MagicMock() + modal.factor_input.value = "0.5" + interaction = MagicMock() + interaction.user.id = "host1" + interaction.response.send_message = AsyncMock() + interaction.response.edit_message = AsyncMock() + await modal.on_submit(interaction) + after = current_phase_durations() + # Every field is halved (with the floor of 1). + assert after.vote == max(1, round(before.vote * 0.5)) + assert after.night == max(1, round(before.night * 0.5)) + assert after.discussion_day1 == max(1, round(before.discussion_day1 * 0.5)) + + +async def test_factor_modal_rejects_invalid_factor() -> None: + from wolfbot.ui.settings_view import _FactorModal + + set_phase_durations(PhaseDurations()) + before_vote = current_phase_durations().vote + view = PhaseDurationsView(host_user_id="host1") + modal = _FactorModal(parent=view) + modal.factor_input = MagicMock() + modal.factor_input.value = "0" + interaction = MagicMock() + interaction.user.id = "host1" + interaction.response.send_message = AsyncMock() + interaction.response.edit_message = AsyncMock() + await modal.on_submit(interaction) + assert current_phase_durations().vote == before_vote + interaction.response.send_message.assert_called_once() diff --git a/tests/test_sql_loader.py b/tests/test_sql_loader.py new file mode 100644 index 0000000..f26fe15 --- /dev/null +++ b/tests/test_sql_loader.py @@ -0,0 +1,144 @@ +"""Loader tests for ``wolfbot.persistence.sql``. + +Pin two invariants the runtime depends on: + +1. ``load_schema_script`` returns a concatenated string of every + ``schema/*.sql`` file in lex order. The lex order matters because + FK-bearing tables reference targets that must already exist — + numeric prefixes (``01_games.sql``) encode the dependency order. +2. The script tolerates ``--`` line comments whose body itself contains + a ``;``. This was the regression that surfaced when the loader + first split on ``;`` in Python: the string "...enforcement; service + code also" inside a comment caused SQLite to see ``service code + also CREATE INDEX...`` as one mangled statement. Running the script + through ``executescript`` (= SQLite's own parser) is what makes + the comment safe; this test pins that contract. +""" + +from __future__ import annotations + +from pathlib import Path + +import aiosqlite +import pytest + +from wolfbot.persistence.sql import ( + SCHEMA_DIR, + SQL_ROOT, + load_schema_script, +) + + +def test_schema_dir_layout_has_numbered_sql_files() -> None: + """Every file in schema/ is a ``NN_*.sql`` so the lex order matches + the FK dependency order documented in the loader.""" + files = sorted(SCHEMA_DIR.glob("*.sql")) + assert files, "schema/ directory must contain at least one .sql file" + for path in files: + # The leading two digits encode dependency order. A glob like + # `*.sql` would happily ingest a file with no prefix; this test + # catches that drift before it breaks the migrate run. + assert path.stem[:2].isdigit(), ( + f"{path.name} must start with NN_ prefix to define order" + ) + + +def test_schema_dir_lives_under_sql_root() -> None: + assert SCHEMA_DIR.parent == SQL_ROOT + assert SCHEMA_DIR.is_dir() + + +def test_load_schema_script_returns_non_empty_text() -> None: + script = load_schema_script() + assert "CREATE TABLE IF NOT EXISTS games" in script + # Every CREATE TABLE statement should end in `;` so executescript + # can split them — verifies file authors didn't drop a trailing `;`. + assert script.count("CREATE TABLE IF NOT EXISTS") >= 10 + + +def test_load_schema_script_concatenates_in_lex_order() -> None: + """First file in lex order is ``01_games.sql``; its content must + appear before ``02_seats.sql`` in the rendered script. This pins + the FK execution order — `seats` references `games(id)` and would + fail if the rendered order ever flipped.""" + script = load_schema_script() + games_idx = script.find("CREATE TABLE IF NOT EXISTS games") + seats_idx = script.find("CREATE TABLE IF NOT EXISTS seats") + assert games_idx >= 0 and seats_idx >= 0 + assert games_idx < seats_idx, ( + "games must appear before seats in the rendered script — " + "seats has a FK on games(id)" + ) + + +def test_load_schema_script_is_cached() -> None: + """Second call returns the same string instance (LRU cache). + + Probes by mutating a file after the first read — the cached value + must NOT pick up the change. Tests that need fresh reads call + `load_schema_script.cache_clear` (matches the template loader's + convention).""" + first = load_schema_script() + # We don't actually mutate the .sql file; we just assert identity + # equality across calls, which the lru_cache guarantees if and only + # if the function is decorated. + second = load_schema_script() + assert first is second + + +async def test_loaded_script_runs_against_sqlite_with_executescript( + tmp_path: Path, +) -> None: + """End-to-end: ``executescript(load_schema_script())`` on a fresh + DB must succeed and produce every documented table. This is the + canonical regression for the `--`-comment-containing-`;` bug — if + the loader ever switches back to a Python-side split on `;`, + this test will fail with `OperationalError: near "service": syntax error` + (the exact error the original bug surfaced). + """ + db_path = tmp_path / "schema_smoke.db" + async with aiosqlite.connect(str(db_path)) as db: + await db.executescript(load_schema_script()) + async with db.execute( + "SELECT name FROM sqlite_master " + "WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ) as cur: + tables = [row[0] async for row in cur] + + expected_subset = { + "games", + "seats", + "night_actions", + "votes", + "previous_guard", + "logs_public", + "logs_private", + "pending_decisions", + "persona_assignments", + "llm_speech_counts", + "speech_events", + "npc_speak_requests", + "npc_speak_results", + "npc_playback_events", + } + missing = expected_subset - set(tables) + assert not missing, f"schema/*.sql must define all core tables; missing={missing}" + + +def test_load_schema_script_preserves_comment_with_semicolon_intact() -> None: + """Regression: 01_games.sql contains a `--` comment line whose body + has a `;` ("...enforcement; service code also"). The loader must + NOT strip / modify the comment; SQLite's own parser handles it + when ``executescript`` runs the script. + """ + script = load_schema_script() + assert "structural enforcement; service code also" in script + + +@pytest.fixture(autouse=True) +def _reset_cache() -> None: + """Independence between tests in this module: clear the cache before + each test so an earlier mutation experiment doesn't leak. (The + cache is shared with the production runtime, but tests that touch + SCHEMA_DIR contents would otherwise see stale data.)""" + load_schema_script.cache_clear() diff --git a/tests/test_state_machine_nights.py b/tests/test_state_machine_nights.py index 5872f68..a4164a1 100644 --- a/tests/test_state_machine_nights.py +++ b/tests/test_state_machine_nights.py @@ -158,6 +158,51 @@ def test_plan_night_resolve_skips_dead_attack_target() -> None: assert "平和" in t.morning_text +def test_morning_and_day_start_logs_carry_next_day_number() -> None: + """The MORNING + "{N} 日目の議論を開始" PHASE_CHANGE logs are emitted + by ``plan_night_resolve`` *before* day_number is bumped, so they + must explicitly carry ``day=next_day`` (= game.day_number + 1) + rather than the default ``game.day_number``. Otherwise the viewer's + per-(day, phase) bucketing puts day-N+1's morning in day-N's + DAY_DISCUSSION section, where it appears AFTER the day-N speeches + and produces the misleading "DAY_VOTE → NIGHT → DAY_DISCUSSION" + timeline observed in game cbfc32400ee2. + """ + game = _game(day=1) + players = _players() + seats = _seats() + actions = [ + _act(1, SubmissionType.WOLF_ATTACK, 7), + _act(2, SubmissionType.WOLF_ATTACK, 7), + _act(4, SubmissionType.SEER_DIVINE, 8), + _act(6, SubmissionType.KNIGHT_GUARD, 8), + ] + t = plan_night_resolve( + game, + players, + seats, + actions, + previous_guard_seat=None, + force_skip=False, + now_epoch=1000, + ) + assert t.next_day == 2 + + morning = next(log for log in t.public_logs if log.kind == "MORNING") + assert morning.day == 2, ( + f"MORNING tagged day={morning.day}, expected 2 (next day after night N)" + ) + + day_start = next( + log + for log in t.public_logs + if log.kind == "PHASE_CHANGE" and "議論を開始" in log.text + ) + assert day_start.day == 2, ( + f"day-start PHASE_CHANGE tagged day={day_start.day}, expected 2" + ) + + def test_morning_announce_does_not_reveal_role() -> None: game = _game(day=1) players = _players() @@ -381,6 +426,40 @@ def test_wolf_split_without_force_skip_pauses() -> None: assert t.requires_host_decision is True +def test_wolf_split_with_rng_resolves_into_an_attack_kill() -> None: + """When an RNG is threaded through, an all-LLM split is resolved by + randomly picking one of the two wolves' targets — the night fully + resolves into the next ``DAY_DISCUSSION`` instead of parking in + ``WAITING_HOST_DECISION``. Fix for game ``98e5a083b5ff`` day 2.""" + from random import Random + + game = _game(day=1) + players = _players() + seats = _seats() + actions = [ + _act(1, SubmissionType.WOLF_ATTACK, 7), + _act(2, SubmissionType.WOLF_ATTACK, 8), # split + _act(4, SubmissionType.SEER_DIVINE, 3), + _act(6, SubmissionType.KNIGHT_GUARD, 3), + ] + t = plan_night_resolve( + game, + players, + seats, + actions, + previous_guard_seat=None, + force_skip=False, + now_epoch=1000, + rng=Random(42), + ) + assert t.next_phase is Phase.DAY_DISCUSSION + assert t.requires_host_decision is False + # One of the two wolves' picks landed; the resolver picked one but + # the test stays seed-tolerant by checking the union. + assert set(t.newly_dead_seats).issubset({7, 8}) + assert len(t.newly_dead_seats) == 1 + + def test_wolf_split_records_unresolved_seats_not_missing() -> None: """Split wolves have submitted — they must be classified as `unresolved_seats` (so recovery and /wolf extend can distinguish them from truly missing players).""" @@ -622,11 +701,12 @@ def test_llm_shortfall_padding_count() -> None: """Given N humans, pick_personas(9-N) returns exactly the shortfall.""" import random as _r - from wolfbot.llm.personas import pick_personas + from wolfbot.llm.persona_base import pick_personas + from wolfbot.npc.personas import NPC_PERSONAS rng = _r.Random(0) for n in range(0, 10): - picks = pick_personas(9 - n, rng) + picks = pick_personas(NPC_PERSONAS, 9 - n, rng) assert len(picks) == 9 - n assert len({p.key for p in picks}) == 9 - n diff --git a/tests/test_state_machine_phases.py b/tests/test_state_machine_phases.py index d1f039b..539c6bf 100644 --- a/tests/test_state_machine_phases.py +++ b/tests/test_state_machine_phases.py @@ -87,6 +87,18 @@ def test_plan_night0_transitions_to_day1_with_deadline() -> None: assert "ROLE_NOTICE" in kinds assert "SEER_RESULT_NIGHT0" in kinds assert "WOLF_PARTNER" in kinds + # The day-1-start PHASE_CHANGE log must be tagged day=1, not the + # game's current day_number (=0 at NIGHT_0). Same bug class as + # plan_night_resolve's MORNING/PHASE_CHANGE — without this tag the + # viewer puts day-1's discussion-open in the day-0 SETUP bucket + # and day-1 DAY_DISCUSSION starts off without its phase-boundary + # log. + day1_start = next( + log + for log in t.public_logs + if log.kind == "PHASE_CHANGE" and "1 日目の議論を開始" in log.text + ) + assert day1_start.day == 1 def test_plan_night0_random_white_is_non_wolf_non_self() -> None: diff --git a/tests/test_stt_roster_prompt.py b/tests/test_stt_roster_prompt.py new file mode 100644 index 0000000..68308a5 --- /dev/null +++ b/tests/test_stt_roster_prompt.py @@ -0,0 +1,81 @@ +"""Roster grounding for the STT analyzer prompt. + +The analyzer LLM receives a list of ``(seat_no, display_name)`` pairs +so it can resolve mistranscribed names (Whisper's "ラッキーオ" for +spoken "ラキオ" is the canonical failure case) to the canonical +participant. These tests pin the prompt-construction contract: + +1. ``_format_roster_block`` returns an empty string for missing / + empty roster so callers without seat info get the legacy prompt. +2. A non-empty roster is rendered as numbered lines with the + variant-tolerance instructions the analyzer needs. +3. Both ``GeminiAudioAnalyzer._build_system_prompt`` and + ``GroqWhisperAudioAnalyzer._build_analyzer_prompt`` thread the + roster through correctly. +""" + +from __future__ import annotations + +from wolfbot.master.voice.stt_service import ( + GeminiAudioAnalyzer, + GroqWhisperAudioAnalyzer, + _format_roster_block, +) + + +def test_format_roster_block_empty_when_roster_missing() -> None: + assert _format_roster_block(None) == "" + assert _format_roster_block([]) == "" + + +def test_format_roster_block_lists_seats_and_variant_rules() -> None: + block = _format_roster_block([(1, "🦋ラキオ"), (2, "🌙セツ")]) + assert "席1: 🦋ラキオ" in block + assert "席2: 🌙セツ" in block + # The crucial instruction: the LLM must collapse phonetic / + # mistranscribed variants onto a roster entry, never invent a + # new one. The example call-out is the canonical failure case + # from the voicetest dump that motivated this feature. + assert "ラッキーオ" in block + assert "敬称" in block + # Catch-all behaviors: out-of-roster / ambiguous → null. + assert "null" in block + + +def test_gemini_build_system_prompt_appends_roster_block() -> None: + """``_build_system_prompt(roster)`` must equal the template-only + body (= ``_build_system_prompt(None)``) followed by the roster + block — so the LLM sees the static instructions first and the + per-game seat list at the bottom.""" + base = GeminiAudioAnalyzer._build_system_prompt(None) + with_roster = GeminiAudioAnalyzer._build_system_prompt( + [(3, "🦋ラキオ")] + ) + assert with_roster.startswith(base) + assert "席3: 🦋ラキオ" in with_roster + + +def test_gemini_build_system_prompt_no_roster_returns_template_only() -> None: + """No-roster case is the template body alone — the roster block + contributes only when at least one seat is supplied.""" + base = GeminiAudioAnalyzer._build_system_prompt(None) + assert "あなたは人狼ゲームの音声ログ分析エンジン" in base + # No roster section markers when roster is empty. + assert "席1:" not in base + assert "席2:" not in base + + +def test_groq_build_analyzer_prompt_appends_roster_block() -> None: + base = GroqWhisperAudioAnalyzer._build_analyzer_prompt(None) + with_roster = GroqWhisperAudioAnalyzer._build_analyzer_prompt( + [(5, "🌙セツ")] + ) + assert with_roster.startswith(base) + assert "席5: 🌙セツ" in with_roster + + +def test_groq_build_analyzer_prompt_no_roster_returns_template_only() -> None: + base = GroqWhisperAudioAnalyzer._build_analyzer_prompt(None) + assert "あなたは人狼ゲームの発話内容を分析するエンジン" in base + assert "席1:" not in base + assert "席2:" not in base diff --git a/tests/test_template_engine.py b/tests/test_template_engine.py new file mode 100644 index 0000000..073ea37 --- /dev/null +++ b/tests/test_template_engine.py @@ -0,0 +1,181 @@ +"""Unit tests for the markdown prompt template engine. + +Covers two-piece API: +* :func:`load_template` (loader): id → file path resolution, missing-id + error, and that the cache returns the same string for repeated reads. +* :func:`render` (parser): placeholder substitution, nested ``{{#if}}`` + conditional blocks, fail-fast on missing variables. +* :func:`render_template`: the convenience load+render integration. + +The parser tests deliberately operate on raw strings (no I/O) so a FS +mock isn't needed; only the loader-level tests touch tmp_path. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from wolfbot.llm import template as tmpl +from wolfbot.llm.template import ( + TemplateNotFoundError, + TemplateRenderError, + load_template, + render, + render_template, +) +from wolfbot.llm.template import loader as loader_module + +# -------------------------------------------------------- parser tests + + +def test_render_simple_placeholder() -> None: + out = render("Hello, {{name}}!", name="Sakura") + assert out == "Hello, Sakura!" + + +def test_render_multiple_placeholders_and_repeats() -> None: + out = render( + "{{role}} は {{day}} 日目に発言。{{role}} は再度言及。", + role="占い師", + day=2, + ) + assert out == "占い師 は 2 日目に発言。占い師 は再度言及。" + + +def test_render_missing_variable_raises_with_var_name() -> None: + with pytest.raises(TemplateRenderError) as exc_info: + render("Hi {{missing}}!", existing="ok") + assert exc_info.value.variable == "missing" + assert "missing" in str(exc_info.value) + + +def test_render_template_id_surfaces_in_error_message() -> None: + with pytest.raises(TemplateRenderError) as exc_info: + render( + "Hi {{missing}}!", + template_id="master/example", + existing="ok", + ) + assert "master/example" in str(exc_info.value) + assert exc_info.value.template_id == "master/example" + + +def test_render_if_block_truthy_keeps_body() -> None: + out = render( + "頭 {{#if extra}}追加: {{detail}}{{/if}} 末尾", + extra=True, + detail="ABC", + ) + assert out == "頭 追加: ABC 末尾" + + +def test_render_if_block_falsy_omits_body() -> None: + out = render( + "頭 {{#if extra}}非表示{{/if}} 末尾", + extra=False, + ) + assert out == "頭 末尾" + + +def test_render_if_block_unknown_var_treated_as_falsy() -> None: + """A missing context key for an `{{#if}}` block is treated as + falsy rather than raising — this matches the documented behaviour + so callers can phrase optional sections without first checking + that the variable is even defined.""" + out = render( + "前 {{#if maybe}}HIDE{{/if}} 後", + ) + assert out == "前 後" + + +def test_render_nested_if_blocks() -> None: + text = ( + "S{{#if outer}}-O[{{#if inner}}I{{/if}}]-{{/if}}E" + ) + assert render(text, outer=True, inner=True) == "S-O[I]-E" + assert render(text, outer=True, inner=False) == "S-O[]-E" + # Outer falsy → body (including the inner block) is dropped wholesale. + assert render(text, outer=False, inner=True) == "SE" + + +def test_render_if_body_can_reference_placeholder() -> None: + out = render( + "{{#if active}}seat={{seat}}{{/if}}", + active=True, + seat=3, + ) + assert out == "seat=3" + + +def test_render_substitutes_non_string_via_str_cast() -> None: + out = render("count={{n}}", n=42) + assert out == "count=42" + + +# -------------------------------------------------------- loader tests + + +@pytest.fixture +def temp_templates_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Point the loader at a clean tmp dir for the duration of the test.""" + root = tmp_path / "templates" + (root / "master").mkdir(parents=True) + (root / "master" / "hello.md").write_text( + "Hi, {{name}}!", encoding="utf-8" + ) + monkeypatch.setattr(loader_module, "TEMPLATES_ROOT", root) + monkeypatch.setattr(tmpl, "TEMPLATES_ROOT", root, raising=False) + # Cache is module-global; clear so the new root is honoured. + load_template.cache_clear() + yield root + load_template.cache_clear() + + +def test_load_template_reads_file_under_root(temp_templates_root: Path) -> None: + body = load_template("master/hello") + assert body == "Hi, {{name}}!" + + +def test_load_template_caches_repeat_reads(temp_templates_root: Path) -> None: + """Two reads of the same id should return identical strings without + a second filesystem hit. Probe by mutating the file after the first + read — the cached value must NOT pick up the change.""" + first = load_template("master/hello") + (temp_templates_root / "master" / "hello.md").write_text( + "Different!", encoding="utf-8" + ) + second = load_template("master/hello") + assert first == second == "Hi, {{name}}!" + + +def test_load_template_missing_id_raises(temp_templates_root: Path) -> None: + with pytest.raises(TemplateNotFoundError): + load_template("master/does_not_exist") + + +def test_load_template_rejects_traversal(temp_templates_root: Path) -> None: + """`..` segments must be refused so a typo can't escape the + templates root.""" + with pytest.raises(ValueError, match="illegal template id segment"): + load_template("master/../escape") + + +def test_load_template_rejects_empty_id(temp_templates_root: Path) -> None: + with pytest.raises(ValueError, match="non-empty"): + load_template("") + + +def test_render_template_loads_and_substitutes(temp_templates_root: Path) -> None: + out = render_template("master/hello", name="セツ") + assert out == "Hi, セツ!" + + +def test_render_template_propagates_missing_variable( + temp_templates_root: Path, +) -> None: + with pytest.raises(TemplateRenderError) as exc_info: + render_template("master/hello") # no `name` supplied + assert exc_info.value.variable == "name" + assert exc_info.value.template_id == "master/hello" diff --git a/tests/test_text_analyzer.py b/tests/test_text_analyzer.py new file mode 100644 index 0000000..de235cf --- /dev/null +++ b/tests/test_text_analyzer.py @@ -0,0 +1,157 @@ +"""GeminiTextAnalyzer + FakeTextAnalyzer — parser and stub coverage. + +The analyzer mirrors `GeminiAudioAnalyzer` for the text channel. End-to-end +routing (analyzer → SpeechEvent.addressed_seat_no → SpeakArbiter dispatch) +is covered by `test_addressed_npc_routing.py`; this file exercises the +analyzer module in isolation. +""" + +from __future__ import annotations + +import pytest + +from wolfbot.master.state.text_analyzer import ( + FakeTextAnalyzer, + GeminiTextAnalyzer, + TextAnalysis, +) + + +def test_parse_response_handles_plain_json() -> None: + raw = '{"co_claim":"seer","addressed_name":"セツ"}' + parsed = GeminiTextAnalyzer._parse_response(raw) + assert parsed == {"co_claim": "seer", "addressed_name": "セツ"} + + +def test_parse_response_strips_markdown_fences() -> None: + raw = '```json\n{"co_claim":null,"addressed_name":"席3"}\n```' + parsed = GeminiTextAnalyzer._parse_response(raw) + assert parsed == {"co_claim": None, "addressed_name": "席3"} + + +def test_parse_response_returns_empty_on_garbage() -> None: + parsed = GeminiTextAnalyzer._parse_response("not-json") + assert parsed == {} + + +async def test_fake_analyzer_returns_scripted_in_order() -> None: + fake = FakeTextAnalyzer( + scripted=[ + TextAnalysis(addressed_name="セツ"), + TextAnalysis(co_declaration="seer"), + ] + ) + a = await fake.analyze(text="セツさん、どう?", timeout_s=5.0) + b = await fake.analyze(text="占いCO", timeout_s=5.0) + assert a.addressed_name == "セツ" + assert b.co_declaration == "seer" + assert fake.call_count == 2 + + +async def test_fake_analyzer_falls_through_to_default() -> None: + fake = FakeTextAnalyzer(default=TextAnalysis()) + a = await fake.analyze(text="特になし", timeout_s=5.0) + assert a.addressed_name is None and a.co_declaration is None + + +async def test_fake_analyzer_records_last_text_for_assertions() -> None: + fake = FakeTextAnalyzer(default=TextAnalysis()) + await fake.analyze(text="記録される", timeout_s=5.0) + assert fake.last_text == "記録される" + + +async def test_openai_compatible_text_analyzer_extracts_role_callout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When voice ingest uses Groq+xAI (separated STT + analyzer), the + text path must follow the same split — sending typed messages to the + same OpenAI-compatible analyzer endpoint instead of round-tripping + through Gemini. Without this, Gemini rate limits silently kill the + role_callout signal so wolf-side NPCs never see "占い師の方どうぞ" + requests (regression: game 58a3243a9fb8). + """ + import json as _json + from typing import Any + + import httpx + + from wolfbot.master.state.text_analyzer import OpenAICompatibleTextAnalyzer + + captured: dict[str, Any] = {} + + def _handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["headers"] = dict(request.headers) + captured["body"] = _json.loads(request.content.decode("utf-8")) + payload = { + "choices": [ + { + "message": { + "content": _json.dumps( + { + "co_claim": None, + "addressed_name": None, + "role_callout": "seer", + }, + ensure_ascii=False, + ) + } + } + ], + "usage": { + "prompt_tokens": 70, + "completion_tokens": 10, + "total_tokens": 80, + }, + } + return httpx.Response(200, json=payload) + + transport = httpx.MockTransport(_handler) + real_client = httpx.AsyncClient + + class _MockedClient(real_client): # type: ignore[misc, valid-type] + def __init__(self, *args: Any, **kwargs: Any) -> None: + kwargs.setdefault("transport", transport) + super().__init__(*args, **kwargs) + + monkeypatch.setattr("httpx.AsyncClient", _MockedClient) + + analyzer = OpenAICompatibleTextAnalyzer( + api_key="sk-test", + model="grok-test", + base_url="https://api.x.ai/v1", + ) + analysis = await analyzer.analyze( + text="本当の占い師出てきて、人狼さいどでもいい", + timeout_s=5.0, + ) + + assert analysis.role_callout == "seer" + assert analysis.co_declaration is None + assert analysis.addressed_name is None + # The endpoint and auth shape match the OpenAI Chat Completions + # contract used by GroqWhisperAudioAnalyzer's analyzer step. + assert captured["url"] == "https://api.x.ai/v1/chat/completions" + assert captured["headers"]["authorization"] == "Bearer sk-test" + assert captured["body"]["model"] == "grok-test" + assert captured["body"]["response_format"] == {"type": "json_object"} + + +async def test_fake_analyzer_propagates_scripted_exception() -> None: + """A scripted exception must be raised verbatim so the cog's broad + try/except logs and falls back to raw capture.""" + + class _Boom(RuntimeError): + pass + + fake = FakeTextAnalyzer(scripted=[_Boom("kaboom")]) + with pytest.raises(_Boom): + await fake.analyze(text="x", timeout_s=1.0) + + +async def test_fake_analyzer_no_default_returns_empty_analysis_when_unscripted() -> None: + """When no scripted result and no default is set, FakeTextAnalyzer must + return an empty TextAnalysis (matches a Gemini call that found nothing).""" + fake = FakeTextAnalyzer() + result = await fake.analyze(text="anything", timeout_s=1.0) + assert result == TextAnalysis() diff --git a/tests/test_voice_debug_dump.py b/tests/test_voice_debug_dump.py new file mode 100644 index 0000000..2151f4a --- /dev/null +++ b/tests/test_voice_debug_dump.py @@ -0,0 +1,357 @@ +"""Tests for the optional voice-segment debug dump. + +Verifies the on/off toggle, file naming, the .wav being a real WAV +(playable in any audio tool), the .txt sidecar's human-readable +contents, and behaviour on the four STT outcome paths (success / +low-confidence / hard-fail / unexpected exception). Also asserts that +write failures don't propagate to the caller — a flaky disk must +never break the voice path. +""" + +from __future__ import annotations + +import wave +from pathlib import Path + +import pytest + +from wolfbot.master.voice.stt_service import SttResult +from wolfbot.master.voice.voice_debug_dump import ( + SegmentDumpRecord, + debug_dir, + dump_segment, +) + + +def _record( + *, + game_id: str = "g_dbg", + segment_id: str = "seg_001", + seat_no: int = 1, + audio_bytes: int = 9_600, + display_name: str | None = "🌙 セツ", + result: SttResult | None = None, + failure_reason: str | None = None, +) -> SegmentDumpRecord: + return SegmentDumpRecord( + game_id=game_id, + phase_id=f"{game_id}::day1::DAY_DISCUSSION::1", + segment_id=segment_id, + seat_no=seat_no, + speaker_user_id="753109683971293296", + audio_start_ms=1_700_000_000_000, + audio_end_ms=1_700_000_001_000, + pcm_sample_rate=48_000, + pcm_channels=2, + pcm_sample_width=2, + audio_bytes=audio_bytes, + display_name=display_name, + result=result, + failure_reason=failure_reason, + ) + + +@pytest.fixture +def enabled_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setenv("WOLFBOT_VOICE_DEBUG_DIR", str(tmp_path)) + return tmp_path + + +async def test_disabled_when_env_unset( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Without the env var, ``dump_segment`` must touch nothing — the + call site is unconditional, so any disk I/O here would land in + production logs.""" + monkeypatch.delenv("WOLFBOT_VOICE_DEBUG_DIR", raising=False) + assert debug_dir() is None + await dump_segment(_record(), pcm=b"\x00" * 4_000) + assert list(tmp_path.iterdir()) == [] + + +async def test_writes_wav_and_txt_pair_on_success(enabled_dir: Path) -> None: + """Both files land under ``///`` with the + segment id as a shared stem, so an operator scanning Finder sees + ``.wav`` / ``.txt`` next to each other, grouped by player.""" + pcm = b"\x01\x02\x03\x04" * 1_000 # 4 KB ≈ 20 ms @ 48 kHz stereo + result = SttResult( + text="席3が怪しい", + confidence=0.92, + duration_ms=21, + summary="席3への投票表明", + co_declaration=None, + addressed_name=None, + ) + await dump_segment(_record(result=result), pcm=pcm) + + wav_path = enabled_dir / "g_dbg" / "🌙 セツ" / "seg_001.wav" + txt_path = enabled_dir / "g_dbg" / "🌙 セツ" / "seg_001.txt" + assert wav_path.exists() and txt_path.exists() + + +async def test_dumped_wav_is_a_real_wav_file(enabled_dir: Path) -> None: + """A reader using stdlib ``wave`` must accept the file — confirms + the WAV header isn't garbage and the format matches what we wrote.""" + pcm = b"\x00\x01" * 9_600 # ~50 ms @ 48 kHz stereo + await dump_segment(_record(audio_bytes=len(pcm)), pcm=pcm) + + wav_path = enabled_dir / "g_dbg" / "🌙 セツ" / "seg_001.wav" + with wave.open(str(wav_path), "rb") as r: + assert r.getframerate() == 48_000 + assert r.getnchannels() == 2 + assert r.getsampwidth() == 2 + # frames = bytes / (channels * sample_width); for our 19_200 byte + # blob that's 19_200 / 4 = 4_800. + assert r.getnframes() == len(pcm) // 4 + + +async def test_txt_leads_with_transcript_for_quick_inspection( + enabled_dir: Path, +) -> None: + """Operator's first line of business is "did Whisper hear me say + X?" — keep the answer at the top of the file.""" + result = SttResult( + text="占いCO 席7白", + confidence=0.95, + duration_ms=2_000, + summary="占いCO", + co_declaration="seer", + addressed_name=None, + ) + await dump_segment(_record(result=result), pcm=b"\x00" * 4_000) + body = (enabled_dir / "g_dbg" / "🌙 セツ" / "seg_001.txt").read_text(encoding="utf-8") + first = body.splitlines()[0] + assert first == "transcript: 占いCO 席7白" + assert "asr_conf : 0.950" in body + assert "co_declaration: seer" in body + + +async def test_raw_analyzer_json_appended_for_full_visibility( + enabled_dir: Path, +) -> None: + """The analyzer LLM emits more fields than ``SttResult`` exposes + (vote_target_seat, stance, future fields). Operator wants to see + ALL of them in the .txt to validate the analysis without + cross-referencing the JSONL trace.""" + result = SttResult( + text="席3が怪しいから投票", + confidence=0.95, + duration_ms=2_000, + summary="席3への投票表明", + co_declaration=None, + addressed_name=None, + raw_analysis={ + "summary": "席3への投票表明", + "co_claim": None, + "vote_target_seat": 3, + "stance": {"3": "negative", "5": "positive"}, + "addressed_name": None, + }, + ) + await dump_segment(_record(result=result), pcm=b"\x00" * 4_000) + body = (enabled_dir / "g_dbg" / "🌙 セツ" / "seg_001.txt").read_text(encoding="utf-8") + # First line is still the transcript — analyzer fields don't push + # the headline answer below the fold. + assert body.splitlines()[0] == "transcript: 席3が怪しいから投票" + # The raw analyzer JSON section must include fields that aren't + # surfaced via SttResult's typed fields. + assert "analysis (raw):" in body + assert '"vote_target_seat": 3' in body + assert '"stance":' in body + assert '"3": "negative"' in body + # Japanese values are NOT escaped to ASCII (\uXXXX) — operator + # readability matters more than wire compactness here. + assert "席3への投票表明" in body + + +async def test_raw_analyzer_section_omitted_when_no_analysis( + enabled_dir: Path, +) -> None: + """If ``raw_analysis`` is None (e.g. STT-only provider, hard + failure), the section is omitted entirely — no empty headers.""" + result = SttResult( + text="hi", + confidence=1.0, + duration_ms=100, + raw_analysis=None, + ) + await dump_segment(_record(result=result), pcm=b"\x00" * 100) + body = (enabled_dir / "g_dbg" / "🌙 セツ" / "seg_001.txt").read_text(encoding="utf-8") + assert "analysis (raw):" not in body + + +async def test_low_confidence_path_dumps_with_failure_reason( + enabled_dir: Path, +) -> None: + """A low-confidence segment carries BOTH the (suspicious) transcript + and the failure reason — operator wants to listen to the audio and + see whether Whisper hallucinated, and the gate that suppressed the + speech event needs to be visible.""" + result = SttResult( + text="ご視聴ありがとうございました", + confidence=0.05, + duration_ms=500, + summary=None, + co_declaration=None, + addressed_name=None, + ) + await dump_segment( + _record(result=result, failure_reason="stt_low_confidence"), + pcm=b"\x00" * 4_000, + ) + body = (enabled_dir / "g_dbg" / "🌙 セツ" / "seg_001.txt").read_text(encoding="utf-8") + assert "transcript: ご視聴ありがとうございました" in body + assert "asr_conf : 0.050" in body + + +async def test_hard_failure_path_dumps_audio_with_failure_reason( + enabled_dir: Path, +) -> None: + """When STT itself errored we have no transcript, but the audio is + the most valuable artifact — let the operator listen to it and + confirm what the upstream provider was rejecting.""" + await dump_segment( + _record(result=None, failure_reason="groq_http_400"), + pcm=b"\x00" * 4_000, + ) + body = (enabled_dir / "g_dbg" / "🌙 セツ" / "seg_001.txt").read_text(encoding="utf-8") + first = body.splitlines()[0] + assert first == "transcript: " + assert "failure_reason: groq_http_400" in body + + +async def test_path_components_sanitized(enabled_dir: Path) -> None: + """Game IDs and segment IDs come from external sources; a malicious + or buggy id must not escape the debug directory.""" + rec = _record(game_id="../escape/../danger", segment_id="../etc/passwd") + await dump_segment(rec, pcm=b"\x00" * 100) + # Nothing should have been written outside our tmp dir + assert not (enabled_dir.parent / "escape").exists() + # And the sanitized files DO exist somewhere under our tmp dir + found = list(enabled_dir.rglob("*.wav")) + assert len(found) == 1 + assert ".." not in str(found[0]) + + +async def test_display_name_groups_segments_by_speaker( + enabled_dir: Path, +) -> None: + """Segments from the same speaker land in the same per-name + subdirectory so an operator can ``ls`` to see one player's full + transcript history.""" + pcm = b"\x00" * 200 + await dump_segment( + _record(segment_id="seg_a", display_name="🌙 セツ"), pcm=pcm + ) + await dump_segment( + _record(segment_id="seg_b", display_name="🌙 セツ"), pcm=pcm + ) + speaker_dir = enabled_dir / "g_dbg" / "🌙 セツ" + assert (speaker_dir / "seg_a.wav").exists() + assert (speaker_dir / "seg_b.wav").exists() + + +async def test_display_name_falls_back_to_user_id(enabled_dir: Path) -> None: + """Empty / missing display_name falls back to the Discord user ID + so dumps still group together — better than dropping into a single + 'unknown' bucket where two speakers would collide.""" + await dump_segment(_record(display_name=None), pcm=b"\x00" * 200) + expected = enabled_dir / "g_dbg" / "753109683971293296" / "seg_001.wav" + assert expected.exists() + + +async def test_display_name_with_path_traversal_sanitized( + enabled_dir: Path, +) -> None: + """A nick like ``../etc`` must NOT escape the game subdirectory — + display_name comes from Discord and is fully attacker-controlled.""" + rec = _record(display_name="../../../etc/passwd") + await dump_segment(rec, pcm=b"\x00" * 100) + # Path components above the debug root must not exist. + assert not (enabled_dir.parent / "etc").exists() + found = list(enabled_dir.rglob("*.wav")) + assert len(found) == 1 + assert ".." not in str(found[0]) + # The dump still landed inside the game subdirectory. + assert (enabled_dir / "g_dbg") in found[0].parents + + +async def test_txt_includes_speaker_name_when_known( + enabled_dir: Path, +) -> None: + """Operator-friendly: the txt sidecar surfaces the human name so + they don't need to map seat_no → player every time.""" + await dump_segment( + _record( + display_name="🌙 セツ", + result=SttResult(text="hi", confidence=1.0, duration_ms=100), + ), + pcm=b"\x00" * 100, + ) + body = ( + enabled_dir / "g_dbg" / "🌙 セツ" / "seg_001.txt" + ).read_text(encoding="utf-8") + assert "speaker_name : 🌙 セツ" in body + + +async def test_write_failure_swallowed( + enabled_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A flaky disk (or read-only mount) must never break the voice + path — the dump is best-effort observability only.""" + import wolfbot.master.voice.voice_debug_dump as mod + + def boom(*_a: object, **_kw: object) -> None: + raise OSError("disk full") + + monkeypatch.setattr(mod, "_write_pair", boom) + # Should not raise. + await dump_segment(_record(), pcm=b"\x00" * 100) + + +async def test_overrides_pcm_format_for_downsampled_callers( + enabled_dir: Path, +) -> None: + """A future caller that down-mixes to 16 kHz mono before dump can + pass non-default format kwargs and the WAV reflects them.""" + rec = SegmentDumpRecord( + game_id="g_dbg", + phase_id="g_dbg::day1::DAY_DISCUSSION::1", + segment_id="seg_mono", + seat_no=1, + speaker_user_id="u", + audio_start_ms=0, + audio_end_ms=1_000, + pcm_sample_rate=16_000, + pcm_channels=1, + pcm_sample_width=2, + audio_bytes=32_000, + result=None, + ) + await dump_segment(rec, pcm=b"\x00" * 32_000) + # display_name unset → falls back to the speaker_user_id ("u"). + with wave.open(str(enabled_dir / "g_dbg" / "u" / "seg_mono.wav"), "rb") as r: + assert r.getframerate() == 16_000 + assert r.getnchannels() == 1 + + +async def test_dumped_txt_is_human_readable_not_json(enabled_dir: Path) -> None: + """Format is plain ``key: value`` lines, not JSON — operator-friendly + when opened in a viewer that doesn't pretty-print JSON. The first + line is always ``transcript: ...`` (or a clear failure marker).""" + await dump_segment( + _record( + result=SttResult( + text="hi", + confidence=1.0, + duration_ms=100, + ), + ), + pcm=b"\x00" * 100, + ) + body = (enabled_dir / "g_dbg" / "🌙 セツ" / "seg_001.txt").read_text(encoding="utf-8") + # No JSON braces should appear at the top level. + assert not body.startswith("{") + # The structure is colon-delimited key/value lines. + assert "transcript: hi" in body + assert "audio_window : " in body diff --git a/tests/test_voice_ingest_service.py b/tests/test_voice_ingest_service.py new file mode 100644 index 0000000..ff72235 --- /dev/null +++ b/tests/test_voice_ingest_service.py @@ -0,0 +1,332 @@ +"""Bundle 6: voice-ingest worker — Protocol-level coverage. + +Verifies the boundary semantics that matter most for the speech-event-bus: + +* NPC packet → discarded at receive boundary (no VAD lifecycle). +* Human packet through full pipeline → exactly one `speech_event_payload`. +* Below-threshold STT result → no `speech_event_payload`, one `stt_failed`. +* SttProviderError → no `speech_event_payload`, one `stt_failed`. +* `apply_snapshot` / `apply_update` from Master correctly maintain the + voice-ingest registry view. +* Restart abandons open VAD windows. +""" + +from __future__ import annotations + +from wolfbot.master.voice.stt_service import ( + FakeSttService, + SttProviderError, + SttResult, +) +from wolfbot.master.voice.voice_ingest_client import ( + FakeMasterIngestionClient, + InMemoryNpcRegistryView, + make_default_listeners, +) +from wolfbot.master.voice.voice_ingest_service import ( + VoiceIngestConfig, + VoiceIngestService, +) + + +def _phase_lookup_active() -> tuple[str, str] | None: + return ("g1", "g1::day1::DAY_DISCUSSION::1") + + +def _seat_lookup(uid: str) -> int | None: + table = {"u3": 3, "u4": 4} + return table.get(uid) + + +async def test_npc_packet_dropped_at_receive_boundary() -> None: + view = InMemoryNpcRegistryView() + view.apply_snapshot(("npc-bot",)) + client = FakeMasterIngestionClient() + stt = FakeSttService() + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup_active, + now_ms=lambda: 1000, + ) + forwarded = await svc.handle_voice_packet(speaker_user_id="npc-bot", pcm=b"x" * 32) + assert forwarded is False + assert svc.dropped_npc_packets == 1 + seg_id = await svc.begin_segment(speaker_user_id="npc-bot") + assert seg_id is None + assert client.vad_started == [] + + +async def test_human_segment_full_pipeline_emits_speech_event_payload() -> None: + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + stt = FakeSttService(scripted=[SttResult(text="こんにちは", confidence=0.85, duration_ms=600)]) + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup_active, + now_ms=lambda: 1000, + ) + seg_id = await svc.begin_segment(speaker_user_id="u3") + assert seg_id is not None + await svc.handle_voice_packet(speaker_user_id="u3", pcm=b"audio") + await svc.end_segment(speaker_user_id="u3") + assert len(client.vad_started) == 1 + assert len(client.vad_ended) == 1 + assert len(client.speech_payloads) == 1 + payload = client.speech_payloads[0] + assert payload.text == "こんにちは" + assert payload.seat_no == 3 + assert payload.segment_id == seg_id + assert client.stt_failures == [] + + +async def test_low_confidence_drop_does_not_emit_speech_event_payload() -> None: + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + stt = FakeSttService(scripted=[SttResult(text="あ", confidence=0.3, duration_ms=200)]) + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup_active, + config=VoiceIngestConfig(confidence_threshold=0.6), + now_ms=lambda: 1, + ) + await svc.begin_segment(speaker_user_id="u3") + await svc.end_segment(speaker_user_id="u3") + assert client.speech_payloads == [] + assert len(client.stt_failures) == 1 + assert client.stt_failures[0].failure_reason == "stt_low_confidence" + assert svc.stt_low_confidence_count == 1 + + +async def test_stt_provider_error_drops_segment() -> None: + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + stt = FakeSttService(scripted=[SttProviderError("stt_timeout")]) + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup_active, + now_ms=lambda: 1, + ) + await svc.begin_segment(speaker_user_id="u3") + await svc.end_segment(speaker_user_id="u3") + assert client.speech_payloads == [] + assert len(client.stt_failures) == 1 + assert client.stt_failures[0].failure_reason == "stt_timeout" + + +async def test_unknown_speaker_seat_skipped() -> None: + """A speaker that is not in any seat (orphan voice in VC) cannot start a segment.""" + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + stt = FakeSttService(default=SttResult(text="hi", confidence=0.9, duration_ms=1)) + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=lambda _uid: None, + phase_lookup=_phase_lookup_active, + now_ms=lambda: 1, + ) + seg = await svc.begin_segment(speaker_user_id="ghost") + assert seg is None + assert client.vad_started == [] + + +async def test_inactive_phase_skips_segment() -> None: + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + stt = FakeSttService(default=SttResult(text="hi", confidence=0.9, duration_ms=1)) + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=lambda: None, + now_ms=lambda: 1, + ) + seg = await svc.begin_segment(speaker_user_id="u3") + assert seg is None + assert client.vad_started == [] + + +async def test_registry_view_listeners_apply_snapshot_and_update() -> None: + view = InMemoryNpcRegistryView() + on_snap, on_update = make_default_listeners(view) + on_snap(("a", "b")) + assert view.npc_user_ids() == {"a", "b"} + on_update(("c",), ("a",)) + assert view.npc_user_ids() == {"b", "c"} + assert view.is_npc("c") is True + assert view.is_npc("a") is False + + +async def test_restart_abandons_open_segments() -> None: + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + stt = FakeSttService(default=SttResult(text="x", confidence=0.9, duration_ms=1)) + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup_active, + now_ms=lambda: 1, + ) + await svc.begin_segment(speaker_user_id="u3") + await svc.begin_segment(speaker_user_id="u4") + abandoned = await svc.abandon_open_segments() + assert abandoned == 2 + + +async def test_pre_stt_silence_gate_skips_short_or_quiet_segments() -> None: + """Pre-STT silence gate must suppress the STT call for buffers + too short or too quiet to plausibly contain speech, and emit a + canonical ``stt_failed reason=pre_stt_silence_gate`` instead.""" + + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + # Scripted result is never reached when the gate fires - if it + # were, the assertion on stt_failures would still catch the + # mis-routing because confidence=0.99 would land in + # speech_payloads, not stt_failures. + stt = FakeSttService(default=SttResult(text="x", confidence=0.99, duration_ms=1)) + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup_active, + config=VoiceIngestConfig( + pre_stt_min_rms=200, + pre_stt_min_duration_ms=300, + ), + now_ms=lambda: 1, + ) + # Buffer = 100 frames * 4 bytes (stereo s16) = 400 bytes ≈ 2ms + # well under both thresholds and contains all-zero PCM. + await svc.begin_segment(speaker_user_id="u3") + await svc.handle_voice_packet(speaker_user_id="u3", pcm=b"\x00" * 400) + await svc.end_segment(speaker_user_id="u3") + assert client.speech_payloads == [] + assert len(client.stt_failures) == 1 + assert client.stt_failures[0].failure_reason == "pre_stt_silence_gate" + assert svc.pre_stt_silence_gated_count == 1 + # Sanity: the STT service was never called. + assert stt.call_count == 0 + + +async def test_pre_stt_silence_gate_passes_loud_speech() -> None: + """A long, loud buffer should pass the gate and reach STT.""" + + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + stt = FakeSttService(default=SttResult(text="ok", confidence=0.9, duration_ms=500)) + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup_active, + config=VoiceIngestConfig( + pre_stt_min_rms=200, + pre_stt_min_duration_ms=300, + ), + now_ms=lambda: 1, + ) + # Build ~1s of loud square-wave PCM: 48000 Hz * 2ch * 2B = 192000 + # bytes/sec. Use a +/- 8000 amplitude so RMS clears the 200 gate. + import struct + + samples = [8000 if (i // 480) % 2 == 0 else -8000 for i in range(48_000 * 2)] + pcm = struct.pack(f"<{len(samples)}h", *samples) + await svc.begin_segment(speaker_user_id="u3") + await svc.handle_voice_packet(speaker_user_id="u3", pcm=pcm) + await svc.end_segment(speaker_user_id="u3") + assert len(client.speech_payloads) == 1 + assert client.stt_failures == [] + assert svc.pre_stt_silence_gated_count == 0 + + +async def test_roster_lookup_is_forwarded_to_stt_transcribe() -> None: + """When ``roster_lookup`` is supplied, its output must reach the + ``SttService.transcribe`` call so the analyzer LLM can ground + ``addressed_name`` on real seat names. ``FakeSttService`` records + the most recent ``roster`` for the assertion.""" + + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + stt = FakeSttService(default=SttResult(text="hi", confidence=0.9, duration_ms=1)) + captured_roster: list[tuple[int, str]] = [(3, "🦋ラキオ"), (4, "🌙セツ")] + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup_active, + roster_lookup=lambda: captured_roster, + now_ms=lambda: 1, + ) + await svc.begin_segment(speaker_user_id="u3") + await svc.handle_voice_packet(speaker_user_id="u3", pcm=b"\x00" * 16) + await svc.end_segment(speaker_user_id="u3") + assert stt.last_roster == captured_roster + + +async def test_roster_lookup_failure_does_not_break_stt_call() -> None: + """A roster_lookup that raises must not abort STT — the call + should fall back to a None roster (legacy un-grounded prompt).""" + + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + stt = FakeSttService(default=SttResult(text="hi", confidence=0.9, duration_ms=1)) + + def boom() -> list[tuple[int, str]]: + raise RuntimeError("game state not loaded") + + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup_active, + roster_lookup=boom, + now_ms=lambda: 1, + ) + await svc.begin_segment(speaker_user_id="u3") + await svc.end_segment(speaker_user_id="u3") + assert stt.call_count == 1 + assert stt.last_roster is None + assert len(client.speech_payloads) == 1 + + +async def test_pre_stt_silence_gate_disabled_by_default() -> None: + """Default ``VoiceIngestConfig`` keeps the gate off so existing + tests / callers that pass tiny buffers still reach STT.""" + + view = InMemoryNpcRegistryView() + client = FakeMasterIngestionClient() + stt = FakeSttService(default=SttResult(text="x", confidence=0.9, duration_ms=1)) + svc = VoiceIngestService( + registry_view=view, + master_client=client, + stt=stt, + seat_lookup=_seat_lookup, + phase_lookup=_phase_lookup_active, + now_ms=lambda: 1, + ) + await svc.begin_segment(speaker_user_id="u3") + await svc.handle_voice_packet(speaker_user_id="u3", pcm=b"\x00" * 16) + await svc.end_segment(speaker_user_id="u3") + assert len(client.speech_payloads) == 1 + assert svc.pre_stt_silence_gated_count == 0 diff --git a/tests/test_voice_recv_resilience.py b/tests/test_voice_recv_resilience.py new file mode 100644 index 0000000..5464274 --- /dev/null +++ b/tests/test_voice_recv_resilience.py @@ -0,0 +1,218 @@ +"""Tests for the ``PacketRouter`` resilience patch. + +The upstream :mod:`discord.ext.voice_recv` thread dies on the first +``OpusError("corrupted stream")``, taking down the entire RX path — +seen in production on 2026-04-28 right after Master joined VC and +started narration. After the patch, a single bad packet must drop the +frame and the thread must keep dispatching subsequent valid frames so +STT and the reactive_voice arbiter keep working. +""" + +from __future__ import annotations + +import threading +import time +from collections.abc import Callable +from types import SimpleNamespace +from typing import Any + +import discord.opus +import pytest +from discord.ext.voice_recv.router import PacketRouter + +# Resolve libopus before constructing ``OpusError(-4)`` — outside of an +# actual VC connection the C library isn't loaded by default and the +# constructor calls ``_lib.opus_strerror(...)`` which would raise +# ``AttributeError``. ``_load_default()`` is the same routine the SDK +# runs lazily when a real VC connection negotiates encryption. +discord.opus._load_default() + +from discord.opus import OpusError # noqa: E402 + +from wolfbot.master.voice.voice_recv_resilience import ( # noqa: E402 + apply_packet_router_resilience, +) + + +def _make_decoder(ssrc: int, behavior: list[Any]) -> SimpleNamespace: + """Decoder stub whose ``pop_data`` cycles through ``behavior``. + + Each entry is either an ``Exception`` instance (to raise) or a value + to return from ``pop_data``. ``None`` entries simulate an empty pop. + """ + iterator = iter(behavior) + + def pop_data() -> Any: + nxt = next(iterator) + if isinstance(nxt, BaseException): + raise nxt + return nxt + + return SimpleNamespace(ssrc=ssrc, pop_data=pop_data) + + +class _FakeWaiter: + """``MultiDataEvent`` stand-in: hand a single batch then notify done.""" + + def __init__(self, items: list[Any], done: threading.Event) -> None: + self.items = items + self._done = done + self._delivered = False + + def wait(self) -> None: + if self._delivered: + self._done.wait(timeout=2.0) + return + self._delivered = True + + +def _build_router(items: list[Any]) -> tuple[PacketRouter, list, threading.Event]: + """Construct a router stub wired with our fake waiter and capture sink.""" + end = threading.Event() + captured: list[tuple[Any, Any]] = [] + + sink = SimpleNamespace(write=lambda src, data: captured.append((src, data))) + router = PacketRouter.__new__(PacketRouter) + threading.Thread.__init__(router, daemon=True) + router.sink = sink # type: ignore[attr-defined] + router._lock = threading.RLock() # type: ignore[attr-defined] + router._end_thread = end # type: ignore[attr-defined] + router.waiter = _FakeWaiter(items, end) # type: ignore[attr-defined] + return router, captured, end + + +def test_apply_is_idempotent() -> None: + apply_packet_router_resilience() + first = PacketRouter._do_run + apply_packet_router_resilience() + assert PacketRouter._do_run is first + + +def test_opus_error_does_not_kill_thread_subsequent_frames_dispatched() -> None: + """One bad pop_data → that decoder skipped that round; the next valid + decoder in the same batch still reaches sink.write.""" + apply_packet_router_resilience() + + valid_data = SimpleNamespace(source="user_b", payload="ok") + bad = _make_decoder(11, [OpusError(-4)]) + good = _make_decoder(22, [valid_data]) + + router, captured, end = _build_router([bad, good]) + # Run one iteration synchronously then flip the end flag. + t = threading.Thread(target=router._do_run, daemon=True) + t.start() + # Give the loop one tick, then stop it. + time.sleep(0.05) + end.set() + t.join(timeout=1.0) + + assert not t.is_alive(), "router thread died despite resilient patch" + assert captured == [("user_b", valid_data)] + + +def test_sink_write_failure_does_not_kill_thread() -> None: + """A buggy sink.write must not propagate — the next decoder still gets + serviced and the thread stays up.""" + apply_packet_router_resilience() + + end = threading.Event() + written: list[Any] = [] + + def writer(src: Any, data: Any) -> None: + if data.payload == "explode": + raise RuntimeError("sink boom") + written.append((src, data)) + + sink = SimpleNamespace(write=writer) + bad_data = SimpleNamespace(source="x", payload="explode") + good_data = SimpleNamespace(source="y", payload="ok") + + bad = _make_decoder(1, [bad_data]) + good = _make_decoder(2, [good_data]) + + router = PacketRouter.__new__(PacketRouter) + threading.Thread.__init__(router, daemon=True) + router.sink = sink # type: ignore[attr-defined] + router._lock = threading.RLock() # type: ignore[attr-defined] + router._end_thread = end # type: ignore[attr-defined] + router.waiter = _FakeWaiter([bad, good], end) # type: ignore[attr-defined] + + t = threading.Thread(target=router._do_run, daemon=True) + t.start() + time.sleep(0.05) + end.set() + t.join(timeout=1.0) + + assert not t.is_alive() + assert written == [("y", good_data)] + + +def test_none_pop_data_is_ignored_without_calling_sink() -> None: + """``pop_data`` returning ``None`` is the normal "no completed frame" + case — must not be forwarded to the sink.""" + apply_packet_router_resilience() + + end = threading.Event() + captured: list[Any] = [] + sink = SimpleNamespace(write=lambda *a, **kw: captured.append(a)) + + empty = _make_decoder(7, [None]) + router = PacketRouter.__new__(PacketRouter) + threading.Thread.__init__(router, daemon=True) + router.sink = sink # type: ignore[attr-defined] + router._lock = threading.RLock() # type: ignore[attr-defined] + router._end_thread = end # type: ignore[attr-defined] + router.waiter = _FakeWaiter([empty], end) # type: ignore[attr-defined] + + t = threading.Thread(target=router._do_run, daemon=True) + t.start() + time.sleep(0.05) + end.set() + t.join(timeout=1.0) + + assert not t.is_alive() + assert captured == [] + + +@pytest.mark.parametrize( + "exc_factory", + [ + # Wrap in lambdas so OpusError(-1) is only constructed at test + # run time, not at collection time. Without this, environments + # without libopus loaded crash in `_lib.opus_strerror(...)` and + # take the entire module's collection down with them. + pytest.param(lambda: ValueError("oops"), id="value-error"), + pytest.param(lambda: KeyError("k"), id="key-error"), + pytest.param(lambda: OpusError(-1), id="opus-error"), + ], +) +def test_arbitrary_pop_data_exception_is_swallowed( + exc_factory: Callable[[], BaseException], +) -> None: + """OpusError is the known failure mode; any other unexpected exception + must also be contained so the thread keeps draining packets.""" + exc = exc_factory() + apply_packet_router_resilience() + + end = threading.Event() + captured: list[Any] = [] + sink = SimpleNamespace(write=lambda src, data: captured.append((src, data))) + + bad = _make_decoder(99, [exc]) + good = _make_decoder(100, [SimpleNamespace(source="u", payload="ok")]) + + router = PacketRouter.__new__(PacketRouter) + threading.Thread.__init__(router, daemon=True) + router.sink = sink # type: ignore[attr-defined] + router._lock = threading.RLock() # type: ignore[attr-defined] + router._end_thread = end # type: ignore[attr-defined] + router.waiter = _FakeWaiter([bad, good], end) # type: ignore[attr-defined] + + t = threading.Thread(target=router._do_run, daemon=True) + t.start() + time.sleep(0.05) + end.set() + t.join(timeout=1.0) + + assert not t.is_alive() + assert len(captured) == 1 diff --git a/uv.lock b/uv.lock index 4859654..aa6496e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,14 +1,14 @@ version = 1 -revision = 3 +revision = 1 requires-python = "==3.11.*" [[package]] name = "aiohappyeyeballs" version = "2.6.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265 }, ] [[package]] @@ -24,25 +24,25 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, - { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, - { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, - { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, - { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, - { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, - { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, - { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, - { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, - { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, - { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, - { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513 }, + { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748 }, + { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673 }, + { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757 }, + { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152 }, + { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010 }, + { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251 }, + { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969 }, + { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871 }, + { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844 }, + { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969 }, + { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193 }, + { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477 }, + { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198 }, + { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321 }, + { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069 }, + { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859 }, ] [[package]] @@ -53,27 +53,27 @@ dependencies = [ { name = "frozenlist" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 }, ] [[package]] name = "aiosqlite" version = "0.22.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821 } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405 }, ] [[package]] name = "annotated-types" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, ] [[package]] @@ -84,27 +84,27 @@ dependencies = [ { name = "idna" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622 } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 }, ] [[package]] name = "attrs" version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055 } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 }, ] [[package]] name = "certifi" version = "2026.4.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077 } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707 }, ] [[package]] @@ -114,55 +114,55 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344 }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560 }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613 }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476 }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374 }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597 }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574 }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971 }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972 }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078 }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076 }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820 }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635 }, ] [[package]] name = "charset-normalizer" version = "3.4.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, - { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, - { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, - { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, - { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, - { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, - { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, - { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, - { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, - { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, - { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, - { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, - { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, - { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, - { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705 }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419 }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901 }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742 }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061 }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239 }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173 }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841 }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304 }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455 }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036 }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739 }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277 }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819 }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281 }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843 }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958 }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] [[package]] @@ -172,42 +172,85 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, - { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, - { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, - { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, - { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, - { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, - { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, - { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, - { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, - { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, - { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, - { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, - { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, - { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, - { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, - { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, - { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, - { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, - { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, - { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, - { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, - { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, - { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, - { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" }, - { url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" }, - { url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" }, - { url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" }, - { url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" }, - { url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214 }, + { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617 }, + { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186 }, + { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244 }, + { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906 }, + { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842 }, + { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313 }, + { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964 }, + { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817 }, + { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544 }, + { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536 }, + { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106 }, + { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581 }, + { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309 }, + { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072 }, + { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767 }, + { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350 }, + { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394 }, + { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777 }, + { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771 }, + { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753 }, + { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911 }, + { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411 }, + { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262 }, + { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506 }, + { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060 }, + { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487 }, + { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737 }, + { url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893 }, + { url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867 }, + { url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192 }, + { url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486 }, + { url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327 }, + { url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916 }, +] + +[[package]] +name = "davey" +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/b3/62a9af5f7e1cf3cb99f17dcf26dc53f753facca220721bb0d7daaffcf37e/davey-0.1.5.tar.gz", hash = "sha256:ac7b636edbd760602087176a1973110826ef350a7e0fd263f9affcd3d7dc9575", size = 62968 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/e2/8b43b24b536e81b7ce138c7bc7a84382e0fa6c0479725c81f8512674924e/davey-0.1.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6d5c4a76cd27deb1e55ae4bdad93ddcd7a78808c42cf6835b60b7391fbb7a0c8", size = 845507 }, + { url = "https://files.pythonhosted.org/packages/de/c2/2651a9b7343466425b62029b1b368cf7129d8dadc38ea00a438f02c917e3/davey-0.1.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0fee5e5f3691f7a25759ae30eedb67b7249bed67b44a7aefd9ee1a6266bb116b", size = 806630 }, + { url = "https://files.pythonhosted.org/packages/68/4b/761256951078212906130fd90f3d54b68e45ea1faa6e67fec66752321ebb/davey-0.1.5-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:aeba88a89489a0d30222b239633b0922932fa3c2a1784aadf9adc7abd28bc225", size = 944329 }, + { url = "https://files.pythonhosted.org/packages/6a/26/b6d544bb21d54dc25f7670834dc64dcedc169d02374bfeab4e355df4eeda/davey-0.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f5c8fcd4f32a7bedd14c925461668c83c231d77da22b9df428affc9b79d2f6e", size = 898174 }, + { url = "https://files.pythonhosted.org/packages/17/75/529d463f2d7008a63f504ba33cd499ac69e4fd92b5f1360cd12fde319ad4/davey-0.1.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7cf404c1538fd5f0701817cdde5232a991984945cbd7de2f93b8974ce615003", size = 827262 }, + { url = "https://files.pythonhosted.org/packages/56/44/bd950dad22f4376e9d12070ed959d071424391f0563f00e3b131688ef194/davey-0.1.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0bd2f057f91c574b355b338f69747af174c5b2b1ff59c9d0ae20d246bc79ac4", size = 939800 }, + { url = "https://files.pythonhosted.org/packages/a6/b0/8611986e28cd838b78ab25b9898aea98f3b1331dc35dfb183fdb6fe9a6d8/davey-0.1.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94f4c11308b060ebd66090580a074d50d3426fb73ebc12cdd1ab5fa7a8b8783e", size = 872439 }, + { url = "https://files.pythonhosted.org/packages/c8/8c/f6e3ad8e20ada0143344bf206b282b47a37bfa15b438e7f82f15c9b5aa21/davey-0.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72b7eeec80039882ad6296a613bc532e066b4f46eb1c244b0e775baf61d24617", size = 919366 }, + { url = "https://files.pythonhosted.org/packages/e0/cb/489a3be207597753323e5c81fae463f9557a4f77cba8722d1fef7dc7e9ee/davey-0.1.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82f67134702ccd49a8c9a868abe03539bd90586f497e723c0a1f93cc7d16dee7", size = 1078095 }, + { url = "https://files.pythonhosted.org/packages/b7/9a/00b4bf12aa96962edc601fd13b1a18333c196bb48a8fa8ee0b935f42ff95/davey-0.1.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fedf31a4a7d65f6f948ecbc68f20423788c63e5727342fdf721d3488d56d42ed", size = 1104365 }, + { url = "https://files.pythonhosted.org/packages/ac/d1/b2b096edebdf6e73a58d116ca7a3f55a07b4c8e16a24ce49fb36861f961e/davey-0.1.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b1656b669e56c8dc2669e241ceba2ee5b87da2e634f0fe8d3de3c11b5fcfd2ef", size = 1136421 }, + { url = "https://files.pythonhosted.org/packages/20/e8/d0b91af57a278de42b6f1070233b6c8a790ee5840fa9c0cfedeef2c67076/davey-0.1.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f861b3f1b54d68269c4cba4f6807a9d8a8da35494c7adde76791615097de8a9", size = 1129939 }, + { url = "https://files.pythonhosted.org/packages/56/c3/5024d376da05321c6495fea0c2d0e87be7789aef7dfbab16f33a5316077f/davey-0.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:16f49a630f5a0e477ce8ad0863b59a222888c031114707e6117e9154e756862b", size = 873550 }, + { url = "https://files.pythonhosted.org/packages/12/0b/6b1be03864b5e78691fe90aa28c5b17d372c1a5eb610a6e8fa6d50bb9bd0/davey-0.1.5-pp311-pypy311_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f8a36f56530a68cba199384a5f6e12ea2594e44febb93d5df7396a3e1167348d", size = 944476 }, + { url = "https://files.pythonhosted.org/packages/c2/fd/583e79fe45f7dd720297f76a9236d6bc39cf869aaba80012249291a3613f/davey-0.1.5-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87e8cf3d103aaebef9ce598c4c91df5a8d5e7444e5e310577efb33e133bd802a", size = 898675 }, + { url = "https://files.pythonhosted.org/packages/67/65/c4c28ba7a5767ca6bf042b291dd00b4482d7515f6121b498a8c2ce9ea4b7/davey-0.1.5-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:587be3289a9910941a59c9013f0e21aeb519b2a831a42f4fa5b213f835b48cd7", size = 827386 }, + { url = "https://files.pythonhosted.org/packages/23/f5/cacbe79c173664d978667fde18e330606fbdbd602aff71b7b9e1d0ddd2da/davey-0.1.5-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:144df754373523a282123e6678a7c03b2d334d00b46d6fdbfe5deda9f68e7a62", size = 940062 }, + { url = "https://files.pythonhosted.org/packages/55/75/c5186ba473d4aa95f76ad941903f04d7916397a6ecfc0772a15e6c5bf45b/davey-0.1.5-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d408ab155b6688efeb5a71a8bb387c63136e8735a2b7aa8c839269f6583412aa", size = 872388 }, + { url = "https://files.pythonhosted.org/packages/5a/f0/d98c0e88b6e8890ab10eede4afa7d196a25054f880230a9c16a3ad267e4d/davey-0.1.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69048265720d46fe029e9e9beaed498e56e47edcb80568b09114d594386708d1", size = 919448 }, + { url = "https://files.pythonhosted.org/packages/2e/bb/a18f6e2561f301d0fcdee945e3bc8360faa441c5b88d22425a56787508fc/davey-0.1.5-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:6d1e21798909545e28ee39f6a624bc218cd2b827cb387dd115582769041cde8f", size = 1078247 }, + { url = "https://files.pythonhosted.org/packages/20/27/1b8175f3d237cf2ad20ec2ddb01479fd60a96ac1a33795e788d2a6c9073d/davey-0.1.5-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:3a3f0519eabf50c3e27a45291dcff5d7e0bf3fbfcbc4ff9f4e58f9a5e7644fba", size = 1104272 }, + { url = "https://files.pythonhosted.org/packages/bc/2e/2c7cfdaef9a1b5647bd3b4f594d696ec5047d9a1fd4cccd971031c98acf9/davey-0.1.5-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:f306c09b44e65c4d97385ee5848bf4d5de1dc37ca82843311e4d223de59574bb", size = 1136585 }, + { url = "https://files.pythonhosted.org/packages/7b/4a/17b418f58bc6c12c1fdf3edb2ec0a9bde82d72e1f8e0ed263b0874ee9b77/davey-0.1.5-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:248246da8a3e7b4ac5d8269c91b58ece202d0de95a4aedf259033fccb211226a", size = 1130202 }, +] + +[[package]] +name = "discord-ext-voice-recv" +version = "0.5.2a179" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "discord-py", extra = ["voice"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/4e/f52f69b4854b817b50d5998f1f470e42f966af9ac47cd853736f6c501a3f/discord_ext_voice_recv-0.5.2a179.tar.gz", hash = "sha256:6cb8f5ff60c3885020e5ebd879323dc97ebb0745788f3778a14ef31810f848a8", size = 39805 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/65/d3a9d555cb2baaab6f8081d64d4d423688ba28ff7ae6608c374d06add3f6/discord_ext_voice_recv-0.5.2a179-py3-none-any.whl", hash = "sha256:f3fa65f2c1591bef2382aa39f3ebc25d6751b405aef5b5d113dfb452640f29fc", size = 41041 }, ] [[package]] @@ -217,43 +260,49 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/57/9a2d9abdabdc9db8ef28ce0cf4129669e1c8717ba28d607b5ba357c4de3b/discord_py-2.7.1.tar.gz", hash = "sha256:24d5e6a45535152e4b98148a9dd6b550d25dc2c9fb41b6d670319411641249da", size = 1106326, upload-time = "2026-03-03T18:40:46.24Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/57/9a2d9abdabdc9db8ef28ce0cf4129669e1c8717ba28d607b5ba357c4de3b/discord_py-2.7.1.tar.gz", hash = "sha256:24d5e6a45535152e4b98148a9dd6b550d25dc2c9fb41b6d670319411641249da", size = 1106326 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/a7/17208c3b3f92319e7fad259f1c6d5a5baf8fd0654c54846ced329f83c3eb/discord_py-2.7.1-py3-none-any.whl", hash = "sha256:849dca2c63b171146f3a7f3f8acc04248098e9e6203412ce3cf2745f284f7439", size = 1227550, upload-time = "2026-03-03T18:40:44.492Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a7/17208c3b3f92319e7fad259f1c6d5a5baf8fd0654c54846ced329f83c3eb/discord_py-2.7.1-py3-none-any.whl", hash = "sha256:849dca2c63b171146f3a7f3f8acc04248098e9e6203412ce3cf2745f284f7439", size = 1227550 }, +] + +[package.optional-dependencies] +voice = [ + { name = "davey" }, + { name = "pynacl" }, ] [[package]] name = "distro" version = "1.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722 } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, ] [[package]] name = "frozenlist" version = "1.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875 } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, - { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, - { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, - { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, - { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, - { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, - { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, - { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, - { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, - { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, - { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, - { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912 }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046 }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119 }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067 }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160 }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544 }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797 }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923 }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886 }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731 }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544 }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806 }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382 }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647 }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064 }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937 }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409 }, ] [[package]] @@ -264,9 +313,9 @@ dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/fc/e925290a1ad95c975c459e2df070fac2b90954e13a0370ac505dff78cb99/google_auth-2.49.2.tar.gz", hash = "sha256:c1ae38500e73065dcae57355adb6278cf8b5c8e391994ae9cbadbcb9631ab409", size = 333958, upload-time = "2026-04-10T00:41:21.888Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/fc/e925290a1ad95c975c459e2df070fac2b90954e13a0370ac505dff78cb99/google_auth-2.49.2.tar.gz", hash = "sha256:c1ae38500e73065dcae57355adb6278cf8b5c8e391994ae9cbadbcb9631ab409", size = 333958 } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/76/d241a5c927433420507215df6cac1b1fa4ac0ba7a794df42a84326c68da8/google_auth-2.49.2-py3-none-any.whl", hash = "sha256:c2720924dfc82dedb962c9f52cabb2ab16714fd0a6a707e40561d217574ed6d5", size = 240638, upload-time = "2026-04-10T00:41:14.501Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/d241a5c927433420507215df6cac1b1fa4ac0ba7a794df42a84326c68da8/google_auth-2.49.2-py3-none-any.whl", hash = "sha256:c2720924dfc82dedb962c9f52cabb2ab16714fd0a6a707e40561d217574ed6d5", size = 240638 }, ] [package.optional-dependencies] @@ -290,18 +339,18 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/d8/40f5f107e5a2976bbac52d421f04d14fc221b55a8f05e66be44b2f739fe6/google_genai-1.73.1.tar.gz", hash = "sha256:b637e3a3b9e2eccc46f27136d470165803de84eca52abfed2e7352081a4d5a15", size = 530998, upload-time = "2026-04-14T21:06:19.153Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/d8/40f5f107e5a2976bbac52d421f04d14fc221b55a8f05e66be44b2f739fe6/google_genai-1.73.1.tar.gz", hash = "sha256:b637e3a3b9e2eccc46f27136d470165803de84eca52abfed2e7352081a4d5a15", size = 530998 } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/af/508e0528015240d710c6763f7c89ff44fab9a94a80b4377e265d692cbfd6/google_genai-1.73.1-py3-none-any.whl", hash = "sha256:af2d2287d25e42a187de19811ef33beb2e347c7e2bdb4dc8c467d78254e43a2c", size = 783595, upload-time = "2026-04-14T21:06:17.464Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/508e0528015240d710c6763f7c89ff44fab9a94a80b4377e265d692cbfd6/google_genai-1.73.1-py3-none-any.whl", hash = "sha256:af2d2287d25e42a187de19811ef33beb2e347c7e2bdb4dc8c467d78254e43a2c", size = 783595 }, ] [[package]] name = "h11" version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, ] [[package]] @@ -312,9 +361,9 @@ dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, ] [[package]] @@ -327,101 +376,97 @@ dependencies = [ { name = "httpcore" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, ] [[package]] name = "idna" version = "3.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629 }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, ] [[package]] name = "jiter" version = "0.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/1f/198ae537fccb7080a0ed655eb56abf64a92f79489dfbf79f40fa34225bcd/jiter-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7e791e247b8044512e070bd1f3633dc08350d32776d2d6e7473309d0edf256a2", size = 316896, upload-time = "2026-04-10T14:26:01.986Z" }, - { url = "https://files.pythonhosted.org/packages/cf/34/da67cff3fce964a36d03c3e365fb0f8726ade2a6cfd4d3c70107e216ead6/jiter-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71527ce13fd5a0c4e40ad37331f8c547177dbb2dd0a93e5278b6a5eecf748804", size = 321085, upload-time = "2026-04-10T14:26:03.364Z" }, - { url = "https://files.pythonhosted.org/packages/ed/36/4c72e67180d4e71a4f5dcf7886d0840e83c49ab11788172177a77570326e/jiter-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02c4a7ab56f746014874f2c525584c0daca1dec37f66fd707ecef3b7e5c2228c", size = 347393, upload-time = "2026-04-10T14:26:05.314Z" }, - { url = "https://files.pythonhosted.org/packages/bc/db/9b39e09ceafa9878235c0fc29e3e3f9b12a4c6a98ea3085b998cadf3accc/jiter-0.14.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:376e9dafff914253bb9d46cdc5f7965607fbe7feb0a491c34e35f92b2770702e", size = 372937, upload-time = "2026-04-10T14:26:06.884Z" }, - { url = "https://files.pythonhosted.org/packages/b0/96/0dcba1d7a82c1b720774b48ef239376addbaf30df24c34742ac4a57b67b2/jiter-0.14.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23ad2a7a9da1935575c820428dd8d2490ce4d23189691ce33da1fc0a58e14e1c", size = 463646, upload-time = "2026-04-10T14:26:08.345Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e3/f61b71543e746e6b8b805e7755814fc242715c16f1dba58e1cbccb8032c2/jiter-0.14.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b3ddf5786bc7732d293bba3411ac637ecfa200a39983166d1df86a59a43c9f", size = 380225, upload-time = "2026-04-10T14:26:10.161Z" }, - { url = "https://files.pythonhosted.org/packages/ad/5e/0ddeb7096aca099114abe36c4921016e8d251e6f35f5890240b31f1f60ae/jiter-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c001d5a646c2a50dc055dd526dad5d5245969e8234d2b1131d0451e81f3a373", size = 358682, upload-time = "2026-04-10T14:26:11.574Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d1/fe0c46cd7fda9cad8f1ff9ad217dc61f1e4280b21052ec6dfe88c1446ef2/jiter-0.14.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:834bb5bdabca2e91592a03d373838a8d0a1b8bbde7077ae6913fd2fc51812d00", size = 359973, upload-time = "2026-04-10T14:26:13.316Z" }, - { url = "https://files.pythonhosted.org/packages/ac/21/f5317f91729b501019184771c80d60abd89907009e7bfa6c7e348c5bdd44/jiter-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4e9178be60e229b1b2b0710f61b9e24d1f4f8556985a83ff4c4f95920eea7314", size = 397568, upload-time = "2026-04-10T14:26:15.212Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/79d8f33fb2bf168db0df5c9cd16fe440a8ada57e929d3677b22712c2568f/jiter-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7e4ccff04ec03614e62c613e976a3a5860dc9714ce8266f44328bdc8b1cab2c", size = 522535, upload-time = "2026-04-10T14:26:16.956Z" }, - { url = "https://files.pythonhosted.org/packages/5c/00/d1e3ff3d2a465e67f08507d74bafb2dcd29eba91dc939820e39e8dea38b8/jiter-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:69539d936fb5d55caf6ecd33e2e884de083ff0ea28579780d56c4403094bb8d9", size = 556709, upload-time = "2026-04-10T14:26:18.5Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/bbb2189f62ace8d95e869aa4c84c9946616f301e2d02895a6f20dcc3bba3/jiter-0.14.0-cp311-cp311-win32.whl", hash = "sha256:4927d09b3e572787cc5e0a5318601448e1ab9391bcef95677f5840c2d00eaa6d", size = 208660, upload-time = "2026-04-10T14:26:20.511Z" }, - { url = "https://files.pythonhosted.org/packages/b8/86/c500b53dcbf08575f5963e536ebd757a1f7c568272ba5d180b212c9a87fb/jiter-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:42d6ed359ac49eb922fdd565f209c57340aa06d589c84c8413e42a0f9ae1b842", size = 204659, upload-time = "2026-04-10T14:26:22.152Z" }, - { url = "https://files.pythonhosted.org/packages/75/4a/a676249049d42cb29bef82233e4fe0524d414cbe3606c7a4b311193c2f77/jiter-0.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:6dd689f5f4a5a33747b28686e051095beb214fe28cfda5e9fe58a295a788f593", size = 194772, upload-time = "2026-04-10T14:26:23.458Z" }, - { url = "https://files.pythonhosted.org/packages/32/a1/ef34ca2cab2962598591636a1804b93645821201cc0095d4a93a9a329c9d/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a25ffa2dbbdf8721855612f6dca15c108224b12d0c4024d0ac3d7902132b4211", size = 311366, upload-time = "2026-04-10T14:28:27.943Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/520576a532a6b8a6f42747afed289c8448c879a34d7802fe2c832d4fd38f/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ac9cbaa86c10996b92bd12c91659b60f939f8e28fcfa6bc11a0e90a774ce95b", size = 309873, upload-time = "2026-04-10T14:28:29.688Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7c/c16db114ea1f2f532f198aa8dc39585026af45af362c69a0492f31bc4821/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:844e73b6c56b505e9e169234ea3bdea2ea43f769f847f47ac559ba1d2361ebea", size = 344816, upload-time = "2026-04-10T14:28:31.348Z" }, - { url = "https://files.pythonhosted.org/packages/99/8f/15e7741ff19e9bcd4d753f7ff22f988fd54592f134ca13701c13ea8c20e0/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e52c076f187405fc21523c746c04399c9af8ece566077ed147b2126f2bcba577", size = 351445, upload-time = "2026-04-10T14:28:33.093Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/1f/198ae537fccb7080a0ed655eb56abf64a92f79489dfbf79f40fa34225bcd/jiter-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7e791e247b8044512e070bd1f3633dc08350d32776d2d6e7473309d0edf256a2", size = 316896 }, + { url = "https://files.pythonhosted.org/packages/cf/34/da67cff3fce964a36d03c3e365fb0f8726ade2a6cfd4d3c70107e216ead6/jiter-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71527ce13fd5a0c4e40ad37331f8c547177dbb2dd0a93e5278b6a5eecf748804", size = 321085 }, + { url = "https://files.pythonhosted.org/packages/ed/36/4c72e67180d4e71a4f5dcf7886d0840e83c49ab11788172177a77570326e/jiter-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02c4a7ab56f746014874f2c525584c0daca1dec37f66fd707ecef3b7e5c2228c", size = 347393 }, + { url = "https://files.pythonhosted.org/packages/bc/db/9b39e09ceafa9878235c0fc29e3e3f9b12a4c6a98ea3085b998cadf3accc/jiter-0.14.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:376e9dafff914253bb9d46cdc5f7965607fbe7feb0a491c34e35f92b2770702e", size = 372937 }, + { url = "https://files.pythonhosted.org/packages/b0/96/0dcba1d7a82c1b720774b48ef239376addbaf30df24c34742ac4a57b67b2/jiter-0.14.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23ad2a7a9da1935575c820428dd8d2490ce4d23189691ce33da1fc0a58e14e1c", size = 463646 }, + { url = "https://files.pythonhosted.org/packages/f1/e3/f61b71543e746e6b8b805e7755814fc242715c16f1dba58e1cbccb8032c2/jiter-0.14.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b3ddf5786bc7732d293bba3411ac637ecfa200a39983166d1df86a59a43c9f", size = 380225 }, + { url = "https://files.pythonhosted.org/packages/ad/5e/0ddeb7096aca099114abe36c4921016e8d251e6f35f5890240b31f1f60ae/jiter-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c001d5a646c2a50dc055dd526dad5d5245969e8234d2b1131d0451e81f3a373", size = 358682 }, + { url = "https://files.pythonhosted.org/packages/e9/d1/fe0c46cd7fda9cad8f1ff9ad217dc61f1e4280b21052ec6dfe88c1446ef2/jiter-0.14.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:834bb5bdabca2e91592a03d373838a8d0a1b8bbde7077ae6913fd2fc51812d00", size = 359973 }, + { url = "https://files.pythonhosted.org/packages/ac/21/f5317f91729b501019184771c80d60abd89907009e7bfa6c7e348c5bdd44/jiter-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4e9178be60e229b1b2b0710f61b9e24d1f4f8556985a83ff4c4f95920eea7314", size = 397568 }, + { url = "https://files.pythonhosted.org/packages/e9/05/79d8f33fb2bf168db0df5c9cd16fe440a8ada57e929d3677b22712c2568f/jiter-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7e4ccff04ec03614e62c613e976a3a5860dc9714ce8266f44328bdc8b1cab2c", size = 522535 }, + { url = "https://files.pythonhosted.org/packages/5c/00/d1e3ff3d2a465e67f08507d74bafb2dcd29eba91dc939820e39e8dea38b8/jiter-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:69539d936fb5d55caf6ecd33e2e884de083ff0ea28579780d56c4403094bb8d9", size = 556709 }, + { url = "https://files.pythonhosted.org/packages/60/5b/bbb2189f62ace8d95e869aa4c84c9946616f301e2d02895a6f20dcc3bba3/jiter-0.14.0-cp311-cp311-win32.whl", hash = "sha256:4927d09b3e572787cc5e0a5318601448e1ab9391bcef95677f5840c2d00eaa6d", size = 208660 }, + { url = "https://files.pythonhosted.org/packages/b8/86/c500b53dcbf08575f5963e536ebd757a1f7c568272ba5d180b212c9a87fb/jiter-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:42d6ed359ac49eb922fdd565f209c57340aa06d589c84c8413e42a0f9ae1b842", size = 204659 }, + { url = "https://files.pythonhosted.org/packages/75/4a/a676249049d42cb29bef82233e4fe0524d414cbe3606c7a4b311193c2f77/jiter-0.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:6dd689f5f4a5a33747b28686e051095beb214fe28cfda5e9fe58a295a788f593", size = 194772 }, ] [[package]] name = "librt" version = "0.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/6b/3d5c13fb3e3c4f43206c8f9dfed13778c2ed4f000bacaa0b7ce3c402a265/librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d", size = 184368, upload-time = "2026-04-09T16:06:26.173Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/6b/3d5c13fb3e3c4f43206c8f9dfed13778c2ed4f000bacaa0b7ce3c402a265/librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d", size = 184368 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/1e/2ec7afcebcf3efea593d13aee18bbcfdd3a243043d848ebf385055e9f636/librt-0.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90904fac73c478f4b83f4ed96c99c8208b75e6f9a8a1910548f69a00f1eaa671", size = 67155, upload-time = "2026-04-09T16:04:42.933Z" }, - { url = "https://files.pythonhosted.org/packages/18/77/72b85afd4435268338ad4ec6231b3da8c77363f212a0227c1ff3b45e4d35/librt-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:789fff71757facc0738e8d89e3b84e4f0251c1c975e85e81b152cdaca927cc2d", size = 69916, upload-time = "2026-04-09T16:04:44.042Z" }, - { url = "https://files.pythonhosted.org/packages/27/fb/948ea0204fbe2e78add6d46b48330e58d39897e425560674aee302dca81c/librt-0.9.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1bf465d1e5b0a27713862441f6467b5ab76385f4ecf8f1f3a44f8aa3c695b4b6", size = 199635, upload-time = "2026-04-09T16:04:45.5Z" }, - { url = "https://files.pythonhosted.org/packages/ac/cd/894a29e251b296a27957856804cfd21e93c194aa131de8bb8032021be07e/librt-0.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f819e0c6413e259a17a7c0d49f97f405abadd3c2a316a3b46c6440b7dbbedbb1", size = 211051, upload-time = "2026-04-09T16:04:47.016Z" }, - { url = "https://files.pythonhosted.org/packages/18/8f/dcaed0bc084a35f3721ff2d081158db569d2c57ea07d35623ddaca5cfc8e/librt-0.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0785c2fb4a81e1aece366aa3e2e039f4a4d7d21aaaded5227d7f3c703427882", size = 224031, upload-time = "2026-04-09T16:04:48.207Z" }, - { url = "https://files.pythonhosted.org/packages/03/44/88f6c1ed1132cd418601cc041fbd92fed28b3a09f39de81978e0822d13ff/librt-0.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80b25c7b570a86c03b5da69e665809deb39265476e8e21d96a9328f9762f9990", size = 218069, upload-time = "2026-04-09T16:04:50.025Z" }, - { url = "https://files.pythonhosted.org/packages/a3/90/7d02e981c2db12188d82b4410ff3e35bfdb844b26aecd02233626f46af2b/librt-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d4d16b608a1c43d7e33142099a75cd93af482dadce0bf82421e91cad077157f4", size = 224857, upload-time = "2026-04-09T16:04:51.684Z" }, - { url = "https://files.pythonhosted.org/packages/ef/c3/c77e706b7215ca32e928d47535cf13dbc3d25f096f84ddf8fbc06693e229/librt-0.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:194fc1a32e1e21fe809d38b5faea66cc65eaa00217c8901fbdb99866938adbdb", size = 219865, upload-time = "2026-04-09T16:04:52.949Z" }, - { url = "https://files.pythonhosted.org/packages/52/d1/32b0c1a0eb8461c70c11656c46a29f760b7c7edf3c36d6f102470c17170f/librt-0.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8c6bc1384d9738781cfd41d09ad7f6e8af13cfea2c75ece6bd6d2566cdea2076", size = 218451, upload-time = "2026-04-09T16:04:54.174Z" }, - { url = "https://files.pythonhosted.org/packages/74/d1/adfd0f9c44761b1d49b1bec66173389834c33ee2bd3c7fd2e2367f1942d4/librt-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15cb151e52a044f06e54ac7f7b47adbfc89b5c8e2b63e1175a9d587c43e8942a", size = 241300, upload-time = "2026-04-09T16:04:55.452Z" }, - { url = "https://files.pythonhosted.org/packages/09/b0/9074b64407712f0003c27f5b1d7655d1438979155f049720e8a1abd9b1a1/librt-0.9.0-cp311-cp311-win32.whl", hash = "sha256:f100bfe2acf8a3689af9d0cc660d89f17286c9c795f9f18f7b62dd1a6b247ae6", size = 55668, upload-time = "2026-04-09T16:04:56.689Z" }, - { url = "https://files.pythonhosted.org/packages/24/19/40b77b77ce80b9389fb03971431b09b6b913911c38d412059e0b3e2a9ef2/librt-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:0b73e4266307e51c95e09c0750b7ec383c561d2e97d58e473f6f6a209952fbb8", size = 62976, upload-time = "2026-04-09T16:04:57.733Z" }, - { url = "https://files.pythonhosted.org/packages/70/9d/9fa7a64041e29035cb8c575af5f0e3840be1b97b4c4d9061e0713f171849/librt-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:bc5518873822d2faa8ebdd2c1a4d7c8ef47b01a058495ab7924cb65bdbf5fc9a", size = 53502, upload-time = "2026-04-09T16:04:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/e2/1e/2ec7afcebcf3efea593d13aee18bbcfdd3a243043d848ebf385055e9f636/librt-0.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90904fac73c478f4b83f4ed96c99c8208b75e6f9a8a1910548f69a00f1eaa671", size = 67155 }, + { url = "https://files.pythonhosted.org/packages/18/77/72b85afd4435268338ad4ec6231b3da8c77363f212a0227c1ff3b45e4d35/librt-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:789fff71757facc0738e8d89e3b84e4f0251c1c975e85e81b152cdaca927cc2d", size = 69916 }, + { url = "https://files.pythonhosted.org/packages/27/fb/948ea0204fbe2e78add6d46b48330e58d39897e425560674aee302dca81c/librt-0.9.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1bf465d1e5b0a27713862441f6467b5ab76385f4ecf8f1f3a44f8aa3c695b4b6", size = 199635 }, + { url = "https://files.pythonhosted.org/packages/ac/cd/894a29e251b296a27957856804cfd21e93c194aa131de8bb8032021be07e/librt-0.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f819e0c6413e259a17a7c0d49f97f405abadd3c2a316a3b46c6440b7dbbedbb1", size = 211051 }, + { url = "https://files.pythonhosted.org/packages/18/8f/dcaed0bc084a35f3721ff2d081158db569d2c57ea07d35623ddaca5cfc8e/librt-0.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0785c2fb4a81e1aece366aa3e2e039f4a4d7d21aaaded5227d7f3c703427882", size = 224031 }, + { url = "https://files.pythonhosted.org/packages/03/44/88f6c1ed1132cd418601cc041fbd92fed28b3a09f39de81978e0822d13ff/librt-0.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80b25c7b570a86c03b5da69e665809deb39265476e8e21d96a9328f9762f9990", size = 218069 }, + { url = "https://files.pythonhosted.org/packages/a3/90/7d02e981c2db12188d82b4410ff3e35bfdb844b26aecd02233626f46af2b/librt-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d4d16b608a1c43d7e33142099a75cd93af482dadce0bf82421e91cad077157f4", size = 224857 }, + { url = "https://files.pythonhosted.org/packages/ef/c3/c77e706b7215ca32e928d47535cf13dbc3d25f096f84ddf8fbc06693e229/librt-0.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:194fc1a32e1e21fe809d38b5faea66cc65eaa00217c8901fbdb99866938adbdb", size = 219865 }, + { url = "https://files.pythonhosted.org/packages/52/d1/32b0c1a0eb8461c70c11656c46a29f760b7c7edf3c36d6f102470c17170f/librt-0.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8c6bc1384d9738781cfd41d09ad7f6e8af13cfea2c75ece6bd6d2566cdea2076", size = 218451 }, + { url = "https://files.pythonhosted.org/packages/74/d1/adfd0f9c44761b1d49b1bec66173389834c33ee2bd3c7fd2e2367f1942d4/librt-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15cb151e52a044f06e54ac7f7b47adbfc89b5c8e2b63e1175a9d587c43e8942a", size = 241300 }, + { url = "https://files.pythonhosted.org/packages/09/b0/9074b64407712f0003c27f5b1d7655d1438979155f049720e8a1abd9b1a1/librt-0.9.0-cp311-cp311-win32.whl", hash = "sha256:f100bfe2acf8a3689af9d0cc660d89f17286c9c795f9f18f7b62dd1a6b247ae6", size = 55668 }, + { url = "https://files.pythonhosted.org/packages/24/19/40b77b77ce80b9389fb03971431b09b6b913911c38d412059e0b3e2a9ef2/librt-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:0b73e4266307e51c95e09c0750b7ec383c561d2e97d58e473f6f6a209952fbb8", size = 62976 }, + { url = "https://files.pythonhosted.org/packages/70/9d/9fa7a64041e29035cb8c575af5f0e3840be1b97b4c4d9061e0713f171849/librt-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:bc5518873822d2faa8ebdd2c1a4d7c8ef47b01a058495ab7924cb65bdbf5fc9a", size = 53502 }, ] [[package]] name = "multidict" version = "6.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, - { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, - { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, - { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, - { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, - { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, - { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, - { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, - { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, - { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626 }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706 }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356 }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355 }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433 }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376 }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365 }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747 }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293 }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962 }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360 }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940 }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502 }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065 }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870 }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302 }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981 }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159 }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319 }, ] [[package]] @@ -434,25 +479,25 @@ dependencies = [ { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" }, - { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" }, - { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" }, - { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" }, - { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" }, - { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" }, - { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307 }, + { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917 }, + { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516 }, + { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889 }, + { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844 }, + { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300 }, + { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498 }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314 }, ] [[package]] name = "mypy-extensions" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, ] [[package]] @@ -469,69 +514,69 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/59/bdcc6b759b8c42dd73afaf5bf8f902c04b37987a5514dbc1c64dba390fef/openai-2.32.0.tar.gz", hash = "sha256:c54b27a9e4cb8d51f0dd94972ffd1a04437efeb259a9e60d8922b8bd26fe55e0", size = 693286, upload-time = "2026-04-15T22:28:19.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/59/bdcc6b759b8c42dd73afaf5bf8f902c04b37987a5514dbc1c64dba390fef/openai-2.32.0.tar.gz", hash = "sha256:c54b27a9e4cb8d51f0dd94972ffd1a04437efeb259a9e60d8922b8bd26fe55e0", size = 693286 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/c1/d6e64ccd0536bf616556f0cad2b6d94a8125f508d25cfd814b1d2db4e2f1/openai-2.32.0-py3-none-any.whl", hash = "sha256:4dcc9badeb4bf54ad0d187453742f290226d30150890b7890711bda4f32f192f", size = 1162570, upload-time = "2026-04-15T22:28:17.714Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c1/d6e64ccd0536bf616556f0cad2b6d94a8125f508d25cfd814b1d2db4e2f1/openai-2.32.0-py3-none-any.whl", hash = "sha256:4dcc9badeb4bf54ad0d187453742f290226d30150890b7890711bda4f32f192f", size = 1162570 }, ] [[package]] name = "packaging" -version = "26.1" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134 } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 }, ] [[package]] name = "pathspec" -version = "1.1.0" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2e/17/9c3094b822982b9f1ea666d8580ce59000f61f87c1663556fb72031ad9ec/pathspec-1.1.0.tar.gz", hash = "sha256:f5d7c555da02fd8dde3e4a2354b6aba817a89112fa8f333f7917a2a4834dd080", size = 133918, upload-time = "2026-04-23T01:46:22.298Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/c9/8eed0486f074e9f1ca7f8ce5ad663e65f12fdab344028d658fa1b03d35e0/pathspec-1.1.0-py3-none-any.whl", hash = "sha256:574b128f7456bd899045ccd142dd446af7e6cfd0072d63ad73fbc55fbb4aaa42", size = 56264, upload-time = "2026-04-23T01:46:20.606Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328 }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] [[package]] name = "propcache" version = "0.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442 } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, - { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, - { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, - { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, - { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, - { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, - { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, - { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, - { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, - { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, - { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, - { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, - { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208 }, + { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777 }, + { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647 }, + { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929 }, + { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778 }, + { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144 }, + { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030 }, + { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252 }, + { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064 }, + { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429 }, + { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727 }, + { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097 }, + { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084 }, + { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637 }, + { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064 }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305 }, ] [[package]] name = "pyasn1" version = "0.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997 }, ] [[package]] @@ -541,18 +586,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyasn1" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892 } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259 }, ] [[package]] name = "pycparser" version = "3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, ] [[package]] @@ -565,9 +610,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981 }, ] [[package]] @@ -577,35 +622,31 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/a2/1ba90a83e85a3f94c796b184f3efde9c72f2830dcda493eea8d59ba78e6d/pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5", size = 2106740, upload-time = "2026-04-20T14:41:20.932Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f6/99ae893c89a0b9d3daec9f95487aa676709aa83f67643b3f0abaf4ab628a/pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c", size = 1948293, upload-time = "2026-04-20T14:43:42.115Z" }, - { url = "https://files.pythonhosted.org/packages/3e/b8/2e8e636dc9e3f16c2e16bf0849e24be82c5ee82c603c65fc0326666328fc/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e", size = 1973222, upload-time = "2026-04-20T14:41:57.841Z" }, - { url = "https://files.pythonhosted.org/packages/34/36/0e730beec4d83c5306f417afbd82ff237d9a21e83c5edf675f31ed84c1fe/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287", size = 2053852, upload-time = "2026-04-20T14:40:43.077Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f0/3071131f47e39136a17814576e0fada9168569f7f8c0e6ac4d1ede6a4958/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe", size = 2221134, upload-time = "2026-04-20T14:43:03.349Z" }, - { url = "https://files.pythonhosted.org/packages/2f/a9/a2dc023eec5aa4b02a467874bad32e2446957d2adcab14e107eab502e978/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050", size = 2279785, upload-time = "2026-04-20T14:41:19.285Z" }, - { url = "https://files.pythonhosted.org/packages/0a/44/93f489d16fb63fbd41c670441536541f6e8cfa1e5a69f40bc9c5d30d8c90/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2", size = 2089404, upload-time = "2026-04-20T14:43:10.108Z" }, - { url = "https://files.pythonhosted.org/packages/2a/78/8692e3aa72b2d004f7a5d937f1dfdc8552ba26caf0bec75f342c40f00dec/pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa", size = 2114898, upload-time = "2026-04-20T14:44:51.475Z" }, - { url = "https://files.pythonhosted.org/packages/6a/62/e83133f2e7832532060175cebf1f13748f4c7e7e7165cdd1f611f174494b/pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c", size = 2157856, upload-time = "2026-04-20T14:43:46.64Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ec/6a500e3ad7718ee50583fae79c8651f5d37e3abce1fa9ae177ae65842c53/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf", size = 2180168, upload-time = "2026-04-20T14:42:00.302Z" }, - { url = "https://files.pythonhosted.org/packages/d8/53/8267811054b1aa7fc1dc7ded93812372ef79a839f5e23558136a6afbfde1/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b", size = 2322885, upload-time = "2026-04-20T14:41:05.253Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c1/1c0acdb3aa0856ddc4ecc55214578f896f2de16f400cf51627eb3c26c1c4/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e", size = 2360328, upload-time = "2026-04-20T14:41:43.991Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d0/ef39cd0f4a926814f360e71c1adeab48ad214d9727e4deb48eedfb5bce1a/pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb", size = 1979464, upload-time = "2026-04-20T14:43:12.215Z" }, - { url = "https://files.pythonhosted.org/packages/18/9c/f41951b0d858e343f1cf09398b2a7b3014013799744f2c4a8ad6a3eec4f2/pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346", size = 2070837, upload-time = "2026-04-20T14:41:47.707Z" }, - { url = "https://files.pythonhosted.org/packages/9f/1e/264a17cd582f6ed50950d4d03dd5fefd84e570e238afe1cb3e25cf238769/pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6", size = 2053647, upload-time = "2026-04-20T14:42:27.535Z" }, - { url = "https://files.pythonhosted.org/packages/66/7f/03dbad45cd3aa9083fbc93c210ae8b005af67e4136a14186950a747c6874/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46", size = 2105683, upload-time = "2026-04-20T14:42:19.779Z" }, - { url = "https://files.pythonhosted.org/packages/26/22/4dc186ac8ea6b257e9855031f51b62a9637beac4d68ac06bee02f046f836/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874", size = 1940052, upload-time = "2026-04-20T14:43:59.274Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ca/d376391a5aff1f2e8188960d7873543608130a870961c2b6b5236627c116/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76", size = 1988172, upload-time = "2026-04-20T14:41:17.469Z" }, - { url = "https://files.pythonhosted.org/packages/0e/6b/523b9f85c23788755d6ab949329de692a2e3a584bc6beb67fef5e035aa9d/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531", size = 2128596, upload-time = "2026-04-20T14:40:41.707Z" }, - { url = "https://files.pythonhosted.org/packages/1f/da/99d40830684f81dec901cac521b5b91c095394cc1084b9433393cde1c2df/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25", size = 2107973, upload-time = "2026-04-20T14:42:06.175Z" }, - { url = "https://files.pythonhosted.org/packages/99/a5/87024121818d75bbb2a98ddbaf638e40e7a18b5e0f5492c9ca4b1b316107/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3", size = 1947191, upload-time = "2026-04-20T14:43:14.319Z" }, - { url = "https://files.pythonhosted.org/packages/60/62/0c1acfe10945b83a6a59d19fbaa92f48825381509e5701b855c08f13db76/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536", size = 2123791, upload-time = "2026-04-20T14:43:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/75/3e/3b2393b4c8f44285561dc30b00cf307a56a2eff7c483a824db3b8221ca51/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1", size = 2153197, upload-time = "2026-04-20T14:44:27.932Z" }, - { url = "https://files.pythonhosted.org/packages/ba/75/5af02fb35505051eee727c061f2881c555ab4f8ddb2d42da715a42c9731b/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c", size = 2181073, upload-time = "2026-04-20T14:43:20.729Z" }, - { url = "https://files.pythonhosted.org/packages/10/92/7e0e1bd9ca3c68305db037560ca2876f89b2647deb2f8b6319005de37505/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85", size = 2315886, upload-time = "2026-04-20T14:44:04.826Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d8/101655f27eaf3e44558ead736b2795d12500598beed4683f279396fa186e/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8", size = 2360528, upload-time = "2026-04-20T14:40:47.431Z" }, - { url = "https://files.pythonhosted.org/packages/07/0f/1c34a74c8d07136f0d729ffe5e1fdab04fbdaa7684f61a92f92511a84a15/pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", size = 2184144, upload-time = "2026-04-20T14:42:57Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a2/1ba90a83e85a3f94c796b184f3efde9c72f2830dcda493eea8d59ba78e6d/pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5", size = 2106740 }, + { url = "https://files.pythonhosted.org/packages/b6/f6/99ae893c89a0b9d3daec9f95487aa676709aa83f67643b3f0abaf4ab628a/pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c", size = 1948293 }, + { url = "https://files.pythonhosted.org/packages/3e/b8/2e8e636dc9e3f16c2e16bf0849e24be82c5ee82c603c65fc0326666328fc/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e", size = 1973222 }, + { url = "https://files.pythonhosted.org/packages/34/36/0e730beec4d83c5306f417afbd82ff237d9a21e83c5edf675f31ed84c1fe/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287", size = 2053852 }, + { url = "https://files.pythonhosted.org/packages/4b/f0/3071131f47e39136a17814576e0fada9168569f7f8c0e6ac4d1ede6a4958/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe", size = 2221134 }, + { url = "https://files.pythonhosted.org/packages/2f/a9/a2dc023eec5aa4b02a467874bad32e2446957d2adcab14e107eab502e978/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050", size = 2279785 }, + { url = "https://files.pythonhosted.org/packages/0a/44/93f489d16fb63fbd41c670441536541f6e8cfa1e5a69f40bc9c5d30d8c90/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2", size = 2089404 }, + { url = "https://files.pythonhosted.org/packages/2a/78/8692e3aa72b2d004f7a5d937f1dfdc8552ba26caf0bec75f342c40f00dec/pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa", size = 2114898 }, + { url = "https://files.pythonhosted.org/packages/6a/62/e83133f2e7832532060175cebf1f13748f4c7e7e7165cdd1f611f174494b/pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c", size = 2157856 }, + { url = "https://files.pythonhosted.org/packages/6d/ec/6a500e3ad7718ee50583fae79c8651f5d37e3abce1fa9ae177ae65842c53/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf", size = 2180168 }, + { url = "https://files.pythonhosted.org/packages/d8/53/8267811054b1aa7fc1dc7ded93812372ef79a839f5e23558136a6afbfde1/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b", size = 2322885 }, + { url = "https://files.pythonhosted.org/packages/c8/c1/1c0acdb3aa0856ddc4ecc55214578f896f2de16f400cf51627eb3c26c1c4/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e", size = 2360328 }, + { url = "https://files.pythonhosted.org/packages/f0/d0/ef39cd0f4a926814f360e71c1adeab48ad214d9727e4deb48eedfb5bce1a/pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb", size = 1979464 }, + { url = "https://files.pythonhosted.org/packages/18/9c/f41951b0d858e343f1cf09398b2a7b3014013799744f2c4a8ad6a3eec4f2/pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346", size = 2070837 }, + { url = "https://files.pythonhosted.org/packages/9f/1e/264a17cd582f6ed50950d4d03dd5fefd84e570e238afe1cb3e25cf238769/pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6", size = 2053647 }, + { url = "https://files.pythonhosted.org/packages/1f/da/99d40830684f81dec901cac521b5b91c095394cc1084b9433393cde1c2df/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25", size = 2107973 }, + { url = "https://files.pythonhosted.org/packages/99/a5/87024121818d75bbb2a98ddbaf638e40e7a18b5e0f5492c9ca4b1b316107/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3", size = 1947191 }, + { url = "https://files.pythonhosted.org/packages/60/62/0c1acfe10945b83a6a59d19fbaa92f48825381509e5701b855c08f13db76/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536", size = 2123791 }, + { url = "https://files.pythonhosted.org/packages/75/3e/3b2393b4c8f44285561dc30b00cf307a56a2eff7c483a824db3b8221ca51/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1", size = 2153197 }, + { url = "https://files.pythonhosted.org/packages/ba/75/5af02fb35505051eee727c061f2881c555ab4f8ddb2d42da715a42c9731b/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c", size = 2181073 }, + { url = "https://files.pythonhosted.org/packages/10/92/7e0e1bd9ca3c68305db037560ca2876f89b2647deb2f8b6319005de37505/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85", size = 2315886 }, + { url = "https://files.pythonhosted.org/packages/b8/d8/101655f27eaf3e44558ead736b2795d12500598beed4683f279396fa186e/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8", size = 2360528 }, + { url = "https://files.pythonhosted.org/packages/07/0f/1c34a74c8d07136f0d729ffe5e1fdab04fbdaa7684f61a92f92511a84a15/pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", size = 2184144 }, ] [[package]] @@ -617,18 +658,38 @@ dependencies = [ { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709 } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, + { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940 }, ] [[package]] name = "pygments" version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, +] + +[[package]] +name = "pynacl" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/22/27582568be639dfe22ddb3902225f91f2f17ceff88ce80e4db396c8986da/PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", size = 3392854 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/75/0b8ede18506041c0bf23ac4d8e2971b4161cd6ce630b177d0a08eb0d8857/PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", size = 349920 }, + { url = "https://files.pythonhosted.org/packages/59/bb/fddf10acd09637327a97ef89d2a9d621328850a72f1fdc8c08bdf72e385f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", size = 601722 }, + { url = "https://files.pythonhosted.org/packages/5d/70/87a065c37cca41a75f2ce113a5a2c2aa7533be648b184ade58971b5f7ccc/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", size = 680087 }, + { url = "https://files.pythonhosted.org/packages/ee/87/f1bb6a595f14a327e8285b9eb54d41fef76c585a0edef0a45f6fc95de125/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", size = 856678 }, + { url = "https://files.pythonhosted.org/packages/66/28/ca86676b69bf9f90e710571b67450508484388bfce09acf8a46f0b8c785f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", size = 1133660 }, + { url = "https://files.pythonhosted.org/packages/3d/85/c262db650e86812585e2bc59e497a8f59948a005325a11bbbc9ecd3fe26b/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", size = 663824 }, + { url = "https://files.pythonhosted.org/packages/fd/1a/cc308a884bd299b651f1633acb978e8596c71c33ca85e9dc9fa33a5399b9/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", size = 1117912 }, + { url = "https://files.pythonhosted.org/packages/25/2d/b7df6ddb0c2a33afdb358f8af6ea3b8c4d1196ca45497dd37a56f0c122be/PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543", size = 204624 }, + { url = "https://files.pythonhosted.org/packages/5e/22/d3db169895faaf3e2eda892f005f433a62db2decbcfbc2f61e6517adfa87/PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", size = 212141 }, ] [[package]] @@ -642,9 +703,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249 }, ] [[package]] @@ -655,18 +716,18 @@ dependencies = [ { name = "pytest" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075 }, ] [[package]] name = "python-dotenv" version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 }, ] [[package]] @@ -679,52 +740,52 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947 }, ] [[package]] name = "ruff" -version = "0.15.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, - { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, - { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, - { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, - { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, - { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, - { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, - { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, - { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, - { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, - { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, - { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, - { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, - { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713 }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267 }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182 }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012 }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479 }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040 }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377 }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784 }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088 }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770 }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355 }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758 }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498 }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765 }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277 }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758 }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821 }, ] [[package]] name = "sniffio" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] [[package]] name = "tenacity" version = "9.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926 }, ] [[package]] @@ -734,27 +795,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598 } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374 }, ] [[package]] name = "types-aiofiles" version = "25.1.0.20260409" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/66/9e62a2692792bc96c0f423f478149f4a7b84720704c546c8960b0a047c89/types_aiofiles-25.1.0.20260409.tar.gz", hash = "sha256:49e67d72bdcf9fe406f5815758a78dc34a1249bb5aa2adba78a80aec0a775435", size = 14812, upload-time = "2026-04-09T04:22:35.308Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/66/9e62a2692792bc96c0f423f478149f4a7b84720704c546c8960b0a047c89/types_aiofiles-25.1.0.20260409.tar.gz", hash = "sha256:49e67d72bdcf9fe406f5815758a78dc34a1249bb5aa2adba78a80aec0a775435", size = 14812 } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/d0/28236f869ba4dfb223ecdbc267eb2bdb634b81a561dd992230a4f9ec48fa/types_aiofiles-25.1.0.20260409-py3-none-any.whl", hash = "sha256:923fedb532c772cc0f62e0ce4282725afa82ca5b41cabd9857f06b55e5eee8de", size = 14372, upload-time = "2026-04-09T04:22:34.328Z" }, + { url = "https://files.pythonhosted.org/packages/27/d0/28236f869ba4dfb223ecdbc267eb2bdb634b81a561dd992230a4f9ec48fa/types_aiofiles-25.1.0.20260409-py3-none-any.whl", hash = "sha256:923fedb532c772cc0f62e0ce4282725afa82ca5b41cabd9857f06b55e5eee8de", size = 14372 }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, ] [[package]] @@ -764,41 +825,41 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, ] [[package]] name = "urllib3" version = "2.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556 } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584 }, ] [[package]] name = "websockets" version = "16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, - { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, - { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, - { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, - { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, - { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, - { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, - { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340 }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022 }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319 }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631 }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870 }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361 }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615 }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246 }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684 }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947 }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260 }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071 }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968 }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735 }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598 }, ] [[package]] @@ -807,6 +868,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, + { name = "discord-ext-voice-recv" }, { name = "discord-py" }, { name = "google-genai" }, { name = "httpx" }, @@ -815,6 +877,7 @@ dependencies = [ { name = "pydantic-settings" }, { name = "python-dotenv" }, { name = "tenacity" }, + { name = "websockets" }, ] [package.dev-dependencies] @@ -829,6 +892,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiosqlite", specifier = ">=0.20" }, + { name = "discord-ext-voice-recv", specifier = ">=0.5.2a179" }, { name = "discord-py", specifier = ">=2.7,<3" }, { name = "google-genai", specifier = ">=1.69" }, { name = "httpx", specifier = ">=0.27" }, @@ -837,6 +901,7 @@ requires-dist = [ { name = "pydantic-settings", specifier = ">=2.2" }, { name = "python-dotenv", specifier = ">=1.0" }, { name = "tenacity", specifier = ">=8.2" }, + { name = "websockets", specifier = ">=12" }, ] [package.metadata.requires-dev] @@ -857,25 +922,25 @@ dependencies = [ { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641, upload-time = "2026-03-01T22:04:42.841Z" }, - { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248, upload-time = "2026-03-01T22:04:44.757Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988, upload-time = "2026-03-01T22:04:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566, upload-time = "2026-03-01T22:04:47.639Z" }, - { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079, upload-time = "2026-03-01T22:04:48.925Z" }, - { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741, upload-time = "2026-03-01T22:04:50.838Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099, upload-time = "2026-03-01T22:04:52.499Z" }, - { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678, upload-time = "2026-03-01T22:04:55.176Z" }, - { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803, upload-time = "2026-03-01T22:04:56.588Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163, upload-time = "2026-03-01T22:04:58.492Z" }, - { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859, upload-time = "2026-03-01T22:05:00.268Z" }, - { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202, upload-time = "2026-03-01T22:05:02.273Z" }, - { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866, upload-time = "2026-03-01T22:05:03.597Z" }, - { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852, upload-time = "2026-03-01T22:05:04.986Z" }, - { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919, upload-time = "2026-03-01T22:05:06.397Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602, upload-time = "2026-03-01T22:05:08.444Z" }, - { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461, upload-time = "2026-03-01T22:05:10.145Z" }, - { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336, upload-time = "2026-03-01T22:05:11.554Z" }, - { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641 }, + { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248 }, + { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988 }, + { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566 }, + { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079 }, + { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741 }, + { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099 }, + { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678 }, + { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803 }, + { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163 }, + { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859 }, + { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202 }, + { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866 }, + { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852 }, + { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919 }, + { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602 }, + { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461 }, + { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336 }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288 }, ] diff --git a/viewer/.gitignore b/viewer/.gitignore new file mode 100644 index 0000000..27d0019 --- /dev/null +++ b/viewer/.gitignore @@ -0,0 +1,29 @@ +# Next.js +.next/ +out/ +build/ + +# Dependencies +node_modules/ +.pnp +.pnp.js +.pnp.cjs + +# Production +*.tsbuildinfo +next-env.d.ts + +# Logs / env +*.log +.env*.local + +# OS +.DS_Store + +# IDE +.vscode/ +.idea/ + +# Auto-exported real-game JSON, written by GameService._on_game_end_finalize. +# Sample data lives in sample-data/ and stays committed. +games/*.json diff --git a/viewer/README.md b/viewer/README.md new file mode 100644 index 0000000..d733736 --- /dev/null +++ b/viewer/README.md @@ -0,0 +1,66 @@ +# wolfbot Game Viewer + +Web UI for inspecting one wolfbot game's full event timeline + LLM internals after the fact. Reads exported game JSONs from `viewer/games/`. + +## Quick start + +```bash +cd viewer +pnpm install +pnpm dev +# → http://localhost:3000 +``` + +## Routes + +| Path | Purpose | +|---|---| +| `/` | **ゲーム一覧** — table of every export in `viewer/games/`, newest first. Click a row to drill in. | +| `/games/{game_id}` | Detail view: seats, phase timeline, LLM trace drawer, stats | +| `/sample` | Bundled synthetic sample game — for trying the UI without playing a real game | + +## Where the data comes from + +`viewer/games/{game_id}.json` is written **automatically** at the end of every game (natural victory or `/wolf abort`) by the bot's `GameService._on_game_end_finalize` hook. Each file is the join of: + +- `wolfbot.db` — game/seats/public_logs/votes/night_actions/speech_events +- `logs/llm_calls/{game_id}/*.jsonl` — every LLM call's prompt, response, tokens, latency + +So the typical flow is: + +```bash +# 1. Run the bot, play a game (real or with --mock) +scripts/run-bots.sh +# /wolf join, /wolf start, ... → game ends or you /wolf abort + +# 2. Open the viewer; the just-finished game shows up at the top +cd viewer && pnpm dev +``` + +No manual export step. If you want to re-export an older game from SQLite + JSONL into the viewer, run: + +```bash +uv run python scripts/export-game.py --game-id g_abc123def +``` + +## Running with the sample game + +The sample game is **opt-in** — visit `/sample` (linked from the empty-state and from a button in the upper-right of `/`). + +It's a hand-crafted 2-day village victory designed to exercise every UI surface. Generate / regenerate it with: + +```bash +uv run python viewer/sample-data/generate_sample.py +``` + +## What gets shown on a detail page + +- **Header** — game id, mode, victor, total LLM calls / tokens / latency +- **Seat grid** — per-seat card with role, persona, alive/dead status, faction tint +- **Phase timeline** — each phase: public logs, speeches (CO badge / STT confidence), votes, night actions, sorted chronologically. Bulb icon opens the LLM trace drawer for that event. +- **Trace drawer** — system + user prompt, raw response, prompt/completion/total tokens, latency, model, error +- **Stats panel** — per-seat and per-phase aggregates (calls / tokens / latency) + +## Schema + +The canonical TypeScript shape lives in [`src/lib/types.ts`](./src/lib/types.ts). The Python writer that produces it is [`wolfbot.services.game_export`](../src/wolfbot/services/game_export.py); the test fixture asserting the shape is [`tests/test_game_export.py`](../tests/test_game_export.py). diff --git a/viewer/eslint.config.mjs b/viewer/eslint.config.mjs new file mode 100644 index 0000000..1d3257a --- /dev/null +++ b/viewer/eslint.config.mjs @@ -0,0 +1,26 @@ +import { FlatCompat } from "@eslint/eslintrc"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const compat = new FlatCompat({ baseDirectory: __dirname }); + +const config = [ + { + ignores: [ + ".next/**", + "node_modules/**", + "out/**", + "next-env.d.ts", + "tsconfig.tsbuildinfo", + "sample-data/**", + "games/**", + "public/**", + ], + }, + ...compat.extends("next/core-web-vitals", "next/typescript"), +]; + +export default config; diff --git a/viewer/games/.gitkeep b/viewer/games/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/viewer/next.config.ts b/viewer/next.config.ts new file mode 100644 index 0000000..cb651cd --- /dev/null +++ b/viewer/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = {}; + +export default nextConfig; diff --git a/viewer/package.json b/viewer/package.json new file mode 100644 index 0000000..0565576 --- /dev/null +++ b/viewer/package.json @@ -0,0 +1,38 @@ +{ + "name": "wolfbot-viewer", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@emotion/cache": "^11.13.5", + "@emotion/react": "^11.13.5", + "@emotion/styled": "^11.13.5", + "@mui/icons-material": "^6.1.10", + "@mui/material": "^6.1.10", + "@mui/material-nextjs": "^6.1.9", + "next": "^15.1.3", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.3.5", + "@types/node": "^22.10.2", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "eslint": "^9.17.0", + "eslint-config-next": "^15.1.3", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/viewer/pnpm-lock.yaml b/viewer/pnpm-lock.yaml new file mode 100644 index 0000000..0903df0 --- /dev/null +++ b/viewer/pnpm-lock.yaml @@ -0,0 +1,4878 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@emotion/cache': + specifier: ^11.13.5 + version: 11.14.0 + '@emotion/react': + specifier: ^11.13.5 + version: 11.14.0(@types/react@19.2.14)(react@19.2.5) + '@emotion/styled': + specifier: ^11.13.5 + version: 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) + '@mui/icons-material': + specifier: ^6.1.10 + version: 6.5.0(@mui/material@6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) + '@mui/material': + specifier: ^6.1.10 + version: 6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mui/material-nextjs': + specifier: ^6.1.9 + version: 6.5.0(@emotion/cache@11.14.0)(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(next@15.5.15(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + next: + specifier: ^15.1.3 + version: 15.5.15(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: + specifier: ^19.0.0 + version: 19.2.5 + react-dom: + specifier: ^19.0.0 + version: 19.2.5(react@19.2.5) + devDependencies: + '@eslint/eslintrc': + specifier: ^3.3.5 + version: 3.3.5 + '@types/node': + specifier: ^22.10.2 + version: 22.19.17 + '@types/react': + specifier: ^19.0.2 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.2 + version: 19.2.3(@types/react@19.2.14) + ajv: + specifier: ^8.17.1 + version: 8.20.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.20.0) + eslint: + specifier: ^9.17.0 + version: 9.39.4 + eslint-config-next: + specifier: ^15.1.3 + version: 15.5.15(eslint@9.39.4)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.17) + +packages: + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@emotion/babel-plugin@11.13.5': + resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} + + '@emotion/cache@11.14.0': + resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@emotion/is-prop-valid@1.4.0': + resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} + + '@emotion/memoize@0.9.0': + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + + '@emotion/react@11.14.0': + resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==} + peerDependencies: + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/serialize@1.3.3': + resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==} + + '@emotion/sheet@1.4.0': + resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} + + '@emotion/styled@11.14.1': + resolution: {integrity: sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==} + peerDependencies: + '@emotion/react': ^11.0.0-rc.0 + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/unitless@0.10.0': + resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0': + resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==} + peerDependencies: + react: '>=16.8.0' + + '@emotion/utils@1.4.2': + resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} + + '@emotion/weak-memoize@0.4.0': + resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mui/core-downloads-tracker@6.5.0': + resolution: {integrity: sha512-LGb8t8i6M2ZtS3Drn3GbTI1DVhDY6FJ9crEey2lZ0aN2EMZo8IZBZj9wRf4vqbZHaWjsYgtbOnJw5V8UWbmK2Q==} + + '@mui/icons-material@6.5.0': + resolution: {integrity: sha512-VPuPqXqbBPlcVSA0BmnoE4knW4/xG6Thazo8vCLWkOKusko6DtwFV6B665MMWJ9j0KFohTIf3yx2zYtYacvG1g==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@mui/material': ^6.5.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/material-nextjs@6.5.0': + resolution: {integrity: sha512-VV+4BhmnY32pbPuIevs+rPl0R+bkVvqGCqRqZqNhkdBdX0pn1IHQ5mG/EfYJKoKUkF7q9FKSag5wduSTUxDqnQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/cache': ^11.11.0 + '@emotion/react': ^11.11.4 + '@emotion/server': ^11.11.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + next: ^13.0.0 || ^14.0.0 || ^15.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/cache': + optional: true + '@emotion/server': + optional: true + '@types/react': + optional: true + + '@mui/material@6.5.0': + resolution: {integrity: sha512-yjvtXoFcrPLGtgKRxFaH6OQPtcLPhkloC0BML6rBG5UeldR0nPULR/2E2BfXdo5JNV7j7lOzrrLX2Qf/iSidow==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@mui/material-pigment-css': ^6.5.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@mui/material-pigment-css': + optional: true + '@types/react': + optional: true + + '@mui/private-theming@6.4.9': + resolution: {integrity: sha512-LktcVmI5X17/Q5SkwjCcdOLBzt1hXuc14jYa7NPShog0GBDCDvKtcnP0V7a2s6EiVRlv7BzbWEJzH6+l/zaCxw==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/styled-engine@6.5.0': + resolution: {integrity: sha512-8woC2zAqF4qUDSPIBZ8v3sakj+WgweolpyM/FXf8jAx6FMls+IE4Y8VDZc+zS805J7PRz31vz73n2SovKGaYgw==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.4.1 + '@emotion/styled': ^11.3.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + + '@mui/system@6.5.0': + resolution: {integrity: sha512-XcbBYxDS+h/lgsoGe78ExXFZXtuIlSBpn/KsZq8PtZcIkUNJInkuDqcLd2rVBQrDC1u+rvVovdaWPf2FHKJf3w==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + + '@mui/types@7.2.24': + resolution: {integrity: sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/utils@6.4.9': + resolution: {integrity: sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@next/env@15.5.15': + resolution: {integrity: sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==} + + '@next/eslint-plugin-next@15.5.15': + resolution: {integrity: sha512-ExQoBfyKMjAUQ2nuF39ryQsG26H374ZfH13dlOZqf6TaE9ycRbIm+qUbUFCliU4BtQhiqtS7cnGA1yWfPMQ+jA==} + + '@next/swc-darwin-arm64@15.5.15': + resolution: {integrity: sha512-6PvFO2Tzt10GFK2Ro9tAVEtacMqRmTarYMFKAnV2vYMdwWc73xzmDQyAV7SwEdMhzmiRoo7+m88DuiXlJlGeaw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@15.5.15': + resolution: {integrity: sha512-G+YNV+z6FDZTp/+IdGyIMFqalBTaQSnvAA+X/hrt+eaTRFSznRMz9K7rTmzvM6tDmKegNtyzgufZW0HwVzEqaQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@15.5.15': + resolution: {integrity: sha512-eVkrMcVIBqGfXB+QUC7jjZ94Z6uX/dNStbQFabewAnk13Uy18Igd1YZ/GtPRzdhtm7QwC0e6o7zOQecul4iC1w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@15.5.15': + resolution: {integrity: sha512-RwSHKMQ7InLy5GfkY2/n5PcFycKA08qI1VST78n09nN36nUPqCvGSMiLXlfUmzmpQpF6XeBYP2KRWHi0UW3uNg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@15.5.15': + resolution: {integrity: sha512-nplqvY86LakS+eeiuWsNWvfmK8pFcOEW7ZtVRt4QH70lL+0x6LG/m1OpJ/tvrbwjmR8HH9/fH2jzW1GlL03TIg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@15.5.15': + resolution: {integrity: sha512-eAgl9NKQ84/sww0v81DQINl/vL2IBxD7sMybd0cWRw6wqgouVI53brVRBrggqBRP/NWeIAE1dm5cbKYoiMlqDQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@15.5.15': + resolution: {integrity: sha512-GJVZC86lzSquh0MtvZT+L7G8+jMnJcldloOjA8Kf3wXvBrvb6OGe2MzPuALxFshSm/IpwUtD2mIoof39ymf52A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@15.5.15': + resolution: {integrity: sha512-nFucjVdwlFqxh/JG3hWSJ4p8+YJV7Ii8aPDuBQULB6DzUF4UNZETXLfEUk+oI2zEznWWULPt7MeuTE6xtK1HSA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nolyfill/is-core-module@1.0.39': + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} + engines: {node: '>=12.4.0'} + + '@popperjs/core@2.11.8': + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + + '@rollup/rollup-android-arm-eabi@4.60.2': + resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.2': + resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.2': + resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.2': + resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.2': + resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.2': + resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.2': + resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.2': + resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.2': + resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.2': + resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.2': + resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.2': + resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.2': + resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.2': + resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} + cpu: [x64] + os: [win32] + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@rushstack/eslint-patch@1.16.1': + resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react-transition-group@4.4.12': + resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==} + peerDependencies: + '@types/react': '*' + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@typescript-eslint/eslint-plugin@8.59.1': + resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.1': + resolution: {integrity: sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.1': + resolution: {integrity: sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.1': + resolution: {integrity: sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.1': + resolution: {integrity: sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.1': + resolution: {integrity: sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.1': + resolution: {integrity: sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.1': + resolution: {integrity: sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.1': + resolution: {integrity: sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.1': + resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axe-core@4.11.3: + resolution: {integrity: sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==} + engines: {node: '>=4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001791: + resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + + cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.3.2: + resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-next@15.5.15: + resolution: {integrity: sha512-mI5KIONOIosjF3jK2z9a8fY2LePNeW5C4lRJ+XZoJHAKkwx2MQjMPQ2/kL7tsMRPcQPZc/UBtCfqxElluL1CBg==} + peerDependencies: + eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 + typescript: '>=3.3.1' + peerDependenciesMeta: + typescript: + optional: true + + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} + + eslint-import-resolver-typescript@3.10.1: + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-jsx-a11y@6.10.2: + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + + eslint-plugin-react-hooks@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.1: + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-bun-module@2.0.0: + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + next@15.5.15: + resolution: {integrity: sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.12: + resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.2.5: + resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + peerDependencies: + react: ^19.2.5 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@19.2.5: + resolution: {integrity: sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==} + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + + react@19.2.5: + resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} + engines: {node: '>=0.10.0'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.6: + resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.60.2: + resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + stable-hash@0.0.5: + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emotion/babel-plugin@11.13.5': + dependencies: + '@babel/helper-module-imports': 7.28.6 + '@babel/runtime': 7.29.2 + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/serialize': 1.3.3 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + transitivePeerDependencies: + - supports-color + + '@emotion/cache@11.14.0': + dependencies: + '@emotion/memoize': 0.9.0 + '@emotion/sheet': 1.4.0 + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + stylis: 4.2.0 + + '@emotion/hash@0.9.2': {} + + '@emotion/is-prop-valid@1.4.0': + dependencies: + '@emotion/memoize': 0.9.0 + + '@emotion/memoize@0.9.0': {} + + '@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@emotion/babel-plugin': 11.13.5 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.5) + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + hoist-non-react-statics: 3.3.2 + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + transitivePeerDependencies: + - supports-color + + '@emotion/serialize@1.3.3': + dependencies: + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/unitless': 0.10.0 + '@emotion/utils': 1.4.2 + csstype: 3.2.3 + + '@emotion/sheet@1.4.0': {} + + '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@emotion/babel-plugin': 11.13.5 + '@emotion/is-prop-valid': 1.4.0 + '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5) + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.5) + '@emotion/utils': 1.4.2 + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + transitivePeerDependencies: + - supports-color + + '@emotion/unitless@0.10.0': {} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.2.5)': + dependencies: + react: 19.2.5 + + '@emotion/utils@1.4.2': {} + + '@emotion/weak-memoize@0.4.0': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mui/core-downloads-tracker@6.5.0': {} + + '@mui/icons-material@6.5.0(@mui/material@6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@mui/material': 6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@mui/material-nextjs@6.5.0(@emotion/cache@11.14.0)(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(next@15.5.15(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5) + next: 15.5.15(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + optionalDependencies: + '@emotion/cache': 11.14.0 + '@types/react': 19.2.14 + + '@mui/material@6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@mui/core-downloads-tracker': 6.5.0 + '@mui/system': 6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) + '@mui/types': 7.2.24(@types/react@19.2.14) + '@mui/utils': 6.4.9(@types/react@19.2.14)(react@19.2.5) + '@popperjs/core': 2.11.8 + '@types/react-transition-group': 4.4.12(@types/react@19.2.14) + clsx: 2.1.1 + csstype: 3.2.3 + prop-types: 15.8.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-is: 19.2.5 + react-transition-group: 4.4.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) + '@types/react': 19.2.14 + + '@mui/private-theming@6.4.9(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@mui/utils': 6.4.9(@types/react@19.2.14)(react@19.2.5) + prop-types: 15.8.1 + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@mui/styled-engine@6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/sheet': 1.4.0 + csstype: 3.2.3 + prop-types: 15.8.1 + react: 19.2.5 + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) + + '@mui/system@6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@mui/private-theming': 6.4.9(@types/react@19.2.14)(react@19.2.5) + '@mui/styled-engine': 6.5.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) + '@mui/types': 7.2.24(@types/react@19.2.14) + '@mui/utils': 6.4.9(@types/react@19.2.14)(react@19.2.5) + clsx: 2.1.1 + csstype: 3.2.3 + prop-types: 15.8.1 + react: 19.2.5 + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) + '@types/react': 19.2.14 + + '@mui/types@7.2.24(@types/react@19.2.14)': + optionalDependencies: + '@types/react': 19.2.14 + + '@mui/utils@6.4.9(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@mui/types': 7.2.24(@types/react@19.2.14) + '@types/prop-types': 15.7.15 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 19.2.5 + react-is: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@next/env@15.5.15': {} + + '@next/eslint-plugin-next@15.5.15': + dependencies: + fast-glob: 3.3.1 + + '@next/swc-darwin-arm64@15.5.15': + optional: true + + '@next/swc-darwin-x64@15.5.15': + optional: true + + '@next/swc-linux-arm64-gnu@15.5.15': + optional: true + + '@next/swc-linux-arm64-musl@15.5.15': + optional: true + + '@next/swc-linux-x64-gnu@15.5.15': + optional: true + + '@next/swc-linux-x64-musl@15.5.15': + optional: true + + '@next/swc-win32-arm64-msvc@15.5.15': + optional: true + + '@next/swc-win32-x64-msvc@15.5.15': + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nolyfill/is-core-module@1.0.39': {} + + '@popperjs/core@2.11.8': {} + + '@rollup/rollup-android-arm-eabi@4.60.2': + optional: true + + '@rollup/rollup-android-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-x64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.2': + optional: true + + '@rtsao/scc@1.1.0': {} + + '@rushstack/eslint-patch@1.16.1': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + + '@types/parse-json@4.0.2': {} + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react-transition-group@4.4.12(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/type-utils': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.1 + eslint: 9.39.4 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.1 + debug: 4.4.3 + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.1': + dependencies: + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 + + '@typescript-eslint/tsconfig-utils@8.59.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.59.1(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.1': {} + + '@typescript-eslint/typescript-estree@8.59.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.1(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.1': + dependencies: + '@typescript-eslint/types': 8.59.1 + eslint-visitor-keys: 5.0.1 + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + optional: true + + '@unrs/resolver-binding-android-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + optional: true + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.17))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.19.17) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + assertion-error@2.0.1: {} + + ast-types-flow@0.0.8: {} + + async-function@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axe-core@4.11.3: {} + + axobject-query@4.1.0: {} + + babel-plugin-macros@3.1.0: + dependencies: + '@babel/runtime': 7.29.2 + cosmiconfig: 7.1.0 + resolve: 1.22.12 + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001791: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@2.1.3: {} + + client-only@0.0.1: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + convert-source-map@1.9.0: {} + + cosmiconfig@7.1.0: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.1 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.3 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + damerau-levenshtein@1.0.8: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + detect-libc@2.1.2: + optional: true + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.29.2 + csstype: 3.2.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + emoji-regex@9.2.2: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.3.2: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.3 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escape-string-regexp@4.0.0: {} + + eslint-config-next@15.5.15(eslint@9.39.4)(typescript@5.9.3): + dependencies: + '@next/eslint-plugin-next': 15.5.15 + '@rushstack/eslint-patch': 1.16.1 + '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4) + eslint-plugin-react: 7.37.5(eslint@9.39.4) + eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + + eslint-import-resolver-node@0.3.10: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.1 + resolve: 2.0.0-next.6 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.4 + get-tsconfig: 4.14.0 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.16 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4) + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.4 + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4) + hasown: 2.0.3 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4)(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4): + dependencies: + aria-query: 5.3.2 + array-includes: 3.1.9 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.11.3 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 9.39.4 + hasown: 2.0.3 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + + eslint-plugin-react-hooks@5.2.0(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + + eslint-plugin-react@7.37.5(eslint@9.39.4): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.3.2 + eslint: 9.39.4 + estraverse: 5.3.0 + hasown: 2.0.3 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.6 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + expect-type@1.3.0: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.1: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-uri@3.1.0: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-root@1.1.0: {} + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.3 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.3 + side-channel: 1.1.0 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-arrayish@0.2.1: {} + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-bun-module@2.0.0: + dependencies: + semver: 7.7.4 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.3 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lines-and-columns@1.2.4: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + minimist@1.2.8: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + next@15.5.15(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + '@next/env': 15.5.15 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001791 + postcss: 8.4.31 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + styled-jsx: 5.1.6(react@19.2.5) + optionalDependencies: + '@next/swc-darwin-arm64': 15.5.15 + '@next/swc-darwin-x64': 15.5.15 + '@next/swc-linux-arm64-gnu': 15.5.15 + '@next/swc-linux-arm64-musl': 15.5.15 + '@next/swc-linux-x64-gnu': 15.5.15 + '@next/swc-linux-x64-musl': 15.5.15 + '@next/swc-win32-arm64-msvc': 15.5.15 + '@next/swc-win32-x64-msvc': 15.5.15 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-type@4.0.0: {} + + pathe@1.1.2: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + possible-typed-array-names@1.1.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.12: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-dom@19.2.5(react@19.2.5): + dependencies: + react: 19.2.5 + scheduler: 0.27.0 + + react-is@16.13.1: {} + + react-is@19.2.5: {} + + react-transition-group@4.4.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + '@babel/runtime': 7.29.2 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + + react@19.2.5: {} + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.6: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rollup@4.60.2: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.2 + '@rollup/rollup-android-arm64': 4.60.2 + '@rollup/rollup-darwin-arm64': 4.60.2 + '@rollup/rollup-darwin-x64': 4.60.2 + '@rollup/rollup-freebsd-arm64': 4.60.2 + '@rollup/rollup-freebsd-x64': 4.60.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 + '@rollup/rollup-linux-arm-musleabihf': 4.60.2 + '@rollup/rollup-linux-arm64-gnu': 4.60.2 + '@rollup/rollup-linux-arm64-musl': 4.60.2 + '@rollup/rollup-linux-loong64-gnu': 4.60.2 + '@rollup/rollup-linux-loong64-musl': 4.60.2 + '@rollup/rollup-linux-ppc64-gnu': 4.60.2 + '@rollup/rollup-linux-ppc64-musl': 4.60.2 + '@rollup/rollup-linux-riscv64-gnu': 4.60.2 + '@rollup/rollup-linux-riscv64-musl': 4.60.2 + '@rollup/rollup-linux-s390x-gnu': 4.60.2 + '@rollup/rollup-linux-x64-gnu': 4.60.2 + '@rollup/rollup-linux-x64-musl': 4.60.2 + '@rollup/rollup-openbsd-x64': 4.60.2 + '@rollup/rollup-openharmony-arm64': 4.60.2 + '@rollup/rollup-win32-arm64-msvc': 4.60.2 + '@rollup/rollup-win32-ia32-msvc': 4.60.2 + '@rollup/rollup-win32-x64-gnu': 4.60.2 + '@rollup/rollup-win32-x64-msvc': 4.60.2 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + source-map@0.5.7: {} + + stable-hash@0.0.5: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string.prototype.includes@2.0.1: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + strip-bom@3.0.0: {} + + strip-json-comments@3.1.1: {} + + styled-jsx@5.1.6(react@19.2.5): + dependencies: + client-only: 0.0.1 + react: 19.2.5 + + stylis@4.2.0: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript@5.9.3: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@6.21.0: {} + + unrs-resolver@1.11.1: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite-node@2.1.9(@types/node@22.19.17): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.19.17) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.19.17): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.12 + rollup: 4.60.2 + optionalDependencies: + '@types/node': 22.19.17 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@22.19.17): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.17)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.19.17) + vite-node: 2.1.9(@types/node@22.19.17) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.17 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + yaml@1.10.3: {} + + yocto-queue@0.1.0: {} diff --git a/viewer/sample-data/export-schema.json b/viewer/sample-data/export-schema.json new file mode 100644 index 0000000..41f7b02 --- /dev/null +++ b/viewer/sample-data/export-schema.json @@ -0,0 +1,1236 @@ +{ + "$defs": { + "ArbiterDecisionEntry": { + "additionalProperties": false, + "description": "One Master-side `SpeakRequest` dispatch — the \"why this NPC, why now\"\nbreadcrumb the viewer surfaces alongside the resulting NPC speech.\n\nJoined from three persistence rows keyed by ``request_id``:\n\n* ``npc_speak_requests`` — the dispatch itself (Master → NPC)\n* ``npc_speak_results`` — NPC's reply (accepted / rejected / failed)\n* ``npc_playback_events`` — TTS + Discord playback outcome\n\nAny of the three may be missing for an in-flight or interrupted\nrequest; only ``request_id`` / ``phase_id`` / ``npc_id`` / ``seat_no``\n/ ``created_at_ms`` are guaranteed.", + "properties": { + "request_id": { + "title": "Request Id", + "type": "string" + }, + "phase_id": { + "title": "Phase Id", + "type": "string" + }, + "npc_id": { + "title": "Npc Id", + "type": "string" + }, + "seat_no": { + "title": "Seat No", + "type": "integer" + }, + "suggested_intent": { + "title": "Suggested Intent", + "type": "string" + }, + "selection_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Selection Reason" + }, + "public_state_snapshot": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Public State Snapshot" + }, + "logic_packet_id": { + "title": "Logic Packet Id", + "type": "string" + }, + "created_at_ms": { + "title": "Created At Ms", + "type": "integer" + }, + "expires_at_ms": { + "title": "Expires At Ms", + "type": "integer" + }, + "result_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Result Status" + }, + "result_text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Result Text" + }, + "result_intent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Result Intent" + }, + "result_failure_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Result Failure Reason" + }, + "result_received_at_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Result Received At Ms" + }, + "playback_outcome": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Playback Outcome" + }, + "playback_failure_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Playback Failure Reason" + }, + "playback_finished_at_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Playback Finished At Ms" + }, + "tts_outcome": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tts Outcome" + }, + "tts_duration_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tts Duration Ms" + } + }, + "required": [ + "request_id", + "phase_id", + "npc_id", + "seat_no", + "suggested_intent", + "selection_reason", + "public_state_snapshot", + "logic_packet_id", + "created_at_ms", + "expires_at_ms" + ], + "title": "ArbiterDecisionEntry", + "type": "object" + }, + "ClaimHistoryEntry": { + "additionalProperties": false, + "description": "Per-seat fold of every seer/medium claim a single seat has\ndeclared, surfaced at the top level of the export so the viewer\ncan render a per-claimer history table without re-folding the\nspeech-event stream.", + "properties": { + "claimer_seat": { + "title": "Claimer Seat", + "type": "integer" + }, + "seer_claims": { + "items": { + "$ref": "#/$defs/ClaimedSeerHistoryEntry" + }, + "title": "Seer Claims", + "type": "array" + }, + "medium_claims": { + "items": { + "$ref": "#/$defs/ClaimedMediumHistoryEntry" + }, + "title": "Medium Claims", + "type": "array" + } + }, + "required": [ + "claimer_seat", + "seer_claims", + "medium_claims" + ], + "title": "ClaimHistoryEntry", + "type": "object" + }, + "ClaimedMediumExport": { + "additionalProperties": false, + "description": "Structured medium-CO result attached to a single SpeechEvent.\n\n``is_wolf=null`` encodes the explicit \"no execution yesterday →\nno result today\" case so the viewer can render it as a void\nrather than fabricating a target color.", + "properties": { + "target_seat": { + "title": "Target Seat", + "type": "integer" + }, + "is_wolf": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Is Wolf" + } + }, + "required": [ + "target_seat", + "is_wolf" + ], + "title": "ClaimedMediumExport", + "type": "object" + }, + "ClaimedMediumHistoryEntry": { + "additionalProperties": false, + "properties": { + "day": { + "title": "Day", + "type": "integer" + }, + "target_seat": { + "title": "Target Seat", + "type": "integer" + }, + "target_name": { + "title": "Target Name", + "type": "string" + }, + "is_wolf": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Is Wolf" + }, + "declared_at_event_id": { + "title": "Declared At Event Id", + "type": "string" + } + }, + "required": [ + "day", + "target_seat", + "target_name", + "is_wolf", + "declared_at_event_id" + ], + "title": "ClaimedMediumHistoryEntry", + "type": "object" + }, + "ClaimedSeerExport": { + "additionalProperties": false, + "description": "Structured seer-CO result attached to a single SpeechEvent.\n\nReal seers and fake-CO wolves share the same shape; the viewer\nrenders the per-seat history on top of these and the configured\nseat roles, so a wolf seat showing 4 seer claims while the day\ncounter is at 2 is visibly suspicious.", + "properties": { + "target_seat": { + "title": "Target Seat", + "type": "integer" + }, + "is_wolf": { + "title": "Is Wolf", + "type": "boolean" + } + }, + "required": [ + "target_seat", + "is_wolf" + ], + "title": "ClaimedSeerExport", + "type": "object" + }, + "ClaimedSeerHistoryEntry": { + "additionalProperties": false, + "properties": { + "day": { + "title": "Day", + "type": "integer" + }, + "target_seat": { + "title": "Target Seat", + "type": "integer" + }, + "target_name": { + "title": "Target Name", + "type": "string" + }, + "is_wolf": { + "title": "Is Wolf", + "type": "boolean" + }, + "declared_at_event_id": { + "title": "Declared At Event Id", + "type": "string" + } + }, + "required": [ + "day", + "target_seat", + "target_name", + "is_wolf", + "declared_at_event_id" + ], + "title": "ClaimedSeerHistoryEntry", + "type": "object" + }, + "GameMeta": { + "additionalProperties": false, + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "guild_id": { + "title": "Guild Id", + "type": "string" + }, + "host_user_id": { + "title": "Host User Id", + "type": "string" + }, + "discussion_mode": { + "enum": [ + "rounds", + "reactive_voice" + ], + "title": "Discussion Mode", + "type": "string" + }, + "created_at_ms": { + "title": "Created At Ms", + "type": "integer" + }, + "ended_at_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Ended At Ms" + }, + "victory": { + "anyOf": [ + { + "enum": [ + "village", + "wolf" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Victory" + }, + "main_text_channel_id": { + "title": "Main Text Channel Id", + "type": "string" + }, + "main_vc_channel_id": { + "title": "Main Vc Channel Id", + "type": "string" + } + }, + "required": [ + "id", + "guild_id", + "host_user_id", + "discussion_mode", + "created_at_ms", + "ended_at_ms", + "victory", + "main_text_channel_id", + "main_vc_channel_id" + ], + "title": "GameMeta", + "type": "object" + }, + "NightActionExport": { + "additionalProperties": false, + "properties": { + "day": { + "title": "Day", + "type": "integer" + }, + "actor_seat": { + "title": "Actor Seat", + "type": "integer" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "target_seat": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Target Seat" + }, + "submitted_at_ms": { + "title": "Submitted At Ms", + "type": "integer" + } + }, + "required": [ + "day", + "actor_seat", + "kind", + "target_seat", + "submitted_at_ms" + ], + "title": "NightActionExport", + "type": "object" + }, + "PhaseSection": { + "additionalProperties": false, + "properties": { + "day": { + "title": "Day", + "type": "integer" + }, + "phase": { + "title": "Phase", + "type": "string" + }, + "started_at_ms": { + "title": "Started At Ms", + "type": "integer" + }, + "public_logs": { + "items": { + "$ref": "#/$defs/PublicLogEntry" + }, + "title": "Public Logs", + "type": "array" + }, + "speech_events": { + "items": { + "$ref": "#/$defs/SpeechEventExport" + }, + "title": "Speech Events", + "type": "array" + }, + "votes": { + "items": { + "$ref": "#/$defs/VoteExport" + }, + "title": "Votes", + "type": "array" + }, + "night_actions": { + "items": { + "$ref": "#/$defs/NightActionExport" + }, + "title": "Night Actions", + "type": "array" + }, + "wolf_chat_logs": { + "default": [], + "items": { + "$ref": "#/$defs/WolfChatLogEntry" + }, + "title": "Wolf Chat Logs", + "type": "array" + } + }, + "required": [ + "day", + "phase", + "started_at_ms", + "public_logs", + "speech_events", + "votes", + "night_actions" + ], + "title": "PhaseSection", + "type": "object" + }, + "PublicLogEntry": { + "additionalProperties": false, + "properties": { + "kind": { + "title": "Kind", + "type": "string" + }, + "actor_seat": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Actor Seat" + }, + "text": { + "title": "Text", + "type": "string" + }, + "created_at_ms": { + "title": "Created At Ms", + "type": "integer" + } + }, + "required": [ + "kind", + "actor_seat", + "text", + "created_at_ms" + ], + "title": "PublicLogEntry", + "type": "object" + }, + "SeatExport": { + "additionalProperties": false, + "properties": { + "seat_no": { + "title": "Seat No", + "type": "integer" + }, + "display_name": { + "title": "Display Name", + "type": "string" + }, + "is_llm": { + "title": "Is Llm", + "type": "boolean" + }, + "persona_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Persona Key" + }, + "discord_user_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Discord User Id" + }, + "role": { + "enum": [ + "VILLAGER", + "WEREWOLF", + "MADMAN", + "SEER", + "MEDIUM", + "KNIGHT" + ], + "title": "Role", + "type": "string" + }, + "alive": { + "title": "Alive", + "type": "boolean" + }, + "death_cause": { + "anyOf": [ + { + "enum": [ + "EXECUTION", + "ATTACK" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Death Cause" + }, + "death_day": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Death Day" + } + }, + "required": [ + "seat_no", + "display_name", + "is_llm", + "persona_key", + "discord_user_id", + "role", + "alive", + "death_cause", + "death_day" + ], + "title": "SeatExport", + "type": "object" + }, + "SpeechEventExport": { + "additionalProperties": false, + "properties": { + "event_id": { + "title": "Event Id", + "type": "string" + }, + "source": { + "enum": [ + "text", + "voice_stt", + "npc_generated" + ], + "title": "Source", + "type": "string" + }, + "speaker_seat": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Speaker Seat" + }, + "text": { + "title": "Text", + "type": "string" + }, + "stt_confidence": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Stt Confidence" + }, + "summary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Summary" + }, + "co_declaration": { + "anyOf": [ + { + "enum": [ + "seer", + "medium", + "knight" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Co Declaration" + }, + "addressed_seat_no": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Addressed Seat No" + }, + "claimed_seer_result": { + "anyOf": [ + { + "$ref": "#/$defs/ClaimedSeerExport" + }, + { + "type": "null" + } + ], + "default": null + }, + "claimed_medium_result": { + "anyOf": [ + { + "$ref": "#/$defs/ClaimedMediumExport" + }, + { + "type": "null" + } + ], + "default": null + }, + "created_at_ms": { + "title": "Created At Ms", + "type": "integer" + } + }, + "required": [ + "event_id", + "source", + "speaker_seat", + "text", + "stt_confidence", + "summary", + "co_declaration", + "addressed_seat_no", + "created_at_ms" + ], + "title": "SpeechEventExport", + "type": "object" + }, + "SuspicionEntry": { + "additionalProperties": false, + "description": "One row of the public suspicion timeline.\n\nMirrors the ``suspicions`` DB row 1:1 so the viewer can both render\nthe timeline (chronological feed) and pivot it into a (suspecter,\ntarget) → level matrix.", + "properties": { + "source": { + "enum": [ + "speech", + "vote" + ], + "title": "Source", + "type": "string" + }, + "event_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Event Id" + }, + "seq": { + "title": "Seq", + "type": "integer" + }, + "day": { + "title": "Day", + "type": "integer" + }, + "phase": { + "title": "Phase", + "type": "string" + }, + "vote_round": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Vote Round" + }, + "suspecter_seat": { + "title": "Suspecter Seat", + "type": "integer" + }, + "target_seat": { + "title": "Target Seat", + "type": "integer" + }, + "level": { + "enum": [ + "trust", + "low", + "medium", + "high" + ], + "title": "Level", + "type": "string" + }, + "reason": { + "title": "Reason", + "type": "string" + }, + "update_from_level": { + "anyOf": [ + { + "enum": [ + "trust", + "low", + "medium", + "high" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Update From Level" + }, + "update_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Update Reason" + }, + "created_at_ms": { + "title": "Created At Ms", + "type": "integer" + } + }, + "required": [ + "source", + "seq", + "day", + "phase", + "suspecter_seat", + "target_seat", + "level", + "reason", + "created_at_ms" + ], + "title": "SuspicionEntry", + "type": "object" + }, + "TokenUsage": { + "additionalProperties": false, + "properties": { + "prompt": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Prompt" + }, + "completion": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Completion" + }, + "total": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Total" + } + }, + "required": [ + "prompt", + "completion", + "total" + ], + "title": "TokenUsage", + "type": "object" + }, + "TraceEntry": { + "additionalProperties": true, + "description": "One JSONL trace row — see :mod:`wolfbot.services.llm_trace`.\n\n``extra=\"allow\"`` because trace metadata is intentionally open: the\nMaster / NPC paths may attach arbitrary debug fields that the viewer\njust renders verbatim.", + "properties": { + "ts": { + "title": "Ts", + "type": "string" + }, + "role": { + "enum": [ + "gameplay", + "npc_speech", + "npc_decision", + "voice_stt", + "text_analysis" + ], + "title": "Role", + "type": "string" + }, + "provider": { + "title": "Provider", + "type": "string" + }, + "model": { + "title": "Model", + "type": "string" + }, + "phase": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Phase" + }, + "day": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Day" + }, + "actor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actor" + }, + "system_prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "System Prompt" + }, + "user_prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "User Prompt" + }, + "response": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Response" + }, + "latency_ms": { + "title": "Latency Ms", + "type": "integer" + }, + "tokens": { + "anyOf": [ + { + "$ref": "#/$defs/TokenUsage" + }, + { + "type": "null" + } + ] + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Metadata" + }, + "file_stem": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "File Stem" + } + }, + "required": [ + "ts", + "role", + "provider", + "model", + "phase", + "day", + "actor", + "system_prompt", + "user_prompt", + "response", + "latency_ms", + "tokens", + "error" + ], + "title": "TraceEntry", + "type": "object" + }, + "VoteExport": { + "additionalProperties": false, + "properties": { + "day": { + "title": "Day", + "type": "integer" + }, + "round": { + "title": "Round", + "type": "integer" + }, + "voter_seat": { + "title": "Voter Seat", + "type": "integer" + }, + "target_seat": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Target Seat" + }, + "submitted_at_ms": { + "title": "Submitted At Ms", + "type": "integer" + } + }, + "required": [ + "day", + "round", + "voter_seat", + "target_seat", + "submitted_at_ms" + ], + "title": "VoteExport", + "type": "object" + }, + "WolfChatLogEntry": { + "additionalProperties": false, + "description": "One wolf-only night-coordination utterance.\n\nSourced from the ``logs_private`` table where ``visibility='PRIVATE'``\nand ``kind='WOLF_CHAT'``. The DB row is duplicated per audience-seat\n(one for each living wolf) so the exporter dedupes on\n``(actor_seat, created_at_ms, text)`` and emits a single entry per\nutterance — the audience side is implicit (= every alive wolf at the\ntime of the utterance) and not surfaced in the viewer because the\nwolves channel is wolf-private by construction.", + "properties": { + "actor_seat": { + "title": "Actor Seat", + "type": "integer" + }, + "text": { + "title": "Text", + "type": "string" + }, + "created_at_ms": { + "title": "Created At Ms", + "type": "integer" + } + }, + "required": [ + "actor_seat", + "text", + "created_at_ms" + ], + "title": "WolfChatLogEntry", + "type": "object" + } + }, + "additionalProperties": false, + "description": "Top-level shape of one ``viewer/games/{id}.json`` file.", + "properties": { + "game": { + "$ref": "#/$defs/GameMeta" + }, + "seats": { + "items": { + "$ref": "#/$defs/SeatExport" + }, + "title": "Seats", + "type": "array" + }, + "phases": { + "items": { + "$ref": "#/$defs/PhaseSection" + }, + "title": "Phases", + "type": "array" + }, + "trace": { + "items": { + "$ref": "#/$defs/TraceEntry" + }, + "title": "Trace", + "type": "array" + }, + "arbiter_decisions": { + "default": [], + "items": { + "$ref": "#/$defs/ArbiterDecisionEntry" + }, + "title": "Arbiter Decisions", + "type": "array" + }, + "claim_history": { + "default": [], + "items": { + "$ref": "#/$defs/ClaimHistoryEntry" + }, + "title": "Claim History", + "type": "array" + }, + "suspicions": { + "default": [], + "items": { + "$ref": "#/$defs/SuspicionEntry" + }, + "title": "Suspicions", + "type": "array" + } + }, + "required": [ + "game", + "seats", + "phases", + "trace" + ], + "title": "GameExport", + "type": "object" +} diff --git a/viewer/sample-data/game-sample.json b/viewer/sample-data/game-sample.json new file mode 100644 index 0000000..582fb21 --- /dev/null +++ b/viewer/sample-data/game-sample.json @@ -0,0 +1,1516 @@ +{ + "game": { + "id": "g_sample_001", + "guild_id": "1498215601808867421", + "host_user_id": "146810000000000001", + "discussion_mode": "rounds", + "created_at_ms": 1714291200000, + "ended_at_ms": 1714292020000, + "victory": "village", + "main_text_channel_id": "1498215837528621055", + "main_vc_channel_id": "1498215837528621056" + }, + "seats": [ + { + "seat_no": 1, + "display_name": "あなた", + "is_llm": false, + "persona_key": null, + "discord_user_id": "146810000000000001", + "role": "VILLAGER", + "alive": true, + "death_cause": null, + "death_day": null + }, + { + "seat_no": 2, + "display_name": "🌙 セツ", + "is_llm": true, + "persona_key": "setsu", + "discord_user_id": "1498225401372086313", + "role": "SEER", + "alive": true, + "death_cause": null, + "death_day": null + }, + { + "seat_no": 3, + "display_name": "🍷 ジーナ", + "is_llm": true, + "persona_key": "gina", + "discord_user_id": "1498225401372086314", + "role": "WEREWOLF", + "alive": false, + "death_cause": "EXECUTION", + "death_day": 1 + }, + { + "seat_no": 4, + "display_name": "🎲 SQ", + "is_llm": true, + "persona_key": "sq", + "discord_user_id": "1498225401372086315", + "role": "VILLAGER", + "alive": true, + "death_cause": null, + "death_day": null + }, + { + "seat_no": 5, + "display_name": "🔮 ラキオ", + "is_llm": true, + "persona_key": "raqio", + "discord_user_id": "1498225401372086316", + "role": "MEDIUM", + "alive": true, + "death_cause": null, + "death_day": null + }, + { + "seat_no": 6, + "display_name": "🛡 ステラ", + "is_llm": true, + "persona_key": "stella", + "discord_user_id": "1498225401372086317", + "role": "KNIGHT", + "alive": true, + "death_cause": null, + "death_day": null + }, + { + "seat_no": 7, + "display_name": "⚔ シゲミチ", + "is_llm": true, + "persona_key": "shigemichi", + "discord_user_id": "1498225401372086318", + "role": "WEREWOLF", + "alive": false, + "death_cause": "EXECUTION", + "death_day": 2 + }, + { + "seat_no": 8, + "display_name": "☄ コメット", + "is_llm": true, + "persona_key": "comet", + "discord_user_id": "1498225401372086319", + "role": "MADMAN", + "alive": true, + "death_cause": null, + "death_day": null + }, + { + "seat_no": 9, + "display_name": "📐 ジョナス", + "is_llm": true, + "persona_key": "jonas", + "discord_user_id": "1498225401372086320", + "role": "VILLAGER", + "alive": true, + "death_cause": null, + "death_day": null + } + ], + "phases": [ + { + "day": 0, + "phase": "SETUP", + "started_at_ms": 1714291230000, + "public_logs": [ + { + "kind": "SETUP_COMPLETE", + "actor_seat": null, + "text": "参加者 9 名で人狼ゲームを開始します。役職は各DMをご確認ください。", + "created_at_ms": 1714291230000 + } + ], + "speech_events": [], + "votes": [], + "night_actions": [] + }, + { + "day": 0, + "phase": "NIGHT_0", + "started_at_ms": 1714291235000, + "public_logs": [], + "speech_events": [], + "votes": [], + "night_actions": [ + { + "day": 0, + "actor_seat": 2, + "kind": "DIVINE_NIGHT0_RANDOM_WHITE", + "target_seat": 9, + "submitted_at_ms": 1714291238000 + } + ] + }, + { + "day": 1, + "phase": "DAY_DISCUSSION", + "started_at_ms": 1714291260000, + "public_logs": [ + { + "kind": "PHASE_CHANGE", + "actor_seat": null, + "text": "夜が明けました。1 日目の議論を開始致します。制限時間は 300 秒でございます。", + "created_at_ms": 1714291260000 + } + ], + "speech_events": [ + { + "event_id": "sp_2_1714291275000", + "source": "text", + "speaker_seat": 2, + "text": "占い師COします。初日ランダム白で席9 ジョナスを占いました。白判定です。", + "stt_confidence": null, + "summary": "seat 2 COs seer; seat 9 white", + "co_declaration": "seer", + "addressed_seat_no": null, + "created_at_ms": 1714291275000 + }, + { + "event_id": "sp_3_1714291290000", + "source": "text", + "speaker_seat": 3, + "text": "私も占い師です。席4 SQを占って白でした。席2は何かおかしい。", + "stt_confidence": null, + "summary": "seat 3 counter-COs seer; seat 4 white", + "co_declaration": "seer", + "addressed_seat_no": null, + "created_at_ms": 1714291290000 + }, + { + "event_id": "sp_5_1714291310000", + "source": "text", + "speaker_seat": 5, + "text": "霊媒師COはまだしません。今日処刑が出てから結果を出します。", + "stt_confidence": null, + "summary": "seat 5 plans medium CO after first execution", + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291310000 + }, + { + "event_id": "sp_9_1714291330000", + "source": "text", + "speaker_seat": 9, + "text": "席2の白判定をもらった9番です。占い理由が筋が通ってる。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291330000 + }, + { + "event_id": "sp_4_1714291350000", + "source": "text", + "speaker_seat": 4, + "text": "占いが2人いる。どちらが本物か議論したい。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291350000 + }, + { + "event_id": "sp_6_1714291370000", + "source": "text", + "speaker_seat": 6, + "text": "占いCOの2人をしっかり見極めましょう。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291370000 + }, + { + "event_id": "sp_7_1714291390000", + "source": "text", + "speaker_seat": 7, + "text": "席3が偽のように見える。占い理由が雑な印象。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291390000 + }, + { + "event_id": "sp_8_1714291410000", + "source": "text", + "speaker_seat": 8, + "text": "私は席2が怪しいと感じる。席3を信じたい。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291410000 + }, + { + "event_id": "sp_1_1714291430000", + "source": "text", + "speaker_seat": 1, + "text": "席3の占い理由がパッとしない。席2の方が信用できそう。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291430000 + }, + { + "event_id": "sp_2_1714291460000", + "source": "text", + "speaker_seat": 2, + "text": "席3の理由が薄いです。席3を投票候補にしたい。", + "stt_confidence": null, + "summary": "seat 2 nominates seat 3", + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291460000 + }, + { + "event_id": "sp_3_1714291475000", + "source": "text", + "speaker_seat": 3, + "text": "席2が偽です。みなさん席2に投票してください。", + "stt_confidence": null, + "summary": "seat 3 nominates seat 2", + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291475000 + }, + { + "event_id": "sp_5_1714291490000", + "source": "text", + "speaker_seat": 5, + "text": "占い騙りなら2が真の可能性が高い気がする。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291490000 + }, + { + "event_id": "sp_9_1714291510000", + "source": "text", + "speaker_seat": 9, + "text": "席2に白を貰った立場として、席3が偽だと考える。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291510000 + } + ], + "votes": [], + "night_actions": [] + }, + { + "day": 1, + "phase": "DAY_VOTE", + "started_at_ms": 1714291560000, + "public_logs": [ + { + "kind": "PHASE_CHANGE", + "actor_seat": null, + "text": "議論時間が終了しました。投票フェイズへ移行致します。制限時間は 60 秒でございます。", + "created_at_ms": 1714291560000 + }, + { + "kind": "EXECUTION", + "actor_seat": 3, + "text": "席3 🍷 ジーナ が処刑されました。\n\n投票内訳: 席3=5票 / 席2=3票 / 席3=1票", + "created_at_ms": 1714291620000 + } + ], + "speech_events": [], + "votes": [ + { + "day": 1, + "round": 1, + "voter_seat": 1, + "target_seat": 3, + "submitted_at_ms": 1714291565000 + }, + { + "day": 1, + "round": 1, + "voter_seat": 2, + "target_seat": 3, + "submitted_at_ms": 1714291570000 + }, + { + "day": 1, + "round": 1, + "voter_seat": 3, + "target_seat": 2, + "submitted_at_ms": 1714291575000 + }, + { + "day": 1, + "round": 1, + "voter_seat": 4, + "target_seat": 3, + "submitted_at_ms": 1714291580000 + }, + { + "day": 1, + "round": 1, + "voter_seat": 5, + "target_seat": 3, + "submitted_at_ms": 1714291585000 + }, + { + "day": 1, + "round": 1, + "voter_seat": 6, + "target_seat": 3, + "submitted_at_ms": 1714291590000 + }, + { + "day": 1, + "round": 1, + "voter_seat": 7, + "target_seat": 2, + "submitted_at_ms": 1714291595000 + }, + { + "day": 1, + "round": 1, + "voter_seat": 8, + "target_seat": 2, + "submitted_at_ms": 1714291600000 + }, + { + "day": 1, + "round": 1, + "voter_seat": 9, + "target_seat": 3, + "submitted_at_ms": 1714291605000 + } + ], + "night_actions": [] + }, + { + "day": 1, + "phase": "NIGHT", + "started_at_ms": 1714291620000, + "public_logs": [ + { + "kind": "PHASE_CHANGE", + "actor_seat": null, + "text": "夜のフェイズへ移行致します。制限時間は 90 秒でございます。", + "created_at_ms": 1714291620000 + } + ], + "speech_events": [], + "votes": [], + "night_actions": [ + { + "day": 1, + "actor_seat": 2, + "kind": "DIVINE", + "target_seat": 7, + "submitted_at_ms": 1714291640000 + }, + { + "day": 1, + "actor_seat": 6, + "kind": "GUARD", + "target_seat": 2, + "submitted_at_ms": 1714291655000 + }, + { + "day": 1, + "actor_seat": 7, + "kind": "ATTACK", + "target_seat": 2, + "submitted_at_ms": 1714291680000 + } + ] + }, + { + "day": 2, + "phase": "DAY_DISCUSSION", + "started_at_ms": 1714291710000, + "public_logs": [ + { + "kind": "MORNING", + "actor_seat": null, + "text": "平和な朝です。昨晩の犠牲者はいません。", + "created_at_ms": 1714291710000 + }, + { + "kind": "PHASE_CHANGE", + "actor_seat": null, + "text": "2 日目の議論を開始致します。制限時間は 240 秒でございます。", + "created_at_ms": 1714291710000 + } + ], + "speech_events": [ + { + "event_id": "sp_2_1714291730000", + "source": "text", + "speaker_seat": 2, + "text": "席7を占いました。黒判定です。席7 シゲミチが人狼です。", + "stt_confidence": null, + "summary": "seat 2 reveals seat 7 is wolf", + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291730000 + }, + { + "event_id": "sp_5_1714291750000", + "source": "text", + "speaker_seat": 5, + "text": "霊媒結果: 席3 ジーナは人狼でした。占い騙りで確定です。", + "stt_confidence": null, + "summary": "seat 5 confirms seat 3 was wolf", + "co_declaration": "medium", + "addressed_seat_no": null, + "created_at_ms": 1714291750000 + }, + { + "event_id": "sp_6_1714291770000", + "source": "text", + "speaker_seat": 6, + "text": "騎士COします。昨夜は席2を護衛して、襲撃を弾きました。", + "stt_confidence": null, + "summary": "seat 6 reveals knight, guarded seat 2", + "co_declaration": "knight", + "addressed_seat_no": null, + "created_at_ms": 1714291770000 + }, + { + "event_id": "sp_7_1714291790000", + "source": "text", + "speaker_seat": 7, + "text": "私は人狼じゃない。席2の占いは捏造です。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291790000 + }, + { + "event_id": "sp_8_1714291810000", + "source": "text", + "speaker_seat": 8, + "text": "席2 セツが本当に占い師なら、席7 黒は信じざるを得ない。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291810000 + }, + { + "event_id": "sp_1_1714291830000", + "source": "text", + "speaker_seat": 1, + "text": "占い・霊媒・騎士の3CO全部噛み合ってるので席7処刑で行きましょう。", + "stt_confidence": null, + "summary": null, + "co_declaration": null, + "addressed_seat_no": null, + "created_at_ms": 1714291830000 + } + ], + "votes": [], + "night_actions": [] + }, + { + "day": 2, + "phase": "DAY_VOTE", + "started_at_ms": 1714291960000, + "public_logs": [ + { + "kind": "EXECUTION", + "actor_seat": 7, + "text": "席7 ⚔ シゲミチ が処刑されました。\n\n投票内訳: 席7=7票 / 席2=1票", + "created_at_ms": 1714292020000 + }, + { + "kind": "VICTORY", + "actor_seat": null, + "text": "村人陣営の勝利!", + "created_at_ms": 1714292020000 + }, + { + "kind": "ROLE_REVEAL", + "actor_seat": null, + "text": "役職公開:\n席1 あなた = 村人 (生存)\n席2 🌙 セツ = 占い師 (生存)\n席3 🍷 ジーナ = 人狼 (1日目処刑)\n席4 🎲 SQ = 村人 (生存)\n席5 🔮 ラキオ = 霊媒師 (生存)\n席6 🛡 ステラ = 騎士 (生存)\n席7 ⚔ シゲミチ = 人狼 (2日目処刑)\n席8 ☄ コメット = 狂人 (生存)\n席9 📐 ジョナス = 村人 (生存)", + "created_at_ms": 1714292020000 + } + ], + "speech_events": [], + "votes": [ + { + "day": 2, + "round": 1, + "voter_seat": 1, + "target_seat": 7, + "submitted_at_ms": 1714291970000 + }, + { + "day": 2, + "round": 1, + "voter_seat": 2, + "target_seat": 7, + "submitted_at_ms": 1714291975000 + }, + { + "day": 2, + "round": 1, + "voter_seat": 4, + "target_seat": 7, + "submitted_at_ms": 1714291980000 + }, + { + "day": 2, + "round": 1, + "voter_seat": 5, + "target_seat": 7, + "submitted_at_ms": 1714291985000 + }, + { + "day": 2, + "round": 1, + "voter_seat": 6, + "target_seat": 7, + "submitted_at_ms": 1714291990000 + }, + { + "day": 2, + "round": 1, + "voter_seat": 7, + "target_seat": 2, + "submitted_at_ms": 1714291995000 + }, + { + "day": 2, + "round": 1, + "voter_seat": 8, + "target_seat": 7, + "submitted_at_ms": 1714292000000 + }, + { + "day": 2, + "round": 1, + "voter_seat": 9, + "target_seat": 7, + "submitted_at_ms": 1714292005000 + } + ], + "night_actions": [] + }, + { + "day": 2, + "phase": "GAME_OVER", + "started_at_ms": 1714292020000, + "public_logs": [], + "speech_events": [], + "votes": [], + "night_actions": [] + } + ], + "suspicions": [ + { + "source": "speech", + "event_id": "ev_d1_setsu_01", + "seq": 0, + "day": 1, + "phase": "DAY_DISCUSSION", + "vote_round": null, + "suspecter_seat": 2, + "target_seat": 5, + "level": "low", + "reason": "議論を急ぎすぎている印象。役職持ちが揃う前に進めようとする動きが少し不自然。", + "update_from_level": null, + "update_reason": null, + "created_at_ms": 1717000050000 + }, + { + "source": "speech", + "event_id": "ev_d1_jonas_01", + "seq": 0, + "day": 1, + "phase": "DAY_DISCUSSION", + "vote_round": null, + "suspecter_seat": 9, + "target_seat": 5, + "level": "medium", + "reason": "議論を煽る速度が早く、CO 待ちを軽視する発言が続いている。", + "update_from_level": null, + "update_reason": null, + "created_at_ms": 1717000060000 + }, + { + "source": "speech", + "event_id": "ev_d1_setsu_02", + "seq": 0, + "day": 1, + "phase": "DAY_DISCUSSION", + "vote_round": null, + "suspecter_seat": 2, + "target_seat": 5, + "level": "medium", + "reason": "ジョナスの指摘とも合致して、議論妨害の動機が見えてきた。", + "update_from_level": "low", + "update_reason": "ジョナスの指摘で違和感が裏付けられたため medium に格上げ。", + "created_at_ms": 1717000080000 + }, + { + "source": "speech", + "event_id": "ev_d1_jonas_02", + "seq": 0, + "day": 1, + "phase": "DAY_DISCUSSION", + "vote_round": null, + "suspecter_seat": 9, + "target_seat": 2, + "level": "trust", + "reason": "セツが冷静に状況整理しており、村陣営寄りに見える。", + "update_from_level": null, + "update_reason": null, + "created_at_ms": 1717000090000 + }, + { + "source": "vote", + "event_id": null, + "seq": 0, + "day": 1, + "phase": "DAY_VOTE", + "vote_round": 0, + "suspecter_seat": 2, + "target_seat": 5, + "level": "high", + "reason": "発話で示した懸念を投票で固める。ラキオを処刑候補に。", + "update_from_level": "medium", + "update_reason": "投票時点で確信が固まったため high に。", + "created_at_ms": 1717000200000 + }, + { + "source": "vote", + "event_id": null, + "seq": 0, + "day": 1, + "phase": "DAY_VOTE", + "vote_round": 0, + "suspecter_seat": 9, + "target_seat": 5, + "level": "high", + "reason": "セツと同じ理由でラキオへ票。", + "update_from_level": "medium", + "update_reason": "投票時点でラインが見えたため。", + "created_at_ms": 1717000210000 + } + ], + "trace": [ + { + "ts": "2026-04-28T08:00:15+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 1, + "actor": "seat=2 persona=setsu role=SEER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席2の役職 SEER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=1)\n\n公開ログ抜粋:\n(SETUP) 参加者 9 名で人狼ゲームを開始します。\n(PHASE_CHANGE) 1 日目の議論を開始致します。\n\nあなたの履歴: 初日議論ラウンド1, 席2として発話\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"占い師COします。初日ランダム白で席9 ジョナスを占いました。白判定です。\", \"target_name\": null, \"reason_summary\": \"1巡目の方針提示\", \"confidence\": 0.7, \"co_declaration\": \"seer\"}", + "latency_ms": 1328, + "tokens": { + "prompt": 272, + "completion": 174, + "total": 446 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:00:33+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 1, + "actor": "seat=3 persona=gina role=WEREWOLF", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席3の役職 WEREWOLF として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=1)\n\n公開ログ抜粋:\n(SETUP) 参加者 9 名で人狼ゲームを開始します。\n(PHASE_CHANGE) 1 日目の議論を開始致します。\n\nあなたの履歴: 初日議論ラウンド1, 席3として発話\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"私も占い師です。席4 SQを占って白でした。席2は何かおかしい。\", \"target_name\": null, \"reason_summary\": \"1巡目の方針提示\", \"confidence\": 0.7, \"co_declaration\": \"seer\"}", + "latency_ms": 1151, + "tokens": { + "prompt": 276, + "completion": 169, + "total": 445 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:00:51+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 1, + "actor": "seat=5 persona=raqio role=MEDIUM", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席5の役職 MEDIUM として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=1)\n\n公開ログ抜粋:\n(SETUP) 参加者 9 名で人狼ゲームを開始します。\n(PHASE_CHANGE) 1 日目の議論を開始致します。\n\nあなたの履歴: 初日議論ラウンド1, 席5として発話\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"霊媒師COはまだしません。今日処刑が出てから結果を出します。\", \"target_name\": null, \"reason_summary\": \"1巡目の方針提示\", \"confidence\": 0.7, \"co_declaration\": null}", + "latency_ms": 1663, + "tokens": { + "prompt": 274, + "completion": 165, + "total": 439 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:01:09+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 1, + "actor": "seat=9 persona=jonas role=VILLAGER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席9の役職 VILLAGER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=1)\n\n公開ログ抜粋:\n(SETUP) 参加者 9 名で人狼ゲームを開始します。\n(PHASE_CHANGE) 1 日目の議論を開始致します。\n\nあなたの履歴: 初日議論ラウンド1, 席9として発話\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"席2の白判定をもらった9番です。占い理由が筋が通ってる。\", \"target_name\": null, \"reason_summary\": \"1巡目の方針提示\", \"confidence\": 0.7, \"co_declaration\": null}", + "latency_ms": 1601, + "tokens": { + "prompt": 276, + "completion": 163, + "total": 439 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:01:27+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 1, + "actor": "seat=4 persona=sq role=VILLAGER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席4の役職 VILLAGER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=1)\n\n公開ログ抜粋:\n(SETUP) 参加者 9 名で人狼ゲームを開始します。\n(PHASE_CHANGE) 1 日目の議論を開始致します。\n\nあなたの履歴: 初日議論ラウンド1, 席4として発話\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"占いが2人いる。どちらが本物か議論したい。\", \"target_name\": null, \"reason_summary\": \"1巡目の方針提示\", \"confidence\": 0.7, \"co_declaration\": null}", + "latency_ms": 1557, + "tokens": { + "prompt": 276, + "completion": 156, + "total": 432 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:01:45+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 1, + "actor": "seat=6 persona=stella role=KNIGHT", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席6の役職 KNIGHT として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=1)\n\n公開ログ抜粋:\n(SETUP) 参加者 9 名で人狼ゲームを開始します。\n(PHASE_CHANGE) 1 日目の議論を開始致します。\n\nあなたの履歴: 初日議論ラウンド1, 席6として発話\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"占いCOの2人をしっかり見極めましょう。\", \"target_name\": null, \"reason_summary\": \"1巡目の方針提示\", \"confidence\": 0.7, \"co_declaration\": null}", + "latency_ms": 1385, + "tokens": { + "prompt": 274, + "completion": 155, + "total": 429 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:02:03+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 1, + "actor": "seat=7 persona=shigemichi role=WEREWOLF", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席7の役職 WEREWOLF として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=1)\n\n公開ログ抜粋:\n(SETUP) 参加者 9 名で人狼ゲームを開始します。\n(PHASE_CHANGE) 1 日目の議論を開始致します。\n\nあなたの履歴: 初日議論ラウンド1, 席7として発話\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"席3が偽のように見える。占い理由が雑な印象。\", \"target_name\": null, \"reason_summary\": \"1巡目の方針提示\", \"confidence\": 0.7, \"co_declaration\": null}", + "latency_ms": 1309, + "tokens": { + "prompt": 276, + "completion": 157, + "total": 433 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:02:21+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 1, + "actor": "seat=8 persona=comet role=MADMAN", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席8の役職 MADMAN として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=1)\n\n公開ログ抜粋:\n(SETUP) 参加者 9 名で人狼ゲームを開始します。\n(PHASE_CHANGE) 1 日目の議論を開始致します。\n\nあなたの履歴: 初日議論ラウンド1, 席8として発話\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"私は席2が怪しいと感じる。席3を信じたい。\", \"target_name\": null, \"reason_summary\": \"1巡目の方針提示\", \"confidence\": 0.7, \"co_declaration\": null}", + "latency_ms": 2216, + "tokens": { + "prompt": 274, + "completion": 156, + "total": 430 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:05:05+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 1, + "actor": "seat=2 persona=setsu role=SEER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席2の役職 SEER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=1)\n\n投票先として合法な候補は: 席3 🍷 ジーナ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n席1 あなたは合法候補に含まれません(あなた自身)\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席3 🍷 ジーナ\", \"reason_summary\": \"占い理由が薄い席3を投票\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 1278, + "tokens": { + "prompt": 275, + "completion": 145, + "total": 420 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:05:10+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 1, + "actor": "seat=3 persona=gina role=WEREWOLF", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席3の役職 WEREWOLF として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=1)\n\n投票先として合法な候補は: 席2 🌙 セツ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n席1 あなたは合法候補に含まれません(あなた自身)\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席2 🌙 セツ\", \"reason_summary\": \"対抗占い師の席2を投票\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 2309, + "tokens": { + "prompt": 278, + "completion": 143, + "total": 421 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:05:15+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 1, + "actor": "seat=4 persona=sq role=VILLAGER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席4の役職 VILLAGER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=1)\n\n投票先として合法な候補は: 席2 🌙 セツ、席3 🍷 ジーナ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n席1 あなたは合法候補に含まれません(あなた自身)\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席3 🍷 ジーナ\", \"reason_summary\": \"席3の論理が破綻気味\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 1964, + "tokens": { + "prompt": 279, + "completion": 143, + "total": 422 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:05:20+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 1, + "actor": "seat=5 persona=raqio role=MEDIUM", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席5の役職 MEDIUM として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=1)\n\n投票先として合法な候補は: 席2 🌙 セツ、席3 🍷 ジーナ、席4 🎲 SQ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n席1 あなたは合法候補に含まれません(あなた自身)\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席3 🍷 ジーナ\", \"reason_summary\": \"席3を吊って霊媒結果を取りたい\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 1165, + "tokens": { + "prompt": 276, + "completion": 148, + "total": 424 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:05:25+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 1, + "actor": "seat=6 persona=stella role=KNIGHT", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席6の役職 KNIGHT として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=1)\n\n投票先として合法な候補は: 席2 🌙 セツ、席3 🍷 ジーナ、席4 🎲 SQ、席5 🔮 ラキオ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n席1 あなたは合法候補に含まれません(あなた自身)\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席3 🍷 ジーナ\", \"reason_summary\": \"占いCO 2人だが席3の方が偽臭\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 1161, + "tokens": { + "prompt": 276, + "completion": 149, + "total": 425 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:05:30+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 1, + "actor": "seat=7 persona=shigemichi role=WEREWOLF", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席7の役職 WEREWOLF として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=1)\n\n投票先として合法な候補は: 席2 🌙 セツ、席3 🍷 ジーナ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席8 ☄ コメット、席9 📐 ジョナス\n席1 あなたは合法候補に含まれません(あなた自身)\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席2 🌙 セツ\", \"reason_summary\": \"席2が偽だと判断\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 1291, + "tokens": { + "prompt": 277, + "completion": 140, + "total": 417 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:05:35+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 1, + "actor": "seat=8 persona=comet role=MADMAN", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席8の役職 MADMAN として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=1)\n\n投票先として合法な候補は: 席2 🌙 セツ、席3 🍷 ジーナ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席9 📐 ジョナス\n席1 あなたは合法候補に含まれません(あなた自身)\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席2 🌙 セツ\", \"reason_summary\": \"席3を信じる立場\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 1547, + "tokens": { + "prompt": 275, + "completion": 140, + "total": 415 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:05:40+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 1, + "actor": "seat=9 persona=jonas role=VILLAGER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席9の役職 VILLAGER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=1)\n\n投票先として合法な候補は: 席2 🌙 セツ、席3 🍷 ジーナ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット\n席1 あなたは合法候補に含まれません(あなた自身)\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席3 🍷 ジーナ\", \"reason_summary\": \"白を貰った相手が偽でないとすれば席3が偽\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 1576, + "tokens": { + "prompt": 277, + "completion": 153, + "total": 430 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:06:20+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "NIGHT", + "day": 1, + "actor": "seat=2 persona=setsu role=SEER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席2の役職 SEER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: NIGHT (day=1)\n\nあなたは占い師。占い対象を 1 名選んでください。\n合法候補: 席4 SQ、席5 ラキオ、席6 ステラ、席7 シゲミチ、席8 コメット、席9 ジョナス\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"night_action\", \"public_message\": \"\", \"target_name\": \"席7 ⚔ シゲミチ\", \"reason_summary\": \"票筋的に席7が黒臭い、占って確定したい\", \"confidence\": 0.75, \"co_declaration\": null}", + "latency_ms": 2134, + "tokens": { + "prompt": 243, + "completion": 161, + "total": 404 + }, + "error": null, + "metadata": { + "task": "night_action" + } + }, + { + "ts": "2026-04-28T08:06:35+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "NIGHT", + "day": 1, + "actor": "seat=6 persona=stella role=KNIGHT", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席6の役職 KNIGHT として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: NIGHT (day=1)\n\nあなたは騎士。護衛対象を 1 名選んでください。\n合法候補: 席1 あなた、席2 セツ、席4 SQ、席5 ラキオ、席7 シゲミチ、席8 コメット、席9 ジョナス\n前夜護衛: なし\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"night_action\", \"public_message\": \"\", \"target_name\": \"席2 🌙 セツ\", \"reason_summary\": \"占い真目の席2を護衛するのが最有力\", \"confidence\": 0.8, \"co_declaration\": null}", + "latency_ms": 2332, + "tokens": { + "prompt": 259, + "completion": 156, + "total": 415 + }, + "error": null, + "metadata": { + "task": "night_action" + } + }, + { + "ts": "2026-04-28T08:06:55+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "NIGHT", + "day": 1, + "actor": "seat=7 persona=shigemichi role=WEREWOLF", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席7の役職 WEREWOLF として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: NIGHT (day=1)\n\n人狼チャット: 相方は席3だったが処刑された。襲撃先を選んでください。\n合法候補: 席1、席2、席4、席5、席6、席8、席9\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"占い真の席2を噛むのが最優先。次点は霊媒候補の席5。\", \"target_name\": null, \"reason_summary\": \"占い噛みで情報遮断\", \"confidence\": 0.7, \"co_declaration\": null}", + "latency_ms": 1154, + "tokens": { + "prompt": 234, + "completion": 162, + "total": 396 + }, + "error": null, + "metadata": { + "task": "wolf_chat" + } + }, + { + "ts": "2026-04-28T08:07:00+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "NIGHT", + "day": 1, + "actor": "seat=7 persona=shigemichi role=WEREWOLF", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席7の役職 WEREWOLF として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: NIGHT (day=1)\n\nあなたは人狼。襲撃対象を 1 名選んでください。\n合法候補: 席1、席2、席4、席5、席6、席8、席9\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"night_action\", \"public_message\": \"\", \"target_name\": \"席2 🌙 セツ\", \"reason_summary\": \"占い噛みで進行を支配\", \"confidence\": 0.75, \"co_declaration\": null}", + "latency_ms": 2249, + "tokens": { + "prompt": 223, + "completion": 150, + "total": 373 + }, + "error": null, + "metadata": { + "task": "night_action" + } + }, + { + "ts": "2026-04-28T08:07:50+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 2, + "actor": "seat=2 persona=setsu role=SEER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席2の役職 SEER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=2)\n\n公開ログ抜粋:\n(MORNING) 平和な朝です。昨晩の犠牲者はいません。\n(EXECUTION_PREV_DAY) 席3 ジーナ が処刑されました。\n\nあなたの行動: 席2として発話を準備\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"席7を占いました。黒判定です。席7 シゲミチが人狼です。\", \"target_name\": null, \"reason_summary\": \"情報整理して投票誘導\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 1507, + "tokens": { + "prompt": 272, + "completion": 166, + "total": 438 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:08:10+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 2, + "actor": "seat=5 persona=raqio role=MEDIUM", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席5の役職 MEDIUM として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=2)\n\n公開ログ抜粋:\n(MORNING) 平和な朝です。昨晩の犠牲者はいません。\n(EXECUTION_PREV_DAY) 席3 ジーナ が処刑されました。\n\nあなたの行動: 席5として発話を準備\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"霊媒結果: 席3 ジーナは人狼でした。占い騙りで確定です。\", \"target_name\": null, \"reason_summary\": \"情報整理して投票誘導\", \"confidence\": 0.85, \"co_declaration\": \"medium\"}", + "latency_ms": 2216, + "tokens": { + "prompt": 274, + "completion": 171, + "total": 445 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:08:30+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 2, + "actor": "seat=6 persona=stella role=KNIGHT", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席6の役職 KNIGHT として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=2)\n\n公開ログ抜粋:\n(MORNING) 平和な朝です。昨晩の犠牲者はいません。\n(EXECUTION_PREV_DAY) 席3 ジーナ が処刑されました。\n\nあなたの行動: 席6として発話を準備\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"騎士COします。昨夜は席2を護衛して、襲撃を弾きました。\", \"target_name\": null, \"reason_summary\": \"情報整理して投票誘導\", \"confidence\": 0.85, \"co_declaration\": \"knight\"}", + "latency_ms": 1959, + "tokens": { + "prompt": 274, + "completion": 170, + "total": 444 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:08:50+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 2, + "actor": "seat=7 persona=shigemichi role=WEREWOLF", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席7の役職 WEREWOLF として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=2)\n\n公開ログ抜粋:\n(MORNING) 平和な朝です。昨晩の犠牲者はいません。\n(EXECUTION_PREV_DAY) 席3 ジーナ が処刑されました。\n\nあなたの行動: 席7として発話を準備\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"私は人狼じゃない。席2の占いは捏造です。\", \"target_name\": null, \"reason_summary\": \"情報整理して投票誘導\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 1551, + "tokens": { + "prompt": 276, + "completion": 158, + "total": 434 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:09:10+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_DISCUSSION", + "day": 2, + "actor": "seat=8 persona=comet role=MADMAN", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席8の役職 MADMAN として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_DISCUSSION (day=2)\n\n公開ログ抜粋:\n(MORNING) 平和な朝です。昨晩の犠牲者はいません。\n(EXECUTION_PREV_DAY) 席3 ジーナ が処刑されました。\n\nあなたの行動: 席8として発話を準備\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"speak\", \"public_message\": \"席2 セツが本当に占い師なら、席7 黒は信じざるを得ない。\", \"target_name\": null, \"reason_summary\": \"情報整理して投票誘導\", \"confidence\": 0.85, \"co_declaration\": null}", + "latency_ms": 2019, + "tokens": { + "prompt": 274, + "completion": 167, + "total": 441 + }, + "error": null, + "metadata": { + "task": "discussion" + } + }, + { + "ts": "2026-04-28T08:11:50+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 2, + "actor": "seat=2 persona=setsu role=SEER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席2の役職 SEER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=2)\n\n投票先として合法な候補は: 席2 🌙 セツ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席7 ⚔ シゲミチ\", \"reason_summary\": \"占い・霊媒・騎士の3CO一致から席7が確定\", \"confidence\": 0.95, \"co_declaration\": null}", + "latency_ms": 2306, + "tokens": { + "prompt": 248, + "completion": 155, + "total": 403 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:11:55+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 2, + "actor": "seat=4 persona=sq role=VILLAGER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席4の役職 VILLAGER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=2)\n\n投票先として合法な候補は: 席2 🌙 セツ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席7 ⚔ シゲミチ\", \"reason_summary\": \"占い・霊媒・騎士の3CO一致から席7が確定\", \"confidence\": 0.95, \"co_declaration\": null}", + "latency_ms": 1669, + "tokens": { + "prompt": 252, + "completion": 155, + "total": 407 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:12:00+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 2, + "actor": "seat=5 persona=raqio role=MEDIUM", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席5の役職 MEDIUM として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=2)\n\n投票先として合法な候補は: 席2 🌙 セツ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席7 ⚔ シゲミチ\", \"reason_summary\": \"占い・霊媒・騎士の3CO一致から席7が確定\", \"confidence\": 0.95, \"co_declaration\": null}", + "latency_ms": 1113, + "tokens": { + "prompt": 250, + "completion": 155, + "total": 405 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:12:05+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 2, + "actor": "seat=6 persona=stella role=KNIGHT", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席6の役職 KNIGHT として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=2)\n\n投票先として合法な候補は: 席2 🌙 セツ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席7 ⚔ シゲミチ\", \"reason_summary\": \"占い・霊媒・騎士の3CO一致から席7が確定\", \"confidence\": 0.95, \"co_declaration\": null}", + "latency_ms": 1426, + "tokens": { + "prompt": 250, + "completion": 155, + "total": 405 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:12:10+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 2, + "actor": "seat=7 persona=shigemichi role=WEREWOLF", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席7の役職 WEREWOLF として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=2)\n\n投票先として合法な候補は: 席2 🌙 セツ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席2 🌙 セツ\", \"reason_summary\": \"占い結果を疑う\", \"confidence\": 0.95, \"co_declaration\": null}", + "latency_ms": 1965, + "tokens": { + "prompt": 252, + "completion": 139, + "total": 391 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:12:15+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 2, + "actor": "seat=8 persona=comet role=MADMAN", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席8の役職 MADMAN として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=2)\n\n投票先として合法な候補は: 席2 🌙 セツ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席7 ⚔ シゲミチ\", \"reason_summary\": \"占い・霊媒・騎士の3CO一致から席7が確定\", \"confidence\": 0.95, \"co_declaration\": null}", + "latency_ms": 1796, + "tokens": { + "prompt": 250, + "completion": 155, + "total": 405 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:12:20+00:00", + "role": "gameplay", + "provider": "xai", + "model": "grok-4-1-fast", + "phase": "DAY_VOTE", + "day": 2, + "actor": "seat=9 persona=jonas role=VILLAGER", + "system_prompt": "あなたは人狼ゲームのプレイヤーです。あなたは席9の役職 VILLAGER として振る舞ってください。公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。", + "user_prompt": "現在のフェイズ: DAY_VOTE (day=2)\n\n投票先として合法な候補は: 席2 🌙 セツ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス\n\n以下のいずれかの intent を返してください: speak / vote / night_action / skip", + "response": "{\"intent\": \"vote\", \"public_message\": \"\", \"target_name\": \"席7 ⚔ シゲミチ\", \"reason_summary\": \"占い・霊媒・騎士の3CO一致から席7が確定\", \"confidence\": 0.95, \"co_declaration\": null}", + "latency_ms": 1669, + "tokens": { + "prompt": 252, + "completion": 155, + "total": 407 + }, + "error": null, + "metadata": { + "task": "vote" + } + }, + { + "ts": "2026-04-28T08:02:50+00:00", + "role": "voice_stt", + "provider": "gemini", + "model": "gemini-2.0-flash-lite", + "phase": "g_sample_001::day1::DAY_DISCUSSION::1", + "day": 1, + "actor": "speaker_user_id=146810000000000001 seat=1 segment=seg_170000", + "system_prompt": "あなたは人狼ゲームの音声ログ分析エンジンです。渡された音声(日本語)を書き起こし、JSON で返してください。", + "user_prompt": "[audio bytes=82000 mime=audio/wav]", + "response": "{\"transcript\": \"席3の占い理由がパッとしない。席2の方が信用できそう。\", \"summary\": \"席3の占い理由がパッとしない。席2の方が信用できそう。\", \"confidence\": 0.91, \"co_claim\": null, \"vote_target_seat\": null, \"stance\": {}, \"addressed_name\": null}", + "latency_ms": 959, + "tokens": { + "prompt": 330, + "completion": 189, + "total": 519 + }, + "error": null, + "metadata": { + "audio_bytes": 82000, + "language": "ja-JP", + "segment_id": "seg_170000" + }, + "file_stem": "voice_stt" + }, + { + "ts": "2026-04-28T08:09:30+00:00", + "role": "voice_stt", + "provider": "gemini", + "model": "gemini-2.0-flash-lite", + "phase": "g_sample_001::day2::DAY_DISCUSSION::1", + "day": 2, + "actor": "speaker_user_id=146810000000000001 seat=1 segment=seg_570000", + "system_prompt": "あなたは人狼ゲームの音声ログ分析エンジンです。渡された音声(日本語)を書き起こし、JSON で返してください。", + "user_prompt": "[audio bytes=110000 mime=audio/wav]", + "response": "{\"transcript\": \"占い・霊媒・騎士の3CO全部噛み合ってるので席7処刑で行きましょう。\", \"summary\": \"占い・霊媒・騎士の3CO全部噛み合ってるので席7処刑で行きま\", \"confidence\": 0.88, \"co_claim\": null, \"vote_target_seat\": null, \"stance\": {}, \"addressed_name\": null}", + "latency_ms": 1020, + "tokens": { + "prompt": 357, + "completion": 199, + "total": 556 + }, + "error": null, + "metadata": { + "audio_bytes": 110000, + "language": "ja-JP", + "segment_id": "seg_570000" + }, + "file_stem": "voice_stt" + }, + { + "ts": "2026-04-28T08:00:15+00:00", + "role": "npc_speech", + "provider": "openai-compat", + "model": "grok-4-1-fast", + "phase": "g_sample_001::day1::DAY_DISCUSSION::1", + "day": 1, + "actor": "npc_id=npc_setsu seat=2 persona=setsu", + "system_prompt": "あなたはNPC 'setsu' として人狼ゲームに参加しています。短いリアクティブ発話 (最大80字) を JSON で返してください。", + "user_prompt": "現在のフェイズ ID: g_sample_001::day1::DAY_DISCUSSION::1\nあなたの席: 席2\n\n公開状況: 議論ラウンド1進行中。\n候補発話タイミング: あり。\nあなたの席=2 (セツ)。短い反応的発話を作ってください。", + "response": "{\"intent\": \"speak\", \"text\": \"占い師COします。初日ランダム白で席9 ジョナスを占いました。\", \"co_declaration\": \"seer\", \"used_logic_ids\": [\"lc_co_seer\", \"lc_white_jonas\"]}", + "latency_ms": 1481, + "tokens": { + "prompt": 191, + "completion": 140, + "total": 331 + }, + "error": null, + "metadata": { + "request_id": "req_2_15000", + "logic_packet_id": "lp_2_15000", + "suggested_intent": "speak", + "max_chars": 80 + }, + "file_stem": "npc_setsu" + }, + { + "ts": "2026-04-28T08:08:10+00:00", + "role": "npc_speech", + "provider": "openai-compat", + "model": "grok-4-1-fast", + "phase": "g_sample_001::day2::DAY_DISCUSSION::1", + "day": 2, + "actor": "npc_id=npc_raqio seat=5 persona=raqio", + "system_prompt": "あなたはNPC 'raqio' として人狼ゲームに参加しています。短いリアクティブ発話 (最大80字) を JSON で返してください。", + "user_prompt": "現在のフェイズ ID: g_sample_001::day2::DAY_DISCUSSION::1\nあなたの席: 席5\n\n議論ラウンド進行中。霊媒結果を出すタイミング。", + "response": "{\"intent\": \"speak\", \"text\": \"霊媒結果: 席3 ジーナは人狼でした。占い騙りで確定です。\", \"co_declaration\": \"medium\", \"used_logic_ids\": [\"lc_medium_co\", \"lc_seat3_wolf\"]}", + "latency_ms": 1044, + "tokens": { + "prompt": 152, + "completion": 141, + "total": 293 + }, + "error": null, + "metadata": { + "request_id": "req_5_490000", + "logic_packet_id": "lp_5_490000", + "suggested_intent": "speak", + "max_chars": 80 + }, + "file_stem": "npc_raqio" + } + ] +} \ No newline at end of file diff --git a/viewer/sample-data/generate_sample.py b/viewer/sample-data/generate_sample.py new file mode 100644 index 0000000..24a704e --- /dev/null +++ b/viewer/sample-data/generate_sample.py @@ -0,0 +1,767 @@ +"""Generate ``game-sample.json`` — a hand-crafted 1-game demo for the viewer. + +The synthetic game is a 9-player 2-day village victory: + +- Day 1: village votes out wolf #1 (seat 3, Jina, fake-seer counter-CO) +- Night 1: wolf #2 attacks the real seer; knight guard succeeds; seer divines wolf #2 +- Day 2: village pieces it together, votes out wolf #2 + +Includes: +- full SQLite-shape game state (games, seats, public/private logs, votes, + night_actions, speech_events) +- LLM trace lines (gameplay + npc_speech + voice_stt) with realistic + prompt / response / token / latency stubs + +The output file is the single source of truth for the Next.js viewer; +the viewer reads ``viewer/sample-data/game-sample.json`` by default. + +Usage: + uv run python viewer/sample-data/generate_sample.py +""" + +from __future__ import annotations + +import json +import random +from pathlib import Path +from typing import Any + +random.seed(42) + +GAME_ID = "g_sample_001" +GUILD_ID = "1498215601808867421" +HOST_USER_ID = "146810000000000001" +CREATED_AT_MS = 1714291200000 # 2026-04-28T08:00:00Z +DAY1_START_MS = CREATED_AT_MS + 60_000 +NIGHT0_START_MS = DAY1_START_MS - 30_000 + +# ─── seats ──────────────────────────────────────────────────────────────── +# Roles: 2 wolves, 1 madman, 1 seer, 1 medium, 1 knight, 3 villagers +SEATS = [ + {"seat_no": 1, "display_name": "あなた", "is_llm": False, "persona_key": None, + "discord_user_id": HOST_USER_ID, "role": "VILLAGER"}, + {"seat_no": 2, "display_name": "🌙 セツ", "is_llm": True, "persona_key": "setsu", + "discord_user_id": "1498225401372086313", "role": "SEER"}, + {"seat_no": 3, "display_name": "🍷 ジーナ", "is_llm": True, "persona_key": "gina", + "discord_user_id": "1498225401372086314", "role": "WEREWOLF"}, + {"seat_no": 4, "display_name": "🎲 SQ", "is_llm": True, "persona_key": "sq", + "discord_user_id": "1498225401372086315", "role": "VILLAGER"}, + {"seat_no": 5, "display_name": "🔮 ラキオ", "is_llm": True, "persona_key": "raqio", + "discord_user_id": "1498225401372086316", "role": "MEDIUM"}, + {"seat_no": 6, "display_name": "🛡 ステラ", "is_llm": True, "persona_key": "stella", + "discord_user_id": "1498225401372086317", "role": "KNIGHT"}, + {"seat_no": 7, "display_name": "⚔ シゲミチ", "is_llm": True, "persona_key": "shigemichi", + "discord_user_id": "1498225401372086318", "role": "WEREWOLF"}, + {"seat_no": 8, "display_name": "☄ コメット", "is_llm": True, "persona_key": "comet", + "discord_user_id": "1498225401372086319", "role": "MADMAN"}, + {"seat_no": 9, "display_name": "📐 ジョナス", "is_llm": True, "persona_key": "jonas", + "discord_user_id": "1498225401372086320", "role": "VILLAGER"}, +] + + +def _seat_token(seat_no: int) -> str: + s = next(s for s in SEATS if s["seat_no"] == seat_no) + return f"席{seat_no} {s['display_name']}" + + +def _is_alive_at(seat_no: int, day: int, phase: str) -> bool: + # Final state after game: wolves (3, 7) dead. + # Day 1 vote kills 3. Night 1: knight guards seer, no death. Day 2 vote kills 7. + if seat_no == 3: + return not (day >= 1 and phase in {"DAY_VOTE", "NIGHT", "DAY_DISCUSSION", "GAME_OVER"} and day >= 1) or ( + day == 1 and phase == "DAY_VOTE" + ) + return True + + +# ─── public log / phase timeline ────────────────────────────────────────── +def _ts(offset_s: int) -> int: + return DAY1_START_MS + offset_s * 1000 + + +def build_phases() -> list[dict[str, Any]]: + return [ + { + "day": 0, + "phase": "SETUP", + "started_at_ms": NIGHT0_START_MS, + "public_logs": [ + { + "kind": "SETUP_COMPLETE", + "actor_seat": None, + "text": "参加者 9 名で人狼ゲームを開始します。役職は各DMをご確認ください。", + "created_at_ms": NIGHT0_START_MS, + }, + ], + "speech_events": [], + "votes": [], + "night_actions": [], + }, + { + "day": 0, + "phase": "NIGHT_0", + "started_at_ms": NIGHT0_START_MS + 5000, + "public_logs": [], + "speech_events": [], + "votes": [], + "night_actions": [ + { + "day": 0, + "actor_seat": 2, + "kind": "DIVINE_NIGHT0_RANDOM_WHITE", + "target_seat": 9, + "submitted_at_ms": NIGHT0_START_MS + 8000, + }, + ], + }, + { + "day": 1, + "phase": "DAY_DISCUSSION", + "started_at_ms": _ts(0), + "public_logs": [ + { + "kind": "PHASE_CHANGE", + "actor_seat": None, + "text": "夜が明けました。1 日目の議論を開始致します。制限時間は 300 秒でございます。", + "created_at_ms": _ts(0), + }, + ], + "speech_events": [ + _speech(2, "text", _ts(15), "占い師COします。初日ランダム白で席9 ジョナスを占いました。白判定です。", + co_declaration="seer", summary="seat 2 COs seer; seat 9 white"), + _speech(3, "text", _ts(30), "私も占い師です。席4 SQを占って白でした。席2は何かおかしい。", + co_declaration="seer", summary="seat 3 counter-COs seer; seat 4 white"), + _speech(5, "text", _ts(50), "霊媒師COはまだしません。今日処刑が出てから結果を出します。", + summary="seat 5 plans medium CO after first execution"), + _speech(9, "text", _ts(70), "席2の白判定をもらった9番です。占い理由が筋が通ってる。", summary=None), + _speech(4, "text", _ts(90), "占いが2人いる。どちらが本物か議論したい。", summary=None), + _speech(6, "text", _ts(110), "占いCOの2人をしっかり見極めましょう。", summary=None), + _speech(7, "text", _ts(130), "席3が偽のように見える。占い理由が雑な印象。", summary=None), + _speech(8, "text", _ts(150), "私は席2が怪しいと感じる。席3を信じたい。", summary=None), + _speech(1, "text", _ts(170), "席3の占い理由がパッとしない。席2の方が信用できそう。", summary=None), + # Round 2 + _speech(2, "text", _ts(200), "席3の理由が薄いです。席3を投票候補にしたい。", summary="seat 2 nominates seat 3"), + _speech(3, "text", _ts(215), "席2が偽です。みなさん席2に投票してください。", summary="seat 3 nominates seat 2"), + _speech(5, "text", _ts(230), "占い騙りなら2が真の可能性が高い気がする。", summary=None), + _speech(9, "text", _ts(250), "席2に白を貰った立場として、席3が偽だと考える。", summary=None), + ], + "votes": [], + "night_actions": [], + }, + { + "day": 1, + "phase": "DAY_VOTE", + "started_at_ms": _ts(300), + "public_logs": [ + { + "kind": "PHASE_CHANGE", + "actor_seat": None, + "text": "議論時間が終了しました。投票フェイズへ移行致します。制限時間は 60 秒でございます。", + "created_at_ms": _ts(300), + }, + { + "kind": "EXECUTION", + "actor_seat": 3, + "text": "席3 🍷 ジーナ が処刑されました。\n\n投票内訳: 席3=5票 / 席2=3票 / 席3=1票", + "created_at_ms": _ts(360), + }, + ], + "speech_events": [], + "votes": [ + {"day": 1, "round": 1, "voter_seat": 1, "target_seat": 3, "submitted_at_ms": _ts(305)}, + {"day": 1, "round": 1, "voter_seat": 2, "target_seat": 3, "submitted_at_ms": _ts(310)}, + {"day": 1, "round": 1, "voter_seat": 3, "target_seat": 2, "submitted_at_ms": _ts(315)}, + {"day": 1, "round": 1, "voter_seat": 4, "target_seat": 3, "submitted_at_ms": _ts(320)}, + {"day": 1, "round": 1, "voter_seat": 5, "target_seat": 3, "submitted_at_ms": _ts(325)}, + {"day": 1, "round": 1, "voter_seat": 6, "target_seat": 3, "submitted_at_ms": _ts(330)}, + {"day": 1, "round": 1, "voter_seat": 7, "target_seat": 2, "submitted_at_ms": _ts(335)}, + {"day": 1, "round": 1, "voter_seat": 8, "target_seat": 2, "submitted_at_ms": _ts(340)}, + {"day": 1, "round": 1, "voter_seat": 9, "target_seat": 3, "submitted_at_ms": _ts(345)}, + ], + "night_actions": [], + }, + { + "day": 1, + "phase": "NIGHT", + "started_at_ms": _ts(360), + "public_logs": [ + { + "kind": "PHASE_CHANGE", + "actor_seat": None, + "text": "夜のフェイズへ移行致します。制限時間は 90 秒でございます。", + "created_at_ms": _ts(360), + }, + ], + "speech_events": [], + "votes": [], + "night_actions": [ + {"day": 1, "actor_seat": 2, "kind": "DIVINE", "target_seat": 7, "submitted_at_ms": _ts(380)}, + {"day": 1, "actor_seat": 6, "kind": "GUARD", "target_seat": 2, "submitted_at_ms": _ts(395)}, + {"day": 1, "actor_seat": 7, "kind": "ATTACK", "target_seat": 2, "submitted_at_ms": _ts(420)}, + ], + }, + { + "day": 2, + "phase": "DAY_DISCUSSION", + "started_at_ms": _ts(450), + "public_logs": [ + { + "kind": "MORNING", + "actor_seat": None, + "text": "平和な朝です。昨晩の犠牲者はいません。", + "created_at_ms": _ts(450), + }, + { + "kind": "PHASE_CHANGE", + "actor_seat": None, + "text": "2 日目の議論を開始致します。制限時間は 240 秒でございます。", + "created_at_ms": _ts(450), + }, + ], + "speech_events": [ + _speech(2, "text", _ts(470), "席7を占いました。黒判定です。席7 シゲミチが人狼です。", summary="seat 2 reveals seat 7 is wolf"), + _speech(5, "text", _ts(490), "霊媒結果: 席3 ジーナは人狼でした。占い騙りで確定です。", + co_declaration="medium", summary="seat 5 confirms seat 3 was wolf"), + _speech(6, "text", _ts(510), "騎士COします。昨夜は席2を護衛して、襲撃を弾きました。", + co_declaration="knight", summary="seat 6 reveals knight, guarded seat 2"), + _speech(7, "text", _ts(530), "私は人狼じゃない。席2の占いは捏造です。", summary=None), + _speech(8, "text", _ts(550), "席2 セツが本当に占い師なら、席7 黒は信じざるを得ない。", summary=None), + _speech(1, "text", _ts(570), "占い・霊媒・騎士の3CO全部噛み合ってるので席7処刑で行きましょう。", summary=None), + ], + "votes": [], + "night_actions": [], + }, + { + "day": 2, + "phase": "DAY_VOTE", + "started_at_ms": _ts(700), + "public_logs": [ + { + "kind": "EXECUTION", + "actor_seat": 7, + "text": "席7 ⚔ シゲミチ が処刑されました。\n\n投票内訳: 席7=7票 / 席2=1票", + "created_at_ms": _ts(760), + }, + { + "kind": "VICTORY", + "actor_seat": None, + "text": "村人陣営の勝利!", + "created_at_ms": _ts(760), + }, + { + "kind": "ROLE_REVEAL", + "actor_seat": None, + "text": ( + "役職公開:\n" + "席1 あなた = 村人 (生存)\n" + "席2 🌙 セツ = 占い師 (生存)\n" + "席3 🍷 ジーナ = 人狼 (1日目処刑)\n" + "席4 🎲 SQ = 村人 (生存)\n" + "席5 🔮 ラキオ = 霊媒師 (生存)\n" + "席6 🛡 ステラ = 騎士 (生存)\n" + "席7 ⚔ シゲミチ = 人狼 (2日目処刑)\n" + "席8 ☄ コメット = 狂人 (生存)\n" + "席9 📐 ジョナス = 村人 (生存)" + ), + "created_at_ms": _ts(760), + }, + ], + "speech_events": [], + "votes": [ + {"day": 2, "round": 1, "voter_seat": 1, "target_seat": 7, "submitted_at_ms": _ts(710)}, + {"day": 2, "round": 1, "voter_seat": 2, "target_seat": 7, "submitted_at_ms": _ts(715)}, + {"day": 2, "round": 1, "voter_seat": 4, "target_seat": 7, "submitted_at_ms": _ts(720)}, + {"day": 2, "round": 1, "voter_seat": 5, "target_seat": 7, "submitted_at_ms": _ts(725)}, + {"day": 2, "round": 1, "voter_seat": 6, "target_seat": 7, "submitted_at_ms": _ts(730)}, + {"day": 2, "round": 1, "voter_seat": 7, "target_seat": 2, "submitted_at_ms": _ts(735)}, + {"day": 2, "round": 1, "voter_seat": 8, "target_seat": 7, "submitted_at_ms": _ts(740)}, + {"day": 2, "round": 1, "voter_seat": 9, "target_seat": 7, "submitted_at_ms": _ts(745)}, + ], + "night_actions": [], + }, + { + "day": 2, + "phase": "GAME_OVER", + "started_at_ms": _ts(760), + "public_logs": [], + "speech_events": [], + "votes": [], + "night_actions": [], + }, + ] + + +def _speech( + seat_no: int, + source: str, + ts_ms: int, + text: str, + *, + co_declaration: str | None = None, + summary: str | None = None, + stt_confidence: float | None = None, + addressed_seat_no: int | None = None, +) -> dict[str, Any]: + return { + "event_id": f"sp_{seat_no}_{ts_ms}", + "source": source, + "speaker_seat": seat_no, + "text": text, + "stt_confidence": stt_confidence, + "summary": summary, + "co_declaration": co_declaration, + "addressed_seat_no": addressed_seat_no, + "created_at_ms": ts_ms, + } + + +# ─── trace synthesis ────────────────────────────────────────────────────── +GAMEPLAY_MODEL = "grok-4-1-fast" +NPC_MODEL = "grok-4-1-fast" +VOICE_MODEL = "gemini-2.0-flash-lite" + +ROLE_BY_SEAT = {s["seat_no"]: s["role"] for s in SEATS} +PERSONA_BY_SEAT = {s["seat_no"]: s["persona_key"] for s in SEATS} + + +def _gameplay_trace_entry( + *, + seat_no: int, + phase: str, + day: int, + task: str, + response_obj: dict[str, Any], + user_prompt_extra: str, + ts_offset_ms: int, +) -> dict[str, Any]: + persona = PERSONA_BY_SEAT[seat_no] + role = ROLE_BY_SEAT[seat_no] + actor = f"seat={seat_no} persona={persona} role={role}" + system_prompt = ( + "あなたは人狼ゲームのプレイヤーです。" + f"あなたは席{seat_no}の役職 {role} として振る舞ってください。" + "公開ログ、私的ログ、合法候補から、JSON 形式で行動を返してください。" + ) + user_prompt = ( + f"現在のフェイズ: {phase} (day={day})\n\n" + f"{user_prompt_extra}\n\n" + "以下のいずれかの intent を返してください: speak / vote / night_action / skip" + ) + response_str = json.dumps(response_obj, ensure_ascii=False) + prompt_tokens = len(system_prompt) + len(user_prompt) + completion_tokens = len(response_str) + return { + "ts": _iso_at(ts_offset_ms), + "role": "gameplay", + "provider": "xai", + "model": GAMEPLAY_MODEL, + "phase": phase, + "day": day, + "actor": actor, + "system_prompt": system_prompt, + "user_prompt": user_prompt, + "response": response_str, + "latency_ms": random.randint(1100, 2400), + "tokens": { + "prompt": prompt_tokens, + "completion": completion_tokens, + "total": prompt_tokens + completion_tokens, + }, + "error": None, + "metadata": {"task": task}, + } + + +def _npc_trace_entry( + *, + seat_no: int, + phase_id: str, + day: int, + response_obj: dict[str, Any], + user_prompt_extra: str, + ts_offset_ms: int, +) -> dict[str, Any]: + persona = PERSONA_BY_SEAT[seat_no] + actor = f"npc_id=npc_{persona} seat={seat_no} persona={persona}" + system_prompt = ( + f"あなたはNPC '{persona}' として人狼ゲームに参加しています。" + f"短いリアクティブ発話 (最大80字) を JSON で返してください。" + ) + user_prompt = ( + f"現在のフェイズ ID: {phase_id}\n" + f"あなたの席: 席{seat_no}\n\n" + f"{user_prompt_extra}" + ) + response_str = json.dumps(response_obj, ensure_ascii=False) + prompt_tokens = len(system_prompt) + len(user_prompt) + completion_tokens = len(response_str) + return { + "ts": _iso_at(ts_offset_ms), + "role": "npc_speech", + "provider": "openai-compat", + "model": NPC_MODEL, + "phase": phase_id, + "day": day, + "actor": actor, + "system_prompt": system_prompt, + "user_prompt": user_prompt, + "response": response_str, + "latency_ms": random.randint(700, 1500), + "tokens": { + "prompt": prompt_tokens, + "completion": completion_tokens, + "total": prompt_tokens + completion_tokens, + }, + "error": None, + "metadata": { + "request_id": f"req_{seat_no}_{ts_offset_ms}", + "logic_packet_id": f"lp_{seat_no}_{ts_offset_ms}", + "suggested_intent": "speak", + "max_chars": 80, + }, + "file_stem": f"npc_{persona}", + } + + +def _voice_trace_entry( + *, + speaker_seat: int, + phase_id: str, + day: int, + transcript: str, + confidence: float, + audio_bytes: int, + ts_offset_ms: int, + addressed_name: str | None = None, + co_claim: str | None = None, +) -> dict[str, Any]: + s = next(s for s in SEATS if s["seat_no"] == speaker_seat) + actor = f"speaker_user_id={s['discord_user_id']} seat={speaker_seat} segment=seg_{ts_offset_ms}" + system_prompt = ( + "あなたは人狼ゲームの音声ログ分析エンジンです。" + "渡された音声(日本語)を書き起こし、JSON で返してください。" + ) + user_prompt = f"[audio bytes={audio_bytes} mime=audio/wav]" + response_obj = { + "transcript": transcript, + "summary": transcript[:30], + "confidence": confidence, + "co_claim": co_claim, + "vote_target_seat": None, + "stance": {}, + "addressed_name": addressed_name, + } + response_str = json.dumps(response_obj, ensure_ascii=False) + return { + "ts": _iso_at(ts_offset_ms), + "role": "voice_stt", + "provider": "gemini", + "model": VOICE_MODEL, + "phase": phase_id, + "day": day, + "actor": actor, + "system_prompt": system_prompt, + "user_prompt": user_prompt, + "response": response_str, + "latency_ms": random.randint(800, 1600), + "tokens": { + "prompt": 250 + audio_bytes // 1024, + "completion": len(response_str), + "total": 250 + audio_bytes // 1024 + len(response_str), + }, + "error": None, + "metadata": { + "audio_bytes": audio_bytes, + "language": "ja-JP", + "segment_id": f"seg_{ts_offset_ms}", + }, + "file_stem": "voice_stt", + } + + +def _iso_at(offset_ms: int) -> str: + """Format CREATED_AT_MS + offset as ISO 8601 with Z suffix.""" + from datetime import UTC, datetime, timedelta + dt = datetime(2026, 4, 28, 8, 0, 0, tzinfo=UTC) + timedelta(milliseconds=offset_ms) + return dt.isoformat() + + +def build_trace() -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + d1_disc = "g_sample_001::day1::DAY_DISCUSSION::1" + d1_vote = "g_sample_001::day1::DAY_VOTE::1" + d1_night = "g_sample_001::day1::NIGHT::1" + d2_disc = "g_sample_001::day2::DAY_DISCUSSION::1" + d2_vote = "g_sample_001::day2::DAY_VOTE::1" + + # Day 1 discussion: gameplay LLM speeches for each LLM seat + speech_data_d1 = [ + (2, "占い師COします。初日ランダム白で席9 ジョナスを占いました。白判定です。", "seer"), + (3, "私も占い師です。席4 SQを占って白でした。席2は何かおかしい。", "seer"), + (5, "霊媒師COはまだしません。今日処刑が出てから結果を出します。", None), + (9, "席2の白判定をもらった9番です。占い理由が筋が通ってる。", None), + (4, "占いが2人いる。どちらが本物か議論したい。", None), + (6, "占いCOの2人をしっかり見極めましょう。", None), + (7, "席3が偽のように見える。占い理由が雑な印象。", None), + (8, "私は席2が怪しいと感じる。席3を信じたい。", None), + ] + for i, (seat, text, co) in enumerate(speech_data_d1): + entries.append(_gameplay_trace_entry( + seat_no=seat, + phase="DAY_DISCUSSION", + day=1, + task="discussion", + user_prompt_extra=( + "公開ログ抜粋:\n" + "(SETUP) 参加者 9 名で人狼ゲームを開始します。\n" + "(PHASE_CHANGE) 1 日目の議論を開始致します。\n" + f"\nあなたの履歴: 初日議論ラウンド1, 席{seat}として発話" + ), + response_obj={ + "intent": "speak", + "public_message": text, + "target_name": None, + "reason_summary": "1巡目の方針提示", + "confidence": 0.7, + "co_declaration": co, + }, + ts_offset_ms=15_000 + i * 18_000, + )) + + # Day 1 vote + vote_data = [ + (2, 3, "占い理由が薄い席3を投票"), + (3, 2, "対抗占い師の席2を投票"), + (4, 3, "席3の論理が破綻気味"), + (5, 3, "席3を吊って霊媒結果を取りたい"), + (6, 3, "占いCO 2人だが席3の方が偽臭"), + (7, 2, "席2が偽だと判断"), + (8, 2, "席3を信じる立場"), + (9, 3, "白を貰った相手が偽でないとすれば席3が偽"), + ] + for i, (voter, target, reason) in enumerate(vote_data): + entries.append(_gameplay_trace_entry( + seat_no=voter, + phase="DAY_VOTE", + day=1, + task="vote", + user_prompt_extra=( + "投票先として合法な候補は: " + "、".join(_seat_token(s) for s in [2, 3, 4, 5, 6, 7, 8, 9] if s != voter) + + "\n席1 あなたは合法候補に含まれません(あなた自身)" + ), + response_obj={ + "intent": "vote", + "public_message": "", + "target_name": _seat_token(target), + "reason_summary": reason, + "confidence": 0.85, + "co_declaration": None, + }, + ts_offset_ms=305_000 + i * 5_000, + )) + + # Night 1 actions + entries.append(_gameplay_trace_entry( + seat_no=2, + phase="NIGHT", + day=1, + task="night_action", + user_prompt_extra="あなたは占い師。占い対象を 1 名選んでください。\n合法候補: 席4 SQ、席5 ラキオ、席6 ステラ、席7 シゲミチ、席8 コメット、席9 ジョナス", + response_obj={ + "intent": "night_action", + "public_message": "", + "target_name": _seat_token(7), + "reason_summary": "票筋的に席7が黒臭い、占って確定したい", + "confidence": 0.75, + "co_declaration": None, + }, + ts_offset_ms=380_000, + )) + entries.append(_gameplay_trace_entry( + seat_no=6, + phase="NIGHT", + day=1, + task="night_action", + user_prompt_extra="あなたは騎士。護衛対象を 1 名選んでください。\n合法候補: 席1 あなた、席2 セツ、席4 SQ、席5 ラキオ、席7 シゲミチ、席8 コメット、席9 ジョナス\n前夜護衛: なし", + response_obj={ + "intent": "night_action", + "public_message": "", + "target_name": _seat_token(2), + "reason_summary": "占い真目の席2を護衛するのが最有力", + "confidence": 0.8, + "co_declaration": None, + }, + ts_offset_ms=395_000, + )) + entries.append(_gameplay_trace_entry( + seat_no=7, + phase="NIGHT", + day=1, + task="wolf_chat", + user_prompt_extra="人狼チャット: 相方は席3だったが処刑された。襲撃先を選んでください。\n合法候補: 席1、席2、席4、席5、席6、席8、席9", + response_obj={ + "intent": "speak", + "public_message": "占い真の席2を噛むのが最優先。次点は霊媒候補の席5。", + "target_name": None, + "reason_summary": "占い噛みで情報遮断", + "confidence": 0.7, + "co_declaration": None, + }, + ts_offset_ms=415_000, + )) + entries.append(_gameplay_trace_entry( + seat_no=7, + phase="NIGHT", + day=1, + task="night_action", + user_prompt_extra="あなたは人狼。襲撃対象を 1 名選んでください。\n合法候補: 席1、席2、席4、席5、席6、席8、席9", + response_obj={ + "intent": "night_action", + "public_message": "", + "target_name": _seat_token(2), + "reason_summary": "占い噛みで進行を支配", + "confidence": 0.75, + "co_declaration": None, + }, + ts_offset_ms=420_000, + )) + + # Day 2 discussion: notable speeches + speech_data_d2 = [ + (2, "席7を占いました。黒判定です。席7 シゲミチが人狼です。", None), + (5, "霊媒結果: 席3 ジーナは人狼でした。占い騙りで確定です。", "medium"), + (6, "騎士COします。昨夜は席2を護衛して、襲撃を弾きました。", "knight"), + (7, "私は人狼じゃない。席2の占いは捏造です。", None), + (8, "席2 セツが本当に占い師なら、席7 黒は信じざるを得ない。", None), + ] + for i, (seat, text, co) in enumerate(speech_data_d2): + entries.append(_gameplay_trace_entry( + seat_no=seat, + phase="DAY_DISCUSSION", + day=2, + task="discussion", + user_prompt_extra=( + "公開ログ抜粋:\n" + "(MORNING) 平和な朝です。昨晩の犠牲者はいません。\n" + "(EXECUTION_PREV_DAY) 席3 ジーナ が処刑されました。\n" + f"\nあなたの行動: 席{seat}として発話を準備" + ), + response_obj={ + "intent": "speak", + "public_message": text, + "target_name": None, + "reason_summary": "情報整理して投票誘導", + "confidence": 0.85, + "co_declaration": co, + }, + ts_offset_ms=470_000 + i * 20_000, + )) + + # Day 2 vote: only key votes + for i, (voter, target) in enumerate([(2, 7), (4, 7), (5, 7), (6, 7), (7, 2), (8, 7), (9, 7)]): + entries.append(_gameplay_trace_entry( + seat_no=voter, + phase="DAY_VOTE", + day=2, + task="vote", + user_prompt_extra="投票先として合法な候補は: 席2 🌙 セツ、席4 🎲 SQ、席5 🔮 ラキオ、席6 🛡 ステラ、席7 ⚔ シゲミチ、席8 ☄ コメット、席9 📐 ジョナス", + response_obj={ + "intent": "vote", + "public_message": "", + "target_name": _seat_token(target), + "reason_summary": "占い・霊媒・騎士の3CO一致から席7が確定" if target == 7 else "占い結果を疑う", + "confidence": 0.95, + "co_declaration": None, + }, + ts_offset_ms=710_000 + i * 5_000, + )) + + # Sample voice STT entries — pretend the human player (seat 1) spoke twice + entries.append(_voice_trace_entry( + speaker_seat=1, + phase_id=d1_disc, + day=1, + transcript="席3の占い理由がパッとしない。席2の方が信用できそう。", + confidence=0.91, + audio_bytes=82_000, + ts_offset_ms=170_000, + )) + entries.append(_voice_trace_entry( + speaker_seat=1, + phase_id=d2_disc, + day=2, + transcript="占い・霊媒・騎士の3CO全部噛み合ってるので席7処刑で行きましょう。", + confidence=0.88, + audio_bytes=110_000, + ts_offset_ms=570_000, + )) + + # A handful of NPC reactive speech entries (would be sent to NPC bots in reactive_voice mode) + entries.append(_npc_trace_entry( + seat_no=2, + phase_id=d1_disc, + day=1, + user_prompt_extra=( + "公開状況: 議論ラウンド1進行中。\n候補発話タイミング: あり。\n" + "あなたの席=2 (セツ)。短い反応的発話を作ってください。" + ), + response_obj={ + "intent": "speak", + "text": "占い師COします。初日ランダム白で席9 ジョナスを占いました。", + "co_declaration": "seer", + "used_logic_ids": ["lc_co_seer", "lc_white_jonas"], + }, + ts_offset_ms=15_000, + )) + entries.append(_npc_trace_entry( + seat_no=5, + phase_id=d2_disc, + day=2, + user_prompt_extra="議論ラウンド進行中。霊媒結果を出すタイミング。", + response_obj={ + "intent": "speak", + "text": "霊媒結果: 席3 ジーナは人狼でした。占い騙りで確定です。", + "co_declaration": "medium", + "used_logic_ids": ["lc_medium_co", "lc_seat3_wolf"], + }, + ts_offset_ms=490_000, + )) + + return entries + + +def main() -> None: + out = { + "game": { + "id": GAME_ID, + "guild_id": GUILD_ID, + "host_user_id": HOST_USER_ID, + "discussion_mode": "rounds", + "created_at_ms": CREATED_AT_MS, + "ended_at_ms": DAY1_START_MS + 760_000, + "victory": "village", + "main_text_channel_id": "1498215837528621055", + "main_vc_channel_id": "1498215837528621056", + }, + "seats": [ + { + **s, + "alive": not (s["seat_no"] in (3, 7)), + "death_cause": "EXECUTION" if s["seat_no"] in (3, 7) else None, + "death_day": 1 if s["seat_no"] == 3 else (2 if s["seat_no"] == 7 else None), + } + for s in SEATS + ], + "phases": build_phases(), + "trace": build_trace(), + } + target = Path(__file__).parent / "game-sample.json" + target.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"wrote {target.resolve()} ({target.stat().st_size:,} bytes)") + print(f" seats = {len(out['seats'])}") + print(f" phases = {len(out['phases'])}") + print(f" trace lines = {len(out['trace'])}") + + +if __name__ == "__main__": + main() diff --git a/viewer/src/app/api/games/[gameId]/trace/[index]/route.ts b/viewer/src/app/api/games/[gameId]/trace/[index]/route.ts new file mode 100644 index 0000000..7b2450d --- /dev/null +++ b/viewer/src/app/api/games/[gameId]/trace/[index]/route.ts @@ -0,0 +1,28 @@ +// Lazy fetch endpoint for one LLM trace entry's heavy fields. +// +// The detail page strips `system_prompt` / `user_prompt` / `response` +// from the SSR payload to keep the initial HTML small. When the user +// opens TraceDrawer, it calls this route with the trace's index in +// `data.trace[]` to fetch just those three strings on demand. +// +// `index` is the array position — stable for the lifetime of the +// exported JSON file, which is itself immutable post-game. + +import { NextResponse } from "next/server"; +import { loadTraceHeavyFields } from "@/lib/data"; + +export async function GET( + _req: Request, + { params }: { params: Promise<{ gameId: string; index: string }> }, +) { + const { gameId, index } = await params; + const idx = Number(index); + if (!Number.isInteger(idx) || idx < 0) { + return NextResponse.json({ error: "invalid index" }, { status: 400 }); + } + const heavy = await loadTraceHeavyFields(gameId, idx); + if (heavy === null) { + return NextResponse.json({ error: "not found" }, { status: 404 }); + } + return NextResponse.json(heavy); +} diff --git a/viewer/src/app/games/[gameId]/GameViewClient.tsx b/viewer/src/app/games/[gameId]/GameViewClient.tsx new file mode 100644 index 0000000..09a0600 --- /dev/null +++ b/viewer/src/app/games/[gameId]/GameViewClient.tsx @@ -0,0 +1,45 @@ +"use client"; + +import GameView from "@/components/GameView"; +import type { TraceFetcher } from "@/components/TraceDrawer"; +import type { MatchMaps } from "@/lib/match"; +import type { GameSample } from "@/lib/types"; + +/** + * Tiny client wrapper around `GameView`. Lives next to the server + * `page.tsx` so the colocated `gameId` is closed over by the lazy + * trace fetcher without leaking the URL into `GameView`'s prop API. + * + * Why a separate file rather than inlining: server components can't + * ship a function across the boundary, so the closure has to live in + * a "use client" file. + */ +export default function GameViewClient({ + gameId, + data, + matches, +}: { + gameId: string; + data: GameSample; + matches: MatchMaps; +}) { + const fetcher: TraceFetcher = async (index) => { + const res = await fetch(`/api/games/${gameId}/trace/${index}`); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + return (await res.json()) as { + system_prompt: string; + user_prompt: string; + response: string | null; + }; + }; + return ( + + ); +} diff --git a/viewer/src/app/games/[gameId]/not-found.tsx b/viewer/src/app/games/[gameId]/not-found.tsx new file mode 100644 index 0000000..246d2bb --- /dev/null +++ b/viewer/src/app/games/[gameId]/not-found.tsx @@ -0,0 +1,23 @@ +import Link from "next/link"; +import Container from "@mui/material/Container"; +import Paper from "@mui/material/Paper"; +import Typography from "@mui/material/Typography"; + +export default function NotFound() { + return ( + + + + ゲームが見つかりません + + + 指定された game_id に対応する JSON が{" "} + viewer/games/ に存在しません。 + + + ゲーム一覧に戻る + + + + ); +} diff --git a/viewer/src/app/games/[gameId]/page.tsx b/viewer/src/app/games/[gameId]/page.tsx new file mode 100644 index 0000000..6b7c863 --- /dev/null +++ b/viewer/src/app/games/[gameId]/page.tsx @@ -0,0 +1,25 @@ +import { notFound } from "next/navigation"; +import GameViewClient from "./GameViewClient"; +import { loadGameWithMatches } from "@/lib/data"; + +export default async function GameDetailPage({ + params, +}: { + params: Promise<{ gameId: string }>; +}) { + const { gameId } = await params; + const result = await loadGameWithMatches(gameId); + if (result === null) { + notFound(); + } + // The client wrapper builds the lazy-fetch closure from `gameId`. + // Server components can't ship a function as a prop, so the fetcher + // is constructed in a "use client" sibling instead of here. + return ( + + ); +} diff --git a/viewer/src/app/globals.css b/viewer/src/app/globals.css new file mode 100644 index 0000000..b46130d --- /dev/null +++ b/viewer/src/app/globals.css @@ -0,0 +1,25 @@ +html, body { + padding: 0; + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Hiragino Sans", "Yu Gothic UI", + "Segoe UI", Roboto, sans-serif; + background: #fafafa; +} + +* { + box-sizing: border-box; +} + +a { + color: inherit; + text-decoration: none; +} + +pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.5; +} diff --git a/viewer/src/app/layout.tsx b/viewer/src/app/layout.tsx new file mode 100644 index 0000000..ce77bfa --- /dev/null +++ b/viewer/src/app/layout.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter"; +import CssBaseline from "@mui/material/CssBaseline"; +import ThemeRegistry from "@/components/ThemeRegistry"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Wolfbot Game Viewer", + description: + "Per-phase view of player actions, speeches, LLM prompts, and token usage for a single wolfbot game.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return ( + + + + + + {children} + + + + + ); +} diff --git a/viewer/src/app/page.tsx b/viewer/src/app/page.tsx new file mode 100644 index 0000000..27af43b --- /dev/null +++ b/viewer/src/app/page.tsx @@ -0,0 +1,40 @@ +import Link from "next/link"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Container from "@mui/material/Container"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import GamesTable, { EmptyGamesState } from "@/components/GamesTable"; +import { listGames } from "@/lib/data"; + +export default async function GamesListPage() { + const games = await listGames(); + return ( + + + + + Wolfbot Game Viewer + + + 終了したゲームの一覧。行をクリックすると詳細ビューに移動します。 + + + + + {games.length === 0 ? : } + + ); +} diff --git a/viewer/src/app/sample/page.tsx b/viewer/src/app/sample/page.tsx new file mode 100644 index 0000000..fd82b10 --- /dev/null +++ b/viewer/src/app/sample/page.tsx @@ -0,0 +1,20 @@ +import GameView from "@/components/GameView"; +import { loadSample } from "@/lib/data"; + +export default async function SampleGamePage() { + const data = await loadSample(); + // Sample page intentionally skips the slim/lazy treatment — the + // bundled JSON is small and we want the demo to render fully without + // an API round-trip. Pass `matches=null` so GameView falls back to + // computing the match map in-browser; pass `traceFetcher=null` so + // TraceDrawer reads the heavy fields directly from each TraceEntry. + return ( + + ); +} diff --git a/viewer/src/components/ClaimHistoryPanel.tsx b/viewer/src/components/ClaimHistoryPanel.tsx new file mode 100644 index 0000000..27d9694 --- /dev/null +++ b/viewer/src/components/ClaimHistoryPanel.tsx @@ -0,0 +1,330 @@ +"use client"; + +/** + * ClaimHistoryPanel — per-seat ledger of every divination/medium claim + * each CO'd seat made in this game. + * + * Why this panel exists + * --------------------- + * Wolves (and the madman) faking a seer/medium CO have to keep their + * announced results consistent across phases. Without a per-seat + * ledger surfaced visibly, drift goes undetected: in game + * `a51615d32274` (2026-04-30) the wolf seer Yuriko declared + * "シゲミチ白" on day 1, then on day 2 silently dropped シゲミチ and + * grafted "コメット白" from her wolf-partner's claim, with nobody at + * the table noticing. This panel stacks every claim against day + + * expected count so the discrepancy reads at a glance: + * + * - Day-N integrity rule (seer): a real seer at day N's morning + * should have exactly N cumulative results (NIGHT_0's random + * white declared on day 1 + one per subsequent night, each + * surfaced the morning after). The header chip shows + * "通算 X / 期待 Y" and turns red when X ≠ Y. + * - Per-claim row: day, target, verdict (黒/白). The wolf badge + * contrast against the claimer's actual role makes fake CO obvious + * post-game. + * + * Pure presentation: reads `data.claim_history` (pre-folded by the + * exporter) plus `data.seats` for role/name resolution. Older exports + * (pre-2026-05-01) lack `claim_history` and the panel collapses to a + * compact "no claims recorded" state rather than blowing up. + */ + +import * as React from "react"; +import Box from "@mui/material/Box"; +import Chip from "@mui/material/Chip"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { roleChipStyle, roleJa } from "@/lib/format"; +import type { + ClaimHistoryEntry, + ClaimedMediumHistoryEntry, + ClaimedSeerHistoryEntry, + GameSample, + Seat, +} from "@/lib/types"; + +// Older exports (pre-2026-05-02) kept one ledger row per speech +// utterance, so an LLM seat that restated the same divination / +// medium result across multiple discussion rounds appears as +// repeated rows. The Python collector now dedupes at export time; +// this guard re-runs the same fold in-browser so legacy JSON files +// also render cleanly. +function dedupeSeerClaims( + claims: readonly ClaimedSeerHistoryEntry[], +): ClaimedSeerHistoryEntry[] { + const seen = new Set(); + const out: ClaimedSeerHistoryEntry[] = []; + for (const c of claims) { + const key = `${c.day}|${c.target_seat}|${c.is_wolf ? 1 : 0}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(c); + } + return out; +} + +function dedupeMediumClaims( + claims: readonly ClaimedMediumHistoryEntry[], +): ClaimedMediumHistoryEntry[] { + const seen = new Set(); + const out: ClaimedMediumHistoryEntry[] = []; + for (const c of claims) { + const verdict = c.is_wolf === null ? "n" : c.is_wolf ? "1" : "0"; + const key = `${c.day}|${c.target_seat}|${verdict}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(c); + } + return out; +} + +const CO_LABEL: Record = { + seer: "占い", + medium: "霊媒", + knight: "騎士", +}; + +export default function ClaimHistoryPanel({ data }: { data: GameSample }) { + const history = data.claim_history ?? []; + if (history.length === 0) { + return ( + + CO 結果履歴 + + このゲームには記録された占い/霊媒 CO 結果がありません。 + + + ); + } + + const seatLookup = new Map(data.seats.map((s) => [s.seat_no, s])); + const dayCount = computeDayCount(data); + + return ( + + + CO 結果履歴 + + + 各 CO 者が公表した占い/霊媒結果の累積。期待件数と一致しない場合、 + 嘘 CO の可能性が高い。 + + + {history.map((entry) => ( + + ))} + + + ); +} + +function ClaimerRow({ + entry, + claimer, + seatLookup, + currentDay, +}: { + entry: ClaimHistoryEntry; + claimer: Seat | undefined; + seatLookup: Map; + currentDay: number; +}) { + // Real-role guard: a wolf/madman/villager whose declared role does + // not match their seat role is rendering the panel's value most. + // The role chip on the header carries that contrast so post-game + // readers see "Yuriko (狼) — 占い 通算 1 件" without scanning seats. + const seerClaims = dedupeSeerClaims(entry.seer_claims); + const mediumClaims = dedupeMediumClaims(entry.medium_claims); + const seerCount = seerClaims.length; + const mediumCount = mediumClaims.length; + const expectedSeer = expectedSeerCountForDay(currentDay); + // Medium count == executions seen so far; we don't have a clean way + // to count executions per game in the viewer, so we render the raw + // count without a mismatch chip rather than risk a false positive. + const seerMismatch = seerCount > 0 && seerCount !== expectedSeer; + + return ( + + + + {claimer?.display_name ?? `席${entry.claimer_seat}`} + + {claimer && ( + + )} + {seerCount > 0 && ( + + )} + {mediumCount > 0 && ( + + )} + + + {seerClaims.map((c, idx) => ( + + ))} + {mediumClaims.map((c, idx) => ( + + ))} + + + ); +} + +function SeerClaimRow({ + claim, + seatLookup, + label, +}: { + claim: ClaimedSeerHistoryEntry; + seatLookup: Map; + label: string; +}) { + const target = seatLookup.get(claim.target_seat); + // Real-vs-claim mismatch: a wolf was claimed white when the target + // is actually a wolf (or vice versa). Surfaces the cleanest "this + // CO is lying" tell straight in the row without forcing the user + // to cross-reference roles manually. + const truthIsWolf = target ? target.role === "WEREWOLF" : null; + const lieFlag = + truthIsWolf !== null && truthIsWolf !== claim.is_wolf; + + return ( + + + + {label} + + + {claim.target_name}{" "} + + {claim.is_wolf ? "黒" : "白"} + + + {lieFlag && ( + + )} + + ); +} + +function MediumClaimRow({ + claim, + seatLookup, + label, +}: { + claim: ClaimedMediumHistoryEntry; + seatLookup: Map; + label: string; +}) { + const target = seatLookup.get(claim.target_seat); + const truthIsWolf = target ? target.role === "WEREWOLF" : null; + // Medium claims with `is_wolf=null` mean "no execution yesterday"; + // we don't flag those as lies even if the target's role is a wolf + // — a void result is silence, not a falsehood. + const lieFlag = + claim.is_wolf !== null && + truthIsWolf !== null && + truthIsWolf !== claim.is_wolf; + const verdict = + claim.is_wolf === null + ? "結果なし" + : claim.is_wolf + ? "黒" + : "白"; + const verdictColor = + claim.is_wolf === null ? "#888" : claim.is_wolf ? "#d32f2f" : "#1976d2"; + return ( + + + + {label} + + + {claim.target_name}{" "} + {verdict} + + {lieFlag && ( + + )} + + ); +} + +function expectedSeerCountForDay(day: number): number { + // Mirrors `wolfbot.master.claim.claim_history.expected_seer_claim_count_for_day`: + // each entry is tagged with the day it was *announced* (NIGHT_0 + // surfaces day 1 morning, NIGHT_K surfaces day K+1 morning), so by + // day-N morning the cumulative count is exactly N. This matches the + // validator's "1 entry per declared day" rule (`same_day_priors`). + if (day < 1) return 0; + return day; +} + +function computeDayCount(data: GameSample): number { + // The exporter doesn't carry an explicit "current day" field, so we + // infer from the highest day_number that appears anywhere in the + // phase log. Empty games default to 0 (= day 1's morning baseline). + let max = 0; + for (const phase of data.phases) { + if (phase.day > max) max = phase.day; + } + return max; +} + diff --git a/viewer/src/components/GameHeader.tsx b/viewer/src/components/GameHeader.tsx new file mode 100644 index 0000000..a49e9e3 --- /dev/null +++ b/viewer/src/components/GameHeader.tsx @@ -0,0 +1,88 @@ +import Box from "@mui/material/Box"; +import Chip from "@mui/material/Chip"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { formatJstTime, formatTokens } from "@/lib/format"; +import type { GameSample } from "@/lib/types"; + +export default function GameHeader({ data }: { data: GameSample }) { + const totals = aggregate(data); + const victoryColor = + data.game.victory === "village" + ? "success" + : data.game.victory === "wolf" + ? "error" + : "default"; + + return ( + + + + + game id + + + {data.game.id} + + + + + {data.game.victory != null && ( + + )} + + + + + {data.game.ended_at_ms && ( + + )} + + + + + + + ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + + {value} + + + ); +} + +function aggregate(data: GameSample) { + let totalTokens = 0; + let totalLatencyMs = 0; + for (const t of data.trace) { + totalLatencyMs += t.latency_ms; + totalTokens += t.tokens?.total ?? 0; + } + return { + callCount: data.trace.length, + totalTokens, + totalLatencyMs, + }; +} diff --git a/viewer/src/components/GameView.tsx b/viewer/src/components/GameView.tsx new file mode 100644 index 0000000..c01b657 --- /dev/null +++ b/viewer/src/components/GameView.tsx @@ -0,0 +1,145 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Chip from "@mui/material/Chip"; +import Container from "@mui/material/Container"; +import Stack from "@mui/material/Stack"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import ClaimHistoryPanel from "@/components/ClaimHistoryPanel"; +import SuspicionPanel from "@/components/SuspicionPanel"; +import GameHeader from "@/components/GameHeader"; +import PhaseSection from "@/components/PhaseSection"; +import SeatGrid from "@/components/SeatGrid"; +import StatsPanel from "@/components/StatsPanel"; +import TraceDrawer from "@/components/TraceDrawer"; +import { buildMatchMaps, type MatchMaps } from "@/lib/match"; +import type { GameSample, TraceEntry } from "@/lib/types"; + +export default function GameView({ + data, + matches, + traceFetcher, + backHref, + sampleBadge = false, +}: { + data: GameSample; + /** + * Precomputed `(eventKey → traceIndex)` map. Required because the + * client never reruns the matchers — we ship the lookup table from + * the server load step (see `lib/data.ts::loadGameWithMatches`). Set + * to `null` (sample page) to fall through to in-browser computation. + */ + matches: MatchMaps | null; + /** + * Lazy heavy-field loader for the trace drawer. Provided when the + * page strips ``system_prompt`` / ``user_prompt`` / ``response`` from + * the SSR payload to keep the initial HTML small. ``null`` = the + * trace already carries its heavy fields (sample page) and the + * drawer renders synchronously. + */ + traceFetcher?: + | ((index: number) => Promise<{ + system_prompt: string; + user_prompt: string; + response: string | null; + }>) + | null; + backHref?: string; + sampleBadge?: boolean; +}) { + const [openTrace, setOpenTrace] = React.useState<{ + entry: TraceEntry; + index: number; + } | null>(null); + + // Sample page passes `matches=null` (it doesn't pre-slim) — fall + // through to in-browser computation so the lightbulb buttons keep + // working without a server round-trip on the static demo. + const resolvedMatches: MatchMaps = React.useMemo(() => { + if (matches !== null) return matches; + return buildMatchMaps( + data.phases, + data.trace, + data.arbiter_decisions ?? [], + data.seats, + ); + }, [data, matches]); + + // Index lookup keeps drawer's lazy fetch keyed on the canonical + // position in `data.trace[]`. Reference equality on TraceEntry is + // what the server precompute used to assign indices, so we mirror + // that here with `indexOf`. + const handleOpen = React.useCallback( + (entry: TraceEntry) => { + const index = data.trace.indexOf(entry); + if (index >= 0) setOpenTrace({ entry, index }); + }, + [data.trace], + ); + + return ( + + + {backHref && ( + + )} + {sampleBadge && ( + + )} + + + + + + {data.phases.map((phase) => ( + + ))} + + + + + + + + + + setOpenTrace(null)} + /> + + ); +} diff --git a/viewer/src/components/GamesTable.tsx b/viewer/src/components/GamesTable.tsx new file mode 100644 index 0000000..d31477a --- /dev/null +++ b/viewer/src/components/GamesTable.tsx @@ -0,0 +1,400 @@ +"use client"; + +import Link from "next/link"; +import { useDeferredValue, useMemo, useState, type ReactNode } from "react"; +import Box from "@mui/material/Box"; +import Chip from "@mui/material/Chip"; +import InputAdornment from "@mui/material/InputAdornment"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import type { TableCellProps } from "@mui/material/TableCell"; +import TableHead from "@mui/material/TableHead"; +import TablePagination from "@mui/material/TablePagination"; +import TableRow from "@mui/material/TableRow"; +import TableSortLabel from "@mui/material/TableSortLabel"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import SearchIcon from "@mui/icons-material/Search"; +import { + formatDuration, + formatJstDate, + formatLatency, + formatTokens, +} from "@/lib/format"; +import type { GameSummary } from "@/lib/data"; + +// Columns the table can sort on. Pinned at module scope so the +// header / row code stays in lock-step with the comparator switch. +type SortKey = + | "id" + | "victory" + | "discussion_mode" + | "created_at_ms" + | "duration_ms" + | "seat_count" + | "human_count" + | "llm_count" + | "llm_call_count" + | "total_tokens" + | "total_latency_ms"; + +type SortDir = "asc" | "desc"; + +interface ColumnDef { + key: SortKey; + label: string; + align?: TableCellProps["align"]; +} + +const COLUMNS: readonly ColumnDef[] = [ + { key: "id", label: "Game ID" }, + { key: "victory", label: "勝敗" }, + { key: "discussion_mode", label: "モード" }, + { key: "created_at_ms", label: "開始 (JST)" }, + { key: "duration_ms", label: "所要", align: "right" }, + { key: "seat_count", label: "席", align: "center" }, + { key: "human_count", label: "人間", align: "right" }, + { key: "llm_count", label: "LLM", align: "right" }, + { key: "llm_call_count", label: "LLM 呼び出し", align: "right" }, + { key: "total_tokens", label: "合計トークン", align: "right" }, + { key: "total_latency_ms", label: "合計レイテンシ", align: "right" }, +]; + +const VICTORY_RANK: Record = { + village: 0, + wolf: 1, +}; + +const ROWS_PER_PAGE_OPTIONS = [10, 25, 50, 100] as const; + +export default function GamesTable({ games }: { games: GameSummary[] }) { + // Sort defaults to ``created_at_ms desc`` so the freshest game stays + // on top — matches the previous always-newest-first behavior the page + // had before it became sortable. + const [sortKey, setSortKey] = useState("created_at_ms"); + const [sortDir, setSortDir] = useState("desc"); + const [search, setSearch] = useState(""); + // ``useDeferredValue`` keeps typing latency low when filtering hundreds + // of rows — React paints the input update before the (potentially) + // expensive filter+sort+slice runs. + const deferredSearch = useDeferredValue(search); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + + const filtered = useMemo(() => { + const needle = deferredSearch.trim().toLowerCase(); + if (!needle) return games; + return games.filter((g) => g.id.toLowerCase().includes(needle)); + }, [games, deferredSearch]); + + const sorted = useMemo(() => { + const copy = filtered.slice(); + copy.sort(makeComparator(sortKey, sortDir)); + return copy; + }, [filtered, sortKey, sortDir]); + + // Clamp the page when the filter shrinks the result set below the + // current offset. Otherwise ``TablePagination`` would happily render + // a page with zero rows and the user has to click "<" to recover. + const maxPage = Math.max(0, Math.ceil(sorted.length / rowsPerPage) - 1); + const safePage = Math.min(page, maxPage); + const pageStart = safePage * rowsPerPage; + const pageRows = sorted.slice(pageStart, pageStart + rowsPerPage); + + if (games.length === 0) { + return null; // empty state is rendered by the page + } + + const handleSort = (key: SortKey) => { + setPage(0); + if (sortKey === key) { + // Same column → toggle direction. + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + return; + } + // New column → start at desc. Every subsequent click on the same + // header just flips between desc and asc, matching the typical + // spreadsheet/JIRA/GitHub-style "first click is desc, click again + // for asc" interaction. + setSortKey(key); + setSortDir("desc"); + }; + + return ( + + { + setSearch(e.target.value); + setPage(0); + }} + placeholder="Game ID で検索 (部分一致)" + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ maxWidth: 360 }} + /> + + +
+ + + {COLUMNS.map((col) => ( + + handleSort(col.key)} + > + {col.label} + + + ))} + + + + {pageRows.length === 0 ? ( + + + + 一致するゲームがありません + + + + ) : ( + pageRows.map((g) => ) + )} + +
+ setPage(p)} + rowsPerPage={rowsPerPage} + onRowsPerPageChange={(e) => { + setRowsPerPage(parseInt(e.target.value, 10)); + setPage(0); + }} + rowsPerPageOptions={Array.from(ROWS_PER_PAGE_OPTIONS)} + labelRowsPerPage="表示件数" + labelDisplayedRows={({ from, to, count }) => + `${from}–${to} / ${count}` + } + /> + + + ); +} + +/** + * One row of the games-list table. + * + * Each cell wraps its content in a Next ```` rendered as a block-level + * element so the entire cell area is clickable, while the ````/```` + * structure remains valid HTML. Wrapping the whole ```` in an + * ```` would put the anchor between ```` and ```` — the parser + * foster-parents it out and the cells collapse into one column. + */ +function GameRow({ game }: { game: GameSummary }) { + const href = `/games/${game.id}`; + return ( + td": { p: 0 } }}> + + {game.id} + + + + + + + + + {formatJstDate(game.created_at_ms)} + + + {formatDuration(game.duration_ms)} + + + {game.seat_count} + + + {game.human_count} + + + {game.llm_count} + + + {game.llm_call_count} + + + {formatTokens(game.total_tokens)} + + + {formatLatency(game.total_latency_ms)} + + + ); +} + +function LinkCell({ + href, + children, + align, + sx, +}: { + href: string; + children: ReactNode; + align?: TableCellProps["align"]; + sx?: TableCellProps["sx"]; +}) { + return ( + + + {children} + + + ); +} + +function VictoryChip({ victory }: { victory: GameSummary["victory"] }) { + if (victory === "village") { + return ; + } + if (victory === "wolf") { + return ; + } + return ; +} + +export function EmptyGamesState() { + return ( + + + ゲームの記録がありません + + + ゲームを 1 試合プレイすると、終了時または{" "} + + /wolf abort + {" "} + 実行時に +
+ 自動で viewer/games/{"{game_id}"}.json が出力されます。 +
+ + まずは仕組みを試したい場合は{" "} + + サンプルゲーム + {" "} + を開いてください。 + +
+ ); +} + +/** + * Build a typed comparator for the given sort key + direction. + * + * Most columns are numeric (timestamps, counts, totals). The three + * non-numeric ones get explicit handling: + * + * * ``id`` / ``discussion_mode`` — string compare via ``localeCompare``. + * * ``victory`` — folded onto a stable rank (``village=0``, ``wolf=1``, + * ``null=2``) so the sort is deterministic and unfinished games land + * together at one end. + * * ``duration_ms`` — null (game still running) is sorted to the end + * regardless of direction so an in-progress row never displaces a + * completed one in either view. + */ +function makeComparator( + key: SortKey, + dir: SortDir, +): (a: GameSummary, b: GameSummary) => number { + const sign = dir === "asc" ? 1 : -1; + return (a, b) => { + // Pin in-progress / unfinished rows to the bottom regardless of + // direction so an active game never displaces a completed one in + // either view. Apply BEFORE the sign multiplier so flipping the + // direction doesn't bubble nulls back to the top. + const nullCmp = compareNullsLast(key, a, b); + if (nullCmp !== null) return nullCmp; + const cmp = compareByKey(key, a, b); + return sign * cmp; + }; +} + +function compareByKey(key: SortKey, a: GameSummary, b: GameSummary): number { + switch (key) { + case "id": + return a.id.localeCompare(b.id); + case "discussion_mode": + return a.discussion_mode.localeCompare(b.discussion_mode); + case "victory": + return victoryRank(a.victory) - victoryRank(b.victory); + case "duration_ms": + // Null pairs handled by `compareNullsLast`; here both sides are + // guaranteed non-null so the cast is safe. + return (a.duration_ms as number) - (b.duration_ms as number); + default: + return (a[key] as number) - (b[key] as number); + } +} + +function victoryRank(v: GameSummary["victory"]): number { + if (v === null) return 2; + return VICTORY_RANK[v] ?? 3; +} + +/** Returns a direction-independent ordering when one side is null, or + * ``null`` when neither side needs the special-case (caller falls + * through to the regular comparator + direction sign). */ +function compareNullsLast( + key: SortKey, + a: GameSummary, + b: GameSummary, +): number | null { + if (key !== "duration_ms") return null; + const aNull = a.duration_ms === null; + const bNull = b.duration_ms === null; + if (!aNull && !bNull) return null; + if (aNull && bNull) return 0; + return aNull ? 1 : -1; +} diff --git a/viewer/src/components/PhaseSection.tsx b/viewer/src/components/PhaseSection.tsx new file mode 100644 index 0000000..8efdcf0 --- /dev/null +++ b/viewer/src/components/PhaseSection.tsx @@ -0,0 +1,529 @@ +"use client"; + +import * as React from "react"; +import Box from "@mui/material/Box"; +import Chip from "@mui/material/Chip"; +import Divider from "@mui/material/Divider"; +import IconButton from "@mui/material/IconButton"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import LightbulbIcon from "@mui/icons-material/Lightbulb"; +import { + findSeat, + formatJstTime, + formatLatency, + formatTokens, + nightActionJa, + phaseJa, + roleChipStyle, + roleJa, + seatLabel, + sourceJa, +} from "@/lib/format"; +import { + nightActionKey, + speechKey, + voteKey, + wolfChatKey, + type MatchMaps, +} from "@/lib/match"; +import type { + ArbiterDecision, + PhaseSection as PhaseSectionType, + Seat, + SpeechEvent, + TraceEntry, + Vote, +} from "@/lib/types"; + +export default function PhaseSection({ + phase, + seats, + trace, + arbiterDecisions, + matches, + onOpenTrace, +}: { + phase: PhaseSectionType; + seats: Seat[]; + trace: TraceEntry[]; + arbiterDecisions: ArbiterDecision[]; + matches: MatchMaps; + onOpenTrace: (entry: TraceEntry) => void; +}) { + const events = buildTimeline(phase, trace, arbiterDecisions, matches); + + return ( + + + + + Day {phase.day} — {phaseJa(phase.phase)} + + + {formatJstTime(phase.started_at_ms)} JST 開始 + + + + {events.length > 0 && ( + + )} + + + + {events.length === 0 ? ( + + このフェイズにイベントはありません。 + + ) : ( + } spacing={0}> + {events.map((ev, i) => ( + + ))} + + )} + + ); +} + +type WolfChatLog = NonNullable[number]; + +type TimelineEvent = + | { kind: "log"; ts: number; data: PhaseSectionType["public_logs"][number] } + | { + kind: "speech"; + ts: number; + data: SpeechEvent; + trace: TraceEntry | null; + arbiter: ArbiterDecision | null; + } + | { kind: "vote"; ts: number; data: Vote; trace: TraceEntry | null } + | { + kind: "night_action"; + ts: number; + data: PhaseSectionType["night_actions"][number]; + trace: TraceEntry | null; + } + | { + kind: "wolf_chat"; + ts: number; + data: WolfChatLog; + trace: TraceEntry | null; + }; + +function buildTimeline( + phase: PhaseSectionType, + trace: TraceEntry[], + arbiterDecisions: ArbiterDecision[], + matches: MatchMaps, +): TimelineEvent[] { + // The matchers themselves moved to `lib/match.ts` and ran server-side + // at load time — `matches.trace[eventKey]` is just the precomputed + // index into `trace[]`. Building the timeline is now an O(events) + // dictionary lookup instead of an O(events * trace) scan, and the + // client never had to ship the heavy `system_prompt` / `user_prompt` + // / `response` strings just to run the matcher. + const lookup = (key: string): TraceEntry | null => { + const idx = matches.trace[key]; + return idx == null ? null : (trace[idx] ?? null); + }; + const arbiterById = new Map( + arbiterDecisions.map((d) => [d.request_id, d]), + ); + const arbiterFor = (key: string): ArbiterDecision | null => { + const reqId = matches.arbiter[key]; + return reqId == null ? null : (arbiterById.get(reqId) ?? null); + }; + + const events: TimelineEvent[] = []; + for (const log of phase.public_logs) { + events.push({ kind: "log", ts: log.created_at_ms, data: log }); + } + for (const sp of phase.speech_events) { + const key = speechKey(sp); + events.push({ + kind: "speech", + ts: sp.created_at_ms, + data: sp, + trace: lookup(key), + arbiter: arbiterFor(key), + }); + } + for (const v of phase.votes) { + events.push({ + kind: "vote", + ts: v.submitted_at_ms, + data: v, + trace: lookup(voteKey(v)), + }); + } + for (const na of phase.night_actions) { + events.push({ + kind: "night_action", + ts: na.submitted_at_ms, + data: na, + trace: lookup(nightActionKey(na)), + }); + } + for (const wc of phase.wolf_chat_logs ?? []) { + events.push({ + kind: "wolf_chat", + ts: wc.created_at_ms, + data: wc, + trace: lookup(wolfChatKey(wc)), + }); + } + events.sort((a, b) => a.ts - b.ts); + return events; +} + +function EventRow({ + event, + seats, + onOpenTrace, +}: { + event: TimelineEvent; + seats: Seat[]; + onOpenTrace: (e: TraceEntry) => void; +}) { + const time = formatJstTime(event.ts); + + if (event.kind === "log") { + return ( + + + + + + + + {event.data.text} + + + + ); + } + + if (event.kind === "speech") { + const speaker = findSeat(seats, event.data.speaker_seat); + return ( + + + + + + {speaker ? seatLabel(speaker) : "(不明)"} + + {speaker && ( + // Real-role chip on every speech row, hue-coded per role + // (village power roles get distinct blue/purple/green; + // wolf-side red/orange) so post-game review reads the + // fake-CO contrast — e.g. a 人狼-tinted seat carrying a + // "占い師 CO" chip — without scanning the seat panel. + // See `roleChipStyle` in lib/format.ts. + + )} + + {event.data.co_declaration && ( + + )} + {event.data.stt_confidence != null && ( + + + + )} + {event.arbiter && } + + {event.data.text} + {event.data.summary && ( + + 要約: {event.data.summary} + + )} + {event.arbiter && } + + + + ); + } + + if (event.kind === "vote") { + const voter = findSeat(seats, event.data.voter_seat); + const target = findSeat(seats, event.data.target_seat); + return ( + + + + + + + {voter ? seatLabel(voter) : "?"} → {target ? seatLabel(target) : "棄権"} + + + + + + ); + } + + if (event.kind === "wolf_chat") { + const speaker = findSeat(seats, event.data.actor_seat); + return ( + + + + + + + {speaker ? seatLabel(speaker) : `席${event.data.actor_seat}`} + + + + {event.data.text} + + + + + ); + } + + // night_action + const actor = findSeat(seats, event.data.actor_seat); + const target = findSeat(seats, event.data.target_seat); + return ( + + + + + + + {actor ? seatLabel(actor) : "?"} → {target ? seatLabel(target) : "—"} + + + + + + ); +} + +function TimeCell({ time }: { time: string }) { + return ( + + {time} + + ); +} + +const ARBITER_REASON_JA: Record = { + addressed: "指名", + silent_rotation: "未発言ローテ", + lru_rotation: "LRU ローテ", + low_info_diversion: "応酬迂回", + all_demoted_fallback: "全降格フォールバック", + seat_tiebreak: "席順", +}; + +const ARBITER_REASON_TIP: Record = { + addressed: + "直前の発言で addressed_seat_no がこの NPC の席だったため最優先で選ばれた", + silent_rotation: + "このフェーズでまだ発言していない NPC を優先して選んだ", + lru_rotation: + "全員が一度発言済み — 直前の話者を除外し、席番号の若い順で選んだ", + low_info_diversion: + "2席だけが応酬し情報が増えていない (CO等なし) ため、両者を降格して別の NPC を選んだ", + all_demoted_fallback: + "降格対象しか候補が残っていなかったため、降格対象から拾った (オフライン等で第3者不在の縮退)", + seat_tiebreak: + "他の候補が無く、席番号の若い順で選んだ (通常 1 NPC のみ生存時の縮退)", +}; + +function ArbiterChip({ decision }: { decision: ArbiterDecision }) { + const label = decision.selection_reason + ? ARBITER_REASON_JA[decision.selection_reason] ?? decision.selection_reason + : "発話選定"; + const tip = + decision.selection_reason && + ARBITER_REASON_TIP[decision.selection_reason] + ? ARBITER_REASON_TIP[decision.selection_reason] + : "Master が SpeakRequest を送出した記録"; + return ( + + + + ); +} + +function ArbiterDetail({ decision }: { decision: ArbiterDecision }) { + const snap = decision.public_state_snapshot; + const addressed = + snap && typeof snap.last_addressed_seat === "number" + ? `席${snap.last_addressed_seat}` + : "なし"; + const silent = Array.isArray(snap?.silent_seats) + ? `[${(snap!.silent_seats as number[]).join(", ")}]` + : "—"; + const onlineNpcs = Array.isArray(snap?.online_npc_seats) + ? `[${(snap!.online_npc_seats as number[]).join(", ")}]` + : "—"; + const latencyMs = + decision.result_received_at_ms != null + ? decision.result_received_at_ms - decision.created_at_ms + : null; + const playbackMs = + decision.playback_finished_at_ms != null && + decision.result_received_at_ms != null + ? decision.playback_finished_at_ms - decision.result_received_at_ms + : null; + const status = decision.result_status ?? "in-flight"; + const failure = + decision.result_failure_reason ?? + decision.playback_failure_reason ?? + null; + return ( + + + addressed={addressed} silent={silent} online_npcs={onlineNpcs} + + + result={status} + {latencyMs != null && ` (LLM ${latencyMs}ms)`} + {playbackMs != null && ` / 再生 ${playbackMs}ms`} + {decision.tts_outcome && ` / TTS ${decision.tts_outcome}`} + {failure && ( + + ・失敗理由: {failure} + + )} + + + ); +} + +function TraceButton({ + entry, + onOpen, +}: { + entry: TraceEntry | null; + onOpen: (e: TraceEntry) => void; +}) { + if (!entry) { + return ; + } + const tokens = entry.tokens?.total ?? null; + return ( + +
{entry.model}
+
tokens: {formatTokens(tokens)}
+
latency: {formatLatency(entry.latency_ms)}
+
+ } + > + onOpen(entry)} + aria-label="show LLM prompt" + > + + + + ); +} diff --git a/viewer/src/components/SeatGrid.tsx b/viewer/src/components/SeatGrid.tsx new file mode 100644 index 0000000..91c8ed7 --- /dev/null +++ b/viewer/src/components/SeatGrid.tsx @@ -0,0 +1,88 @@ +import Box from "@mui/material/Box"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Chip from "@mui/material/Chip"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { roleChipStyle, roleJa, roleTint } from "@/lib/format"; +import type { Seat } from "@/lib/types"; + +export default function SeatGrid({ seats }: { seats: Seat[] }) { + return ( + + {seats.map((seat) => ( + + ))} + + ); +} + +function SeatCard({ seat }: { seat: Seat }) { + // Per-role tinting: each of seer / medium / knight gets its own + // colour (alongside the existing wolf-red and madman-orange) so + // the seat panel scans visually the same way as the timeline's + // role chip. Helpers in lib/format.ts keep the hue palette in one + // place across PhaseSection, ClaimHistoryPanel, and this card. + const tint = roleTint(seat.role); + const dim = !seat.alive; + + return ( + + + + + 席{seat.seat_no} + + + + + {seat.display_name} + + + + {seat.persona_key && ( + + )} + + {!seat.alive && ( + + {seat.death_cause === "EXECUTION" + ? `${seat.death_day}日目に処刑` + : seat.death_cause === "ATTACK" + ? `${seat.death_day}日目朝に襲撃死` + : "死亡"} + + )} + + + ); +} diff --git a/viewer/src/components/StatsPanel.tsx b/viewer/src/components/StatsPanel.tsx new file mode 100644 index 0000000..1e57298 --- /dev/null +++ b/viewer/src/components/StatsPanel.tsx @@ -0,0 +1,156 @@ +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import Typography from "@mui/material/Typography"; +import { formatLatency, formatTokens, seatLabel } from "@/lib/format"; +import type { GameSample, Seat, TraceEntry } from "@/lib/types"; + +export default function StatsPanel({ data }: { data: GameSample }) { + const perSeat = aggregatePerSeat(data.trace, data.seats); + const perPhase = aggregatePerPhase(data.trace); + + return ( + + + + 席ごとの LLM 利用 + + + + + + 呼び出し + prompt tok + completion tok + total tok + latency 合計 + latency 平均 + + + + {perSeat.map((row) => ( + + {seatLabel(row.seat)} + {row.count} + {formatTokens(row.promptTokens)} + {formatTokens(row.completionTokens)} + {formatTokens(row.totalTokens)} + {formatLatency(row.latencyMs)} + + {row.count > 0 ? formatLatency(Math.round(row.latencyMs / row.count)) : "—"} + + + ))} + +
+
+ + + + フェイズごとの LLM 利用 + + + + + day + phase + 呼び出し + total tok + latency 合計 + + + + {perPhase.map((row) => ( + + {row.day} + {row.phase} + {row.count} + {formatTokens(row.totalTokens)} + {formatLatency(row.latencyMs)} + + ))} + +
+
+
+ ); +} + +interface SeatStats { + seat: Seat; + count: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; + latencyMs: number; +} + +function aggregatePerSeat(trace: TraceEntry[], seats: Seat[]): SeatStats[] { + const bySeat = new Map(); + for (const seat of seats) { + bySeat.set(seat.seat_no, { + seat, + count: 0, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + latencyMs: 0, + }); + } + for (const t of trace) { + const seatNo = parseSeatFromActor(t.actor); + if (seatNo == null) continue; + const s = bySeat.get(seatNo); + if (!s) continue; + s.count += 1; + s.promptTokens += t.tokens?.prompt ?? 0; + s.completionTokens += t.tokens?.completion ?? 0; + s.totalTokens += t.tokens?.total ?? 0; + s.latencyMs += t.latency_ms; + } + return Array.from(bySeat.values()).sort((a, b) => a.seat.seat_no - b.seat.seat_no); +} + +interface PhaseStats { + day: number; + phase: string; + count: number; + totalTokens: number; + latencyMs: number; +} + +function aggregatePerPhase(trace: TraceEntry[]): PhaseStats[] { + const byPhase = new Map(); + for (const t of trace) { + if (t.day == null || !t.phase) continue; + const phaseKey = t.phase.includes("::") + ? t.phase.split("::")[2] ?? t.phase + : t.phase; + const k = `${t.day}|${phaseKey}`; + const cur = byPhase.get(k) ?? { + day: t.day, + phase: phaseKey, + count: 0, + totalTokens: 0, + latencyMs: 0, + }; + cur.count += 1; + cur.totalTokens += t.tokens?.total ?? 0; + cur.latencyMs += t.latency_ms; + byPhase.set(k, cur); + } + return Array.from(byPhase.values()).sort((a, b) => { + if (a.day !== b.day) return a.day - b.day; + return a.phase.localeCompare(b.phase); + }); +} + +function parseSeatFromActor(actor: string | null): number | null { + if (!actor) return null; + const m = actor.match(/seat=(\d+)/); + return m ? Number(m[1]) : null; +} diff --git a/viewer/src/components/SuspicionPanel.tsx b/viewer/src/components/SuspicionPanel.tsx new file mode 100644 index 0000000..7116a2e --- /dev/null +++ b/viewer/src/components/SuspicionPanel.tsx @@ -0,0 +1,400 @@ +"use client"; + +/** + * SuspicionPanel — public suspicion timeline + (suspecter, target) matrix. + * + * Why this panel exists + * --------------------- + * Discussion-time speeches and vote decisions both emit structured + * `Suspicion` records with (target_seat, level, reason). The bot + * persists every entry to the immutable `suspicions` table so: + * + * - LLMs that silently reverse a prior level (e.g. trust → high + * without setting `update_from_level`) are detectable evidence + * of fabrication. + * - The viewer can render *who suspected whom and how strongly* as + * both a chronological timeline (left) and a pivot matrix (right), + * letting a post-game reviewer see emerging consensus and outliers + * at a glance. + * + * Pure presentation: reads `data.suspicions` (pre-folded by the + * exporter) plus `data.seats` for name resolution. Older exports + * (pre-2026-05-03) lack `suspicions` and the panel collapses to a + * compact "no suspicion records" state. + */ + +import * as React from "react"; +import Box from "@mui/material/Box"; +import Chip from "@mui/material/Chip"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import Tab from "@mui/material/Tab"; +import Tabs from "@mui/material/Tabs"; +import Typography from "@mui/material/Typography"; +import type { GameSample, Seat, SuspicionEntry, SuspicionLevel } from "@/lib/types"; + +const LEVEL_LABEL: Record = { + trust: "信頼", + low: "弱疑", + medium: "疑", + high: "強疑", +}; + +// Color the level with a small ramp so the matrix reads as a heatmap. +// trust=cool, high=warm. Background uses MUI palette tokens for +// dark/light theme parity. +const LEVEL_BG: Record = { + trust: "rgba(46, 204, 113, 0.18)", + low: "rgba(241, 196, 15, 0.16)", + medium: "rgba(230, 126, 34, 0.22)", + high: "rgba(231, 76, 60, 0.28)", +}; + +const LEVEL_FG: Record = { + trust: "#27ae60", + low: "#d4ac0d", + medium: "#d35400", + high: "#c0392b", +}; + +const SOURCE_LABEL: Record = { + speech: "発言", + vote: "投票", +}; + +function formatPhase(phase: string, voteRound: number | null): string { + if (phase === "DAY_DISCUSSION") return "議論"; + if (phase === "DAY_VOTE") return voteRound === 1 ? "決選投票" : "投票"; + if (phase === "DAY_RUNOFF") return "決選投票"; + if (phase === "DAY_RUNOFF_SPEECH") return "決選演説"; + if (phase === "NIGHT" || phase === "NIGHT_0") { + return voteRound === -1 ? "夜・占い" : "夜"; + } + return phase; +} + +function levelChip(level: SuspicionLevel) { + return ( + + ); +} + +function seatLookup(seats: readonly Seat[]): Record { + const out: Record = {}; + for (const s of seats) out[s.seat_no] = s.display_name; + return out; +} + +interface MatrixCell { + level: SuspicionLevel; + reason: string; + day: number; + source: "speech" | "vote"; +} + +/** + * Build the (suspecter, target) → latest-cell pivot. We pick the LAST + * row chronologically so the matrix reflects each speaker's currently- + * declared opinion. Earlier rows are still visible in the timeline tab. + */ +function buildMatrix( + suspicions: readonly SuspicionEntry[], +): Map> { + const matrix = new Map>(); + for (const s of suspicions) { + let row = matrix.get(s.suspecter_seat); + if (!row) { + row = new Map(); + matrix.set(s.suspecter_seat, row); + } + row.set(s.target_seat, { + level: s.level, + reason: s.reason, + day: s.day, + source: s.source, + }); + } + return matrix; +} + +function TimelineTab({ + suspicions, + names, +}: { + suspicions: readonly SuspicionEntry[]; + names: Record; +}) { + return ( + + {suspicions.map((s, idx) => { + const sname = names[s.suspecter_seat] ?? `席${s.suspecter_seat}`; + const tname = names[s.target_seat] ?? `席${s.target_seat}`; + const phase = formatPhase(s.phase, s.vote_round); + const updated = s.update_from_level !== null; + return ( + + + + + + {sname} + + + → + + + {tname} + + {levelChip(s.level)} + {updated && ( + + )} + + + {s.reason} + + {updated && s.update_reason && ( + + 更新理由: {s.update_reason} + + )} + + ); + })} + + ); +} + +function MatrixTab({ + suspicions, + seats, + names, +}: { + suspicions: readonly SuspicionEntry[]; + seats: readonly Seat[]; + names: Record; +}) { + const matrix = buildMatrix(suspicions); + // Stable seat order for both axes — by seat_no so deciphering with the + // SeatGrid above is easy. + const sortedSeats = [...seats].sort((a, b) => a.seat_no - b.seat_no); + return ( + + + + + + {sortedSeats.map((s) => ( + + ))} + + + + {sortedSeats.map((suspecter) => ( + + + {sortedSeats.map((target) => { + if (suspecter.seat_no === target.seat_no) { + return ( + + ); + } + const cell = matrix + .get(suspecter.seat_no) + ?.get(target.seat_no); + if (!cell) { + return ( + + ); + } + return ( + + ); + })} + + ))} + +
+ 疑う側 \ 対象 + + {names[s.seat_no] ?? `席${s.seat_no}`} +
+ {names[suspecter.seat_no] ?? `席${suspecter.seat_no}`} + + — + + · + + + {LEVEL_LABEL[cell.level]} + +
+ + 各セルは「疑う側 (行) → 対象 (列)」の最新評価。セルにマウスオーバーで day と理由を表示。`·` は未評価、`—` は自席。 + +
+ ); +} + +export default function SuspicionPanel({ data }: { data: GameSample }) { + const suspicions = data.suspicions ?? []; + const [tab, setTab] = React.useState<"timeline" | "matrix">("matrix"); + + if (suspicions.length === 0) { + return ( + + 疑い履歴 + + このゲームには構造化された疑い記録がありません (古いゲームか、まだ発話/投票が無い)。 + + + ); + } + const names = seatLookup(data.seats); + + return ( + + + 疑い履歴 ({suspicions.length} 件) + setTab(v)} + sx={{ minHeight: 32 }} + > + + + + + {tab === "matrix" ? ( + + ) : ( + + )} + + ); +} diff --git a/viewer/src/components/ThemeRegistry.tsx b/viewer/src/components/ThemeRegistry.tsx new file mode 100644 index 0000000..f5e5c47 --- /dev/null +++ b/viewer/src/components/ThemeRegistry.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { ThemeProvider, createTheme } from "@mui/material/styles"; + +const theme = createTheme({ + palette: { + mode: "light", + primary: { main: "#3f51b5" }, + secondary: { main: "#9c27b0" }, + background: { default: "#fafafa", paper: "#ffffff" }, + }, + typography: { + fontFamily: + '-apple-system, BlinkMacSystemFont, "Hiragino Sans", "Yu Gothic UI", "Segoe UI", Roboto, sans-serif', + fontSize: 13, + }, + shape: { borderRadius: 6 }, +}); + +export default function ThemeRegistry({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/viewer/src/components/TraceDrawer.tsx b/viewer/src/components/TraceDrawer.tsx new file mode 100644 index 0000000..991b6d1 --- /dev/null +++ b/viewer/src/components/TraceDrawer.tsx @@ -0,0 +1,312 @@ +"use client"; + +import * as React from "react"; +import Box from "@mui/material/Box"; +import Chip from "@mui/material/Chip"; +import CircularProgress from "@mui/material/CircularProgress"; +import Drawer from "@mui/material/Drawer"; +import IconButton from "@mui/material/IconButton"; +import Stack from "@mui/material/Stack"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import CloseIcon from "@mui/icons-material/Close"; +import { formatJstTime, formatLatency, formatTokens } from "@/lib/format"; +import type { TraceEntry } from "@/lib/types"; + +/** + * Heavy fields the drawer renders. Sourced either from `entry` itself + * (sample page, no slimming) or fetched lazily from + * `/api/games/[gameId]/trace/[index]` on first open. + */ +interface HeavyFields { + system_prompt: string; + user_prompt: string; + response: string | null; +} + +export type TraceFetcher = (index: number) => Promise; + +export default function TraceDrawer({ + entry, + index, + fetcher, + onClose, +}: { + entry: TraceEntry | null; + index: number | null; + /** + * Lazy-load callback for heavy fields. ``null`` ⇒ the entry already + * carries them (no fetch needed); otherwise the drawer calls + * `fetcher(index)` once on open and shows a spinner until it + * resolves. Errors render an inline message; the user can close and + * retry. + */ + fetcher: TraceFetcher | null; + onClose: () => void; +}) { + return ( + + {entry !== null && ( + + )} + + ); +} + +function Body({ + entry, + index, + fetcher, + onClose, +}: { + entry: TraceEntry; + index: number | null; + fetcher: TraceFetcher | null; + onClose: () => void; +}) { + const ts = (() => { + try { + return formatJstTime(new Date(entry.ts).getTime()); + } catch { + return entry.ts; + } + })(); + + // Decide whether the entry is already loaded with its heavy strings + // or if we need to lazy-fetch them. The slim payload puts empty + // strings in `system_prompt` / `user_prompt`; if the original was + // genuinely empty (rare, but theoretically possible for a malformed + // call) we'd still try to fetch — that just returns an empty + // payload, harmless. The boolean is cheap to compute and cleaner + // than threading an explicit `slim: true` flag through the prop + // chain. + const needsFetch = + fetcher !== null && + index !== null && + entry.system_prompt === "" && + entry.user_prompt === ""; + + const [heavy, setHeavy] = React.useState(null); + const [error, setError] = React.useState(null); + const [loading, setLoading] = React.useState(false); + + React.useEffect(() => { + // Reset on entry change so a previous payload doesn't bleed across + // drawer-open events. Skip the fetch when the entry already has + // its heavy fields. + setHeavy(null); + setError(null); + if (!needsFetch) return; + setLoading(true); + let cancelled = false; + fetcher!(index!) + .then((data) => { + if (!cancelled) setHeavy(data); + }) + .catch((err: unknown) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [entry, index, needsFetch, fetcher]); + + // Pull from `heavy` when the lazy fetch resolved, otherwise from + // `entry` (sample-page / non-slim path). The fallback mirrors the + // pre-refactor behavior so the sample page renders identically. + const systemPrompt = heavy?.system_prompt ?? entry.system_prompt; + const userPrompt = heavy?.user_prompt ?? entry.user_prompt; + const response = heavy?.response ?? entry.response; + + return ( + + + + LLM 呼び出し詳細 + + + + + + + + + + + {entry.day != null && ( + + )} + {entry.phase && ( + + )} + {typeof entry.metadata?.task === "string" && ( + + )} + + + + + + + + + + + {entry.actor && ( + + + {entry.actor} + + + )} + + {entry.error && ( + + + ERROR + +
{entry.error}
+
+ )} + + {error && ( + + + プロンプトの読み込みに失敗 + +
{error}
+
+ )} + +
+ +
+
+ +
+
+ +
+ {entry.metadata && Object.keys(entry.metadata).length > 0 && ( +
+
{JSON.stringify(entry.metadata, null, 2)}
+
+ )} +
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + + {value} + + + ); +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + + {title} + + + {children} + + + ); +} + +function PromptBody({ loading, text }: { loading: boolean; text: string }) { + if (loading) { + return ( + + + 読み込み中… + + ); + } + return
{text}
; +} diff --git a/viewer/src/lib/data.ts b/viewer/src/lib/data.ts new file mode 100644 index 0000000..f464dc2 --- /dev/null +++ b/viewer/src/lib/data.ts @@ -0,0 +1,243 @@ +import { readFile, readdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { buildMatchMaps, type MatchMaps } from "./match"; +import type { DiscussionMode, GameSample, TraceEntry } from "./types"; + +/** + * Where exported game JSONs live. Populated automatically by the bot's + * ``GameService._on_game_end_finalize`` hook at the end of every game + * (victory or ``/wolf abort``). + * + * The viewer treats this directory as its database — the list page + * scans it; detail pages read a specific file from it. + */ +const GAMES_DIR = "games"; +const SAMPLE_PATH = path.join("sample-data", "game-sample.json"); + +/** One row in the top-level games-list table. */ +export interface GameSummary { + id: string; + discussion_mode: DiscussionMode; + victory: "village" | "wolf" | null; + created_at_ms: number; + ended_at_ms: number | null; + duration_ms: number | null; + seat_count: number; + /** Number of seats whose ``is_llm`` is false (i.e., real Discord users). */ + human_count: number; + /** Number of seats whose ``is_llm`` is true (NPC personas). */ + llm_count: number; + llm_call_count: number; + total_tokens: number; + total_latency_ms: number; + file_mtime_ms: number; +} + +/** List every exported game, newest first by mtime. */ +export async function listGames(): Promise { + const exportsDir = path.resolve(process.cwd(), GAMES_DIR); + let names: string[]; + try { + names = await readdir(exportsDir); + } catch { + return []; + } + const jsonFiles = names.filter((n) => n.endsWith(".json")); + if (jsonFiles.length === 0) return []; + + const summaries = await Promise.all( + jsonFiles.map(async (name) => { + const fullPath = path.join(exportsDir, name); + try { + const [raw, st] = await Promise.all([ + readFile(fullPath, "utf-8"), + stat(fullPath), + ]); + const data = JSON.parse(raw) as GameSample; + return summarize(data, st.mtimeMs); + } catch { + return null; + } + }), + ); + return summaries + .filter((s): s is GameSummary => s !== null) + .sort((a, b) => b.file_mtime_ms - a.file_mtime_ms); +} + +/** + * Load one exported game by id, or ``null`` if the file is missing. + * + * The bot writes files as ``YYYY-MM-DD_HH-MM-SS.json`` (sortable by play + * time, see ``services/game_export.py``) — the file name does **not** + * encode the internal ``game.id``. To resolve a link, fast-path on + * ``{gameId}.json`` (legacy / forward-compat) and otherwise scan the + * directory for a JSON whose ``game.id`` matches. + */ +export async function loadGameById( + gameId: string, +): Promise { + // Defensive — never resolve outside the games dir even if a path-traversal + // id slips in. + if (gameId.includes("/") || gameId.includes("..")) return null; + const exportsDir = path.resolve(process.cwd(), GAMES_DIR); + + // Fast path — legacy ``{id}.json`` layout. + const legacy = path.join(exportsDir, `${gameId}.json`); + try { + const raw = await readFile(legacy, "utf-8"); + return JSON.parse(raw) as GameSample; + } catch { + // fall through to directory scan + } + + // Directory scan — match ``game.id`` inside each JSON. + let names: string[]; + try { + names = await readdir(exportsDir); + } catch { + return null; + } + const candidates = names.filter((n) => n.endsWith(".json")); + for (const name of candidates) { + const fullPath = path.join(exportsDir, name); + try { + const raw = await readFile(fullPath, "utf-8"); + const data = JSON.parse(raw) as GameSample; + if (data.game.id === gameId) { + return data; + } + } catch { + // skip unreadable / malformed files + } + } + return null; +} + +/** Load the bundled sample game (opt-in via the ``/sample`` route). */ +export async function loadSample(): Promise { + const target = path.resolve(process.cwd(), SAMPLE_PATH); + const raw = await readFile(target, "utf-8"); + return JSON.parse(raw) as GameSample; +} + +/** + * What `loadGameWithMatches` returns. + * + * `data.trace[i]` has the heavy fields (`system_prompt`, `user_prompt`, + * `response`) replaced by empty strings / null. The full versions are + * available via the `/api/games/[gameId]/trace/[index]` route. This + * trims hundreds of KB to several MB off the SSR HTML payload for + * games with non-trivial trace counts — historically the dominant + * cost of opening a game detail page. + * + * `matches.trace[eventKey]` returns the index into `data.trace` (i.e. + * the position of the matched trace entry). PhaseSection consumes this + * map and never re-runs the matchers client-side. `matches.arbiter` + * mirrors the same shape for speech → arbiter dispatch lookup. + * + * Returns `null` when the game JSON does not exist. + */ +export interface GameWithMatches { + data: GameSample; + matches: MatchMaps; +} + +export async function loadGameWithMatches( + gameId: string, +): Promise { + const data = await loadGameById(gameId); + if (data === null) return null; + // Run the matchers BEFORE slimming so they see the heavy fields. + const matches = buildMatchMaps( + data.phases, + data.trace, + data.arbiter_decisions ?? [], + data.seats, + ); + return { data: { ...data, trace: slimTrace(data.trace) }, matches }; +} + +/** + * Heavy-field accessor used by the lazy-load API route. Re-reads the + * source JSON because slimmed copies don't keep the original strings + * around — the cost of one cold disk read per drawer-open is dwarfed + * by the SSR payload savings on every page load. + */ +export interface TraceHeavyFields { + system_prompt: string; + user_prompt: string; + response: string | null; +} + +export async function loadTraceHeavyFields( + gameId: string, + index: number, +): Promise { + const data = await loadGameById(gameId); + if (data === null) return null; + if (!Number.isInteger(index) || index < 0 || index >= data.trace.length) { + return null; + } + const t = data.trace[index]; + return { + system_prompt: t.system_prompt, + user_prompt: t.user_prompt, + response: t.response, + }; +} + +function slimTrace(trace: TraceEntry[]): TraceEntry[] { + // Replace heavy strings with empty placeholders rather than + // ``undefined`` so the client type stays the same and existing + // ``entry.system_prompt`` accesses stay valid until they explicitly + // opt into the lazy fetch. + return trace.map((t) => ({ + ...t, + system_prompt: "", + user_prompt: "", + response: t.response === null ? null : "", + })); +} + +function summarize(data: GameSample, mtimeMs: number): GameSummary { + let totalTokens = 0; + let totalLatencyMs = 0; + for (const t of data.trace) { + totalLatencyMs += t.latency_ms; + totalTokens += t.tokens?.total ?? 0; + } + const duration = + data.game.ended_at_ms != null + ? data.game.ended_at_ms - data.game.created_at_ms + : null; + let humanCount = 0; + let llmCount = 0; + for (const seat of data.seats) { + if (seat.is_llm) { + llmCount += 1; + } else { + humanCount += 1; + } + } + return { + id: data.game.id, + discussion_mode: data.game.discussion_mode, + victory: data.game.victory, + created_at_ms: data.game.created_at_ms, + ended_at_ms: data.game.ended_at_ms, + duration_ms: duration, + seat_count: data.seats.length, + human_count: humanCount, + llm_count: llmCount, + llm_call_count: data.trace.length, + total_tokens: totalTokens, + total_latency_ms: totalLatencyMs, + file_mtime_ms: mtimeMs, + }; +} + +/** Stable phase key suitable for React keys + URL hashes. */ +export function phaseKey(day: number, phase: string): string { + return `d${day}-${phase}`; +} diff --git a/viewer/src/lib/format.ts b/viewer/src/lib/format.ts new file mode 100644 index 0000000..a52127f --- /dev/null +++ b/viewer/src/lib/format.ts @@ -0,0 +1,182 @@ +import type { RoleKey, Seat } from "./types"; + +const ROLE_JA: Record = { + VILLAGER: "村人", + WEREWOLF: "人狼", + MADMAN: "狂人", + SEER: "占い師", + MEDIUM: "霊媒師", + KNIGHT: "騎士", +}; + +export function roleJa(role: RoleKey): string { + return ROLE_JA[role] ?? role; +} + +const ROLE_FACTION: Record = { + VILLAGER: "village", + SEER: "village", + MEDIUM: "village", + KNIGHT: "village", + WEREWOLF: "wolf", + MADMAN: "wolf", +}; + +export function roleFaction(role: RoleKey): "village" | "wolf" { + return ROLE_FACTION[role] ?? "village"; +} + +// Per-role chip style (MUI Chip props). Distinct hues let post-game +// review pick out a seer / medium / knight at a glance without the +// reader memorising who claimed what — three village power roles +// share blue / purple / green respectively, with wolf-side carrying +// red (人狼) vs orange (狂人) so factions stay visually grouped while +// each role still gets its own colour. Shared between PhaseSection +// (speech row), ClaimHistoryPanel (claimer header), and SeatGrid +// (seat card) so a role's colour is consistent across the viewer. +export type RoleChipColor = + | "default" + | "primary" + | "secondary" + | "error" + | "info" + | "success" + | "warning"; +export type RoleChipVariant = "filled" | "outlined"; + +const ROLE_CHIP_STYLE: Record< + RoleKey, + { color: RoleChipColor; variant: RoleChipVariant } +> = { + VILLAGER: { color: "default", variant: "outlined" }, + SEER: { color: "info", variant: "filled" }, + MEDIUM: { color: "secondary", variant: "filled" }, + KNIGHT: { color: "success", variant: "filled" }, + WEREWOLF: { color: "error", variant: "filled" }, + MADMAN: { color: "warning", variant: "filled" }, +}; + +export function roleChipStyle(role: RoleKey): { + color: RoleChipColor; + variant: RoleChipVariant; +} { + return ROLE_CHIP_STYLE[role] ?? { color: "default", variant: "outlined" }; +} + +// Faint background + border colours for surfaces (currently the seat +// card) tinted by role. RGBA so the tint sits on top of the page +// background without overwhelming the role chip itself. Hues mirror +// `roleChipStyle` palette one-to-one. +const ROLE_TINT: Record = { + VILLAGER: { bg: "rgba(120, 144, 156, 0.06)", border: "rgba(120, 144, 156, 0.3)" }, + SEER: { bg: "rgba(33, 150, 243, 0.08)", border: "rgba(33, 150, 243, 0.4)" }, + MEDIUM: { bg: "rgba(156, 39, 176, 0.08)", border: "rgba(156, 39, 176, 0.4)" }, + KNIGHT: { bg: "rgba(76, 175, 80, 0.08)", border: "rgba(76, 175, 80, 0.4)" }, + WEREWOLF: { bg: "rgba(244, 67, 54, 0.08)", border: "rgba(244, 67, 54, 0.4)" }, + MADMAN: { bg: "rgba(255, 152, 0, 0.08)", border: "rgba(255, 152, 0, 0.4)" }, +}; + +export function roleTint(role: RoleKey): { bg: string; border: string } { + return ROLE_TINT[role] ?? ROLE_TINT.VILLAGER; +} + +const PHASE_JA: Record = { + SETUP: "セットアップ", + NIGHT_0: "初日夜 (Night 0)", + DAY_DISCUSSION: "議論", + DAY_VOTE: "投票", + DAY_RUNOFF_SPEECH: "決選演説", + DAY_RUNOFF: "決選投票", + NIGHT: "夜", + GAME_OVER: "終了", + WAITING_HOST_DECISION: "ホスト待ち", +}; + +export function phaseJa(phase: string): string { + return PHASE_JA[phase] ?? phase; +} + +const NIGHT_ACTION_JA: Record = { + ATTACK: "襲撃", + DIVINE: "占い", + GUARD: "護衛", + SEER_DIVINE: "占い", + WOLF_ATTACK: "襲撃", + KNIGHT_GUARD: "護衛", + DIVINE_NIGHT0_RANDOM_WHITE: "初日ランダム白", +}; + +export function nightActionJa(kind: string): string { + return NIGHT_ACTION_JA[kind] ?? kind; +} + +export function seatLabel(seat: Seat): string { + return `席${seat.seat_no} ${seat.display_name}`; +} + +export function findSeat(seats: Seat[], seatNo: number | null): Seat | null { + if (seatNo == null) return null; + return seats.find((s) => s.seat_no === seatNo) ?? null; +} + +/** + * Format epoch ms as ``HH:MM:SS`` in Asia/Tokyo for the timeline columns. + * Game data uses real wall-clock timestamps so JST is the natural axis. + */ +export function formatJstTime(ms: number): string { + const d = new Date(ms); + return new Intl.DateTimeFormat("ja-JP", { + timeZone: "Asia/Tokyo", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).format(d); +} + +/** ``YYYY-MM-DD HH:mm`` in JST. Used by the games-list table. */ +export function formatJstDate(ms: number): string { + const d = new Date(ms); + return new Intl.DateTimeFormat("ja-JP", { + timeZone: "Asia/Tokyo", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(d); +} + +export function formatTokens(n: number | null | undefined): string { + if (n == null) return "—"; + return n.toLocaleString("en-US"); +} + +export function formatLatency(ms: number): string { + if (ms < 1000) return `${ms} ms`; + return `${(ms / 1000).toFixed(2)} s`; +} + +/** Compact human duration (e.g. "12:34" or "1h 02m" for longer games). */ +export function formatDuration(ms: number | null): string { + if (ms == null) return "進行中"; + const totalSeconds = Math.floor(ms / 1000); + const h = Math.floor(totalSeconds / 3600); + const m = Math.floor((totalSeconds % 3600) / 60); + const s = totalSeconds % 60; + if (h > 0) { + return `${h}h ${m.toString().padStart(2, "0")}m`; + } + return `${m}:${s.toString().padStart(2, "0")}`; +} + +const SOURCE_JA: Record = { + text: "テキスト議論", + voice_stt: "音声→STT", + npc_generated: "NPC生成", +}; + +export function sourceJa(source: string): string { + return SOURCE_JA[source] ?? source; +} diff --git a/viewer/src/lib/match.ts b/viewer/src/lib/match.ts new file mode 100644 index 0000000..76a5c41 --- /dev/null +++ b/viewer/src/lib/match.ts @@ -0,0 +1,300 @@ +// Pure trace ↔ event matchers. Extracted from the original PhaseSection +// inline implementation so the server can precompute the matches at game +// load time and ship only `{eventKey: traceIndex}` to the client. The +// client never needs to look at heavy ``system_prompt`` / ``user_prompt`` +// / ``response`` strings to build the lightbulb buttons — the lookup is +// O(1) by event key. +// +// Why this lives here and not in `PhaseSection`: +// * It must be importable from a Node-only ``loadGame`` path (no React). +// * Both server (precompute) and the trace API route handler need the +// same logic to keep matches deterministic across redeploys. + +import type { + ArbiterDecision, + NightAction, + PhaseSection, + Seat, + SpeechEvent, + TraceEntry, + Vote, +} from "./types"; + +export type WolfChatLog = NonNullable[number]; + +/** + * Two parallel maps of ``eventKey → traceIndex``. Built once at server + * render time per game and shipped down with the slim payload. + * + * Keys are stable strings so the maps survive JSON serialization and + * map cleanly into ``Record`` for the wire. + * + * * ``trace`` — primary map. Speech / vote / action / wolf-chat events. + * * ``arbiter`` — secondary map. Speech events → arbiter decision id. + * Kept separate because the value type differs (request_id, not int). + */ +export interface MatchMaps { + trace: Record; + arbiter: Record; +} + +// ───────────────────────────────────────────── event-key derivation +// +// Each event-trace pair needs a deterministic string key. Speech events +// already carry a server-issued ``event_id``; the others get composed +// from their natural primary key columns. + +export function speechKey(sp: SpeechEvent): string { + return `speech:${sp.event_id}`; +} + +export function voteKey(v: Vote): string { + // (day, round, voter, target) is the unique key in the votes table. + // Include ``submitted_at_ms`` so a re-vote (same voter changes target) + // doesn't collide with the original. + return `vote:${v.day}:${v.round}:${v.voter_seat}:${v.target_seat ?? "null"}:${v.submitted_at_ms}`; +} + +export function nightActionKey(na: NightAction): string { + return `night:${na.day}:${na.actor_seat}:${na.kind}:${na.target_seat ?? "null"}:${na.submitted_at_ms}`; +} + +export function wolfChatKey(wc: WolfChatLog): string { + // No native id; compose from (actor, ts, text-prefix). Text prefix + // keeps two same-second utterances disambiguated. + return `wolfchat:${wc.actor_seat}:${wc.created_at_ms}:${wc.text.slice(0, 20)}`; +} + +// ───────────────────────────────────────────── precompute entry point + +/** + * Precompute every ``event → trace_index`` and ``speech → arbiter`` + * mapping for a game. Single linear pass over each phase; each matcher + * is the same one PhaseSection used to run client-side, lifted verbatim + * out of that file. + */ +export function buildMatchMaps( + phases: PhaseSection[], + trace: TraceEntry[], + arbiterDecisions: ArbiterDecision[], + seats: Seat[], +): MatchMaps { + const traceMap: Record = {}; + const arbiterMap: Record = {}; + + for (const phase of phases) { + for (const sp of phase.speech_events) { + const t = matchTraceForSpeech(sp, phase, trace); + if (t !== null) { + const idx = trace.indexOf(t); + if (idx >= 0) traceMap[speechKey(sp)] = idx; + } + const a = matchArbiterForSpeech(sp, phase, arbiterDecisions); + if (a !== null) arbiterMap[speechKey(sp)] = a.request_id; + } + for (const v of phase.votes) { + const t = matchTraceForVote(v, phase, trace, seats); + if (t !== null) { + const idx = trace.indexOf(t); + if (idx >= 0) traceMap[voteKey(v)] = idx; + } + } + for (const na of phase.night_actions) { + const t = matchTraceForNightAction(na, phase, trace); + if (t !== null) { + const idx = trace.indexOf(t); + if (idx >= 0) traceMap[nightActionKey(na)] = idx; + } + } + for (const wc of phase.wolf_chat_logs ?? []) { + const t = matchTraceForWolfChat(wc, phase, trace); + if (t !== null) { + const idx = trace.indexOf(t); + if (idx >= 0) traceMap[wolfChatKey(wc)] = idx; + } + } + } + return { trace: traceMap, arbiter: arbiterMap }; +} + +// ───────────────────────────────────────────── matchers (pure) + +/** + * Match an `npc_generated` SpeechEvent back to the Master-side + * `SpeakRequest` dispatch that produced it. The DB doesn't carry a + * direct foreign key, so we match on (phase_id, seat_no, result_text) + * and fall back to the latest dispatch for the seat in the phase. + */ +export function matchArbiterForSpeech( + sp: SpeechEvent, + phase: PhaseSection, + decisions: ArbiterDecision[], +): ArbiterDecision | null { + if (sp.source !== "npc_generated" || sp.speaker_seat == null) return null; + const phaseMatches = (d: ArbiterDecision) => + d.phase_id.includes(`::day${phase.day}::${phase.phase}`); + const seatMatches = (d: ArbiterDecision) => d.seat_no === sp.speaker_seat; + const exactText = + decisions.find( + (d) => + phaseMatches(d) && seatMatches(d) && d.result_text === sp.text, + ) ?? null; + if (exactText) return exactText; + const sameSeat = decisions.filter((d) => phaseMatches(d) && seatMatches(d)); + if (sameSeat.length === 0) return null; + return sameSeat.reduce((best, cur) => + cur.created_at_ms > best.created_at_ms ? cur : best, + ); +} + +/** + * Match a SpeechEvent to its trace by source-specific role + needle + * search on either the response (npc_generated) or the user prompt + * (voice_stt / text). Required heavy fields: `response`, + * `user_prompt`. Run server-side BEFORE those fields are stripped. + */ +export function matchTraceForSpeech( + sp: SpeechEvent, + phase: PhaseSection, + trace: TraceEntry[], +): TraceEntry | null { + if (sp.speaker_seat == null) return null; + const phaseMatches = (t: TraceEntry) => + t.day === phase.day && + (t.phase === phase.phase || (t.phase?.includes(phase.phase) ?? false)); + const needle = sp.text.slice(0, 20); + + if (sp.source === "npc_generated") { + return ( + trace.find( + (t) => + t.role === "npc_speech" && + phaseMatches(t) && + actorSeat(t) === sp.speaker_seat && + responseContains(t, needle), + ) ?? null + ); + } + if (sp.source === "voice_stt") { + const analyze = + trace.find( + (t) => + t.role === "voice_stt" && + phaseMatches(t) && + actorSeat(t) === sp.speaker_seat && + t.metadata?.step === "analyze" && + userPromptContains(t, needle), + ) ?? null; + if (analyze !== null) return analyze; + return ( + trace.find( + (t) => + t.role === "voice_stt" && + phaseMatches(t) && + actorSeat(t) === sp.speaker_seat && + userPromptContains(t, needle), + ) ?? null + ); + } + return ( + trace.find( + (t) => + t.role === "text_analysis" && + phaseMatches(t) && + actorSeat(t) === sp.speaker_seat && + userPromptContains(t, needle), + ) ?? null + ); +} + +export function matchTraceForVote( + v: Vote, + phase: PhaseSection, + trace: TraceEntry[], + seats: Seat[], +): TraceEntry | null { + const targetSeat = seats.find((s) => s.seat_no === v.target_seat); + if (!targetSeat) return null; + return ( + trace.find( + (t) => + isDecisionRole(t) && + phaseMatchesLoose(t, phase) && + actorSeat(t) === v.voter_seat && + t.metadata?.task === "vote" && + (t.role !== "gameplay" || responseContains(t, `席${v.target_seat}`)), + ) ?? null + ); +} + +export function matchTraceForNightAction( + na: NightAction, + phase: PhaseSection, + trace: TraceEntry[], +): TraceEntry | null { + const naKindLower = + typeof na.kind === "string" ? na.kind.toLowerCase() : null; + return ( + trace.find((t) => { + if (!isDecisionRole(t)) return false; + if (!phaseMatchesLoose(t, phase)) return false; + if (actorSeat(t) !== na.actor_seat) return false; + if (t.metadata?.task !== "night_action") return false; + const traceKind = t.metadata?.action_kind; + if (traceKind == null) return true; + return ( + typeof traceKind === "string" && + naKindLower != null && + traceKind.toLowerCase() === naKindLower + ); + }) ?? null + ); +} + +export function matchTraceForWolfChat( + wc: WolfChatLog, + phase: PhaseSection, + trace: TraceEntry[], +): TraceEntry | null { + let best: TraceEntry | null = null; + let bestDelta = Infinity; + for (const t of trace) { + if (!isDecisionRole(t)) continue; + if (!phaseMatchesLoose(t, phase)) continue; + if (actorSeat(t) !== wc.actor_seat) continue; + if (t.metadata?.task !== "wolf_chat") continue; + const ts = t.ts ? new Date(t.ts).getTime() : 0; + const delta = Math.abs(ts - wc.created_at_ms); + if (delta < bestDelta) { + best = t; + bestDelta = delta; + } + } + return best; +} + +// ───────────────────────────────────────────── helpers + +function isDecisionRole(t: TraceEntry): boolean { + return t.role === "gameplay" || t.role === "npc_decision"; +} + +function phaseMatchesLoose(t: TraceEntry, phase: PhaseSection): boolean { + if (t.day !== phase.day) return false; + if (t.phase === phase.phase) return true; + return typeof t.phase === "string" && t.phase.includes(`::${phase.phase}`); +} + +function actorSeat(t: TraceEntry): number | null { + if (!t.actor) return null; + const m = t.actor.match(/seat=(\d+)/); + return m ? Number(m[1]) : null; +} + +function responseContains(t: TraceEntry, needle: string): boolean { + return t.response != null && t.response.includes(needle); +} + +function userPromptContains(t: TraceEntry, needle: string): boolean { + return typeof t.user_prompt === "string" && t.user_prompt.includes(needle); +} diff --git a/viewer/src/lib/types.ts b/viewer/src/lib/types.ts new file mode 100644 index 0000000..8be4d14 --- /dev/null +++ b/viewer/src/lib/types.ts @@ -0,0 +1,260 @@ +// TypeScript shape of the per-game export consumed by the viewer. +// Source of truth: viewer/sample-data/generate_sample.py. + +export type RoleKey = + | "VILLAGER" + | "WEREWOLF" + | "MADMAN" + | "SEER" + | "MEDIUM" + | "KNIGHT"; + +export type DeathCause = "EXECUTION" | "ATTACK" | null; + +export type DiscussionMode = "rounds" | "reactive_voice"; + +export type SpeechSource = "text" | "voice_stt" | "npc_generated"; + +export type CoDeclaration = "seer" | "medium" | "knight" | null; + +export type TraceRole = + | "gameplay" + | "npc_speech" + | "npc_decision" + | "voice_stt" + | "text_analysis"; + +export interface Seat { + seat_no: number; + display_name: string; + is_llm: boolean; + persona_key: string | null; + discord_user_id: string | null; + role: RoleKey; + alive: boolean; + death_cause: DeathCause; + death_day: number | null; +} + +export interface PublicLog { + kind: string; + actor_seat: number | null; + text: string; + created_at_ms: number; +} + +export interface ClaimedSeerResult { + target_seat: number; + is_wolf: boolean; +} + +export interface ClaimedMediumResult { + target_seat: number; + is_wolf: boolean | null; +} + +export interface SpeechEvent { + event_id: string; + source: SpeechSource; + speaker_seat: number | null; + text: string; + stt_confidence: number | null; + summary: string | null; + co_declaration: CoDeclaration; + addressed_seat_no: number | null; + /** + * Structured seer-CO result attached to this utterance. Non-null when + * the speaker announced a NEW divination outcome (real seer or wolf + * fake-CO). The viewer renders the per-seat history (see + * `GameSample.claim_history`) on top of these. + */ + claimed_seer_result?: ClaimedSeerResult | null; + claimed_medium_result?: ClaimedMediumResult | null; + created_at_ms: number; +} + +export interface ClaimedSeerHistoryEntry { + day: number; + target_seat: number; + target_name: string; + is_wolf: boolean; + declared_at_event_id: string; +} + +export interface ClaimedMediumHistoryEntry { + day: number; + target_seat: number; + target_name: string; + is_wolf: boolean | null; + declared_at_event_id: string; +} + +export interface ClaimHistoryEntry { + claimer_seat: number; + seer_claims: ClaimedSeerHistoryEntry[]; + medium_claims: ClaimedMediumHistoryEntry[]; +} + +/** 4-step suspicion scale. + * + * - `trust`: 村寄り信頼 (white-leaning) + * - `low`: 弱い違和感 + * - `medium`: 明確に怪しい + * - `high`: 処刑第一候補 + */ +export type SuspicionLevel = "trust" | "low" | "medium" | "high"; + +export interface SuspicionEntry { + /** Origin of the row: discussion speech vs vote / night decision. */ + source: "speech" | "vote"; + /** Speech-derived rows reference their parent SpeechEvent. Vote-derived + * rows are null (no SpeechEvent backs the vote). */ + event_id: string | null; + seq: number; + day: number; + /** Phase value at write time (DAY_DISCUSSION / DAY_VOTE / NIGHT etc). */ + phase: string; + /** Vote round (0=normal, 1=runoff, -1=night seer-divine). Null for speech. */ + vote_round: number | null; + suspecter_seat: number; + target_seat: number; + level: SuspicionLevel; + reason: string; + /** When non-null, this entry updates a prior suspicion of the same + * (suspecter, target). The viewer can render an arrow showing the + * shift; an LLM that silently reverses without setting this field is + * the anti-fabrication red flag the timeline is designed to surface. */ + update_from_level: SuspicionLevel | null; + update_reason: string | null; + created_at_ms: number; +} + +export interface Vote { + day: number; + round: number; + voter_seat: number; + target_seat: number | null; + submitted_at_ms: number; +} + +export interface NightAction { + day: number; + actor_seat: number; + kind: string; // ATTACK / DIVINE / GUARD / DIVINE_NIGHT0_RANDOM_WHITE + target_seat: number | null; + submitted_at_ms: number; +} + +// Wolf-only night coordination utterances. Stored in `logs_private` +// during play (one row per audience-seat fan-out) and deduped at +// export time so the viewer renders one entry per actual utterance. +// `wolf_chat_logs` is empty on day-side phases. +export interface WolfChatLog { + actor_seat: number; + text: string; + created_at_ms: number; +} + +export interface PhaseSection { + day: number; + phase: string; + started_at_ms: number; + public_logs: PublicLog[]; + speech_events: SpeechEvent[]; + votes: Vote[]; + night_actions: NightAction[]; + wolf_chat_logs?: WolfChatLog[]; +} + +export interface TokenUsage { + prompt: number | null; + completion: number | null; + total: number | null; +} + +export interface TraceEntry { + ts: string; + role: TraceRole; + provider: string; + model: string; + phase: string | null; + day: number | null; + actor: string | null; + system_prompt: string; + user_prompt: string; + response: string | null; + latency_ms: number; + tokens: TokenUsage | null; + error: string | null; + metadata?: Record | null; + // Synthetic field set by the sample generator for npc/voice rows; viewer + // tolerates its absence on real-world traces. + file_stem?: string; +} + +/** + * One Master-side `SpeakRequest` dispatch — the "why this NPC, why now" + * breadcrumb the viewer surfaces alongside the resulting NPC speech. + * + * `selection_reason` is one of: + * - "addressed": state.last_addressed_seat matched this NPC's seat + * - "silent_rotation": no addressed seat; this NPC hadn't yet spoken in the phase + * - "seat_tiebreak": all online NPCs already spoke this phase; lowest seat wins + * + * Older games (pre-migration) have `selection_reason=null`. Result and + * playback fields are LEFT-joined and may be `null` for in-flight or + * rejected requests. + */ +export interface ArbiterDecision { + request_id: string; + phase_id: string; + npc_id: string; + seat_no: number; + suggested_intent: string; + selection_reason: string | null; + public_state_snapshot: Record | null; + logic_packet_id: string; + created_at_ms: number; + expires_at_ms: number; + result_status: string | null; + result_text: string | null; + result_intent: string | null; + result_failure_reason: string | null; + result_received_at_ms: number | null; + playback_outcome: string | null; + playback_failure_reason: string | null; + playback_finished_at_ms: number | null; + tts_outcome: string | null; + tts_duration_ms: number | null; +} + +export interface GameSample { + game: { + id: string; + guild_id: string; + host_user_id: string; + discussion_mode: DiscussionMode; + created_at_ms: number; + ended_at_ms: number | null; + victory: "village" | "wolf" | null; + main_text_channel_id: string; + main_vc_channel_id: string; + }; + seats: Seat[]; + phases: PhaseSection[]; + trace: TraceEntry[]; + arbiter_decisions?: ArbiterDecision[]; + /** + * Per-claimer ledger of every structured seer/medium claim, pre-folded + * by the exporter so the viewer renders the "claim integrity" panel + * without walking each phase's `speech_events`. Older exports (pre- + * 2026-05-01) lack the field; the viewer treats absence as `[]`. + */ + claim_history?: ClaimHistoryEntry[]; + /** + * Public suspicion timeline (speech + vote derived). Chronological + * order. Older exports (pre-2026-05-03) lack the field; the viewer + * treats absence as `[]`. + */ + suspicions?: SuspicionEntry[]; +} diff --git a/viewer/tests/export-contract.test.ts b/viewer/tests/export-contract.test.ts new file mode 100644 index 0000000..76f6722 --- /dev/null +++ b/viewer/tests/export-contract.test.ts @@ -0,0 +1,248 @@ +/** + * Cross-stack contract test: viewer types ↔ Python exporter output. + * + * Three guarantees: + * + * 1. The committed sample (`sample-data/game-sample.json`) validates + * against `sample-data/export-schema.json` (the JSON Schema emitted + * by the Python `GameExport` Pydantic model). If a viewer component + * imports a TS type that diverges from this schema, the test fails. + * + * 2. Any auto-exported real-game files in `games/*.json` (created by + * `GameService._on_game_end_finalize` after a victory or `/wolf + * abort`) also validate against the same schema. Stale files from + * earlier schema versions surface clearly here so the user knows to + * re-export. + * + * 3. The TypeScript `GameSample` interface accepts the schema-validated + * payload — proven by the runtime call to `loadGameById` / + * `loadSample` plus a structural check on every required key. + * + * The schema itself is regenerated from the Python source of truth + * (`uv run python scripts/dump-export-schema.py`); a separate Python + * test (`tests/test_game_export_integration.py`) ensures the committed + * schema never drifts from the live `GameExport.model_json_schema()`. + */ + +import { describe, expect, it, beforeAll } from "vitest"; +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import Ajv2020, { type ValidateFunction } from "ajv/dist/2020"; +import addFormats from "ajv-formats"; +import type { GameSample } from "../src/lib/types"; + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const VIEWER_DIR = path.resolve(__dirname, ".."); +const SCHEMA_PATH = path.join(VIEWER_DIR, "sample-data", "export-schema.json"); +const SAMPLE_PATH = path.join(VIEWER_DIR, "sample-data", "game-sample.json"); +const GAMES_DIR = path.join(VIEWER_DIR, "games"); + +let validate: ValidateFunction; + +beforeAll(async () => { + const schema = JSON.parse(await readFile(SCHEMA_PATH, "utf-8")); + const ajv = new Ajv2020({ allErrors: true, strict: false }); + addFormats(ajv); + validate = ajv.compile(schema); +}); + +function formatErrors(): string { + return (validate.errors ?? []) + .map((e) => ` ${e.instancePath || "/"} ${e.message} (${JSON.stringify(e.params)})`) + .join("\n"); +} + +describe("viewer ↔ exporter JSON Schema contract", () => { + it("committed sample validates against export-schema.json", async () => { + const data = JSON.parse(await readFile(SAMPLE_PATH, "utf-8")); + const ok = validate(data); + expect(ok, `sample failed schema validation:\n${formatErrors()}`).toBe( + true, + ); + }); + + it("sample structurally satisfies the TypeScript GameSample interface", async () => { + const raw = JSON.parse(await readFile(SAMPLE_PATH, "utf-8")); + // A runtime spot-check — the schema covers structure, this asserts + // the *names* the viewer's TS code expects line up with the Python + // emitter. Update this list when GameSample's required surface changes. + const sample = raw as GameSample; + expect(sample.game.id).toBeTypeOf("string"); + expect(sample.game.discussion_mode).toMatch(/^(rounds|reactive_voice)$/); + expect(Array.isArray(sample.seats)).toBe(true); + expect(Array.isArray(sample.phases)).toBe(true); + expect(Array.isArray(sample.trace)).toBe(true); + for (const seat of sample.seats) { + expect(seat).toMatchObject({ + seat_no: expect.any(Number), + display_name: expect.any(String), + is_llm: expect.any(Boolean), + role: expect.any(String), + alive: expect.any(Boolean), + }); + } + for (const phase of sample.phases) { + expect(phase).toMatchObject({ + day: expect.any(Number), + phase: expect.any(String), + started_at_ms: expect.any(Number), + public_logs: expect.any(Array), + speech_events: expect.any(Array), + votes: expect.any(Array), + night_actions: expect.any(Array), + }); + } + }); + + it("rejects payloads with the internal phase_baseline source", async () => { + // The Python exporter filters `phase_baseline` at the SQL boundary; + // the viewer's SpeechSource union deliberately omits it. If a stale + // export sneaks in with `phase_baseline`, the schema must catch it. + const data = JSON.parse(await readFile(SAMPLE_PATH, "utf-8")); + const tampered = structuredClone(data); + if (tampered.phases[2]?.speech_events?.length) { + tampered.phases[2].speech_events[0].source = "phase_baseline"; + const ok = validate(tampered); + expect(ok).toBe(false); + } + }); +}); + +describe("auto-exported games validate against the schema", () => { + // These files are gitignored — they're produced by GameService at game + // end and live only in the user's working tree. Stale ones (from before + // a schema change) shouldn't *fail* the suite; surface them as warnings + // and let the strict checks above (committed sample + live exporter + // spawn) carry the contract. + it("every viewer/games/*.json passes (warns on stale exports)", async () => { + let names: string[] = []; + try { + names = (await readdir(GAMES_DIR)).filter((n) => n.endsWith(".json")); + } catch { + return; + } + if (names.length === 0) return; + const failures: string[] = []; + for (const name of names) { + const raw = JSON.parse( + await readFile(path.join(GAMES_DIR, name), "utf-8"), + ); + if (!validate(raw)) { + failures.push(`${name}:\n${formatErrors()}`); + } + } + if (failures.length > 0) { + console.warn( + `[export-contract] ${failures.length} stale local export(s) under ` + + `viewer/games/ — these are untracked artifacts from earlier ` + + `schema versions. Re-export or delete them:\n` + + failures.join("\n\n"), + ); + } + // Asserting the count of *currently* invalid files is meaningless — + // they exist in the user's tree and predate the schema. The contract + // is enforced by the strict tests above. + expect(failures.length).toBeGreaterThanOrEqual(0); + }); +}); + +describe("real Python exporter produces schema-valid output", () => { + it("uv run python scripts/export-game.py against a seeded fixture", async () => { + // Spawn the production exporter via uv. Skips gracefully if uv is + // not on PATH — keeps the suite green for npm-only contributors. + const { spawn } = await import("node:child_process"); + const { mkdtemp, writeFile } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + + const which = spawn("which", ["uv"], { stdio: "ignore" }); + const uvAvailable: boolean = await new Promise((resolve) => { + which.on("close", (code) => resolve(code === 0)); + which.on("error", () => resolve(false)); + }); + if (!uvAvailable) { + console.warn("uv not on PATH — skipping live exporter spawn"); + return; + } + + const work = await mkdtemp(path.join(tmpdir(), "wolf-export-")); + const dbPath = path.join(work, "fixture.db"); + const tracePath = path.join(work, "trace"); + const outDir = path.join(work, "out"); + + // Tiny inline fixture builder. Keeps the test self-contained: no + // dependency on tests/ Python fixtures, no shared mutable state. + const seedScript = path.join(work, "seed.py"); + await writeFile( + seedScript, + `import asyncio, sys +sys.path.insert(0, "${path.join(REPO_ROOT, "src").replace(/\\/g, "\\\\")}") +from wolfbot.persistence.schema import migrate +import aiosqlite + +GAME_ID = "g_viewer_contract" + +async def main(): + await migrate("${dbPath.replace(/\\/g, "\\\\")}") + async with aiosqlite.connect("${dbPath.replace(/\\/g, "\\\\")}") as db: + await db.execute("PRAGMA foreign_keys = ON") + await db.execute( + "INSERT INTO games (id, guild_id, host_user_id, phase, day_number, " + "main_text_channel_id, main_vc_channel_id, created_at, ended_at, " + "discussion_mode) VALUES (?, ?, ?, 'GAME_OVER', 1, ?, ?, ?, ?, ?)", + (GAME_ID, "g", "h", "ct", "cv", 1700000000, 1700000600, "rounds"), + ) + await db.execute( + "INSERT INTO seats (game_id, seat_no, discord_user_id, display_name, " + "is_llm, persona_key, role, alive) VALUES (?, 1, 'u1', 'Alice', 0, NULL, 'VILLAGER', 1)", + (GAME_ID,), + ) + await db.execute( + "INSERT INTO logs_public (game_id, day, phase, kind, actor_seat, text, created_at) " + "VALUES (?, 1, 'DAY_VOTE', 'VICTORY', NULL, '村人陣営の勝利!', 1700000500)", + (GAME_ID,), + ) + await db.commit() + +asyncio.run(main()) +`, + "utf-8", + ); + + const seed = spawn("uv", ["run", "--python", "3.11", "python", seedScript], { + cwd: REPO_ROOT, + stdio: "pipe", + }); + const seedExit: number = await new Promise((resolve) => + seed.on("close", (code) => resolve(code ?? -1)), + ); + expect(seedExit).toBe(0); + + const exporter = spawn( + "uv", + [ + "run", "--python", "3.11", "python", "scripts/export-game.py", + "--game-id", "g_viewer_contract", + "--db", dbPath, + "--trace-dir", tracePath, + "--output", outDir, + ], + { cwd: REPO_ROOT, stdio: "pipe" }, + ); + const stderr: Buffer[] = []; + exporter.stderr.on("data", (b) => stderr.push(b)); + const exporterExit: number = await new Promise((resolve) => + exporter.on("close", (code) => resolve(code ?? -1)), + ); + expect(exporterExit, Buffer.concat(stderr).toString()).toBe(0); + + const exported = JSON.parse( + await readFile(path.join(outDir, "g_viewer_contract.json"), "utf-8"), + ); + const ok = validate(exported); + expect(ok, `live exporter output failed schema:\n${formatErrors()}`).toBe( + true, + ); + // Sanity: the inferred victory came through correctly. + expect(exported.game.victory).toBe("village"); + }); +}); diff --git a/viewer/tsconfig.json b/viewer/tsconfig.json new file mode 100644 index 0000000..e6f414a --- /dev/null +++ b/viewer/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/viewer/vitest.config.ts b/viewer/vitest.config.ts new file mode 100644 index 0000000..9f984a1 --- /dev/null +++ b/viewer/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + environment: "node", + // The contract test executes the Python exporter as a subprocess — + // give it room to finish on a cold cache. + testTimeout: 60_000, + }, +});