From 9a53d8fe040f018d605efd1bab081248aec7456b Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Thu, 1 Oct 2026 20:40:00 +0200 Subject: [PATCH] Start forfra: server-autoritativ spilmotor over WebSocket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Den tidligere version (Angular-SPA, REST og polling) er gemt på branchen legacy. Denne version samler alle regler i en ren Python-spilmotor (fupogfakta/engine.py), holder rummene i hukommelsen i én proces og sender hele spiltilstanden pr. rolle over én WebSocket. Vært-login sker via Authentik forward-auth i NPMplus. Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu --- .claude/settings.json | 7 - .gitea/workflows/ci.yml | 47 - .gitignore | 27 +- CHANGELOG.md | 13 - CLAUDE.md | 119 - PROMPT.md | 71 - README.md | 46 + TODO.md | 124 - VERSION | 1 - coordination/PO_DECISIONS_2026-02-27.md | 41 - coordination/assignments.json | 5 - core_admin/admin.py | 3 - core_admin/apps.py | 5 - core_admin/models.py | 3 - core_admin/tests.py | 3 - core_admin/views.py | 3 - deploy/deploy.sh | 48 + deploy/wpp.env.example | 6 + deploy/wpp.service | 15 + docs/ARCHITECTURE.md | 27 - docs/F0_ANTI_CHEAT_RULES.md | 53 - docs/F0_MVP_FUP_OG_FAKTA.md | 51 - docs/GIT_WORKFLOW.md | 17 - docs/I18N_ARCHITECTURE.md | 49 - ...ISSUE-175-I18N-SHARED-CONTRACT-ARTIFACT.md | 35 - ...A-NEXT-ROUND-FINAL-LEADERBOARD-EVIDENCE.md | 28 - ...SSUE-188-SPA-CUTOVER-HARDENING-ARTIFACT.md | 47 - ...200-SPA-HOST-PLAYER-PHASE-SYNC-ARTIFACT.md | 57 - docs/ISSUE-205-I18N-FOUNDATION.md | 36 - docs/ISSUE-207-I18N-AUDIO-SMOKE-ARTIFACT.md | 35 - docs/ISSUE-223-PLAYER-AUDIO-GUARD-ARTIFACT.md | 20 - docs/ISSUE-224-TRUNK-SEQUENCE-175.md | 55 - ...SSUE-225-BACKEND-I18N-BASELINE-ARTIFACT.md | 44 - ...ISSUE-226-SHARED-KEYMAP-LOCALE-CONTRACT.md | 30 - ...50-PRIMARY-DEVICE-AUDIO-POLICY-ARTIFACT.md | 16 - ...UE-251-RELEASE-OFTEN-SPA-MVP-BATCH-PLAN.md | 124 - ...UE-252-REACT-FALLBACK-TRIGGERS-ARTIFACT.md | 27 - ...-I18N-KEYSPACE-FRONTEND-LOADER-ARTIFACT.md | 32 - docs/ISSUE-257-SHARED-I18N-LOADER-ARTIFACT.md | 37 - ...60-PHONE-CLIENT-NO-AUDIO-GUARD-ARTIFACT.md | 18 - docs/ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md | 80 - docs/ISSUE-278-SMOKE-E2E-GATE-ARTIFACT.md | 36 - docs/ISSUE-279-I18N-MVP-CLOSEOUT.md | 168 - ...7-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md | 22 - ...ISSUE-301-CLIENT-ACTION-GATING-ARTIFACT.md | 52 - docs/ISSUE-302-CANONICAL-LOOP-EVIDENCE.md | 55 - ...10-HOST-TRANSITION-IDEMPOTENCY-ARTIFACT.md | 33 - ...SUE-312-LOBBY-FUPOGFAKTA-EXTRACTION-MAP.md | 202 - docs/PROTOCOL.md | 116 + docs/RELEASE_POLICY.md | 28 - docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md | 66 - docs/UI_SMOKE.md | 48 - .../issue-302-canonical-loop-smoke.json | 110 - docs/i18n-drift-check.md | 34 - docs/i18n-keymap.md | 69 - docs/issue-180-flow-log.md | 28 - ...026-03-09-fupogfakta-game-engine-design.md | 272 - ...26-03-09-fupogfakta-implementation-plan.md | 2180 ----- docs/spa-cutover-flag.md | 99 - frontend/.gitignore | 1 - frontend/README.md | 12 - frontend/angular/.gitignore | 3 - frontend/angular/angular.json | 36 - frontend/angular/package-lock.json | 8577 ----------------- frontend/angular/package.json | 28 - .../src/app/api-contract-smoke.spec.ts | 273 - frontend/angular/src/app/app.component.css | 5 - frontend/angular/src/app/app.component.html | 20 - frontend/angular/src/app/app.component.ts | 33 - frontend/angular/src/app/app.config.ts | 15 - frontend/angular/src/app/app.routes.ts | 49 - .../host/host-shell.component.spec.ts | 455 - .../app/features/host/host-shell.component.ts | 396 - .../player/player-shell.component.spec.ts | 695 -- .../features/player/player-shell.component.ts | 620 -- .../src/app/gameplay-phase-machine.spec.ts | 96 - .../src/app/i18n-mvp-flow-smoke.spec.ts | 87 - frontend/angular/src/app/lobby-i18n.spec.ts | 137 - frontend/angular/src/app/lobby-i18n.ts | 74 - .../src/app/session-route-context.spec.ts | 105 - .../angular/src/app/session-route-context.ts | 156 - .../angular/src/app/wpp-api-client.spec.ts | 43 - frontend/angular/src/app/wpp-api-client.ts | 58 - frontend/angular/src/index.html | 12 - frontend/angular/src/main.ts | 7 - frontend/angular/src/styles.css | 4 - frontend/angular/src/test-setup.ts | 1 - frontend/angular/tsconfig.app.json | 9 - frontend/angular/tsconfig.json | 22 - frontend/angular/vitest.config.ts | 8 - frontend/package-lock.json | 1588 --- frontend/package.json | 26 - frontend/shared/i18n/lobby-loader.ts | 74 - frontend/src/api/angular-client.ts | 262 - frontend/src/api/client.ts | 198 - frontend/src/api/mappers.ts | 434 - frontend/src/api/types.ts | 254 - frontend/src/spa/gameplay-phase-machine.ts | 122 - frontend/src/spa/lobby-i18n.ts | 48 - frontend/src/spa/session-context-store.ts | 123 - frontend/src/spa/vertical-slice.ts | 146 - frontend/tests/angular-api-client.test.ts | 739 -- frontend/tests/api-client.integration.test.ts | 166 - frontend/tests/gameplay-phase-machine.test.ts | 150 - frontend/tests/lobby-i18n.contract.test.ts | 68 - frontend/tests/lobby-loader.parity.test.ts | 38 - frontend/tests/session-context-store.test.ts | 56 - frontend/tests/vertical-slice.test.ts | 236 - frontend/tsconfig.json | 13 - frontend/vitest.config.ts | 9 - fupogfakta/admin.py | 60 +- fupogfakta/apps.py | 5 +- fupogfakta/data/questions_a.json | 81 + fupogfakta/data/questions_b.json | 1198 +++ fupogfakta/engine.py | 577 ++ .../management}/__init__.py | 0 .../management/commands}/__init__.py | 0 .../management/commands/load_questions.py | 33 + fupogfakta/migrations/0001_initial.py | 130 +- .../migrations/0002_roundquestion_shown_at.py | 19 - .../0003_roundquestion_mixed_answers.py | 18 - .../migrations/0004_player_session_token.py | 19 - .../0005_alter_gamesession_status.py | 18 - .../0005_gamesession_scoreboard_status.py | 26 - .../migrations/0006_merge_20260315_1249.py | 10 - ...007_roundconfig_started_from_scoreboard.py | 18 - fupogfakta/models.py | 137 +- fupogfakta/payloads.py | 275 - fupogfakta/services.py | 479 - fupogfakta/tests.py | 410 - {lobby => fupogfakta/tests}/__init__.py | 0 fupogfakta/tests/test_engine.py | 198 + fupogfakta/views.py | 2 - infra/env/.env.prod.example | 12 - infra/env/.env.staging.example | 12 - infra/env/.env.test.example | 12 - infra/staging/DB_SETUP.md | 35 - infra/staging/README.md | 50 - infra/staging/deploy_staging.sh | 96 - infra/staging/smoke_suite.sh | 79 - lobby/admin.py | 2 - lobby/apps.py | 5 - lobby/feature_flags.py | 6 - lobby/i18n.py | 100 - lobby/management/__init__.py | 1 - lobby/management/commands/__init__.py | 1 - lobby/management/commands/smoke_staging.py | 320 - lobby/models.py | 2 - lobby/templates/lobby/host_screen.html | 124 - lobby/templates/lobby/player_screen.html | 174 - lobby/templates/lobby/spa_shell.html | 13 - lobby/tests.py | 2739 ------ lobby/ui_views.py | 56 - lobby/urls.py | 39 - lobby/views.py | 891 -- manage.py | 19 +- {lobby/migrations => party}/__init__.py | 0 party/apps.py | 6 + party/auth.py | 66 + party/consumers.py | 157 + party/rooms.py | 115 + party/routing.py | 8 + {realtime => party/tests}/__init__.py | 0 party/tests/test_views.py | 35 + party/tests/test_ws.py | 126 + party/urls.py | 14 + party/views.py | 66 + partyhub/asgi.py | 12 +- partyhub/i18n_bootstrap.py | 30 - partyhub/settings.py | 176 +- partyhub/tests_i18n_bootstrap.py | 28 - partyhub/urls.py | 10 +- partyhub/wsgi.py | 16 - realtime/admin.py | 3 - realtime/apps.py | 5 - realtime/broadcast.py | 35 - realtime/consumers.py | 63 - realtime/migrations/__init__.py | 0 realtime/models.py | 3 - realtime/routing.py | 7 - realtime/tests.py | 123 - realtime/views.py | 3 - requirements-dev.txt | 3 + requirements.txt | 11 +- scripts/build_i18n_parity_report.py | 214 - scripts/check_i18n_drift.py | 132 - scripts/check_i18n_parity_artifact.py | 36 - scripts/report_i18n_parity.py | 196 - .../lobby-mvp-keyspace-parity-report.v1.json | 158 - shared/i18n/key-manifest.json | 109 - shared/i18n/lobby.json | 743 -- voice/__init__.py | 0 voice/admin.py | 3 - voice/apps.py | 5 - voice/migrations/__init__.py | 0 voice/models.py | 3 - voice/tests.py | 3 - voice/views.py | 3 - 198 files changed, 3064 insertions(+), 30073 deletions(-) delete mode 100644 .claude/settings.json delete mode 100644 .gitea/workflows/ci.yml delete mode 100644 CHANGELOG.md delete mode 100644 CLAUDE.md delete mode 100644 PROMPT.md create mode 100644 README.md delete mode 100644 TODO.md delete mode 100644 VERSION delete mode 100644 coordination/PO_DECISIONS_2026-02-27.md delete mode 100644 coordination/assignments.json delete mode 100644 core_admin/admin.py delete mode 100644 core_admin/apps.py delete mode 100644 core_admin/models.py delete mode 100644 core_admin/tests.py delete mode 100644 core_admin/views.py create mode 100755 deploy/deploy.sh create mode 100644 deploy/wpp.env.example create mode 100644 deploy/wpp.service delete mode 100644 docs/ARCHITECTURE.md delete mode 100644 docs/F0_ANTI_CHEAT_RULES.md delete mode 100644 docs/F0_MVP_FUP_OG_FAKTA.md delete mode 100644 docs/GIT_WORKFLOW.md delete mode 100644 docs/I18N_ARCHITECTURE.md delete mode 100644 docs/ISSUE-175-I18N-SHARED-CONTRACT-ARTIFACT.md delete mode 100644 docs/ISSUE-180-SPA-NEXT-ROUND-FINAL-LEADERBOARD-EVIDENCE.md delete mode 100644 docs/ISSUE-188-SPA-CUTOVER-HARDENING-ARTIFACT.md delete mode 100644 docs/ISSUE-200-SPA-HOST-PLAYER-PHASE-SYNC-ARTIFACT.md delete mode 100644 docs/ISSUE-205-I18N-FOUNDATION.md delete mode 100644 docs/ISSUE-207-I18N-AUDIO-SMOKE-ARTIFACT.md delete mode 100644 docs/ISSUE-223-PLAYER-AUDIO-GUARD-ARTIFACT.md delete mode 100644 docs/ISSUE-224-TRUNK-SEQUENCE-175.md delete mode 100644 docs/ISSUE-225-BACKEND-I18N-BASELINE-ARTIFACT.md delete mode 100644 docs/ISSUE-226-SHARED-KEYMAP-LOCALE-CONTRACT.md delete mode 100644 docs/ISSUE-250-PRIMARY-DEVICE-AUDIO-POLICY-ARTIFACT.md delete mode 100644 docs/ISSUE-251-RELEASE-OFTEN-SPA-MVP-BATCH-PLAN.md delete mode 100644 docs/ISSUE-252-REACT-FALLBACK-TRIGGERS-ARTIFACT.md delete mode 100644 docs/ISSUE-257-SHARED-I18N-KEYSPACE-FRONTEND-LOADER-ARTIFACT.md delete mode 100644 docs/ISSUE-257-SHARED-I18N-LOADER-ARTIFACT.md delete mode 100644 docs/ISSUE-260-PHONE-CLIENT-NO-AUDIO-GUARD-ARTIFACT.md delete mode 100644 docs/ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md delete mode 100644 docs/ISSUE-278-SMOKE-E2E-GATE-ARTIFACT.md delete mode 100644 docs/ISSUE-279-I18N-MVP-CLOSEOUT.md delete mode 100644 docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md delete mode 100644 docs/ISSUE-301-CLIENT-ACTION-GATING-ARTIFACT.md delete mode 100644 docs/ISSUE-302-CANONICAL-LOOP-EVIDENCE.md delete mode 100644 docs/ISSUE-310-HOST-TRANSITION-IDEMPOTENCY-ARTIFACT.md delete mode 100644 docs/ISSUE-312-LOBBY-FUPOGFAKTA-EXTRACTION-MAP.md create mode 100644 docs/PROTOCOL.md delete mode 100644 docs/RELEASE_POLICY.md delete mode 100644 docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md delete mode 100644 docs/UI_SMOKE.md delete mode 100644 docs/artifacts/issue-302-canonical-loop-smoke.json delete mode 100644 docs/i18n-drift-check.md delete mode 100644 docs/i18n-keymap.md delete mode 100644 docs/issue-180-flow-log.md delete mode 100644 docs/plans/2026-03-09-fupogfakta-game-engine-design.md delete mode 100644 docs/plans/2026-03-09-fupogfakta-implementation-plan.md delete mode 100644 docs/spa-cutover-flag.md delete mode 100644 frontend/.gitignore delete mode 100644 frontend/README.md delete mode 100644 frontend/angular/.gitignore delete mode 100644 frontend/angular/angular.json delete mode 100644 frontend/angular/package-lock.json delete mode 100644 frontend/angular/package.json delete mode 100644 frontend/angular/src/app/api-contract-smoke.spec.ts delete mode 100644 frontend/angular/src/app/app.component.css delete mode 100644 frontend/angular/src/app/app.component.html delete mode 100644 frontend/angular/src/app/app.component.ts delete mode 100644 frontend/angular/src/app/app.config.ts delete mode 100644 frontend/angular/src/app/app.routes.ts delete mode 100644 frontend/angular/src/app/features/host/host-shell.component.spec.ts delete mode 100644 frontend/angular/src/app/features/host/host-shell.component.ts delete mode 100644 frontend/angular/src/app/features/player/player-shell.component.spec.ts delete mode 100644 frontend/angular/src/app/features/player/player-shell.component.ts delete mode 100644 frontend/angular/src/app/gameplay-phase-machine.spec.ts delete mode 100644 frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts delete mode 100644 frontend/angular/src/app/lobby-i18n.spec.ts delete mode 100644 frontend/angular/src/app/lobby-i18n.ts delete mode 100644 frontend/angular/src/app/session-route-context.spec.ts delete mode 100644 frontend/angular/src/app/session-route-context.ts delete mode 100644 frontend/angular/src/app/wpp-api-client.spec.ts delete mode 100644 frontend/angular/src/app/wpp-api-client.ts delete mode 100644 frontend/angular/src/index.html delete mode 100644 frontend/angular/src/main.ts delete mode 100644 frontend/angular/src/styles.css delete mode 100644 frontend/angular/src/test-setup.ts delete mode 100644 frontend/angular/tsconfig.app.json delete mode 100644 frontend/angular/tsconfig.json delete mode 100644 frontend/angular/vitest.config.ts delete mode 100644 frontend/package-lock.json delete mode 100644 frontend/package.json delete mode 100644 frontend/shared/i18n/lobby-loader.ts delete mode 100644 frontend/src/api/angular-client.ts delete mode 100644 frontend/src/api/client.ts delete mode 100644 frontend/src/api/mappers.ts delete mode 100644 frontend/src/api/types.ts delete mode 100644 frontend/src/spa/gameplay-phase-machine.ts delete mode 100644 frontend/src/spa/lobby-i18n.ts delete mode 100644 frontend/src/spa/session-context-store.ts delete mode 100644 frontend/src/spa/vertical-slice.ts delete mode 100644 frontend/tests/angular-api-client.test.ts delete mode 100644 frontend/tests/api-client.integration.test.ts delete mode 100644 frontend/tests/gameplay-phase-machine.test.ts delete mode 100644 frontend/tests/lobby-i18n.contract.test.ts delete mode 100644 frontend/tests/lobby-loader.parity.test.ts delete mode 100644 frontend/tests/session-context-store.test.ts delete mode 100644 frontend/tests/vertical-slice.test.ts delete mode 100644 frontend/tsconfig.json delete mode 100644 frontend/vitest.config.ts create mode 100644 fupogfakta/data/questions_a.json create mode 100644 fupogfakta/data/questions_b.json create mode 100644 fupogfakta/engine.py rename {core_admin => fupogfakta/management}/__init__.py (100%) rename {core_admin/migrations => fupogfakta/management/commands}/__init__.py (100%) create mode 100644 fupogfakta/management/commands/load_questions.py delete mode 100644 fupogfakta/migrations/0002_roundquestion_shown_at.py delete mode 100644 fupogfakta/migrations/0003_roundquestion_mixed_answers.py delete mode 100644 fupogfakta/migrations/0004_player_session_token.py delete mode 100644 fupogfakta/migrations/0005_alter_gamesession_status.py delete mode 100644 fupogfakta/migrations/0005_gamesession_scoreboard_status.py delete mode 100644 fupogfakta/migrations/0006_merge_20260315_1249.py delete mode 100644 fupogfakta/migrations/0007_roundconfig_started_from_scoreboard.py delete mode 100644 fupogfakta/payloads.py delete mode 100644 fupogfakta/services.py delete mode 100644 fupogfakta/tests.py rename {lobby => fupogfakta/tests}/__init__.py (100%) create mode 100644 fupogfakta/tests/test_engine.py delete mode 100644 fupogfakta/views.py delete mode 100644 infra/env/.env.prod.example delete mode 100644 infra/env/.env.staging.example delete mode 100644 infra/env/.env.test.example delete mode 100644 infra/staging/DB_SETUP.md delete mode 100644 infra/staging/README.md delete mode 100755 infra/staging/deploy_staging.sh delete mode 100755 infra/staging/smoke_suite.sh delete mode 100644 lobby/admin.py delete mode 100644 lobby/apps.py delete mode 100644 lobby/feature_flags.py delete mode 100644 lobby/i18n.py delete mode 100644 lobby/management/__init__.py delete mode 100644 lobby/management/commands/__init__.py delete mode 100644 lobby/management/commands/smoke_staging.py delete mode 100644 lobby/models.py delete mode 100644 lobby/templates/lobby/host_screen.html delete mode 100644 lobby/templates/lobby/player_screen.html delete mode 100644 lobby/templates/lobby/spa_shell.html delete mode 100644 lobby/tests.py delete mode 100644 lobby/ui_views.py delete mode 100644 lobby/urls.py delete mode 100644 lobby/views.py rename {lobby/migrations => party}/__init__.py (100%) create mode 100644 party/apps.py create mode 100644 party/auth.py create mode 100644 party/consumers.py create mode 100644 party/rooms.py create mode 100644 party/routing.py rename {realtime => party/tests}/__init__.py (100%) create mode 100644 party/tests/test_views.py create mode 100644 party/tests/test_ws.py create mode 100644 party/urls.py create mode 100644 party/views.py delete mode 100644 partyhub/i18n_bootstrap.py delete mode 100644 partyhub/tests_i18n_bootstrap.py delete mode 100644 partyhub/wsgi.py delete mode 100644 realtime/admin.py delete mode 100644 realtime/apps.py delete mode 100644 realtime/broadcast.py delete mode 100644 realtime/consumers.py delete mode 100644 realtime/migrations/__init__.py delete mode 100644 realtime/models.py delete mode 100644 realtime/routing.py delete mode 100644 realtime/tests.py delete mode 100644 realtime/views.py create mode 100644 requirements-dev.txt delete mode 100644 scripts/build_i18n_parity_report.py delete mode 100755 scripts/check_i18n_drift.py delete mode 100644 scripts/check_i18n_parity_artifact.py delete mode 100644 scripts/report_i18n_parity.py delete mode 100644 shared/i18n/artifacts/lobby-mvp-keyspace-parity-report.v1.json delete mode 100644 shared/i18n/key-manifest.json delete mode 100644 shared/i18n/lobby.json delete mode 100644 voice/__init__.py delete mode 100644 voice/admin.py delete mode 100644 voice/apps.py delete mode 100644 voice/migrations/__init__.py delete mode 100644 voice/models.py delete mode 100644 voice/tests.py delete mode 100644 voice/views.py diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index aecadcb..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash" - ] - } -} diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml deleted file mode 100644 index 9fc93e0..0000000 --- a/.gitea/workflows/ci.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: CI - -on: - push: - branches: - - "**" - pull_request: - branches: - - main - -jobs: - test-and-quality: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install ruff - - - name: Lint - run: ruff check lobby - - - name: Tests - run: python manage.py test lobby -v 1 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "22" - - - name: Install SPA dependencies - run: | - npm ci --prefix frontend/angular - node -e "require('./frontend/angular/node_modules/rollup/dist/native.js')" \ - || npm install --prefix frontend/angular - - - name: SPA Angular smoke tests - run: npm --prefix frontend/angular test diff --git a/.gitignore b/.gitignore index a06f3ff..854b6ed 100644 --- a/.gitignore +++ b/.gitignore @@ -1,31 +1,6 @@ -# Python __pycache__/ -*.py[cod] -*.so -*.egg-info/ -.eggs/ -.pytest_cache/ -.mypy_cache/ -.ruff_cache/ - -# Virtual env +*.pyc .venv/ -venv/ - -# Django -*.log db.sqlite3 staticfiles/ -media/ - -# Env/secrets .env -.env.* -!.env.test.example -!.env.staging.example -!.env.prod.example - -# Editors/OS -.vscode/ -.idea/ -.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 7fe8610..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,13 +0,0 @@ -# Changelog - -## [Unreleased] -### Docs -- Added `docs/ISSUE-279-I18N-MVP-CLOSEOUT.md` with the issue #279 i18n MVP close-out note, including migration impact, reusable release-note text, and a release-readiness checklist refreshed against `main@1bc4c27` after PR #282/#283 landed on 2026-03-13 UTC. -- Clarified that the close-out note supersedes earlier PR snapshot assumptions and now treats PR #282 (`6ad5430`) and PR #283 (`1bc4c27`) as already merged on `main`. - -## [0.1.0] - 2026-02-27 -### Added -- Projekt scaffold for Weirsøe Party Protocol (Django 6.0.2) -- Apps: core_admin, fupogfakta, lobby, realtime, voice -- Initial datamodel for Fup og Fakta + admin registrering -- Environment templates for test/prod diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index c96a4fb..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,119 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -**Weirsøe Party Protocol** is a Danish party game web platform (Jackbox-style) where games display on a primary screen and players participate via mobile. The MVP game is "Fup og Fakta" (a Fibbage-style lie-and-guess game). - -- Backend: Django 6.0.2 + Django Channels (WebSockets) + Redis -- Frontend: Angular 19 shell + shared TypeScript API client library -- Database: MySQL (SQLite fallback for dev) -- Deployment: Proxmox LXC containers (not Docker) - -## Commands - -### Backend (Django) -```bash -python manage.py runserver # Dev server -python manage.py migrate # Apply migrations -python manage.py test # Run all backend tests -python manage.py test lobby # Run tests for a single app -python manage.py shell # Django shell -``` - -### Frontend — API client (`/frontend`) -```bash -cd frontend -npm install -npm test # Vitest unit tests -npm run build # TypeScript compile check (--noEmit) -``` - -### Frontend — Angular shell (`/frontend/angular`) -```bash -cd frontend/angular -npm install -npm start # Dev server (ng serve) -npm run build # Production build -npm run test # Vitest unit tests -``` - -### i18n validation -```bash -python scripts/check_i18n_drift.py # Check for key drift between locales -``` - -## Architecture - -### Backend apps - -| App | Purpose | -|-----|---------| -| `partyhub/` | Main Django project — settings, root URLs, ASGI/WSGI, i18n bootstrap | -| `lobby/` | Session & player management — create/join session, locale-aware error responses | -| `fupogfakta/` | Game logic — all domain models, score calculation (server-authoritative) | -| `realtime/` | WebSocket event layer (stub) | -| `voice/` | Voice/TTS interface (stub, Phase 2) | -| `core_admin/` | Health endpoint (`/healthz`), global admin | - -**Key domain models** (all in `fupogfakta/models.py`): `GameSession`, `Player`, `Category`, `Question`, `RoundConfig`, `RoundQuestion`, `LieAnswer`, `Guess`, `ScoreEvent`. - -Score calculation is server-side only. `ScoreEvent` provides an auditable trail of all point changes. - -### Frontend layers - -1. **Shared API client** (`frontend/src/`) — pure TypeScript, framework-agnostic. Defines all API types (`api/types.ts`) and HTTP client abstraction (`api/client.ts`). -2. **Angular shell** (`frontend/angular/`) — Angular 19 standalone components (no NgModules), hash-based routing. `host-shell.component` for the presenter screen; `player-shell.component` for mobile players. - -The Angular shell consumes the shared client via `frontend/src/api/angular-client.ts`. - -### Real-time flow - -`LOBBY → LIE → GUESS → REVEAL → FINISHED` — phase transitions broadcast a `PhaseViewModel` to all connected clients via WebSocket. Clients are read-only; only the server is authoritative for state. - -### i18n - -- **Single source of truth**: `shared/i18n/lobby.json` (keys in both `en` and `da`) -- Loaded once at startup with LRU cache (`partyhub/i18n_bootstrap.py`) -- Key naming: domain-first — `frontend.ui.host.*`, `frontend.ui.player.*`, `backend.errors.*`, `backend.error_codes.*` -- Locale resolved from `Accept-Language` header; missing key returns key + logs warning; missing translation falls back to `en` - -## Key Conventions - -### Errors -Backend error responses use stable machine-readable codes (`backend.error_codes.*`) with separately localized messages. Never couple error code strings to locale. - -### Game constraints (MVP) -- 3–12 players per session -- Session codes: 6-char alphanumeric (no 0/O/1/I/L) -- Anti-cheat: no duplicate lies, lies cannot match the correct answer, answer order randomized - -### Git workflow -- `main`: stable baseline -- `feature/`: development branches -- `release/vX.Y.Z`: release preparation -- Release: merge → create release branch → update `VERSION` + `CHANGELOG.md` → tag → push - -### TypeScript -Strict mode required. Target ES2022. API response interfaces in `frontend/src/api/types.ts` must match backend responses exactly. - -### Database -Use `ForeignKey` with explicit `on_delete` (`PROTECT`/`CASCADE`/`SET_NULL`). Add `db_index=True` on frequently queried fields. Migrations are auto-generated by Django and versioned in `migrations/`. - -## Environment Variables - -``` -DJANGO_SECRET_KEY, DJANGO_DEBUG, DJANGO_ALLOWED_HOSTS -DB_ENGINE, DB_NAME, DB_USER, DB_PASSWORD, DB_HOST, DB_PORT -CHANNEL_REDIS_HOST, CHANNEL_REDIS_PORT -USE_SPA_UI (fallback: WPP_SPA_ENABLED) -WPP_SPA_ASSET_BASE, WPP_SPA_ASSET_VERSION -``` - -## Test Files of Note - -- `lobby/tests.py` — comprehensive Django TestCase coverage for session/player/i18n/error flows -- `frontend/angular/src/app/api-contract-smoke.spec.ts` — API contract smoke tests -- `frontend/angular/src/app/lobby-i18n.spec.ts` — i18n parity checks -- `frontend/tests/lobby-loader.parity.test.ts` — shared i18n loader parity diff --git a/PROMPT.md b/PROMPT.md deleted file mode 100644 index a7b413a..0000000 --- a/PROMPT.md +++ /dev/null @@ -1,71 +0,0 @@ -# Ralph Loop: Implement WebSocket push for Weirsøe Party Protocol - -## Context -- Project: /home/agw/projects/weirsoe-party-protocol -- Backend: Django 6.0.2 + Django Channels + Redis -- The full game REST flow is already implemented in lobby/views.py - (create_session, join_session, start_round, show_question, submit_lie, - mix_answers, submit_guess, calculate_scores, reveal_scoreboard, finish_game) -- realtime/ app exists but is a stub (no consumers.py, no routing) -- partyhub/settings.py has channels in INSTALLED_APPS but no CHANNEL_LAYERS or routing -- PO hard requirement: WebSocket push is mandatory in MVP (no polling) - -## What to build - -### 1. realtime/consumers.py — GameConsumer -- AsyncJsonWebsocketConsumer -- Connects to group game_{session_code} on connect (session_code from URL) -- Player auth: session_token query param validated against Player model -- Host auth: query param role=host, no token required for MVP -- On disconnect: clean leave from group -- Handles incoming message type "ping" -> replies with {"type": "pong"} -- Forwards broadcast group events to WebSocket client - -### 2. partyhub/settings.py — CHANNEL_LAYERS -Add CHANNEL_LAYERS using channels_redis.core.RedisChannelLayer. -Read CHANNEL_REDIS_HOST (default 127.0.0.1) and CHANNEL_REDIS_PORT (default 6379) from env. - -### 3. partyhub/asgi.py — ASGI routing -Wire URLRouter so ws/game// routes to GameConsumer. -Keep existing HTTP routing intact. - -### 4. realtime/routing.py -Define websocket_urlpatterns list. - -### 5. realtime/broadcast.py — broadcast helper -- async def broadcast_phase_event(session_code, event_type, payload) - Sends to group game_{session_code} via channel layer. -- def sync_broadcast_phase_event(session_code, event_type, payload) - Sync wrapper using async_to_sync for calling from sync REST views. - -### 6. lobby/views.py — hook broadcasts into phase transitions -After each phase transition, call sync_broadcast_phase_event: -- start_round -> phase.lie_started (question prompt + time limit) -- show_question -> phase.question_shown (question text) -- mix_answers -> phase.guess_started (shuffled answers + time limit) -- calculate_scores -> phase.scores_calculated (per-player score delta) -- reveal_scoreboard -> phase.scoreboard (ranked player list) -- finish_game -> phase.game_over (final rankings) - -### 7. realtime/tests.py — basic tests -- Connect/disconnect test using channels.testing.WebsocketCommunicator -- Verify a broadcast reaches a connected client - -## Constraints -- Keep auth simple: session_token query param for players, unauthenticated host in MVP -- Use async_to_sync wrapper for sync REST views calling async broadcast -- Do not break existing REST tests (python manage.py test lobby must still pass) -- After each file written, run: python manage.py check -- Follow existing code style in lobby/views.py - -## Completion criteria -Output the exact text: WEBSOCKET COMPLETE - -...when ALL of the following are true: -- realtime/consumers.py exists and handles connect/disconnect/ping -- realtime/broadcast.py exists with sync_broadcast_phase_event -- partyhub/settings.py has CHANNEL_LAYERS configured -- partyhub/asgi.py routes ws/game// to GameConsumer -- All 6 phase transitions in lobby/views.py call sync_broadcast_phase_event -- python manage.py check passes with no errors -- python manage.py test lobby passes (existing tests not broken) diff --git a/README.md b/README.md new file mode 100644 index 0000000..1fbbb8f --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# Weirsøe Party Protocol + +Dansk party-spil i stil med Jackbox. Spillet vises på en storskærm, og spillerne er med fra deres telefoner. Første spil er **Fup og Fakta**: alle finder på en troværdig løgn til et kuriøst faktum, og så skal man finde sandheden blandt løgnene. + +- Spillere: `https://party.weircon.dk` og indtast koden fra skærmen (eller scan QR-koden). +- Vært: `https://party.weircon.dk/host/` (Authentik, grupperne Familie og authentik Admins). +- Spørgsmål: `https://party.weircon.dk/admin/` (Authentik-admins logges ind automatisk). + +## Sådan virker det + +Serveren er autoritativ. Hver skærm har én WebSocket, sender handlinger og får hele spiltilstanden tilbage efter hver ændring (se [docs/PROTOCOL.md](docs/PROTOCOL.md)). Spilmotoren (`fupogfakta/engine.py`) er ren Python uden I/O og ejer alle regler og timere. Rummene (`party/rooms.py`) ligger i hukommelsen i én proces, så **serveren skal køre med én worker**. Et genstart afbryder igangværende spil. + +| Mappe | Indhold | +| --- | --- | +| `fupogfakta/` | Spilmotor, modeller for kategorier og spørgsmål, spørgsmålsbank i `data/*.json` | +| `party/` | Rum, WebSocket-consumers, sider, Authentik-header-login, templates og JS/CSS | +| `partyhub/` | Django-projekt (settings, ASGI) | +| `deploy/` | systemd-unit, miljøfil-skabelon og `deploy.sh` | + +## Udvikling + +```bash +python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt +export DJANGO_DEBUG=true DEV_HOST_USER=dev +.venv/bin/python manage.py migrate && .venv/bin/python manage.py load_questions +.venv/bin/uvicorn partyhub.asgi:application --reload +.venv/bin/python manage.py test +``` + +`DEV_HOST_USER` lader dig åbne `/host/` uden Authentik (kun med `DJANGO_DEBUG=true`). + +## Drift + +CT143 (`192.168.11.143`), uvicorn bag NPMplus (`custom_nginx/hosts/party-weircon.conf` i CT101). Deploy fra Proxmox-værten: + +```bash +deploy/deploy.sh # seneste main fra dev-klonen i CT132 +``` + +Miljø i `/etc/wpp/wpp.env` (se `deploy/wpp.env.example`), database i `/var/lib/wpp/db.sqlite3`. + +## Spørgsmål + +`fupogfakta/data/*.json` indlæses med `manage.py load_questions` (ved hver deploy; matcher på spørgsmålsteksten, så rettelser i admin bevares, medmindre samme spørgsmål ændres i filen). Skriv `_____` hvor svaret står, og giv 3 husets løgne. + +Den tidligere version (Angular-SPA, REST) ligger på branchen `legacy`. diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 9a97b32..0000000 --- a/TODO.md +++ /dev/null @@ -1,124 +0,0 @@ -# TODO — Weirsøe Party Protocol (Django) - -## Projektmål -Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvor spil vises på storskærm/web, og spillere deltager via mobil. - -## Navne -- **Platform/repo:** `weirsoe-party-protocol` -- **Spil 1 (inspireret af Fibbage):** `Fup og Fakta` (django-app slug: `fupogfakta`) - -## Teknologivalg (besluttet) -- Backend: **Django 6.0.2** -- Realtid: Django Channels + Redis (websocket til host + telefoner) -- DB: MySQL (test + prod databaser i samme MySQL LXC) -- Deployment: Proxmox LXC (ikke Docker) - -## Midlertidige defaults (kan finjusteres senere) -- Spillere: min 3, default max 5 (MVP) -- Løgntid (X): 45 sek -- Gættetid (Z): 30 sek -- Login: username/password for host/admin -- Join i kørende spil: session-kode - -## Arbejdsaftale (kommunikation) -- Giv korte progress updates intermitterende under udvikling (ikke spam). -- Hvis der opdages nice-to-have eller need-to-have, skriv dem ind i TODO.md med tydelig markering. - -## Faseplan - -### Fase 0 — Scope + regler -- [x] Fastlæg MVP for Spil 1 (`Fup og Fakta`) — se `docs/F0_MVP_FUP_OG_FAKTA.md` -- [x] Midlertidige defaults sat (X/Z, spillerantal) -- [x] Fastlæg anti-cheat regler (fx ingen identiske løgne) — se docs/F0_ANTI_CHEAT_RULES.md - -### Fase 1 — Monorepo + Django skelet -- [x] Opret Django-projekt (`partyhub`) -- [x] Opret apps: - - [x] `core_admin` (global administration) - - [x] `fupogfakta` (Spil 1) - - [x] `lobby` (room/session/player join flow) - - [x] `realtime` (app-skelet oprettet — consumers/routing IKKE implementeret endnu) - - [x] `voice` (fælles voice-acting interface — stub) -- [x] Miljøfiler (`.env.test`, `.env.prod` skabeloner) -- [x] Konfig for MySQL test/prod - -### Fase 2 — Domænemodel (MVP) -- [x] `GameSession` (kode, host, status) -- [x] `Player` (nickname, session, score) -- [x] `RoundConfig` (kategori, K spørgsmål, point korrekt, point bluff) -- [x] `Category` -- [x] `Question` (prompt + korrekt svar + kategori) -- [x] `LieAnswer` (spillers løgn) -- [x] `Guess` (hvilket svar spiller valgte) -- [x] `ScoreEvent` (auditérbar pointslog) - -### Fase 3 — Spilflow `Fup og Fakta` -- [x] Lobby: host opretter session, spillere joiner via kode (REST) -- [x] Runde starter med kategori (REST) -- [x] Spørgsmål vises -> alle skriver løgn inden X sek (REST) -- [x] System blander korrekt svar + løgne (persisted i JSONField, anti-cheat dedup) -- [x] Guessfase: alle gætter inden Z sek (REST) -- [x] Pointudregning (konfigurerbar pr. runde, ScoreEvent audit trail) -- [x] Scoreboard + næste spørgsmål/runde (REST) -- [x] Slutresultat (REST) -- [x] **WebSocket push af phase-events til host + spillere** (GameConsumer + broadcast.py, InMemoryChannelLayer i tests) - -### Fase 4 — Voice-acting (platformkrav) -- [ ] Definér TTS provider-interface -- [ ] Voice-queue pr. game session -- [ ] Per-spil hooks for voice lines -- [ ] Konfigurerbare stemmer/sprog/hastighed - -### Fase 5 — Admin & content management -- [x] Django admin til kategorier/spørgsmål (grundregistrering) -- [ ] Bulk-import spørgsmål (CSV/JSON) -- [ ] Validering af spørgsmål -- [ ] Rundeopsætning i admin (Y/K/point) - -### Fase 6 — Drift/admin-app (`core_admin`) -- [ ] Miljø-checks (DB, Redis, websockets) -- [ ] Port/host sanity checks (read-only) -- [ ] DB indeks-checks -- [ ] Brugeroprettelse (username/password) - -### Fase 7 — Test og kvalitet -- [ ] Enhedstests for scoring/rules engine -- [ ] Integrationstest for fuld runde -- [ ] Reconnect-scenarier for mobilklient - -### Fase 8 — Dokumentation (Gitea wiki) -- [ ] Arkitektur-overblik -- [ ] Setup guide (test/prod) -- [ ] Spilflow for `Fup og Fakta` -- [ ] Driftsguide + fejlsøgning - -### Fase 9 — Secrets og DB-oprettelse -- [ ] Opret databaser + brugere (`wpp_test`, `wpp_prod`) -- [ ] Gem credentials i `https://gitea.weircon.dk/agw/Secrets/src/branch/main/secrets.csv` -- [ ] Verificér least-privilege - -### Fase 10 — Deployment i Proxmox LXC -- [ ] Opret dedikeret LXC -- [ ] Installér Python/venv + services -- [ ] Reverse proxy/NPM + TLS -- [ ] Migrations + static + health checks - -### Backlog — Need-to-have / Nice-to-have -- [x] (Need-to-have) Persistér mixed svarrækkefølge pr. round question — DONE (JSONField + migration 0003 + test) -- [x] (Need-to-have) Tilføj spiller-auth/session-token for submit_lie (pt. baseret på player_id i payload) -- [ ] (Nice-to-have) Endpoint til status/progress i løgnfasen (antal indsendt ud af total) -- [ ] (Need-to-have) Fjern ubrugte scaffold-imports i core_admin/*, realtime/*, voice/*, fupogfakta/views.py (kør `ruff check --fix`) så CI quality gate er grøn -- [x] (Need-to-have) [Issue #251] Release-often lane: SPA MVP opdelt i 3 merge-klare micro-PR batches (plan + acceptance criteria dokumenteret i `docs/ISSUE-251-RELEASE-OFTEN-SPA-MVP-BATCH-PLAN.md`). -- [ ] (Need-to-have) Rate limiting på join/submit endpoints -- [ ] (Need-to-have) Session-kode brute-force beskyttelse -- [ ] (Need-to-have) Audit-log for host-handlinger (start/stop/skip) -- [ ] (Nice-to-have) Runde-tema musik/lyd-cues -- [ ] (Nice-to-have) Hurtig onboarding-skærm for nye spillere - -## PO-beslutninger (2026-02-27) -- MVP: Første spil (Fup og Fakta) skal være spilbart end-to-end og stabilt. -- Realtime: WebSocket er krav i MVP (ingen polling). -- Join: kun i LOBBY i MVP (viewers senere). -- Sikkerhed: sanitised inputs + server-side validering + fairness logging er hard requirement. -- Spillere: default max 5, minimum 3 for start (konfigurerbart). -- Se detaljer: coordination/PO_DECISIONS_2026-02-27.md. diff --git a/VERSION b/VERSION deleted file mode 100644 index 6e8bf73..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.1.0 diff --git a/coordination/PO_DECISIONS_2026-02-27.md b/coordination/PO_DECISIONS_2026-02-27.md deleted file mode 100644 index 54d1d69..0000000 --- a/coordination/PO_DECISIONS_2026-02-27.md +++ /dev/null @@ -1,41 +0,0 @@ -# PO decisions — 2026-02-27 - -## MVP scope -- MVP = første spil (`Fup og Fakta`) er spilbart end-to-end og fungerer stabilt. -- Platformen skal fra start være modulariseret så flere spil kan tilføjes senere. - -## Realtime -- WebSocket er et krav i MVP (ikke polling). -- Redis/RabbitMQ er muligt senere for skalering; Redis kan bruges tidligt til channels layer. - -## Join-regel -- Spillere kan kun joine i `LOBBY` i MVP. -- Viewers/observers er post-MVP. - -## Sikkerhed / "anti-cheat" -- Fokus i MVP: sikre/sanitized user inputs og robust server-side validering. -- Hard requirements i første spilbare version: - - input sanitation (XSS/angrebsvektorer) - - server-side validering af submit/guess-flow - - fairness logging - -## Release-klar kriterier -1. Stabilitet er prioritet #1. -2. Spillerantal er konfigurerbart; default: max 5, minimum 3 for at starte. -3. Spilstruktur skal understøtte parametre: - - X = spørgsmål i kategori (stor pool) - - K = antal spørgsmål udvalgt pr. kategori (K < X) - - Z = antal kategorier i pool (stor pool) - - N = antal runder pr. spil - - M = antal kategorier pr. runde - - K og M holdes ens på tværs af runder i MVP - -## Eksempel -- 4 runder, 3 kategorier pr. runde, 2 spørgsmål pr. kategori. - -## Prioriteret roadmap -- M1: Lås MVP-scope + acceptkriterier -- M2: End-to-end round engine -- M3: Anti-cheat/sikkerhed enforcement + fairness logging -- M4: Stabilitet/UX + release gates -- M5: UI/high-performance forbedringer diff --git a/coordination/assignments.json b/coordination/assignments.json deleted file mode 100644 index daba46c..0000000 --- a/coordination/assignments.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "updatedAt": "2026-02-27T13:38:35Z", - "active": [], - "queue": [] -} diff --git a/core_admin/admin.py b/core_admin/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/core_admin/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/core_admin/apps.py b/core_admin/apps.py deleted file mode 100644 index e010c8e..0000000 --- a/core_admin/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class CoreAdminConfig(AppConfig): - name = 'core_admin' diff --git a/core_admin/models.py b/core_admin/models.py deleted file mode 100644 index 71a8362..0000000 --- a/core_admin/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/core_admin/tests.py b/core_admin/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/core_admin/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/core_admin/views.py b/core_admin/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/core_admin/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..90d27d0 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Kør på Proxmox-værten: deploy main fra dev-klonen i CT132 til CT143. +# deploy/deploy.sh (seneste commit på main) +# deploy/deploy.sh (vilkårlig ref) +set -euo pipefail +REF=${1:-main} +DEV_CT=132; PROD_CT=143 +REPO=/home/coder/repos/weirsoe-party-protocol +TS=$(date +%Y%m%d-%H%M%S) +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +pct exec $DEV_CT -- su - coder -s /bin/bash -c "cd $REPO && git archive --format=tar $REF" > "$TMP/release.tar" +SHA=$(pct exec $DEV_CT -- su - coder -s /bin/bash -c "cd $REPO && git rev-parse --short $REF") +pct push $PROD_CT "$TMP/release.tar" /tmp/wpp-release-$TS.tar + +pct exec $PROD_CT -- bash -euo pipefail -c " + id wpp >/dev/null 2>&1 || useradd --system --home /var/lib/wpp --shell /usr/sbin/nologin wpp + install -d -o wpp -g wpp /var/lib/wpp /opt/wpp/releases + install -d -m 750 -o root -g wpp /etc/wpp + D=/opt/wpp/releases/$TS-$SHA + mkdir -p \$D && tar -C \$D -xf /tmp/wpp-release-$TS.tar && rm /tmp/wpp-release-$TS.tar + echo $SHA > \$D/REVISION + [ -d /opt/wpp/venv ] || python3 -m venv /opt/wpp/venv + /opt/wpp/venv/bin/pip install -q --disable-pip-version-check -r \$D/requirements.txt + chown -R wpp:wpp \$D + set -a; . /etc/wpp/wpp.env; set +a + cd \$D + runuser -u wpp -- /opt/wpp/venv/bin/python manage.py migrate --noinput + runuser -u wpp -- /opt/wpp/venv/bin/python manage.py collectstatic --noinput -v0 + runuser -u wpp -- /opt/wpp/venv/bin/python manage.py load_questions + ln -sfn \$D /opt/wpp/current + cp \$D/deploy/wpp.service /etc/systemd/system/wpp.service + systemctl daemon-reload + systemctl enable -q wpp + systemctl restart wpp + # behold de 5 nyeste releases + ls -1dt /opt/wpp/releases/* | tail -n +6 | xargs -r rm -rf +" +for i in $(seq 1 20); do + if pct exec $PROD_CT -- curl -fsS -m 2 http://127.0.0.1:8000/healthz >/dev/null 2>&1; then + echo "Deployet $SHA til CT$PROD_CT"; exit 0 + fi + sleep 1 +done +echo "healthz svarer ikke efter deploy" >&2 +pct exec $PROD_CT -- journalctl -u wpp -n 30 --no-pager >&2 +exit 1 diff --git a/deploy/wpp.env.example b/deploy/wpp.env.example new file mode 100644 index 0000000..72f4cae --- /dev/null +++ b/deploy/wpp.env.example @@ -0,0 +1,6 @@ +DJANGO_SECRET_KEY=skift-mig +DJANGO_ALLOWED_HOSTS=party.weircon.dk,192.168.11.143,localhost +PUBLIC_URL=https://party.weircon.dk +TRUSTED_PROXIES=127.0.0.1,192.168.10.201 +DB_PATH=/var/lib/wpp/db.sqlite3 +STATIC_ROOT=/var/lib/wpp/static diff --git a/deploy/wpp.service b/deploy/wpp.service new file mode 100644 index 0000000..7e7db99 --- /dev/null +++ b/deploy/wpp.service @@ -0,0 +1,15 @@ +[Unit] +Description=Weirsøe Party Protocol (uvicorn, én worker: spiltilstand ligger i hukommelsen) +After=network.target + +[Service] +User=wpp +Group=wpp +WorkingDirectory=/opt/wpp/current +EnvironmentFile=/etc/wpp/wpp.env +ExecStart=/opt/wpp/venv/bin/uvicorn partyhub.asgi:application --host 0.0.0.0 --port 8000 --workers 1 --ws-ping-interval 20 --no-server-header +Restart=always +RestartSec=2 + +[Install] +WantedBy=multi-user.target diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index 6d57ec6..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,27 +0,0 @@ -# Arkitektur (MVP) - -## Moduler -- `core_admin`: global drift/admin, health, valideringer -- `lobby`: session creation/join, player presence -- `fupogfakta`: game rules, rounds, scoring (spil 1) -- `realtime`: websocket events + state sync -- `voice`: fælles voice-acting/TTS interface - -## Auth & sessions -- Login (username/password) kræves for at oprette/hoste spil -- Deltagelse i kørende spil sker via session-kode - -## Voice-acting (platformkrav) -- Alle spil skal kunne afspille voice lines via fælles interface -- Voice er modulært pr. spil (ikke hardcoded) - -## Realtidsmodel -- Host-screen og mobilklienter forbinder via websocket -- Autoritativ game state ligger server-side -- Klienter sender intents (`submit_lie`, `submit_guess`) -- Server broadcaster state transitions - -## Datamodel-principper -- Score beregnes server-side -- Hver scoreændring gemmes i `ScoreEvent` -- Runde-konfiguration gemmes per session (points ikke hardcoded) diff --git a/docs/F0_ANTI_CHEAT_RULES.md b/docs/F0_ANTI_CHEAT_RULES.md deleted file mode 100644 index acd58af..0000000 --- a/docs/F0_ANTI_CHEAT_RULES.md +++ /dev/null @@ -1,53 +0,0 @@ -# F0 anti-cheat regler — Fup og Fakta - -## Formål -Fastlæg simple, håndhævelige anti-cheat regler for F0, så runderne opleves fair uden tung NLP/moderation. - -## Scope (F0) -Gælder for inputfasen hvor spillere indsender løgne til et spørgsmål. - -## Regelsæt (F0) - -### 1) Ingen identiske løgne i samme runde -- To spillere må ikke have samme løgntekst efter normalisering. -- Hvis en indsendt løgn matcher en eksisterende løgn i runden, afvises den med fejlbesked og spilleren skal indsende ny tekst. - -### 2) Ingen løgn må være identisk med korrekt svar -- En løgn, der matcher det korrekte svar efter normalisering, afvises. - -### 3) Ingen tomme eller trivielt ugyldige svar -- Tom tekst, kun whitespace eller kun tegnsætning afvises. -- Minimumslængde: 2 tegn efter trim. - -### 4) Én aktiv løgn pr. spiller pr. spørgsmål -- Spilleren må gerne overskrive sin egen løgn inden deadline. -- Ved deadline er kun seneste gyldige version gældende. - -### 5) Ingen afslørende metatekst -- Svar som eksplicit afslører bluff-mekanikken (fx “det rigtige svar er …”, “jeg lyver”) afvises i F0 via enkel nøgleordsblokliste. - -## Normalisering (bruges i regel 1 + 2) -Følgende normalisering anvendes før sammenligning: -1. Unicode NFKC -2. Trim + collapse af flere mellemrum til ét -3. Lowercase -4. Fjern afsluttende tegnsætning (`.`, `,`, `!`, `?`, `:` `;`) - -Bemærk: F0 bruger **ikke** semantisk duplikatdetektion (synonymer/stavevarianter kan passere). - -## Håndhævelse i UX/API -- Validering sker server-side ved submit. -- Klienten får konkret fejlårsag og kan indsende igen inden tidsfrist. -- Samme valideringsregler gælder for alle klienter (web/mobil). - -## Acceptance criteria (F0) -- Identiske løgne (efter normalisering) kan ikke gemmes i samme runde. -- Løgn == korrekt svar (efter normalisering) kan ikke gemmes. -- Tom/ugyldig input afvises. -- Overskrivning af egen løgn inden deadline virker. -- Ved deadline bruges kun seneste gyldige løgn. - -## Out-of-scope (bevidst udskudt) -- Semantisk duplikatdetektion (embeddings/fuzzy matching) -- Avanceret toxicitet/moderation -- Sprogdetektion og translitterering diff --git a/docs/F0_MVP_FUP_OG_FAKTA.md b/docs/F0_MVP_FUP_OG_FAKTA.md deleted file mode 100644 index e76b4fb..0000000 --- a/docs/F0_MVP_FUP_OG_FAKTA.md +++ /dev/null @@ -1,51 +0,0 @@ -# F0 MVP-definition — Spil 1: Fup og Fakta - -## Formål -Definere et klart, implementerbart MVP-scope for første spil, så teamet kan bygge og validere en første spilbar version uden scope creep. - -## MVP-mål (Spilbar kerne) -MVP er opfyldt når en host kan starte en session, 3-12 spillere kan deltage via kode, og mindst én fuld runde kan gennemføres med korrekt pointgivning og synlig scoreboard. - -## In-scope (skal med i MVP) -1. **Lobby + sessionstyring** - - Host opretter session (kræver login). - - Spillere joiner med nickname + session-kode. - - Session håndterer 3-12 aktive spillere. - -2. **Rundeflow (single game loop)** - - System vælger kategori + spørgsmål. - - Alle spillere indsender én løgn inden for X=45 sek. - - System samler svarliste: korrekt svar + alle gyldige løgne (blandet rækkefølge). - - Alle spillere gætter inden for Z=30 sek. - -3. **Scoring (server-side)** - - Point for korrekt gæt. - - Point når andre vælger ens løgn (bluff-point). - - Score opdateres pr. spørgsmål og logges i `ScoreEvent`. - -4. **Resultatvisning** - - Scoreboard efter hvert spørgsmål. - - Slutresultat efter sidste spørgsmål i runden. - -5. **Driftbar baseline** - - Realtidsopdateringer via websocket (host + mobilklienter). - - Grundlæggende fejlhåndtering for timeout/manglende svar (spillet går videre). - -## Out-of-scope for F0 MVP -- Voice-acting/TTS-afvikling (beholdes som platformkrav i senere fase). -- Avancerede anti-cheat regler (fx semantisk duplikatdetektion). -- Bulk-import/avanceret content moderation. -- Reconnect-robusthed ud over basal håndtering. -- Temaer, lydeffekter, onboarding-polish. - -## Afgrænsning af “Done” for F0 -F0-MVP anses som leveret når følgende acceptance criteria er opfyldt: -- Én host kan oprette og starte en `Fup og Fakta` session. -- Mindst 3 spillere kan joine med kode og gennemføre en hel runde. -- Tidsfaser (løgntid/gættetid) håndhæves server-side. -- Pointtildeling er korrekt og auditerbar via `ScoreEvent`. -- Scoreboard og slutresultat vises deterministisk for alle klienter. - -## Åbne afhængigheder (ikke blokkerende for definitionen) -- Endelig pointmatrix i `RoundConfig` (kan justeres uden at ændre MVP-scope). -- Anti-cheat-regler specificeres separat i Fase 0-opgaven “Fastlæg anti-cheat regler”. diff --git a/docs/GIT_WORKFLOW.md b/docs/GIT_WORKFLOW.md deleted file mode 100644 index ac23570..0000000 --- a/docs/GIT_WORKFLOW.md +++ /dev/null @@ -1,17 +0,0 @@ -# Git workflow - -## Branches -- `main`: stabil baseline -- `feature/`: løbende udvikling -- `release/vX.Y.Z`: release-klargøring - -## Commit-strategi -- Commit often, små commits med klare budskaber. - -## Release-flow -1. Arbejd i `feature/*` -2. Merge til `main` -3. Opret `release/vX.Y.Z` fra `main` -4. Opdater `VERSION` + `CHANGELOG.md` -5. Tag: `vX.Y.Z` -6. Push branch + tag til Gitea diff --git a/docs/I18N_ARCHITECTURE.md b/docs/I18N_ARCHITECTURE.md deleted file mode 100644 index 2ba2de1..0000000 --- a/docs/I18N_ARCHITECTURE.md +++ /dev/null @@ -1,49 +0,0 @@ -# Shared i18n architecture (frontend + backend) - -## Scope -Issue #175 requires one shared i18n contract for MVP host/player flows across frontend and backend. - -## Source of truth -- Catalog: `shared/i18n/lobby.json` -- Locales: - - supported: `en`, `da` - - default/fallback: `en` - -Django (`lobby/i18n.py`) reads directly from the catalog. Frontend runtimes are Angular-first and use a shared loader (`frontend/shared/i18n/lobby-loader.ts`) so Angular shell and SPA fallback consume the same keyspace + locale normalization. - -## Key naming convention -- Domain-first namespaces: - - `frontend.ui.common.*` - - `frontend.ui.host.*` - - `frontend.ui.player.*` - - `frontend.errors.*` - - `backend.errors.*` - - `backend.error_codes.*` -- UI lookup keys in Angular are shortened aliases mapped under `frontend.ui`, e.g.: - - `host.start_round` - - `player.submit_guess` - - `common.session_code` -- Backend API errors return stable code + localized message: - - `error_code` = machine-stable key from `backend.error_codes` - - `error` = localized message from `backend.errors` - - `locale` = resolved request locale - -## Fallback model (robust) -1. Resolve requested locale (`Accept-Language` on backend, user/browser preference on frontend). -2. If locale unsupported -> use default `en`. -3. If key missing -> return key and log warning. -4. If locale translation missing for key -> fallback to `en` translation. - -## Audio-routing policy -- Catalog capability: `frontend.capabilities.client_has_no_audio_output = true` -- Host/player clients expose this as a read-only capability flag. -- Policy: phone clients must not play audio directly; only primary/host output is allowed. - -## Verification -- Backend tests: `lobby/tests.py` i18n coverage for locale selection + fallback + error-code/message matrix. -- Frontend smoke/e2e-level unit coverage: - - `frontend/angular/src/app/lobby-i18n.spec.ts` - - `frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts` - - `frontend/angular/src/app/features/host/host-shell.component.spec.ts` - - `frontend/angular/src/app/features/player/player-shell.component.spec.ts` - - `frontend/tests/lobby-loader.parity.test.ts` (minimal da/en key parity guard for shared keyspace) diff --git a/docs/ISSUE-175-I18N-SHARED-CONTRACT-ARTIFACT.md b/docs/ISSUE-175-I18N-SHARED-CONTRACT-ARTIFACT.md deleted file mode 100644 index 4807bd0..0000000 --- a/docs/ISSUE-175-I18N-SHARED-CONTRACT-ARTIFACT.md +++ /dev/null @@ -1,35 +0,0 @@ -# ISSUE-175 Artifact — shared i18n contract + MVP host/player flow - -## Delivered -- Shared i18n contract is centralized in `shared/i18n/lobby.json` (da/en, default `en`, robust fallback). -- Frontend host/player MVP copy is read via shared keys (no hardcoded Danish strings in Angular MVP flow components). -- Backend error messages resolve from shared keyspace with locale-aware API payload (`error_code`, `error`, `locale`). -- Audio-routing policy is explicit and shared: `frontend.capabilities.client_has_no_audio_output=true`. -- Architecture + key naming documented in `docs/I18N_ARCHITECTURE.md`. - -## Smoke / e2e evidence -### Backend locale + fallback -Command: -```bash -. .venv/bin/activate && python manage.py test \ - lobby.tests.LobbyFlowTests.test_join_error_localizes_to_danish_with_accept_language_header \ - lobby.tests.LobbyFlowTests.test_join_error_falls_back_to_english_for_unsupported_locale \ - lobby.tests.I18nResolverTests -``` -Result: -- PASS (7 tests) -- Confirms `da` localization and fallback to `en` for unsupported locale. - -### Frontend host/player + language switch + audio policy -Command: -```bash -cd frontend/angular -npm test -- --run \ - src/app/lobby-i18n.spec.ts \ - src/app/i18n-mvp-flow-smoke.spec.ts \ - src/app/features/host/host-shell.component.spec.ts \ - src/app/features/player/player-shell.component.spec.ts -``` -Result: -- PASS (22 tests) -- Includes smoke for host/player copy in **both en and da** and verifies primary-only audio policy flag (`clientHasNoAudioOutput=true`). diff --git a/docs/ISSUE-180-SPA-NEXT-ROUND-FINAL-LEADERBOARD-EVIDENCE.md b/docs/ISSUE-180-SPA-NEXT-ROUND-FINAL-LEADERBOARD-EVIDENCE.md deleted file mode 100644 index cffc004..0000000 --- a/docs/ISSUE-180-SPA-NEXT-ROUND-FINAL-LEADERBOARD-EVIDENCE.md +++ /dev/null @@ -1,28 +0,0 @@ -# Issue #180 Evidence — SPA next-round + final leaderboard - -## Flow log (Angular SPA) -1. Host reaches `reveal` phase and runs `loadScoreboard()` (`GET /lobby/sessions/:code/scoreboard`). -2. Host can start next round directly in SPA via `startNextRound()` (`POST /lobby/sessions/:code/rounds/next`) and then session hydrate (`GET /lobby/sessions/:code`) without page reload. -3. Host can finish game directly in SPA via `finishGame()` (`POST /lobby/sessions/:code/finish`), rendering winner + sorted final leaderboard (plus raw payload for debug) in the same shell. -4. Player SPA renders final leaderboard from refreshed finished-session payload (sorted by score desc, nickname tiebreak) without leaving SPA route. -5. Error/retry paths are implemented and covered: - - scoreboard failure -> `scoreboardError` + retry button - - next-round failure -> `nextRoundError` + retry button - - finish-game/final-leaderboard failure -> `finishError` + retry button - -## Test evidence -### `frontend/angular` (Vitest) -- `src/app/features/host/host-shell.component.spec.ts` - - `runs next-round transition without reload and clears scoreboard payload` - - `captures finish-game failure for retry and stores final leaderboard on success` -- `src/app/features/player/player-shell.component.spec.ts` - - `builds final leaderboard in finished status without legacy page hop` - -Result: -- Test Files: 2 passed -- Tests: 9 passed - -### `frontend` shared SPA tests (regression) -Result: -- Test Files: 5 passed -- Tests: 24 passed diff --git a/docs/ISSUE-188-SPA-CUTOVER-HARDENING-ARTIFACT.md b/docs/ISSUE-188-SPA-CUTOVER-HARDENING-ARTIFACT.md deleted file mode 100644 index 38932ae..0000000 --- a/docs/ISSUE-188-SPA-CUTOVER-HARDENING-ARTIFACT.md +++ /dev/null @@ -1,47 +0,0 @@ -# Issue #188 Artifact — SPA cutover hardening (asset versioning + rollback) - -## Scope -Acceptance for `[READY][SPA][P11] Cutover hardening`: -1. Dokumenteret strategi for cache-busting/versionering af SPA assets i Django staticfiles/reverse proxy setup. -2. Konfigurerbar rollback-procedure for `USE_SPA_UI` (trin-for-trin, target <10 min). -3. Smoke-artefakt for både SPA on/off i samme release-vindue. -4. Ingen gameplay-ændringer. - -## 1) Asset versioning/cache-busting strategi -Implementeret i SPA shell render-path: - -- Konfiguration i `partyhub/settings.py`: - - `WPP_SPA_ASSET_BASE` (eksisterende) - - `WPP_SPA_ASSET_VERSION` (ny) -- `lobby/ui_views.py` injicerer `spa_asset_version` til template-context. -- `lobby/templates/lobby/spa_shell.html` appender `?v={{ spa_asset_version }}` på: - - `styles.css` - - `main.js` - -Effekt: -- Ny release-version (fx SHA/tag) kan tvinge cache-miss i browser/proxy uden ændring af route. -- Rollback kan pege på tidligere stabil version-token med samme mekanisme. - -## 2) Rollback playbook (`USE_SPA_UI`) — target <10 min -Dokumenteret i `docs/spa-cutover-flag.md`: - -1. Sæt `USE_SPA_UI=false`. -2. Sæt `WPP_SPA_ASSET_VERSION` til sidste stabile release-token. -3. Deploy/reload app-processer. -4. Verificér legacy routes (`/lobby/ui/host` + `/lobby/ui/player`). -5. Kør hurtig smoke sanity. -6. Log trigger/timestamp/resultat i smoke artifact. - -## 3) Smoke artifact for SPA OFF/ON i samme release-vindue -Dokumenteret i: -- `docs/UI_SMOKE.md` (sektion: "Samme release-vindue: SPA OFF + ON verifikation") -- `docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md` (template udvidet med release-window check + `WPP_SPA_ASSET_VERSION`) - -Krav: -- OFF-pass (legacy) og ON-pass (SPA) køres i samme deploy/release-vindue. -- Begge passes logges i samme artifact med UTC timestamps og version-token. - -## Non-goals bekræftet -- Ingen gameplay-regler ændret. -- Ingen API-kontrakter ændret. -- Ingen UX-redesign; kun drift/cutover-hardening. diff --git a/docs/ISSUE-200-SPA-HOST-PLAYER-PHASE-SYNC-ARTIFACT.md b/docs/ISSUE-200-SPA-HOST-PLAYER-PHASE-SYNC-ARTIFACT.md deleted file mode 100644 index 3237b96..0000000 --- a/docs/ISSUE-200-SPA-HOST-PLAYER-PHASE-SYNC-ARTIFACT.md +++ /dev/null @@ -1,57 +0,0 @@ -# Issue #200 Artifact — SPA host→player phase sync (no reload) - -## Scope -Acceptance for `[READY][SPA][P14] Gameplay MVP-del 5`: -1. Host handlinger (`start round` / `næste fase`) propagere korrekt til player-ruter i SPA. -2. Happy-path artefakt for én fuld faseovergang uden page reload. -3. Sync-fejl giver kontrolleret fallback/error state. - -Afgrænsning overholdt: ingen nye spilregler og ingen redesign af backend event-model. - -## Happy-path faseovergang (uden page reload) -Reference-flow i Angular shell: - -1. **Host starter runde/fase** - - Host action kalder backend endpoint (`POST /lobby/sessions/:code/start` eller fase-endpoint) - - Host shell hydrerer session igen (`GET /lobby/sessions/:code`) - - Host route synkes via hash-rewrite i samme SPA shell: - - `#/host//` - - Implementeret i `HostShellComponent.syncRouteFromSession()` - -2. **Player modtager ny fase via state sync** - - Player shell kører periodisk session-refresh (3s), uden hard reload - - Når `session.status` ændrer sig, synkes hash-route i samme SPA shell: - - `#/player//` - - Implementeret i `PlayerShellComponent.syncRouteFromSession()` - -3. **Ingen page reload** - - Routing sker med `window.history.replaceState(...)` - - URL opdateres i eksisterende SPA instans (ingen template-hop, ingen full refresh) - -## Kontrolleret fallback ved sync-fejl -Når player-sync fejler (netværk/fetch/session-refresh): - -- UI går i kontrolleret connection-state: - - `reconnecting` ved online netværksfejl - - `offline` når browser rapporterer offline -- Fejl vises med retry/back-to-join handlinger -- Reconnect forsøges igen via timer (`scheduleReconnect`) uden at crashe shell - -## Verifikation (tests) -Kørt i `frontend/angular` med Vitest: - -- `src/app/features/host/host-shell.component.spec.ts` - - Verificerer host-faseflow og hash-route sync uden reload -- `src/app/features/player/player-shell.component.spec.ts` - - Verificerer periodisk player state sync + hash-route sync - - Verificerer reconnect/offline fallback ved sync-fejl -- `src/app/api-contract-smoke.spec.ts` -- `src/app/session-route-context.spec.ts` - -Kommando: - -```bash -npm test -- --reporter=dot -``` - -Resultat: **4/4 testfiler grønne, 22/22 tests bestået**. diff --git a/docs/ISSUE-205-I18N-FOUNDATION.md b/docs/ISSUE-205-I18N-FOUNDATION.md deleted file mode 100644 index 0186b34..0000000 --- a/docs/ISSUE-205-I18N-FOUNDATION.md +++ /dev/null @@ -1,36 +0,0 @@ -# Issue #205 — Django i18n foundation (da/en) - -## Implemented acceptance checks - -- **Django i18n setup for `en` + `da` with `en` fallback** - - `LANGUAGE_CODE` set to `en`. - - `LANGUAGES = [('en', 'English'), ('da', 'Danish')]`. - - `LocaleMiddleware` enabled in middleware chain. -- **Shared-key resolver/adapter (no ad hoc backend mapping)** - - Backend error responses now resolve from shared catalog keys in `shared/i18n/lobby.json`. - - `lobby.i18n.api_error()` accepts a shared key and resolves locale-specific text. -- **Representative API flow documented with key/locale behavior** - - `POST /lobby/join` with empty code returns: - - `error_code: "session_code_required"` - - localized `error` - - resolved `locale` -- **Missing key handling deterministic and loggable** - - `resolve_error_message()` returns key string when key/translation is missing. - - Warning is logged (`lobby.i18n` logger) for missing key/translation. - -## Example response behavior - -### Request -`POST /lobby/join` with empty code and header `Accept-Language: da` - -### Response (400) - -```json -{ - "error": "Sessionskode er påkrævet", - "error_code": "session_code_required", - "locale": "da" -} -``` - -If locale is unsupported (e.g. `fr`), response uses `locale: "en"` and English message. diff --git a/docs/ISSUE-207-I18N-AUDIO-SMOKE-ARTIFACT.md b/docs/ISSUE-207-I18N-AUDIO-SMOKE-ARTIFACT.md deleted file mode 100644 index 092526a..0000000 --- a/docs/ISSUE-207-I18N-AUDIO-SMOKE-ARTIFACT.md +++ /dev/null @@ -1,35 +0,0 @@ -# Issue #207 Artifact — da/en locale switch + audio routing (primary-only) - -## Scope -Acceptance for `[READY][i18n][P19]`: -1. Verify one MVP host+player flow in both `en` and `da` locale context. -2. Verify controlled fallback to `en` when a locale key is missing. -3. Verify audio-routing policy is primary-only (client/player has no audio output). - -No new feature behavior added — this is verification-only evidence. - -## Smoke/e2e evidence (Angular shell test run) -Executed from `frontend/angular`: - -```bash -npm test -- --reporter=dot src/app/lobby-i18n.spec.ts src/app/features/host/host-shell.component.spec.ts src/app/features/player/player-shell.component.spec.ts -``` - -Evidence covered by the run: -- `lobby-i18n.spec.ts` - - subscriber locale switch updates (`en -> da -> en`) - - controlled fallback where `da` key is removed during test and `en` copy is returned - - `clientHasNoAudioOutput === true` (primary-only audio routing) -- `host-shell.component.spec.ts` - - host actions and route sync in SPA (no reload) -- `player-shell.component.spec.ts` - - player session refresh/sync path and retry behavior - -## Policy evidence -- Shared catalog capability: `shared/i18n/lobby.json` - - `frontend.capabilities.client_has_no_audio_output = true` -- Angular host/player shells bind this capability: - - `HostShellComponent.clientHasNoAudioOutput` - - `PlayerShellComponent.clientHasNoAudioOutput` - -Conclusion: locale switching works in `da`/`en`, fallback resolves to `en` when locale key is intentionally missing, and client audio remains disabled by policy (`primary-only`). diff --git a/docs/ISSUE-223-PLAYER-AUDIO-GUARD-ARTIFACT.md b/docs/ISSUE-223-PLAYER-AUDIO-GUARD-ARTIFACT.md deleted file mode 100644 index b24e7f9..0000000 --- a/docs/ISSUE-223-PLAYER-AUDIO-GUARD-ARTIFACT.md +++ /dev/null @@ -1,20 +0,0 @@ -# Issue #223 — Telefon-klient audio guard (artifact) - -## Scope leveret -- Telefon-/player-klient installerer en eksplicit audio guard ved mount (`installSecondaryDeviceAudioGuard`). -- Guard overskriver `HTMLMediaElement.prototype.play` til en no-op på secondary device (client policy: `client_has_no_audio_output=true`). -- Guard fjernes igen ved unmount (`ngOnDestroy`) så øvrige flows/enheder ikke påvirkes. -- Ingen audio-controls er eksponeret i player-shell UI. - -## Acceptance mapping -1. **Telefon-klient trigger ikke audio playback i kerneflow** - - Verificeret af test: `player-shell.component.spec.ts` (`installs secondary-device audio guard while player shell is mounted`). -2. **Primær enhed påvirkes ikke negativt** - - Guard er scoped til player-shell lifecycle og restore'r original `play` ved destroy. -3. **Enkel test/verifikation dokumenteret** - - Dokumenteret her + testkørsel nedenfor. - -## Testkørsel -- Kommando: - - `cd frontend/angular && npm test -- src/app/features/player/player-shell.component.spec.ts` -- Resultat: bestået (inkl. audio-guard test). diff --git a/docs/ISSUE-224-TRUNK-SEQUENCE-175.md b/docs/ISSUE-224-TRUNK-SEQUENCE-175.md deleted file mode 100644 index fde3135..0000000 --- a/docs/ISSUE-224-TRUNK-SEQUENCE-175.md +++ /dev/null @@ -1,55 +0,0 @@ -# Issue #224 — Trunk-sekvens for #175 (A/B/C) - -Formål: gøre #175 scheduler-klar som tre små, uafhængige og mergeklare bidder. - -## Sekvens - -### A) Backend i18n baseline -- Tracking issue: #225 -- Scope: - - Backend resolver til locale (da/en) - - Fallback til `en` ved unsupported locale - - Stabil fejlkontrakt i payload (`error_code`, `error`, `locale`) -- Mergebarhed: Kan merges uden frontend-ændringer. -- Acceptance: - - Backend tests dækker `da` + fallback `en` - - Kontraktfelter er stabile i response - -### B) Shared key-map + locale-kontrakt -- Tracking issue: #226 -- Scope: - - Én shared key-map for lobby/kerneflow - - Locale-kontrakt (tilladte locales, default locale, fallback-regler) - - Dokumentation af naming + ownership -- Mergebarhed: Kan merges uden host/player UI-migrering. -- Acceptance: - - Shared kontrakt findes ét sted - - Begge sider kan importere den - - Docs opdateret med da/en eksempler - -### C) Angular host/player integration + hardcoded-text cleanup -- Tracking issue: #227 -- Scope: - - Angular host/player kerneflow bruger shared keys - - Hardcoded tekster fjernes i aftalte kernekomponenter - - Sprogskift verificeres i kritiske states -- Mergebarhed: Kan merges selvstændigt når frontend-tests er grønne. -- Acceptance: - - Korrekt i18n-copy i da/en i kerneflow - - Ingen hardcoded kerneflow-tekster tilbage - - Frontend tests/smoke grønne - -## PR-grænser (per bid) -- 1 PR pr. bid (A/B/C) mod `main` -- Mål: ~200–300 net LOC per PR (ekskl. generated artefakter) -- Undgå cross-layer scope creep -- Review-tid <30 min - -## Overordnet acceptance for #224 -- A/B/C-sekvens er tydelig med links -- Hver bid er mergebar isoleret -- Scheduler kan assign’e direkte uden ekstra afklaring - -## Verification (docs-only) -- Verificeret at dokumentet kun beskriver trunk-sekvensen for issue #224 og linker til #225/#226/#227. -- Ingen runtime-kode ændret; der er derfor ikke kørt kode/tests i denne PR. diff --git a/docs/ISSUE-225-BACKEND-I18N-BASELINE-ARTIFACT.md b/docs/ISSUE-225-BACKEND-I18N-BASELINE-ARTIFACT.md deleted file mode 100644 index 7cdc2cf..0000000 --- a/docs/ISSUE-225-BACKEND-I18N-BASELINE-ARTIFACT.md +++ /dev/null @@ -1,44 +0,0 @@ -# ISSUE-225 Artifact — Backend i18n baseline (resolver + fallback) - -Issue: **#225** (`[MVP][READY] #175-A: Backend i18n baseline (resolver + fallback)`) - -## Scope verified - -- Backend locale resolver supports `da` + `en` and normalizes language tags. -- Unsupported locale requests fall back to default locale (`en`). -- Error payload contract is stable for API errors: - - `error_code` - - `error` - - `locale` - -## Implementation references - -- Locale resolution + fallback chain: - - `lobby/i18n.py` - - `resolve_locale()` - - `resolve_error_message()` - - `api_error()` -- Shared locale contract source: - - `shared/i18n/lobby.json` (`locales.default=en`, supported includes `en`, `da`) - -## Acceptance checks run - -Command: - -```bash -.venv/bin/python manage.py test \ - lobby.tests.I18nResolverTests \ - lobby.tests.LobbyFlowTests \ - lobby.tests.StartRoundTests -``` - -Result (2026-03-02): - -- `Ran 28 tests in 24.781s — OK` -- `System check identified no issues (0 silenced).` -- Confirms resolver behavior for locale normalization + fallback and stable error payload fields across flow endpoints. - -## Notes - -- Existing merged follow-ups in `main` include Accept-Language parsing fixes for q-values and locale tag normalization. -- This artifact documents the final baseline state and verification evidence for #225. diff --git a/docs/ISSUE-226-SHARED-KEYMAP-LOCALE-CONTRACT.md b/docs/ISSUE-226-SHARED-KEYMAP-LOCALE-CONTRACT.md deleted file mode 100644 index ed305b4..0000000 --- a/docs/ISSUE-226-SHARED-KEYMAP-LOCALE-CONTRACT.md +++ /dev/null @@ -1,30 +0,0 @@ -# ISSUE-226 — Shared key-map + locale-kontrakt (backend/frontend) - -## Source of truth -- Single shared artifact: `shared/i18n/lobby.json` -- Ownership is documented under `contract.ownership` in the same artifact. - -## Locale contract -Defined under `contract.locale`: -- default locale: `en` -- supported locales: `en`, `da` -- fallback rule: use default locale when requested locale is unsupported or a key translation is missing. - -## Shared backend→frontend key-map -Defined under `contract.backend_to_frontend_error_keys`. - -Examples: -- `session_not_found -> session_not_found` -- `session_not_joinable -> join_failed` -- `round_start_invalid_phase -> start_round_failed` - -This allows backend error codes to remain stable while frontend copy keys stay UX-oriented. - -## da/en example values -From `shared/i18n/lobby.json`: -- `frontend.errors.session_code_required.en = "Session code is required."` -- `frontend.errors.session_code_required.da = "Sessionskoden er påkrævet."` - -## Verification -- Backend: `python manage.py test lobby.tests.I18nResolverTests` -- Frontend: `npm test -- --run tests/lobby-i18n.contract.test.ts` diff --git a/docs/ISSUE-250-PRIMARY-DEVICE-AUDIO-POLICY-ARTIFACT.md b/docs/ISSUE-250-PRIMARY-DEVICE-AUDIO-POLICY-ARTIFACT.md deleted file mode 100644 index 8239f2d..0000000 --- a/docs/ISSUE-250-PRIMARY-DEVICE-AUDIO-POLICY-ARTIFACT.md +++ /dev/null @@ -1,16 +0,0 @@ -# Issue #250 Artifact — MVP guardrail (telefon-klient uden lydafspilning) - -## Scope -Implementeret guardrail for `primary-device only` audio policy i SPA player-flow. - -## Acceptance mapping -1. **Telefon-klient flow indeholder ingen audio-play init-path** - - Test: `player-shell.component.spec.ts` → `does not trigger original media play during player-shell init path`. -2. **Primær enhed policy er dokumenteret og testbar** - - Policy i contract: `shared/i18n/lobby.json` → `frontend.capabilities.client_has_no_audio_output=true`. - - Testbar via eksisterende guard-tests + init-path test i player-shell specs. -3. **Krav er refereret i SPA-plan/cutover-noter** - - Dokumenteret i `docs/spa-cutover-flag.md` under *MVP audio policy guardrail (telefon-klient)*. - -## UX/i18n note -- Player shell viser advarselstekst via i18n key: `frontend.ui.player.audio_policy_notice`. diff --git a/docs/ISSUE-251-RELEASE-OFTEN-SPA-MVP-BATCH-PLAN.md b/docs/ISSUE-251-RELEASE-OFTEN-SPA-MVP-BATCH-PLAN.md deleted file mode 100644 index 51643e2..0000000 --- a/docs/ISSUE-251-RELEASE-OFTEN-SPA-MVP-BATCH-PLAN.md +++ /dev/null @@ -1,124 +0,0 @@ -# Issue #251 — Release-often lane for SPA MVP (3 micro-PR batches) - -## Formål -Bryde SPA MVP-arbejdet op i **3 merge-klare micro-PRs** med tydelige acceptance criteria, -så vi kan levere værdi oftere, reducere review-risiko og holde `main` grøn. - -## Scope (issue-bound) -Denne plan dækker kun planlægning/acceptance for den kommende SPA MVP-lane. -Implementering af de konkrete features sker i efterfølgende PRs. - -## Hard acceptance criteria for issue #251 -- [x] Der findes en dokumenteret plan med præcis **3 batches**. -- [x] Hver batch har: - - [x] mål og afgrænsning - - [x] konkrete leverancer (kodeområder) - - [x] test/checks før merge - - [x] rollback-note - - [x] "ikke med i denne batch" for at undgå scope creep -- [x] Batch-rækkefølgen er dependencies-sikker (batch B bygger på batch A, batch C på batch B). -- [x] Hver batch kan merges/releaseres uafhængigt uden at blokere drift på `main`. -- [x] Planen linker til konkrete dev-opgaver for lane-kørsel. - -## Batch-plan (merge-klare micro-PRs) - -### Batch A — SPA shell + routing baseline -**Mål:** Et stabilt SPA-skelet med route-struktur og guard-basics. - -**Leverancer (kodeområder)** -- `frontend/angular/src/app/app.routes.ts` (host/player entry routes + fallback) -- `frontend/angular/src/app/session-route-context.ts` (baseline route guards) -- `frontend/angular/src/app/app.component.*` (shell-nav + route outlet wiring) -- `lobby/templates/lobby/spa_shell.html` (kompatibel shell-entry ved SPA cutover) - -**Done-kriterier** -- Host- og player-entry routes kan åbnes uden runtime-fejl i samme SPA-shell. -- Route guards afviser ugyldige parametre deterministisk (ingen hard crash). -- `USE_SPA_UI=false` fortsætter med legacy-flow uden regression. - -**Checks før merge** -- `cd frontend/angular && npm test -- --run src/app/app.routes.spec.ts src/app/session-route-context.spec.ts` -- `cd frontend/angular && npm run build` -- Manual smoke: `/lobby/ui/host` + `/lobby/ui/player` (både `USE_SPA_UI=false` og `true`). - -**Rollback-note** -- Sæt `USE_SPA_UI=false` og redeploy; verificér legacy routes svarer 200. - -**Ikke med i batch A** -- Fuld gameplay-state synkronisering. -- Audio/polish og i18n finpudsning ud over baseline wiring. - ---- - -### Batch B — Session-state + host/player sync MVP -**Mål:** Korrekt synkronisering af session-state mellem host og spillerklient. - -**Leverancer (kodeområder)** -- `frontend/angular/src/app/features/host/host-shell.component.ts` -- `frontend/angular/src/app/features/player/player-shell.component.ts` -- `frontend/src/api/angular-client.ts` (MVP-kald for status/phase-overgange) - -**Done-kriterier** -- Host handlinger (`start`, `show`, `mix`, `score`, `next`, `finish`) afspejles hos player uden side-reload. -- Session-phase transitions er deterministiske i happy-path (`lobby -> question -> score -> next/finish`). -- Guardrails reducerer race-condition regressions ved hurtige phase-skift. - -**Checks før merge** -- `cd frontend/angular && npm test -- --run src/app/features/host/host-shell.component.spec.ts src/app/features/player/player-shell.component.spec.ts` -- `cd frontend && npm test -- --run tests/angular-api-client.test.ts` -- Manual smoke: host action -> player phase sync indenfor forventet latenstid. - -**Rollback-note** -- Slå `USE_SPA_UI=false`, redeploy, og kør hurtig gameplay-smoke i legacy flow. - -**Ikke med i batch B** -- Avanceret UX-polish/animation. -- Udvidet observability udenfor MVP-kritiske logs. - ---- - -### Batch C — Lobby/join/start minimal flow + release readiness -**Mål:** Gøre SPA MVP release-klar med fokus på stabilitet og driftssikkerhed omkring det minimale flow. - -**Leverancer (kodeområder)** -- `frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts` + relevante shell-tests -- `docs/UI_SMOKE.md` + `docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md` -- `CHANGELOG.md` release-input for SPA MVP lane - -**Done-kriterier** -- End-to-end minimal flow (`lobby -> join -> start`) er dokumenteret PASS i SPA. -- Fejl-/empty-/loading states for flowets kritiske skærme er verificeret. -- Driftsteam kan udføre cutover + rollback uden tvetydighed. - -**Checks før merge** -- `cd frontend/angular && npm test -- --run src/app/i18n-mvp-flow-smoke.spec.ts` -- `python manage.py test lobby.tests.LobbyFlowTests` -- Opdateret staging-smoke artifact med UTC tidsstempler og gate-resultat. - -**Rollback-note** -- Brug eksisterende playbook i `docs/spa-cutover-flag.md` (`USE_SPA_UI=false` + asset-version rollback). - -**Ikke med i batch C** -- Post-MVP featureudvidelser. -- Større refactors uden direkte release-værdi. - -## Rækkefølge og parallel-kørsel -- **Dependency-rækkefølge:** A -> B -> C. -- **Kan køres parallelt uden konflikt:** - - Test-/doc-forberedelse til C kan startes parallelt med B (ingen blokering af runtime-kode), men merges først efter B. - - Drift-smoke templates kan opdateres tidligt, så længe de ikke ændrer runtime-adfærd. -- **Må ikke køre parallelt:** - - Runtime routing/guard ændringer i A og session-sync logik i B på samme filer uden feature-flag koordinering. - -## Konkret lane-opgavebinding (dev-opgaver) -- Batch A PR: `feat/issue-251-batch-1-spa-shell-routing` -- Batch B PR: `feat/issue-251-batch-2-session-sync` -- Batch C PR: `feat/issue-251-batch-3-lobby-join-start-release-readiness` - -Hver PR skal linke tilbage til issue #251 og inkludere test-evidence + rollback-check. - -## Merge-gate for alle 3 batches -- Små PRs (mål: reviewbar størrelse, helst < ~300 netto-linjer når muligt). -- Grøn CI/checks før review-request. -- Tydelig PR-beskrivelse med: scope, test evidence, out-of-scope. -- Ingen skjulte sideeffekter på tværs af apps/domæner. diff --git a/docs/ISSUE-252-REACT-FALLBACK-TRIGGERS-ARTIFACT.md b/docs/ISSUE-252-REACT-FALLBACK-TRIGGERS-ARTIFACT.md deleted file mode 100644 index a13fe9d..0000000 --- a/docs/ISSUE-252-REACT-FALLBACK-TRIGGERS-ARTIFACT.md +++ /dev/null @@ -1,27 +0,0 @@ -# ISSUE-252 Artifact — React fallback trigger criteria (delivery-blocking only) - -Issue: **#252** - -## Leveret ændring -Dokumentationen i `docs/spa-cutover-flag.md` er opdateret med en dedikeret sektion: -- **React fallback trigger-kriterier (kun delivery-blocking)** -- klare **tilladelses-kriterier** (alle skal være opfyldt) -- tydelige **scope-limits** -- eksplicitte **ikke-tilladte** anvendelser - -## Acceptance mapping -1. **Clear trigger criteria** - - Definerer præcist hvornår fallback er tilladt: - - aktiv delivery-blocking fejl i Angular SPA - - ingen sikker Angular-fix inden release-vinduet - - rollback alene er utilstrækkelig for leveringsbehovet - - beslutning + evidens logges eksplicit (inkl. issue/incident-reference) -2. **Scope limits** - - Begrænset til delivery-blocking host/player-paths. - - Ingen feature-bundling eller ikke-kritiske ændringer. - - Midlertidig anvendelse kun i aktiv incident/release-vindue. -3. **When fallback is allowed** - - Kun når alle trigger-kriterier er opfyldt og dokumenteret. - -## Resultat -Issue #252 er dokumenteret med operationelle guardrails, så React fallback kun bruges i kontrollerede, leveringsblokerende situationer. diff --git a/docs/ISSUE-257-SHARED-I18N-KEYSPACE-FRONTEND-LOADER-ARTIFACT.md b/docs/ISSUE-257-SHARED-I18N-KEYSPACE-FRONTEND-LOADER-ARTIFACT.md deleted file mode 100644 index 51948c9..0000000 --- a/docs/ISSUE-257-SHARED-I18N-KEYSPACE-FRONTEND-LOADER-ARTIFACT.md +++ /dev/null @@ -1,32 +0,0 @@ -# ISSUE-257 Artifact — Shared i18n keyspace + frontend loader (da/en, Angular-first) - -Issue: **#257** (`[MVP][READY] #175-B: Shared i18n keyspace + frontend loader`) - -## Acceptance checklist - -- [x] **Delt key-strategi dokumenteret (frontend/backend)** - - Arkitektur/deling beskrevet i `docs/I18N_ARCHITECTURE.md`. - - Shared contract + keyspace source of truth: `shared/i18n/lobby.json`. - -- [x] **Frontend loader kan indlæse da+en med samme keyspace** - - Shared loader: `frontend/shared/i18n/lobby-loader.ts`. - - Angular-first integration via `frontend/angular/src/app/lobby-i18n.ts` (samme loader/samme keyspace). - - Locale-normalisering inkluderer underscore/hyphen variants (`da_DK` → `da`). - -- [x] **Minimal check for key-paritet da/en** - - `collectLocaleParityIssues(...)` i shared loader. - - Testet i `frontend/tests/lobby-loader.parity.test.ts`. - -- [x] **Ingen API-kontraktbrud** - - Contract-test: `frontend/tests/lobby-i18n.contract.test.ts`. - - Drift-check mellem manifest og katalog: `scripts/check_i18n_drift.py`. - -## Kørte checks - -```bash -python3 scripts/check_i18n_drift.py -cd frontend && npm test -- --run tests/lobby-loader.parity.test.ts tests/lobby-i18n.contract.test.ts -cd frontend/angular && npm test -- --run src/app/lobby-i18n.spec.ts src/app/i18n-mvp-flow-smoke.spec.ts -``` - -Resultat: alle checks grønne. diff --git a/docs/ISSUE-257-SHARED-I18N-LOADER-ARTIFACT.md b/docs/ISSUE-257-SHARED-I18N-LOADER-ARTIFACT.md deleted file mode 100644 index 1348913..0000000 --- a/docs/ISSUE-257-SHARED-I18N-LOADER-ARTIFACT.md +++ /dev/null @@ -1,37 +0,0 @@ -# ISSUE-257 Artifact — shared i18n keyspace + frontend loader (Angular-first) - -Issue: **#257** (`[MVP][READY] #175-B: Shared i18n keyspace + frontend loader (da/en, Angular-first)`) - -## Acceptance mapping - -### 1) Delt key-strategi dokumenteret (frontend/backend) -- Shared contract source: `shared/i18n/lobby.json` -- Architecture doc: `docs/I18N_ARCHITECTURE.md` -- Key-map/contract doc: `docs/ISSUE-226-SHARED-KEYMAP-LOCALE-CONTRACT.md` - -### 2) Frontend loader kan indlæse da+en med samme keyspace -- Shared loader: `frontend/shared/i18n/lobby-loader.ts` -- Angular-first consumer path: - - `frontend/src/spa/lobby-i18n.ts` - - Angular shell/tests continue to consume same shared catalog through shared loader contract. - -### 3) Minimal check for key-paritet da/en -- Guard test: `frontend/tests/lobby-loader.parity.test.ts` -- Contract test: `frontend/tests/lobby-i18n.contract.test.ts` - -### 4) Ingen API-kontraktbrud -- Frontend API contract smoke: - - `frontend/angular/src/app/api-contract-smoke.spec.ts` - - `frontend/tests/angular-api-client.test.ts` - -## Verification run (this lane) - -```bash -cd frontend -npm test -- --run tests/lobby-loader.parity.test.ts tests/lobby-i18n.contract.test.ts tests/angular-api-client.test.ts - -cd ../frontend/angular -npm test -- --run src/app/api-contract-smoke.spec.ts -``` - -Result: PASS (all selected suites green). diff --git a/docs/ISSUE-260-PHONE-CLIENT-NO-AUDIO-GUARD-ARTIFACT.md b/docs/ISSUE-260-PHONE-CLIENT-NO-AUDIO-GUARD-ARTIFACT.md deleted file mode 100644 index 8401951..0000000 --- a/docs/ISSUE-260-PHONE-CLIENT-NO-AUDIO-GUARD-ARTIFACT.md +++ /dev/null @@ -1,18 +0,0 @@ -# Issue #260 Artifact — Phone/client no-audio guard (primary-device only playback) - -## Scope -Added regression coverage for MVP audio policy to ensure phone/client flows never claim playback ownership, while primary-device playback stays unaffected. - -## Acceptance mapping -1. **client/phone triggers no playback** - - Existing test coverage retained in `player-shell.component.spec.ts`: - - `does not trigger original media play during player-shell init path` - - `installs secondary-device audio guard while player shell is mounted` -2. **primary device playback unaffected** - - New negative test in `player-shell.component.spec.ts`: - - `keeps primary-device playback untouched when no-audio capability is disabled` -3. **one negative test for phone audio** - - Existing negative path preserved: - - `does not trigger original media play during player-shell init path` -4. **no backend contract changes** - - Frontend test/docs-only scope; no backend contract files changed. diff --git a/docs/ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md b/docs/ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md deleted file mode 100644 index bfe3887..0000000 --- a/docs/ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md +++ /dev/null @@ -1,80 +0,0 @@ -# ISSUE-277 Artifact — shared i18n registry parity report (Django ↔ Angular MVP) - -Issue: **#277** (`[READY][#175][P3] Shared i18n registry artifact: backend/frontend keyspace parity report`) - -## Artifact metadata - -- `artifact_id`: `issue-277-shared-i18n-parity-report` -- `artifact_version`: `1.0` -- `catalog_source`: `shared/i18n/lobby.json` -- `generator`: `scripts/report_i18n_parity.py` - -## Naming/version rules (email-manager-inspired strategy) - -- **Single canonical artifact per issue**: issue-bundne rapporter navngives `docs/ISSUE---ARTIFACT.md`. -- **Stable artifact identity**: `artifact_id` ændres ikke ved tekstlige opdateringer i samme rapporttype; det er den faste reference i review/ops. -- **Explicit artifact versioning**: `artifact_version` bumpes, når rapportlogik eller scope ændres, så drift/review kan se forskel på format- vs. dataændringer. -- **Shared namespace first**: keys refereres med fulde navnerum (`frontend.ui.*`, `frontend.errors.*`, `backend.error_codes.*`, `backend.errors.*`) i stedet for lokale aliases i artefakter. -- **Source-of-truth before consumers**: rapporten afledes fra `shared/i18n/lobby.json`; Django/Angular beskrives som consumers af samme registry og ikke som parallelle kontrakter. - -## MVP-critical parity summary - -- Frontend UI gameplay keys checked: **16** → `OK` -- Frontend error keys checked: **7** → `OK` -- Backend gameplay/error codes checked: **9** → `OK` -- Distinct frontend error keys reached from backend MVP flow: **6** (`join_failed, nickname_invalid, nickname_taken, session_code_required, session_not_found, start_round_failed`) - -Status: **Shared locale matrix is aligned (`en`, `da`) and backend→frontend error handling is contract-complete for MVP-critical flow.** - -## Django ↔ Angular parity matrix (MVP-critical error contract) - -| Backend code (`backend.error_codes.*`) | Django message key (`backend.errors.*`) | Angular key (`frontend.errors.*`) | Locales `en/da` | Parity note | -|---|---|---|---|---| -| `session_code_required` | `session_code_required` | `session_code_required` | `OK` | 1:1 | -| `nickname_invalid` | `nickname_invalid` | `nickname_invalid` | `OK` | 1:1 | -| `session_not_found` | `session_not_found` | `session_not_found` | `OK` | 1:1 | -| `session_not_joinable` | `session_not_joinable` | `join_failed` | `OK` | mapped alias | -| `nickname_taken` | `nickname_taken` | `nickname_taken` | `OK` | 1:1 | -| `category_slug_required` | `category_slug_required` | `start_round_failed` | `OK` | many:1 collapse | -| `category_not_found` | `category_not_found` | `start_round_failed` | `OK` | many:1 collapse | -| `round_start_invalid_phase` | `round_start_invalid_phase` | `start_round_failed` | `OK` | many:1 collapse | -| `round_already_configured` | `round_already_configured` | `start_round_failed` | `OK` | many:1 collapse | - -## Scope notes - -- **Django** consumes backend codes/messages directly from `shared/i18n/lobby.json` via `lobby/i18n.py`. -- **Angular** consumes the same registry via `frontend/shared/i18n/lobby-loader.ts` and runtime helpers in `frontend/angular/src/app/lobby-i18n.ts`. -- **Parity in MVP** is therefore strongest on the shared error contract and locale matrix; gameplay UI labels are frontend-owned but still live in the same registry. - -## Verified MVP gameplay UI keyspace present in the shared registry - -- `frontend.ui.host.title` -- `frontend.ui.player.title` -- `frontend.ui.common.session_code` -- `frontend.ui.player.nickname` -- `frontend.ui.player.join` -- `frontend.ui.host.start_round` -- `frontend.ui.host.show_question` -- `frontend.ui.player.lie_label` -- `frontend.ui.player.submit_lie` -- `frontend.ui.player.submit_guess` -- `frontend.ui.host.mix_answers` -- `frontend.ui.host.calculate_scores` -- `frontend.ui.host.load_scoreboard` -- `frontend.ui.host.final_leaderboard` -- `frontend.ui.player.final_leaderboard` -- `frontend.ui.common.points_short` - -## Concrete deviations / follow-up items - -1. **Error granularity collapse remains intentional**: backend codes `category_slug_required, category_not_found, round_start_invalid_phase, round_already_configured` all map to `frontend.errors.start_round_failed`. Follow-up only if product wants case-specific Angular copy instead of one shared host failure message. -2. **Frontend-only fallback copy is not mirrored in Django**: `frontend.errors.unknown` and `frontend.errors.session_fetch_failed` are Angular-side resilience keys, not backend contract keys. Follow-up if API responses should expose stable backend equivalents for these states. -3. **Gameplay UI labels are registry-shared but not backend-rendered**: `frontend.ui.host.*`, `frontend.ui.player.*`, and `frontend.ui.common.*` are available in the shared artifact, but Django currently consumes only the backend error slice. Follow-up only if server-rendered views must guarantee the same UI label surface as Angular. - -## Re-run - -```bash -python3 scripts/check_i18n_drift.py -python3 scripts/report_i18n_parity.py -python3 scripts/check_i18n_parity_artifact.py -``` diff --git a/docs/ISSUE-278-SMOKE-E2E-GATE-ARTIFACT.md b/docs/ISSUE-278-SMOKE-E2E-GATE-ARTIFACT.md deleted file mode 100644 index 361b3d3..0000000 --- a/docs/ISSUE-278-SMOKE-E2E-GATE-ARTIFACT.md +++ /dev/null @@ -1,36 +0,0 @@ -# Issue #278 Artifact — smoke/e2e gate for da+en locale flow and primary-only audio - -## Scope -Acceptance for `[READY][#175][P4]`: -1. Verify one MVP host+player smoke run in `en`. -2. Verify one MVP host+player smoke run in `da`. -3. Verify audio routing remains `primary-device only` so phone/player clients never take playback ownership. - -Dette er en gate-/evidensleverance. Ingen ny produktfunktion ud over test/verifikation. - -## Implemented smoke gate -Angular smoke spec: `frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts` - -The gate now runs two explicit locale scenarios: -- `en`: host refresh/start-round copy + player submit-guess copy -- `da`: samme flow med dansk copy - -Audio-policy delen af samme smoke-spec verificerer: -- host/primary playback path er uændret før player mount -- player mount installerer no-audio guard på secondary device -- guard fjernes igen ved unmount, så primary path fortsat er eneste aktive output - -## Recommended verification command -Køres fra `frontend/angular`: - -```bash -npm test -- --run src/app/i18n-mvp-flow-smoke.spec.ts src/app/lobby-i18n.spec.ts src/app/features/player/player-shell.component.spec.ts -``` - -## Why this is the gate -- `i18n-mvp-flow-smoke.spec.ts` giver en lille, samlet smoke/e2e-lignende verifikation af host+player i begge locale-kontekster. -- `lobby-i18n.spec.ts` holder shared locale propagation + contract fallback grøn. -- `player-shell.component.spec.ts` dækker den dybere regressionflade for audio-guard på secondary device. - -## Conclusion -Gate’en verificerer nu eksplicit begge locale-runs (`da` + `en`) og bekræfter primary-only audio routing i MVP-flowet. diff --git a/docs/ISSUE-279-I18N-MVP-CLOSEOUT.md b/docs/ISSUE-279-I18N-MVP-CLOSEOUT.md deleted file mode 100644 index 4855b43..0000000 --- a/docs/ISSUE-279-I18N-MVP-CLOSEOUT.md +++ /dev/null @@ -1,168 +0,0 @@ -# ISSUE-279 — i18n MVP close-out note - -Issue: **#279** (`[READY][#175][P5] MVP close-out note: migration/changelog + release-readiness checklist for i18n`) - -## Scope - -Dette dokument lukker MVP-sporet for issue #175 med tre konkrete ting: - -1. en migrationsnote for release/deploy, -2. changelog-indhold der kan genbruges i næste release-note, -3. en release-readiness checkliste for i18n, forankret i et verificeret snapshot af `main` ved reviewtidspunktet. - -Repo-state ved review-opdatering: -- `main` peger nu på merge commit `1bc4c27` (PR #283), og inkluderer også PR #282 via merge commit `6ad5430`. -- Denne note er opdateret mod repo-tilstanden verificeret 2026-03-13 UTC, ikke en løbende garanti for senere `main`-ændringer. -- Denne revision afløser de tidligere snapshot-antagelser fra PR-historikken, hvor #282/#283 endnu ikke var landet. -- Der er ingen åbne release-afklaringer tilbage for PR #282/#283; begge er allerede landet på `main`. - -## Current i18n MVP state on `main` - -Følgende er allerede til stede på `main`: - -- **Shared contract** i `shared/i18n/lobby.json` - - default locale: `en` - - supported locales: `en`, `da` - - fælles frontend/backend keyspace + fallback-regler -- **Django bootstrap** via `partyhub/i18n_bootstrap.py` og `partyhub/settings.py` - - `LocaleMiddleware` aktiv - - `LANGUAGE_CODE` + `LANGUAGES` bootstrappes fra shared catalog -- **Backend locale/error flow** via `lobby/i18n.py` - - normalisering af locale-tags - - locale-aware fejlpayload med `error_code`, `error`, `locale` - - fallback til `en` når locale eller oversættelse mangler -- **Angular MVP wiring** via - - `frontend/shared/i18n/lobby-loader.ts` - - `frontend/angular/src/app/lobby-i18n.ts` - - host/player shells med locale switch og shared copy-opslag -- **Drift/parity guardrails** - - `shared/i18n/key-manifest.json` - - `scripts/check_i18n_drift.py` - - frontend parity/contract tests -- **Existing documentation/artifacts** - - `docs/I18N_ARCHITECTURE.md` - - `docs/ISSUE-175-I18N-SHARED-CONTRACT-ARTIFACT.md` - - `docs/ISSUE-225-BACKEND-I18N-BASELINE-ARTIFACT.md` - - `docs/ISSUE-257-SHARED-I18N-KEYSPACE-FRONTEND-LOADER-ARTIFACT.md` - - `docs/ISSUE-207-I18N-AUDIO-SMOKE-ARTIFACT.md` - - `docs/i18n-drift-check.md` - -## Migration note for release - -### Schema impact - -**Der er ingen nye Django-migrations i selve i18n-MVP-sporet på `main`.** - -Den i18n-relaterede leverance ligger i shared catalog, locale-bootstrap, error-payload-kontrakt, Angular wiring og test/documentation. Den kræver derfor ikke en særskilt i18n-database-migration for at gå i release. - -### Release/deploy expectation - -Selv om issue #279 ikke introducerer schemaændringer, skal release-flow stadig følge repoets generelle migreringsgate: - -```bash -python manage.py makemigrations --check --dry-run -python manage.py migrate --check --noinput -``` - -Hvorfor: release-policyen kræver, at vi undgår code/schema drift, og staging-smoke-suiten forventer eksplicit migration consistency check. - -### Praktisk migrationskonklusion - -Til release-notes/deploy-runbook kan i18n-sporet beskrives sådan her: - -- **Migration impact:** none for i18n MVP itself -- **Deploy requirement:** run standard Django migration consistency checks anyway -- **Rollback note:** rollback er primært kode-/asset-baseret (shared catalog, frontend bundles, backend locale resolver), ikke schema-baseret - -## Suggested changelog content - -Følgende tekst kan bruges direkte i næste unreleased/release-sektion: - -```markdown -### i18n -- Shared da/en lobby i18n contract is now wired across Django and Angular MVP flows via `shared/i18n/lobby.json`. -- Backend error payloads expose stable locale-aware fields (`error_code`, `error`, `locale`) with fallback to English for unsupported locales. -- Angular host/player shells now consume shared i18n copy, persist preferred locale, and keep audio-policy messaging aligned with the shared catalog. -- Added repo guardrails for i18n drift/parity through the shared key manifest, drift checker, and focused frontend/backend contract tests. -- Release migration impact for the i18n MVP is **none** beyond the standard Django migration consistency checks. -``` - -Kort version til annoterede release-notes: - -```markdown -## i18n MVP close-out -- Shared da/en contract is active across backend + Angular MVP shell. -- Locale fallback remains `en` for unsupported requests and missing translations. -- No i18n-specific schema migration is required; keep standard `migrate --check --noinput` in release verification. -``` - -## Release-readiness checklist for i18n - -Status er vurderet mod verificeret snapshot `main@1bc4c27` (reviewet 2026-03-13 UTC, inkl. PR #282/#283). - -### 1) Shared contract and locale behavior - -- [x] Shared catalog findes i `shared/i18n/lobby.json`. -- [x] Default/supported locales er dokumenteret og implementeret som `en` + `da`. -- [x] Backend bruger shared contract til locale-aware fejlbeskeder. -- [x] Frontend/Angular bruger shared loader + shared keyspace. -- [x] Fallback-regel til `en` er dokumenteret og testet. - -### 2) Verification artifacts and local checks - -- [x] Arkitektur-note findes: `docs/I18N_ARCHITECTURE.md`. -- [x] Baseline artifact for issue #175 findes. -- [x] Backend artifact for issue #225 findes. -- [x] Frontend/shared loader artifact for issue #257 findes. -- [x] Drift-check dokumentation findes i `docs/i18n-drift-check.md`. -- [x] Parity artifact fra issue #277 er på `main` via PR #282 (merge commit `6ad5430`). - -### 3) Code readiness on current branch topology - -- [x] Angular MVP host/player i18n flow er på `main` (PR #281). -- [x] Shared locale/bootstrap wiring er på `main`. -- [x] Django i18n hardening fra issue #275 er på `main` via PR #283 (merge commit `1bc4c27`). -- [x] PR #283 er ikke længere en separat release-afklaring; hardeningen er allerede indarbejdet på `main`. - -### 4) Release gate before shipping i18n as “done” - -- [x] PR #282 er allerede merged, så parity-artifact-status er afklaret på `main`. -- [x] PR #283 er allerede merged, så backend hardening-status er afklaret på `main`. -- [ ] Kør drift-check fra repo root: - ```bash - python3 scripts/check_i18n_drift.py - ``` -- [ ] Kør backend i18n regressions: - ```bash - . .venv/bin/activate && python manage.py test \ - partyhub.tests_i18n_bootstrap \ - lobby.tests.I18nResolverTests - ``` -- [ ] Kør frontend shared-contract/parity checks: - ```bash - cd frontend && npm test -- --run \ - tests/lobby-loader.parity.test.ts \ - tests/lobby-i18n.contract.test.ts - ``` -- [ ] Kør Angular MVP locale smoke: - ```bash - cd frontend/angular && npm test -- --run \ - src/app/lobby-i18n.spec.ts \ - src/app/i18n-mvp-flow-smoke.spec.ts \ - src/app/features/host/host-shell.component.spec.ts \ - src/app/features/player/player-shell.component.spec.ts - ``` -- [ ] Bekræft standard migration consistency gate: - ```bash - . .venv/bin/activate && python manage.py makemigrations --check --dry-run - . .venv/bin/activate && python manage.py migrate --check --noinput - ``` -- [ ] Følg `docs/RELEASE_POLICY.md`: staging deploy, `/healthz`, smoke-resultat og changelog-reference før tag. - -## Close-out conclusion - -**Konklusion:** i18n-MVP'en er implementeret på `main`, og issue #279 leverer den manglende release-/migration-closeout dokumentation uden nye kodeændringer i app-logikken. - -PR #282 (parity artifact) og PR #283 (Django i18n hardening) er nu begge merged på `main`, så close-out-noten, changelog-teksten og release-readiness-checklisten kan behandles som indbyrdes konsistente for det verificerede snapshot. - -Det betyder, at de resterende release-gates for i18n nu er de almindelige verificeringstrin (drift-check, backend/frontend-smoke, migrations-konsistens, staging deploy og changelog-reference) — ikke længere afklaring af om #282/#283 skal lande. diff --git a/docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md b/docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md deleted file mode 100644 index 2aec3b7..0000000 --- a/docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md +++ /dev/null @@ -1,22 +0,0 @@ -# Issue #287 — Canonical round-flow backend artifact - -## State-transition matrix - -| Trigger | From | To | Server-owned effect | -|---|---|---|---| -| `POST /lobby/sessions/{code}/rounds/start` | `lobby` | `lie` | Opretter `RoundConfig`, vælger/låser konkret `RoundQuestion`, eksponerer prompt + lie-deadline i samme svar | -| Sidste gyldige `submit_lie` for aktivt spørgsmål | `lie` | `guess` | Dedupe/shuffle `correct_answer + lies`, persisterer `mixed_answers`, broadcaster `phase.guess_started` | -| Sidste gyldige `submit_guess` for aktivt spørgsmål | `guess` | `reveal` | Beregner score deterministisk, persisterer `ScoreEvent` + opdaterede `Player.score`, returnerer canonical reveal payload | -| Første canonical state-read efter resolved reveal (`session_detail`, og idempotent `GET /scoreboard` hvis state allerede er resolved) | `reveal` | `scoreboard` | Promoverer scoreboard som state, broadcaster `phase.scoreboard`, eksponerer leaderboard + readiness | -| `POST /lobby/sessions/{code}/rounds/next` | `scoreboard` | `lie` | Increment round counter, kopierer seneste `RoundConfig`, vælger/låser næste spørgsmål i samme kategori og broadcaster `phase.lie_started` | -| `POST /lobby/sessions/{code}/finish` | `scoreboard` | `finished` | Fryser slutresultat og returnerer final leaderboard | - -## Flow-log (happy path) - -1. Host starter runde med kategori. -2. Server vælger straks spørgsmål og går i `lie`. -3. Spillere sender løgne; sidste submission auto-advancer til `guess`. -4. Spillere sender gæt; sidste submission auto-advancer til `reveal` og scorer runden. -5. Næste canonical state-read promoverer resolved reveal til `scoreboard`; state findes uden separat debug-knap. -6. Host kan nu kun vælge `next round` eller `finish game`. -7. `next round` starter næste runde direkte i `lie` med nyt konkret spørgsmål; ingen mellem-hop tilbage til `lobby`. diff --git a/docs/ISSUE-301-CLIENT-ACTION-GATING-ARTIFACT.md b/docs/ISSUE-301-CLIENT-ACTION-GATING-ARTIFACT.md deleted file mode 100644 index 51cc364..0000000 --- a/docs/ISSUE-301-CLIENT-ACTION-GATING-ARTIFACT.md +++ /dev/null @@ -1,52 +0,0 @@ -# Issue #301 Artifact — Client action gating from canonical phase state - -Refs: #287, #301 - -## What changed - -Frontend host/player shells now prefer the canonical phase exposed by `phase_view_model.current_phase` when deciding: - -- which gameplay actions are enabled -- whether reveal data should still be shown -- which SPA hash-route should represent the active game state - -This tightens the #301 slice so the client stays aligned with backend canonicalisation even when `session.status` lags during reveal/scoreboard promotion. - -## Gated UI actions by phase - -### Lobby -- **Host:** `startRound` -- **Player:** `join` - -### Bluff / lie -- **Host:** `showQuestion` -- **Player:** `submitLie` -- **Blocked:** guess submission, scoreboard load, next round, finish game - -### Guess -- **Host:** `mixAnswers`, `calculateScores` -- **Player:** `submitGuess` -- **Blocked:** lie submission, scoreboard load, next round, finish game - -### Reveal -- **Host:** `loadScoreboard` -- **Player:** display-only reveal state -- **Blocked:** start next round, finish game, guess/lie submission - -### Scoreboard -- **Host:** `startNextRound`, `finishGame` -- **Player:** display-only reveal/scoreboard state -- **Blocked:** scoreboard reload, guess/lie submission - -## Test evidence - -Targeted tests added/updated for: - -- host shell canonical gating and route sync when `current_phase` differs from `session.status` -- player shell canonical gating and route sync when `current_phase` differs from `session.status` -- shared gameplay phase machine gating from canonical permissions -- shared API mapper contract coverage, including reveal/scoreboard payload stability - -## Contract note - -No backend protocol redesign was introduced. This follow-up only preserves and consumes the existing canonical phase/action contract more strictly on the client side. diff --git a/docs/ISSUE-302-CANONICAL-LOOP-EVIDENCE.md b/docs/ISSUE-302-CANONICAL-LOOP-EVIDENCE.md deleted file mode 100644 index aa30eac..0000000 --- a/docs/ISSUE-302-CANONICAL-LOOP-EVIDENCE.md +++ /dev/null @@ -1,55 +0,0 @@ -# Issue #302 Evidence — canonical bluff → guess → reveal → scoreboard regression - -## Runnable command - -```bash -python manage.py migrate --noinput -python manage.py smoke_staging --artifact docs/artifacts/issue-302-canonical-loop-smoke.json -``` - -`migrate` is the normal local bootstrap precondition when the database has not been initialized yet; the regression evidence itself is produced by `smoke_staging`. - -## What the regression proves - -`smoke_staging` now exercises one full canonical round and fails fast with step-specific diagnostics if any of these break: - -1. `start_round` lands the session in `lie` and returns a concrete `round_question_id`. -2. Final `submit_lie` auto-advances the session to `guess` and exposes mixed answers containing both the correct answer and player bluffs. -3. Final `submit_guess` auto-advances the session to `reveal` and returns the canonical reveal payload. -4. The reveal payload includes: - - correct answer - - all lies - - all guesses - - fooled-player references for bluff hits -5. The first canonical state read after reveal promotes the session to `scoreboard`. -6. Scoreboard promotion preserves the same reveal payload and exposes a leaderboard with `scoreboard_ready=true`. - -## Artifact shape - -When `--artifact` is provided, the JSON file records: - -- the exact smoke command -- session code and round question id -- deterministic guess plan used to produce both bluff hits and one correct guess -- per-step evidence for: - - `create_session` - - `join_players` - - `start_round` - - `auto_guess_transition` - - `submit_guesses` - - `auto_reveal_transition` - - `auto_scoreboard_transition` - - `finish_game` -- reveal summary (`correct_answer`, lie/guess counts, fooled-player ids, correct guess player ids) -- promoted scoreboard leaderboard payload - -## Targeted test coverage - -Backend regression coverage lives in `lobby/tests.py`: - -- `test_smoke_staging_command_runs_full_flow` -- `test_smoke_staging_writes_phase_evidence_artifact_when_requested` - -Together they ensure the command stays runnable in normal workflow and that the evidence artifact contains phase-by-phase proof instead of only a generic pass/fail. - -Refs #287 #302 diff --git a/docs/ISSUE-310-HOST-TRANSITION-IDEMPOTENCY-ARTIFACT.md b/docs/ISSUE-310-HOST-TRANSITION-IDEMPOTENCY-ARTIFACT.md deleted file mode 100644 index 8a06681..0000000 --- a/docs/ISSUE-310-HOST-TRANSITION-IDEMPOTENCY-ARTIFACT.md +++ /dev/null @@ -1,33 +0,0 @@ -# Issue #310 — Host transition idempotency and error catalog - -## Scope - -This artifact hardens the two host-owned scoreboard exits in the canonical gameplay flow: - -- `POST /lobby/sessions/{code}/rounds/next` -- `POST /lobby/sessions/{code}/finish` - -The goal is retry-safe host behavior when the scoreboard transition already succeeded server-side but the client retries because of a duplicate click, timeout, or lost response. - -## Transition contract - -| Endpoint | First valid transition | Idempotent replay state | Replay result | Broadcast behavior | Still-invalid states | -|---|---|---|---|---|---| -| `POST /lobby/sessions/{code}/rounds/next` | `scoreboard -> lie` | `lie` with persisted current-round bootstrap (`RoundConfig` + `RoundQuestion`) | `200 OK` with the same canonical next-round payload shape | `phase.lie_started` fires only on the first transition | `lobby`, `guess`, `reveal`, `finished` → `next_round_invalid_phase` | -| `POST /lobby/sessions/{code}/finish` | `scoreboard -> finished` | `finished` | `200 OK` with the same final leaderboard payload shape | `phase.game_over` fires only on the first transition | `lobby`, `lie`, `guess`, `reveal` → `finish_game_invalid_phase` | - -## Error catalog notes - -No new backend error codes were introduced for this slice. - -The contract change is behavioral: - -- `next_round_invalid_phase` now means the session is in a phase where the scoreboard → next-round transition has **not** already been completed, or the expected bootstrap artifact for the already-started round is missing. -- `finish_game_invalid_phase` now means the session is in a phase where the scoreboard → finish transition has **not** already been completed. -- Successful replays are returned as normal `200 OK` canonical responses instead of phase errors. - -## Acceptance evidence - -- Repeated `rounds/next` calls after a successful scoreboard exit return the same canonical lie/bootstrap payload without incrementing the round twice. -- Repeated `finish` calls after a successful scoreboard exit return the same finished leaderboard payload without rebroadcasting game-over. -- Wrong-phase calls outside those replay states still return the existing shared error codes. diff --git a/docs/ISSUE-312-LOBBY-FUPOGFAKTA-EXTRACTION-MAP.md b/docs/ISSUE-312-LOBBY-FUPOGFAKTA-EXTRACTION-MAP.md deleted file mode 100644 index 9b5b6d2..0000000 --- a/docs/ISSUE-312-LOBBY-FUPOGFAKTA-EXTRACTION-MAP.md +++ /dev/null @@ -1,202 +0,0 @@ -# Issue #312 — FupOgFakta extraction map for logic currently living in `lobby/` - -Parent: #311 -Issue: #312 - -## Purpose - -This artifact documents the concrete FupOgFakta-specific logic that still lives in `lobby/`, separates it from true platform/session concerns, and names the intended destination ownership before any larger code move happens. - -It is intentionally an inventory + extraction plan only. It does **not** perform the full move. - -## Architectural boundary this map is enforcing - -The target boundary is already described in: - -- `docs/plans/2026-03-09-fupogfakta-game-engine-design.md` -- `docs/plans/2026-03-09-fupogfakta-implementation-plan.md` -- `docs/ARCHITECTURE.md` - -Those docs consistently describe: - -- `lobby/` as the **platform layer** for session lifecycle, player presence, host ownership, generic game-run orchestration, and transport-facing platform concerns. -- `fupogfakta/` as the **game cartridge** that owns question selection rules, round config semantics, lie/guess/reveal/scoreboard flow, answer mixing, scoring, and game-specific response/event payloads. - -In other words: - -- **Platform (`lobby/`)** should know that a session exists and that a game can be started/observed. -- **Cartridge (`fupogfakta/`)** should know what a lie is, what a guess is, how answers are mixed, when phases advance, and what payload shape those game phases expose. - -## Summary split - -### Generic platform/session concerns that belong in `lobby/` - -These are not FupOgFakta-specific and should remain platform-owned: - -- Session code parsing/generation: - - `lobby/views.py::_generate_session_code` - - `lobby/views.py::_normalize_session_code` - - `lobby/views.py::_create_unique_session_code` -- Generic request parsing: - - `lobby/views.py::_json_body` -- Session lifecycle and player presence endpoints: - - `lobby/views.py::create_session` - - `lobby/views.py::join_session` - - `lobby/views.py::session_detail` **only for the generic session/player shell part** -- Generic ownership / host authorization checks -- Generic session detail payload fields: - - `session.code` - - `session.status` - - `session.host_id` - - `session.current_round` - - `session.players_count` - - `players[].id|nickname|score|is_connected` -- Generic i18n/error transport helper usage: - - `lobby/i18n.py` - - `api_error(...)` -- Route mounting / namespace ownership in `lobby/urls.py` for platform routes only - -### FupOgFakta-specific logic currently misplaced in `lobby/` - -These items are game-cartridge logic and should move behind `fupogfakta/` ownership: - -- Round question selection by category and previously-used questions -- Lie-phase payload construction and lie timer semantics -- Mixed-answer preparation for bluff gameplay -- Guess correctness / fooled-player detection -- Bluff/correct-answer score resolution -- Reveal payload construction -- Reveal → scoreboard promotion rules -- Start round / mix answers / submit lie / submit guess / calculate scores / reveal scoreboard / next round / finish game gameplay endpoints -- Phase view-model booleans that encode FupOgFakta rules rather than generic platform readiness - -## Extraction map - -| Source file | Current function / concern | Why it is FupOgFakta-specific | Intended destination / owner | -| --- | --- | --- | --- | -| `lobby/views.py` | `_build_player_ref(player)` | Helper is only used to shape FupOgFakta reveal payloads; not a generic platform concern today. | `fupogfakta/serializers.py` or `fupogfakta/payloads.py` owned by cartridge. | -| `lobby/views.py` | `_build_reveal_payload(round_question)` | Encodes FupOgFakta reveal contract: lies, guesses, fooled-player refs, correct answer, prompt. | `fupogfakta/payloads.py::build_reveal_payload` or equivalent cartridge response builder. | -| `lobby/views.py` | `_build_leaderboard(session)` | Current implementation is generic-ish, but used exclusively by FupOgFakta scoreboard/finish flow and coupled to that response shape. | Short term: keep shared helper if multiple games will consume same contract; otherwise move to `fupogfakta/payloads.py` until a true shared scoreboard contract exists. | -| `lobby/views.py` | `_get_current_round_question(session)` | Depends on FupOgFakta `RoundQuestion` model and current-round semantics. | `fupogfakta/services/rounds.py` or `fupogfakta/queries.py`. | -| `lobby/views.py` | `_select_round_question(session, round_config)` | Implements FupOgFakta question selection rules by category, active questions, and not-yet-used question set. | `fupogfakta/services/rounds.py::select_round_question`. | -| `lobby/views.py` | `_build_lie_started_payload(session, round_config, round_question)` | Builds a FupOgFakta event/response contract for lie phase, including category, prompt, lie deadline, round question id. | `fupogfakta/payloads.py::build_lie_started_payload`. | -| `lobby/views.py` | `_prepare_mixed_answers(round_question)` | Bluff-answer dedupe and shuffle is core FupOgFakta gameplay logic. | `fupogfakta/services/answers.py::prepare_mixed_answers`. | -| `lobby/views.py` | `_resolve_scores(session, round_question, round_config)` | Applies FupOgFakta scoring rules for correct guesses and successful bluffs; depends on `Guess`, `LieAnswer`, `ScoreEvent`, `points_correct`, `points_bluff`. | `fupogfakta/services/scoring.py::resolve_scores`. | -| `lobby/views.py` | `_maybe_promote_reveal_to_scoreboard(session)` | Encodes FupOgFakta reveal completion semantics and scoreboard transition trigger. | `fupogfakta/services/phases.py::maybe_promote_reveal_to_scoreboard`. | -| `lobby/views.py` | `_build_phase_view_model(session, players_count, has_round_question)` | Most booleans are not platform-generic; they encode FupOgFakta phase names (`lie`, `guess`, `scoreboard`) and MVP constraints (`3-5 players`, round-question readiness, next-round/finish gating). | Split: keep platform-shell fields in `lobby/`; move game-specific readiness/action flags to `fupogfakta/payloads.py::build_phase_view_model` or cartridge driver payload builder. | -| `lobby/views.py` | `start_round(request, code)` | Starts FupOgFakta round, binds category, creates `RoundConfig`, selects `RoundQuestion`, transitions to `LIE`, broadcasts `phase.lie_started`. | `fupogfakta/views.py` or cartridge command handler behind a future `GameDriver.on_game_start` / round bootstrap service. | -| `lobby/views.py` | `show_question(request, code)` | Emits lie-phase question payload using FupOgFakta `RoundQuestion` and `RoundConfig`. | `fupogfakta/views.py` or remove entirely once canonical driver flow owns the transition. | -| `lobby/views.py` | `submit_lie(request, code, round_question_id)` | Pure FupOgFakta gameplay endpoint: lie validation, deadline semantics, auto-advance to guess phase, `phase.guess_started` payload. | `fupogfakta/views.py::submit_lie` (or cartridge intent handler). | -| `lobby/views.py` | `mix_answers(request, code, round_question_id)` | Manual FupOgFakta host action for lie→guess transition and answer mixing. | `fupogfakta/views.py` short term; long term likely deleted in favor of cartridge-driven automatic transition. | -| `lobby/views.py` | `submit_guess(request, code, round_question_id)` | Pure FupOgFakta gameplay endpoint: validates answer choice, resolves correctness/bluff source, auto-calculates scores, transitions to reveal. | `fupogfakta/views.py::submit_guess` plus `fupogfakta/services/scoring.py` and `fupogfakta/services/phases.py`. | -| `lobby/views.py` | `reveal_scoreboard(request, code)` | FupOgFakta reveal/scoreboard progression, not a generic platform capability. | `fupogfakta/views.py::reveal_scoreboard` or cartridge phase service. | -| `lobby/views.py` | `start_next_round(request, code)` | FupOgFakta next-round bootstrap: copies prior `RoundConfig`, increments round, picks next question, re-enters lie phase. | `fupogfakta/services/rounds.py::start_next_round` plus cartridge-owned endpoint/driver integration. | -| `lobby/views.py` | `finish_game(request, code)` | Current finish path is tied to FupOgFakta scoreboard semantics and winner payload. | `fupogfakta/views.py::finish_game` until a truly generic platform finish contract exists. | -| `lobby/views.py` | `calculate_scores(request, code, round_question_id)` | Explicit FupOgFakta score resolution endpoint. | `fupogfakta/services/scoring.py` and/or remove when fully absorbed by cartridge phase driver. | -| `lobby/urls.py` | Gameplay routes for rounds, lies, guesses, scoreboard, finish | These route names expose FupOgFakta-specific phase/actions from the platform namespace. | Re-home under `fupogfakta/urls.py` or leave mounted under `/lobby/sessions/...` only as a temporary façade delegating to cartridge-owned code. | -| `lobby/tests.py` | `StartRoundTests`, `LieSubmissionTests`, `MixAnswersTests`, `GuessSubmissionTests`, `CanonicalRoundFlowTests`, `ScoreCalculationTests`, `RevealRoundFlowTests`, `SessionDetailRoundQuestionTests`, `SessionDetailPhaseViewModelTests`, `SmokeStagingCommandTests` | These test classes verify FupOgFakta game flow rather than platform mechanics. | Move/split into `fupogfakta/tests/` with only session creation/join/platform transport tests left in `lobby/tests.py`. | -| `lobby/management/commands/smoke_staging.py` | End-to-end gameplay smoke through lies/guesses/finish | Script executes one concrete game flow and should be cartridge-aware, not platform-owned. | `fupogfakta/management/commands/` or a shared smoke harness that delegates into cartridge-specific scenario runners. | - -## Recommended ownership split by module - -### Keep in `lobby/` - -- Session creation/join and session-code lifecycle -- Generic player membership/presence reads -- Generic auth/host checks helpers (if extracted from views) -- Generic API error/i18n plumbing -- Future `GameRun` / driver orchestration, timers, and cartridge dispatch -- A slim generic `session_detail` envelope that can embed cartridge payloads under a dedicated game key - -### Move to `fupogfakta/` - -- Round state queries -- Question selection -- Lie/guess/reveal/scoreboard/finish transition rules -- Score calculation -- Answer mixing -- Gameplay payload/response builders -- Gameplay endpoints and tests -- Gameplay smoke command - -## Explicit boundary for `session_detail` - -`session_detail` is currently mixed. - -### Generic part that should remain platform-owned - -- Session identity/status metadata -- Player list / presence list -- Generic host/player capability envelope if it is game-agnostic - -### FupOgFakta part that should move or be delegated - -- `round_question` payload -- `reveal` payload -- `scoreboard` payload -- `phase_view_model` fields keyed to `lie`, `guess`, `scoreboard`, `finished`, `question_ready`, and 3–5-player MVP rules - -A clean future shape would be: - -```json -{ - "session": {"code": "ABC123", "status": "active", "game_type": "fupogfakta"}, - "players": [...], - "game": { - "phase": "lie", - "payload": {"round_question": {...}, "reveal": null, "scoreboard": null} - } -} -``` - -That makes `lobby/` the shell and `fupogfakta/` the authority for game-state payloads. - -## Concrete extraction sequence - -1. **Move pure helpers first** - - `_get_current_round_question` - - `_select_round_question` - - `_prepare_mixed_answers` - - `_resolve_scores` - - `_build_lie_started_payload` - - `_build_reveal_payload` -2. **Move gameplay endpoints behind cartridge-owned service functions** - - `submit_lie` - - `submit_guess` - - `start_round` - - `start_next_round` - - `finish_game` - - `reveal_scoreboard` - - `calculate_scores` -3. **Slim `session_detail` into platform envelope + delegated cartridge payload** -4. **Move gameplay tests out of `lobby/tests.py`** -5. **Optionally leave compatibility routes in `lobby/urls.py` as a façade** until clients are rewired - -## Risks this map is explicitly preventing - -- Moving only models but leaving hidden phase-transition rules in `lobby/views.py` -- Treating `session_detail` as platform-generic while it still leaks cartridge payload semantics -- Leaving scoreboard/reveal transition logic behind as an undocumented coupling -- Splitting tests incorrectly so regressions stay "green" in `lobby/` while FupOgFakta behavior silently drifts - -## Decision - -For #311 / #312, the repository should treat the following as **game-specific and extraction candidates**: - -- round-question selection -- lie/guess/reveal/scoreboard/finish transitions -- answer mixing -- score resolution -- reveal/scoreboard payload builders -- FupOgFakta-specific session-detail subpayloads -- gameplay flow tests and smoke command - -And it should treat the following as **platform-generic**: - -- session identity/lifecycle -- player presence/membership -- host authorization shell -- generic error transport -- future game-driver dispatch/orchestration - -That is the explicit `lobby` vs `fupogfakta` boundary this issue needs before code extraction proceeds. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..2ad2688 --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,116 @@ +# Protokol: server ↔ klient + +Al spilkommunikation går over én WebSocket pr. skærm. Serveren er autoritativ: klienten sender **handlinger** og modtager **hele tilstanden** (et snapshot tilpasset modtagerens rolle) efter hver ændring. Klienten gemmer intet spil-state ud over sin `token` og tegner altid fra seneste snapshot. + +## Endpoints + +| Sti | Hvem | Hvad | +| --- | --- | --- | +| `GET /` | spillere/publikum | Join-side (`?code=ABCD` udfylder koden) | +| `GET /host/` | vært (Authentik) | Opret nyt spil | +| `POST /host/new` | vært | Opretter rum, redirect til `/host//` | +| `GET /host//` | vært | Storskærm | +| `GET /qr/.svg` | alle | QR-kode til `https:///?code=` | +| `WS /ws/play//` | spillere/publikum | | +| `WS /ws/host//` | vært (Authentik) | | + +Koder er 4 store bogstaver (`BCDFGHJKLMNPQRSTVWXZ`), fx `KLMT`. + +## Klient → server + +Alle beskeder er JSON `{"action": "...", ...}`. + +**Spiller/publikum (`/ws/play/`)** + +| action | felter | hvornår | +| --- | --- | --- | +| `join` | `name` (1-12 tegn) | første gang. Svar: `joined`. Er der 8 spillere, eller er spillet i gang, bliver man publikum. | +| `resume` | `token` | ved genforbindelse (token fra `localStorage["wpp:"+code]`). Ukendt token → `error` `unknown_token`, klienten viser join-formularen igen. | +| `start` | | VIP (første spiller) starter fra lobby | +| `choose_category` | `category_id` | kun den valgte spiller i fasen `category` | +| `submit_lie` | `text` (1-40 tegn) | fase `lie` | +| `lie_for_me` | | fase `lie`; svar: `suggestion` | +| `guess` | `answer_id` | fase `guess`; spillere og publikum, én gang | +| `like` | `answer_id` | fase `guess` (efter eget gæt) og `reveal`; slår til/fra; ikke eget svar | +| `play_again` | | VIP i fasen `final` | +| `ping` | | svar `pong` | + +**Vært (`/ws/host/`)**: `start`, `skip` (afslut nuværende fase nu), `play_again`, `kick` (`player_id`), `ping`. + +## Server → klient + +| type | felter | +| --- | --- | +| `state` | `state`: snapshot (se nedenfor) | +| `joined` | `token`, `player_id`, `role` (`player`/`audience`) | +| `suggestion` | `text` | +| `error` | `code`, `message` (dansk, kan vises direkte) | +| `pong` | | + +Fejlkoder: `unknown_room`, `unknown_token`, `name_taken`, `name_invalid`, `not_allowed`, `wrong_phase`, `lie_is_truth`, `lie_invalid`, `already_done`, `answer_invalid`, `too_few_players`. + +## Snapshot + +```jsonc +{ + "code": "KLMT", + "phase": "lobby", // lobby | category | lie | guess | reveal | scores | final + "server_time": 1790000000000,// ms epoch; klienten udregner sin forskydning + "deadline": 1790000060000, // ms epoch eller null + "join_url": "https://party.weircon.dk/?code=KLMT", + "min_players": 2, "max_players": 8, + "round": {"number": 1, "name": "Runde 1", "multiplier": 1, + "question_number": 2, "questions_in_round": 3}, // null i lobby/final + "players": [ + {"id": "p1", "name": "Asger", "color": "#ff4d6d", "score": 1500, + "connected": true, "vip": true, "done": false} // done = har løjet/gættet i nuværende fase + ], + "audience_count": 0, + "category": {"chooser_id": "p2", "options": [{"id": 3, "name": "Dyreriget"}]}, // kun i fase category + "question": {"category": "Dyreriget", "prompt": "En flok _____ kaldes en parlament."}, // lie/guess/reveal + "answers": [{"id": "a1", "text": "ugler"}], // guess + reveal; spillerens egen løgn er udeladt i guess + "reveal": { // kun i fase reveal + "steps": [ // løgne først (kun dem der blev valgt), sandheden sidst + {"answer_id": "a3", "text": "krager", "is_truth": false, "house": false, + "authors": ["p2"], "guessed_by": ["p1", "p3"], "audience_votes": 2, "likes": 1, + "points": [{"player_id": "p2", "delta": 1000}]}, + {"answer_id": "a1", "text": "ugler", "is_truth": true, "house": false, + "authors": [], "guessed_by": ["p4"], "audience_votes": 1, "likes": 0, + "points": [{"player_id": "p4", "delta": 1000}]} + ], + "step_ms": 4000 // værtsskærmen viser ét trin pr. step_ms + }, + "deltas": [{"player_id": "p1", "delta": 1500}], // fase scores: point i den netop afsluttede runde + "final": { // kun i fase final + "ranking": [{"player_id": "p1", "score": 9000, "place": 1}], + "awards": [{"title": "Største løgner", "player_id": "p2", "detail": "narrede 7 gange"}] + }, + "me": { // kun til spiller/publikum + "id": "p1", "role": "player", "vip": true, + "lie": "krager", // egen løgn i nuværende spørgsmål eller null + "guess": "a3", // eget gæt eller null + "likes": ["a2"], + "is_chooser": false, + "last_delta": 1500 // point fra seneste spørgsmål (reveal/scores) + } +} +``` + +Værten får samme snapshot uden `me`, men med `"host": true`. + +## Spillets gang (Fibbage-klassisk) + +| Runde | Spørgsmål | Point: rigtigt gæt / pr. narret spiller | +| --- | --- | --- | +| Runde 1 | 3 | 1000 / 500 | +| Runde 2 | 3 | 2000 / 1000 | +| Finale | 1 | 3000 / 1500 | + +For hvert spørgsmål: `category` (15 s; skiftende spiller vælger blandt 4; i finalen springes fasen over) → `lie` (60 s, slutter når alle har løjet) → `guess` (30 s, slutter når alle spillere har gættet) → `reveal` (automatisk: antal trin × `step_ms` + 3 s; værten kan `skip`). Efter sidste spørgsmål i en runde: `scores` (10 s). Efter finalen: `final`. + +- Løgn lig sandheden (eller næsten lig) afvises med `lie_is_truth`. Ens løgne slås sammen, og alle forfattere får point. +- Spillere, der ikke løj i tide, får ingen løgn; huset fylder op med lokkesvar, så der er mindst `max(4, spillere+1)` svar. +- Man kan ikke vælge sin egen løgn. +- Like: +100 til forfatteren pr. like (uden multiplikator). +- Publikum: forfatteren får `round(500 × multiplikator × andel af publikum narret)`. +- Spil igen: samme spillere, point nulstilles. diff --git a/docs/RELEASE_POLICY.md b/docs/RELEASE_POLICY.md deleted file mode 100644 index 47261cb..0000000 --- a/docs/RELEASE_POLICY.md +++ /dev/null @@ -1,28 +0,0 @@ -# Release policy (Issue #23) - -## Formål -Sikre at release-tags altid repræsenterer faktisk deployet software. - -## Hård regel -- **Ingen release-tag før staging deploy er succesfuld.** -- **Ingen release-tag uden changelog-reference.** -- **Ingen deploy hvis tester er i gang med smoke-run.** - -## Release-flow -1. Bekræft architect-gate (`issue #17`) er release-approved. -2. Bekræft tester ikke er aktiv. -3. Deploy kandidat til staging (`infra/staging/deploy_staging.sh`). -4. Verificér `/healthz` + smoke-resultat. -5. Tilføj changelog-entry i `CHANGELOG.md`. -6. Opret release-tag i Gitea (annotated), og referér changelog-sektion i release-notes. - -## Minimum release-notes template -```markdown -## Changelog -- Ref: CHANGELOG.md# - -## Deploy -- Environment: staging -- Status: success -- Healthz: ok -``` diff --git a/docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md b/docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md deleted file mode 100644 index 5be9b5e..0000000 --- a/docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md +++ /dev/null @@ -1,66 +0,0 @@ -# Staging gameplay smoke artifact (Issue #144) - -Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke i #16/#90-sporet uden scope-udvidelse. - -## Guardrails (MVP) -- Hold scope inden for #16 (execution board) og #17 (scope guardrail). -- Kun verifikation af eksisterende flow; ingen nye features/polish. - -## Hvornår bruges artifacten -- Efter staging-smoke af gameplay-flowet: lobby -> join -> start -> runde -> scoreboard -> next/final. -- Resultatet logges i issue/PR-kommentar med denne skabelon. - -## Evidence template (kopiér i PR/issue-kommentar) -```markdown -### Staging gameplay smoke evidence -- Timestamp (UTC): -- Environment: staging -- Commit/Head SHA: -- Linked scope: #16 #17 #90 #129 #144 - -#### Setup -- Host authenticated in Django admin: -- Active category/questions present: -- Participants: host + players -- `USE_SPA_UI`: -- `WPP_SPA_ASSET_VERSION`: -- UI route used: - - OFF (legacy): `/lobby/ui/host` + `/lobby/ui/player` - - ON (SPA shell): `/lobby/ui/host/` + `/lobby/ui/player` - -#### Checks (PASS/FAIL) -0. Same release-window verification - - OFF + ON smoke kørt i samme release-vindue: -1. Cutover route sanity - - Flag OFF serves legacy UI templates: - - Flag ON serves SPA shell on expected path(s): -2. Lobby -> join -> start - - Mixed-case + whitespace session code accepted: -3. One full round to scoreboard - - submit lie -> mix -> submit guess -> calculate score -> show scoreboard: -4. Next-round + game-end sanity - - next round transitions: - - final leaderboard visible: - -#### Smoke-gate decision (før `USE_SPA_UI=true`) -- Gate status: -- Gate criteria met: - - [ ] Cutover route sanity = PASS (OFF + ON) - - [ ] Full gameplay round = PASS - - [ ] Next-round/final leaderboard sanity = PASS - - [ ] Ingen nye blocker-regressioner i host/player flow - -#### Rollback checkpoint -- Rollback required: -- Trigger reason (if yes): -- Rollback done (`USE_SPA_UI=false`) verified: - -#### Evidence pointers -- Command(s): `` -- UI notes/screenshots/log refs: -- Result: -- If FAIL: blocker issue link + shortest repro -``` - -## Anti-stall minimum -Hvis der ikke er ny kode at ændre, er denne artifact-skabelon den mindste gyldige leverance for at sikre ensartet, reviewbar smoke-evidens i staging. diff --git a/docs/UI_SMOKE.md b/docs/UI_SMOKE.md deleted file mode 100644 index d95a135..0000000 --- a/docs/UI_SMOKE.md +++ /dev/null @@ -1,48 +0,0 @@ -# UI smoke (MVP) - -## Forudsætning -- Host er logget ind i Django. -- Mindst én aktiv kategori med spørgsmål findes. - -## Cutover-forudsætning (`USE_SPA_UI`) -- `USE_SPA_UI=false` (default): brug legacy routes `/lobby/ui/host` + `/lobby/ui/player`. -- `USE_SPA_UI=true`: host må gerne testes på SPA deep-link route `/lobby/ui/host/` (fx `/lobby/ui/host/guess`), player på `/lobby/ui/player`. - -## Flow -1. Verificér cutover-route matcher valgt flag (legacy vs SPA shell). -2. Åbn host-siden og tryk Opret session. -3. Åbn player-siden i 3 faner/enheder. -4. Join alle spillere med sessionkode og nickname. -5. Host: vælg kategori, Start runde, Vis spørgsmål. -6. Spillere: brug round_question_id og submit løgn. -7. Host: Mix svar. -8. Spillere: submit gæt. -9. Host: Beregn score og Vis scoreboard. -10. Host: Næste runde eller Afslut spil. - -## Smoke-gate (staging cutover) -`USE_SPA_UI` må kun aktiveres i staging når følgende er opfyldt: -- Cutover route sanity er PASS for både OFF (legacy) og ON (SPA shell). -- Én fuld gameplay-runde til scoreboard er PASS. -- Next-round/final leaderboard sanity er PASS. -- Ingen nye blocker-regressioner i host/player kerneflow. - -## Samme release-vindue: SPA OFF + ON verifikation -Kør begge checks i samme release-vindue (samme deploy/artifact version): - -1. **OFF-pass (legacy)** - - `USE_SPA_UI=false` - - Verificér legacy routes + fuld runde. -2. **ON-pass (SPA)** - - `USE_SPA_UI=true` - - Behold samme release artifact og kun toggl flag/version-token ved behov. - - Verificér SPA shell routes + fuld runde. -3. Dokumentér begge pass i samme smoke-artifact med UTC timestamps og `WPP_SPA_ASSET_VERSION`. - -## Rollback check points -Skift straks tilbage til `USE_SPA_UI=false` hvis en gate fejler: -1. Verificér legacy routes (`/lobby/ui/host` + `/lobby/ui/player`) fungerer igen. -2. Log rollback trigger + kort repro i smoke artifact. -3. Opret/link blocker issue før nyt cutover-forsøg. - -Resultat: En fuld runde kan køres uden rå API-kald fra terminal. diff --git a/docs/artifacts/issue-302-canonical-loop-smoke.json b/docs/artifacts/issue-302-canonical-loop-smoke.json deleted file mode 100644 index 19682ca..0000000 --- a/docs/artifacts/issue-302-canonical-loop-smoke.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "ok": true, - "command": "python manage.py smoke_staging --artifact ", - "generated_at": "2026-03-16T15:19:30.105231+00:00", - "question": { - "prompt": "Smoke prompt?", - "correct_answer": "Correct" - }, - "steps": [ - { - "step": "create_session", - "session_status": "lobby" - }, - { - "step": "join_players", - "players_count": 3 - }, - { - "step": "start_round", - "session_status": "lie", - "round_question_id": 1 - }, - { - "step": "auto_guess_transition", - "session_status": "guess", - "answers": [ - "Lie from P3", - "Lie from P1", - "Lie from P2", - "Correct" - ] - }, - { - "step": "submit_guesses", - "guess_results": [ - { - "player_id": 1, - "selected_text": "Lie from P2", - "is_correct": false, - "fooled_player_id": 2 - }, - { - "player_id": 2, - "selected_text": "Correct", - "is_correct": true, - "fooled_player_id": null - }, - { - "player_id": 3, - "selected_text": "Lie from P1", - "is_correct": false, - "fooled_player_id": 1 - } - ] - }, - { - "step": "auto_reveal_transition", - "session_status": "reveal", - "reveal": { - "correct_answer": "Correct", - "lies_count": 3, - "guesses_count": 3, - "fooled_player_ids": [ - 1, - 2 - ], - "correct_guess_player_ids": [ - 2 - ] - } - }, - { - "step": "auto_scoreboard_transition", - "session_status": "scoreboard", - "leaderboard": [ - { - "id": 2, - "nickname": "P2", - "score": 7 - }, - { - "id": 1, - "nickname": "P1", - "score": 2 - }, - { - "id": 3, - "nickname": "P3", - "score": 0 - } - ] - }, - { - "step": "finish_game", - "session_status": "finished" - } - ], - "session_code": "7YV59E", - "players": [ - "P1", - "P2", - "P3" - ], - "round_question_id": 1, - "guess_plan": { - "P1": "Lie from P2", - "P2": "Correct", - "P3": "Lie from P1" - } -} diff --git a/docs/i18n-drift-check.md b/docs/i18n-drift-check.md deleted file mode 100644 index d88ec0f..0000000 --- a/docs/i18n-drift-check.md +++ /dev/null @@ -1,34 +0,0 @@ -# i18n key manifest + drift check - -Issue: #240 - -This repo keeps shared lobby keyspaces in two files: - -- Contract source: `shared/i18n/lobby.json` -- Key manifest: `shared/i18n/key-manifest.json` - -The manifest is intentionally small and explicit. It lists: - -- Supported locales (`locales`) -- Frontend error key set (`frontend_error_keys`) -- Backend error code set (`backend_error_codes`) -- Backend translation key set (`backend_error_keys`) -- Optional contract-only aliases (`allowed_contract_only_backend_codes`) - -## Local check - -Run the read-only drift checker from repo root: - -```bash -python3 scripts/check_i18n_drift.py -``` - -The script returns non-zero when it detects drift, including: - -- key set mismatch between manifest and shared catalog -- missing backend→frontend mapping coverage -- mapping to unknown frontend keys -- mappings for unknown backend codes -- missing/empty locale translations (`en`/`da`) - -No CI gating changes are included in this task; this is a local guardrail. diff --git a/docs/i18n-keymap.md b/docs/i18n-keymap.md deleted file mode 100644 index 43b7515..0000000 --- a/docs/i18n-keymap.md +++ /dev/null @@ -1,69 +0,0 @@ -# i18n key-map bootstrap (Angular host/player MVP) - -Issue: #220 -Scope: Lobby → Join → Start round → Round → Reveal → Scoreboard -Locales: `en`, `da` - -This document is the gameplay key-namespace map for Angular host/player MVP. -It maps existing text keys only (no feature expansion) and stays aligned with `shared/i18n/lobby.json`. - -## Key families - -- `host` — `frontend.ui.host.*` host-facing gameplay actions and status text. -- `player` — `frontend.ui.player.*` player-facing gameplay actions and status text. -- `system` — shared UI labels used across host/player views (implemented under `frontend.ui.common.*` in `shared/i18n/lobby.json`). -- `errors` — user-facing error keys shown by frontend (`frontend.errors.*`) plus backend code → frontend key bridge via `backend.error_codes.*` / `contract.backend_to_frontend_error_keys.*`. - -## Gameplay flow key map - -| Flow step | Family | Key | en | da | -|---|---|---|---|---| -| Lobby | `host` | `host.title` | Host gameplay flow | Vært gameplay-flow | -| Lobby | `player` | `player.title` | Player gameplay flow | Spiller gameplay-flow | -| Lobby | `system` (`frontend.ui.common`) | `common.session_code` | Session code | Sessionskode | -| Lobby | `player` | `player.nickname` | Nickname | Kaldenavn | -| Join | `player` | `player.join` | Join | Join | -| Start round | `host` | `host.start_round` | Start round | Start runde | -| Round | `host` | `host.show_question` | Show question | Vis spørgsmål | -| Round | `player` | `player.lie_label` | Lie | Løgn | -| Round | `player` | `player.submit_lie` | Submit lie | Send løgn | -| Round | `player` | `player.submit_guess` | Submit guess | Send gæt | -| Reveal | `host` | `host.mix_answers` | Mix answers → guess | Bland svar → gæt | -| Reveal | `host` | `host.calculate_scores` | Calculate scores → reveal | Udregn score → afslør | -| Scoreboard | `host` | `host.load_scoreboard` | Load scoreboard | Hent scoreboard | -| Scoreboard | `host` | `host.final_leaderboard` | Final leaderboard | Finale leaderboard | -| Scoreboard | `player` | `player.final_leaderboard` | Final leaderboard | Finale leaderboard | -| Scoreboard | `system` (`frontend.ui.common`) | `common.points_short` | pts | point | - -## Frontend error keys used in flow scope - -| Error family | Key | en | da | -|---|---|---|---| -| Join | `frontend.errors.session_code_required` | Session code is required. | Sessionskoden er påkrævet. | -| Join | `frontend.errors.session_not_found` | Session code is invalid or the session no longer exists. | Sessionskoden er ugyldig, eller sessionen findes ikke længere. | -| Join | `frontend.errors.nickname_invalid` | Nickname must be between 2 and 40 characters. | Kaldenavn skal være mellem 2 og 40 tegn. | -| Join | `frontend.errors.nickname_taken` | Nickname is already taken. | Kaldenavnet er allerede taget. | -| Join | `frontend.errors.join_failed` | Join failed. Check code or nickname and try again. | Kunne ikke joine. Tjek kode eller kaldenavn og prøv igen. | -| Start round | `frontend.errors.start_round_failed` | Could not start round. Refresh the lobby and try again. | Kunne ikke starte runden. Opdater lobbyen og prøv igen. | -| Any | `frontend.errors.unknown` | Action failed. Refresh status and try again. | Handlingen fejlede. Opdater status og prøv igen. | - -## Backend→frontend mapping for gameplay errors - -Mapped in `contract.backend_to_frontend_error_keys` (source: `shared/i18n/lobby.json`): - -> Note: `host_only_action` is **not** part of the current shared contract mapping and is intentionally not listed here. - -- `session_code_required` → `session_code_required` -- `nickname_invalid` → `nickname_invalid` -- `session_not_found` → `session_not_found` -- `session_not_joinable` → `join_failed` -- `nickname_taken` → `nickname_taken` -- `category_slug_required` → `start_round_failed` -- `category_not_found` → `start_round_failed` -- `round_start_invalid_phase` → `start_round_failed` -- `round_already_configured` → `start_round_failed` - -## Notes - -- This is a bootstrap key-map doc for MVP mergeability. -- The key/value source of truth remains `shared/i18n/lobby.json`. diff --git a/docs/issue-180-flow-log.md b/docs/issue-180-flow-log.md deleted file mode 100644 index fae3376..0000000 --- a/docs/issue-180-flow-log.md +++ /dev/null @@ -1,28 +0,0 @@ -# Issue #180 – SPA gameplay flow evidence - -## Flow log (host + player, no page reload) - -1. Host opens SPA host shell and loads scoreboard (`GET /lobby/sessions/{code}/scoreboard`). -2. Host starts next round (`POST /lobby/sessions/{code}/rounds/next`). -3. Host shell refreshes session state in-place (`GET /lobby/sessions/{code}`) and clears old scoreboard/final leaderboard payloads. -4. Player shell performs periodic session refresh while online (3s cadence) and transitions from `scoreboard` to `lobby` without page reload. -5. Host finishes game (`POST /lobby/sessions/{code}/finish`) and renders final leaderboard directly in SPA shell. -6. Player shell reads `finished` state and renders final leaderboard in SPA (sorted by score). -7. Error/retry paths available: - - Host: next-round and finish-game retry buttons with explicit error feedback. - - Player: reconnect + submit retry feedback. - -## Test output snapshot - -Command: - -```bash -cd frontend/angular -npm test -- --run src/app/features/host/host-shell.component.spec.ts src/app/features/player/player-shell.component.spec.ts -``` - -Result: - -- `host-shell.component.spec.ts`: 6 passed -- `player-shell.component.spec.ts`: 7 passed -- Total: 13 passed, 0 failed diff --git a/docs/plans/2026-03-09-fupogfakta-game-engine-design.md b/docs/plans/2026-03-09-fupogfakta-game-engine-design.md deleted file mode 100644 index a8877a0..0000000 --- a/docs/plans/2026-03-09-fupogfakta-game-engine-design.md +++ /dev/null @@ -1,272 +0,0 @@ -# Design: Fup og Fakta — Game Engine & Platform Architecture - -**Date:** 2026-03-09 -**Status:** Approved - ---- - -## Overview - -Build a working Fup og Fakta game (Fibbage-style) on top of a **pluggable game platform**. The platform handles sessions, players, WebSocket push, and Celery-driven timers. Each game is a self-contained **cartridge** that implements a shared driver interface and owns its own models, config, and phase logic. - ---- - -## Platform Architecture - -``` -partyhub/ Django project — settings, Celery app, ASGI -lobby/ Platform layer — sessions, players, GameRun, timer dispatch -realtime/ WebSocket consumers (already built) -fupogfakta/ Game cartridge #1 -future_game/ Game cartridge #N (same interface) -``` - -### Platform provides (`lobby/`) - -#### Models - -**`GameSession`** (exists, minor additions) -- `game_type` (CharField) — e.g. `"fupogfakta"` -- `host` (FK → User) -- `code` (6-char session code) -- `status` (LOBBY / ACTIVE / FINISHED) -- `config_id` / `config_snapshot` — see Config section - -**`GameRun`** (new — ephemeral, deleted on game exit) -- `session` (OneToOne → GameSession) -- `current_state` (CharField — game-defined state string) -- `phase_deadline` (DateTimeField, nullable) -- `is_paused` (BooleanField, default False) -- `paused_remaining_seconds` (FloatField, nullable) -- `celery_task_id` (CharField, nullable) -- `state_data` (JSONField) — game-specific snapshot for current phase - -**`Player`** (exists) -- `session`, `nickname`, `score`, `session_token`, `is_connected` - -#### GameDriver interface - -Each cartridge implements: - -```python -class GameDriver: - game_type: str # e.g. "fupogfakta" - - def on_game_start(session, run, config) -> PhaseResult - def on_timer_expired(session, run, config) -> PhaseResult - def on_pause(session, run) -> None - def on_resume(session, run) -> None - def on_exit(session, run) -> None # must clean up all game data - def get_ws_payload(state, state_data) -> dict -``` - -`PhaseResult` = `(next_state: str, duration_seconds: int | None, broadcast_payload: dict)` - -#### Celery task - -```python -@app.task -def handle_timer_expired(run_id: int, expected_state: str): - # If run no longer exists or state has changed → stale task, ignore - # Call driver.on_timer_expired(session, run, config) - # Apply PhaseResult: update run, broadcast via channel layer, schedule next task -``` - -`expected_state` prevents stale tasks from firing after pause/resume or manual state changes. - -#### REST endpoints (platform-level) - -- `POST /sessions/{code}/play` — start or resume -- `POST /sessions/{code}/pause` — pause current phase timer -- `POST /sessions/{code}/exit` — end game, delete GameRun + all game data - ---- - -## Configuration System - -### Base config model (`partyhub/`) - -```python -class BaseGameConfig(models.Model): - class Meta: - abstract = True - - name = models.CharField(max_length=100) # "Quick game", "Full evening" - user = models.ForeignKey(User, null=True, ...) # null = system default - is_default = models.BooleanField(default=False) - created_at = models.DateTimeField(auto_now_add=True) -``` - -### Game-specific config (`fupogfakta/`) - -```python -class FupOgFaktaConfig(BaseGameConfig): - num_rounds = PositiveIntegerField(default=3) - questions_per_round = PositiveIntegerField(default=3) - min_players = PositiveIntegerField(default=2) - max_players = PositiveIntegerField(default=8) - lie_seconds = PositiveIntegerField(default=45) - guess_seconds = PositiveIntegerField(default=30) - reveal_seconds_per_lie = PositiveIntegerField(default=8) - scoreboard_recap_seconds = PositiveIntegerField(default=10) - # Escalating scoring per round (stored as arrays or separate fields) - points_correct = JSONField(default=[1500, 3000, 4500]) - points_bluff = JSONField(default=[500, 1000, 1500]) - # Reaction bonus (static, feeds post-game awards only) - reaction_bonus = IntegerField(default=5) -``` - -### Default resolution at session start - -1. User has `is_default=True` row for this game type → use that -2. System default (`user=null, is_default=True`) — set in Django admin -3. Model field `default=` values (hardcoded) - -User can have **multiple named presets** (one-to-many). When starting a session they choose which to use (or it auto-selects their default). The chosen config's values are **snapshotted into `GameRun.state_data`** at game start — immutable for the life of the session. - ---- - -## Fup og Fakta — Game States - -``` -LOBBY - │ (host presses Play) - ▼ -LIE_PHASE timer: lie_seconds - │ (all submitted OR timer expires) - ▼ -GUESS_PHASE timer: guess_seconds - │ (timer expires — no mercy) - ▼ -REVEAL_LIE_{n} timer: reveal_seconds_per_lie (one per lie with ≥1 guess) - │ → score liar incrementally as each is shown - ▼ -REVEAL_TRUTH timer: reveal_seconds_per_lie - │ → score correct guessers - ▼ -SCOREBOARD_RECAP timer: scoreboard_recap_seconds - │ - ├─ more questions in round → back to LIE_PHASE (next question) - ├─ round done, more rounds → back to LIE_PHASE (next round, next category) - └─ all rounds done → POST_GAME_AWARDS - timer: configurable - → FINISHED (GameRun deleted, GameSession status = FINISHED) -``` - ---- - -## Fup og Fakta — Phase Details - -### LIE_PHASE -- Question shown to all clients via WebSocket (`phase.lie_started` event) -- Players submit lie via `POST /fupogfakta/{code}/lie` -- **If lie matches correct answer (case-insensitive):** return `error_code: lie_matches_correct_answer` — player prompted again, does not consume their submission -- Anonymous to other players during this phase -- `state_data` tracks: question id, round number, how many have submitted (for progress display on host screen) -- Timer expires → transition to GUESS_PHASE regardless of how many submitted - -### GUESS_PHASE -- Answers mixed (lies + truth, deduped) broadcast to all clients (`phase.guess_started`) -- Players guess via `POST /fupogfakta/{code}/guess` -- **After selecting:** player can react to other lies with 👍 😂 ❤️ etc. until timer expires. Cannot change guess. -- Reactions stored in `LieReaction` model (player, lie, reaction_type) -- Timer expires → transition to first REVEAL_LIE (or REVEAL_TRUTH if no lies had guesses) - -### REVEAL_LIE_{n} -- One Celery task per lie to reveal (only lies with ≥1 guesser) -- Broadcast: which lie, who wrote it, who guessed it (`phase.reveal_lie`) -- Score awarded to liar: `points_bluff[round_index] × guesser_count` -- Score broadcast immediately (`phase.score_delta`) -- Skipped lies (0 guesses): not shown at all - -### REVEAL_TRUTH -- Broadcast: correct answer, who guessed correctly (`phase.reveal_truth`) -- Score awarded: `points_correct[round_index]` per correct guesser -- Also show reaction totals on each lie during this phase - -### SCOREBOARD_RECAP -- Full leaderboard broadcast (`phase.scoreboard`) -- Auto-advances to next question, next round, or post-game - -### POST_GAME_AWARDS -- Computed from `LieReaction` aggregate: - - "Most Hilarious Liar" — most 😂 reactions total - - "Most Beloved Lie" — most ❤️ reactions on a single lie - - etc. (extensible) -- Broadcast as `phase.awards` -- Then FINISHED → GameRun deleted, all session game data wiped - ---- - -## Fup og Fakta — Models - -**Existing (keep):** `Category`, `Question`, `RoundQuestion`, `LieAnswer`, `Guess` - -**Remove:** `ScoreEvent` (no audit trail needed — game state is ephemeral) - -**New:** -```python -class LieReaction(models.Model): - lie = ForeignKey(LieAnswer, on_delete=CASCADE) - player = ForeignKey(Player, on_delete=CASCADE) - reaction = CharField(max_length=20) # "laugh", "heart", "fire", etc. - created_at = auto_now_add - class Meta: - unique_together = [("lie", "player", "reaction")] -``` - -**Modify `RoundQuestion`:** -- Add `reveal_order` (PositiveIntegerField, nullable) — set when GUESS_PHASE ends, determines reveal sequence - ---- - -## Pause / Resume - -- **Pause:** compute `remaining = phase_deadline - now`, store in `paused_remaining_seconds`, set `is_paused=True`, revoke Celery task by `celery_task_id` -- **Resume:** set `phase_deadline = now + paused_remaining_seconds`, schedule new Celery task, clear pause fields -- Stale task guard: every Celery task checks `expected_state == run.current_state` before firing - ---- - -## Host Controls (Session Owner Only) - -| Action | Effect | -|--------|--------| -| Play | Starts game from LOBBY, or resumes from paused | -| Pause | Freezes current phase timer, broadcasts `phase.paused` | -| Exit | Ends game immediately, deletes GameRun + all game data | - -Cannot skip. Cannot manually advance phases. - ---- - -## WebSocket Event Reference - -| Event | Triggered by | Payload | -|-------|-------------|---------| -| `phase.lie_started` | LIE_PHASE start | question prompt, deadline, round info | -| `phase.lie_progress` | Each lie submitted | n_submitted / n_players (no names) | -| `phase.guess_started` | GUESS_PHASE start | mixed answers, deadline | -| `phase.reveal_lie` | REVEAL_LIE_{n} | lie text, author, guessers, score delta | -| `phase.reveal_truth` | REVEAL_TRUTH | correct answer, correct guessers, score delta | -| `phase.scoreboard` | SCOREBOARD_RECAP | full leaderboard | -| `phase.awards` | POST_GAME_AWARDS | award winners | -| `phase.paused` | Pause | remaining_seconds | -| `phase.resumed` | Resume | new deadline | -| `phase.game_over` | FINISHED | final leaderboard | - ---- - -## Data Lifecycle - -All game session data (`GameRun`, `RoundQuestion`, `LieAnswer`, `Guess`, `LieReaction`, `Player`) is **deleted when host exits or game reaches FINISHED**. `GameSession` row is kept (with status=FINISHED) for the session code uniqueness constraint. `Category` and `Question` content is permanent. - ---- - -## Not In Scope (This Implementation) - -- TTS / read-aloud (Fase 4, deferred) -- Reconnect recovery after server restart (game is gone if server dies) -- Spectator/viewer mode (post-MVP) -- Rate limiting on endpoints (backlog) -- Bulk question import (Fase 5) diff --git a/docs/plans/2026-03-09-fupogfakta-implementation-plan.md b/docs/plans/2026-03-09-fupogfakta-implementation-plan.md deleted file mode 100644 index b0001fd..0000000 --- a/docs/plans/2026-03-09-fupogfakta-implementation-plan.md +++ /dev/null @@ -1,2180 +0,0 @@ -# Fup og Fakta — Game Engine Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Implement a fully working Fup og Fakta game on top of a pluggable game-platform engine with Celery-driven timers, incremental reveal scoring, emoji reactions, and post-game awards. - -**Architecture:** Platform layer (`lobby/`) provides `GameRun`, `GameDriver` interface, and Celery timer dispatch. Each game cartridge (starting with `fupogfakta/`) owns its models, config, and phase logic. The Angular frontend gets proper game screens replacing the current developer control panels. - -**Tech Stack:** Django 6.0.2, Celery + Redis, Django Channels (WebSocket already built), Angular 19 standalone components, Vitest. - ---- - -## Reading Before You Start - -- `docs/plans/2026-03-09-fupogfakta-game-engine-design.md` — full design, read this first -- `lobby/views.py` — existing REST endpoints (most will be replaced) -- `fupogfakta/models.py` — existing models -- `realtime/broadcast.py` — sync_broadcast_phase_event helper -- `partyhub/settings.py` — env-driven config pattern used throughout - -**Run tests before starting:** `.venv/bin/python manage.py test lobby realtime` → must be 78/78 green. - ---- - -## Batch 1 — Celery Infrastructure - -No breaking changes. Additive only. Existing tests stay green throughout. - ---- - -### Task 1: Add Celery to dependencies - -**Files:** -- Modify: `requirements.txt` - -**Step 1: Add celery and redis to requirements** - -``` -celery>=5.3,<6 -redis>=5.0,<6 -``` - -`redis` is the pure-Python Redis client Celery uses as a broker. (`channels-redis` uses it too but declares it as a dep — add explicitly for clarity.) - -**Step 2: Install** - -```bash -.venv/bin/pip install celery>=5.3,<6 redis>=5.0,<6 -``` - -Expected: installs without errors. - -**Step 3: Commit** - -```bash -git add requirements.txt -git commit -m "chore: add celery + redis to requirements" -``` - ---- - -### Task 2: Create Celery app - -**Files:** -- Create: `partyhub/celery.py` -- Modify: `partyhub/__init__.py` - -**Step 1: Create `partyhub/celery.py`** - -```python -import os -from celery import Celery - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'partyhub.settings') - -app = Celery('partyhub') -app.config_from_object('django.conf:settings', namespace='CELERY') -app.autodiscover_tasks() -``` - -**Step 2: Read `partyhub/__init__.py` first, then update it** - -Add at the top (after any existing content): - -```python -from .celery import app as celery_app - -__all__ = ('celery_app',) -``` - -**Step 3: Add Celery config to `partyhub/settings.py`** - -Add after the `CHANNEL_LAYERS` block: - -```python -CELERY_BROKER_URL = f"redis://{env('CELERY_REDIS_HOST', '127.0.0.1')}:{env('CELERY_REDIS_PORT', '6379')}/1" -CELERY_RESULT_BACKEND = CELERY_BROKER_URL -CELERY_TASK_SERIALIZER = 'json' -CELERY_ACCEPT_CONTENT = ['json'] -CELERY_TIMEZONE = TIME_ZONE -# In tests: use synchronous task execution (no broker needed) -CELERY_TASK_ALWAYS_EAGER = _testing -CELERY_TASK_EAGER_PROPAGATES = _testing -``` - -**Step 4: Verify Django check still passes** - -```bash -.venv/bin/python manage.py check -``` - -Expected: `System check identified no issues (0 silenced).` - -**Step 5: Commit** - -```bash -git add partyhub/celery.py partyhub/__init__.py partyhub/settings.py -git commit -m "feat(platform): add celery app and broker config" -``` - ---- - -### Task 3: Create the timer Celery task (skeleton) - -**Files:** -- Create: `lobby/tasks.py` - -**Step 1: Write failing test** - -In `lobby/tests.py`, add at the bottom: - -```python -class TimerTaskStaleGuardTest(TestCase): - def test_stale_task_does_nothing_when_run_not_found(self): - from lobby.tasks import handle_timer_expired - # Should not raise — run_id that does not exist is silently ignored - handle_timer_expired(run_id=99999, expected_state="LIE_PHASE") -``` - -**Step 2: Run test to verify it fails** - -```bash -.venv/bin/python manage.py test lobby.tests.TimerTaskStaleGuardTest --verbosity=2 -``` - -Expected: `ImportError` or `ModuleNotFoundError` for `lobby.tasks`. - -**Step 3: Create `lobby/tasks.py`** - -```python -import logging -from celery import shared_task - -logger = logging.getLogger(__name__) - - -@shared_task(bind=True, max_retries=0) -def handle_timer_expired(self, run_id: int, expected_state: str) -> None: - """ - Fired by Celery when a phase timer expires. - - Guards against stale tasks: if the GameRun no longer exists or its - current_state no longer matches expected_state (e.g. after pause/resume), - the task silently exits. - - Full driver dispatch is wired in Batch 5 once GameRun model exists. - """ - # Import here to avoid circular imports at module load time - try: - from lobby.models import GameRun # will exist after Batch 2 - except ImportError: - logger.warning("handle_timer_expired: GameRun model not yet available (pre-migration)") - return - - try: - run = GameRun.objects.select_related('session').get(pk=run_id) - except GameRun.DoesNotExist: - logger.info("handle_timer_expired: run %s not found — stale task, ignoring", run_id) - return - - if run.current_state != expected_state: - logger.info( - "handle_timer_expired: run %s state is %r, expected %r — stale task, ignoring", - run_id, run.current_state, expected_state, - ) - return - - # Full transition logic wired in Batch 5 - logger.info("handle_timer_expired: run %s state=%r — TODO: dispatch driver", run_id, expected_state) -``` - -**Step 4: Run test to verify it passes** - -```bash -.venv/bin/python manage.py test lobby.tests.TimerTaskStaleGuardTest --verbosity=2 -``` - -Expected: PASS (ImportError path is handled gracefully). - -**Step 5: Commit** - -```bash -git add lobby/tasks.py lobby/tests.py -git commit -m "feat(platform): add handle_timer_expired celery task skeleton" -``` - ---- - -## Batch 2 — GameRun Model + GameDriver Interface - ---- - -### Task 4: Add `game_type` field to GameSession and simplify status - -**Files:** -- Modify: `fupogfakta/models.py` -- Create: `fupogfakta/migrations/0005_gamesession_game_type_status_active.py` (auto-generated) - -**Step 1: Write failing test** - -In `lobby/tests.py`, in an appropriate test class or new one: - -```python -class GameSessionGameTypeTest(TestCase): - def setUp(self): - self.user = User.objects.create_user(username='host2', password='pw') - - def test_game_session_has_game_type_field(self): - session = GameSession.objects.create(host=self.user, code='GGTYPE') - self.assertEqual(session.game_type, 'fupogfakta') - - def test_game_session_status_active_exists(self): - session = GameSession.objects.create(host=self.user, code='ACTSTS') - session.status = GameSession.Status.ACTIVE - session.save() - session.refresh_from_db() - self.assertEqual(session.status, GameSession.Status.ACTIVE) -``` - -**Step 2: Run test to verify it fails** - -```bash -.venv/bin/python manage.py test lobby.tests.GameSessionGameTypeTest --verbosity=2 -``` - -Expected: `AttributeError: type object 'GameSession' has no attribute 'game_type'` - -**Step 3: Modify `fupogfakta/models.py` — add `game_type` and `ACTIVE` status** - -In the `GameSession` class, add `game_type` field and `ACTIVE` to the Status enum: - -```python -class Status(models.TextChoices): - LOBBY = "lobby", "Lobby" - ACTIVE = "active", "Aktiv" # ADD THIS — game is running (state detail in GameRun) - LIE = "lie", "Løgnfase" # keep for now — removed in Batch 6 - GUESS = "guess", "Gættefase" # keep for now - REVEAL = "reveal", "Reveal" # keep for now - FINISHED = "finished", "Afsluttet" -``` - -And add the field (after `current_round`): - -```python -game_type = models.CharField(max_length=50, default='fupogfakta') -``` - -**Step 4: Generate and apply migration** - -```bash -.venv/bin/python manage.py makemigrations fupogfakta --name gamesession_game_type_active_status -.venv/bin/python manage.py migrate -``` - -**Step 5: Run test to verify it passes** - -```bash -.venv/bin/python manage.py test lobby.tests.GameSessionGameTypeTest --verbosity=2 -``` - -Expected: PASS. - -**Step 6: Run full test suite — must stay green** - -```bash -.venv/bin/python manage.py test lobby realtime --verbosity=1 -``` - -Expected: all passing. - -**Step 7: Commit** - -```bash -git add fupogfakta/models.py fupogfakta/migrations/ lobby/tests.py -git commit -m "feat(platform): add game_type field and ACTIVE status to GameSession" -``` - ---- - -### Task 5: Create `GameDriver` interface - -**Files:** -- Create: `lobby/driver.py` - -**Step 1: Write failing test** - -```python -# lobby/tests.py — add to bottom -class GameDriverInterfaceTest(TestCase): - def test_cannot_instantiate_abstract_driver(self): - from lobby.driver import GameDriver - with self.assertRaises(TypeError): - GameDriver() - - def test_phase_result_is_namedtuple(self): - from lobby.driver import PhaseResult - result = PhaseResult(next_state='SOME_STATE', duration_seconds=30, broadcast_payload={'type': 'x'}) - self.assertEqual(result.next_state, 'SOME_STATE') - self.assertEqual(result.duration_seconds, 30) -``` - -**Step 2: Run test to verify it fails** - -```bash -.venv/bin/python manage.py test lobby.tests.GameDriverInterfaceTest --verbosity=2 -``` - -Expected: `ModuleNotFoundError: No module named 'lobby.driver'` - -**Step 3: Create `lobby/driver.py`** - -```python -from abc import ABC, abstractmethod -from typing import NamedTuple - - -class PhaseResult(NamedTuple): - """Return value from GameDriver phase methods.""" - next_state: str - duration_seconds: int | None # None = no timer, wait for manual trigger - broadcast_payload: dict - - -class GameDriver(ABC): - """ - Abstract base class for game cartridges. - - Each game app implements this and registers via GAME_DRIVERS in settings. - The platform (lobby/) calls these methods; game logic never touches - GameRun directly. - """ - - game_type: str # must be set on concrete subclass, e.g. "fupogfakta" - - @abstractmethod - def on_game_start(self, session, run, config: dict) -> PhaseResult: - """Called when host presses Play from LOBBY. Returns initial phase.""" - - @abstractmethod - def on_timer_expired(self, session, run, config: dict) -> PhaseResult: - """Called when Celery timer fires for run.current_state. Returns next phase.""" - - @abstractmethod - def on_exit(self, session, run) -> None: - """ - Called when host presses Exit. Must delete all ephemeral game data - (RoundQuestion, LieAnswer, Guess, LieReaction, Player, GameRun). - GameSession row is kept. - """ - - def on_pause(self, session, run) -> None: - """Default: no-op. Override if game needs to pause internal state.""" - - def on_resume(self, session, run) -> None: - """Default: no-op. Override if game needs to handle resume.""" - - -def get_driver(game_type: str) -> GameDriver: - """ - Look up and return a registered GameDriver instance by game_type. - Raises KeyError if game_type is not registered. - Import and call this from the platform task/views. - """ - from django.conf import settings - registry: dict[str, str] = getattr(settings, 'GAME_DRIVERS', {}) - if game_type not in registry: - raise KeyError(f"No GameDriver registered for game_type={game_type!r}. " - f"Add it to GAME_DRIVERS in settings.py.") - # Lazy import the driver class from dotted path - module_path, class_name = registry[game_type].rsplit('.', 1) - import importlib - module = importlib.import_module(module_path) - cls = getattr(module, class_name) - return cls() -``` - -**Step 4: Register driver path in `partyhub/settings.py`** - -Add after the CELERY block: - -```python -GAME_DRIVERS = { - 'fupogfakta': 'fupogfakta.driver.FupOgFaktaDriver', -} -``` - -**Step 5: Run test to verify it passes** - -```bash -.venv/bin/python manage.py test lobby.tests.GameDriverInterfaceTest --verbosity=2 -``` - -Expected: PASS. - -**Step 6: Commit** - -```bash -git add lobby/driver.py partyhub/settings.py lobby/tests.py -git commit -m "feat(platform): add GameDriver abstract interface and get_driver registry" -``` - ---- - -### Task 6: Create `GameRun` model - -**Files:** -- Create: `lobby/models.py` (currently empty scaffold) -- Create migration - -**Step 1: Write failing test** - -```python -# lobby/tests.py -class GameRunModelTest(TestCase): - def setUp(self): - from django.contrib.auth import get_user_model - User = get_user_model() - self.user = User.objects.create_user(username='runhost', password='pw') - self.session = GameSession.objects.create(host=self.user, code='RUNTEST') - - def test_create_game_run(self): - from lobby.models import GameRun - run = GameRun.objects.create( - session=self.session, - current_state='LIE_PHASE', - ) - self.assertEqual(run.current_state, 'LIE_PHASE') - self.assertFalse(run.is_paused) - self.assertIsNone(run.phase_deadline) - self.assertIsNone(run.celery_task_id) - self.assertEqual(run.state_data, {}) - - def test_game_run_is_one_to_one_with_session(self): - from lobby.models import GameRun - from django.db import IntegrityError - GameRun.objects.create(session=self.session, current_state='LIE_PHASE') - with self.assertRaises(IntegrityError): - GameRun.objects.create(session=self.session, current_state='GUESS_PHASE') -``` - -**Step 2: Run test to verify it fails** - -```bash -.venv/bin/python manage.py test lobby.tests.GameRunModelTest --verbosity=2 -``` - -Expected: fails — GameRun does not exist. - -**Step 3: Read and update `lobby/models.py`** - -```python -from django.db import models -from django.contrib.auth import get_user_model - -User = get_user_model() - - -class GameRun(models.Model): - """ - Ephemeral runtime state for an active game session. - Created when host presses Play. Deleted when game ends (exit or finish). - """ - session = models.OneToOneField( - 'fupogfakta.GameSession', - on_delete=models.CASCADE, - related_name='run', - ) - current_state = models.CharField(max_length=64) - phase_deadline = models.DateTimeField(null=True, blank=True) - is_paused = models.BooleanField(default=False) - paused_remaining_seconds = models.FloatField(null=True, blank=True) - celery_task_id = models.CharField(max_length=255, null=True, blank=True) - # Game-specific snapshot data (config, current question id, etc.) - state_data = models.JSONField(default=dict, blank=True) - created_at = models.DateTimeField(auto_now_add=True) - - def __str__(self): - return f"GameRun({self.session.code}, state={self.current_state})" -``` - -**Step 4: Generate and apply migration** - -```bash -.venv/bin/python manage.py makemigrations lobby --name add_gamerun -.venv/bin/python manage.py migrate -``` - -**Step 5: Run test to verify it passes** - -```bash -.venv/bin/python manage.py test lobby.tests.GameRunModelTest --verbosity=2 -``` - -Expected: PASS. - -**Step 6: Run full suite** - -```bash -.venv/bin/python manage.py test lobby realtime --verbosity=1 -``` - -Expected: all green. - -**Step 7: Commit** - -```bash -git add lobby/models.py lobby/migrations/ lobby/tests.py -git commit -m "feat(platform): add GameRun model" -``` - ---- - -## Batch 3 — Config System - ---- - -### Task 7: BaseGameConfig abstract model + FupOgFaktaConfig - -**Files:** -- Create: `lobby/base_config.py` -- Modify: `fupogfakta/models.py` -- Create migration - -**Step 1: Write failing test** - -```python -# fupogfakta/tests.py (create if empty) -from django.test import TestCase -from django.contrib.auth import get_user_model - -User = get_user_model() - - -class FupOgFaktaConfigTest(TestCase): - def setUp(self): - self.user = User.objects.create_user(username='cfghost', password='pw') - - def test_system_default_config_exists_after_migration(self): - from fupogfakta.models import FupOgFaktaConfig - default = FupOgFaktaConfig.objects.filter(user=None, is_default=True).first() - # System default is created by a data migration (Task 8) - self.assertIsNotNone(default) - self.assertEqual(default.num_rounds, 3) - self.assertEqual(default.lie_seconds, 45) - - def test_user_can_have_multiple_presets(self): - from fupogfakta.models import FupOgFaktaConfig - FupOgFaktaConfig.objects.create(user=self.user, name='Quick', is_default=True) - FupOgFaktaConfig.objects.create(user=self.user, name='Long', is_default=False) - self.assertEqual(FupOgFaktaConfig.objects.filter(user=self.user).count(), 2) - - def test_resolve_config_returns_user_default_if_exists(self): - from fupogfakta.config import resolve_config - user_cfg = FupOgFaktaConfig.objects.create( - user=self.user, name='Mine', is_default=True, lie_seconds=20 - ) - result = resolve_config(self.user) - self.assertEqual(result['lie_seconds'], 20) - - def test_resolve_config_falls_back_to_system_default(self): - from fupogfakta.config import resolve_config - # No user preset created - result = resolve_config(self.user) - self.assertEqual(result['lie_seconds'], 45) # system default -``` - -**Step 2: Run test to verify it fails** - -```bash -.venv/bin/python manage.py test fupogfakta --verbosity=2 -``` - -Expected: module-level failures. - -**Step 3: Create `lobby/base_config.py`** - -```python -from django.db import models -from django.contrib.auth import get_user_model - -User = get_user_model() - - -class BaseGameConfig(models.Model): - """ - Abstract base for per-user game configuration presets. - Each game cartridge extends this with its own concrete fields. - """ - name = models.CharField(max_length=100, default='Default') - user = models.ForeignKey( - User, - on_delete=models.CASCADE, - null=True, - blank=True, - help_text="null = system default (set in Django admin)", - ) - is_default = models.BooleanField( - default=False, - help_text="If True and user=null, this is the system default for this game.", - ) - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - abstract = True -``` - -**Step 4: Add `FupOgFaktaConfig` to `fupogfakta/models.py`** - -At the bottom of `fupogfakta/models.py`, add: - -```python -from lobby.base_config import BaseGameConfig - - -class FupOgFaktaConfig(BaseGameConfig): - num_rounds = models.PositiveIntegerField(default=3) - questions_per_round = models.PositiveIntegerField(default=3) - min_players = models.PositiveIntegerField(default=2) - max_players = models.PositiveIntegerField(default=8) - lie_seconds = models.PositiveIntegerField(default=45) - guess_seconds = models.PositiveIntegerField(default=30) - reveal_seconds_per_lie = models.PositiveIntegerField(default=8) - scoreboard_recap_seconds = models.PositiveIntegerField(default=10) - awards_display_seconds = models.PositiveIntegerField(default=15) - # Escalating scoring: index 0 = round 1, 1 = round 2, etc. - points_correct = models.JSONField(default=list) - points_bluff = models.JSONField(default=list) - - def points_correct_for_round(self, round_index: int) -> int: - defaults = [1500, 3000, 4500] - pts = self.points_correct or defaults - return pts[min(round_index, len(pts) - 1)] - - def points_bluff_for_round(self, round_index: int) -> int: - defaults = [500, 1000, 1500] - pts = self.points_bluff or defaults - return pts[min(round_index, len(pts) - 1)] - - class Meta: - verbose_name = "Fup og Fakta config" - ordering = ["name"] - - def __str__(self): - owner = self.user.username if self.user else "SYSTEM" - return f"{self.name} ({owner})" -``` - -**Step 5: Create `fupogfakta/config.py`** - -```python -from django.contrib.auth import get_user_model - -User = get_user_model() - -_CONFIG_FIELDS = [ - 'num_rounds', 'questions_per_round', 'min_players', 'max_players', - 'lie_seconds', 'guess_seconds', 'reveal_seconds_per_lie', - 'scoreboard_recap_seconds', 'awards_display_seconds', - 'points_correct', 'points_bluff', -] - - -def resolve_config(user) -> dict: - """ - Return config dict for a user starting a fupogfakta session. - - Resolution order: - 1. User's is_default=True preset (if any) - 2. System default (user=None, is_default=True) - 3. Model field defaults (hardcoded) - """ - from fupogfakta.models import FupOgFaktaConfig - - cfg = ( - FupOgFaktaConfig.objects.filter(user=user, is_default=True).first() - or FupOgFaktaConfig.objects.filter(user=None, is_default=True).first() - ) - - if cfg is None: - # Fall back to model defaults - return _model_defaults() - - return {field: getattr(cfg, field) for field in _CONFIG_FIELDS} - - -def _model_defaults() -> dict: - from fupogfakta.models import FupOgFaktaConfig - tmp = FupOgFaktaConfig() - return {field: getattr(tmp, field) for field in _CONFIG_FIELDS} -``` - -**Step 6: Generate migration and data migration for system default** - -```bash -.venv/bin/python manage.py makemigrations fupogfakta --name add_fupogfakta_config -``` - -Then create the data migration manually: - -```bash -.venv/bin/python manage.py makemigrations fupogfakta --empty --name seed_system_default_config -``` - -Edit the generated file to add: - -```python -def seed_default(apps, schema_editor): - FupOgFaktaConfig = apps.get_model('fupogfakta', 'FupOgFaktaConfig') - FupOgFaktaConfig.objects.create( - name='System Default', - user=None, - is_default=True, - num_rounds=3, - questions_per_round=3, - min_players=2, - max_players=8, - lie_seconds=45, - guess_seconds=30, - reveal_seconds_per_lie=8, - scoreboard_recap_seconds=10, - awards_display_seconds=15, - points_correct=[1500, 3000, 4500], - points_bluff=[500, 1000, 1500], - ) - -class Migration(migrations.Migration): - dependencies = [('fupogfakta', '0005_...')] # previous migration name - operations = [migrations.RunPython(seed_default, migrations.RunPython.noop)] -``` - -```bash -.venv/bin/python manage.py migrate -``` - -**Step 7: Register in admin — modify `fupogfakta/admin.py`** - -```python -from django.contrib import admin -from .models import Category, Question, FupOgFaktaConfig - -admin.site.register(Category) -admin.site.register(Question) - -@admin.register(FupOgFaktaConfig) -class FupOgFaktaConfigAdmin(admin.ModelAdmin): - list_display = ['name', 'user', 'is_default', 'num_rounds', 'lie_seconds', 'guess_seconds'] - list_filter = ['is_default', 'user'] -``` - -**Step 8: Run tests** - -```bash -.venv/bin/python manage.py test fupogfakta lobby realtime --verbosity=1 -``` - -Expected: all green. - -**Step 9: Commit** - -```bash -git add lobby/base_config.py fupogfakta/models.py fupogfakta/migrations/ \ - fupogfakta/config.py fupogfakta/admin.py fupogfakta/tests.py -git commit -m "feat(config): add FupOgFaktaConfig model, resolve_config, and system default seed" -``` - ---- - -## Batch 4 — FupOgFakta Game Models - ---- - -### Task 8: Add `LieReaction` model + `reveal_order` to RoundQuestion, remove ScoreEvent - -**Files:** -- Modify: `fupogfakta/models.py` -- Create migrations - -**Step 1: Write failing tests** - -```python -# fupogfakta/tests.py — add - -class LieReactionModelTest(TestCase): - def setUp(self): - from django.contrib.auth import get_user_model - User = get_user_model() - host = User.objects.create_user(username='rhost', password='pw') - from fupogfakta.models import GameSession, Player, Category, Question, RoundQuestion, LieAnswer - self.session = GameSession.objects.create(host=host, code='REACT1') - self.player = Player.objects.create(session=self.session, nickname='Alice') - self.other = Player.objects.create(session=self.session, nickname='Bob') - cat = Category.objects.create(name='Test', slug='test') - q = Question.objects.create(category=cat, prompt='Q?', correct_answer='A') - self.rq = RoundQuestion.objects.create(session=self.session, round_number=1, question=q, correct_answer='A') - self.lie = LieAnswer.objects.create(round_question=self.rq, player=self.player, text='Fake') - - def test_player_can_react_to_lie(self): - from fupogfakta.models import LieReaction - r = LieReaction.objects.create(lie=self.lie, player=self.other, reaction='laugh') - self.assertEqual(r.reaction, 'laugh') - - def test_duplicate_reaction_raises(self): - from fupogfakta.models import LieReaction - from django.db import IntegrityError - LieReaction.objects.create(lie=self.lie, player=self.other, reaction='laugh') - with self.assertRaises(IntegrityError): - LieReaction.objects.create(lie=self.lie, player=self.other, reaction='laugh') - - def test_round_question_has_reveal_order_field(self): - from fupogfakta.models import RoundQuestion - self.assertIsNone(self.rq.reveal_order) -``` - -**Step 2: Run test to verify it fails** - -```bash -.venv/bin/python manage.py test fupogfakta.tests.LieReactionModelTest --verbosity=2 -``` - -**Step 3: Update `fupogfakta/models.py`** - -Remove the entire `ScoreEvent` class. - -Add `reveal_order` to `RoundQuestion`: - -```python -reveal_order = models.PositiveIntegerField(null=True, blank=True) -``` - -Add `LieReaction` at the bottom: - -```python -class LieReaction(models.Model): - REACTION_CHOICES = [ - ('laugh', '😂 Laugh'), - ('heart', '❤️ Heart'), - ('fire', '🔥 Fire'), - ('wow', '😮 Wow'), - ] - lie = models.ForeignKey(LieAnswer, on_delete=models.CASCADE, related_name='reactions') - player = models.ForeignKey(Player, on_delete=models.CASCADE, related_name='lie_reactions') - reaction = models.CharField(max_length=20, choices=REACTION_CHOICES) - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - unique_together = [('lie', 'player', 'reaction')] - ordering = ['created_at'] -``` - -**Step 4: Generate migration** - -```bash -.venv/bin/python manage.py makemigrations fupogfakta --name add_liereaction_reveal_order_remove_scoreevent -.venv/bin/python manage.py migrate -``` - -**Step 5: Run tests** - -```bash -.venv/bin/python manage.py test fupogfakta lobby realtime --verbosity=1 -``` - -Expected: all green (ScoreEvent removal will break `calculate_scores` in lobby/views.py — fix in next step). - -**Step 6: Fix `lobby/views.py` — remove ScoreEvent references** - -In `lobby/views.py`, the `calculate_scores` view imports and uses `ScoreEvent`. Replace the score-saving logic with direct `Player.score` updates only (no audit model). Remove `ScoreEvent` from imports. - -The score_events list building and `ScoreEvent.objects.bulk_create(score_events)` lines should be replaced with just the `player.save()` calls already present. Remove the `score_events` list and `events_created` from the response. - -**Step 7: Run full test suite** - -```bash -.venv/bin/python manage.py test fupogfakta lobby realtime --verbosity=1 -``` - -Expected: all green. - -**Step 8: Commit** - -```bash -git add fupogfakta/models.py fupogfakta/migrations/ lobby/views.py fupogfakta/tests.py -git commit -m "feat(fupogfakta): add LieReaction + reveal_order, remove ScoreEvent" -``` - ---- - -## Batch 5 — FupOgFakta Game Driver - -This is the core game logic. Take it one state at a time. - ---- - -### Task 9: Driver skeleton + LIE_PHASE entry - -**Files:** -- Create: `fupogfakta/driver.py` -- Create: `fupogfakta/phases.py` (phase transition helpers) - -**Step 1: Write failing test** - -```python -# fupogfakta/tests.py — add - -class FupOgFaktaDriverStartTest(TestCase): - def setUp(self): - from django.contrib.auth import get_user_model - User = get_user_model() - self.host = User.objects.create_user(username='drvhost', password='pw') - from fupogfakta.models import GameSession, Category, Question, FupOgFaktaConfig - from lobby.models import GameRun - self.session = GameSession.objects.create(host=self.host, code='DRVST1') - cat = Category.objects.create(name='DrvCat', slug='drvcat') - Question.objects.create(category=cat, prompt='Q1?', correct_answer='A1') - Question.objects.create(category=cat, prompt='Q2?', correct_answer='A2') - Question.objects.create(category=cat, prompt='Q3?', correct_answer='A3') - self.run = GameRun.objects.create(session=self.session, current_state='LOBBY') - self.config = { - 'num_rounds': 1, 'questions_per_round': 1, - 'lie_seconds': 30, 'guess_seconds': 20, - 'reveal_seconds_per_lie': 5, 'scoreboard_recap_seconds': 5, - 'awards_display_seconds': 5, - 'points_correct': [1500], 'points_bluff': [500], - 'min_players': 2, 'max_players': 8, - 'category_slug': 'drvcat', - } - - def test_on_game_start_returns_lie_phase(self): - from fupogfakta.driver import FupOgFaktaDriver - driver = FupOgFaktaDriver() - result = driver.on_game_start(self.session, self.run, self.config) - self.assertEqual(result.next_state, 'LIE_PHASE') - self.assertEqual(result.duration_seconds, 30) - self.assertIn('prompt', result.broadcast_payload) - - def test_on_game_start_creates_round_question(self): - from fupogfakta.driver import FupOgFaktaDriver - from fupogfakta.models import RoundQuestion - driver = FupOgFaktaDriver() - driver.on_game_start(self.session, self.run, self.config) - self.assertEqual(RoundQuestion.objects.filter(session=self.session).count(), 1) -``` - -**Step 2: Run test to verify it fails** - -```bash -.venv/bin/python manage.py test fupogfakta.tests.FupOgFaktaDriverStartTest --verbosity=2 -``` - -**Step 3: Create `fupogfakta/driver.py`** - -```python -import random -import logging - -from lobby.driver import GameDriver, PhaseResult - -logger = logging.getLogger(__name__) - - -class FupOgFaktaDriver(GameDriver): - game_type = 'fupogfakta' - - def on_game_start(self, session, run, config: dict) -> PhaseResult: - """Select first question and enter LIE_PHASE.""" - from fupogfakta.phases import enter_lie_phase - return enter_lie_phase(session, run, config, round_number=1, question_number=1) - - def on_timer_expired(self, session, run, config: dict) -> PhaseResult: - """Dispatch to the correct next-state handler based on current_state.""" - from fupogfakta import phases - state = run.current_state - state_data = run.state_data - - if state == 'LIE_PHASE': - return phases.lie_phase_expired(session, run, config) - if state == 'GUESS_PHASE': - return phases.guess_phase_expired(session, run, config) - if state.startswith('REVEAL_LIE_'): - return phases.reveal_lie_expired(session, run, config) - if state == 'REVEAL_TRUTH': - return phases.reveal_truth_expired(session, run, config) - if state == 'SCOREBOARD_RECAP': - return phases.scoreboard_recap_expired(session, run, config) - if state == 'POST_GAME_AWARDS': - return phases.post_game_awards_expired(session, run, config) - - logger.error("FupOgFaktaDriver: unknown state %r for run %s", state, run.pk) - return PhaseResult('FINISHED', None, {'type': 'phase.game_over'}) - - def on_exit(self, session, run) -> None: - """Delete all ephemeral game data. Keep GameSession row.""" - from fupogfakta.models import RoundQuestion, LieAnswer, Guess, LieReaction, Player - from lobby.models import GameRun - # Cascade deletes handle LieAnswer, Guess, LieReaction via RoundQuestion - RoundQuestion.objects.filter(session=session).delete() - Player.objects.filter(session=session).delete() - GameRun.objects.filter(session=session).delete() - session.status = session.Status.FINISHED - session.save(update_fields=['status']) -``` - -**Step 4: Create `fupogfakta/phases.py`** - -Start with just `enter_lie_phase` — other phases added in subsequent tasks: - -```python -import random -import logging - -from lobby.driver import PhaseResult - -logger = logging.getLogger(__name__) - - -def _pick_question(session, config: dict, round_number: int): - """Pick a random unused question for this session from the configured category.""" - from fupogfakta.models import Category, Question, RoundQuestion - category = Category.objects.get(slug=config['category_slug']) - used_ids = RoundQuestion.objects.filter(session=session).values_list('question_id', flat=True) - available = list( - Question.objects.filter(category=category, is_active=True).exclude(pk__in=used_ids) - ) - if not available: - raise ValueError(f"No available questions for category {config['category_slug']!r}") - return random.choice(available) - - -def enter_lie_phase(session, run, config: dict, round_number: int, question_number: int) -> PhaseResult: - """Select a question, create RoundQuestion, return LIE_PHASE result.""" - from fupogfakta.models import RoundQuestion - question = _pick_question(session, config, round_number) - rq = RoundQuestion.objects.create( - session=session, - round_number=round_number, - question=question, - correct_answer=question.correct_answer, - ) - run.state_data = { - **run.state_data, - 'round_number': round_number, - 'question_number': question_number, - 'round_question_id': rq.id, - } - run.save(update_fields=['state_data']) - return PhaseResult( - next_state='LIE_PHASE', - duration_seconds=config['lie_seconds'], - broadcast_payload={ - 'type': 'phase.lie_started', - 'round_question_id': rq.id, - 'prompt': question.prompt, - 'round_number': round_number, - 'question_number': question_number, - 'lie_seconds': config['lie_seconds'], - }, - ) - - -def lie_phase_expired(session, run, config: dict) -> PhaseResult: - """Timer ran out in LIE_PHASE. Mix answers and enter GUESS_PHASE.""" - from fupogfakta.models import RoundQuestion, LieAnswer - rq_id = run.state_data['round_question_id'] - rq = RoundQuestion.objects.get(pk=rq_id) - - # Mix answers (dedup, shuffle) - lie_texts = list(LieAnswer.objects.filter(round_question=rq).values_list('text', flat=True)) - seen = set() - answers = [] - for text in [rq.correct_answer, *lie_texts]: - norm = text.strip().casefold() - if norm and norm not in seen: - seen.add(norm) - answers.append(text.strip()) - random.shuffle(answers) - rq.mixed_answers = answers - rq.save(update_fields=['mixed_answers']) - - return PhaseResult( - next_state='GUESS_PHASE', - duration_seconds=config['guess_seconds'], - broadcast_payload={ - 'type': 'phase.guess_started', - 'round_question_id': rq.id, - 'answers': [{'text': t} for t in answers], - 'guess_seconds': config['guess_seconds'], - }, - ) - - -def guess_phase_expired(session, run, config: dict) -> PhaseResult: - """Timer ran out in GUESS_PHASE. Compute reveal order, enter first REVEAL_LIE or REVEAL_TRUTH.""" - from fupogfakta.models import RoundQuestion, LieAnswer, Guess - rq_id = run.state_data['round_question_id'] - rq = RoundQuestion.objects.prefetch_related('lies', 'guesses').get(pk=rq_id) - - # Determine which lies had at least one guesser - guessed_lie_ids = set( - Guess.objects.filter(round_question=rq, is_correct=False) - .exclude(fooled_player=None) - .values_list('fooled_player_id', flat=True) - ) - # Map: liar player_id → lie - lie_map = {lie.player_id: lie for lie in rq.lies.all()} - reveal_lies = [lie for pid, lie in lie_map.items() if any( - g.fooled_player_id == pid for g in rq.guesses.all() - )] - - # Assign reveal_order to each lie being revealed - for i, lie in enumerate(reveal_lies): - lie.reveal_order = i - lie.save(update_fields=['reveal_order']) - - run.state_data = { - **run.state_data, - 'reveal_lies': [lie.id for lie in reveal_lies], - 'reveal_index': 0, - } - run.save(update_fields=['state_data']) - - if reveal_lies: - return _reveal_lie_result(rq, reveal_lies[0], config, run.state_data) - else: - return _reveal_truth_result(rq, config) - - -def reveal_lie_expired(session, run, config: dict) -> PhaseResult: - """A lie has been shown. Score the liar. Move to next lie or truth.""" - from fupogfakta.models import LieAnswer, Guess, Player, RoundQuestion - rq_id = run.state_data['round_question_id'] - reveal_lies = run.state_data['reveal_lies'] - reveal_index = run.state_data['reveal_index'] - round_number = run.state_data['round_number'] - - # Score the liar for this lie - lie = LieAnswer.objects.get(pk=reveal_lies[reveal_index]) - guessers = list(Guess.objects.filter(round_question_id=rq_id, fooled_player=lie.player).select_related('player')) - points_bluff = _points_bluff(config, round_number) - if guessers: - delta = points_bluff * len(guessers) - lie.player.score += delta - lie.player.save(update_fields=['score']) - - # Next reveal - next_index = reveal_index + 1 - run.state_data = {**run.state_data, 'reveal_index': next_index} - run.save(update_fields=['state_data']) - - rq = RoundQuestion.objects.get(pk=rq_id) - if next_index < len(reveal_lies): - next_lie = LieAnswer.objects.get(pk=reveal_lies[next_index]) - return _reveal_lie_result(rq, next_lie, config, run.state_data) - else: - return _reveal_truth_result(rq, config) - - -def reveal_truth_expired(session, run, config: dict) -> PhaseResult: - """Truth revealed. Score correct guessers. Enter SCOREBOARD_RECAP.""" - from fupogfakta.models import Guess, RoundQuestion - rq_id = run.state_data['round_question_id'] - round_number = run.state_data['round_number'] - rq = RoundQuestion.objects.get(pk=rq_id) - - correct_guessers = list(Guess.objects.filter(round_question=rq, is_correct=True).select_related('player')) - points_correct = _points_correct(config, round_number) - for guess in correct_guessers: - guess.player.score += points_correct - guess.player.save(update_fields=['score']) - - leaderboard = _leaderboard(session) - return PhaseResult( - next_state='SCOREBOARD_RECAP', - duration_seconds=config['scoreboard_recap_seconds'], - broadcast_payload={ - 'type': 'phase.scoreboard', - 'leaderboard': leaderboard, - 'round_number': round_number, - }, - ) - - -def scoreboard_recap_expired(session, run, config: dict) -> PhaseResult: - """Scoreboard shown. Advance to next question, next round, or post-game awards.""" - round_number = run.state_data['round_number'] - question_number = run.state_data['question_number'] - num_rounds = config['num_rounds'] - questions_per_round = config['questions_per_round'] - - if question_number < questions_per_round: - # Next question in same round - return enter_lie_phase(session, run, config, round_number, question_number + 1) - elif round_number < num_rounds: - # Next round - return enter_lie_phase(session, run, config, round_number + 1, 1) - else: - # All rounds done → post-game awards - return _post_game_awards_result(session, config) - - -def post_game_awards_expired(session, run, config: dict) -> PhaseResult: - """Awards shown. Game over.""" - return PhaseResult( - next_state='FINISHED', - duration_seconds=None, - broadcast_payload={ - 'type': 'phase.game_over', - 'leaderboard': _leaderboard(session), - }, - ) - - -# ── helpers ────────────────────────────────────────────────────────────────── - -def _points_correct(config: dict, round_number: int) -> int: - pts = config.get('points_correct') or [1500, 3000, 4500] - return pts[min(round_number - 1, len(pts) - 1)] - - -def _points_bluff(config: dict, round_number: int) -> int: - pts = config.get('points_bluff') or [500, 1000, 1500] - return pts[min(round_number - 1, len(pts) - 1)] - - -def _leaderboard(session) -> list: - from fupogfakta.models import Player - return list( - Player.objects.filter(session=session) - .order_by('-score', 'nickname') - .values('id', 'nickname', 'score') - ) - - -def _reveal_lie_result(rq, lie, config: dict, state_data: dict) -> PhaseResult: - from fupogfakta.models import Guess - guessers = list( - Guess.objects.filter(round_question=rq, fooled_player=lie.player) - .select_related('player') - .values('player__nickname') - ) - return PhaseResult( - next_state=f'REVEAL_LIE_{state_data["reveal_index"]}', - duration_seconds=config['reveal_seconds_per_lie'], - broadcast_payload={ - 'type': 'phase.reveal_lie', - 'lie_text': lie.text, - 'author': lie.player.nickname, - 'guessers': [g['player__nickname'] for g in guessers], - 'guesser_count': len(guessers), - }, - ) - - -def _reveal_truth_result(rq, config: dict) -> PhaseResult: - from fupogfakta.models import Guess - correct_guessers = list( - Guess.objects.filter(round_question=rq, is_correct=True) - .select_related('player') - .values('player__nickname') - ) - return PhaseResult( - next_state='REVEAL_TRUTH', - duration_seconds=config['reveal_seconds_per_lie'], - broadcast_payload={ - 'type': 'phase.reveal_truth', - 'correct_answer': rq.correct_answer, - 'correct_guessers': [g['player__nickname'] for g in correct_guessers], - }, - ) - - -def _post_game_awards_result(session, config: dict) -> PhaseResult: - from fupogfakta.models import LieReaction, LieAnswer - from django.db.models import Count - - # Most laughs overall per player - top_laugh = ( - LieReaction.objects.filter(lie__round_question__session=session, reaction='laugh') - .values('lie__player__nickname') - .annotate(total=Count('id')) - .order_by('-total') - .first() - ) - # Most hearts on a single lie - top_heart = ( - LieReaction.objects.filter(lie__round_question__session=session, reaction='heart') - .values('lie__player__nickname', 'lie__text') - .annotate(total=Count('id')) - .order_by('-total') - .first() - ) - - awards = [] - if top_laugh: - awards.append({ - 'award': 'most_hilarious', - 'label': 'Most Hilarious Liar 😂', - 'winner': top_laugh['lie__player__nickname'], - 'count': top_laugh['total'], - }) - if top_heart: - awards.append({ - 'award': 'most_beloved', - 'label': 'Most Beloved Lie ❤️', - 'winner': top_heart['lie__player__nickname'], - 'lie': top_heart['lie__text'], - 'count': top_heart['total'], - }) - - return PhaseResult( - next_state='POST_GAME_AWARDS', - duration_seconds=config['awards_display_seconds'], - broadcast_payload={ - 'type': 'phase.awards', - 'awards': awards, - 'leaderboard': _leaderboard(session), - }, - ) -``` - -**Step 5: Run tests** - -```bash -.venv/bin/python manage.py test fupogfakta.tests.FupOgFaktaDriverStartTest --verbosity=2 -``` - -Expected: PASS. - -**Step 6: Run full suite** - -```bash -.venv/bin/python manage.py test fupogfakta lobby realtime --verbosity=1 -``` - -Expected: all green. - -**Step 7: Commit** - -```bash -git add fupogfakta/driver.py fupogfakta/phases.py fupogfakta/tests.py -git commit -m "feat(fupogfakta): implement game driver and all phase transitions" -``` - ---- - -## Batch 6 — Platform REST Endpoints + Celery Wiring - ---- - -### Task 10: Wire `handle_timer_expired` to the driver - -**Files:** -- Modify: `lobby/tasks.py` - -**Step 1: Write failing test** - -```python -# lobby/tests.py — add - -class TimerTaskDispatchTest(TestCase): - def setUp(self): - from django.contrib.auth import get_user_model - User = get_user_model() - self.host = User.objects.create_user(username='taskhost', password='pw') - from fupogfakta.models import GameSession, Category, Question, Player - from lobby.models import GameRun - self.session = GameSession.objects.create(host=self.host, code='TASKTS') - self.cat = Category.objects.create(name='TskCat', slug='tskcat') - for i in range(3): - Question.objects.create(category=self.cat, prompt=f'Q{i}', correct_answer=f'A{i}') - Player.objects.create(session=self.session, nickname='P1') - Player.objects.create(session=self.session, nickname='P2') - self.run = GameRun.objects.create( - session=self.session, - current_state='LIE_PHASE', - state_data={ - 'round_number': 1, 'question_number': 1, - 'round_question_id': None, # set below - 'config': { - 'num_rounds': 1, 'questions_per_round': 1, - 'lie_seconds': 30, 'guess_seconds': 20, - 'reveal_seconds_per_lie': 5, 'scoreboard_recap_seconds': 5, - 'awards_display_seconds': 5, - 'points_correct': [1500], 'points_bluff': [500], - 'min_players': 2, 'max_players': 8, - 'category_slug': 'tskcat', - }, - }, - ) - from fupogfakta.models import RoundQuestion, Question as Q - q = Q.objects.filter(category=self.cat).first() - rq = RoundQuestion.objects.create(session=self.session, round_number=1, question=q, correct_answer=q.correct_answer) - self.run.state_data['round_question_id'] = rq.id - self.run.save(update_fields=['state_data']) - self.rq = rq - - def test_task_transitions_lie_to_guess(self): - from lobby.tasks import handle_timer_expired - from lobby.models import GameRun - handle_timer_expired(run_id=self.run.pk, expected_state='LIE_PHASE') - self.run.refresh_from_db() - self.assertEqual(self.run.current_state, 'GUESS_PHASE') - - def test_stale_task_does_not_transition(self): - from lobby.tasks import handle_timer_expired - from lobby.models import GameRun - handle_timer_expired(run_id=self.run.pk, expected_state='WRONG_STATE') - self.run.refresh_from_db() - self.assertEqual(self.run.current_state, 'LIE_PHASE') # unchanged -``` - -**Step 2: Update `lobby/tasks.py` — full implementation** - -```python -import logging -from celery import shared_task -from asgiref.sync import async_to_sync -from channels.layers import get_channel_layer - -logger = logging.getLogger(__name__) - - -def _schedule_next(run, duration_seconds: int) -> str: - """Schedule handle_timer_expired for run after duration_seconds. Returns task id.""" - from datetime import timedelta - from django.utils import timezone - eta = timezone.now() + timedelta(seconds=duration_seconds) - result = handle_timer_expired.apply_async( - kwargs={'run_id': run.pk, 'expected_state': run.current_state}, - eta=eta, - ) - return result.id - - -def _broadcast(session_code: str, payload: dict) -> None: - channel_layer = get_channel_layer() - group_name = f"game_{session_code.upper()}" - async_to_sync(channel_layer.group_send)( - group_name, - {'type': 'phase_event', 'payload': payload}, - ) - - -@shared_task(bind=True, max_retries=0) -def handle_timer_expired(self, run_id: int, expected_state: str) -> None: - from lobby.models import GameRun - from lobby.driver import get_driver - from django.utils import timezone - - try: - run = GameRun.objects.select_related('session').get(pk=run_id) - except GameRun.DoesNotExist: - logger.info("handle_timer_expired: run %s not found — stale task", run_id) - return - - if run.current_state != expected_state: - logger.info("handle_timer_expired: run %s state mismatch (%r != %r) — stale", run_id, run.current_state, expected_state) - return - - if run.is_paused: - logger.info("handle_timer_expired: run %s is paused — ignoring", run_id) - return - - config = run.state_data.get('config', {}) - driver = get_driver(run.session.game_type) - - if run.current_state == 'FINISHED': - # Clean up - driver.on_exit(run.session, run) - return - - result = driver.on_timer_expired(run.session, run, config) - - if result.next_state == 'FINISHED': - _broadcast(run.session.code, result.broadcast_payload) - driver.on_exit(run.session, run) - return - - run.current_state = result.next_state - run.phase_deadline = timezone.now() + __import__('datetime').timedelta(seconds=result.duration_seconds) if result.duration_seconds else None - run.celery_task_id = None - run.save(update_fields=['current_state', 'phase_deadline', 'celery_task_id', 'state_data']) - - if result.duration_seconds: - task_id = _schedule_next(run, result.duration_seconds) - run.celery_task_id = task_id - run.save(update_fields=['celery_task_id']) - - _broadcast(run.session.code, result.broadcast_payload) -``` - -**Step 3: Run tests** - -```bash -.venv/bin/python manage.py test lobby.tests.TimerTaskDispatchTest --verbosity=2 -``` - -Expected: PASS (CELERY_TASK_ALWAYS_EAGER=True in test mode). - -**Step 4: Commit** - -```bash -git add lobby/tasks.py lobby/tests.py -git commit -m "feat(platform): wire handle_timer_expired to GameDriver dispatch" -``` - ---- - -### Task 11: Platform play/pause/exit endpoints - -**Files:** -- Modify: `lobby/views.py` — add play, pause, exit views -- Modify: `lobby/urls.py` — register routes - -**Step 1: Write failing tests** - -```python -# lobby/tests.py — add - -class PlayPauseExitTest(TestCase): - def setUp(self): - from django.contrib.auth import get_user_model - User = get_user_model() - self.host = User.objects.create_user(username='ppe_host', password='pw') - self.client.force_login(self.host) - from fupogfakta.models import GameSession, Category, Question, Player - from fupogfakta.models import FupOgFaktaConfig - self.session = GameSession.objects.create(host=self.host, code='PPETEST') - cat = Category.objects.create(name='PPECat', slug='ppecat') - for i in range(3): - Question.objects.create(category=cat, prompt=f'Q{i}', correct_answer=f'A{i}') - Player.objects.create(session=self.session, nickname='P1') - Player.objects.create(session=self.session, nickname='P2') - - def test_play_creates_game_run_and_transitions_to_active(self): - from lobby.models import GameRun - resp = self.client.post(f'/lobby/sessions/PPETEST/play', - content_type='application/json', - data={'category_slug': 'ppecat'}) - self.assertEqual(resp.status_code, 201) - self.assertTrue(GameRun.objects.filter(session__code='PPETEST').exists()) - - def test_pause_sets_is_paused(self): - from lobby.models import GameRun - self.client.post(f'/lobby/sessions/PPETEST/play', - content_type='application/json', - data={'category_slug': 'ppecat'}) - resp = self.client.post('/lobby/sessions/PPETEST/pause') - self.assertEqual(resp.status_code, 200) - run = GameRun.objects.get(session__code='PPETEST') - self.assertTrue(run.is_paused) - - def test_exit_deletes_game_run(self): - from lobby.models import GameRun - self.client.post('/lobby/sessions/PPETEST/play', - content_type='application/json', - data={'category_slug': 'ppecat'}) - resp = self.client.post('/lobby/sessions/PPETEST/exit') - self.assertEqual(resp.status_code, 200) - self.assertFalse(GameRun.objects.filter(session__code='PPETEST').exists()) -``` - -**Step 2: Add views to `lobby/views.py`** - -Add these three views at the bottom: - -```python -@require_POST -@login_required -def play_session(request: HttpRequest, code: str) -> JsonResponse: - """Start game (or resume if paused).""" - from lobby.models import GameRun - from lobby.driver import get_driver - from lobby.tasks import _schedule_next, _broadcast - from fupogfakta.config import resolve_config - from django.utils import timezone - - session_code = _normalize_session_code(code) - try: - session = GameSession.objects.get(code=session_code) - except GameSession.DoesNotExist: - return JsonResponse({'error': 'Session not found'}, status=404) - - if session.host_id != request.user.id: - return JsonResponse({'error': 'Only host can start game'}, status=403) - - # Resume from pause - run = GameRun.objects.filter(session=session).first() - if run and run.is_paused: - remaining = run.paused_remaining_seconds or 0 - run.phase_deadline = timezone.now() + __import__('datetime').timedelta(seconds=remaining) - run.is_paused = False - run.paused_remaining_seconds = None - run.save(update_fields=['phase_deadline', 'is_paused', 'paused_remaining_seconds']) - task_id = _schedule_next(run, int(remaining)) - run.celery_task_id = task_id - run.save(update_fields=['celery_task_id']) - _broadcast(session.code, {'type': 'phase.resumed', 'phase_deadline': run.phase_deadline.isoformat()}) - return JsonResponse({'status': 'resumed'}) - - if run: - return JsonResponse({'error': 'Game already running'}, status=409) - - # Fresh start - payload = _json_body(request) - category_slug = str(payload.get('category_slug', '')).strip() - if not category_slug: - return JsonResponse({'error': 'category_slug required'}, status=400) - - config = resolve_config(request.user) - config['category_slug'] = category_slug - - run = GameRun.objects.create( - session=session, - current_state='LOBBY', - state_data={'config': config}, - ) - - driver = get_driver(session.game_type) - result = driver.on_game_start(session, run, config) - - run.current_state = result.next_state - run.state_data['config'] = config - run.save(update_fields=['current_state', 'state_data']) - - if result.duration_seconds: - run.phase_deadline = timezone.now() + __import__('datetime').timedelta(seconds=result.duration_seconds) - task_id = _schedule_next(run, result.duration_seconds) - run.celery_task_id = task_id - run.save(update_fields=['phase_deadline', 'celery_task_id']) - - session.status = GameSession.Status.ACTIVE - session.save(update_fields=['status']) - - _broadcast(session.code, result.broadcast_payload) - - return JsonResponse({'status': 'started', 'state': run.current_state}, status=201) - - -@require_POST -@login_required -def pause_session(request: HttpRequest, code: str) -> JsonResponse: - from lobby.models import GameRun - from lobby.tasks import _broadcast - from django.utils import timezone - from celery.app.control import Control - - session_code = _normalize_session_code(code) - try: - session = GameSession.objects.get(code=session_code) - except GameSession.DoesNotExist: - return JsonResponse({'error': 'Session not found'}, status=404) - - if session.host_id != request.user.id: - return JsonResponse({'error': 'Only host can pause'}, status=403) - - try: - run = GameRun.objects.get(session=session) - except GameRun.DoesNotExist: - return JsonResponse({'error': 'Game not running'}, status=400) - - if run.is_paused: - return JsonResponse({'error': 'Already paused'}, status=400) - - # Revoke Celery task - if run.celery_task_id: - from partyhub.celery import app as celery_app - celery_app.control.revoke(run.celery_task_id, terminate=True) - - remaining = (run.phase_deadline - timezone.now()).total_seconds() if run.phase_deadline else 0 - run.is_paused = True - run.paused_remaining_seconds = max(remaining, 0) - run.celery_task_id = None - run.save(update_fields=['is_paused', 'paused_remaining_seconds', 'celery_task_id']) - - _broadcast(session.code, {'type': 'phase.paused', 'remaining_seconds': run.paused_remaining_seconds}) - return JsonResponse({'status': 'paused', 'remaining_seconds': run.paused_remaining_seconds}) - - -@require_POST -@login_required -def exit_session(request: HttpRequest, code: str) -> JsonResponse: - from lobby.models import GameRun - from lobby.driver import get_driver - from celery.app.control import Control - - session_code = _normalize_session_code(code) - try: - session = GameSession.objects.get(code=session_code) - except GameSession.DoesNotExist: - return JsonResponse({'error': 'Session not found'}, status=404) - - if session.host_id != request.user.id: - return JsonResponse({'error': 'Only host can exit'}, status=403) - - run = GameRun.objects.filter(session=session).first() - if run: - if run.celery_task_id: - from partyhub.celery import app as celery_app - celery_app.control.revoke(run.celery_task_id, terminate=True) - driver = get_driver(session.game_type) - driver.on_exit(session, run) - - return JsonResponse({'status': 'exited'}) -``` - -**Step 3: Add routes to `lobby/urls.py`** - -```python -path("sessions//play", views.play_session, name="play_session"), -path("sessions//pause", views.pause_session, name="pause_session"), -path("sessions//exit", views.exit_session, name="exit_session"), -``` - -**Step 4: Run tests** - -```bash -.venv/bin/python manage.py test lobby.tests.PlayPauseExitTest --verbosity=2 -``` - -Expected: PASS. - -**Step 5: Run full suite** - -```bash -.venv/bin/python manage.py test fupogfakta lobby realtime --verbosity=1 -``` - -**Step 6: Commit** - -```bash -git add lobby/views.py lobby/urls.py lobby/tests.py -git commit -m "feat(platform): add play/pause/exit session endpoints" -``` - ---- - -### Task 12: FupOgFakta game-specific endpoints (lie, guess, react) - -**Files:** -- Create: `fupogfakta/views.py` -- Create: `fupogfakta/urls.py` -- Modify: `partyhub/urls.py` - -**Step 1: Write failing tests** - -```python -# fupogfakta/tests.py — add - -class SubmitLieViewTest(TestCase): - def setUp(self): - # Create session in LIE_PHASE with a RoundQuestion active - from django.contrib.auth import get_user_model - User = get_user_model() - self.host = User.objects.create_user(username='liehost', password='pw') - from fupogfakta.models import GameSession, Player, Category, Question, RoundQuestion - from lobby.models import GameRun - self.session = GameSession.objects.create(host=self.host, code='LIETEST', status='active') - cat = Category.objects.create(name='LieCat', slug='liecat') - q = Question.objects.create(category=cat, prompt='What is?', correct_answer='Real Answer') - self.rq = RoundQuestion.objects.create(session=self.session, round_number=1, question=q, correct_answer='Real Answer') - self.player = Player.objects.create(session=self.session, nickname='Liar') - self.run = GameRun.objects.create( - session=self.session, current_state='LIE_PHASE', - state_data={'round_question_id': self.rq.id, 'config': {'lie_seconds': 45}} - ) - - def test_submit_lie_succeeds(self): - resp = self.client.post( - '/fupogfakta/LIETEST/lie', - content_type='application/json', - data={'session_token': self.player.session_token, 'text': 'My fake answer'}, - ) - self.assertEqual(resp.status_code, 201) - - def test_submit_correct_answer_as_lie_rejected(self): - resp = self.client.post( - '/fupogfakta/LIETEST/lie', - content_type='application/json', - data={'session_token': self.player.session_token, 'text': 'real answer'}, - ) - self.assertEqual(resp.status_code, 422) - self.assertIn('lie_matches_correct_answer', resp.json().get('error_code', '')) -``` - -**Step 2: Create `fupogfakta/views.py`** - -```python -import json -from django.http import HttpRequest, JsonResponse -from django.views.decorators.http import require_POST - -from fupogfakta.models import GameSession, Player, RoundQuestion, LieAnswer, Guess, LieReaction -from lobby.models import GameRun - - -def _json_body(request: HttpRequest) -> dict: - try: - return json.loads(request.body) if request.body else {} - except json.JSONDecodeError: - return {} - - -def _get_active_run_and_player(code: str, session_token: str): - """Returns (session, run, player) or raises.""" - try: - session = GameSession.objects.get(code=code.upper()) - except GameSession.DoesNotExist: - return None, None, None - try: - run = GameRun.objects.get(session=session) - except GameRun.DoesNotExist: - return session, None, None - try: - player = Player.objects.get(session=session, session_token=session_token) - except Player.DoesNotExist: - return session, run, None - return session, run, player - - -@require_POST -def submit_lie(request: HttpRequest, code: str) -> JsonResponse: - payload = _json_body(request) - session_token = str(payload.get('session_token', '')).strip() - lie_text = str(payload.get('text', '')).strip() - - if not session_token: - return JsonResponse({'error': 'session_token required'}, status=400) - if not lie_text or len(lie_text) > 255: - return JsonResponse({'error': 'text must be 1-255 characters'}, status=400) - - session, run, player = _get_active_run_and_player(code, session_token) - if not session: - return JsonResponse({'error': 'Session not found'}, status=404) - if not run: - return JsonResponse({'error': 'Game not running'}, status=400) - if not player: - return JsonResponse({'error': 'Invalid session_token'}, status=403) - if run.current_state != 'LIE_PHASE': - return JsonResponse({'error': 'Not in lie phase'}, status=400) - - rq_id = run.state_data.get('round_question_id') - try: - rq = RoundQuestion.objects.get(pk=rq_id, session=session) - except RoundQuestion.DoesNotExist: - return JsonResponse({'error': 'No active question'}, status=400) - - # Reject if lie matches correct answer (case-insensitive) - if lie_text.strip().casefold() == rq.correct_answer.strip().casefold(): - return JsonResponse({ - 'error': 'Your lie matches the correct answer — try something else!', - 'error_code': 'lie_matches_correct_answer', - }, status=422) - - from django.db import IntegrityError - try: - lie = LieAnswer.objects.create(round_question=rq, player=player, text=lie_text) - except IntegrityError: - return JsonResponse({'error': 'Lie already submitted'}, status=409) - - # Broadcast progress (anonymous count only) - from realtime.broadcast import sync_broadcast_phase_event - total_players = Player.objects.filter(session=session).count() - submitted = LieAnswer.objects.filter(round_question=rq).count() - sync_broadcast_phase_event(session.code, 'phase.lie_progress', { - 'submitted': submitted, - 'total': total_players, - }) - - return JsonResponse({'lie_id': lie.id}, status=201) - - -@require_POST -def submit_guess(request: HttpRequest, code: str) -> JsonResponse: - payload = _json_body(request) - session_token = str(payload.get('session_token', '')).strip() - selected_text = str(payload.get('selected_text', '')).strip() - - if not session_token: - return JsonResponse({'error': 'session_token required'}, status=400) - if not selected_text: - return JsonResponse({'error': 'selected_text required'}, status=400) - - session, run, player = _get_active_run_and_player(code, session_token) - if not session: - return JsonResponse({'error': 'Session not found'}, status=404) - if not run: - return JsonResponse({'error': 'Game not running'}, status=400) - if not player: - return JsonResponse({'error': 'Invalid session_token'}, status=403) - if run.current_state != 'GUESS_PHASE': - return JsonResponse({'error': 'Not in guess phase'}, status=400) - - rq_id = run.state_data.get('round_question_id') - rq = RoundQuestion.objects.get(pk=rq_id, session=session) - - allowed = {t.strip().casefold() for t in rq.mixed_answers} - if selected_text.casefold() not in allowed: - return JsonResponse({'error': 'Answer not in this round'}, status=400) - - correct_normalized = rq.correct_answer.strip().casefold() - is_correct = selected_text.casefold() == correct_normalized - - fooled_player_id = None - if not is_correct: - fooled_player_id = ( - LieAnswer.objects.filter(round_question=rq, text__iexact=selected_text) - .values_list('player_id', flat=True) - .first() - ) - - from django.db import IntegrityError - try: - guess = Guess.objects.create( - round_question=rq, - player=player, - selected_text=selected_text, - is_correct=is_correct, - fooled_player_id=fooled_player_id, - ) - except IntegrityError: - return JsonResponse({'error': 'Guess already submitted'}, status=409) - - return JsonResponse({'guess_id': guess.id, 'is_correct': is_correct}, status=201) - - -@require_POST -def submit_reaction(request: HttpRequest, code: str) -> JsonResponse: - payload = _json_body(request) - session_token = str(payload.get('session_token', '')).strip() - lie_id = payload.get('lie_id') - reaction = str(payload.get('reaction', '')).strip() - - VALID_REACTIONS = {'laugh', 'heart', 'fire', 'wow'} - if not session_token: - return JsonResponse({'error': 'session_token required'}, status=400) - if reaction not in VALID_REACTIONS: - return JsonResponse({'error': f'reaction must be one of {sorted(VALID_REACTIONS)}'}, status=400) - - session, run, player = _get_active_run_and_player(code, session_token) - if not session: - return JsonResponse({'error': 'Session not found'}, status=404) - if not run or run.current_state != 'GUESS_PHASE': - return JsonResponse({'error': 'Reactions only allowed during guess phase'}, status=400) - if not player: - return JsonResponse({'error': 'Invalid session_token'}, status=403) - - # Cannot react to own lie - try: - lie = LieAnswer.objects.get(pk=lie_id, round_question__session=session) - except LieAnswer.DoesNotExist: - return JsonResponse({'error': 'Lie not found'}, status=404) - if lie.player_id == player.id: - return JsonResponse({'error': 'Cannot react to your own lie'}, status=400) - - from django.db import IntegrityError - try: - LieReaction.objects.create(lie=lie, player=player, reaction=reaction) - except IntegrityError: - # Already reacted with this emoji — idempotent, return ok - pass - - return JsonResponse({'status': 'ok'}) -``` - -**Step 3: Create `fupogfakta/urls.py`** - -```python -from django.urls import path -from . import views - -app_name = 'fupogfakta' - -urlpatterns = [ - path('/lie', views.submit_lie, name='submit_lie'), - path('/guess', views.submit_guess, name='submit_guess'), - path('/react', views.submit_reaction, name='submit_reaction'), -] -``` - -**Step 4: Register in `partyhub/urls.py`** - -Read the file first, then add: - -```python -path('fupogfakta/', include('fupogfakta.urls')), -``` - -**Step 5: Run tests** - -```bash -.venv/bin/python manage.py test fupogfakta.tests.SubmitLieViewTest --verbosity=2 -``` - -Expected: PASS. - -**Step 6: Commit** - -```bash -git add fupogfakta/views.py fupogfakta/urls.py partyhub/urls.py fupogfakta/tests.py -git commit -m "feat(fupogfakta): add lie/guess/react endpoints" -``` - ---- - -## Batch 7 — Frontend Game Screens - -The Angular host-shell and player-shell currently work as developer control panels. Replace them with proper game screens that react to WebSocket events. - ---- - -### Task 13: WebSocket state machine in Angular - -**Files:** -- Create: `frontend/angular/src/app/game-state.service.ts` - -The service opens a WebSocket to `ws/game/{sessionCode}/` and maintains reactive state from `phase.*` events. Both host and player shells subscribe to this service. - -```typescript -// game-state.service.ts -import { Injectable, signal } from '@angular/core'; - -export type GamePhase = - | 'LOBBY' | 'LIE_PHASE' | 'GUESS_PHASE' - | `REVEAL_LIE_${number}` | 'REVEAL_TRUTH' - | 'SCOREBOARD_RECAP' | 'POST_GAME_AWARDS' | 'FINISHED'; - -export interface GameState { - phase: GamePhase; - sessionCode: string; - payload: Record; -} - -@Injectable({ providedIn: 'root' }) -export class GameStateService { - readonly state = signal({ phase: 'LOBBY', sessionCode: '', payload: {} }); - private ws: WebSocket | null = null; - - connect(sessionCode: string, token: string, role: 'player' | 'host') { - const query = role === 'host' ? 'role=host' : `session_token=${token}`; - this.ws = new WebSocket(`ws://${location.host}/ws/game/${sessionCode}/?${query}`); - this.ws.onmessage = (ev) => { - const msg = JSON.parse(ev.data); - this.state.set({ phase: msg.type.replace('phase.', '').toUpperCase() as GamePhase, sessionCode, payload: msg }); - }; - } - - disconnect() { this.ws?.close(); } - - ping() { this.ws?.send(JSON.stringify({ type: 'ping' })); } -} -``` - -**Step 1: Write Vitest test** - -```typescript -// frontend/angular/src/app/game-state.service.spec.ts -import { TestBed } from '@angular/core/testing'; -import { GameStateService } from './game-state.service'; - -describe('GameStateService', () => { - it('initialises in LOBBY phase', () => { - const svc = TestBed.inject(GameStateService); - expect(svc.state().phase).toBe('LOBBY'); - }); -}); -``` - -**Step 2: Run test** - -```bash -cd frontend/angular && npm test -- --run 2>&1 | grep -E 'PASS|FAIL|GameState' -``` - -**Step 3: Rebuild host-shell for real gameplay** - -Replace `host-shell.component.ts` content. The host screen shows: -- **LOBBY**: session code large, player list, "Play" button with category select -- **LIE_PHASE**: question prompt, lie-submission progress bar (N/total), countdown timer, Pause button -- **GUESS_PHASE**: answers displayed (no correct answer highlighted), countdown timer, Pause button -- **REVEAL_LIE_\***: lie text, author revealed, guessers listed, score delta animation -- **REVEAL_TRUTH**: correct answer, who guessed right, score deltas -- **SCOREBOARD_RECAP**: full leaderboard -- **POST_GAME_AWARDS**: award cards -- **FINISHED**: final leaderboard, "New Game" button - -Host makes REST calls only for: POST `/lobby/sessions/{code}/play`, POST `pause`, POST `exit`. - -**Step 4: Rebuild player-shell for real gameplay** - -Player screen shows: -- **LOBBY**: "Waiting for host to start…" with players list -- **LIE_PHASE**: text input for lie, submit button, countdown — replaced by "Submitted ✓" once sent -- **GUESS_PHASE**: answer buttons, once selected show emoji reaction buttons for other lies (cannot react to own lie), countdown -- **REVEAL_\*** / **SCOREBOARD** / **AWARDS**: display-only, shows own score delta highlighted -- **FINISHED**: final leaderboard - -Player makes REST calls only for: POST `/fupogfakta/{code}/lie`, POST `/fupogfakta/{code}/guess`, POST `/fupogfakta/{code}/react`. - -**Step 5: Run Angular tests** - -```bash -cd frontend/angular && npm test -- --run 2>&1 | tail -10 -``` - -**Step 6: Commit** - -```bash -git add frontend/angular/src/ -git commit -m "feat(frontend): rebuild host and player screens as real game UI" -``` - ---- - -## Batch 8 — Cleanup - -### Task 14: Remove obsolete lobby/views.py endpoints - -The old manual-advance endpoints (`start_round`, `show_question`, `submit_lie`, `mix_answers`, `submit_guess`, `calculate_scores`, `reveal_scoreboard`, `finish_game`, `start_next_round`) are now replaced by the driver + platform endpoints. Remove them from `lobby/views.py` and `lobby/urls.py`, then remove or update the tests that covered them. - -Run full suite after: - -```bash -.venv/bin/python manage.py test fupogfakta lobby realtime --verbosity=1 -``` - -Commit: - -```bash -git commit -m "chore: remove obsolete manual-advance lobby endpoints" -``` - ---- - -### Task 15: Update TODO.md - -Mark completed items, add new backlog items discovered during implementation: -- [ ] Rate limiting on fupogfakta/lie and /guess endpoints -- [ ] Session-code brute-force protection on /join -- [ ] TTS / read-aloud integration (Fase 4) - -```bash -git add TODO.md && git commit -m "docs: update TODO after game engine implementation" -``` - ---- - -## Running Order Summary - -``` -Batch 1 — Celery infra (Tasks 1-3) no breaking changes -Batch 2 — GameRun + Driver (Tasks 4-6) additive -Batch 3 — Config system (Task 7) additive -Batch 4 — Game models (Task 8) remove ScoreEvent -Batch 5 — FupOgFakta driver (Task 9) new game logic -Batch 6 — Platform endpoints (Tasks 10-12) new REST API -Batch 7 — Frontend (Tasks 13) replace UI -Batch 8 — Cleanup (Tasks 14-15) remove old code -``` - -Each batch is independently mergeable. Run `.venv/bin/python manage.py test fupogfakta lobby realtime` before every commit. diff --git a/docs/spa-cutover-flag.md b/docs/spa-cutover-flag.md deleted file mode 100644 index 80c61fd..0000000 --- a/docs/spa-cutover-flag.md +++ /dev/null @@ -1,99 +0,0 @@ -# SPA cutover feature flag (`USE_SPA_UI`) - -## Formål -`USE_SPA_UI` styrer om host/player UI routes serverer Angular SPA shell eller legacy Django templates. - -## Miljø-toggle (uden kodeændring) -Sæt env var pr. miljø: - -- `USE_SPA_UI=true` -> `/lobby/ui/host` og `/lobby/ui/player` returnerer SPA shell -- `USE_SPA_UI=false` (default) -> legacy template-flow bruges uændret - -Backward compatibility under cutover: -- Hvis `USE_SPA_UI` ikke er sat, bruges `WPP_SPA_ENABLED` som fallback. - -## Static asset versioning/cache-busting (hardening) -Formål: sikre at browser/proxy/CDN hurtigt henter ny SPA bundle i release-vinduet uden at kræve hard refresh. - -- `WPP_SPA_ASSET_BASE` peger fortsat på build-output (`/static/frontend/angular/browser`). -- `WPP_SPA_ASSET_VERSION` injiceres i SPA shell URLs som query-param (`?v=`). -- Anbefalet værdi for `WPP_SPA_ASSET_VERSION`: release-tag eller kort commit SHA. -- Ved rollback sættes `WPP_SPA_ASSET_VERSION` til den tidligere kendte stabile release-værdi. - -Eksempel (staging/prod env): - -```env -USE_SPA_UI=true -WPP_SPA_ASSET_BASE=/static/frontend/angular/browser -WPP_SPA_ASSET_VERSION=rel-2026-03-01-bb82357 -``` - -## Staging rollout-checkliste (`USE_SPA_UI`) -1. **Baseline (flag OFF)** - - Bekræft at staging kører med `USE_SPA_UI=false`. - - Kør gameplay smoke på legacy routes (`/lobby/ui/host` + `/lobby/ui/player`). -2. **Smoke-gate før aktivering (skal være grøn)** - - Cutover route sanity = PASS for både OFF og ON checks. - - Full gameplay round (join/start/round/scoreboard) = PASS. - - Next-round/final leaderboard sanity = PASS. - - Ingen nye blocker-regressioner i host/player kerneflow. -3. **Kontrolleret aktivering i staging** - - Sæt `USE_SPA_UI=true` i staging miljøet. - - Kør smoke-flow igen med SPA-route-verifikation. -4. **Post-cutover dokumentation** - - Log evidens med commit/head SHA, UTC timestamp og gate-status. - -## Rollback playbook (`USE_SPA_UI`) — mål: <10 min -Rollback til legacy (`USE_SPA_UI=false`) udføres straks hvis et checkpoint fejler: -- Forkert route/shell for valgt flag i cutover route sanity. -- Gameplay smoke kan ikke gennemføres til scoreboard/final leaderboard. -- Kritisk regression i host/player flow under smoke. - -Trin-for-trin: -1. Sæt `USE_SPA_UI=false` i deploy-env. -2. Sæt `WPP_SPA_ASSET_VERSION` til sidste stabile release-token. -3. Deploy/reload app-processer. -4. Verificér legacy routes: `/lobby/ui/host` + `/lobby/ui/player`. -5. Kør hurtig smoke sanity (join/start/scoreboard path). -6. Log UTC tid, trigger, release-token og resultat i smoke artifact. - -Target: rollback + sanity-verifikation inden for 10 minutter. - -## React fallback trigger-kriterier (kun delivery-blocking) -Formål: React fallback må kun bruges som kortvarig leverings-sikring, når release ellers er blokeret. - -### Hvornår fallback er tilladt -Alle punkter skal være opfyldt: -1. **Delivery-blocking fejl i Angular SPA** - - Host/player kerneflow kan ikke leveres i release-vinduet (fx login/join/start/round/scoreboard stopper). -2. **Ingen hurtig Angular-fix inden for release-vinduet** - - Teamet har vurderet at patch + verificering ikke kan nås sikkert i tide. -3. **Rollback alene løser ikke leveringsbehovet** - - `USE_SPA_UI=false` (legacy) er enten utilstrækkelig for den konkrete leverance eller allerede verificeret som ikke tilstrækkelig. -4. **Beslutning er eksplicit logget** - - Trigger, impact, UTC-tid, ansvarlig, issue/incident-reference og plan for tilbagevenden til Angular er dokumenteret i release/smoke artifact. - -### Scope-limits for fallback -- Fallback omfatter kun **delivery-blocking host/player-paths**. -- Ingen nye features, UX-forbedringer eller ikke-kritiske ændringer må bundtes ind i fallback. -- Fallback er **midlertidig** og gælder kun for aktiv incident/release-vindue. -- Når blocker er fjernet, skal miljøet tilbage på standard cutover-spor (Angular + `USE_SPA_UI` styring). - -### Ikke tilladt -- Proaktiv fallback "for en sikkerheds skyld" uden aktiv blocker. -- Brug af fallback til at omgå normale kvalitetsgates eller testkrav. -- Langvarig drift i fallback-mode uden dokumenteret blocker og opfølgningsplan. - -## Verifikation -- Flag OFF: `UiScreenTests.test_legacy_templates_are_used_when_spa_flag_is_off` -- Flag ON (host): `UiScreenTests.test_host_screen_can_render_angular_shell_when_feature_flag_enabled` -- Flag ON (host deep-link): `UiScreenTests.test_host_screen_deeplink_preserves_spa_path_when_feature_flag_enabled` -- Flag ON (player): `UiScreenTests.test_player_screen_can_render_angular_shell_when_feature_flag_enabled` -- Smoke-checkliste for cutover paths: `docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md` + `docs/UI_SMOKE.md` - - -## MVP audio policy guardrail (telefon-klient) -- Telefon-/player-klienten må ikke starte lydafspilning lokalt i MVP (`primary-device only`). -- Policy er bundet til capability-flaget `frontend.capabilities.client_has_no_audio_output=true` i `shared/i18n/lobby.json`. -- Brugeradvarsel i player UI leveres via i18n key: `frontend.ui.player.audio_policy_notice`. -- Acceptance-spec er dækket i Angular tests (`player-shell.component.spec.ts`), inkl. at init-path ikke kalder original media `play`. diff --git a/frontend/.gitignore b/frontend/.gitignore deleted file mode 100644 index c2658d7..0000000 --- a/frontend/.gitignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index 75702d5..0000000 --- a/frontend/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Frontend API client baseline - -Dette er baseline-klientlaget for SPA-sporet. - -## Kører checks lokalt - -```bash -cd frontend -npm install -npm test -npm run build -``` diff --git a/frontend/angular/.gitignore b/frontend/angular/.gitignore deleted file mode 100644 index 306e8de..0000000 --- a/frontend/angular/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules/ -dist/ -.angular/ diff --git a/frontend/angular/angular.json b/frontend/angular/angular.json deleted file mode 100644 index bc8cfc2..0000000 --- a/frontend/angular/angular.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "$schema": "./node_modules/@angular/cli/lib/config/schema.json", - "version": 1, - "projects": { - "wpp-angular-shell": { - "projectType": "application", - "root": "", - "sourceRoot": "src", - "prefix": "app", - "architect": { - "build": { - "builder": "@angular/build:application", - "options": { - "outputPath": "dist/wpp-angular-shell", - "index": "src/index.html", - "browser": "src/main.ts", - "polyfills": ["zone.js"], - "tsConfig": "tsconfig.app.json", - "assets": [], - "styles": ["src/styles.css"], - "outputHashing": "none" - } - }, - "serve": { - "builder": "@angular/build:dev-server", - "options": { - "buildTarget": "wpp-angular-shell:build" - } - } - } - } - }, - "cli": { - "analytics": false - } -} diff --git a/frontend/angular/package-lock.json b/frontend/angular/package-lock.json deleted file mode 100644 index 51c75d9..0000000 --- a/frontend/angular/package-lock.json +++ /dev/null @@ -1,8577 +0,0 @@ -{ - "name": "wpp-angular-shell", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "wpp-angular-shell", - "version": "0.0.0", - "dependencies": { - "@angular/common": "^19.2.0", - "@angular/compiler": "^19.2.0", - "@angular/core": "^19.2.0", - "@angular/forms": "^19.2.0", - "@angular/platform-browser": "^19.2.0", - "@angular/router": "^19.2.0", - "rxjs": "~7.8.0", - "tslib": "^2.3.0", - "zone.js": "~0.15.0" - }, - "devDependencies": { - "@angular/build": "^19.2.0", - "@angular/cli": "^19.2.0", - "@angular/compiler-cli": "^19.2.0", - "typescript": "~5.7.2", - "vitest": "^2.1.9" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@angular-devkit/architect": { - "version": "0.1902.22", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.22.tgz", - "integrity": "sha512-98NCVZZwDoL1oW0aj9Wd7piIkt/oSjMZEI04f3oL5nGD1LoXcN7BrYKXIpZ4NP4Pfn+7gJo3nrhglHnhcwQXyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "19.2.22", - "rxjs": "7.8.1" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular-devkit/architect/node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@angular-devkit/core": { - "version": "19.2.22", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.22.tgz", - "integrity": "sha512-OqN/Ded+ZKypPZN5+qUFwtnKGl7FKpxJXYO2Vts5vLBojY5goCZd9SGW1CyXeuPnisRUW+vjqBQbWYuEUh36Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "8.18.0", - "ajv-formats": "3.0.1", - "jsonc-parser": "3.3.1", - "picomatch": "4.0.2", - "rxjs": "7.8.1", - "source-map": "0.7.4" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "chokidar": "^4.0.0" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, - "node_modules/@angular-devkit/core/node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@angular-devkit/schematics": { - "version": "19.2.22", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.22.tgz", - "integrity": "sha512-tvfu5jhem1o8qidVxvXe5KfCij65ioMLCOFA947DD+zb3yTl5pJyDm2dqzbOehuQw0fmH4XPQukRJsCUy+UwaA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "19.2.22", - "jsonc-parser": "3.3.1", - "magic-string": "0.30.17", - "ora": "5.4.1", - "rxjs": "7.8.1" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular-devkit/schematics/node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@angular/build": { - "version": "19.2.22", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-19.2.22.tgz", - "integrity": "sha512-7TA2YyMpkFiAoiEP5v8IQVtFEAiphZz1B6NmfvgWRlvnpktSe67qXMDC/IoWnjtV7jyVufQg2BV1prrWIwfwKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1902.22", - "@babel/core": "7.26.10", - "@babel/helper-annotate-as-pure": "7.25.9", - "@babel/helper-split-export-declaration": "7.24.7", - "@babel/plugin-syntax-import-attributes": "7.26.0", - "@inquirer/confirm": "5.1.6", - "@vitejs/plugin-basic-ssl": "1.2.0", - "beasties": "0.3.2", - "browserslist": "^4.23.0", - "esbuild": "0.25.4", - "fast-glob": "3.3.3", - "https-proxy-agent": "7.0.6", - "istanbul-lib-instrument": "6.0.3", - "listr2": "8.2.5", - "magic-string": "0.30.17", - "mrmime": "2.0.1", - "parse5-html-rewriting-stream": "7.0.0", - "picomatch": "4.0.2", - "piscina": "4.8.0", - "rollup": "4.59.0", - "sass": "1.85.0", - "semver": "7.7.1", - "source-map-support": "0.5.21", - "vite": "6.4.1", - "watchpack": "2.4.2" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "optionalDependencies": { - "lmdb": "3.2.6" - }, - "peerDependencies": { - "@angular/compiler": "^19.0.0 || ^19.2.0-next.0", - "@angular/compiler-cli": "^19.0.0 || ^19.2.0-next.0", - "@angular/localize": "^19.0.0 || ^19.2.0-next.0", - "@angular/platform-server": "^19.0.0 || ^19.2.0-next.0", - "@angular/service-worker": "^19.0.0 || ^19.2.0-next.0", - "@angular/ssr": "^19.2.22", - "karma": "^6.4.0", - "less": "^4.2.0", - "ng-packagr": "^19.0.0 || ^19.2.0-next.0", - "postcss": "^8.4.0", - "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", - "typescript": ">=5.5 <5.9" - }, - "peerDependenciesMeta": { - "@angular/localize": { - "optional": true - }, - "@angular/platform-server": { - "optional": true - }, - "@angular/service-worker": { - "optional": true - }, - "@angular/ssr": { - "optional": true - }, - "karma": { - "optional": true - }, - "less": { - "optional": true - }, - "ng-packagr": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tailwindcss": { - "optional": true - } - } - }, - "node_modules/@angular/cli": { - "version": "19.2.22", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.22.tgz", - "integrity": "sha512-lyqlNfRf3PnmSSPbLKLqzol3jWoJXyTYO8u6PA8tMPjQczzWNisrwCjso7vJnECa/R19mFVlVjVNfg0lxsqfFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/architect": "0.1902.22", - "@angular-devkit/core": "19.2.22", - "@angular-devkit/schematics": "19.2.22", - "@inquirer/prompts": "7.3.2", - "@listr2/prompt-adapter-inquirer": "2.0.18", - "@schematics/angular": "19.2.22", - "@yarnpkg/lockfile": "1.1.0", - "ini": "5.0.0", - "jsonc-parser": "3.3.1", - "listr2": "8.2.5", - "npm-package-arg": "12.0.2", - "npm-pick-manifest": "10.0.0", - "pacote": "20.0.0", - "resolve": "1.22.10", - "semver": "7.7.1", - "symbol-observable": "4.0.0", - "yargs": "17.7.2" - }, - "bin": { - "ng": "bin/ng.js" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular/common": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.19.tgz", - "integrity": "sha512-/JYo8jJZ6BAgw3IVYJpinAfGb+RbaZubrElFvaq450BWxDPInv7Z99HKEQ3qEBRsBeIAQ/WrKXDxoJSjy7QMNQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "@angular/core": "19.2.19", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@angular/compiler": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.19.tgz", - "integrity": "sha512-kWlqFW7ExvAqKv+X/6ZsWVW7YTmI1/3VUvADLC/6bkLTdKrHS8OtBHfsklXmHMNVbbFopTvoTeKkoqLGrW2lwg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - } - }, - "node_modules/@angular/compiler-cli": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.19.tgz", - "integrity": "sha512-vdMJX9E7wePN41T+6BYRQBA+XiR9a5DBhs20dqtv8YVireQktH6mxLZIg1pVxkL/gnao2gpl/lOvp0xmC7UN/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "7.26.9", - "@jridgewell/sourcemap-codec": "^1.4.14", - "chokidar": "^4.0.0", - "convert-source-map": "^1.5.1", - "reflect-metadata": "^0.2.0", - "semver": "^7.0.0", - "tslib": "^2.3.0", - "yargs": "^17.2.1" - }, - "bin": { - "ng-xi18n": "bundles/src/bin/ng_xi18n.js", - "ngc": "bundles/src/bin/ngc.js", - "ngcc": "bundles/ngcc/index.js" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "@angular/compiler": "19.2.19", - "typescript": ">=5.5 <5.9" - } - }, - "node_modules/@angular/compiler-cli/node_modules/@babel/core": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz", - "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.9", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.9", - "@babel/parser": "^7.26.9", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.9", - "@babel/types": "^7.26.9", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@angular/core": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.19.tgz", - "integrity": "sha512-VZAzpxBoQgyy7AOlhxbAHxPQWo0nk8xsnrD36PLCZeTZA/5GNzO3lLVaX2N5BCUgpgiCBjNBKq9kVo6ZkAls9g==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "rxjs": "^6.5.3 || ^7.4.0", - "zone.js": "~0.15.0" - } - }, - "node_modules/@angular/forms": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.19.tgz", - "integrity": "sha512-J09++utTVaPs962y/adeDjIgqyhzNpnzAS7Nex+HNy/LnWPcTNW781cOh1EGS1X/+CmgnI8HWs5z4KGeBeU1aA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "@angular/common": "19.2.19", - "@angular/core": "19.2.19", - "@angular/platform-browser": "19.2.19", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@angular/platform-browser": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.19.tgz", - "integrity": "sha512-bnQSmoJNI1LQxJnHnB01XQXqgOdgAtLAOsa24ZT6b2pWV3Vw0/7+V2dZsNZX/TJtejunvSgSDCEqgJhIQ5vBVg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "@angular/animations": "19.2.19", - "@angular/common": "19.2.19", - "@angular/core": "19.2.19" - }, - "peerDependenciesMeta": { - "@angular/animations": { - "optional": true - } - } - }, - "node_modules/@angular/router": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-19.2.19.tgz", - "integrity": "sha512-zh40ihKgYOM5pjgUOLlUKdWYsGgEj7MQHgzdV1E9Zz6LBrQTp/PGS/UdCQn88H6KAshR0uXrkc/vP+tnB2jqdg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "@angular/common": "19.2.19", - "@angular/core": "19.2.19", - "@angular/platform-browser": "19.2.19", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", - "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.10", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.10", - "@babel/parser": "^7.26.10", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.10", - "@babel/types": "^7.26.10", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", - "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", - "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.24.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", - "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", - "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", - "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", - "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", - "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", - "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", - "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", - "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", - "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", - "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", - "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", - "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", - "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", - "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", - "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", - "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", - "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", - "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", - "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", - "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", - "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", - "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", - "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", - "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", - "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", - "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/ansi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", - "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/checkbox": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", - "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/confirm": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.6.tgz", - "integrity": "sha512-6ZXYK3M1XmaVBZX6FCfChgtponnL0R6I7k8Nu+kaoNkT828FVZTcca1MqmWQipaW2oNREQl5AaPCUOOCVNdRMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.1.7", - "@inquirer/type": "^3.0.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "10.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", - "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "cli-width": "^4.1.0", - "mute-stream": "^2.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/editor": { - "version": "4.2.23", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", - "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/external-editor": "^1.0.3", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/expand": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", - "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/external-editor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", - "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chardet": "^2.1.1", - "iconv-lite": "^0.7.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/input": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", - "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/number": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", - "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/password": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", - "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/prompts": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.2.tgz", - "integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/checkbox": "^4.1.2", - "@inquirer/confirm": "^5.1.6", - "@inquirer/editor": "^4.2.7", - "@inquirer/expand": "^4.0.9", - "@inquirer/input": "^4.1.6", - "@inquirer/number": "^3.0.9", - "@inquirer/password": "^4.0.9", - "@inquirer/rawlist": "^4.0.9", - "@inquirer/search": "^3.0.9", - "@inquirer/select": "^4.0.9" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/rawlist": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", - "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/search": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", - "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/select": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", - "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/type": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", - "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@listr2/prompt-adapter-inquirer": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-2.0.18.tgz", - "integrity": "sha512-0hz44rAcrphyXcA8IS7EJ2SCoaBZD2u5goE8S/e+q/DL+dOGpqpcLidVOFeLG3VgML62SXmfRLAhWt0zL1oW4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/type": "^1.5.5" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@inquirer/prompts": ">= 3 < 8" - } - }, - "node_modules/@listr2/prompt-adapter-inquirer/node_modules/@inquirer/type": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.5.5.tgz", - "integrity": "sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mute-stream": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@listr2/prompt-adapter-inquirer/node_modules/mute-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", - "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@lmdb/lmdb-darwin-arm64": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.2.6.tgz", - "integrity": "sha512-yF/ih9EJJZc72psFQbwnn8mExIWfTnzWJg+N02hnpXtDPETYLmQswIMBn7+V88lfCaFrMozJsUvcEQIkEPU0Gg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@lmdb/lmdb-darwin-x64": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.2.6.tgz", - "integrity": "sha512-5BbCumsFLbCi586Bb1lTWQFkekdQUw8/t8cy++Uq251cl3hbDIGEwD9HAwh8H6IS2F6QA9KdKmO136LmipRNkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@lmdb/lmdb-linux-arm": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.2.6.tgz", - "integrity": "sha512-+6XgLpMb7HBoWxXj+bLbiiB4s0mRRcDPElnRS3LpWRzdYSe+gFk5MT/4RrVNqd2MESUDmb53NUXw1+BP69bjiQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@lmdb/lmdb-linux-arm64": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.2.6.tgz", - "integrity": "sha512-l5VmJamJ3nyMmeD1ANBQCQqy7do1ESaJQfKPSm2IG9/ADZryptTyCj8N6QaYgIWewqNUrcbdMkJajRQAt5Qjfg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@lmdb/lmdb-linux-x64": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.2.6.tgz", - "integrity": "sha512-nDYT8qN9si5+onHYYaI4DiauDMx24OAiuZAUsEqrDy+ja/3EbpXPX/VAkMV8AEaQhy3xc4dRC+KcYIvOFefJ4Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@lmdb/lmdb-win32-x64": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.2.6.tgz", - "integrity": "sha512-XlqVtILonQnG+9fH2N3Aytria7P/1fwDgDhl29rde96uH2sLB8CHORIf2PfuLVzFQJ7Uqp8py9AYwr3ZUCFfWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@napi-rs/nice": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz", - "integrity": "sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "optionalDependencies": { - "@napi-rs/nice-android-arm-eabi": "1.1.1", - "@napi-rs/nice-android-arm64": "1.1.1", - "@napi-rs/nice-darwin-arm64": "1.1.1", - "@napi-rs/nice-darwin-x64": "1.1.1", - "@napi-rs/nice-freebsd-x64": "1.1.1", - "@napi-rs/nice-linux-arm-gnueabihf": "1.1.1", - "@napi-rs/nice-linux-arm64-gnu": "1.1.1", - "@napi-rs/nice-linux-arm64-musl": "1.1.1", - "@napi-rs/nice-linux-ppc64-gnu": "1.1.1", - "@napi-rs/nice-linux-riscv64-gnu": "1.1.1", - "@napi-rs/nice-linux-s390x-gnu": "1.1.1", - "@napi-rs/nice-linux-x64-gnu": "1.1.1", - "@napi-rs/nice-linux-x64-musl": "1.1.1", - "@napi-rs/nice-openharmony-arm64": "1.1.1", - "@napi-rs/nice-win32-arm64-msvc": "1.1.1", - "@napi-rs/nice-win32-ia32-msvc": "1.1.1", - "@napi-rs/nice-win32-x64-msvc": "1.1.1" - } - }, - "node_modules/@napi-rs/nice-android-arm-eabi": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.1.1.tgz", - "integrity": "sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-android-arm64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.1.1.tgz", - "integrity": "sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-darwin-arm64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.1.1.tgz", - "integrity": "sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-darwin-x64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.1.1.tgz", - "integrity": "sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-freebsd-x64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.1.1.tgz", - "integrity": "sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.1.1.tgz", - "integrity": "sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-arm64-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.1.1.tgz", - "integrity": "sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-arm64-musl": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.1.1.tgz", - "integrity": "sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-ppc64-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.1.1.tgz", - "integrity": "sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-riscv64-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.1.1.tgz", - "integrity": "sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-s390x-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.1.1.tgz", - "integrity": "sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-x64-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.1.1.tgz", - "integrity": "sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-x64-musl": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.1.1.tgz", - "integrity": "sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-openharmony-arm64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-openharmony-arm64/-/nice-openharmony-arm64-1.1.1.tgz", - "integrity": "sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-win32-arm64-msvc": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.1.1.tgz", - "integrity": "sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-win32-ia32-msvc": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.1.1.tgz", - "integrity": "sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-win32-x64-msvc": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.1.1.tgz", - "integrity": "sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@npmcli/agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", - "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@npmcli/fs": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", - "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/git": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-6.0.3.tgz", - "integrity": "sha512-GUYESQlxZRAdhs3UhbB6pVRNUELQOHXwK9ruDkwmCv2aZ5y0SApQzUJCg02p3A7Ue2J5hxvlk1YI53c00NmRyQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/promise-spawn": "^8.0.0", - "ini": "^5.0.0", - "lru-cache": "^10.0.1", - "npm-pick-manifest": "^10.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "semver": "^7.3.5", - "which": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/git/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@npmcli/installed-package-contents": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-3.0.0.tgz", - "integrity": "sha512-fkxoPuFGvxyrH+OQzyTkX2LUEamrF4jZSmxjAtPPHHGO0dqsQ8tTKjnIS8SAnPHdk2I03BDtSMR5K/4loKg79Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "npm-bundled": "^4.0.0", - "npm-normalize-package-bin": "^4.0.0" - }, - "bin": { - "installed-package-contents": "bin/index.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/node-gyp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-4.0.0.tgz", - "integrity": "sha512-+t5DZ6mO/QFh78PByMq1fGSAub/agLJZDRfJRMeOSNCt8s9YVlTjmGpIPwPhvXTGUIJk+WszlT0rQa1W33yzNA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/package-json": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-6.2.0.tgz", - "integrity": "sha512-rCNLSB/JzNvot0SEyXqWZ7tX2B5dD2a1br2Dp0vSYVo5jh8Z0EZ7lS9TsZ1UtziddB1UfNUaMCc538/HztnJGA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^6.0.0", - "glob": "^10.2.2", - "hosted-git-info": "^8.0.0", - "json-parse-even-better-errors": "^4.0.0", - "proc-log": "^5.0.0", - "semver": "^7.5.3", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/promise-spawn": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-8.0.3.tgz", - "integrity": "sha512-Yb00SWaL4F8w+K8YGhQ55+xE4RUNdMHV43WZGsiTM92gS+lC0mGsn7I4hLug7pbao035S6bj3Y3w0cUNGLfmkg==", - "dev": true, - "license": "ISC", - "dependencies": { - "which": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/redact": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-3.2.2.tgz", - "integrity": "sha512-7VmYAmk4csGv08QzrDKScdzn11jHPFGyqJW39FyPgPuAp3zIaUmuCo1yxw9aGs+NEJuTGQ9Gwqpt93vtJubucg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/run-script": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-9.1.0.tgz", - "integrity": "sha512-aoNSbxtkePXUlbZB+anS1LqsJdctG5n3UVhfU47+CDdwMi6uNTBMF9gPcQRnqghQd2FGzcwwIFBruFMxjhBewg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/node-gyp": "^4.0.0", - "@npmcli/package-json": "^6.0.0", - "@npmcli/promise-spawn": "^8.0.0", - "node-gyp": "^11.0.0", - "proc-log": "^5.0.0", - "which": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@parcel/watcher": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", - "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.3", - "is-glob": "^4.0.3", - "node-addon-api": "^7.0.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.6", - "@parcel/watcher-darwin-arm64": "2.5.6", - "@parcel/watcher-darwin-x64": "2.5.6", - "@parcel/watcher-freebsd-x64": "2.5.6", - "@parcel/watcher-linux-arm-glibc": "2.5.6", - "@parcel/watcher-linux-arm-musl": "2.5.6", - "@parcel/watcher-linux-arm64-glibc": "2.5.6", - "@parcel/watcher-linux-arm64-musl": "2.5.6", - "@parcel/watcher-linux-x64-glibc": "2.5.6", - "@parcel/watcher-linux-x64-musl": "2.5.6", - "@parcel/watcher-win32-arm64": "2.5.6", - "@parcel/watcher-win32-ia32": "2.5.6", - "@parcel/watcher-win32-x64": "2.5.6" - } - }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", - "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", - "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", - "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", - "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", - "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", - "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", - "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", - "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", - "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", - "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", - "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", - "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", - "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher/node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/@parcel/watcher/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@schematics/angular": { - "version": "19.2.22", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-19.2.22.tgz", - "integrity": "sha512-ZCyfDHD3dbU/PEr9M4EGGAWxQLlmITKJgJNAvNMLv8aiXkV31SiUCGwvnXh0dVPkufv+hPnqd+5ZpwAso6wkPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "19.2.22", - "@angular-devkit/schematics": "19.2.22", - "jsonc-parser": "3.3.1" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@sigstore/bundle": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-3.1.0.tgz", - "integrity": "sha512-Mm1E3/CmDDCz3nDhFKTuYdB47EdRFRQMOE/EAbiG1MJW77/w1b3P7Qx7JSrVJs8PfwOLOVcKQCHErIwCTyPbag==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.4.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@sigstore/core": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-2.0.0.tgz", - "integrity": "sha512-nYxaSb/MtlSI+JWcwTHQxyNmWeWrUXJJ/G4liLrGG7+tS4vAz6LF3xRXqLH6wPIVUoZQel2Fs4ddLx4NCpiIYg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@sigstore/protobuf-specs": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.4.3.tgz", - "integrity": "sha512-fk2zjD9117RL9BjqEwF7fwv7Q/P9yGsMV4MUJZ/DocaQJ6+3pKr+syBq1owU5Q5qGw5CUbXzm+4yJ2JVRDQeSA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@sigstore/sign": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-3.1.0.tgz", - "integrity": "sha512-knzjmaOHOov1Ur7N/z4B1oPqZ0QX5geUfhrVaqVlu+hl0EAoL4o+l0MSULINcD5GCWe3Z0+YJO8ues6vFlW0Yw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^3.1.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.0", - "make-fetch-happen": "^14.0.2", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@sigstore/tuf": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-3.1.1.tgz", - "integrity": "sha512-eFFvlcBIoGwVkkwmTi/vEQFSva3xs5Ot3WmBcjgjVdiaoelBLQaQ/ZBfhlG0MnG0cmTYScPpk7eDdGDWUcFUmg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.4.1", - "tuf-js": "^3.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@sigstore/verify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-2.1.1.tgz", - "integrity": "sha512-hVJD77oT67aowHxwT4+M6PGOp+E2LtLdTK3+FC0lBO9T7sYwItDMXZ7Z07IDCvR1M717a4axbIWckrW67KMP/w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^3.1.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@tufjs/canonical-json": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", - "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@tufjs/models": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-3.0.1.tgz", - "integrity": "sha512-UUYHISyhCU3ZgN8yaear3cGATHb3SMuKHsQ/nVbHXcmnBf+LzQ/cQfhNG+rfaSHgqGKNEm2cOCLVLELStUQ1JA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tufjs/canonical-json": "2.0.0", - "minimatch": "^9.0.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitejs/plugin-basic-ssl": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.2.0.tgz", - "integrity": "sha512-mkQnxTkcldAzIsomk1UuLfAu9n+kpQ3JbHcpCp7d2Oo6ITtji8pHS3QToOWjhPFvNQSnhlkAjmGbhv2QvwO/7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.21.3" - }, - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" - } - }, - "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@yarnpkg/lockfile": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", - "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/abbrev": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/beasties": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.3.2.tgz", - "integrity": "sha512-p4AF8uYzm9Fwu8m/hSVTCPXrRBPmB34hQpHsec2KOaR9CZmgoU8IOv4Cvwq4hgz2p4hLMNbsdNl5XeA6XbAQwA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "css-select": "^5.1.0", - "css-what": "^6.1.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "htmlparser2": "^10.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.49", - "postcss-media-query-parser": "^0.2.3" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true, - "license": "ISC" - }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cacache": { - "version": "19.0.1", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", - "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^4.0.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^12.0.0", - "tar": "^7.4.3", - "unique-filename": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/cacache/node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/cacache/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/cacache/node_modules/tar": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", - "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/cacache/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001775", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz", - "integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cross-spawn/node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/cross-spawn/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css-select": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-what": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.302", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", - "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", - "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.4", - "@esbuild/android-arm": "0.25.4", - "@esbuild/android-arm64": "0.25.4", - "@esbuild/android-x64": "0.25.4", - "@esbuild/darwin-arm64": "0.25.4", - "@esbuild/darwin-x64": "0.25.4", - "@esbuild/freebsd-arm64": "0.25.4", - "@esbuild/freebsd-x64": "0.25.4", - "@esbuild/linux-arm": "0.25.4", - "@esbuild/linux-arm64": "0.25.4", - "@esbuild/linux-ia32": "0.25.4", - "@esbuild/linux-loong64": "0.25.4", - "@esbuild/linux-mips64el": "0.25.4", - "@esbuild/linux-ppc64": "0.25.4", - "@esbuild/linux-riscv64": "0.25.4", - "@esbuild/linux-s390x": "0.25.4", - "@esbuild/linux-x64": "0.25.4", - "@esbuild/netbsd-arm64": "0.25.4", - "@esbuild/netbsd-x64": "0.25.4", - "@esbuild/openbsd-arm64": "0.25.4", - "@esbuild/openbsd-x64": "0.25.4", - "@esbuild/sunos-x64": "0.25.4", - "@esbuild/win32-arm64": "0.25.4", - "@esbuild/win32-ia32": "0.25.4", - "@esbuild/win32-x64": "0.25.4" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/exponential-backoff": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fs-minipass": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", - "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hosted-git-info": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz", - "integrity": "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^10.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/htmlparser2": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", - "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", - "dev": true, - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "entities": "^7.0.1" - } - }, - "node_modules/htmlparser2/node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/ignore-walk": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-7.0.0.tgz", - "integrity": "sha512-T4gbf83A4NH95zvhVYZc+qWocBBGlpzUXLPGurJggw/WIOwicfXJChLDP/iBZnN5WqROSu5Bm3hhle4z8a8YGQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "minimatch": "^9.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/immutable": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", - "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", - "dev": true, - "license": "MIT" - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ini": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", - "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-parse-even-better-errors": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", - "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsonc-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", - "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", - "dev": true, - "engines": [ - "node >= 0.2.0" - ], - "license": "MIT" - }, - "node_modules/listr2": { - "version": "8.2.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.5.tgz", - "integrity": "sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^4.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/listr2/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/lmdb": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.2.6.tgz", - "integrity": "sha512-SuHqzPl7mYStna8WRotY8XX/EUZBjjv3QyKIByeCLFfC9uXT/OIHByEcA07PzbMfQAM0KYJtLgtpMRlIe5dErQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "msgpackr": "^1.11.2", - "node-addon-api": "^6.1.0", - "node-gyp-build-optional-packages": "5.2.2", - "ordered-binary": "^1.5.3", - "weak-lru-cache": "^1.2.2" - }, - "bin": { - "download-lmdb-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@lmdb/lmdb-darwin-arm64": "3.2.6", - "@lmdb/lmdb-darwin-x64": "3.2.6", - "@lmdb/lmdb-linux-arm": "3.2.6", - "@lmdb/lmdb-linux-arm64": "3.2.6", - "@lmdb/lmdb-linux-x64": "3.2.6", - "@lmdb/lmdb-win32-x64": "3.2.6" - } - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" - } - }, - "node_modules/make-fetch-happen": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", - "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/agent": "^3.0.0", - "cacache": "^19.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "ssri": "^12.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-collect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", - "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-fetch": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", - "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^3.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-flush/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/msgpackr": { - "version": "1.11.8", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.8.tgz", - "integrity": "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA==", - "dev": true, - "license": "MIT", - "optional": true, - "optionalDependencies": { - "msgpackr-extract": "^3.0.2" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" - } - }, - "node_modules/mute-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/node-gyp": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz", - "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^14.0.3", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "tar": "^7.4.3", - "tinyglobby": "^0.2.12", - "which": "^5.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, - "node_modules/node-gyp/node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/node-gyp/node_modules/tar": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", - "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/node-gyp/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nopt": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", - "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "abbrev": "^3.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm-bundled": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-4.0.0.tgz", - "integrity": "sha512-IxaQZDMsqfQ2Lz37VvyyEtKLe8FsRZuysmedy/N06TU1RyVppYKXrO4xIhR0F+7ubIBox6Q7nir6fQI3ej39iA==", - "dev": true, - "license": "ISC", - "dependencies": { - "npm-normalize-package-bin": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm-install-checks": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-7.1.2.tgz", - "integrity": "sha512-z9HJBCYw9Zr8BqXcllKIs5nI+QggAImbBdHphOzVYrz2CB4iQ6FzWyKmlqDZua+51nAu7FcemlbTc9VgQN5XDQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "semver": "^7.1.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm-normalize-package-bin": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", - "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm-package-arg": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-12.0.2.tgz", - "integrity": "sha512-f1NpFjNI9O4VbKMOlA5QoBq/vSQPORHcTZ2feJpFkTHJ9eQkdlmZEKSjcAhxTGInC7RlEyScT9ui67NaOsjFWA==", - "dev": true, - "license": "ISC", - "dependencies": { - "hosted-git-info": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^6.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm-packlist": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-9.0.0.tgz", - "integrity": "sha512-8qSayfmHJQTx3nJWYbbUmflpyarbLMBc6LCAjYsiGtXxDB68HaZpb8re6zeaLGxZzDuMdhsg70jryJe+RrItVQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "ignore-walk": "^7.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm-pick-manifest": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-10.0.0.tgz", - "integrity": "sha512-r4fFa4FqYY8xaM7fHecQ9Z2nE9hgNfJR+EmoKv0+chvzWkBcORX3r0FpTByP+CbOVJDladMXnPQGVN8PBLGuTQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "npm-install-checks": "^7.1.0", - "npm-normalize-package-bin": "^4.0.0", - "npm-package-arg": "^12.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm-registry-fetch": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-18.0.2.tgz", - "integrity": "sha512-LeVMZBBVy+oQb5R6FDV9OlJCcWDU+al10oKpe+nsvcHnG24Z3uM3SvJYKfGJlfGjVU8v9liejCrUR/M5HO5NEQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/redact": "^3.0.0", - "jsonparse": "^1.3.1", - "make-fetch-happen": "^14.0.0", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minizlib": "^3.0.1", - "npm-package-arg": "^12.0.0", - "proc-log": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ora/node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ora/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ora/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ora/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ordered-binary": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.1.tgz", - "integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/pacote": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/pacote/-/pacote-20.0.0.tgz", - "integrity": "sha512-pRjC5UFwZCgx9kUFDVM9YEahv4guZ1nSLqwmWiLUnDbGsjs+U5w7z6Uc8HNR1a6x8qnu5y9xtGE6D1uAuYz+0A==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^6.0.0", - "@npmcli/installed-package-contents": "^3.0.0", - "@npmcli/package-json": "^6.0.0", - "@npmcli/promise-spawn": "^8.0.0", - "@npmcli/run-script": "^9.0.0", - "cacache": "^19.0.0", - "fs-minipass": "^3.0.0", - "minipass": "^7.0.2", - "npm-package-arg": "^12.0.0", - "npm-packlist": "^9.0.0", - "npm-pick-manifest": "^10.0.0", - "npm-registry-fetch": "^18.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "sigstore": "^3.0.0", - "ssri": "^12.0.0", - "tar": "^6.1.11" - }, - "bin": { - "pacote": "bin/index.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-html-rewriting-stream": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-7.0.0.tgz", - "integrity": "sha512-mazCyGWkmCRWDI15Zp+UiCqMp/0dgEmkZRvhlsqqKYr4SsVm/TvnSpD9fCvqCA2zoWJcfRym846ejWBBHRiYEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^4.3.0", - "parse5": "^7.0.0", - "parse5-sax-parser": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-sax-parser": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-7.0.0.tgz", - "integrity": "sha512-5A+v2SNsq8T6/mG3ahcz8ZtQ0OUFTatxPbeidoMB7tkJSGDY3tdfl4MHovtLQHkEn5CGxijNWRQHhRQ6IRpXKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse5": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/piscina": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.8.0.tgz", - "integrity": "sha512-EZJb+ZxDrQf3dihsUL7p42pjNyrNIFJCrRHPMgxu/svsj+P3xS3fuEWp7k2+rfsavfl1N0G29b1HGs7J0m8rZA==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "@napi-rs/nice": "^1.0.1" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-media-query-parser": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", - "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", - "dev": true, - "license": "MIT" - }, - "node_modules/proc-log": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", - "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/reflect-metadata": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/sass": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz", - "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^4.0.0", - "immutable": "^5.0.2", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "@parcel/watcher": "^2.4.1" - } - }, - "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sigstore": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-3.1.0.tgz", - "integrity": "sha512-ZpzWAFHIFqyFE56dXqgX/DkDRZdz+rRcjoIk/RQU4IX0wiCv1l8S7ZrXDHcCc+uaf+6o7w3h2l3g6GYG5TKN9Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^3.1.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.0", - "@sigstore/sign": "^3.1.0", - "@sigstore/tuf": "^3.1.0", - "@sigstore/verify": "^2.1.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/spdx-correct": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true, - "license": "CC-BY-3.0" - }, - "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", - "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/ssri": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", - "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/symbol-observable": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", - "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/tar/node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/tar/node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tuf-js": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-3.1.0.tgz", - "integrity": "sha512-3T3T04WzowbwV2FDiGXBbr81t64g1MUGGJRgT4x5o97N+8ArdhVCAF9IxFrxuSJmM3E5Asn7nKHkao0ibcZXAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tufjs/models": "3.0.1", - "debug": "^4.4.1", - "make-fetch-happen": "^14.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/typescript": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", - "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/unique-filename": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", - "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/unique-slug": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", - "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "node_modules/validate-npm-package-name": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.2.tgz", - "integrity": "sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "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" - } - }, - "node_modules/vite-node/node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "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 - } - } - }, - "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@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.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "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 - } - } - }, - "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "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" - } - }, - "node_modules/vitest/node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "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 - } - } - }, - "node_modules/watchpack": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", - "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, - "node_modules/weak-lru-cache": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", - "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/yargs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zone.js": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", - "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", - "license": "MIT" - } - } -} diff --git a/frontend/angular/package.json b/frontend/angular/package.json deleted file mode 100644 index 27e66ad..0000000 --- a/frontend/angular/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "wpp-angular-shell", - "version": "0.0.0", - "private": true, - "scripts": { - "start": "ng serve", - "build": "ng build", - "test": "vitest run" - }, - "dependencies": { - "@angular/common": "^19.2.0", - "@angular/compiler": "^19.2.0", - "@angular/core": "^19.2.0", - "@angular/forms": "^19.2.0", - "@angular/platform-browser": "^19.2.0", - "@angular/router": "^19.2.0", - "rxjs": "~7.8.0", - "tslib": "^2.3.0", - "zone.js": "~0.15.0" - }, - "devDependencies": { - "@angular/build": "^19.2.0", - "@angular/cli": "^19.2.0", - "@angular/compiler-cli": "^19.2.0", - "typescript": "~5.7.2", - "vitest": "^2.1.9" - } -} diff --git a/frontend/angular/src/app/api-contract-smoke.spec.ts b/frontend/angular/src/app/api-contract-smoke.spec.ts deleted file mode 100644 index c496c94..0000000 --- a/frontend/angular/src/app/api-contract-smoke.spec.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { createAngularApiClient, type AngularHttpClientLike } from '../../../src/api/angular-client'; - -describe('SPA Angular API contract smoke (host/player foundation)', () => { - it('smoke-checks canonical host/player endpoint contracts', async () => { - const get = vi.fn(async (url: string) => { - if (url === '/lobby/sessions/ABCD12') { - return { - session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 }, - players: [ - { id: 2, nickname: 'Maja', score: 0, is_connected: true }, - { id: 3, nickname: 'Bo', score: 0, is_connected: true } - ], - round_question: { - id: 77, - round_number: 1, - prompt: 'Q?', - shown_at: '2026-03-01T18:00:00Z', - answers: [{ text: 'A' }, { text: 'B' }] - }, - reveal: { - round_question_id: 77, - round_number: 1, - prompt: 'Q?', - correct_answer: 'A', - lies: [{ player_id: 2, nickname: 'Maja', text: 'B', created_at: '2026-03-01T18:00:05Z' }], - guesses: [ - { - player_id: 3, - nickname: 'Bo', - selected_text: 'B', - is_correct: false, - fooled_player_id: 2, - fooled_player_nickname: 'Maja', - created_at: '2026-03-01T18:00:15Z' - } - ] - }, - phase_view_model: { - status: 'lobby', - round_number: 1, - players_count: 2, - constraints: { - min_players_to_start: 2, - max_players_mvp: 8, - min_players_reached: true, - max_players_allowed: true - }, - host: { - can_start_round: true, - can_show_question: false, - can_mix_answers: false, - can_calculate_scores: false, - can_reveal_scoreboard: false, - can_start_next_round: false, - can_finish_game: false - }, - player: { - can_join: true, - can_submit_lie: false, - can_submit_guess: false, - can_view_final_result: false - } - } - } as T; - } - - if (url === '/lobby/sessions/ABCD12/scoreboard') { - return { - session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 }, - leaderboard: [ - { id: 9, nickname: 'Maja', score: 200 }, - { id: 10, nickname: 'Bo', score: 150 } - ] - } as T; - } - - throw { status: 404, error: { error: 'Not found' } }; - }); - - const post = vi.fn(async (url: string, body: unknown) => { - if (url === '/lobby/sessions/join') { - expect(body).toEqual({ code: 'ABCD12', nickname: 'Maja' }); - return { - player: { id: 9, nickname: 'Maja', session_token: 'session-token-1', score: 0 }, - session: { code: 'ABCD12', status: 'lobby' } - } as T; - } - - if (url === '/lobby/sessions/ABCD12/rounds/start') { - expect(body).toEqual({ category_slug: 'history' }); - return { - session: { code: 'ABCD12', status: 'lie', current_round: 1 }, - round: { number: 1, category: { slug: 'history', name: 'History' } } - } as T; - } - - if (url === '/lobby/sessions/ABCD12/questions/show') { - expect(body).toEqual({}); - return { - round_question: { - id: 77, - prompt: 'Q?', - round_number: 1, - shown_at: '2026-03-01T18:00:00Z', - lie_deadline_at: '2026-03-01T18:00:30Z' - }, - config: { lie_seconds: 30 } - } as T; - } - - if (url === '/lobby/sessions/ABCD12/questions/77/answers/mix') { - expect(body).toEqual({}); - return { - session: { code: 'ABCD12', status: 'guess', current_round: 1 }, - round_question: { id: 77, round_number: 1 }, - answers: [{ text: 'A' }, { text: 'B' }] - } as T; - } - - if (url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') { - expect(body).toEqual({}); - return { - session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, - round_question: { id: 77, round_number: 1 }, - events_created: 2, - reveal: { - round_question_id: 77, - correct_answer: 'A', - lies: [], - guesses: [] - }, - leaderboard: [ - { id: 9, nickname: 'Maja', score: 200 }, - { id: 10, nickname: 'Bo', score: 150 } - ] - } as T; - } - - if (url === '/lobby/sessions/ABCD12/rounds/next') { - expect(body).toEqual({}); - return { session: { code: 'ABCD12', status: 'lie', current_round: 2 } } as T; - } - - if (url === '/lobby/sessions/ABCD12/finish') { - expect(body).toEqual({}); - return { - session: { code: 'ABCD12', status: 'finished', current_round: 2 }, - winner: { id: 9, nickname: 'Maja', score: 250 }, - leaderboard: [ - { id: 9, nickname: 'Maja', score: 250 }, - { id: 10, nickname: 'Bo', score: 150 } - ] - } as T; - } - - if (url === '/lobby/sessions/ABCD12/questions/77/lies/submit') { - expect(body).toEqual({ player_id: 9, session_token: 'session-token-1', text: 'my lie' }); - return { - lie: { - id: 501, - player_id: 9, - round_question_id: 77, - text: 'my lie', - created_at: '2026-03-01T18:00:05Z' - }, - window: { lie_deadline_at: '2026-03-01T18:00:30Z' } - } as T; - } - - if (url === '/lobby/sessions/ABCD12/questions/77/guesses/submit') { - expect(body).toEqual({ player_id: 9, session_token: 'session-token-1', selected_text: 'B' }); - return { - guess: { - id: 601, - player_id: 9, - round_question_id: 77, - selected_text: 'B', - is_correct: false, - fooled_player_id: null, - created_at: '2026-03-01T18:00:15Z' - }, - window: { guess_deadline_at: '2026-03-01T18:01:00Z' } - } as T; - } - - throw { status: 404, error: { error: 'Not found' } }; - }); - - const client = createAngularApiClient({ get, post } as AngularHttpClientLike); - - const session = await client.getSession(' abcd12 '); - expect(session.ok).toBe(true); - if (session.ok) { - expect(session.data.session.code).toBe('ABCD12'); - expect(session.data.phase_view_model.host.can_start_next_round).toBe(false); - expect(session.data.phase_view_model.player.can_submit_guess).toBe(false); - expect(session.data.reveal?.correct_answer).toBe('A'); - expect(session.data.reveal?.guesses[0].fooled_player_nickname).toBe('Maja'); - } - - expect((await client.joinSession({ code: ' abcd12 ', nickname: ' Maja ' })).ok).toBe(true); - expect((await client.startRound(' abcd12 ', { category_slug: 'history' })).ok).toBe(true); - expect((await client.showQuestion(' abcd12 ')).ok).toBe(true); - expect((await client.mixAnswers(' abcd12 ', 77)).ok).toBe(true); - expect((await client.calculateScores(' abcd12 ', 77)).ok).toBe(true); - expect((await client.getScoreboard(' abcd12 ')).ok).toBe(true); - expect((await client.startNextRound(' abcd12 ')).ok).toBe(true); - expect((await client.finishGame(' abcd12 ')).ok).toBe(true); - expect( - ( - await client.submitLie(' abcd12 ', 77, { - player_id: 9, - session_token: 'session-token-1', - text: 'my lie' - }) - ).ok - ).toBe(true); - expect( - ( - await client.submitGuess(' abcd12 ', 77, { - player_id: 9, - session_token: 'session-token-1', - selected_text: 'B' - }) - ).ok - ).toBe(true); - - expect(get).toHaveBeenNthCalledWith(1, '/lobby/sessions/ABCD12', { withCredentials: true }); - expect(get).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12/scoreboard', { withCredentials: true }); - - expect(post).toHaveBeenNthCalledWith( - 1, - '/lobby/sessions/join', - { code: 'ABCD12', nickname: 'Maja' }, - { withCredentials: true } - ); - expect(post).toHaveBeenNthCalledWith( - 2, - '/lobby/sessions/ABCD12/rounds/start', - { category_slug: 'history' }, - { withCredentials: true } - ); - expect(post).toHaveBeenNthCalledWith(3, '/lobby/sessions/ABCD12/questions/show', {}, { withCredentials: true }); - expect(post).toHaveBeenNthCalledWith( - 4, - '/lobby/sessions/ABCD12/questions/77/answers/mix', - {}, - { withCredentials: true } - ); - expect(post).toHaveBeenNthCalledWith( - 5, - '/lobby/sessions/ABCD12/questions/77/scores/calculate', - {}, - { withCredentials: true } - ); - expect(post).toHaveBeenNthCalledWith(6, '/lobby/sessions/ABCD12/rounds/next', {}, { withCredentials: true }); - expect(post).toHaveBeenNthCalledWith(7, '/lobby/sessions/ABCD12/finish', {}, { withCredentials: true }); - expect(post).toHaveBeenNthCalledWith( - 8, - '/lobby/sessions/ABCD12/questions/77/lies/submit', - { player_id: 9, session_token: 'session-token-1', text: 'my lie' }, - { withCredentials: true } - ); - expect(post).toHaveBeenNthCalledWith( - 9, - '/lobby/sessions/ABCD12/questions/77/guesses/submit', - { player_id: 9, session_token: 'session-token-1', selected_text: 'B' }, - { withCredentials: true } - ); - }); -}); diff --git a/frontend/angular/src/app/app.component.css b/frontend/angular/src/app/app.component.css deleted file mode 100644 index 1e80afb..0000000 --- a/frontend/angular/src/app/app.component.css +++ /dev/null @@ -1,5 +0,0 @@ -.shell { font-family: Arial, sans-serif; margin: 1rem; } -.shell__header { display: flex; flex-wrap: wrap; gap: 0.75rem; justify-content: space-between; align-items: center; border-bottom: 1px solid #ddd; padding-bottom: 0.75rem; } -.shell__header nav { display: flex; gap: 0.75rem; } -.shell__content { margin-top: 1rem; } -.locale-picker { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.95rem; } diff --git a/frontend/angular/src/app/app.component.html b/frontend/angular/src/app/app.component.html deleted file mode 100644 index b628c8b..0000000 --- a/frontend/angular/src/app/app.component.html +++ /dev/null @@ -1,20 +0,0 @@ -
-
-

{{ copy('app.title') }}

- - -
- -
- -
-
diff --git a/frontend/angular/src/app/app.component.ts b/frontend/angular/src/app/app.component.ts deleted file mode 100644 index 109c09a..0000000 --- a/frontend/angular/src/app/app.component.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Component, inject } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { Router, RouterLink, RouterOutlet } from '@angular/router'; - -import { resolvePreferredLocale, setPreferredLocale, t } from './lobby-i18n'; - -@Component({ - selector: 'app-root', - standalone: true, - imports: [RouterOutlet, RouterLink, FormsModule], - templateUrl: './app.component.html', - styleUrl: './app.component.css', -}) -export class AppComponent { - private readonly router = inject(Router); - - locale = resolvePreferredLocale(); - - constructor() { - const shellRoute = document.body.dataset['wppShellRoute']; - if (shellRoute?.startsWith('/host') || shellRoute?.startsWith('/player')) { - void this.router.navigateByUrl(shellRoute); - } - } - - copy(key: string): string { - return t(key, this.locale); - } - - setLocale(locale: string): void { - this.locale = setPreferredLocale(locale); - } -} diff --git a/frontend/angular/src/app/app.config.ts b/frontend/angular/src/app/app.config.ts deleted file mode 100644 index bc74aa5..0000000 --- a/frontend/angular/src/app/app.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ApplicationConfig } from '@angular/core'; -import { provideRouter, withHashLocation } from '@angular/router'; - -import { routes } from './app.routes'; -import { createWppApiClient, WPP_API_CLIENT } from './wpp-api-client'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideRouter(routes, withHashLocation()), - { - provide: WPP_API_CLIENT, - useFactory: () => createWppApiClient(), - }, - ], -}; diff --git a/frontend/angular/src/app/app.routes.ts b/frontend/angular/src/app/app.routes.ts deleted file mode 100644 index b28cb1c..0000000 --- a/frontend/angular/src/app/app.routes.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Routes } from '@angular/router'; - -import { - hostRouteContextResolver, - hostRouteGuard, - playerRouteContextResolver, - playerRouteGuard, -} from './session-route-context'; - -export const routes: Routes = [ - { - path: 'host', - resolve: { routeContext: hostRouteContextResolver }, - canActivate: [hostRouteGuard], - loadComponent: () => import('./features/host/host-shell.component').then((m) => m.HostShellComponent), - }, - { - path: 'host/:phase', - resolve: { routeContext: hostRouteContextResolver }, - canActivate: [hostRouteGuard], - loadComponent: () => import('./features/host/host-shell.component').then((m) => m.HostShellComponent), - }, - { - path: 'host/:phase/:context', - resolve: { routeContext: hostRouteContextResolver }, - canActivate: [hostRouteGuard], - loadComponent: () => import('./features/host/host-shell.component').then((m) => m.HostShellComponent), - }, - { - path: 'player', - resolve: { routeContext: playerRouteContextResolver }, - canActivate: [playerRouteGuard], - loadComponent: () => import('./features/player/player-shell.component').then((m) => m.PlayerShellComponent), - }, - { - path: 'player/:phase', - resolve: { routeContext: playerRouteContextResolver }, - canActivate: [playerRouteGuard], - loadComponent: () => import('./features/player/player-shell.component').then((m) => m.PlayerShellComponent), - }, - { - path: 'player/:phase/:context', - resolve: { routeContext: playerRouteContextResolver }, - canActivate: [playerRouteGuard], - loadComponent: () => import('./features/player/player-shell.component').then((m) => m.PlayerShellComponent), - }, - { path: '', pathMatch: 'full', redirectTo: 'player' }, - { path: '**', redirectTo: 'player' }, -]; diff --git a/frontend/angular/src/app/features/host/host-shell.component.spec.ts b/frontend/angular/src/app/features/host/host-shell.component.spec.ts deleted file mode 100644 index 0d18e05..0000000 --- a/frontend/angular/src/app/features/host/host-shell.component.spec.ts +++ /dev/null @@ -1,455 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { HostShellComponent } from './host-shell.component'; - -type FetchMock = ReturnType; - -type FetchRouteHandler = (input: RequestInfo | URL, init?: RequestInit) => Response | Promise; - -function jsonResponse(status: number, body: unknown) { - return { - ok: status >= 200 && status < 300, - status, - json: vi.fn().mockResolvedValue(body), - } as unknown as Response; -} - -function createFetchRouteMock(handler: FetchRouteHandler): FetchMock { - return vi.fn((input: RequestInfo | URL, init?: RequestInit) => Promise.resolve(handler(input, init))); -} - -function sessionDetailPayload( - status: string, - options?: { - currentPhase?: string; - roundQuestionId?: number | null; - reveal?: { - correct_answer: string; - prompt?: string; - lies?: Array<{ player_id: number; nickname: string; text: string; created_at?: string }>; - guesses?: Array<{ - player_id: number; - nickname: string; - selected_text: string; - is_correct: boolean; - fooled_player_id: number | null; - fooled_player_nickname?: string; - created_at?: string; - }>; - } | null; - } -) { - const roundQuestionId = options?.roundQuestionId ?? 41; - - return { - session: { - code: 'ABCD12', - status, - host_id: 1, - current_round: status === 'lobby' ? 2 : 1, - players_count: 2, - }, - round_question: - roundQuestionId === null - ? null - : { - id: roundQuestionId, - round_number: 1, - prompt: 'Q?', - shown_at: '2026-01-01T00:00:00Z', - answers: [], - }, - players: [ - { id: 1, nickname: 'Host', score: 0, is_connected: true }, - { id: 2, nickname: 'Mads', score: 120, is_connected: true }, - ], - reveal: - options?.reveal === undefined || options?.reveal === null - ? null - : { - round_question_id: roundQuestionId, - round_number: 1, - prompt: options.reveal.prompt ?? 'Q?', - correct_answer: options.reveal.correct_answer, - lies: (options.reveal.lies ?? []).map((lie) => ({ - ...lie, - created_at: lie.created_at ?? '2026-01-01T00:00:05Z', - })), - guesses: (options.reveal.guesses ?? []).map((guess) => ({ - ...guess, - created_at: guess.created_at ?? '2026-01-01T00:00:10Z', - })), - }, - phase_view_model: { - status, - current_phase: options?.currentPhase ?? status, - round_number: 1, - players_count: 2, - constraints: { - min_players_to_start: 2, - max_players_mvp: 8, - min_players_reached: true, - max_players_allowed: true, - }, - readiness: { - question_ready: (options?.currentPhase ?? status) !== 'lobby', - scoreboard_ready: (options?.currentPhase ?? status) === 'reveal' || (options?.currentPhase ?? status) === 'scoreboard', - }, - host: { - can_start_round: (options?.currentPhase ?? status) === 'lobby', - can_show_question: (options?.currentPhase ?? status) === 'lie', - can_mix_answers: (options?.currentPhase ?? status) === 'lie' || (options?.currentPhase ?? status) === 'guess', - can_calculate_scores: (options?.currentPhase ?? status) === 'guess', - can_reveal_scoreboard: (options?.currentPhase ?? status) === 'reveal', - can_start_next_round: (options?.currentPhase ?? status) === 'scoreboard', - can_finish_game: (options?.currentPhase ?? status) === 'scoreboard', - }, - player: { - can_join: status === 'lobby', - can_submit_lie: status === 'lie', - can_submit_guess: status === 'guess', - can_view_final_result: status === 'finished', - }, - }, - }; -} - -describe('HostShellComponent gameplay wiring', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('runs startRound transition and refreshes session details', async () => { - const fetchMock: FetchMock = vi - .fn() - .mockResolvedValueOnce( - jsonResponse(201, { - session: { code: 'ABCD12', status: 'lie', current_round: 1 }, - round: { number: 1, category: { slug: 'history', name: 'History' } }, - }) - ) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie'))); - - vi.stubGlobal('fetch', fetchMock); - - const component = new HostShellComponent(); - component.sessionCode = ' abcd12 '; - component.categorySlug = ' history '; - - await component.startRound(); - - expect(fetchMock).toHaveBeenNthCalledWith( - 1, - '/lobby/sessions/ABCD12/rounds/start', - expect.objectContaining({ method: 'POST', body: JSON.stringify({ category_slug: 'history' }) }) - ); - expect(fetchMock).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', expect.objectContaining({ method: 'GET' })); - expect(component.session?.session.status).toBe('lie'); - expect(component.roundQuestionId).toBe('41'); - expect(component.loading).toBe(false); - }); - - it('hydrates canonical reveal payload in reveal phase', async () => { - const fetchMock: FetchMock = vi.fn().mockResolvedValue( - jsonResponse( - 200, - sessionDetailPayload('reveal', { - roundQuestionId: 77, - reveal: { - correct_answer: 'Mercury', - lies: [{ player_id: 2, nickname: 'Mads', text: 'Venus' }], - guesses: [ - { - player_id: 3, - nickname: 'Luna', - selected_text: 'Venus', - is_correct: false, - fooled_player_id: 2, - fooled_player_nickname: 'Mads', - }, - ], - }, - }) - ) - ); - - vi.stubGlobal('fetch', fetchMock); - - const component = new HostShellComponent(); - component.sessionCode = 'ABCD12'; - - await component.refreshSession(); - - expect(component.session?.reveal?.correct_answer).toBe('Mercury'); - expect(component.session?.reveal?.lies[0]).toMatchObject({ player_id: 2, nickname: 'Mads', text: 'Venus' }); - expect(component.session?.reveal?.guesses[0]).toMatchObject({ - player_id: 3, - nickname: 'Luna', - selected_text: 'Venus', - fooled_player_id: 2, - fooled_player_nickname: 'Mads', - }); - }); - - it('wires showQuestion, mixAnswers and calculateScores with canonical phase gating', async () => { - let refreshCount = 0; - const fetchMock = createFetchRouteMock((input, init) => { - const url = String(input); - const method = init?.method ?? 'GET'; - - if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/show') { - return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } }); - } - if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/99/answers/mix') { - return jsonResponse(200, { session: { code: 'ABCD12', status: 'guess', current_round: 2 } }); - } - if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') { - return jsonResponse(200, { session: { code: 'ABCD12', status: 'reveal', current_round: 2 } }); - } - if (method === 'GET' && url === '/lobby/sessions/ABCD12') { - refreshCount += 1; - if (refreshCount === 1) { - return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 })); - } - if (refreshCount === 2) { - return jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 })); - } - return jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 })); - } - - throw new Error(`Unhandled fetch in test: ${method} ${url}`); - }); - - vi.stubGlobal('fetch', fetchMock); - - const component = new HostShellComponent(); - component.sessionCode = ' abcd12 '; - component.roundQuestionId = ' 77 '; - - component.session = sessionDetailPayload('lie', { roundQuestionId: null }) as any; - await component.showQuestion(); - expect(component.session?.session.status).toBe('lie'); - expect(component.roundQuestionId).toBe('99'); - - component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any; - await component.mixAnswers(); - expect(component.session?.session.status).toBe('guess'); - - await component.calculateScores(); - - expect(component.session?.session.status).toBe('reveal'); - expect(component.error).toBe(''); - expect(component.loading).toBe(false); - expect(fetchMock).toHaveBeenCalledTimes(6); - }); - - it('runs next-round transition without reload and clears scoreboard payload', async () => { - const fetchMock = createFetchRouteMock((input, init) => { - const url = String(input); - const method = init?.method ?? 'GET'; - - if (method === 'POST' && url === '/lobby/sessions/ABCD12/rounds/next') { - return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } }); - } - if (method === 'GET' && url === '/lobby/sessions/ABCD12') { - return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 })); - } - - throw new Error(`Unhandled fetch in test: ${method} ${url}`); - }); - - vi.stubGlobal('fetch', fetchMock); - - const component = new HostShellComponent(); - component.sessionCode = ' abcd12 '; - component.scoreboardPayload = '{"leaderboard":[]}'; - component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ; - component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }]; - component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any; - - await component.startNextRound(); - - expect(fetchMock).toHaveBeenNthCalledWith( - 1, - '/lobby/sessions/ABCD12/rounds/next', - expect.objectContaining({ method: 'POST', body: JSON.stringify({}) }) - ); - expect(fetchMock).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', expect.objectContaining({ method: 'GET' })); - expect(component.session?.session.status).toBe('lie'); - expect(component.roundQuestionId).toBe('99'); - expect(component.finalLeaderboardPayload).toBe(''); - expect(component.finalLeaderboard).toEqual([]); - expect(component.nextRoundError).toBe(''); - }); - - it('captures finish-game failure for retry and stores final leaderboard on success', async () => { - const fetchMock: FetchMock = vi - .fn() - .mockResolvedValueOnce(jsonResponse(503, { error: 'Final leaderboard timeout' })) - .mockResolvedValueOnce( - jsonResponse(200, { - session: { code: 'ABCD12', status: 'finished', current_round: 2 }, - winner: { id: 1, nickname: 'Luna', score: 320 }, - leaderboard: [ - { id: 2, nickname: 'Mads', score: 120 }, - { id: 1, nickname: 'Luna', score: 320 }, - ], - }) - ) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('finished', { roundQuestionId: null }))); - - vi.stubGlobal('fetch', fetchMock); - - const component = new HostShellComponent(); - component.sessionCode = 'ABCD12'; - component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any; - - await component.finishGame(); - expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout'); - - await component.finishGame(); - - expect(fetchMock).toHaveBeenNthCalledWith( - 2, - '/lobby/sessions/ABCD12/finish', - expect.objectContaining({ method: 'POST', body: JSON.stringify({}) }) - ); - expect(component.finishError).toBe(''); - expect(component.finalLeaderboardPayload).toContain('"status": "finished"'); - expect(component.finalWinner?.nickname).toBe('Luna'); - expect(component.finalLeaderboard.map((entry) => entry.nickname)).toEqual(['Luna', 'Mads']); - }); - - it('guards next-round and finish actions when session code is missing', async () => { - const fetchMock: FetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - - const component = new HostShellComponent(); - component.sessionCode = ' '; - component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any; - - await component.startNextRound(); - await component.finishGame(); - - expect(fetchMock).not.toHaveBeenCalled(); - expect(component.nextRoundError).toContain('Session code is required'); - expect(component.finishError).toContain('Session code is required'); - }); - - it('blocks illegal host actions outside canonical phase permissions', async () => { - const fetchMock: FetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - - const component = new HostShellComponent(); - component.sessionCode = 'ABCD12'; - component.roundQuestionId = '77'; - - for (const status of ['guess', 'reveal', 'scoreboard'] as const) { - component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any; - await component.showQuestion(); - } - - for (const status of ['lie', 'reveal', 'scoreboard'] as const) { - component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any; - await component.calculateScores(); - } - - for (const status of ['lie', 'guess', 'scoreboard'] as const) { - component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any; - await component.loadScoreboard(); - } - - for (const status of ['lie', 'guess', 'reveal'] as const) { - component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any; - await component.startNextRound(); - await component.finishGame(); - } - - component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any; - expect(component.canShowQuestion).toBe(false); - - component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any; - expect(component.canCalculateScores).toBe(false); - expect(component.canLoadScoreboard).toBe(true); - expect(component.canStartNextRound).toBe(false); - expect(component.canFinishGame).toBe(false); - - component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any; - expect(component.canLoadScoreboard).toBe(false); - expect(component.canStartNextRound).toBe(true); - expect(component.canFinishGame).toBe(true); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('prefers canonical current_phase for reveal panel and host routing when status lags behind', async () => { - const fetchMock: FetchMock = vi.fn().mockResolvedValue( - jsonResponse(200, sessionDetailPayload('reveal', { currentPhase: 'scoreboard', roundQuestionId: 77, reveal: { correct_answer: 'Mercury' } })) - ); - vi.stubGlobal('fetch', fetchMock); - - const replaceState = vi.fn(); - vi.stubGlobal('window', { - location: { hash: '#/host/reveal/ABCD12' }, - history: { state: null, replaceState }, - sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn() }, - }); - - const component = new HostShellComponent(); - component.sessionCode = 'ABCD12'; - - await component.refreshSession(); - - expect(component.gameplayPhase).toBe('scoreboard'); - expect(component.showRevealPanel).toBe(true); - expect(component.canLoadScoreboard).toBe(false); - expect(component.canStartNextRound).toBe(true); - expect(component.canFinishGame).toBe(true); - expect(replaceState).toHaveBeenCalledWith(null, '', '#/host/scoreboard/ABCD12'); - }); - - it('syncs host hash-route with latest phase after refresh without page reload', async () => { - const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }))); - vi.stubGlobal('fetch', fetchMock); - - const replaceState = vi.fn(); - vi.stubGlobal('window', { - location: { hash: '#/host/lobby/ABCD12' }, - history: { state: null, replaceState }, - sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn() }, - }); - - const component = new HostShellComponent(); - component.sessionCode = 'ABCD12'; - - await component.refreshSession(); - - expect(replaceState).toHaveBeenCalledWith(null, '', '#/host/guess/ABCD12'); - expect(component.canStartRound).toBe(false); - expect(component.canStartNextRound).toBe(false); - expect(component.canFinishGame).toBe(false); - }); - - it('uses phase_view_model to keep host action surface bound to round boundaries only', async () => { - const component = new HostShellComponent(); - - expect(component.canStartRound).toBe(true); - expect(component.canStartNextRound).toBe(false); - expect(component.canFinishGame).toBe(false); - - component.session = sessionDetailPayload('lie') as any; - expect(component.canStartRound).toBe(false); - expect(component.canShowQuestion).toBe(true); - expect(component.canStartNextRound).toBe(false); - expect(component.canFinishGame).toBe(false); - - component.session = sessionDetailPayload('reveal') as any; - expect(component.canLoadScoreboard).toBe(true); - expect(component.canStartNextRound).toBe(false); - expect(component.canFinishGame).toBe(false); - - component.session = sessionDetailPayload('scoreboard') as any; - expect(component.canLoadScoreboard).toBe(false); - expect(component.canStartNextRound).toBe(true); - expect(component.canFinishGame).toBe(true); - }); -}); diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts deleted file mode 100644 index 5c2826a..0000000 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; - -import { createApiClient } from '../../../../../src/api/client'; -import type { FinishGameResponse, ScoreboardResponse, SessionDetailResponse } from '../../../../../src/api/types'; -import { deriveGameplayPhase, isHostGameplayActionAllowed } from '../../../../../src/spa/gameplay-phase-machine'; -import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; -import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; - -type SessionDetail = SessionDetailResponse; - -type LeaderboardEntry = FinishGameResponse['leaderboard'][number]; -type LeaderboardResponse = FinishGameResponse; - -@Component({ - selector: 'app-host-shell', - standalone: true, - imports: [CommonModule, FormsModule], - template: ` -

{{ copy('host.title') }}

- -
- - - - - - - - - - - - - -
- -

{{ copy('host.audio_locale_hint') }}: {{ locale }}

-

{{ error }}

-

{{ nextRoundError }}

-

{{ finishError }}

- -
-

{{ copy('common.status') }}: {{ session.session.status }} · {{ copy('common.round') }} {{ session.session.current_round }}

-

{{ copy('common.round_question_id') }}: {{ roundQuestionId || '-' }}

-

{{ copy('common.prompt') }}: {{ session.round_question.prompt }}

-
    -
  • {{ p.nickname }}: {{ p.score }}
  • -
-
-

Reveal

-

Korrekt svar: {{ session.reveal.correct_answer }}

-

Spørgsmål: {{ session.reveal.prompt }}

-
- Løgne -
    -
  • {{ lie.nickname }} løj: {{ lie.text }}
  • -
-
-
- Gæt -
    -
  • - {{ guess.nickname }} valgte {{ guess.selected_text }} - · korrekt - · narret af {{ guess.fooled_player_nickname }} - · forkert -
  • -
-
-
-
-

{{ copy('host.final_leaderboard') }}

-

{{ copy('host.winner') }}: {{ finalWinner.nickname }} ({{ finalWinner.score }} {{ copy('common.points_short') }})

-
    -
  1. {{ entry.nickname }}: {{ entry.score }}
  2. -
-
-
{{ finalLeaderboardPayload }}
-
- `, -}) -export class HostShellComponent implements OnInit, OnDestroy { - locale = resolvePreferredLocale(); - readonly clientHasNoAudioOutput = clientHasNoAudioOutput; - - sessionCode = ''; - categorySlug = 'general'; - roundQuestionId = ''; - loading = false; - error = ''; - scoreboardError = ''; - nextRoundError = ''; - finishError = ''; - scoreboardPayload = ''; - finalLeaderboardPayload = ''; - finalLeaderboard: LeaderboardEntry[] = []; - finalWinner: LeaderboardEntry | null = null; - session: SessionDetail | null = null; - - private readonly api = createApiClient(); - private readonly controller = createVerticalSliceController(this.api); - private unsubscribeLocale: (() => void) | null = null; - - ngOnInit(): void { - this.unsubscribeLocale = subscribeToLocaleChanges((locale) => { - this.locale = locale; - }); - if (typeof window === 'undefined') { - return; - } - - const hashRoute = window.location.hash.replace(/^#\/?/, ''); - const match = hashRoute.match(/^host(?:\/[^/]+)?(?:\/([^/?#]+))?/i); - const codeFromRoute = match?.[1] ?? ''; - const storedCode = window.sessionStorage.getItem('wpp.host-session-code') ?? ''; - const candidate = codeFromRoute || storedCode; - - if (!candidate) { - return; - } - - this.sessionCode = this.normalizeCode(candidate); - this.persistSessionCode(this.sessionCode); - void this.refreshSession(); - } - - ngOnDestroy(): void { - this.unsubscribeLocale?.(); - this.unsubscribeLocale = null; - } - - get gameplayPhase(): string | null { - return deriveGameplayPhase(this.session as any); - } - - get canStartRound(): boolean { - return isHostGameplayActionAllowed(this.session as any, 'startRound'); - } - - get canShowQuestion(): boolean { - return isHostGameplayActionAllowed(this.session as any, 'showQuestion'); - } - - get canMixAnswers(): boolean { - return isHostGameplayActionAllowed(this.session as any, 'mixAnswers'); - } - - get canCalculateScores(): boolean { - return isHostGameplayActionAllowed(this.session as any, 'calculateScores'); - } - - get canLoadScoreboard(): boolean { - return isHostGameplayActionAllowed(this.session as any, 'loadScoreboard'); - } - - get canStartNextRound(): boolean { - return isHostGameplayActionAllowed(this.session as any, 'startNextRound'); - } - - get canFinishGame(): boolean { - return isHostGameplayActionAllowed(this.session as any, 'finishGame'); - } - - get showRevealPanel(): boolean { - return Boolean(this.session?.reveal && (this.gameplayPhase === 'reveal' || this.gameplayPhase === 'scoreboard')); - } - - copy(key: string): string { - return t(key, this.locale); - } - - private normalizeCode(value: string): string { - return value.trim().toUpperCase(); - } - - private persistSessionCode(code: string): void { - if (typeof window !== 'undefined') { - window.sessionStorage.setItem('wpp.host-session-code', code); - } - } - - private async request(path: string, method: 'GET' | 'POST', payload?: unknown): Promise { - const response = await fetch(path, { - method, - headers: { - Accept: 'application/json', - ...(payload === undefined ? {} : { 'Content-Type': 'application/json' }), - }, - ...(payload === undefined ? {} : { body: JSON.stringify(payload) }), - credentials: 'same-origin', - }); - - const body = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error((body as { error?: string }).error ?? `HTTP ${response.status}`); - } - - return body as T; - } - - async refreshSession(): Promise { - this.loading = true; - this.error = ''; - this.scoreboardError = ''; - this.nextRoundError = ''; - this.finishError = ''; - try { - const state = await this.controller.hydrateLobby(this.sessionCode); - if (!state.session || state.errorMessage) { - throw new Error(state.errorMessage ?? this.copy('common.unknown_error')); - } - this.session = state.session as SessionDetail; - this.sessionCode = this.session.session.code; - this.persistSessionCode(this.sessionCode); - this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : ''; - if (this.session.session.status !== 'finished') { - this.resetFinalLeaderboard(); - } - this.syncRouteFromSession(); - } catch (error) { - this.error = `${this.copy('host.session_refresh_failed')}: ${(error as Error).message}`; - } finally { - this.loading = false; - } - } - - async startRound(): Promise { - if (!this.canStartRound) { - return; - } - - await this.runAction(async () => { - const state = await this.controller.startRound(this.sessionCode, this.categorySlug.trim()); - if (!state.session || state.errorMessage) { - throw new Error(state.errorMessage ?? this.copy('common.unknown_error')); - } - this.session = state.session as SessionDetail; - this.sessionCode = this.session.session.code; - this.persistSessionCode(this.sessionCode); - this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : ''; - this.resetFinalLeaderboard(); - this.syncRouteFromSession(); - }); - } - - async showQuestion(): Promise { - if (!this.canShowQuestion) { - return; - } - - await this.runAction(async () => { - const code = this.normalizeCode(this.sessionCode); - await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/show`, 'POST', {}); - await this.refreshSession(); - }); - } - - async mixAnswers(): Promise { - if (!this.canMixAnswers) { - return; - } - - await this.runAction(async () => { - const code = this.normalizeCode(this.sessionCode); - const roundQuestionId = this.roundQuestionId.trim(); - await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/${roundQuestionId}/answers/mix`, 'POST', {}); - await this.refreshSession(); - }); - } - - async calculateScores(): Promise { - if (!this.canCalculateScores) { - return; - } - - await this.runAction(async () => { - const code = this.normalizeCode(this.sessionCode); - const roundQuestionId = this.roundQuestionId.trim(); - await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/${roundQuestionId}/scores/calculate`, 'POST', {}); - await this.refreshSession(); - }); - } - - async loadScoreboard(): Promise { - if (!this.canLoadScoreboard) { - return; - } - - this.loading = true; - this.scoreboardError = ''; - this.error = ''; - try { - const code = this.normalizeCode(this.sessionCode); - const payload = await this.request(`/lobby/sessions/${encodeURIComponent(code)}/scoreboard`, 'GET'); - this.scoreboardPayload = JSON.stringify(payload, null, 2); - await this.refreshSession(); - } catch (error) { - this.scoreboardError = `${this.copy('host.scoreboard_failed')}: ${(error as Error).message}`; - } finally { - this.loading = false; - } - } - - async startNextRound(): Promise { - if (!this.canStartNextRound) { - return; - } - - this.loading = true; - this.nextRoundError = ''; - this.error = ''; - try { - const code = this.normalizeCode(this.sessionCode); - if (!code) { - throw new Error(this.copy('host.session_code_required')); - } - await this.request(`/lobby/sessions/${encodeURIComponent(code)}/rounds/next`, 'POST', {}); - this.resetFinalLeaderboard(); - await this.refreshSession(); - } catch (error) { - this.nextRoundError = `${this.copy('host.next_round_failed')}: ${(error as Error).message}`; - } finally { - this.loading = false; - } - } - - async finishGame(): Promise { - if (!this.canFinishGame) { - return; - } - - this.loading = true; - this.finishError = ''; - this.error = ''; - try { - const code = this.normalizeCode(this.sessionCode); - if (!code) { - throw new Error(this.copy('host.session_code_required')); - } - const payload = await this.request(`/lobby/sessions/${encodeURIComponent(code)}/finish`, 'POST', {}); - this.finalLeaderboardPayload = JSON.stringify(payload, null, 2); - this.finalLeaderboard = [...payload.leaderboard].sort((a, b) => { - if (b.score !== a.score) { - return b.score - a.score; - } - return a.nickname.localeCompare(b.nickname); - }); - this.finalWinner = payload.winner ?? this.finalLeaderboard[0] ?? null; - await this.refreshSession(); - } catch (error) { - this.finishError = `${this.copy('host.finish_game_failed')}: ${(error as Error).message}`; - } finally { - this.loading = false; - } - } - - private resetFinalLeaderboard(): void { - this.scoreboardPayload = ''; - this.finalLeaderboardPayload = ''; - this.finalLeaderboard = []; - this.finalWinner = null; - } - - private syncRouteFromSession(): void { - if (!this.session) { - return; - } - - const phase = this.gameplayPhase ?? this.session.session.status ?? 'lobby'; - const code = this.normalizeCode(this.session.session.code || this.sessionCode); - if (!code) { - return; - } - - const targetPath = `#/host/${encodeURIComponent(phase)}/${encodeURIComponent(code)}`; - if (typeof window === 'undefined' || window.location.hash === targetPath) { - return; - } - - window.history.replaceState(window.history.state, '', targetPath); - } - - private async runAction(action: () => Promise): Promise { - this.loading = true; - this.error = ''; - try { - await action(); - } catch (error) { - this.error = (error as Error).message; - } finally { - this.loading = false; - } - } -} diff --git a/frontend/angular/src/app/features/player/player-shell.component.spec.ts b/frontend/angular/src/app/features/player/player-shell.component.spec.ts deleted file mode 100644 index 15c908e..0000000 --- a/frontend/angular/src/app/features/player/player-shell.component.spec.ts +++ /dev/null @@ -1,695 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import lobbyCatalog from '../../../../../../shared/i18n/lobby.json'; -import { PlayerShellComponent } from './player-shell.component'; - -type FetchMock = ReturnType; - -function jsonResponse(status: number, body: unknown) { - return { - ok: status >= 200 && status < 300, - status, - json: vi.fn().mockResolvedValue(body), - } as unknown as Response; -} - -function sessionDetailPayload( - status: string, - options?: { - currentPhase?: string; - answers?: string[]; - players?: Array<{ id: number; nickname: string; score: number }>; - roundQuestionId?: number | null; - reveal?: { - correct_answer: string; - prompt?: string; - lies?: Array<{ player_id: number; nickname: string; text: string; created_at?: string }>; - guesses?: Array<{ - player_id: number; - nickname: string; - selected_text: string; - is_correct: boolean; - fooled_player_id: number | null; - fooled_player_nickname?: string; - created_at?: string; - }>; - } | null; - } -) { - const answers = options?.answers ?? []; - const roundQuestionId = options?.roundQuestionId ?? 11; - - return { - session: { - code: 'ABCD12', - status, - host_id: null, - current_round: 1, - players_count: (options?.players ?? []).length, - }, - round_question: - roundQuestionId === null - ? null - : { - id: roundQuestionId, - round_number: 1, - prompt: 'Q?', - shown_at: '2026-01-01T00:00:00Z', - answers: answers.map((text) => ({ text })), - }, - players: (options?.players ?? []).map((player) => ({ - ...player, - is_connected: true, - })), - reveal: - options?.reveal === undefined || options?.reveal === null - ? null - : { - round_question_id: roundQuestionId, - round_number: 1, - prompt: options.reveal.prompt ?? 'Q?', - correct_answer: options.reveal.correct_answer, - lies: (options.reveal.lies ?? []).map((lie) => ({ - ...lie, - created_at: lie.created_at ?? '2026-01-01T00:00:05Z', - })), - guesses: (options.reveal.guesses ?? []).map((guess) => ({ - ...guess, - created_at: guess.created_at ?? '2026-01-01T00:00:10Z', - })), - }, - phase_view_model: { - status, - current_phase: options?.currentPhase ?? status, - round_number: 1, - players_count: (options?.players ?? []).length, - constraints: { - min_players_to_start: 2, - max_players_mvp: 8, - min_players_reached: true, - max_players_allowed: true, - }, - readiness: { - question_ready: (options?.currentPhase ?? status) !== 'lobby', - scoreboard_ready: (options?.currentPhase ?? status) === 'reveal' || (options?.currentPhase ?? status) === 'scoreboard', - }, - host: { - can_start_round: false, - can_show_question: false, - can_mix_answers: false, - can_calculate_scores: false, - can_reveal_scoreboard: false, - can_start_next_round: false, - can_finish_game: false, - }, - player: { - can_join: (options?.currentPhase ?? status) === 'lobby', - can_submit_lie: (options?.currentPhase ?? status) === 'lie', - can_submit_guess: (options?.currentPhase ?? status) === 'guess', - can_view_final_result: (options?.currentPhase ?? status) === 'finished', - }, - }, - }; -} - -describe('PlayerShellComponent gameplay wiring', () => { - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - it('clears selected guess when refreshed status is no longer guess', async () => { - const fetchMock: FetchMock = vi.fn().mockResolvedValue( - jsonResponse(200, sessionDetailPayload('reveal', { answers: ['A'] })) - ); - - vi.stubGlobal('fetch', fetchMock); - - const component = new PlayerShellComponent(); - component.sessionCode = 'abcd12'; - component.selectedGuess = 'A'; - - await component.refreshSession(); - - expect(fetchMock).toHaveBeenCalledWith( - '/lobby/sessions/ABCD12', - expect.objectContaining({ method: 'GET' }) - ); - expect(component.selectedGuess).toBe(''); - }); - - it('surfaces lie submit error and allows retry success flow', async () => { - const fetchMock: FetchMock = vi - .fn() - .mockResolvedValueOnce(jsonResponse(500, { error: 'Temporary submit outage' })) - .mockResolvedValueOnce(jsonResponse(201, { lie: { id: 1, player_id: 9, round_question_id: 11, text: 'my lie', created_at: '2026-01-01T00:00:01Z' }, window: { lie_deadline_at: '2026-01-01T00:00:45Z' } })) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { answers: ['A', 'B'] }))); - - vi.stubGlobal('fetch', fetchMock); - - const component = new PlayerShellComponent(); - component.sessionCode = 'ABCD12'; - component.playerId = 9; - component.sessionToken = 'token-1'; - component.lieText = 'my lie'; - component.session = { - ...(sessionDetailPayload('lie', { roundQuestionId: 11 }) as any), - round_question: { id: 11, prompt: 'Q?', answers: [] }, - }; - - await component.submitLie(); - - expect(fetchMock).toHaveBeenNthCalledWith( - 1, - '/lobby/sessions/ABCD12/questions/11/lies/submit', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ player_id: 9, session_token: 'token-1', text: 'my lie' }), - }) - ); - expect(component.submitError?.kind).toBe('lie'); - expect(component.submitError?.message).toContain('Lie submit failed: Temporary submit outage'); - - await component.submitLie(); - - expect(component.submitError).toBeNull(); - expect(component.session?.session.status).toBe('guess'); - expect(fetchMock).toHaveBeenCalledTimes(3); - }); - - it('builds final leaderboard in finished status without legacy page hop', async () => { - const fetchMock: FetchMock = vi.fn().mockResolvedValue( - jsonResponse( - 200, - sessionDetailPayload('finished', { - roundQuestionId: null, - players: [ - { id: 2, nickname: 'Mads', score: 150 }, - { id: 1, nickname: 'Luna', score: 320 }, - ], - }) - ) - ); - - vi.stubGlobal('fetch', fetchMock); - - const component = new PlayerShellComponent(); - component.sessionCode = 'ABCD12'; - - await component.refreshSession(); - - expect(component.finalLeaderboard.map((entry) => entry.nickname)).toEqual(['Luna', 'Mads']); - }); - - it('hydrates canonical reveal payload after guess -> reveal', async () => { - const fetchMock: FetchMock = vi.fn().mockResolvedValue( - jsonResponse( - 200, - sessionDetailPayload('reveal', { - answers: ['A', 'B'], - reveal: { - correct_answer: 'A', - lies: [{ player_id: 3, nickname: 'Løgnhals', text: 'B' }], - guesses: [ - { - player_id: 9, - nickname: 'Detektiv', - selected_text: 'B', - is_correct: false, - fooled_player_id: 3, - fooled_player_nickname: 'Løgnhals', - }, - { - player_id: 10, - nickname: 'Sandhed', - selected_text: 'A', - is_correct: true, - fooled_player_id: null, - }, - ], - }, - }) - ) - ); - - vi.stubGlobal('fetch', fetchMock); - - const component = new PlayerShellComponent(); - component.sessionCode = 'ABCD12'; - - await component.refreshSession(); - - expect(component.session?.reveal?.correct_answer).toBe('A'); - expect(component.session?.reveal?.lies[0]).toMatchObject({ player_id: 3, nickname: 'Løgnhals', text: 'B' }); - expect(component.session?.reveal?.guesses[0]).toMatchObject({ - player_id: 9, - nickname: 'Detektiv', - selected_text: 'B', - is_correct: false, - fooled_player_id: 3, - fooled_player_nickname: 'Løgnhals', - }); - expect(component.session?.reveal?.guesses[1]).toMatchObject({ - player_id: 10, - nickname: 'Sandhed', - selected_text: 'A', - is_correct: true, - fooled_player_id: null, - }); - }); - - it('surfaces guess submit error and retries with selected answer payload', async () => { - const fetchMock: FetchMock = vi - .fn() - .mockResolvedValueOnce(jsonResponse(503, { error: 'Guess queue busy' })) - .mockResolvedValueOnce(jsonResponse(201, { guess: { id: 2, player_id: 9, round_question_id: 11, selected_text: 'B', is_correct: false, fooled_player_id: 3, created_at: '2026-01-01T00:00:10Z' }, window: { guess_deadline_at: '2026-01-01T00:01:30Z' } })) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { answers: ['A', 'B'] }))); - - vi.stubGlobal('fetch', fetchMock); - - const component = new PlayerShellComponent(); - component.sessionCode = ' abcd12 '; - component.playerId = 9; - component.sessionToken = 'token-1'; - component.selectedGuess = 'B'; - component.session = { - ...(sessionDetailPayload('guess', { answers: ['A', 'B'], roundQuestionId: 11 }) as any), - round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] }, - }; - - await component.submitGuess(); - - expect(fetchMock).toHaveBeenNthCalledWith( - 1, - '/lobby/sessions/ABCD12/questions/11/guesses/submit', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ player_id: 9, session_token: 'token-1', selected_text: 'B' }), - }) - ); - expect(component.submitError?.kind).toBe('guess'); - expect(component.submitError?.message).toContain('Guess submit failed: Guess queue busy'); - - await component.submitGuess(); - - expect(component.submitError).toBeNull(); - expect(component.session?.session.status).toBe('reveal'); - expect(component.selectedGuess).toBe(''); - expect(fetchMock).toHaveBeenCalledTimes(3); - }); - - it('blocks illegal player guess submission outside canonical guess phase', async () => { - const fetchMock: FetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - - const component = new PlayerShellComponent(); - component.sessionCode = 'ABCD12'; - component.playerId = 9; - component.sessionToken = 'token-1'; - component.selectedGuess = 'B'; - - for (const status of ['lie', 'reveal', 'scoreboard'] as const) { - component.session = { - ...(sessionDetailPayload(status, { answers: ['A', 'B'] }) as any), - round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] }, - }; - - await component.submitGuess(); - } - - expect(component.canSubmitGuess).toBe(false); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('auto-refreshes player session to avoid host/player state desync between rounds', async () => { - vi.useFakeTimers(); - - const fetchMock: FetchMock = vi - .fn() - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('scoreboard', { roundQuestionId: null }))) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null }))); - - vi.stubGlobal('fetch', fetchMock); - - const component = new PlayerShellComponent(); - component.sessionCode = 'ABCD12'; - - await component.refreshSession(); - expect(component.session?.session.status).toBe('scoreboard'); - - await vi.advanceTimersByTimeAsync(3100); - - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(component.session?.session.status).toBe('lobby'); - - component.ngOnDestroy(); - }); - - it('enters reconnecting state when network request fails while online', async () => { - vi.stubGlobal('navigator', { onLine: true }); - - const fetchMock: FetchMock = vi.fn().mockRejectedValueOnce(new TypeError('Failed to fetch')); - vi.stubGlobal('fetch', fetchMock); - - const component = new PlayerShellComponent(); - component.sessionCode = 'ABCD12'; - - await component.refreshSession(); - - expect(component.connectionState === 'reconnecting' || component.connectionState === 'online').toBe(true); - expect(component.error).toContain('Session refresh failed:'); - }); - - it('uses offline state when browser reports disconnected network', async () => { - vi.stubGlobal('navigator', { onLine: false }); - - const fetchMock: FetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')); - vi.stubGlobal('fetch', fetchMock); - - const component = new PlayerShellComponent(); - component.sessionCode = 'ABCD12'; - - await component.refreshSession(); - - expect(component.connectionState).toBe('offline'); - expect(component.error).toContain('Session refresh failed'); - }); - - it('tracks loading transition message for join action', async () => { - let resolveJoin: ((value: Response) => void) | null = null; - const fetchMock: FetchMock = vi.fn().mockImplementation( - () => - new Promise((resolve) => { - resolveJoin = resolve; - }) - ); - vi.stubGlobal('fetch', fetchMock); - - const component = new PlayerShellComponent(); - component.sessionCode = 'ABCD12'; - component.nickname = 'Luna'; - - const joinPromise = component.joinSession(); - - expect(component.loading).toBe(true); - expect(component.loadingMessage).toBe('Joining session… restoring your player state.'); - - resolveJoin?.(jsonResponse(201, sessionDetailPayload('lobby', { roundQuestionId: null }))); - await joinPromise; - - expect(component.loading).toBe(false); - expect(component.loadingTransition).toBeNull(); - }); - - it('returnToJoin clears persisted session context and transient state', () => { - const values = new Map(); - const localStorage = { - getItem: vi.fn((key: string) => values.get(key) ?? null), - setItem: vi.fn((key: string, value: string) => { - values.set(key, value); - }), - removeItem: vi.fn((key: string) => { - values.delete(key); - }), - }; - - vi.stubGlobal('window', { - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - localStorage, - }); - - values.set('wpp.session-context', JSON.stringify({ sessionCode: 'ABCD12', playerId: 9, token: 'tok-1' })); - - const component = new PlayerShellComponent(); - component.sessionCode = 'ABCD12'; - component.playerId = 9; - component.sessionToken = 'tok-1'; - component.error = 'Session refresh failed'; - component.submitError = { kind: 'guess', message: 'Guess submit failed' }; - component.session = { - session: { code: 'ABCD12', status: 'guess', current_round: 1 }, - round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }] }, - players: [], - }; - - component.returnToJoin(); - - expect(component.playerId).toBe(0); - expect(component.sessionToken).toBe(''); - expect(component.session).toBeNull(); - expect(component.error).toBe(''); - expect(component.submitError).toBeNull(); - expect(values.get('wpp.session-context')).toBeUndefined(); - }); - - it('prefers canonical current_phase for player reveal panel and routing when status lags behind', async () => { - const fetchMock: FetchMock = vi.fn().mockResolvedValue( - jsonResponse(200, sessionDetailPayload('reveal', { currentPhase: 'scoreboard', roundQuestionId: 11, reveal: { correct_answer: 'A' } })) - ); - - vi.stubGlobal('fetch', fetchMock); - - const replaceState = vi.fn(); - const localStorage = { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() }; - vi.stubGlobal('window', { - location: { hash: '#/player/reveal/ABCD12' }, - history: { state: null, replaceState }, - localStorage, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }); - - const component = new PlayerShellComponent(); - component.sessionCode = 'ABCD12'; - - await component.refreshSession(); - - expect(component.gameplayPhase).toBe('scoreboard'); - expect(component.showRevealPanel).toBe(true); - expect(component.showGuessControls).toBe(false); - expect(replaceState).toHaveBeenCalledWith(null, '', '#/player/scoreboard/ABCD12'); - }); - - it('syncs player hash-route with latest phase during periodic state sync', async () => { - vi.useFakeTimers(); - - const fetchMock: FetchMock = vi - .fn() - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('scoreboard', { roundQuestionId: null }))) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null }))); - - vi.stubGlobal('fetch', fetchMock); - - const replaceState = vi.fn(); - const localStorage = { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() }; - vi.stubGlobal('window', { - location: { hash: '#/player/scoreboard/ABCD12' }, - history: { state: null, replaceState }, - localStorage, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }); - - const component = new PlayerShellComponent(); - component.sessionCode = 'ABCD12'; - - await component.refreshSession(); - await vi.advanceTimersByTimeAsync(3100); - - expect(replaceState).toHaveBeenCalledWith(null, '', '#/player/lobby/ABCD12'); - - component.ngOnDestroy(); - }); - - it('silences active media elements when secondary-device audio guard is installed', () => { - const pauseAudio = vi.fn(); - const pauseVideo = vi.fn(); - const audioElement = { muted: false, pause: pauseAudio }; - const videoElement = { muted: false, pause: pauseVideo }; - const querySelectorAll = vi.fn().mockReturnValue([audioElement, videoElement]); - - vi.stubGlobal('document', { querySelectorAll }); - vi.stubGlobal('window', { - location: { hash: '', search: '' }, - history: { state: null, replaceState: vi.fn() }, - localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() }, - sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() }, - HTMLMediaElement: { prototype: { play: vi.fn().mockResolvedValue(undefined) } }, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }); - vi.stubGlobal('navigator', { language: 'en-US', onLine: true }); - - const component = new PlayerShellComponent(); - component.ngOnInit(); - - expect(querySelectorAll).toHaveBeenCalledWith('audio,video'); - expect(audioElement.muted).toBe(true); - expect(videoElement.muted).toBe(true); - expect(pauseAudio).toHaveBeenCalledTimes(1); - expect(pauseVideo).toHaveBeenCalledTimes(1); - - component.ngOnDestroy(); - }); - - it('installs secondary-device audio guard while player shell is mounted', async () => { - const originalPlay = vi.fn().mockRejectedValue(new Error('original play')); - const mediaPrototype = { play: originalPlay }; - - vi.stubGlobal('window', { - location: { hash: '', search: '' }, - history: { state: null, replaceState: vi.fn() }, - localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() }, - sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() }, - HTMLMediaElement: { prototype: mediaPrototype }, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }); - vi.stubGlobal('navigator', { language: 'en-US', onLine: true }); - - const component = new PlayerShellComponent(); - component.ngOnInit(); - - const pause = vi.fn(); - const audioElement = { muted: false, defaultMuted: false, volume: 1, pause }; - - await expect(mediaPrototype.play.call(audioElement)).resolves.toBeUndefined(); - expect(audioElement.muted).toBe(true); - expect(audioElement.defaultMuted).toBe(true); - expect(audioElement.volume).toBe(0); - expect(pause).toHaveBeenCalledTimes(1); - - component.ngOnDestroy(); - - await expect(mediaPrototype.play()).rejects.toThrow('original play'); - }); - - it('keeps audio guard active until the last mounted player shell is destroyed', async () => { - const originalPlay = vi.fn().mockRejectedValue(new Error('original play')); - const mediaPrototype = { play: originalPlay }; - - vi.stubGlobal('window', { - location: { hash: '', search: '' }, - history: { state: null, replaceState: vi.fn() }, - localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() }, - sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() }, - HTMLMediaElement: { prototype: mediaPrototype }, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }); - vi.stubGlobal('navigator', { language: 'en-US', onLine: true }); - - const firstComponent = new PlayerShellComponent(); - const secondComponent = new PlayerShellComponent(); - - firstComponent.ngOnInit(); - secondComponent.ngOnInit(); - - await expect(mediaPrototype.play()).resolves.toBeUndefined(); - - firstComponent.ngOnDestroy(); - await expect(mediaPrototype.play()).resolves.toBeUndefined(); - - secondComponent.ngOnDestroy(); - await expect(mediaPrototype.play()).rejects.toThrow('original play'); - }); - - it('does not trigger original media play during player-shell init path', () => { - const originalPlay = vi.fn().mockResolvedValue(undefined); - const mediaPrototype = { play: originalPlay }; - - vi.stubGlobal('window', { - location: { hash: '', search: '' }, - history: { state: null, replaceState: vi.fn() }, - localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() }, - sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() }, - HTMLMediaElement: { prototype: mediaPrototype }, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }); - vi.stubGlobal('navigator', { language: 'en-US', onLine: true }); - - const component = new PlayerShellComponent(); - component.ngOnInit(); - - expect(originalPlay).not.toHaveBeenCalled(); - - component.ngOnDestroy(); - }); - - it('keeps primary-device playback untouched when no-audio capability is disabled', async () => { - const originalPlay = vi.fn().mockResolvedValue(undefined); - const mediaPrototype = { play: originalPlay }; - - vi.stubGlobal('window', { - location: { hash: '', search: '' }, - history: { state: null, replaceState: vi.fn() }, - localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() }, - sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() }, - HTMLMediaElement: { prototype: mediaPrototype }, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }); - vi.stubGlobal('navigator', { language: 'en-US', onLine: true }); - - const component = new PlayerShellComponent(); - (component as any).clientHasNoAudioOutput = false; - - component.ngOnInit(); - - await expect(mediaPrototype.play()).resolves.toBeUndefined(); - expect(mediaPrototype.play).toBe(originalPlay); - expect((mediaPrototype as any).__wppSecondaryDeviceAudioGuard__).toBeUndefined(); - - component.ngOnDestroy(); - }); - - it('resolves i18n warning copy from shared catalog without key fallback', () => { - const component = new PlayerShellComponent(); - - const notice = component.copy('player.audio_policy_notice'); - const expected = lobbyCatalog.frontend.ui.player.audio_policy_notice[component.locale]; - - expect(notice).toBe(expected); - expect(notice).not.toBe('player.audio_policy_notice'); - }); - - it('gates template warning notice on the no-audio-output capability flag', () => { - const templateSource = String((PlayerShellComponent as any).ɵcmp?.template); - - expect(templateSource).toContain('clientHasNoAudioOutput'); - - const component = new PlayerShellComponent(); - expect(component.copy('player.audio_policy_notice')).not.toBe('player.audio_policy_notice'); - expect(component.clientHasNoAudioOutput).toBe(true); - - (component as any).clientHasNoAudioOutput = false; - expect(component.clientHasNoAudioOutput).toBe(false); - }); - - it('keeps phone client controls phase-specific and low-complexity', () => { - const component = new PlayerShellComponent(); - - expect(component.showJoinControls).toBe(true); - expect(component.showLieControls).toBe(false); - expect(component.showGuessControls).toBe(false); - expect(component.showFinalLeaderboard).toBe(false); - - component.session = sessionDetailPayload('lie') as any; - component.playerId = 9; - component.sessionToken = 'tok'; - expect(component.showJoinControls).toBe(false); - expect(component.showLieControls).toBe(true); - expect(component.showGuessControls).toBe(false); - - component.session = sessionDetailPayload('guess', { answers: ['A', 'B'] }) as any; - expect(component.showLieControls).toBe(false); - expect(component.showGuessControls).toBe(true); - - component.session = sessionDetailPayload('finished', { players: [{ id: 1, nickname: 'Luna', score: 8 }] }) as any; - expect(component.showGuessControls).toBe(false); - expect(component.showFinalLeaderboard).toBe(true); - }); - -}); diff --git a/frontend/angular/src/app/features/player/player-shell.component.ts b/frontend/angular/src/app/features/player/player-shell.component.ts deleted file mode 100644 index a514f7a..0000000 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ /dev/null @@ -1,620 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; - -import { createApiClient } from '../../../../../src/api/client'; -import type { SessionDetailResponse } from '../../../../../src/api/types'; -import { - deriveGameplayPhase, - isPlayerGameplayActionAllowed, -} from '../../../../../src/spa/gameplay-phase-machine'; -import { createSessionContextStore } from '../../../../../src/spa/session-context-store'; -import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; -import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; - -type SessionDetail = SessionDetailResponse; - -type ConnectionState = 'online' | 'reconnecting' | 'offline'; -type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null; - -type GuardableMediaElement = { - muted?: boolean; - defaultMuted?: boolean; - volume?: number; - pause?: () => void; -}; - -type MediaPrototypeWithGuardState = { - play?: (this: GuardableMediaElement) => Promise; - __wppSecondaryDeviceAudioGuard__?: { - originalPlay: (this: GuardableMediaElement) => Promise; - installs: number; - }; -}; - - -function resolveLocalStorage(): Storage | undefined { - if (typeof window === 'undefined') { - return undefined; - } - return window.localStorage; -} - -@Component({ - selector: 'app-player-shell', - standalone: true, - imports: [CommonModule, FormsModule], - template: ` -

{{ copy('player.title') }}

-

{{ copy('player.audio_policy_notice') }}

- -
- - - - -
- -

- {{ copy('player.reconnecting_text') }} - - -

-

- {{ copy('player.offline_text') }} - - -

- -

{{ loadingMessage }}

- -
-

{{ copy('common.status') }}: {{ session.session.status }}

-

{{ copy('common.prompt') }}: {{ session.round_question.prompt }}

- - - - - - - - -
- -
- - - -
- -
-

Reveal

-

Korrekt svar: {{ session.reveal.correct_answer }}

-

Spørgsmål: {{ session.reveal.prompt }}

-
- Løgne -
    -
  • {{ lie.nickname }} løj: {{ lie.text }}
  • -
-
-
- Gæt -
    -
  • - {{ guess.nickname }} valgte {{ guess.selected_text }} - · korrekt - · narret af {{ guess.fooled_player_nickname }} - · forkert -
  • -
-
-
- -
-

{{ copy('player.final_leaderboard') }}

-
    -
  1. {{ entry.nickname }}: {{ entry.score }}
  2. -
-
-
- -

{{ error }}

-

{{ submitError.message }}

- -
- - -
- `, -}) -export class PlayerShellComponent implements OnInit, OnDestroy { - locale = resolvePreferredLocale(); - readonly clientHasNoAudioOutput = clientHasNoAudioOutput; - - sessionCode = ''; - nickname = ''; - playerId = 0; - sessionToken = ''; - lieText = ''; - selectedGuess = ''; - loading = false; - error = ''; - submitError: { kind: 'lie' | 'guess'; message: string } | null = null; - session: SessionDetail | null = null; - finalLeaderboard: Array<{ id: number; nickname: string; score: number }> = []; - connectionState: ConnectionState = 'online'; - loadingTransition: LoadingTransition = null; - - private readonly sessionContextStore = createSessionContextStore(resolveLocalStorage()); - private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore); - private reconnectTimer: ReturnType | null = null; - private stateSyncTimer: ReturnType | null = null; - private unsubscribeLocale: (() => void) | null = null; - private restoreAudioGuard: (() => void) | null = null; - - constructor() { - if (typeof navigator !== 'undefined' && !navigator.onLine) { - this.connectionState = 'offline'; - } - - if (typeof window !== 'undefined') { - window.addEventListener('online', this.handleOnline); - window.addEventListener('offline', this.handleOffline); - } - } - - ngOnInit(): void { - this.unsubscribeLocale = subscribeToLocaleChanges((locale) => { - this.locale = locale; - }); - this.installSecondaryDeviceAudioGuard(); - - const hashRoute = window.location.hash.replace(/^#\/?/, ''); - const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i); - const codeFromRoute = match?.[1] ?? ''; - - const persistedContext = this.sessionContextStore.get(); - if (persistedContext) { - this.playerId = persistedContext.playerId; - this.sessionToken = persistedContext.token; - } - - const candidate = codeFromRoute || persistedContext?.sessionCode || ''; - if (!candidate) { - return; - } - - this.sessionCode = this.normalizeCode(candidate); - void this.refreshSession(); - } - - ngOnDestroy(): void { - if (typeof window !== 'undefined') { - window.removeEventListener('online', this.handleOnline); - window.removeEventListener('offline', this.handleOffline); - } - this.clearReconnectTimer(); - this.clearStateSyncTimer(); - this.unsubscribeLocale?.(); - this.unsubscribeLocale = null; - this.restoreAudioGuard?.(); - this.restoreAudioGuard = null; - } - - get gameplayPhase(): string | null { - return deriveGameplayPhase(this.session as any); - } - - get canSubmitLie(): boolean { - return isPlayerGameplayActionAllowed(this.session as any, 'submitLie'); - } - - get canSubmitGuess(): boolean { - return isPlayerGameplayActionAllowed(this.session as any, 'submitGuess'); - } - - get showRevealPanel(): boolean { - return Boolean(this.session?.reveal && (this.gameplayPhase === 'reveal' || this.gameplayPhase === 'scoreboard')); - } - - private readonly handleOnline = (): void => { - this.connectionState = 'reconnecting'; - void this.retryReconnect(); - }; - - private readonly handleOffline = (): void => { - this.connectionState = 'offline'; - this.clearReconnectTimer(); - this.clearStateSyncTimer(); - }; - - private clearReconnectTimer(): void { - if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - } - - private clearStateSyncTimer(): void { - if (this.stateSyncTimer) { - clearTimeout(this.stateSyncTimer); - this.stateSyncTimer = null; - } - } - - private installSecondaryDeviceAudioGuard(): void { - if (!this.clientHasNoAudioOutput || typeof window === 'undefined') { - return; - } - - this.silenceExistingMediaElements(); - - const mediaPrototype = (window as Window & { HTMLMediaElement?: { prototype?: MediaPrototypeWithGuardState } }).HTMLMediaElement - ?.prototype; - - if (!mediaPrototype || typeof mediaPrototype.play !== 'function') { - return; - } - - const guardState = mediaPrototype.__wppSecondaryDeviceAudioGuard__; - if (guardState) { - guardState.installs += 1; - } else { - const originalPlay = mediaPrototype.play; - mediaPrototype.play = function mediaGuardedPlay(this: GuardableMediaElement): Promise { - this.muted = true; - this.defaultMuted = true; - if (typeof this.volume === 'number') { - this.volume = 0; - } - this.pause?.(); - return Promise.resolve(); - }; - mediaPrototype.__wppSecondaryDeviceAudioGuard__ = { - originalPlay, - installs: 1, - }; - } - - this.restoreAudioGuard = () => { - const currentState = mediaPrototype.__wppSecondaryDeviceAudioGuard__; - if (!currentState) { - return; - } - - currentState.installs -= 1; - if (currentState.installs <= 0) { - mediaPrototype.play = currentState.originalPlay; - delete mediaPrototype.__wppSecondaryDeviceAudioGuard__; - } - }; - } - - - private silenceExistingMediaElements(): void { - if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') { - return; - } - - const activeElements = document.querySelectorAll('audio,video') as - | NodeListOf - | GuardableMediaElement[] - | undefined; - - if (!activeElements || typeof (activeElements as { forEach?: unknown }).forEach !== 'function') { - return; - } - - activeElements.forEach((element) => { - element.muted = true; - element.defaultMuted = true; - if (typeof element.volume === 'number') { - element.volume = 0; - } - element.pause?.(); - }); - } - - private scheduleStateSync(): void { - this.clearStateSyncTimer(); - - if (!this.sessionCode.trim() || this.connectionState !== 'online' || !this.session) { - return; - } - - if (this.session.session.status === 'finished') { - return; - } - - this.stateSyncTimer = setTimeout(() => { - this.stateSyncTimer = null; - if (this.loading || this.connectionState !== 'online') { - this.scheduleStateSync(); - return; - } - void this.refreshSession(); - }, 3000); - } - - get showJoinControls(): boolean { - if (!this.session) { - return true; - } - return Boolean(this.session?.phase_view_model?.player?.can_join && !this.playerId && !this.sessionToken); - } - - get showLieControls(): boolean { - return Boolean(this.session?.phase_view_model?.player?.can_submit_lie); - } - - get showGuessControls(): boolean { - return Boolean(this.session?.phase_view_model?.player?.can_submit_guess); - } - - get showFinalLeaderboard(): boolean { - return Boolean(this.session?.phase_view_model?.player?.can_view_final_result); - } - - get loadingMessage(): string { - switch (this.loadingTransition) { - case 'join': - return this.copy('player.loading_join'); - case 'submit-lie': - return this.copy('player.loading_submit_lie'); - case 'submit-guess': - return this.copy('player.loading_submit_guess'); - case 'refresh': - default: - return this.copy('player.loading_refresh'); - } - } - - copy(key: string): string { - return t(key, this.locale); - } - - private normalizeCode(value: string): string { - return value.trim().toUpperCase(); - } - - private toMessage(error: unknown): string { - if (error instanceof Error && error.message) { - return error.message; - } - return this.copy('common.unknown_error'); - } - - private markOnline(): void { - this.connectionState = 'online'; - this.clearReconnectTimer(); - this.scheduleStateSync(); - } - - private markConnectionIssue(error: unknown): void { - this.clearStateSyncTimer(); - - if (typeof navigator !== 'undefined' && !navigator.onLine) { - this.connectionState = 'offline'; - return; - } - - const message = this.toMessage(error).toLowerCase(); - if ( - message.includes('fetch') || - message.includes('network') || - message.includes('failed to') || - message.includes('could not load lobby status') || - message.includes('session refresh failed') - ) { - this.connectionState = 'reconnecting'; - this.scheduleReconnect(); - } - } - - private scheduleReconnect(): void { - if (this.reconnectTimer || !this.sessionCode.trim()) { - return; - } - - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null; - void this.retryReconnect(); - }, 2000); - } - - async retryReconnect(): Promise { - if (!this.sessionCode.trim() || this.loading) { - return; - } - - await this.refreshSession(); - } - - returnToJoin(): void { - this.loadingTransition = null; - this.clearReconnectTimer(); - this.clearStateSyncTimer(); - this.connectionState = typeof navigator !== 'undefined' && !navigator.onLine ? 'offline' : 'online'; - this.session = null; - this.finalLeaderboard = []; - this.selectedGuess = ''; - this.lieText = ''; - this.submitError = null; - this.error = ''; - this.playerId = 0; - this.sessionToken = ''; - this.sessionContextStore.clear(); - } - - private syncFinalLeaderboard(): void { - if (!this.session || this.session.session.status !== 'finished') { - this.finalLeaderboard = []; - return; - } - - this.finalLeaderboard = [...this.session.players].sort((a, b) => { - if (b.score !== a.score) { - return b.score - a.score; - } - return a.nickname.localeCompare(b.nickname); - }); - } - - private syncRouteFromSession(): void { - if (!this.session) { - return; - } - - const phase = this.gameplayPhase ?? this.session.session.status ?? 'lobby'; - const code = this.normalizeCode(this.session.session.code || this.sessionCode); - if (!code) { - return; - } - - const targetPath = `#/player/${encodeURIComponent(phase)}/${encodeURIComponent(code)}`; - if (typeof window === 'undefined' || window.location.hash === targetPath) { - return; - } - - window.history.replaceState(window.history.state, '', targetPath); - } - - private async request(path: string, method: 'GET' | 'POST', payload?: unknown): Promise { - const response = await fetch(path, { - method, - headers: { - Accept: 'application/json', - ...(payload === undefined ? {} : { 'Content-Type': 'application/json' }), - }, - ...(payload === undefined ? {} : { body: JSON.stringify(payload) }), - credentials: 'same-origin', - }); - - const body = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error((body as { error?: string }).error ?? `HTTP ${response.status}`); - } - - return body as T; - } - - async refreshSession(): Promise { - this.loading = true; - this.loadingTransition = 'refresh'; - this.error = ''; - try { - const state = await this.controller.hydrateLobby(this.sessionCode); - if (!state.session || state.errorMessage) { - throw new Error(state.errorMessage ?? this.copy('common.unknown_error')); - } - this.session = state.session as SessionDetail; - this.sessionCode = this.session.session.code; - if (this.session.session.status !== 'guess') { - this.selectedGuess = ''; - } - this.syncFinalLeaderboard(); - this.syncRouteFromSession(); - this.markOnline(); - } catch (error) { - this.error = `${this.copy('player.session_refresh_failed')}: ${this.toMessage(error)}`; - this.markConnectionIssue(error); - } finally { - this.loading = false; - this.loadingTransition = null; - } - } - - async joinSession(): Promise { - this.loading = true; - this.loadingTransition = 'join'; - this.error = ''; - try { - const state = await this.controller.joinLobby(this.sessionCode, this.nickname); - if (!state.session || state.errorMessage) { - throw new Error(state.errorMessage ?? this.copy('common.unknown_error')); - } - this.session = state.session as SessionDetail; - this.sessionCode = this.session.session.code; - - const sessionContext = this.sessionContextStore.get(); - this.playerId = sessionContext?.playerId ?? 0; - this.sessionToken = sessionContext?.token ?? ''; - if (this.session.session.status !== 'guess') { - this.selectedGuess = ''; - } - this.syncFinalLeaderboard(); - this.syncRouteFromSession(); - this.markOnline(); - } catch (error) { - this.error = `${this.copy('player.join_failed')}: ${this.toMessage(error)}`; - this.markConnectionIssue(error); - } finally { - this.loading = false; - this.loadingTransition = null; - } - } - - async submitLie(): Promise { - if (!this.session?.round_question?.id || !this.canSubmitLie) { - return; - } - this.loading = true; - this.loadingTransition = 'submit-lie'; - this.submitError = null; - try { - await this.request( - `/lobby/sessions/${encodeURIComponent(this.normalizeCode(this.sessionCode))}/questions/${this.session.round_question.id}/lies/submit`, - 'POST', - { - player_id: this.playerId, - session_token: this.sessionToken, - text: this.lieText, - } - ); - await this.refreshSession(); - this.markOnline(); - } catch (error) { - this.submitError = { kind: 'lie', message: `${this.copy('player.lie_submit_failed')}: ${this.toMessage(error)}` }; - this.markConnectionIssue(error); - } finally { - this.loading = false; - this.loadingTransition = null; - } - } - - async submitGuess(): Promise { - if (!this.session?.round_question?.id || !this.selectedGuess || !this.canSubmitGuess) { - return; - } - this.loading = true; - this.loadingTransition = 'submit-guess'; - this.submitError = null; - try { - await this.request( - `/lobby/sessions/${encodeURIComponent(this.normalizeCode(this.sessionCode))}/questions/${this.session.round_question.id}/guesses/submit`, - 'POST', - { - player_id: this.playerId, - session_token: this.sessionToken, - selected_text: this.selectedGuess, - } - ); - await this.refreshSession(); - this.markOnline(); - } catch (error) { - this.submitError = { kind: 'guess', message: `${this.copy('player.guess_submit_failed')}: ${this.toMessage(error)}` }; - this.markConnectionIssue(error); - } finally { - this.loading = false; - this.loadingTransition = null; - } - } -} diff --git a/frontend/angular/src/app/gameplay-phase-machine.spec.ts b/frontend/angular/src/app/gameplay-phase-machine.spec.ts deleted file mode 100644 index fe4c709..0000000 --- a/frontend/angular/src/app/gameplay-phase-machine.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { deriveGameplayPhase, transitionGameplayPhase } from '../../../src/spa/gameplay-phase-machine'; - -describe('gameplay phase machine sync guards', () => { - it('keeps explicit scoreboard status as scoreboard phase', () => { - const phase = deriveGameplayPhase({ - session: { - code: 'ABCD12', - status: 'scoreboard', - host_id: 1, - current_round: 1, - players_count: 2, - }, - round_question: null, - players: [], - phase_view_model: { - status: 'scoreboard', - round_number: 1, - players_count: 2, - constraints: { - min_players_to_start: 2, - max_players_mvp: 8, - min_players_reached: true, - max_players_allowed: true, - }, - host: { - can_start_round: false, - can_show_question: false, - can_mix_answers: false, - can_calculate_scores: false, - can_reveal_scoreboard: false, - can_start_next_round: true, - can_finish_game: true, - }, - player: { - can_join: false, - can_submit_lie: false, - can_submit_guess: false, - can_view_final_result: false, - }, - }, - }); - - expect(phase).toBe('scoreboard'); - }); - - it('maps finished status to scoreboard phase fallback', () => { - const phase = deriveGameplayPhase({ - session: { - code: 'ABCD12', - status: 'finished', - host_id: 1, - current_round: 1, - players_count: 2, - }, - round_question: null, - players: [], - phase_view_model: { - status: 'finished', - round_number: 1, - players_count: 2, - constraints: { - min_players_to_start: 2, - max_players_mvp: 8, - min_players_reached: true, - max_players_allowed: true, - }, - host: { - can_start_round: false, - can_show_question: false, - can_mix_answers: false, - can_calculate_scores: false, - can_reveal_scoreboard: false, - can_start_next_round: false, - can_finish_game: false, - }, - player: { - can_join: false, - can_submit_lie: false, - can_submit_guess: false, - can_view_final_result: true, - }, - }, - }); - - expect(phase).toBe('scoreboard'); - }); - - it('transitions reveal -> scoreboard on SCOREBOARD_READY', () => { - expect(transitionGameplayPhase('reveal', 'SCOREBOARD_READY')).toEqual({ - phase: 'scoreboard', - changed: true, - }); - }); -}); diff --git a/frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts b/frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts deleted file mode 100644 index 84b8fd0..0000000 --- a/frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { HostShellComponent } from './features/host/host-shell.component'; -import { PlayerShellComponent } from './features/player/player-shell.component'; -import { setPreferredLocale } from './lobby-i18n'; - -function stubShellGlobals(initialLocale: string) { - vi.stubGlobal('window', { - location: { hash: '', search: '' }, - history: { state: null, replaceState: vi.fn() }, - localStorage: { getItem: vi.fn().mockReturnValue(initialLocale), setItem: vi.fn(), removeItem: vi.fn() }, - sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() }, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }); - vi.stubGlobal('navigator', { language: `${initialLocale}-US`, onLine: true }); -} - -describe('i18n MVP flow smoke (host/player + audio policy)', () => { - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it.each([ - { - locale: 'en', - hostRefresh: 'Refresh', - hostStartRound: 'Start round', - playerSubmitGuess: 'Submit guess', - }, - { - locale: 'da', - hostRefresh: 'Opdatér', - hostStartRound: 'Start runde', - playerSubmitGuess: 'Send gæt', - }, - ])('resolves one host/player locale run for $locale', ({ locale, hostRefresh, hostStartRound, playerSubmitGuess }) => { - stubShellGlobals(locale); - - const host = new HostShellComponent(); - const player = new PlayerShellComponent(); - host.ngOnInit(); - player.ngOnInit(); - setPreferredLocale(locale); - - expect(host.copy('common.refresh')).toBe(hostRefresh); - expect(host.copy('game.host.start_round')).toBe(hostStartRound); - expect(player.copy('game.player.submit_guess')).toBe(playerSubmitGuess); - - player.ngOnDestroy(); - host.ngOnDestroy(); - }); - - it('keeps audio routing primary-only by guarding player playback without muting the host path', async () => { - const originalPlay = vi.fn().mockRejectedValue(new Error('primary host playback')); - const mediaPrototype = { play: originalPlay }; - - vi.stubGlobal('window', { - location: { hash: '', search: '' }, - history: { state: null, replaceState: vi.fn() }, - localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() }, - sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() }, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - HTMLMediaElement: { prototype: mediaPrototype }, - }); - vi.stubGlobal('navigator', { language: 'en-US', onLine: true }); - vi.stubGlobal('document', { querySelectorAll: vi.fn().mockReturnValue([]) }); - - const host = new HostShellComponent(); - host.ngOnInit(); - - await expect(mediaPrototype.play()).rejects.toThrow('primary host playback'); - - const player = new PlayerShellComponent(); - player.ngOnInit(); - - await expect(mediaPrototype.play()).resolves.toBeUndefined(); - - player.ngOnDestroy(); - - await expect(mediaPrototype.play()).rejects.toThrow('primary host playback'); - - host.ngOnDestroy(); - }); -}); diff --git a/frontend/angular/src/app/lobby-i18n.spec.ts b/frontend/angular/src/app/lobby-i18n.spec.ts deleted file mode 100644 index e86d3d5..0000000 --- a/frontend/angular/src/app/lobby-i18n.spec.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -type StorageLike = { - getItem: (key: string) => string | null; - setItem: (key: string, value: string) => void; -}; - -function storageMock(initial: Record = {}): StorageLike { - const data = new Map(Object.entries(initial)); - return { - getItem: vi.fn((key: string) => data.get(key) ?? null), - setItem: vi.fn((key: string, value: string) => { - data.set(key, value); - }), - }; -} - -describe('lobby i18n locale propagation', () => { - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - vi.resetModules(); - }); - - it('notifies subscribers immediately and on locale changes', async () => { - const localStorage = storageMock({ 'wpp.locale': 'en' }); - vi.stubGlobal('window', { - location: { search: '' }, - localStorage, - }); - vi.stubGlobal('navigator', { language: 'en-US' }); - - const i18n = await import('./lobby-i18n'); - - const updates: string[] = []; - const unsubscribe = i18n.subscribeToLocaleChanges((locale) => updates.push(locale)); - - expect(updates).toEqual(['en']); - - i18n.setPreferredLocale('da'); - expect(updates).toEqual(['en', 'da']); - - unsubscribe(); - i18n.setPreferredLocale('en'); - expect(updates).toEqual(['en', 'da']); - }); - - it('prefers backend-provided shell locale over browser defaults', async () => { - vi.stubGlobal('window', { - location: { search: '' }, - localStorage: storageMock(), - }); - vi.stubGlobal('document', { - body: { dataset: { wppLocale: 'da' } }, - querySelector: vi.fn(() => null), - }); - vi.stubGlobal('navigator', { language: 'en-US' }); - - const i18n = await import('./lobby-i18n'); - - expect(i18n.resolvePreferredLocale()).toBe('da'); - }); - - it('falls back to default en translation when da key is intentionally missing', async () => { - vi.stubGlobal('window', { - location: { search: '' }, - localStorage: storageMock({ 'wpp.locale': 'da' }), - }); - vi.stubGlobal('navigator', { language: 'da-DK' }); - - const i18n = await import('./lobby-i18n'); - const catalogModule = await import('../../../../shared/i18n/lobby.json'); - const catalog = catalogModule.default as { - frontend: { - ui: { - common: { - refresh: { - en?: string; - da?: string; - }; - }; - }; - }; - }; - - const originalDa = catalog.frontend.ui.common.refresh.da; - catalog.frontend.ui.common.refresh.da = undefined; - - try { - expect(i18n.t('common.refresh', 'da')).toBe(catalog.frontend.ui.common.refresh.en); - } finally { - catalog.frontend.ui.common.refresh.da = originalDa; - } - }); - - it('resolves baseline shell/game keys from shared namespaces', async () => { - vi.stubGlobal('window', { - location: { search: '' }, - localStorage: storageMock({ 'wpp.locale': 'da' }), - }); - vi.stubGlobal('navigator', { language: 'da-DK' }); - - const i18n = await import('./lobby-i18n'); - - const baselineKeys = [ - 'lobby.shell.title', - 'lobby.shell.host_nav', - 'lobby.shell.player_nav', - 'lobby.shell.language_label', - 'common.refresh', - 'common.session_code', - 'game.host.title', - 'game.host.start_round', - 'game.player.title', - 'game.player.submit_guess', - ] as const; - - for (const key of baselineKeys) { - const value = i18n.t(key, 'da'); - expect(value).toBeTypeOf('string'); - expect(value.length).toBeGreaterThan(0); - expect(value).not.toBe(key); - } - }); - - it('exposes primary-only audio routing policy to clients', async () => { - vi.stubGlobal('window', { - location: { search: '' }, - localStorage: storageMock({ 'wpp.locale': 'en' }), - }); - vi.stubGlobal('navigator', { language: 'en-US' }); - - const i18n = await import('./lobby-i18n'); - - expect(i18n.clientHasNoAudioOutput).toBe(true); - }); -}); diff --git a/frontend/angular/src/app/lobby-i18n.ts b/frontend/angular/src/app/lobby-i18n.ts deleted file mode 100644 index 698184a..0000000 --- a/frontend/angular/src/app/lobby-i18n.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { - DEFAULT_LOCALE, - LOBBY_I18N_CATALOG, - normalizeLocale, - type SupportedLocale, - translateCatalogPath, -} from '../../../shared/i18n/lobby-loader'; - -let activeLocale: SupportedLocale | null = null; -const localeSubscribers = new Set<(locale: SupportedLocale) => void>(); - -export { normalizeLocale }; - -export function resolvePreferredLocale(): SupportedLocale { - if (activeLocale) { - return activeLocale; - } - - if (typeof window === 'undefined') { - activeLocale = DEFAULT_LOCALE; - return activeLocale; - } - - const rootLocale = - typeof document !== 'undefined' ? document.querySelector('app-root')?.dataset?.['wppLocale'] : null; - const shellLocale = typeof document !== 'undefined' ? document.body?.dataset?.['wppLocale'] : null; - const queryLocale = new URLSearchParams(window.location?.search ?? '').get('lang'); - const storedLocale = window.localStorage?.getItem?.('wpp.locale'); - const browserLocale = typeof navigator !== 'undefined' ? navigator.language : ''; - - activeLocale = normalizeLocale(rootLocale || shellLocale || queryLocale || storedLocale || browserLocale || DEFAULT_LOCALE); - return activeLocale; -} - -export function setPreferredLocale(locale: string): SupportedLocale { - const normalized = normalizeLocale(locale); - activeLocale = normalized; - if (typeof window !== 'undefined') { - window.localStorage?.setItem?.('wpp.locale', normalized); - } - - for (const subscriber of localeSubscribers) { - subscriber(normalized); - } - - return normalized; -} - -export function subscribeToLocaleChanges(callback: (locale: SupportedLocale) => void): () => void { - localeSubscribers.add(callback); - callback(resolvePreferredLocale()); - return () => { - localeSubscribers.delete(callback); - }; -} - -function resolveCatalogPath(key: string): string { - if (key.startsWith('lobby.shell.')) { - return key.replace(/^lobby\.shell\./, 'app.'); - } - if (key.startsWith('game.host.')) { - return key.replace(/^game\.host\./, 'host.'); - } - if (key.startsWith('game.player.')) { - return key.replace(/^game\.player\./, 'player.'); - } - return key; -} - -export function t(key: string, locale: string): string { - return translateCatalogPath(LOBBY_I18N_CATALOG.frontend.ui as Record, resolveCatalogPath(key), locale); -} - -export const clientHasNoAudioOutput = Boolean(LOBBY_I18N_CATALOG.frontend.capabilities.client_has_no_audio_output); diff --git a/frontend/angular/src/app/session-route-context.spec.ts b/frontend/angular/src/app/session-route-context.spec.ts deleted file mode 100644 index ee834df..0000000 --- a/frontend/angular/src/app/session-route-context.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { hostRouteContextResolver, playerRouteContextResolver, resolveSessionCode } from './session-route-context'; - -type RouteLike = { - paramMap: { get: (key: string) => string | null }; - queryParamMap: { get: (key: string) => string | null }; -}; - -function route(params: Record, query: Record = {}): RouteLike { - return { - paramMap: { get: (key: string) => params[key] ?? null }, - queryParamMap: { get: (key: string) => query[key] ?? null }, - }; -} - -function storageMock(initial: Record = {}): Storage { - const data = new Map(Object.entries(initial)); - - return { - getItem: vi.fn((key: string) => data.get(key) ?? null), - setItem: vi.fn((key: string, value: string) => { - data.set(key, value); - }), - removeItem: vi.fn((key: string) => { - data.delete(key); - }), - clear: vi.fn(() => { - data.clear(); - }), - key: vi.fn((index: number) => Array.from(data.keys())[index] ?? null), - get length() { - return data.size; - }, - } as unknown as Storage; -} - -function setWindow(localStorage: Storage, sessionStorage: Storage): void { - vi.stubGlobal('window', { localStorage, sessionStorage }); -} - -describe('session route context', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('resolves player code from persisted session context when route has no code', () => { - setWindow( - storageMock({ 'wpp.session-context': JSON.stringify({ sessionCode: 'ab12', playerId: 7, token: 'tok' }) }), - storageMock() - ); - - expect(resolveSessionCode(route({}, {}) as never, 'player')).toBe('AB12'); - }); - - it('resolves host code from session query string', () => { - setWindow(storageMock(), storageMock()); - - expect(resolveSessionCode(route({}, { session: 'qwe9' }) as never, 'host')).toBe('QWE9'); - }); - - it('player resolver emits player id/token when context matches route session', () => { - setWindow( - storageMock({ 'wpp.session-context': JSON.stringify({ sessionCode: 'AB12', playerId: 5, token: 'tok-5' }) }), - storageMock() - ); - - expect(playerRouteContextResolver(route({ context: 'ab12' }) as never, {} as never)).toEqual({ - sessionCode: 'AB12', - playerId: 5, - token: 'tok-5', - locale: 'en', - }); - }); - - it('host resolver stores normalized host session code for refresh bootstrap', () => { - const sessionStorage = storageMock(); - setWindow(storageMock(), sessionStorage); - - expect(hostRouteContextResolver(route({ context: 'ab12' }) as never, {} as never)).toEqual({ - sessionCode: 'AB12', - playerId: null, - token: null, - locale: 'en', - }); - expect(sessionStorage.setItem).toHaveBeenCalledWith('wpp.host-session-code', 'AB12'); - }); - - it('resolvers normalize and expose locale from lang query param', () => { - setWindow(storageMock(), storageMock()); - - expect(hostRouteContextResolver(route({}, { lang: 'da-DK' }) as never, {} as never).locale).toBe('da'); - expect(playerRouteContextResolver(route({}, { lang: 'EN' }) as never, {} as never).locale).toBe('en'); - }); - - it('does not reset persisted preferred locale when lang query param is absent', () => { - const localStorage = storageMock({ 'wpp.locale': 'da' }); - setWindow(localStorage, storageMock()); - - expect(hostRouteContextResolver(route({}, { lang: 'da' }) as never, {} as never).locale).toBe('da'); - expect(hostRouteContextResolver(route({}, {}) as never, {} as never).locale).toBe('da'); - expect(localStorage.setItem).toHaveBeenCalledTimes(1); - expect(localStorage.setItem).toHaveBeenCalledWith('wpp.locale', 'da'); - }); -}); diff --git a/frontend/angular/src/app/session-route-context.ts b/frontend/angular/src/app/session-route-context.ts deleted file mode 100644 index 64240c4..0000000 --- a/frontend/angular/src/app/session-route-context.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { inject } from '@angular/core'; -import { type ActivatedRouteSnapshot, type CanActivateFn, type ResolveFn, Router, type UrlTree } from '@angular/router'; - -import { createSessionContextStore } from '../../../src/spa/session-context-store'; -import { normalizeLocale, resolvePreferredLocale, setPreferredLocale } from './lobby-i18n'; - -export interface RouteSessionContext { - sessionCode: string | null; - playerId: number | null; - token: string | null; - locale: string; -} - -const HOST_STORAGE_KEY = 'wpp.host-session-code'; - -function normalizeCode(value: string): string { - return value.trim().toUpperCase(); -} - -function isCodeLike(value: string | null | undefined): value is string { - return !!value && /^[A-Za-z0-9]{4,12}$/.test(value.trim()); -} - -function hasPlayerSessionContext(sessionCode: string): boolean { - const context = createSessionContextStore(window.localStorage).get(); - if (!context) { - return false; - } - - return ( - isCodeLike(context.sessionCode) && - normalizeCode(context.sessionCode) === normalizeCode(sessionCode) && - Number.isInteger(context.playerId) && - context.playerId > 0 && - !!context.token.trim() - ); -} - -export function resolveSessionCode(route: ActivatedRouteSnapshot, mode: 'host' | 'player'): string | null { - const contextParam = route.paramMap.get('context'); - const queryCode = route.queryParamMap.get('session'); - - if (isCodeLike(contextParam)) { - return normalizeCode(contextParam); - } - if (isCodeLike(queryCode)) { - return normalizeCode(queryCode); - } - - if (mode === 'player') { - const persisted = createSessionContextStore(window.localStorage).get()?.sessionCode; - if (isCodeLike(persisted)) { - return normalizeCode(persisted); - } - return null; - } - - const stored = window.sessionStorage.getItem(HOST_STORAGE_KEY); - if (isCodeLike(stored)) { - return normalizeCode(stored); - } - - return null; -} - -function resolveRouteLocale(route: ActivatedRouteSnapshot): string { - const langParam = route.queryParamMap.get('lang'); - if (langParam !== null) { - const locale = normalizeLocale(langParam); - setPreferredLocale(locale); - return locale; - } - - return resolvePreferredLocale(); -} - -async function sessionExists(code: string): Promise { - const response = await fetch(`/lobby/sessions/${encodeURIComponent(code)}`, { - method: 'GET', - headers: { Accept: 'application/json' }, - credentials: 'same-origin', - }); - - return response.ok; -} - -async function requireSessionContext(route: ActivatedRouteSnapshot, mode: 'host' | 'player'): Promise { - const phase = route.paramMap.get('phase'); - const code = resolveSessionCode(route, mode); - - if (!phase) { - if (mode === 'host' && code) { - window.sessionStorage.setItem(HOST_STORAGE_KEY, code); - } - return true; - } - - if (!code) { - return false; - } - - if (mode === 'player' && !hasPlayerSessionContext(code)) { - return false; - } - - const ok = await sessionExists(code); - if (!ok) { - return false; - } - - if (mode === 'host') { - window.sessionStorage.setItem(HOST_STORAGE_KEY, code); - } - - return true; -} - -async function guard(mode: 'host' | 'player', route: ActivatedRouteSnapshot): Promise { - const router = inject(Router); - const allowed = await requireSessionContext(route, mode); - if (allowed) { - return true; - } - - return router.createUrlTree([`/${mode}`]); -} - -export const hostRouteGuard: CanActivateFn = (route) => guard('host', route); - -export const playerRouteGuard: CanActivateFn = (route) => guard('player', route); - -export const hostRouteContextResolver: ResolveFn = (route) => { - const code = resolveSessionCode(route, 'host'); - const locale = resolveRouteLocale(route); - if (code) { - window.sessionStorage.setItem(HOST_STORAGE_KEY, code); - } - return { sessionCode: code, playerId: null, token: null, locale }; -}; - -export const playerRouteContextResolver: ResolveFn = (route) => { - const code = resolveSessionCode(route, 'player'); - const locale = resolveRouteLocale(route); - const context = createSessionContextStore(window.localStorage).get(); - - if (!code || !context || normalizeCode(context.sessionCode) !== code) { - return { sessionCode: code, playerId: null, token: null, locale }; - } - - return { - sessionCode: code, - playerId: Number.isInteger(context.playerId) && context.playerId > 0 ? context.playerId : null, - token: context.token.trim() || null, - locale, - }; -}; diff --git a/frontend/angular/src/app/wpp-api-client.spec.ts b/frontend/angular/src/app/wpp-api-client.spec.ts deleted file mode 100644 index 45f21fa..0000000 --- a/frontend/angular/src/app/wpp-api-client.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { createWppApiClient } from './wpp-api-client'; - -function jsonResponse(status: number, body: unknown) { - return { - ok: status >= 200 && status < 300, - status, - json: vi.fn().mockResolvedValue(body), - } as unknown as Response; -} - -describe('WPP Angular API client skeleton', () => { - it('normalizes host/player API calls through fetch transport', async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 1 }, players: [], round_question: null, phase_view_model: { status: 'lobby', round_number: 1, players_count: 1, constraints: { min_players_to_start: 2, max_players_mvp: 8, min_players_reached: false, max_players_allowed: true }, host: { can_start_round: false, can_show_question: false, can_mix_answers: false, can_calculate_scores: false, can_reveal_scoreboard: false, can_start_next_round: false, can_finish_game: false }, player: { can_join: true, can_submit_lie: false, can_submit_guess: false, can_view_final_result: false } } })) - .mockResolvedValueOnce(jsonResponse(201, { player: { id: 1, nickname: 'Luna', session_token: 'tok', score: 0 }, session: { code: 'ABCD12', status: 'lobby' } })); - - const client = createWppApiClient(fetchMock); - - const session = await client.getSession(' abcd12 '); - const joined = await client.joinSession({ code: ' abcd12 ', nickname: ' Luna ' }); - - expect(session.ok).toBe(true); - expect(joined.ok).toBe(true); - - expect(fetchMock).toHaveBeenNthCalledWith( - 1, - '/lobby/sessions/ABCD12', - expect.objectContaining({ method: 'GET', credentials: 'same-origin' }) - ); - expect(fetchMock).toHaveBeenNthCalledWith( - 2, - '/lobby/sessions/join', - expect.objectContaining({ - method: 'POST', - credentials: 'same-origin', - body: JSON.stringify({ code: 'ABCD12', nickname: 'Luna' }), - }) - ); - }); -}); diff --git a/frontend/angular/src/app/wpp-api-client.ts b/frontend/angular/src/app/wpp-api-client.ts deleted file mode 100644 index 973f10d..0000000 --- a/frontend/angular/src/app/wpp-api-client.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { InjectionToken } from '@angular/core'; - -import { - createAngularApiClient, - type AngularApiClient, - type AngularHttpClientLike, -} from '../../../src/api/angular-client'; - -export const WPP_API_CLIENT = new InjectionToken('WPP_API_CLIENT'); - -export interface FetchLike { - (input: string, init?: RequestInit): Promise; -} - -export function createFetchHttpClient(fetchImpl: FetchLike): AngularHttpClientLike { - return { - async get(url: string): Promise { - const response = await fetchImpl(url, { - method: 'GET', - headers: { Accept: 'application/json' }, - credentials: 'same-origin', - }); - const payload = await response.json().catch(() => ({})); - if (!response.ok) { - throw { - status: response.status, - message: (payload as { error?: string }).error ?? `HTTP ${response.status}`, - error: payload, - }; - } - return payload as T; - }, - async post(url: string, body: unknown): Promise { - const response = await fetchImpl(url, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - credentials: 'same-origin', - }); - const payload = await response.json().catch(() => ({})); - if (!response.ok) { - throw { - status: response.status, - message: (payload as { error?: string }).error ?? `HTTP ${response.status}`, - error: payload, - }; - } - return payload as T; - }, - }; -} - -export function createWppApiClient(fetchImpl: FetchLike = fetch.bind(globalThis)): AngularApiClient { - return createAngularApiClient(createFetchHttpClient(fetchImpl)); -} diff --git a/frontend/angular/src/index.html b/frontend/angular/src/index.html deleted file mode 100644 index 7a59237..0000000 --- a/frontend/angular/src/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - WPP Angular Shell - - - - - - - diff --git a/frontend/angular/src/main.ts b/frontend/angular/src/main.ts deleted file mode 100644 index 26ed75e..0000000 --- a/frontend/angular/src/main.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { bootstrapApplication } from '@angular/platform-browser'; -import { AppComponent } from './app/app.component'; -import { appConfig } from './app/app.config'; - -bootstrapApplication(AppComponent, appConfig).catch((error) => { - console.error(error); -}); diff --git a/frontend/angular/src/styles.css b/frontend/angular/src/styles.css deleted file mode 100644 index 57c14ee..0000000 --- a/frontend/angular/src/styles.css +++ /dev/null @@ -1,4 +0,0 @@ -html, body { - margin: 0; - padding: 0; -} diff --git a/frontend/angular/src/test-setup.ts b/frontend/angular/src/test-setup.ts deleted file mode 100644 index 36563d6..0000000 --- a/frontend/angular/src/test-setup.ts +++ /dev/null @@ -1 +0,0 @@ -import '@angular/compiler'; diff --git a/frontend/angular/tsconfig.app.json b/frontend/angular/tsconfig.app.json deleted file mode 100644 index 5b9d3c5..0000000 --- a/frontend/angular/tsconfig.app.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./out-tsc/app", - "types": [] - }, - "files": ["src/main.ts"], - "include": ["src/**/*.d.ts"] -} diff --git a/frontend/angular/tsconfig.json b/frontend/angular/tsconfig.json deleted file mode 100644 index 74c39c1..0000000 --- a/frontend/angular/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compileOnSave": false, - "compilerOptions": { - "strict": true, - "skipLibCheck": true, - "isolatedModules": true, - "esModuleInterop": true, - "sourceMap": true, - "declaration": false, - "experimentalDecorators": true, - "moduleResolution": "bundler", - "importHelpers": true, - "target": "ES2022", - "module": "ES2022", - "lib": ["ES2022", "dom"] - }, - "angularCompilerOptions": { - "strictInjectionParameters": true, - "strictInputAccessModifiers": true, - "strictTemplates": true - } -} diff --git a/frontend/angular/vitest.config.ts b/frontend/angular/vitest.config.ts deleted file mode 100644 index 9478b8b..0000000 --- a/frontend/angular/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - include: ['src/**/*.spec.ts'], - setupFiles: ['src/test-setup.ts'], - }, -}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json deleted file mode 100644 index 80e5174..0000000 --- a/frontend/package-lock.json +++ /dev/null @@ -1,1588 +0,0 @@ -{ - "name": "wpp-frontend-api-client-baseline", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "wpp-frontend-api-client-baseline", - "version": "0.1.0", - "dependencies": { - "@angular/common": "^19.2.0", - "@angular/compiler": "^19.2.0", - "@angular/core": "^19.2.0", - "@angular/forms": "^19.2.0", - "@angular/platform-browser": "^19.2.0", - "@angular/router": "^19.2.0", - "rxjs": "~7.8.0", - "tslib": "^2.3.0", - "zone.js": "~0.15.0" - }, - "devDependencies": { - "@types/node": "^22.13.10", - "typescript": "^5.7.3", - "vitest": "^2.1.9" - } - }, - "node_modules/@angular/common": { - "version": "19.2.20", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.20.tgz", - "integrity": "sha512-1M3W3FjUUbVKXDMs+yQpBhnkD/pCe0Jn79rPE5W+EGWWxFoLSyGX+fhnRO5m4c9k66p3nvYrikWQ0ZzMv3M5tw==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "@angular/core": "19.2.20", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@angular/compiler": { - "version": "19.2.20", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.20.tgz", - "integrity": "sha512-LvjE8W58EACgTFaAoqmNe7FRsbvoQ0GvCB/rmm6AEMWx/0W/JBvWkQTrOQlwpoeYOHcMZRGdmPcZoUDwU3JySQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - } - }, - "node_modules/@angular/core": { - "version": "19.2.20", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.20.tgz", - "integrity": "sha512-pxzQh8ouqfE57lJlXjIzXFuRETwkfMVwS+NFCfv2yh01Qtx+vymO8ZClcJMgLPfBYinhBYX+hrRYVSa1nzlkRQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "rxjs": "^6.5.3 || ^7.4.0", - "zone.js": "~0.15.0" - } - }, - "node_modules/@angular/forms": { - "version": "19.2.20", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.20.tgz", - "integrity": "sha512-agi7InbMzop1jrud6L7SlNwnZk3iNolORcFIwBQMvKxLkcJ+ttbSYuM0KAw56IundWHf4dL9GP4cSygm4kUeFA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "@angular/common": "19.2.20", - "@angular/core": "19.2.20", - "@angular/platform-browser": "19.2.20", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@angular/platform-browser": { - "version": "19.2.20", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.20.tgz", - "integrity": "sha512-O9ZoQKILPC1T2c64OASS75XlOLBxY81m5AAgsBKhwiFWq+V28RsO0cnwpi1YSh/z4ryH8Fe7IUFz8jGrsJi3hQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "@angular/animations": "19.2.20", - "@angular/common": "19.2.20", - "@angular/core": "19.2.20" - }, - "peerDependenciesMeta": { - "@angular/animations": { - "optional": true - } - } - }, - "node_modules/@angular/router": { - "version": "19.2.20", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-19.2.20.tgz", - "integrity": "sha512-y0fyKycxJHr82kxXKE50Vac5hPn5Kx3gw9CfqyEuwJ9VQzEixDljU+chrQK4Wods14jJn9Tt2ncNPGH1rLya3Q==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "@angular/common": "19.2.20", - "@angular/core": "19.2.20", - "@angular/platform-browser": "19.2.20", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.19.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", - "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "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" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "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 - } - } - }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@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.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "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 - } - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/zone.js": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", - "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", - "license": "MIT" - } - } -} diff --git a/frontend/package.json b/frontend/package.json deleted file mode 100644 index ba1dfc7..0000000 --- a/frontend/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "wpp-frontend-api-client-baseline", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "test": "vitest run", - "build": "tsc --noEmit" - }, - "dependencies": { - "@angular/common": "^19.2.0", - "@angular/compiler": "^19.2.0", - "@angular/core": "^19.2.0", - "@angular/forms": "^19.2.0", - "@angular/platform-browser": "^19.2.0", - "@angular/router": "^19.2.0", - "rxjs": "~7.8.0", - "tslib": "^2.3.0", - "zone.js": "~0.15.0" - }, - "devDependencies": { - "@types/node": "^22.13.10", - "typescript": "^5.7.3", - "vitest": "^2.1.9" - } -} diff --git a/frontend/shared/i18n/lobby-loader.ts b/frontend/shared/i18n/lobby-loader.ts deleted file mode 100644 index 7b4579d..0000000 --- a/frontend/shared/i18n/lobby-loader.ts +++ /dev/null @@ -1,74 +0,0 @@ -import lobbyCatalog from '../../../shared/i18n/lobby.json'; - -export type LobbyCatalog = typeof lobbyCatalog; -export type SupportedLocale = LobbyCatalog['locales']['supported'][number]; - -export const LOBBY_I18N_CATALOG = lobbyCatalog; -export const DEFAULT_LOCALE = lobbyCatalog.locales.default as SupportedLocale; -export const SUPPORTED_LOCALES = lobbyCatalog.locales.supported as readonly SupportedLocale[]; - -export function normalizeLocale(rawLocale?: string | null): SupportedLocale { - const locale = (rawLocale ?? '').trim().toLowerCase().replace(/_/g, '-'); - if ((SUPPORTED_LOCALES as readonly string[]).includes(locale)) { - return locale as SupportedLocale; - } - - const shortLocale = locale.split('-')[0] ?? ''; - if ((SUPPORTED_LOCALES as readonly string[]).includes(shortLocale)) { - return shortLocale as SupportedLocale; - } - - return DEFAULT_LOCALE; -} - -export function translateCatalogPath( - root: Record, - keyPath: string, - locale: string, - fallback = DEFAULT_LOCALE, -): string { - const normalizedLocale = normalizeLocale(locale); - const segments = keyPath.split('.'); - - let cursor: unknown = root; - for (const segment of segments) { - if (!cursor || typeof cursor !== 'object' || !(segment in (cursor as Record))) { - return keyPath; - } - cursor = (cursor as Record)[segment]; - } - - if (!cursor || typeof cursor !== 'object') { - return keyPath; - } - - const translations = cursor as Record; - return translations[normalizedLocale] ?? translations[fallback] ?? keyPath; -} - -export function collectLocaleParityIssues( - node: unknown, - locales: readonly string[] = SUPPORTED_LOCALES, - path = '', -): string[] { - if (!node || typeof node !== 'object') { - return []; - } - - const record = node as Record; - const keys = Object.keys(record); - const isTranslationLeaf = keys.length > 0 && locales.every((locale) => locale in record); - - if (isTranslationLeaf) { - const issues: string[] = []; - for (const locale of locales) { - const value = record[locale]; - if (typeof value !== 'string' || !value.trim()) { - issues.push(`${path || ''} missing non-empty '${locale}' translation`); - } - } - return issues; - } - - return keys.flatMap((key) => collectLocaleParityIssues(record[key], locales, path ? `${path}.${key}` : key)); -} diff --git a/frontend/src/api/angular-client.ts b/frontend/src/api/angular-client.ts deleted file mode 100644 index 84686b1..0000000 --- a/frontend/src/api/angular-client.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { - mapCalculateScoresResponse, - mapFinishGameResponse, - mapHealthResponse, - mapJoinSessionResponse, - mapMixAnswersResponse, - mapScoreboardResponse, - mapSessionDetailResponse, - mapShowQuestionResponse, - mapStartNextRoundResponse, - mapStartRoundResponse, - mapSubmitGuessResponse, - mapSubmitLieResponse -} from './mappers'; -import type { - ApiFailure, - ApiResult, - CalculateScoresResponse, - FinishGameResponse, - HealthResponse, - JoinSessionRequest, - JoinSessionResponse, - MixAnswersResponse, - ScoreboardResponse, - SessionDetailResponse, - ShowQuestionResponse, - StartNextRoundResponse, - StartRoundRequest, - StartRoundResponse, - SubmitGuessRequest, - SubmitGuessResponse, - SubmitLieRequest, - SubmitLieResponse -} from './types'; - -export interface AngularHttpError { - status?: number; - message?: string; - error?: unknown; -} - -export interface AngularHttpClientLike { - get(url: string, options?: { withCredentials?: boolean }): Promise; - post(url: string, body: unknown, options?: { withCredentials?: boolean }): Promise; -} - -export interface AngularApiClient { - health(): Promise>; - getSession(code: string): Promise>; - joinSession(payload: JoinSessionRequest): Promise>; - startRound(code: string, payload: StartRoundRequest): Promise>; - showQuestion(code: string): Promise>; - mixAnswers(code: string, roundQuestionId: number): Promise>; - calculateScores(code: string, roundQuestionId: number): Promise>; - getScoreboard(code: string): Promise>; - startNextRound(code: string): Promise>; - finishGame(code: string): Promise>; - submitLie(code: string, roundQuestionId: number, payload: SubmitLieRequest): Promise>; - submitGuess( - code: string, - roundQuestionId: number, - payload: SubmitGuessRequest - ): Promise>; -} - -function toFailure(error: unknown): ApiFailure { - const candidate = (error ?? {}) as AngularHttpError; - const status = typeof candidate.status === 'number' ? candidate.status : 0; - const payload = candidate.error; - - if (status === 0) { - return { - kind: 'network', - status: 0, - message: candidate.message ?? 'Network error while contacting API' - }; - } - - return { - kind: 'http', - status, - message: candidate.message ?? `HTTP ${status}`, - ...(payload === undefined ? {} : { payload }) - }; -} - -function normalizeCode(code: string): string { - return code.trim().toUpperCase(); -} - -function normalizeBaseUrl(baseUrl: string): string { - return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; -} - -function buildUrl(baseUrl: string, path: string): string { - return `${normalizeBaseUrl(baseUrl)}${path}`; -} - -async function wrap(call: () => Promise, mapper: (payload: unknown) => T): Promise> { - let payload: unknown; - try { - payload = await call(); - } catch (error: unknown) { - return { - ok: false, - status: typeof (error as AngularHttpError)?.status === 'number' ? (error as AngularHttpError).status! : 0, - error: toFailure(error) - }; - } - - try { - return { ok: true, status: 200, data: mapper(payload) }; - } catch (error: unknown) { - return { - ok: false, - status: 200, - error: { - kind: 'parse', - status: 200, - message: error instanceof Error ? error.message : 'Invalid API response contract', - payload - } - }; - } -} - -export function createAngularApiClient(http: AngularHttpClientLike, baseUrl = ''): AngularApiClient { - return { - health: () => - wrap(() => http.get(buildUrl(baseUrl, '/healthz'), { withCredentials: true }), mapHealthResponse), - getSession: (code: string) => - wrap( - () => - http.get(buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`), { - withCredentials: true - }), - mapSessionDetailResponse - ), - joinSession: (payload: JoinSessionRequest) => - wrap( - () => - http.post( - buildUrl(baseUrl, '/lobby/sessions/join'), - { - code: normalizeCode(payload.code), - nickname: payload.nickname.trim() - }, - { withCredentials: true } - ), - mapJoinSessionResponse - ), - startRound: (code: string, payload: StartRoundRequest) => - wrap( - () => - http.post( - buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/start`), - payload, - { withCredentials: true } - ), - mapStartRoundResponse - ), - showQuestion: (code: string) => - wrap( - () => - http.post( - buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/show`), - {}, - { withCredentials: true } - ), - mapShowQuestionResponse - ), - mixAnswers: (code: string, roundQuestionId: number) => - wrap( - () => - http.post( - buildUrl( - baseUrl, - `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/answers/mix` - ), - {}, - { withCredentials: true } - ), - mapMixAnswersResponse - ), - calculateScores: (code: string, roundQuestionId: number) => - wrap( - () => - http.post( - buildUrl( - baseUrl, - `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/scores/calculate` - ), - {}, - { withCredentials: true } - ), - mapCalculateScoresResponse - ), - getScoreboard: (code: string) => - wrap( - () => - http.get( - buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/scoreboard`), - { withCredentials: true } - ), - mapScoreboardResponse - ), - startNextRound: (code: string) => - wrap( - () => - http.post( - buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/next`), - {}, - { withCredentials: true } - ), - mapStartNextRoundResponse - ), - finishGame: (code: string) => - wrap( - () => - http.post( - buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/finish`), - {}, - { withCredentials: true } - ), - mapFinishGameResponse - ), - submitLie: (code: string, roundQuestionId: number, payload: SubmitLieRequest) => - wrap( - () => - http.post( - buildUrl( - baseUrl, - `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/lies/submit` - ), - { - player_id: payload.player_id, - session_token: payload.session_token, - text: payload.text - }, - { withCredentials: true } - ), - mapSubmitLieResponse - ), - submitGuess: (code: string, roundQuestionId: number, payload: SubmitGuessRequest) => - wrap( - () => - http.post( - buildUrl( - baseUrl, - `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/guesses/submit` - ), - { - player_id: payload.player_id, - session_token: payload.session_token, - selected_text: payload.selected_text - }, - { withCredentials: true } - ), - mapSubmitGuessResponse - ) - }; -} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts deleted file mode 100644 index 02b3775..0000000 --- a/frontend/src/api/client.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { - mapCalculateScoresResponse, - mapFinishGameResponse, - mapHealthResponse, - mapJoinSessionResponse, - mapMixAnswersResponse, - mapScoreboardResponse, - mapSessionDetailResponse, - mapShowQuestionResponse, - mapStartNextRoundResponse, - mapStartRoundResponse, - mapSubmitGuessResponse, - mapSubmitLieResponse -} from './mappers'; -import type { - ApiResult, - CalculateScoresResponse, - FinishGameResponse, - HealthResponse, - JoinSessionRequest, - JoinSessionResponse, - MixAnswersResponse, - ScoreboardResponse, - SessionDetailResponse, - ShowQuestionResponse, - StartNextRoundResponse, - StartRoundRequest, - StartRoundResponse, - SubmitGuessRequest, - SubmitGuessResponse, - SubmitLieRequest, - SubmitLieResponse -} from './types'; - -export interface ApiClient { - health(): Promise>; - getSession(code: string): Promise>; - joinSession(payload: JoinSessionRequest): Promise>; - startRound(code: string, payload: StartRoundRequest): Promise>; - showQuestion(code: string): Promise>; - mixAnswers(code: string, roundQuestionId: number): Promise>; - calculateScores(code: string, roundQuestionId: number): Promise>; - getScoreboard(code: string): Promise>; - startNextRound(code: string): Promise>; - finishGame(code: string): Promise>; - submitLie(code: string, roundQuestionId: number, payload: SubmitLieRequest): Promise>; - submitGuess(code: string, roundQuestionId: number, payload: SubmitGuessRequest): Promise>; -} - -export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): ApiClient { - async function request( - path: string, - method: 'GET' | 'POST', - mapper: (payload: unknown) => T, - payload?: unknown - ): Promise> { - let response: Response; - try { - response = await fetchImpl(`${baseUrl}${path}`, { - method, - headers: { - Accept: 'application/json', - ...(payload === undefined ? {} : { 'Content-Type': 'application/json' }) - }, - ...(payload === undefined ? {} : { body: JSON.stringify(payload) }) - }); - } catch { - return { - ok: false, - status: 0, - error: { kind: 'network', status: 0, message: 'Network error while contacting API' } - }; - } - - let responsePayload: unknown; - try { - responsePayload = await response.json(); - } catch { - return { - ok: false, - status: response.status, - error: { kind: 'parse', status: response.status, message: 'Invalid JSON response from API' } - }; - } - - if (!response.ok) { - return { - ok: false, - status: response.status, - error: { - kind: 'http', - status: response.status, - message: `HTTP ${response.status}`, - payload: responsePayload - } - }; - } - - try { - return { ok: true, status: response.status, data: mapper(responsePayload) }; - } catch (error) { - return { - ok: false, - status: response.status, - error: { - kind: 'parse', - status: response.status, - message: error instanceof Error ? error.message : 'Invalid API response contract', - payload: responsePayload - } - }; - } - } - - const normalizeCode = (value: string): string => value.trim().toUpperCase(); - - return { - health: () => request('/healthz', 'GET', mapHealthResponse), - getSession: (code: string) => - request( - `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`, - 'GET', - mapSessionDetailResponse - ), - joinSession: (payload: JoinSessionRequest) => - request( - '/lobby/sessions/join', - 'POST', - mapJoinSessionResponse, - { - code: normalizeCode(payload.code), - nickname: payload.nickname.trim() - } - ), - startRound: (code: string, payload: StartRoundRequest) => - request( - `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/start`, - 'POST', - mapStartRoundResponse, - payload - ), - showQuestion: (code: string) => - request( - `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/show`, - 'POST', - mapShowQuestionResponse, - {} - ), - mixAnswers: (code: string, roundQuestionId: number) => - request( - `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/answers/mix`, - 'POST', - mapMixAnswersResponse, - {} - ), - calculateScores: (code: string, roundQuestionId: number) => - request( - `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/scores/calculate`, - 'POST', - mapCalculateScoresResponse, - {} - ), - getScoreboard: (code: string) => - request( - `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/scoreboard`, - 'GET', - mapScoreboardResponse - ), - startNextRound: (code: string) => - request( - `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/next`, - 'POST', - mapStartNextRoundResponse, - {} - ), - finishGame: (code: string) => - request( - `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/finish`, - 'POST', - mapFinishGameResponse, - {} - ), - submitLie: (code: string, roundQuestionId: number, payload: SubmitLieRequest) => - request( - `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/lies/submit`, - 'POST', - mapSubmitLieResponse, - payload - ), - submitGuess: (code: string, roundQuestionId: number, payload: SubmitGuessRequest) => - request( - `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/guesses/submit`, - 'POST', - mapSubmitGuessResponse, - payload - ) - }; -} diff --git a/frontend/src/api/mappers.ts b/frontend/src/api/mappers.ts deleted file mode 100644 index fe7c89d..0000000 --- a/frontend/src/api/mappers.ts +++ /dev/null @@ -1,434 +0,0 @@ -import type { - CalculateScoresResponse, - FinishGameResponse, - HealthResponse, - JoinSessionResponse, - MixAnswersResponse, - ScoreboardResponse, - SessionDetailResponse, - ShowQuestionResponse, - StartNextRoundResponse, - StartRoundResponse, - SubmitGuessResponse, - SubmitLieResponse -} from './types'; - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -function isBoolean(value: unknown): value is boolean { - return typeof value === 'boolean'; -} - -function isNumber(value: unknown): value is number { - return typeof value === 'number' && Number.isFinite(value); -} - -function isString(value: unknown): value is string { - return typeof value === 'string'; -} - -function asRecord(value: unknown, path: string): Record { - if (!isRecord(value)) { - throw new Error(`Invalid API contract: expected object at ${path}`); - } - return value; -} - -function readString(record: Record, key: string, path: string): string { - const value = record[key]; - if (!isString(value)) { - throw new Error(`Invalid API contract: expected string at ${path}.${key}`); - } - return value; -} - -function readNumber(record: Record, key: string, path: string): number { - const value = record[key]; - if (!isNumber(value)) { - throw new Error(`Invalid API contract: expected number at ${path}.${key}`); - } - return value; -} - -function readBoolean(record: Record, key: string, path: string): boolean { - const value = record[key]; - if (!isBoolean(value)) { - throw new Error(`Invalid API contract: expected boolean at ${path}.${key}`); - } - return value; -} - -function readNullableNumber(record: Record, key: string, path: string): number | null { - const value = record[key]; - if (value === undefined || value === null) { - return null; - } - if (!isNumber(value)) { - throw new Error(`Invalid API contract: expected number|null at ${path}.${key}`); - } - return value; -} - -export function mapHealthResponse(payload: unknown): HealthResponse { - const root = asRecord(payload, 'health'); - return { - ok: readBoolean(root, 'ok', 'health'), - service: readString(root, 'service', 'health') - }; -} - -function mapSessionDetail(payload: unknown): SessionDetailResponse { - const root = asRecord(payload, 'session_detail'); - const session = asRecord(root.session, 'session_detail.session'); - const players = root.players; - if (!Array.isArray(players)) { - throw new Error('Invalid API contract: expected array at session_detail.players'); - } - - const roundQuestionRaw = root.round_question; - let roundQuestion: SessionDetailResponse['round_question'] = null; - if (roundQuestionRaw !== null) { - const roundQuestionRecord = asRecord(roundQuestionRaw, 'session_detail.round_question'); - const answersRaw = roundQuestionRecord.answers; - if (!Array.isArray(answersRaw)) { - throw new Error('Invalid API contract: expected array at session_detail.round_question.answers'); - } - - roundQuestion = { - id: readNumber(roundQuestionRecord, 'id', 'session_detail.round_question'), - round_number: readNumber(roundQuestionRecord, 'round_number', 'session_detail.round_question'), - prompt: readString(roundQuestionRecord, 'prompt', 'session_detail.round_question'), - shown_at: readString(roundQuestionRecord, 'shown_at', 'session_detail.round_question'), - answers: answersRaw.map((answer, index) => { - const answerRecord = asRecord(answer, `session_detail.round_question.answers[${index}]`); - return { text: readString(answerRecord, 'text', `session_detail.round_question.answers[${index}]`) }; - }) - }; - } - - const phase = asRecord(root.phase_view_model, 'session_detail.phase_view_model'); - const constraints = asRecord(phase.constraints, 'session_detail.phase_view_model.constraints'); - const host = asRecord(phase.host, 'session_detail.phase_view_model.host'); - const player = asRecord(phase.player, 'session_detail.phase_view_model.player'); - - const revealRaw = root.reveal; - let reveal: SessionDetailResponse['reveal'] = null; - if (revealRaw !== null && revealRaw !== undefined) { - const revealRecord = asRecord(revealRaw, 'session_detail.reveal'); - const liesRaw = revealRecord.lies; - const guessesRaw = revealRecord.guesses; - if (!Array.isArray(liesRaw)) { - throw new Error('Invalid API contract: expected array at session_detail.reveal.lies'); - } - if (!Array.isArray(guessesRaw)) { - throw new Error('Invalid API contract: expected array at session_detail.reveal.guesses'); - } - - reveal = { - round_question_id: readNumber(revealRecord, 'round_question_id', 'session_detail.reveal'), - round_number: readNumber(revealRecord, 'round_number', 'session_detail.reveal'), - prompt: readString(revealRecord, 'prompt', 'session_detail.reveal'), - correct_answer: readString(revealRecord, 'correct_answer', 'session_detail.reveal'), - lies: liesRaw.map((lie, index) => { - const record = asRecord(lie, `session_detail.reveal.lies[${index}]`); - return { - player_id: readNumber(record, 'player_id', `session_detail.reveal.lies[${index}]`), - nickname: readString(record, 'nickname', `session_detail.reveal.lies[${index}]`), - text: readString(record, 'text', `session_detail.reveal.lies[${index}]`), - created_at: readString(record, 'created_at', `session_detail.reveal.lies[${index}]`) - }; - }), - guesses: guessesRaw.map((guess, index) => { - const path = `session_detail.reveal.guesses[${index}]`; - const record = asRecord(guess, path); - const fooledPlayerId = readNullableNumber(record, 'fooled_player_id', path); - const fooledPlayerNickname = record.fooled_player_nickname; - if (fooledPlayerId === null) { - if (fooledPlayerNickname !== undefined) { - throw new Error(`Invalid API contract: expected ${path}.fooled_player_nickname to be omitted when fooled_player_id is null`); - } - } else if (!isString(fooledPlayerNickname)) { - throw new Error(`Invalid API contract: expected string at ${path}.fooled_player_nickname when fooled_player_id is set`); - } - return { - player_id: readNumber(record, 'player_id', path), - nickname: readString(record, 'nickname', path), - selected_text: readString(record, 'selected_text', path), - is_correct: readBoolean(record, 'is_correct', path), - fooled_player_id: fooledPlayerId, - ...(fooledPlayerNickname === undefined ? {} : { fooled_player_nickname: fooledPlayerNickname }), - created_at: readString(record, 'created_at', path) - }; - }) - }; - } - - return { - session: { - code: readString(session, 'code', 'session_detail.session'), - status: readString(session, 'status', 'session_detail.session'), - host_id: (() => { - const hostId = session.host_id; - if (hostId === null) { - return null; - } - if (!isNumber(hostId)) { - throw new Error('Invalid API contract: expected number|null at session_detail.session.host_id'); - } - return hostId; - })(), - current_round: readNumber(session, 'current_round', 'session_detail.session'), - players_count: readNumber(session, 'players_count', 'session_detail.session') - }, - players: players.map((item, index) => { - const record = asRecord(item, `session_detail.players[${index}]`); - return { - id: readNumber(record, 'id', `session_detail.players[${index}]`), - nickname: readString(record, 'nickname', `session_detail.players[${index}]`), - score: readNumber(record, 'score', `session_detail.players[${index}]`), - is_connected: readBoolean(record, 'is_connected', `session_detail.players[${index}]`) - }; - }), - round_question: roundQuestion, - reveal, - phase_view_model: { - status: readString(phase, 'status', 'session_detail.phase_view_model'), - current_phase: typeof phase.current_phase === 'string' ? phase.current_phase : undefined, - round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'), - players_count: readNumber(phase, 'players_count', 'session_detail.phase_view_model'), - constraints: { - min_players_to_start: readNumber(constraints, 'min_players_to_start', 'session_detail.phase_view_model.constraints'), - max_players_mvp: readNumber(constraints, 'max_players_mvp', 'session_detail.phase_view_model.constraints'), - min_players_reached: readBoolean(constraints, 'min_players_reached', 'session_detail.phase_view_model.constraints'), - max_players_allowed: readBoolean(constraints, 'max_players_allowed', 'session_detail.phase_view_model.constraints') - }, - readiness: - phase.readiness && typeof phase.readiness === 'object' - ? { - question_ready: - typeof (phase.readiness as Record).question_ready === 'boolean' - ? ((phase.readiness as Record).question_ready as boolean) - : undefined, - scoreboard_ready: - typeof (phase.readiness as Record).scoreboard_ready === 'boolean' - ? ((phase.readiness as Record).scoreboard_ready as boolean) - : undefined, - } - : undefined, - host: { - can_start_round: readBoolean(host, 'can_start_round', 'session_detail.phase_view_model.host'), - can_show_question: readBoolean(host, 'can_show_question', 'session_detail.phase_view_model.host'), - can_mix_answers: readBoolean(host, 'can_mix_answers', 'session_detail.phase_view_model.host'), - can_calculate_scores: readBoolean(host, 'can_calculate_scores', 'session_detail.phase_view_model.host'), - can_reveal_scoreboard: readBoolean(host, 'can_reveal_scoreboard', 'session_detail.phase_view_model.host'), - can_start_next_round: readBoolean(host, 'can_start_next_round', 'session_detail.phase_view_model.host'), - can_finish_game: readBoolean(host, 'can_finish_game', 'session_detail.phase_view_model.host') - }, - player: { - can_join: readBoolean(player, 'can_join', 'session_detail.phase_view_model.player'), - can_submit_lie: readBoolean(player, 'can_submit_lie', 'session_detail.phase_view_model.player'), - can_submit_guess: readBoolean(player, 'can_submit_guess', 'session_detail.phase_view_model.player'), - can_view_final_result: readBoolean(player, 'can_view_final_result', 'session_detail.phase_view_model.player') - } - } - }; -} - -export function mapSessionDetailResponse(payload: unknown): SessionDetailResponse { - return mapSessionDetail(payload); -} - -export function mapJoinSessionResponse(payload: unknown): JoinSessionResponse { - const root = asRecord(payload, 'join_session'); - const player = asRecord(root.player, 'join_session.player'); - const session = asRecord(root.session, 'join_session.session'); - - return { - player: { - id: readNumber(player, 'id', 'join_session.player'), - nickname: readString(player, 'nickname', 'join_session.player'), - session_token: readString(player, 'session_token', 'join_session.player'), - score: readNumber(player, 'score', 'join_session.player') - }, - session: { - code: readString(session, 'code', 'join_session.session'), - status: readString(session, 'status', 'join_session.session') - } - }; -} - -export function mapStartRoundResponse(payload: unknown): StartRoundResponse { - const root = asRecord(payload, 'start_round'); - const session = asRecord(root.session, 'start_round.session'); - const round = asRecord(root.round, 'start_round.round'); - const category = asRecord(round.category, 'start_round.round.category'); - - return { - session: { - code: readString(session, 'code', 'start_round.session'), - status: readString(session, 'status', 'start_round.session'), - current_round: readNumber(session, 'current_round', 'start_round.session') - }, - round: { - number: readNumber(round, 'number', 'start_round.round'), - category: { - slug: readString(category, 'slug', 'start_round.round.category'), - name: readString(category, 'name', 'start_round.round.category') - } - } - }; -} - -function mapLeaderboardEntry(payload: unknown, path: string): { id: number; nickname: string; score: number } { - const record = asRecord(payload, path); - return { - id: readNumber(record, 'id', path), - nickname: readString(record, 'nickname', path), - score: readNumber(record, 'score', path) - }; -} - -function mapSessionState(payload: unknown, path: string): { code: string; status: string; current_round: number } { - const session = asRecord(payload, path); - return { - code: readString(session, 'code', path), - status: readString(session, 'status', path), - current_round: readNumber(session, 'current_round', path) - }; -} - -export function mapShowQuestionResponse(payload: unknown): ShowQuestionResponse { - const root = asRecord(payload, 'show_question'); - const roundQuestion = asRecord(root.round_question, 'show_question.round_question'); - const config = asRecord(root.config, 'show_question.config'); - - return { - round_question: { - id: readNumber(roundQuestion, 'id', 'show_question.round_question'), - prompt: readString(roundQuestion, 'prompt', 'show_question.round_question'), - round_number: readNumber(roundQuestion, 'round_number', 'show_question.round_question'), - shown_at: readString(roundQuestion, 'shown_at', 'show_question.round_question'), - lie_deadline_at: readString(roundQuestion, 'lie_deadline_at', 'show_question.round_question') - }, - config: { - lie_seconds: readNumber(config, 'lie_seconds', 'show_question.config') - } - }; -} - -export function mapMixAnswersResponse(payload: unknown): MixAnswersResponse { - const root = asRecord(payload, 'mix_answers'); - const roundQuestion = asRecord(root.round_question, 'mix_answers.round_question'); - const answersRaw = root.answers; - if (!Array.isArray(answersRaw)) { - throw new Error('Invalid API contract: expected array at mix_answers.answers'); - } - - return { - session: mapSessionState(root.session, 'mix_answers.session'), - round_question: { - id: readNumber(roundQuestion, 'id', 'mix_answers.round_question'), - round_number: readNumber(roundQuestion, 'round_number', 'mix_answers.round_question') - }, - answers: answersRaw.map((answer, index) => { - const record = asRecord(answer, `mix_answers.answers[${index}]`); - return { text: readString(record, 'text', `mix_answers.answers[${index}]`) }; - }) - }; -} - -export function mapCalculateScoresResponse(payload: unknown): CalculateScoresResponse { - const root = asRecord(payload, 'calculate_scores'); - const roundQuestion = asRecord(root.round_question, 'calculate_scores.round_question'); - const leaderboardRaw = root.leaderboard; - if (!Array.isArray(leaderboardRaw)) { - throw new Error('Invalid API contract: expected array at calculate_scores.leaderboard'); - } - - return { - session: mapSessionState(root.session, 'calculate_scores.session'), - round_question: { - id: readNumber(roundQuestion, 'id', 'calculate_scores.round_question'), - round_number: readNumber(roundQuestion, 'round_number', 'calculate_scores.round_question') - }, - events_created: readNumber(root, 'events_created', 'calculate_scores'), - leaderboard: leaderboardRaw.map((entry, index) => mapLeaderboardEntry(entry, `calculate_scores.leaderboard[${index}]`)) - }; -} - -export function mapScoreboardResponse(payload: unknown): ScoreboardResponse { - const root = asRecord(payload, 'scoreboard'); - const leaderboardRaw = root.leaderboard; - if (!Array.isArray(leaderboardRaw)) { - throw new Error('Invalid API contract: expected array at scoreboard.leaderboard'); - } - - return { - session: mapSessionState(root.session, 'scoreboard.session'), - leaderboard: leaderboardRaw.map((entry, index) => mapLeaderboardEntry(entry, `scoreboard.leaderboard[${index}]`)) - }; -} - -export function mapStartNextRoundResponse(payload: unknown): StartNextRoundResponse { - const root = asRecord(payload, 'start_next_round'); - return { session: mapSessionState(root.session, 'start_next_round.session') }; -} - -export function mapFinishGameResponse(payload: unknown): FinishGameResponse { - const root = asRecord(payload, 'finish_game'); - const leaderboardRaw = root.leaderboard; - if (!Array.isArray(leaderboardRaw)) { - throw new Error('Invalid API contract: expected array at finish_game.leaderboard'); - } - - const winnerRaw = root.winner; - - return { - session: mapSessionState(root.session, 'finish_game.session'), - winner: winnerRaw === null ? null : mapLeaderboardEntry(winnerRaw, 'finish_game.winner'), - leaderboard: leaderboardRaw.map((entry, index) => mapLeaderboardEntry(entry, `finish_game.leaderboard[${index}]`)) - }; -} - -export function mapSubmitLieResponse(payload: unknown): SubmitLieResponse { - const root = asRecord(payload, 'submit_lie'); - const lie = asRecord(root.lie, 'submit_lie.lie'); - const window = asRecord(root.window, 'submit_lie.window'); - - return { - lie: { - id: readNumber(lie, 'id', 'submit_lie.lie'), - player_id: readNumber(lie, 'player_id', 'submit_lie.lie'), - round_question_id: readNumber(lie, 'round_question_id', 'submit_lie.lie'), - text: readString(lie, 'text', 'submit_lie.lie'), - created_at: readString(lie, 'created_at', 'submit_lie.lie') - }, - window: { - lie_deadline_at: readString(window, 'lie_deadline_at', 'submit_lie.window') - } - }; -} - -export function mapSubmitGuessResponse(payload: unknown): SubmitGuessResponse { - const root = asRecord(payload, 'submit_guess'); - const guess = asRecord(root.guess, 'submit_guess.guess'); - const window = asRecord(root.window, 'submit_guess.window'); - const fooledPlayerId = readNullableNumber(guess, 'fooled_player_id', 'submit_guess.guess'); - - return { - guess: { - id: readNumber(guess, 'id', 'submit_guess.guess'), - player_id: readNumber(guess, 'player_id', 'submit_guess.guess'), - round_question_id: readNumber(guess, 'round_question_id', 'submit_guess.guess'), - selected_text: readString(guess, 'selected_text', 'submit_guess.guess'), - is_correct: readBoolean(guess, 'is_correct', 'submit_guess.guess'), - fooled_player_id: fooledPlayerId, - created_at: readString(guess, 'created_at', 'submit_guess.guess') - }, - window: { - guess_deadline_at: readString(window, 'guess_deadline_at', 'submit_guess.window') - } - }; -} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts deleted file mode 100644 index 926f28a..0000000 --- a/frontend/src/api/types.ts +++ /dev/null @@ -1,254 +0,0 @@ -export interface HealthResponse { - ok: boolean; - service: string; -} - -export interface SessionSummary { - code: string; - status: string; - host_id: number | null; - current_round: number; - players_count: number; -} - -export interface SessionPlayer { - id: number; - nickname: string; - score: number; - is_connected: boolean; -} - -export interface SessionAnswer { - text: string; -} - -export interface SessionRoundQuestion { - id: number; - round_number: number; - prompt: string; - shown_at: string; - answers: SessionAnswer[]; -} - -export interface PhaseViewModel { - status: string; - current_phase?: string; - round_number: number; - players_count: number; - constraints: { - min_players_to_start: number; - max_players_mvp: number; - min_players_reached: boolean; - max_players_allowed: boolean; - }; - readiness?: { - question_ready?: boolean; - scoreboard_ready?: boolean; - }; - host: { - can_start_round: boolean; - can_show_question: boolean; - can_mix_answers: boolean; - can_calculate_scores: boolean; - can_reveal_scoreboard: boolean; - can_start_next_round: boolean; - can_finish_game: boolean; - }; - player: { - can_join: boolean; - can_submit_lie: boolean; - can_submit_guess: boolean; - can_view_final_result: boolean; - }; -} - -export interface RevealLie { - player_id: number; - nickname: string; - text: string; - created_at: string; -} - -export interface RevealGuess { - player_id: number; - nickname: string; - selected_text: string; - is_correct: boolean; - fooled_player_id: number | null; - fooled_player_nickname?: string; - created_at: string; -} - -export interface RevealPayload { - round_question_id: number; - round_number: number; - prompt: string; - correct_answer: string; - lies: RevealLie[]; - guesses: RevealGuess[]; -} - -export interface SessionDetailResponse { - session: SessionSummary; - players: SessionPlayer[]; - round_question: SessionRoundQuestion | null; - reveal: RevealPayload | null; - phase_view_model: PhaseViewModel; -} - -export interface JoinSessionRequest { - code: string; - nickname: string; -} - -export interface JoinSessionResponse { - player: { - id: number; - nickname: string; - session_token: string; - score: number; - }; - session: { - code: string; - status: string; - }; -} - -export interface StartRoundRequest { - category_slug: string; -} - -export interface StartRoundResponse { - session: { - code: string; - status: string; - current_round: number; - }; - round: { - number: number; - category: { - slug: string; - name: string; - }; - }; -} - -export interface ShowQuestionResponse { - round_question: { - id: number; - prompt: string; - round_number: number; - shown_at: string; - lie_deadline_at: string; - }; - config: { - lie_seconds: number; - }; -} - -export interface MixAnswersResponse { - session: { - code: string; - status: string; - current_round: number; - }; - round_question: { - id: number; - round_number: number; - }; - answers: Array<{ text: string }>; -} - -export interface CalculateScoresResponse { - session: { - code: string; - status: string; - current_round: number; - }; - round_question: { - id: number; - round_number: number; - }; - events_created: number; - leaderboard: Array<{ id: number; nickname: string; score: number }>; -} - -export interface ScoreboardResponse { - session: { - code: string; - status: string; - current_round: number; - }; - leaderboard: Array<{ id: number; nickname: string; score: number }>; -} - -export interface StartNextRoundResponse { - session: { - code: string; - status: string; - current_round: number; - }; -} - -export interface FinishGameResponse { - session: { - code: string; - status: string; - current_round: number; - }; - winner: { id: number; nickname: string; score: number } | null; - leaderboard: Array<{ id: number; nickname: string; score: number }>; -} - -export interface SubmitLieRequest { - player_id: number; - session_token: string; - text: string; -} - -export interface SubmitLieResponse { - lie: { - id: number; - player_id: number; - round_question_id: number; - text: string; - created_at: string; - }; - window: { - lie_deadline_at: string; - }; -} - -export interface SubmitGuessRequest { - player_id: number; - session_token: string; - selected_text: string; -} - -export interface SubmitGuessResponse { - guess: { - id: number; - player_id: number; - round_question_id: number; - selected_text: string; - is_correct: boolean; - fooled_player_id: number | null; - created_at: string; - }; - window: { - guess_deadline_at: string; - }; -} - -export type ApiErrorKind = 'network' | 'http' | 'parse'; - -export interface ApiFailure { - kind: ApiErrorKind; - message: string; - status: number; - payload?: unknown; -} - -export type ApiResult = - | { ok: true; status: number; data: T } - | { ok: false; status: number; error: ApiFailure }; diff --git a/frontend/src/spa/gameplay-phase-machine.ts b/frontend/src/spa/gameplay-phase-machine.ts deleted file mode 100644 index 17d57db..0000000 --- a/frontend/src/spa/gameplay-phase-machine.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { PhaseViewModel, SessionDetailResponse } from '../api/types'; - -export type GameplayPhase = 'lie' | 'guess' | 'reveal' | 'scoreboard'; -export type HostGameplayAction = - | 'startRound' - | 'showQuestion' - | 'mixAnswers' - | 'calculateScores' - | 'loadScoreboard' - | 'startNextRound' - | 'finishGame'; -export type PlayerGameplayAction = 'join' | 'submitLie' | 'submitGuess' | 'viewFinalResult'; - -export type GameplayPhaseEvent = - | 'LIES_LOCKED' - | 'GUESSES_LOCKED' - | 'SCOREBOARD_READY' - | 'NEXT_ROUND'; - -export interface GameplayTransitionResult { - phase: GameplayPhase; - changed: boolean; -} - -const TRANSITIONS: Record>> = { - lie: { - LIES_LOCKED: 'guess' - }, - guess: { - GUESSES_LOCKED: 'reveal' - }, - reveal: { - SCOREBOARD_READY: 'scoreboard' - }, - scoreboard: { - NEXT_ROUND: 'lie' - } -}; - -export function transitionGameplayPhase(phase: GameplayPhase, event: GameplayPhaseEvent): GameplayTransitionResult { - const next = TRANSITIONS[phase][event] ?? phase; - return { - phase: next, - changed: next !== phase - }; -} - -export function allowedGameplayEvents(phase: GameplayPhase): GameplayPhaseEvent[] { - return Object.keys(TRANSITIONS[phase]) as GameplayPhaseEvent[]; -} - -function derivePhaseFromStatus(status: string | null | undefined): GameplayPhase | null { - if (!status) { - return null; - } - - if (status === 'lie' || status === 'guess' || status === 'reveal' || status === 'scoreboard') { - return status; - } - - if (status === 'finished') { - return 'scoreboard'; - } - - return null; -} - -function deriveCanonicalPhaseStatus(phaseViewModel: PhaseViewModel | null | undefined): string | null { - if (!phaseViewModel) { - return null; - } - - const currentPhase = (phaseViewModel as PhaseViewModel & { current_phase?: string }).current_phase; - return currentPhase ?? phaseViewModel.status ?? null; -} - -export function deriveGameplayPhase(session: SessionDetailResponse | null): GameplayPhase | null { - const canonicalStatus = deriveCanonicalPhaseStatus(session?.phase_view_model); - return derivePhaseFromStatus(canonicalStatus ?? session?.session.status); -} - -export function isHostGameplayActionAllowed(session: SessionDetailResponse | null, action: HostGameplayAction): boolean { - if (!session) { - return action === 'startRound'; - } - - const host = session.phase_view_model?.host; - switch (action) { - case 'startRound': - return Boolean(host?.can_start_round ?? false); - case 'showQuestion': - return Boolean(host?.can_show_question ?? false); - case 'mixAnswers': - return Boolean(host?.can_mix_answers ?? false); - case 'calculateScores': - return Boolean(host?.can_calculate_scores ?? false); - case 'loadScoreboard': - return Boolean(host?.can_reveal_scoreboard ?? false); - case 'startNextRound': - return Boolean(host?.can_start_next_round ?? false); - case 'finishGame': - return Boolean(host?.can_finish_game ?? false); - } -} - -export function isPlayerGameplayActionAllowed(session: SessionDetailResponse | null, action: PlayerGameplayAction): boolean { - if (!session) { - return action === 'join'; - } - - const player = session.phase_view_model?.player; - switch (action) { - case 'join': - return Boolean(player?.can_join ?? false); - case 'submitLie': - return Boolean(player?.can_submit_lie ?? false); - case 'submitGuess': - return Boolean(player?.can_submit_guess ?? false); - case 'viewFinalResult': - return Boolean(player?.can_view_final_result ?? false); - } -} diff --git a/frontend/src/spa/lobby-i18n.ts b/frontend/src/spa/lobby-i18n.ts deleted file mode 100644 index ab3f324..0000000 --- a/frontend/src/spa/lobby-i18n.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { DEFAULT_LOCALE, LOBBY_I18N_CATALOG, normalizeLocale } from '../../shared/i18n/lobby-loader'; - -const frontendErrors = LOBBY_I18N_CATALOG.frontend.errors; -const backendToFrontendErrorKeys = LOBBY_I18N_CATALOG.contract.backend_to_frontend_error_keys as Record< - string, - keyof typeof frontendErrors ->; - -type FrontendErrorKey = keyof typeof frontendErrors; - -function isFrontendErrorKey(value: string): value is FrontendErrorKey { - return value in frontendErrors; -} - -export function lobbyMessage(key: FrontendErrorKey, locale?: string): string { - const resolvedLocale = normalizeLocale(locale); - const translations = frontendErrors[key] as Record; - - if (translations[resolvedLocale]) { - return translations[resolvedLocale]; - } - if (translations[DEFAULT_LOCALE]) { - return translations[DEFAULT_LOCALE]; - } - - return key; -} - -export function lobbyMessageFromApiPayload(payload: unknown, fallbackKey: FrontendErrorKey, locale?: string): string { - if (!payload || typeof payload !== 'object') { - return lobbyMessage(fallbackKey, locale); - } - - const record = payload as Record; - const code = typeof record.error_code === 'string' ? record.error_code : ''; - const payloadLocale = typeof record.locale === 'string' ? record.locale : locale; - const mappedKey = code ? backendToFrontendErrorKeys[code] : undefined; - - if (mappedKey && isFrontendErrorKey(mappedKey)) { - return lobbyMessage(mappedKey, payloadLocale); - } - - if (isFrontendErrorKey(code)) { - return lobbyMessage(code, payloadLocale); - } - - return lobbyMessage(fallbackKey, payloadLocale); -} diff --git a/frontend/src/spa/session-context-store.ts b/frontend/src/spa/session-context-store.ts deleted file mode 100644 index b92f5be..0000000 --- a/frontend/src/spa/session-context-store.ts +++ /dev/null @@ -1,123 +0,0 @@ -export interface SessionContext { - sessionCode: string; - playerId: number; - token: string; -} - -export interface SessionContextInput { - sessionCode: string; - playerId: number; - token: string; -} - -export interface SessionContextStore { - get(): SessionContext | null; - set(input: SessionContextInput): SessionContext; - clear(): void; -} - -export interface StorageLike { - getItem(key: string): string | null; - setItem(key: string, value: string): void; - removeItem(key: string): void; -} - -const DEFAULT_STORAGE_KEY = 'wpp.session-context'; - -function normalizeSessionCode(value: string): string { - return value.trim().toUpperCase(); -} - -function normalizeToken(value: string): string { - return value.trim(); -} - -function toContext(input: SessionContextInput): SessionContext { - const sessionCode = normalizeSessionCode(input.sessionCode); - const token = normalizeToken(input.token); - - if (!sessionCode) { - throw new Error('sessionCode is required'); - } - if (!Number.isInteger(input.playerId) || input.playerId <= 0) { - throw new Error('playerId must be a positive integer'); - } - if (!token) { - throw new Error('token is required'); - } - - return { - sessionCode, - playerId: input.playerId, - token - }; -} - -function safeParse(raw: string): SessionContext | null { - try { - const data = JSON.parse(raw) as Partial; - if (typeof data.sessionCode !== 'string' || typeof data.playerId !== 'number' || typeof data.token !== 'string') { - return null; - } - return toContext({ - sessionCode: data.sessionCode, - playerId: data.playerId, - token: data.token - }); - } catch { - return null; - } -} - -export function createSessionContextStore(storage?: StorageLike, storageKey = DEFAULT_STORAGE_KEY): SessionContextStore { - let current: SessionContext | null = null; - - function getFromStorage(): SessionContext | null { - if (!storage) { - return null; - } - - const raw = storage.getItem(storageKey); - if (!raw) { - return null; - } - - const parsed = safeParse(raw); - if (!parsed) { - storage.removeItem(storageKey); - return null; - } - - return parsed; - } - - return { - get(): SessionContext | null { - if (current) { - return { ...current }; - } - - const fromStorage = getFromStorage(); - if (fromStorage) { - current = fromStorage; - return { ...current }; - } - - return null; - }, - set(input: SessionContextInput): SessionContext { - const normalized = toContext(input); - current = normalized; - if (storage) { - storage.setItem(storageKey, JSON.stringify(normalized)); - } - return { ...normalized }; - }, - clear(): void { - current = null; - if (storage) { - storage.removeItem(storageKey); - } - } - }; -} diff --git a/frontend/src/spa/vertical-slice.ts b/frontend/src/spa/vertical-slice.ts deleted file mode 100644 index a6bdac4..0000000 --- a/frontend/src/spa/vertical-slice.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { ApiClient } from '../api/client'; -import type { SessionDetailResponse } from '../api/types'; -import { - createSessionContextStore, - type SessionContext, - type SessionContextInput, - type SessionContextStore as PersistedSessionContextStore -} from './session-context-store'; -import { deriveGameplayPhase, type GameplayPhase } from './gameplay-phase-machine'; -import { lobbyMessage, lobbyMessageFromApiPayload } from './lobby-i18n'; - -export type AsyncState = 'idle' | 'loading' | 'success' | 'error'; - -export type SessionContextStore = Pick; - -export interface VerticalSliceState { - sessionCode: string; - session: SessionDetailResponse | null; - gameplayPhase: GameplayPhase | null; - joinState: AsyncState; - startRoundState: AsyncState; - loadingSession: boolean; - errorMessage: string | null; -} - -export interface VerticalSliceController { - getState(): VerticalSliceState; - hydrateLobby(sessionCode: string): Promise; - joinLobby(sessionCode: string, nickname: string): Promise; - startRound(sessionCode: string, categorySlug: string): Promise; -} - -export function createVerticalSliceController( - api: ApiClient, - sessionContextStore: SessionContextStore = createSessionContextStore() -): VerticalSliceController { - const persistedContext = sessionContextStore.get(); - - const state: VerticalSliceState = { - sessionCode: persistedContext?.sessionCode ?? '', - session: null, - gameplayPhase: null, - joinState: 'idle', - startRoundState: 'idle', - loadingSession: false, - errorMessage: null - }; - - const normalizeCode = (value: string): string => value.trim().toUpperCase(); - - async function hydrateLobby(sessionCode: string): Promise { - state.loadingSession = true; - state.errorMessage = null; - - const normalizedRequestedCode = normalizeCode(sessionCode); - const fallbackCode = normalizeCode(state.sessionCode || persistedContext?.sessionCode || ''); - state.sessionCode = normalizedRequestedCode || fallbackCode; - - if (!state.sessionCode) { - state.loadingSession = false; - state.errorMessage = lobbyMessage('session_code_required'); - return { ...state }; - } - - const result = await api.getSession(state.sessionCode); - state.loadingSession = false; - - if (!result.ok) { - state.errorMessage = lobbyMessageFromApiPayload(result.error.payload, 'session_fetch_failed'); - state.gameplayPhase = null; - return { ...state }; - } - - state.session = result.data; - state.gameplayPhase = deriveGameplayPhase(result.data); - state.sessionCode = normalizeCode(result.data.session.code); - - if (persistedContext && state.sessionCode === normalizeCode(persistedContext.sessionCode)) { - sessionContextStore.set({ ...persistedContext, sessionCode: state.sessionCode }); - } - - return { ...state }; - } - - async function joinLobby(sessionCode: string, nickname: string): Promise { - state.joinState = 'loading'; - state.errorMessage = null; - - const normalizedRequestedCode = normalizeCode(sessionCode); - const fallbackCode = normalizeCode(state.sessionCode || persistedContext?.sessionCode || ''); - const requestCode = normalizedRequestedCode || fallbackCode; - - const join = await api.joinSession({ code: requestCode, nickname }); - if (!join.ok) { - state.joinState = 'error'; - state.errorMessage = lobbyMessageFromApiPayload(join.error.payload, 'join_failed'); - return { ...state }; - } - - state.joinState = 'success'; - state.sessionCode = normalizeCode(join.data.session.code || requestCode); - - const nextContext: SessionContextInput = { - sessionCode: state.sessionCode, - playerId: join.data.player.id, - token: join.data.player.session_token - }; - sessionContextStore.set(nextContext); - - return hydrateLobby(state.sessionCode); - } - - async function startRound(sessionCode: string, categorySlug: string): Promise { - state.startRoundState = 'loading'; - state.errorMessage = null; - - const normalizedRequestedCode = normalizeCode(sessionCode); - const fallbackCode = normalizeCode(state.sessionCode || persistedContext?.sessionCode || ''); - const codeToUse = normalizedRequestedCode || fallbackCode; - - if (!codeToUse) { - state.startRoundState = 'error'; - state.errorMessage = lobbyMessage('session_code_required'); - return { ...state }; - } - - const start = await api.startRound(codeToUse, { category_slug: categorySlug }); - if (!start.ok) { - state.startRoundState = 'error'; - state.errorMessage = lobbyMessageFromApiPayload(start.error.payload, 'start_round_failed'); - return { ...state }; - } - - state.startRoundState = 'success'; - return hydrateLobby(codeToUse); - } - - return { - getState: () => ({ ...state }), - hydrateLobby, - joinLobby, - startRound - }; -} - -export type { SessionContext }; diff --git a/frontend/tests/angular-api-client.test.ts b/frontend/tests/angular-api-client.test.ts deleted file mode 100644 index 328d4f8..0000000 --- a/frontend/tests/angular-api-client.test.ts +++ /dev/null @@ -1,739 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { createAngularApiClient, type AngularHttpClientLike } from '../src/api/angular-client'; -import { mapSessionDetailResponse, mapSubmitGuessResponse } from '../src/api/mappers'; - -describe('createAngularApiClient', () => { - it('reads health and session detail using Django-compatible endpoints', async () => { - const get = vi.fn(async (url: string) => { - if (url === '/healthz') { - return { ok: true, service: 'partyhub' } as T; - } - - if (url === '/lobby/sessions/ABCD12') { - return { - session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 }, - players: [ - { id: 2, nickname: 'Maja', score: 0, is_connected: true }, - { id: 3, nickname: 'Bo', score: 0, is_connected: false } - ], - round_question: null, - phase_view_model: { - status: 'lobby', - round_number: 1, - players_count: 2, - constraints: { - min_players_to_start: 2, - max_players_mvp: 8, - min_players_reached: true, - max_players_allowed: true - }, - host: { - can_start_round: true, - can_show_question: false, - can_mix_answers: false, - can_calculate_scores: false, - can_reveal_scoreboard: false, - can_start_next_round: false, - can_finish_game: false - }, - player: { - can_join: true, - can_submit_lie: false, - can_submit_guess: false, - can_view_final_result: false - } - } - } as T; - } - - throw { status: 404, error: { error: 'Not found' } }; - }); - - const post = vi.fn(async (url: string, body: unknown) => { - if (url === '/lobby/sessions/join') { - expect(body).toEqual({ code: 'ABCD12', nickname: 'Maja' }); - return { - player: { id: 9, nickname: 'Maja', session_token: 'token-1', score: 0 }, - session: { code: 'ABCD12', status: 'lobby' } - } as T; - } - - if (url === '/lobby/sessions/ABCD12/rounds/start') { - expect(body).toEqual({ category_slug: 'history' }); - return { - session: { code: 'ABCD12', status: 'lie', current_round: 1 }, - round: { number: 1, category: { slug: 'history', name: 'History' } } - } as T; - } - - throw { status: 404, error: { error: 'Not found' } }; - }); - - const http = { get, post }; - const client = createAngularApiClient(http as AngularHttpClientLike); - - const health = await client.health(); - expect(health.ok).toBe(true); - if (health.ok) { - expect(health.data.ok).toBe(true); - expect(health.data.service).toBe('partyhub'); - } - - const session = await client.getSession(' abcd12 '); - expect(session.ok).toBe(true); - if (session.ok) { - expect(session.data.session.code).toBe('ABCD12'); - expect(session.data.session.host_id).toBe(1); - expect(session.data.phase_view_model.host.can_start_round).toBe(true); - } - - const join = await client.joinSession({ code: ' abcd12 ', nickname: ' Maja ' }); - expect(join.ok).toBe(true); - - const start = await client.startRound(' abcd12 ', { category_slug: 'history' }); - expect(start.ok).toBe(true); - - expect(get).toHaveBeenNthCalledWith(1, '/healthz', { withCredentials: true }); - expect(get).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', { withCredentials: true }); - expect(post).toHaveBeenNthCalledWith( - 1, - '/lobby/sessions/join', - { code: 'ABCD12', nickname: 'Maja' }, - { withCredentials: true } - ); - expect(post).toHaveBeenNthCalledWith( - 2, - '/lobby/sessions/ABCD12/rounds/start', - { category_slug: 'history' }, - { withCredentials: true } - ); - }); - - it('normalizes baseUrl with trailing slash to keep Django endpoint paths canonical', async () => { - const get = vi.fn(async (url: string) => { - if (url === '/api/healthz') { - return { ok: true, service: 'partyhub' } as T; - } - if (url === '/api/lobby/sessions/ABCD12') { - return { - session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 }, - players: [], - round_question: null, - phase_view_model: { - status: 'lobby', - round_number: 1, - players_count: 2, - constraints: { - min_players_to_start: 2, - max_players_mvp: 8, - min_players_reached: true, - max_players_allowed: true - }, - host: { - can_start_round: true, - can_show_question: false, - can_mix_answers: false, - can_calculate_scores: false, - can_reveal_scoreboard: false, - can_start_next_round: false, - can_finish_game: false - }, - player: { - can_join: true, - can_submit_lie: false, - can_submit_guess: false, - can_view_final_result: false - } - } - } as T; - } - throw { status: 404, error: { error: 'Not found' } }; - }); - - const post = vi.fn(async (url: string) => { - if (url === '/api/lobby/sessions/join') { - return { - player: { id: 9, nickname: 'Maja', session_token: 'token-1', score: 0 }, - session: { code: 'ABCD12', status: 'lobby' } - } as T; - } - if (url === '/api/lobby/sessions/ABCD12/rounds/start') { - return { - session: { code: 'ABCD12', status: 'lie', current_round: 1 }, - round: { number: 1, category: { slug: 'history', name: 'History' } } - } as T; - } - throw { status: 404, error: { error: 'Not found' } }; - }); - - const client = createAngularApiClient({ get, post } as AngularHttpClientLike, '/api/'); - - await client.health(); - await client.getSession('abcd12'); - await client.joinSession({ code: 'abcd12', nickname: 'Maja' }); - await client.startRound('abcd12', { category_slug: 'history' }); - - expect(get).toHaveBeenNthCalledWith(1, '/api/healthz', { withCredentials: true }); - expect(get).toHaveBeenNthCalledWith(2, '/api/lobby/sessions/ABCD12', { withCredentials: true }); - expect(post).toHaveBeenNthCalledWith( - 1, - '/api/lobby/sessions/join', - { code: 'ABCD12', nickname: 'Maja' }, - { withCredentials: true } - ); - expect(post).toHaveBeenNthCalledWith( - 2, - '/api/lobby/sessions/ABCD12/rounds/start', - { category_slug: 'history' }, - { withCredentials: true } - ); - }); - - it('returns parse error when successful payload breaks typed contract', async () => { - const http = { - get: vi.fn(async () => ({ ok: true } as T)), - post: vi.fn(async () => ({ ok: true } as T)) - }; - - const client = createAngularApiClient(http as AngularHttpClientLike); - const session = await client.getSession('ABCD12'); - - expect(session.ok).toBe(false); - if (!session.ok) { - expect(session.status).toBe(200); - expect(session.error.kind).toBe('parse'); - expect(session.error.message).toContain('Invalid API contract'); - } - }); - - it('keeps canonical reveal payload stable when session detail is already in scoreboard phase', async () => { - const get = vi.fn(async (url: string) => { - if (url === '/lobby/sessions/ABCD12') { - return { - session: { code: 'ABCD12', status: 'scoreboard', host_id: 1, current_round: 1, players_count: 2 }, - players: [ - { id: 2, nickname: 'Maja', score: 10, is_connected: true }, - { id: 3, nickname: 'Bo', score: 7, is_connected: true } - ], - round_question: { - id: 77, - round_number: 1, - prompt: 'Q?', - shown_at: '2026-03-01T18:00:00Z', - answers: [{ text: 'A' }, { text: 'B' }] - }, - reveal: { - round_question_id: 77, - round_number: 1, - prompt: 'Q?', - correct_answer: 'A', - lies: [{ player_id: 2, nickname: 'Maja', text: 'B', created_at: '2026-03-01T18:00:05Z' }], - guesses: [ - { - player_id: 3, - nickname: 'Bo', - selected_text: 'B', - is_correct: false, - fooled_player_id: 2, - fooled_player_nickname: 'Maja', - created_at: '2026-03-01T18:00:15Z' - } - ] - }, - phase_view_model: { - status: 'scoreboard', - round_number: 1, - players_count: 2, - constraints: { - min_players_to_start: 2, - max_players_mvp: 8, - min_players_reached: true, - max_players_allowed: true - }, - host: { - can_start_round: false, - can_show_question: false, - can_mix_answers: false, - can_calculate_scores: false, - can_reveal_scoreboard: false, - can_start_next_round: true, - can_finish_game: true - }, - player: { - can_join: true, - can_submit_lie: false, - can_submit_guess: false, - can_view_final_result: false - } - } - } as T; - } - throw { status: 404, error: { error: 'Not found' } }; - }); - - const client = createAngularApiClient({ get, post: vi.fn() } as unknown as AngularHttpClientLike); - const session = await client.getSession('abcd12'); - - expect(session.ok).toBe(true); - if (session.ok) { - expect(session.data.session.status).toBe('scoreboard'); - expect(session.data.reveal?.guesses[0].fooled_player_nickname).toBe('Maja'); - expect(session.data.phase_view_model.host.can_start_next_round).toBe(true); - expect(session.data.phase_view_model.host.can_finish_game).toBe(true); - } - }); - - it('normalizes omitted fooled_player_id to null in canonical reveal payloads', async () => { - const get = vi.fn(async (url: string) => { - if (url === '/lobby/sessions/ABCD12') { - return { - session: { code: 'ABCD12', status: 'reveal', host_id: 1, current_round: 1, players_count: 2 }, - players: [ - { id: 2, nickname: 'Maja', score: 10, is_connected: true }, - { id: 3, nickname: 'Bo', score: 7, is_connected: true } - ], - round_question: { - id: 77, - round_number: 1, - prompt: 'Q?', - shown_at: '2026-03-01T18:00:00Z', - answers: [{ text: 'A' }, { text: 'B' }] - }, - reveal: { - round_question_id: 77, - round_number: 1, - prompt: 'Q?', - correct_answer: 'A', - lies: [], - guesses: [ - { - player_id: 3, - nickname: 'Bo', - selected_text: 'A', - is_correct: true, - created_at: '2026-03-01T18:00:15Z' - } - ] - }, - phase_view_model: { - status: 'reveal', - round_number: 1, - players_count: 2, - constraints: { - min_players_to_start: 2, - max_players_mvp: 8, - min_players_reached: true, - max_players_allowed: true - }, - host: { - can_start_round: false, - can_show_question: false, - can_mix_answers: false, - can_calculate_scores: false, - can_reveal_scoreboard: true, - can_start_next_round: false, - can_finish_game: false - }, - player: { - can_join: true, - can_submit_lie: false, - can_submit_guess: false, - can_view_final_result: false - } - } - } as T; - } - throw { status: 404, error: { error: 'Not found' } }; - }); - - const post = vi.fn(async (url: string, body: unknown) => { - if (url === '/lobby/sessions/ABCD12/questions/77/guesses/submit') { - expect(body).toEqual({ player_id: 9, session_token: 'tok', selected_text: 'A' }); - return { - guess: { - id: 200, - player_id: 9, - round_question_id: 77, - selected_text: 'A', - is_correct: false, - created_at: '2026-03-01T16:01:00Z' - }, - window: { guess_deadline_at: '2026-03-01T16:01:30Z' } - } as T; - } - throw { status: 404, error: { error: 'Not found' } }; - }); - - const client = createAngularApiClient({ get, post } as AngularHttpClientLike); - const session = await client.getSession('abcd12'); - - expect(session.ok).toBe(true); - if (session.ok) { - expect(session.data.reveal?.guesses[0].fooled_player_id).toBeNull(); - expect(session.data.reveal?.guesses[0]).not.toHaveProperty('fooled_player_nickname'); - } - - const submitGuess = await client.submitGuess('ABCD12', 77, { - player_id: 9, - session_token: 'tok', - selected_text: 'A' - }); - expect(submitGuess.ok).toBe(true); - if (submitGuess.ok) { - expect(submitGuess.data.guess.fooled_player_id).toBeNull(); - } - }); - - it('maps omitted fooled_player_id to null in submit guess mapper payloads', () => { - const mapped = mapSubmitGuessResponse({ - guess: { - id: 200, - player_id: 9, - round_question_id: 77, - selected_text: 'A', - is_correct: false, - created_at: '2026-03-01T16:01:00Z' - }, - window: { guess_deadline_at: '2026-03-01T16:01:30Z' } - }); - - expect(mapped.guess.fooled_player_id).toBeNull(); - }); - - it('keeps fooled_player_nickname omitted when canonical reveal payload omits fooled player refs', () => { - const mapped = mapSessionDetailResponse({ - session: { code: 'ABCD12', status: 'reveal', host_id: 1, current_round: 1, players_count: 2 }, - players: [ - { id: 2, nickname: 'Maja', score: 10, is_connected: true }, - { id: 3, nickname: 'Bo', score: 7, is_connected: true } - ], - round_question: { - id: 77, - round_number: 1, - prompt: 'Q?', - shown_at: '2026-03-01T18:00:00Z', - answers: [{ text: 'A' }, { text: 'B' }] - }, - reveal: { - round_question_id: 77, - round_number: 1, - prompt: 'Q?', - correct_answer: 'A', - lies: [], - guesses: [ - { - player_id: 3, - nickname: 'Bo', - selected_text: 'A', - is_correct: true, - created_at: '2026-03-01T18:00:15Z' - } - ] - }, - phase_view_model: { - status: 'reveal', - round_number: 1, - players_count: 2, - constraints: { - min_players_to_start: 2, - max_players_mvp: 8, - min_players_reached: true, - max_players_allowed: true - }, - host: { - can_start_round: false, - can_show_question: false, - can_mix_answers: false, - can_calculate_scores: false, - can_reveal_scoreboard: true, - can_start_next_round: false, - can_finish_game: false - }, - player: { - can_join: true, - can_submit_lie: false, - can_submit_guess: false, - can_view_final_result: false - } - } - }); - - expect(mapped.reveal?.guesses[0].fooled_player_id).toBeNull(); - expect(mapped.reveal?.guesses[0]).not.toHaveProperty('fooled_player_nickname'); - }); - - it('rejects canonical reveal payloads that include fooled_player_nickname without fooled_player_id', () => { - expect(() => - mapSessionDetailResponse({ - session: { code: 'ABCD12', status: 'reveal', host_id: 1, current_round: 1, players_count: 2 }, - players: [ - { id: 2, nickname: 'Maja', score: 10, is_connected: true }, - { id: 3, nickname: 'Bo', score: 7, is_connected: true } - ], - round_question: { - id: 77, - round_number: 1, - prompt: 'Q?', - shown_at: '2026-03-01T18:00:00Z', - answers: [{ text: 'A' }, { text: 'B' }] - }, - reveal: { - round_question_id: 77, - round_number: 1, - prompt: 'Q?', - correct_answer: 'A', - lies: [], - guesses: [ - { - player_id: 3, - nickname: 'Bo', - selected_text: 'A', - is_correct: true, - fooled_player_nickname: 'Maja', - created_at: '2026-03-01T18:00:15Z' - } - ] - }, - phase_view_model: { - status: 'reveal', - round_number: 1, - players_count: 2, - constraints: { - min_players_to_start: 2, - max_players_mvp: 8, - min_players_reached: true, - max_players_allowed: true - }, - host: { - can_start_round: false, - can_show_question: false, - can_mix_answers: false, - can_calculate_scores: false, - can_reveal_scoreboard: true, - can_start_next_round: false, - can_finish_game: false - }, - player: { - can_join: true, - can_submit_lie: false, - can_submit_guess: false, - can_view_final_result: false - } - } - }) - ).toThrow('fooled_player_nickname to be omitted when fooled_player_id is null'); - }); - - it('rejects canonical reveal payloads that omit fooled_player_nickname when fooled_player_id is set', () => { - expect(() => - mapSessionDetailResponse({ - session: { code: 'ABCD12', status: 'reveal', host_id: 1, current_round: 1, players_count: 2 }, - players: [ - { id: 2, nickname: 'Maja', score: 10, is_connected: true }, - { id: 3, nickname: 'Bo', score: 7, is_connected: true } - ], - round_question: { - id: 77, - round_number: 1, - prompt: 'Q?', - shown_at: '2026-03-01T18:00:00Z', - answers: [{ text: 'A' }, { text: 'B' }] - }, - reveal: { - round_question_id: 77, - round_number: 1, - prompt: 'Q?', - correct_answer: 'A', - lies: [], - guesses: [ - { - player_id: 3, - nickname: 'Bo', - selected_text: 'B', - is_correct: false, - fooled_player_id: 2, - created_at: '2026-03-01T18:00:15Z' - } - ] - }, - phase_view_model: { - status: 'reveal', - round_number: 1, - players_count: 2, - constraints: { - min_players_to_start: 2, - max_players_mvp: 8, - min_players_reached: true, - max_players_allowed: true - }, - host: { - can_start_round: false, - can_show_question: false, - can_mix_answers: false, - can_calculate_scores: false, - can_reveal_scoreboard: true, - can_start_next_round: false, - can_finish_game: false - }, - player: { - can_join: true, - can_submit_lie: false, - can_submit_guess: false, - can_view_final_result: false - } - } - }) - ).toThrow('fooled_player_nickname when fooled_player_id is set'); - }); - - it('maps host/player gameplay endpoints through typed response mappers', async () => { - const get = vi.fn(async (url: string) => { - if (url === '/lobby/sessions/ABCD12/scoreboard') { - return { - session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 }, - leaderboard: [ - { id: 2, nickname: 'Maja', score: 11 }, - { id: 3, nickname: 'Bo', score: 7 } - ] - } as T; - } - throw { status: 404, error: { error: 'Not found' } }; - }); - - const post = vi.fn(async (url: string, body: unknown) => { - if (url === '/lobby/sessions/ABCD12/questions/show') { - expect(body).toEqual({}); - return { - round_question: { - id: 77, - prompt: 'Prompt?', - round_number: 1, - shown_at: '2026-03-01T16:00:00Z', - lie_deadline_at: '2026-03-01T16:00:30Z' - }, - config: { lie_seconds: 30 } - } as T; - } - if (url === '/lobby/sessions/ABCD12/questions/77/answers/mix') { - expect(body).toEqual({}); - return { - session: { code: 'ABCD12', status: 'guess', current_round: 1 }, - round_question: { id: 77, round_number: 1 }, - answers: [{ text: 'A' }, { text: 'B' }] - } as T; - } - if (url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') { - expect(body).toEqual({}); - return { - session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 }, - round_question: { id: 77, round_number: 1 }, - events_created: 3, - leaderboard: [{ id: 2, nickname: 'Maja', score: 11 }] - } as T; - } - if (url === '/lobby/sessions/ABCD12/rounds/next') { - expect(body).toEqual({}); - return { session: { code: 'ABCD12', status: 'lie', current_round: 2 } } as T; - } - if (url === '/lobby/sessions/ABCD12/finish') { - expect(body).toEqual({}); - return { - session: { code: 'ABCD12', status: 'finished', current_round: 2 }, - winner: { id: 2, nickname: 'Maja', score: 15 }, - leaderboard: [{ id: 2, nickname: 'Maja', score: 15 }] - } as T; - } - if (url === '/lobby/sessions/ABCD12/questions/77/lies/submit') { - expect(body).toEqual({ player_id: 9, session_token: 'tok', text: 'my lie' }); - return { - lie: { - id: 100, - player_id: 9, - round_question_id: 77, - text: 'my lie', - created_at: '2026-03-01T16:00:10Z' - }, - window: { lie_deadline_at: '2026-03-01T16:00:30Z' } - } as T; - } - if (url === '/lobby/sessions/ABCD12/questions/77/guesses/submit') { - expect(body).toEqual({ player_id: 9, session_token: 'tok', selected_text: 'A' }); - return { - guess: { - id: 200, - player_id: 9, - round_question_id: 77, - selected_text: 'A', - is_correct: false, - fooled_player_id: 3, - created_at: '2026-03-01T16:01:00Z' - }, - window: { guess_deadline_at: '2026-03-01T16:01:30Z' } - } as T; - } - - throw { status: 404, error: { error: 'Not found' } }; - }); - - const client = createAngularApiClient({ get, post } as AngularHttpClientLike); - - const showQuestion = await client.showQuestion('abcd12'); - expect(showQuestion.ok).toBe(true); - - const mixAnswers = await client.mixAnswers('abcd12', 77); - expect(mixAnswers.ok).toBe(true); - - const calculateScores = await client.calculateScores('abcd12', 77); - expect(calculateScores.ok).toBe(true); - - const scoreboard = await client.getScoreboard('abcd12'); - expect(scoreboard.ok).toBe(true); - - const nextRound = await client.startNextRound('abcd12'); - expect(nextRound.ok).toBe(true); - - const finish = await client.finishGame('abcd12'); - expect(finish.ok).toBe(true); - - const submitLie = await client.submitLie('abcd12', 77, { player_id: 9, session_token: 'tok', text: 'my lie' }); - expect(submitLie.ok).toBe(true); - - const submitGuess = await client.submitGuess('abcd12', 77, { - player_id: 9, - session_token: 'tok', - selected_text: 'A' - }); - expect(submitGuess.ok).toBe(true); - }); - - it('maps HttpErrorResponse-style failures to ApiResult errors', async () => { - const http = { - get: vi.fn(async () => { - throw { status: 503, message: 'Service unavailable', error: { error: 'maintenance' } }; - }), - post: vi.fn(async () => { - throw { status: 403, message: 'Forbidden', error: { error: 'Only host can start round' } }; - }) - }; - - const client = createAngularApiClient(http as AngularHttpClientLike); - const health = await client.health(); - - expect(health.ok).toBe(false); - if (!health.ok) { - expect(health.status).toBe(503); - expect(health.error.kind).toBe('http'); - expect(health.error.payload).toEqual({ error: 'maintenance' }); - expect(health.error.message).toContain('Service unavailable'); - } - - const start = await client.startRound('ABCD12', { category_slug: 'history' }); - expect(start.ok).toBe(false); - if (!start.ok) { - expect(start.status).toBe(403); - expect(start.error.kind).toBe('http'); - expect(start.error.payload).toEqual({ error: 'Only host can start round' }); - } - }); -}); diff --git a/frontend/tests/api-client.integration.test.ts b/frontend/tests/api-client.integration.test.ts deleted file mode 100644 index f44e104..0000000 --- a/frontend/tests/api-client.integration.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { AddressInfo } from 'node:net'; -import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; -import { createApiClient } from '../src/api/client'; - -let server: Server; -let baseUrl: string; - -beforeAll(async () => { - server = createServer(async (req: IncomingMessage, res: ServerResponse) => { - if (req.url === '/healthz') { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ ok: true, service: 'weirsoe-party-protocol' })); - return; - } - - if (req.url === '/lobby/sessions/ABCD12' && req.method === 'GET') { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end( - JSON.stringify({ - session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 3 }, - players: [], - round_question: null, - phase_view_model: { - status: 'lobby', - round_number: 1, - players_count: 3, - constraints: { - min_players_to_start: 3, - max_players_mvp: 5, - min_players_reached: true, - max_players_allowed: true - }, - host: { - can_start_round: true, - can_show_question: false, - can_mix_answers: false, - can_calculate_scores: false, - can_reveal_scoreboard: false, - can_start_next_round: false, - can_finish_game: false - }, - player: { - can_join: true, - can_submit_lie: false, - can_submit_guess: false, - can_view_final_result: false - } - } - }) - ); - return; - } - - if (req.url === '/lobby/sessions/join' && req.method === 'POST') { - res.writeHead(201, { 'content-type': 'application/json' }); - res.end( - JSON.stringify({ - player: { id: 9, nickname: 'Maja', session_token: 'token-1', score: 0 }, - session: { code: 'ABCD12', status: 'lobby' } - }) - ); - return; - } - - if (req.url === '/lobby/sessions/ABCD12/rounds/start' && req.method === 'POST') { - res.writeHead(201, { 'content-type': 'application/json' }); - res.end( - JSON.stringify({ - session: { code: 'ABCD12', status: 'lie', current_round: 1 }, - round: { number: 1, category: { slug: 'history', name: 'History' } } - }) - ); - return; - } - - if (req.url === '/lobby/sessions/BADMAP' && req.method === 'GET') { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ session: { code: 'BADMAP' } })); - return; - } - - if (req.url?.startsWith('/lobby/sessions/')) { - res.writeHead(404, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ error: 'Session not found' })); - return; - } - - res.writeHead(500, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ error: 'unexpected route' })); - }); - - await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); - const { port } = server.address() as AddressInfo; - baseUrl = `http://127.0.0.1:${port}`; -}); - -afterAll(async () => { - await new Promise((resolve, reject) => - server.close((err?: Error) => (err ? reject(err) : resolve())) - ); -}); - -describe('createApiClient', () => { - it('reads health + session detail through typed wrappers', async () => { - const client = createApiClient(baseUrl); - - const health = await client.health(); - expect(health.ok).toBe(true); - - const session = await client.getSession('abcd12'); - expect(session.ok).toBe(true); - if (session.ok) { - expect(session.data.session.code).toBe('ABCD12'); - expect(session.data.phase_view_model.host.can_start_round).toBe(true); - } - }); - - it('supports join + start round writes for lobby vertical slice', async () => { - const client = createApiClient(baseUrl); - - const join = await client.joinSession({ code: 'abcd12', nickname: 'Maja' }); - expect(join.ok).toBe(true); - - const start = await client.startRound('abcd12', { category_slug: 'history' }); - expect(start.ok).toBe(true); - if (start.ok) { - expect(start.data.session.status).toBe('lie'); - } - }); - - it('returns parse error when response violates typed contract', async () => { - const client = createApiClient(baseUrl); - - const invalid = await client.getSession('badmap'); - expect(invalid.ok).toBe(false); - if (!invalid.ok) { - expect(invalid.status).toBe(200); - expect(invalid.error.kind).toBe('parse'); - expect(invalid.error.message).toContain('Invalid API contract'); - } - }); - - it('returns consistent HTTP error shape for 4xx/5xx', async () => { - const client = createApiClient(baseUrl); - - const missing = await client.getSession('missing'); - expect(missing.ok).toBe(false); - if (!missing.ok) { - expect(missing.status).toBe(404); - expect(missing.error.kind).toBe('http'); - expect(missing.error.payload).toEqual({ error: 'Session not found' }); - } - }); - - it('returns consistent network error shape', async () => { - const client = createApiClient('http://127.0.0.1:9'); - - const health = await client.health(); - expect(health.ok).toBe(false); - if (!health.ok) { - expect(health.error.kind).toBe('network'); - expect(health.status).toBe(0); - } - }); -}); diff --git a/frontend/tests/gameplay-phase-machine.test.ts b/frontend/tests/gameplay-phase-machine.test.ts deleted file mode 100644 index f67f26d..0000000 --- a/frontend/tests/gameplay-phase-machine.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - allowedGameplayEvents, - deriveGameplayPhase, - isHostGameplayActionAllowed, - isPlayerGameplayActionAllowed, - transitionGameplayPhase, - type GameplayPhase -} from '../src/spa/gameplay-phase-machine'; - -describe('gameplay phase machine skeleton', () => { - it('supports canonical phase progression lie -> guess -> reveal -> scoreboard -> lie', () => { - let phase: GameplayPhase = 'lie'; - - phase = transitionGameplayPhase(phase, 'LIES_LOCKED').phase; - expect(phase).toBe('guess'); - - phase = transitionGameplayPhase(phase, 'GUESSES_LOCKED').phase; - expect(phase).toBe('reveal'); - - phase = transitionGameplayPhase(phase, 'SCOREBOARD_READY').phase; - expect(phase).toBe('scoreboard'); - - phase = transitionGameplayPhase(phase, 'NEXT_ROUND').phase; - expect(phase).toBe('lie'); - }); - - it('keeps state unchanged for invalid transition events', () => { - const transition = transitionGameplayPhase('lie', 'NEXT_ROUND'); - expect(transition.phase).toBe('lie'); - expect(transition.changed).toBe(false); - }); - - it('exposes allowed events per phase', () => { - expect(allowedGameplayEvents('guess')).toEqual(['GUESSES_LOCKED']); - expect(allowedGameplayEvents('scoreboard')).toEqual(['NEXT_ROUND']); - }); - - it('derives gameplay phase from session detail status', () => { - expect( - deriveGameplayPhase({ - session: { code: 'ABCD12', status: 'lie', host_id: 1, current_round: 1, players_count: 3 }, - players: [], - round_question: null, - reveal: null, - phase_view_model: { - status: 'lie', - round_number: 1, - players_count: 3, - constraints: { - min_players_to_start: 3, - max_players_mvp: 5, - min_players_reached: true, - max_players_allowed: true - }, - host: { - can_start_round: false, - can_show_question: true, - can_mix_answers: true, - can_calculate_scores: false, - can_reveal_scoreboard: false, - can_start_next_round: false, - can_finish_game: false - }, - player: { - can_join: false, - can_submit_lie: true, - can_submit_guess: false, - can_view_final_result: false - } - } - }) - ).toBe('lie'); - - expect( - deriveGameplayPhase({ - session: { code: 'ABCD12', status: 'finished', host_id: 1, current_round: 1, players_count: 3 }, - players: [], - round_question: null, - reveal: null, - phase_view_model: { - status: 'finished', - round_number: 1, - players_count: 3, - constraints: { - min_players_to_start: 3, - max_players_mvp: 5, - min_players_reached: true, - max_players_allowed: true - }, - host: { - can_start_round: false, - can_show_question: false, - can_mix_answers: false, - can_calculate_scores: false, - can_reveal_scoreboard: false, - can_start_next_round: false, - can_finish_game: false - }, - player: { - can_join: false, - can_submit_lie: false, - can_submit_guess: false, - can_view_final_result: true - } - } - }) - ).toBe('scoreboard'); - }); - - it('gates host and player actions from canonical phase_view_model permissions', () => { - const session = { - session: { code: 'ABCD12', status: 'scoreboard', host_id: 1, current_round: 1, players_count: 3 }, - players: [], - round_question: { id: 77, prompt: 'Q?', answers: [] }, - phase_view_model: { - status: 'reveal', - round_number: 1, - players_count: 3, - constraints: { - min_players_to_start: 3, - max_players_mvp: 5, - min_players_reached: true, - max_players_allowed: true - }, - host: { - can_start_round: false, - can_show_question: false, - can_mix_answers: false, - can_calculate_scores: false, - can_reveal_scoreboard: true, - can_start_next_round: true, - can_finish_game: true - }, - player: { - can_join: false, - can_submit_lie: false, - can_submit_guess: false, - can_view_final_result: false - } - } - } as const; - - expect(deriveGameplayPhase(session as any)).toBe('reveal'); - expect(isHostGameplayActionAllowed(session as any, 'loadScoreboard')).toBe(true); - expect(isHostGameplayActionAllowed(session as any, 'startNextRound')).toBe(true); - expect(isHostGameplayActionAllowed(session as any, 'finishGame')).toBe(true); - expect(isPlayerGameplayActionAllowed(session as any, 'submitGuess')).toBe(false); - }); -}); diff --git a/frontend/tests/lobby-i18n.contract.test.ts b/frontend/tests/lobby-i18n.contract.test.ts deleted file mode 100644 index 0a20522..0000000 --- a/frontend/tests/lobby-i18n.contract.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import lobbyCatalog from '../../shared/i18n/lobby.json'; -import { lobbyMessage, lobbyMessageFromApiPayload } from '../src/spa/lobby-i18n'; - -describe('shared i18n keyspace contract', () => { - it('keeps en as default and da/en matrix for frontend error keys', () => { - expect(lobbyCatalog.locales.default).toBe('en'); - expect(lobbyCatalog.locales.supported).toEqual(expect.arrayContaining(['en', 'da'])); - - for (const [key, translations] of Object.entries(lobbyCatalog.frontend.errors)) { - expect(translations.en, `${key} missing en`).toBeTruthy(); - expect(translations.da, `${key} missing da`).toBeTruthy(); - } - }); - - it('keeps backend error-code keyspace aligned with shared backend→frontend map and backend translations', () => { - for (const [code, backendKey] of Object.entries(lobbyCatalog.backend.error_codes)) { - const frontendKey = - lobbyCatalog.contract.backend_to_frontend_error_keys[ - code as keyof typeof lobbyCatalog.contract.backend_to_frontend_error_keys - ]; - - expect(lobbyCatalog.backend.errors[backendKey as keyof typeof lobbyCatalog.backend.errors]).toBeDefined(); - expect(frontendKey, `missing frontend mapping for ${code}`).toBeTruthy(); - expect(lobbyCatalog.frontend.errors[frontendKey as keyof typeof lobbyCatalog.frontend.errors]).toBeDefined(); - } - - for (const [key, translations] of Object.entries(lobbyCatalog.backend.errors)) { - expect(translations.en, `${key} missing en`).toBeTruthy(); - expect(translations.da, `${key} missing da`).toBeTruthy(); - } - }); -}); - -describe('lobbyMessage locale handling', () => { - it('uses english by default and falls back to default for unsupported locale', () => { - expect(lobbyMessage('session_code_required')).toBe('Session code is required.'); - expect(lobbyMessage('session_code_required', 'fr')).toBe('Session code is required.'); - }); - - it('resolves locale from api payload and maps known backend error codes directly', () => { - expect( - lobbyMessageFromApiPayload( - { error_code: 'session_not_found', locale: 'da' }, - 'join_failed', - ), - ).toBe('Sessionskoden er ugyldig, eller sessionen findes ikke længere.'); - }); - - it('uses shared backend→frontend key-map at runtime even when fallback key differs', () => { - expect( - lobbyMessageFromApiPayload( - { error_code: 'session_not_joinable', locale: 'da' }, - 'start_round_failed', - ), - ).toBe('Kunne ikke joine. Tjek kode eller kaldenavn og prøv igen.'); - }); - - it('falls back to caller-provided fallback key for unknown backend error codes', () => { - expect( - lobbyMessageFromApiPayload( - { error_code: 'unknown_backend_key', locale: 'da' }, - 'join_failed', - ), - ).toBe('Kunne ikke joine. Tjek kode eller kaldenavn og prøv igen.'); - }); -}); diff --git a/frontend/tests/lobby-loader.parity.test.ts b/frontend/tests/lobby-loader.parity.test.ts deleted file mode 100644 index 91bd038..0000000 --- a/frontend/tests/lobby-loader.parity.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - collectLocaleParityIssues, - LOBBY_I18N_CATALOG, - normalizeLocale, - SUPPORTED_LOCALES, - translateCatalogPath, -} from '../shared/i18n/lobby-loader'; - -describe('shared lobby i18n loader parity', () => { - it('keeps da/en translation parity in shared keyspace', () => { - const issues = collectLocaleParityIssues(LOBBY_I18N_CATALOG, SUPPORTED_LOCALES); - expect(issues).toEqual([]); - }); - - it('normalizes browser-style locale tags to supported keyspace locales', () => { - expect(normalizeLocale('da-DK')).toBe('da'); - expect(normalizeLocale('da_DK')).toBe('da'); - expect(normalizeLocale('en-US')).toBe('en'); - expect(normalizeLocale('en_US')).toBe('en'); - expect(normalizeLocale('fr-FR')).toBe('en'); - }); - - it('resolves shared frontend ui keys with fallback-safe behavior', () => { - expect( - translateCatalogPath(LOBBY_I18N_CATALOG.frontend.ui as Record, 'host.start_round', 'da-DK'), - ).toBe('Start runde'); - - expect( - translateCatalogPath(LOBBY_I18N_CATALOG.frontend.ui as Record, 'app.language_label', 'en-US'), - ).toBe('Language'); - - expect( - translateCatalogPath(LOBBY_I18N_CATALOG.frontend.ui as Record, 'host.non_existing_key', 'da'), - ).toBe('host.non_existing_key'); - }); -}); diff --git a/frontend/tests/session-context-store.test.ts b/frontend/tests/session-context-store.test.ts deleted file mode 100644 index f27fe93..0000000 --- a/frontend/tests/session-context-store.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { createSessionContextStore, type StorageLike } from '../src/spa/session-context-store'; - -function makeMemoryStorage(seed?: Record): StorageLike { - const memory = new Map(Object.entries(seed ?? {})); - return { - getItem: (key: string) => memory.get(key) ?? null, - setItem: (key: string, value: string) => { - memory.set(key, value); - }, - removeItem: (key: string) => { - memory.delete(key); - } - }; -} - -describe('session context store', () => { - it('normalizes and persists sessionCode/playerId/token', () => { - const storage = makeMemoryStorage(); - const store = createSessionContextStore(storage, 'ctx'); - - const value = store.set({ sessionCode: ' abcd12 ', playerId: 12, token: ' token-1 ' }); - expect(value).toEqual({ sessionCode: 'ABCD12', playerId: 12, token: 'token-1' }); - - expect(store.get()).toEqual({ sessionCode: 'ABCD12', playerId: 12, token: 'token-1' }); - expect(storage.getItem('ctx')).toBe('{"sessionCode":"ABCD12","playerId":12,"token":"token-1"}'); - }); - - it('loads from storage and clears invalid payloads', () => { - const storage = makeMemoryStorage({ ctx: '{"sessionCode":"","playerId":0,"token":""}' }); - const store = createSessionContextStore(storage, 'ctx'); - - expect(store.get()).toBeNull(); - expect(storage.getItem('ctx')).toBeNull(); - }); - - it('supports clear()', () => { - const storage = makeMemoryStorage(); - const store = createSessionContextStore(storage, 'ctx'); - - store.set({ sessionCode: 'ABCD12', playerId: 3, token: 'token-3' }); - store.clear(); - - expect(store.get()).toBeNull(); - expect(storage.getItem('ctx')).toBeNull(); - }); - - it('rejects invalid context writes', () => { - const store = createSessionContextStore(); - expect(() => store.set({ sessionCode: '', playerId: 1, token: 'token-1' })).toThrow('sessionCode is required'); - expect(() => store.set({ sessionCode: 'ABCD12', playerId: 0, token: 'token-1' })).toThrow( - 'playerId must be a positive integer' - ); - expect(() => store.set({ sessionCode: 'ABCD12', playerId: 2, token: ' ' })).toThrow('token is required'); - }); -}); diff --git a/frontend/tests/vertical-slice.test.ts b/frontend/tests/vertical-slice.test.ts deleted file mode 100644 index f1c94f8..0000000 --- a/frontend/tests/vertical-slice.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { - createVerticalSliceController, - type SessionContext, - type SessionContextStore -} from '../src/spa/vertical-slice'; -import type { ApiClient } from '../src/api/client'; - -function makeApiMock(overrides?: Partial): ApiClient { - const base: ApiClient = { - health: vi.fn(), - getSession: vi.fn().mockResolvedValue({ - ok: true, - status: 200, - data: { - session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 3 }, - players: [], - round_question: null, - reveal: null, - phase_view_model: { - status: 'lobby', - round_number: 1, - players_count: 3, - constraints: { - min_players_to_start: 3, - max_players_mvp: 5, - min_players_reached: true, - max_players_allowed: true - }, - host: { - can_start_round: true, - can_show_question: false, - can_mix_answers: false, - can_calculate_scores: false, - can_reveal_scoreboard: false, - can_start_next_round: false, - can_finish_game: false - }, - player: { - can_join: true, - can_submit_lie: false, - can_submit_guess: false, - can_view_final_result: false - } - } - } - }), - joinSession: vi.fn().mockResolvedValue({ - ok: true, - status: 201, - data: { player: { id: 9, nickname: 'Maja', session_token: 'token-1', score: 0 }, session: { code: 'ABCD12', status: 'lobby' } } - }), - startRound: vi.fn().mockResolvedValue({ - ok: true, - status: 201, - data: { - session: { code: 'ABCD12', status: 'lie', current_round: 1 }, - round: { number: 1, category: { slug: 'history', name: 'History' } } - } - }), - showQuestion: vi.fn(), - mixAnswers: vi.fn(), - calculateScores: vi.fn(), - getScoreboard: vi.fn(), - startNextRound: vi.fn(), - finishGame: vi.fn(), - submitLie: vi.fn(), - submitGuess: vi.fn() - }; - - return { ...base, ...overrides }; -} - -function makeSessionContextStore(initial: SessionContext | null = null): SessionContextStore { - let value = initial; - return { - get: vi.fn(() => value), - set: vi.fn((next: SessionContext) => { - value = next; - return next; - }) - }; -} - -describe('vertical slice controller: lobby -> join -> start round', () => { - it('uses createSessionContextStore by default (no manual injection)', async () => { - vi.resetModules(); - const defaultStore = { - get: vi.fn(() => null), - set: vi.fn((next: SessionContext) => next), - clear: vi.fn() - }; - - vi.doMock('../src/spa/session-context-store', async () => { - const actual = await vi.importActual('../src/spa/session-context-store'); - return { - ...actual, - createSessionContextStore: vi.fn(() => defaultStore) - }; - }); - - const { createVerticalSliceController: createControllerWithMock } = await import('../src/spa/vertical-slice'); - const api = makeApiMock(); - const controller = createControllerWithMock(api); - - await controller.joinLobby('ABCD12', 'Maja'); - - expect(defaultStore.set).toHaveBeenCalledWith({ - sessionCode: 'ABCD12', - playerId: 9, - token: 'token-1' - }); - - vi.doUnmock('../src/spa/session-context-store'); - vi.resetModules(); - }); - - it('tracks loading and success state for join + start flow', async () => { - const api = makeApiMock(); - const controller = createVerticalSliceController(api); - - const beforeJoinPromise = controller.joinLobby('abcd12', 'Maja'); - expect(controller.getState().joinState).toBe('loading'); - await beforeJoinPromise; - - const postJoin = controller.getState(); - expect(postJoin.joinState).toBe('success'); - expect(postJoin.session?.session.code).toBe('ABCD12'); - - const beforeStartPromise = controller.startRound('abcd12', 'history'); - expect(controller.getState().startRoundState).toBe('loading'); - await beforeStartPromise; - - const postStart = controller.getState(); - expect(postStart.startRoundState).toBe('success'); - }); - - it('persists session context after join and syncs normalized session code', async () => { - const api = makeApiMock(); - const sessionContextStore = makeSessionContextStore(); - const controller = createVerticalSliceController(api, sessionContextStore); - - await controller.joinLobby('abcd12', 'Maja'); - - expect(sessionContextStore.set).toHaveBeenCalledWith({ - sessionCode: 'ABCD12', - playerId: 9, - token: 'token-1' - }); - expect(controller.getState().sessionCode).toBe('ABCD12'); - }); - - it('uses stored session code as fallback for join + hydrate flow when input code is empty', async () => { - const api = makeApiMock(); - const sessionContextStore = makeSessionContextStore({ - sessionCode: 'wxyz99', - playerId: 5, - token: 'token-old' - }); - const controller = createVerticalSliceController(api, sessionContextStore); - - await controller.joinLobby(' ', 'Maja'); - - expect(api.joinSession).toHaveBeenCalledWith({ code: 'WXYZ99', nickname: 'Maja' }); - expect(api.getSession).toHaveBeenCalledWith('ABCD12'); - }); - - it('surfaces a friendly error when join fails', async () => { - const api = makeApiMock({ - joinSession: vi.fn().mockResolvedValue({ - ok: false, - status: 404, - error: { kind: 'http', status: 404, message: 'HTTP 404', payload: { error: 'Session not found', error_code: 'session_not_found' } } - }) - }); - - const controller = createVerticalSliceController(api); - await controller.joinLobby('missing', 'Maja'); - - const state = controller.getState(); - expect(state.joinState).toBe('error'); - expect(state.errorMessage).toBe('Session code is invalid or the session no longer exists.'); - }); - - it('surfaces a friendly error when round start fails', async () => { - const api = makeApiMock({ - startRound: vi.fn().mockResolvedValue({ - ok: false, - status: 400, - error: { kind: 'http', status: 400, message: 'HTTP 400', payload: { error: 'Round can only be started from lobby', error_code: 'round_start_invalid_phase' } } - }) - }); - - const controller = createVerticalSliceController(api); - await controller.startRound('ABCD12', 'history'); - - const state = controller.getState(); - expect(state.startRoundState).toBe('error'); - expect(state.errorMessage).toBe('Could not start round. Refresh the lobby and try again.'); - }); - - it('shows local validation error and avoids API call when hydrating without any session code', async () => { - const api = makeApiMock(); - const controller = createVerticalSliceController(api, makeSessionContextStore(null)); - - await controller.hydrateLobby(' '); - - const state = controller.getState(); - expect(state.errorMessage).toBe('Session code is required.'); - expect(state.loadingSession).toBe(false); - expect(api.getSession).not.toHaveBeenCalled(); - }); - - it('shows local validation error and avoids API call when starting round without any session code', async () => { - const api = makeApiMock(); - const controller = createVerticalSliceController(api, makeSessionContextStore(null)); - - await controller.startRound(' ', 'history'); - - const state = controller.getState(); - expect(state.startRoundState).toBe('error'); - expect(state.errorMessage).toBe('Session code is required.'); - expect(api.startRound).not.toHaveBeenCalled(); - }); - - it('uses joined session code when starting round without a reload', async () => { - const api = makeApiMock(); - const controller = createVerticalSliceController(api); - - await controller.joinLobby(' abcd12 ', 'Maja'); - await controller.startRound('', 'history'); - - expect(api.startRound).toHaveBeenCalledWith('ABCD12', { category_slug: 'history' }); - expect(controller.getState().sessionCode).toBe('ABCD12'); - }); -}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json deleted file mode 100644 index 84641c3..0000000 --- a/frontend/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "strict": true, - "skipLibCheck": true, - "lib": ["ES2022", "DOM"], - "types": ["vitest/globals", "node"] - }, - "include": ["src", "tests", "../shared/i18n/*.json"] -} diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts deleted file mode 100644 index 1ca74b0..0000000 --- a/frontend/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - include: ['tests/**/*.test.ts', 'angular/src/**/*.spec.ts'], - setupFiles: ['angular/src/test-setup.ts'], - exclude: ['**/node_modules/**'] - } -}); diff --git a/fupogfakta/admin.py b/fupogfakta/admin.py index e60566c..0458aef 100644 --- a/fupogfakta/admin.py +++ b/fupogfakta/admin.py @@ -1,57 +1,21 @@ from django.contrib import admin -from .models import Category, Question, GameSession, Player, RoundConfig, RoundQuestion, LieAnswer, Guess, ScoreEvent + +from .models import Category, Question @admin.register(Category) class CategoryAdmin(admin.ModelAdmin): - list_display = ("name", "slug", "is_active") - list_filter = ("is_active",) - search_fields = ("name", "slug") + list_display = ["name", "active", "question_count"] + list_editable = ["active"] + + @admin.display(description="spørgsmål") + def question_count(self, obj): + return obj.questions.count() @admin.register(Question) class QuestionAdmin(admin.ModelAdmin): - list_display = ("id", "category", "is_active") - list_filter = ("category", "is_active") - search_fields = ("prompt", "correct_answer") - - -class PlayerInline(admin.TabularInline): - model = Player - extra = 0 - - -@admin.register(GameSession) -class GameSessionAdmin(admin.ModelAdmin): - list_display = ("code", "host", "status", "current_round", "created_at") - list_filter = ("status",) - inlines = [PlayerInline] - search_fields = ("code", "host__username") - - -@admin.register(RoundConfig) -class RoundConfigAdmin(admin.ModelAdmin): - list_display = ("session", "number", "category", "questions_count", "points_correct", "points_bluff") - list_filter = ("category",) - - -@admin.register(RoundQuestion) -class RoundQuestionAdmin(admin.ModelAdmin): - list_display = ("session", "round_number", "question") - - -@admin.register(LieAnswer) -class LieAnswerAdmin(admin.ModelAdmin): - list_display = ("round_question", "player", "text", "created_at") - - -@admin.register(Guess) -class GuessAdmin(admin.ModelAdmin): - list_display = ("round_question", "player", "selected_text", "is_correct", "fooled_player") - list_filter = ("is_correct",) - - -@admin.register(ScoreEvent) -class ScoreEventAdmin(admin.ModelAdmin): - list_display = ("session", "player", "delta", "reason", "created_at") - list_filter = ("reason",) + list_display = ["prompt", "answer", "category", "active", "times_used"] + list_filter = ["category", "active"] + list_editable = ["active"] + search_fields = ["prompt", "answer", "note"] diff --git a/fupogfakta/apps.py b/fupogfakta/apps.py index 59124cf..5699922 100644 --- a/fupogfakta/apps.py +++ b/fupogfakta/apps.py @@ -1,5 +1,6 @@ from django.apps import AppConfig -class FupogfaktaConfig(AppConfig): - name = 'fupogfakta' +class FupOgFaktaConfig(AppConfig): + name = "fupogfakta" + verbose_name = "Fup og Fakta" diff --git a/fupogfakta/data/questions_a.json b/fupogfakta/data/questions_a.json new file mode 100644 index 0000000..7838fdf --- /dev/null +++ b/fupogfakta/data/questions_a.json @@ -0,0 +1,81 @@ +[ +{"category": "Dyreriget", "prompt": "En flok ugler kaldes på engelsk et _____.", "answer": "parliament", "alternatives": ["et parliament", "parlament"], "decoys": ["library", "choir", "cabinet"], "note": "\"A parliament of owls\" er den klassiske engelske gruppebetegnelse for ugler, inspireret af uglens ry for visdom."}, +{"category": "Dyreriget", "prompt": "Ottearmede blæksprutter har _____ hjerter.", "answer": "tre", "alternatives": ["3"], "decoys": ["to", "fire", "otte"], "note": "To gællehjerter pumper blodet gennem gællerne, og ét systemisk hjerte pumper det rundt i resten af kroppen."}, +{"category": "Dyreriget", "prompt": "Koalaer har _____, der næsten ikke kan skelnes fra menneskers.", "answer": "fingeraftryk", "alternatives": ["fingeraftrykkene"], "decoys": ["tænder", "øjenvipper", "stemmebånd"], "note": "Koalaens fingeraftryk ligner menneskers så meget, at selv eksperter kan have svært ved at se forskel (Henneberg, 1996)."}, +{"category": "Dyreriget", "prompt": "Dolkhalens blod er _____ og bruges til at teste medicin for bakterieforurening.", "answer": "blåt", "alternatives": ["blå", "lyseblåt"], "decoys": ["grønt", "gult", "sort"], "note": "Dolkhaler bruger kobberholdigt hæmocyanin i stedet for jernholdigt hæmoglobin. Deres blod giver LAL-testen for bakterietoksiner."}, +{"category": "Dyreriget", "prompt": "Vombatten er kendt for at lave _____ lorte.", "answer": "terningformede", "alternatives": ["firkantede", "kubiske", "terningeformede"], "decoys": ["spiralformede", "stjerneformede", "trekantede"], "note": "Vombattens tarm har bløde og stive partier, der former afføringen til små terninger. Forskningen fik Ig Nobel-prisen i 2019."}, +{"category": "Dyreriget", "prompt": "En giraf har _____ halshvirvler, præcis lige så mange som et menneske.", "answer": "syv", "alternatives": ["7"], "decoys": ["tolv", "fjorten", "toogtyve"], "note": "Næsten alle pattedyr har syv halshvirvler. Girafens er bare op til 25 cm lange hver."}, +{"category": "Dyreriget", "prompt": "Rensdyrs øjne skifter farve fra gyldne om sommeren til _____ om vinteren.", "answer": "blå", "alternatives": ["dybblå", "blålige"], "decoys": ["røde", "grønne", "hvide"], "note": "Det reflekterende lag bag nethinden ændrer sig i det arktiske vintermørke, så mere lys opfanges (Jeffery m.fl., 2013)."}, +{"category": "Dyreriget", "prompt": "Kolibrier er de eneste fugle, der kan flyve _____ i længere tid.", "answer": "baglæns", "alternatives": ["bagud", "bagud i luften"], "decoys": ["på hovedet", "under vand", "i søvne"], "note": "Kolibriens skulderled lader vingerne rotere, så den kan svæve stille og flyve baglæns."}, +{"category": "Dyreriget", "prompt": "Søstjerner kan skubbe deres _____ ud af kroppen for at fordøje byttet udenfor.", "answer": "mave", "alternatives": ["mavesæk", "maven"], "decoys": ["tunge", "lunge", "hjerne"], "note": "Søstjernen vrænger sin mavesæk ud gennem munden og ind i fx en musling, hvor føden fordøjes."}, +{"category": "Dyreriget", "prompt": "Hannen hos næbdyret har _____ på bagbenene.", "answer": "giftsporer", "alternatives": ["giftpigge", "giftige sporer", "giftige pigge", "sporer"], "decoys": ["sugekopper", "små vinger", "en ekstra tå"], "note": "Næbdyr-hannen er et af de få giftige pattedyr. Giften fra sporerne giver voldsomme smerter hos mennesker."}, +{"category": "Dyreriget", "prompt": "Den europæiske ål, også den fra danske åer, gyder i _____.", "answer": "Sargassohavet", "alternatives": ["sargassohavet", "Sargassohavet i Atlanterhavet"], "decoys": ["Middelhavet", "Østersøen", "Det Kaspiske Hav"], "note": "Danskeren Johannes Schmidt sporede i 1920'erne ålelarverne tilbage til Sargassohavet i Vestatlanten."}, +{"category": "Dyreriget", "prompt": "Narhvalens lange spyd er i virkeligheden en _____.", "answer": "tand", "alternatives": ["hjørnetand", "stødtand"], "decoys": ["næseknogle", "hornplade", "forstenet tunge"], "note": "Spyddet er en hjørnetand, der hos hannen vokser spiralsnoet ud gennem overlæben, typisk på venstre side."}, +{"category": "Dyreriget", "prompt": "Katte kan slet ikke smage _____.", "answer": "sødt", "alternatives": ["sødme", "sukker", "søde ting"], "decoys": ["salt", "surt", "bittert"], "note": "Genet for den ene halvdel af sød-receptoren er defekt hos katte, så de kan ikke registrere sødme."}, +{"category": "Dyreriget", "prompt": "Sommerfugle smager med deres _____.", "answer": "fødder", "alternatives": ["ben", "fod"], "decoys": ["vinger", "øjne", "ryg"], "note": "Smagsreceptorer på fødderne lader hunnen tjekke, om en plante egner sig til at lægge æg på."}, +{"category": "Dyreriget", "prompt": "Ifølge en dansk ledet undersøgelse fra 2016 kan grønlandshajen blive omkring _____ år gammel.", "answer": "400", "alternatives": ["fire hundrede", "392", "næsten 400"], "decoys": ["120", "180", "900"], "note": "Kulstof-14-datering af øjenlinser gav et estimat på 392 ± 120 år, hvilket gør den til verdens længstlevende kendte hvirveldyr."}, + +{"category": "Kroppen", "prompt": "Menneskekroppens mindste knogle sidder i _____.", "answer": "øret", "alternatives": ["mellemøret", "øre", "ørerne"], "decoys": ["lilletåen", "næsen", "lillefingeren"], "note": "Stigbøjlen i mellemøret er kun ca. 3 mm lang."}, +{"category": "Kroppen", "prompt": "_____ i øjet har ingen blodkar og får sin ilt direkte fra luften.", "answer": "hornhinden", "alternatives": ["hornhinde", "cornea", "Hornhinden"], "decoys": ["nethinden", "regnbuehinden", "synsnerven"], "note": "Hornhinden skal være helt gennemsigtig og har derfor ingen blodkar. Ilten optages fra tårefilmen og luften."}, +{"category": "Kroppen", "prompt": "Nyfødte har endnu ingen knæskaller af knogle, de består nemlig af _____.", "answer": "brusk", "alternatives": ["bruskvæv"], "decoys": ["fedt", "muskler", "væske"], "note": "Knæskallen begynder først at forbene sig i 3-6-årsalderen."}, +{"category": "Kroppen", "prompt": "Patienter kan være vågne under hjerneoperationer, fordi selve hjernevævet mangler _____.", "answer": "smertereceptorer", "alternatives": ["smertesensorer", "smertenerver", "nociceptorer", "smertefølere"], "decoys": ["blodkar", "nerveceller", "fedt"], "note": "Hjernen registrerer smerte i resten af kroppen, men hjernevævet selv har ingen smertereceptorer."}, +{"category": "Kroppen", "prompt": "Mavesækken undgår at fordøje sig selv ved hele tiden at danne et nyt lag _____.", "answer": "slim", "alternatives": ["slimhinde", "slimlag"], "decoys": ["fedt", "kalk", "hår"], "note": "Et tykt, basisk slimlag beskytter mavevæggen mod saltsyren og fordøjelsesenzymerne."}, +{"category": "Kroppen", "prompt": "Tandemalje er det _____ væv i menneskekroppen.", "answer": "hårdeste", "alternatives": ["stærkeste", "mest hårde"], "decoys": ["blødeste", "letteste", "mest elastiske"], "note": "Emaljen består af ca. 96 % mineral og er hårdere end knogle."}, +{"category": "Kroppen", "prompt": "Gåsehud er en rest fra dengang, vores forfædre havde _____.", "answer": "pels", "alternatives": ["mere pels", "mere hår", "kropsbehåring", "tæt pels"], "decoys": ["fjer", "skæl", "pigge"], "note": "Små muskler rejser hårene. Hos pelsdyr giver det isolering eller får dyret til at se større ud."}, +{"category": "Kroppen", "prompt": "Det lægelige navn for hikke er _____.", "answer": "singultus", "alternatives": ["singultation"], "decoys": ["diafragmose", "hiccupitis", "spasmus glottis"], "note": "Singultus kommer af latin og betyder noget i retning af et hulk eller en gisp."}, +{"category": "Kroppen", "prompt": "Babyer fødes med omkring 300 knogler, men en voksen har kun _____.", "answer": "206", "alternatives": ["to hundrede og seks"], "decoys": ["250", "180", "312"], "note": "Mange knogler vokser sammen gennem barndommen, fx i kraniet og bækkenet."}, +{"category": "Kroppen", "prompt": "Knæk i fingrene skyldes _____ i ledvæsken.", "answer": "gasbobler", "alternatives": ["bobler", "luftbobler", "gas", "en gasboble"], "decoys": ["kalkkrystaller", "små knoglesplinter", "størknet blod"], "note": "Når leddet trækkes fra hinanden, falder trykket, og der dannes en gasboble i ledvæsken (MR-studie, 2015)."}, +{"category": "Kroppen", "prompt": "Om ens ørevoks er tør eller våd, afgøres af ét enkelt _____.", "answer": "gen", "alternatives": ["genet", "arveanlæg"], "decoys": ["hormon", "vitamin", "mineral"], "note": "Varianter af genet ABCC11 bestemmer ørevoksens type. Tør voks er almindelig i Østasien (2006)."}, +{"category": "Kroppen", "prompt": "Hjernen udgør kun ca. 2 % af kropsvægten, men bruger omkring _____ af kroppens energi.", "answer": "20 procent", "alternatives": ["20 %", "20%", "en femtedel", "tyve procent"], "decoys": ["5 procent", "50 procent", "2 procent"], "note": "I hvile står hjernen for omkring en femtedel af kroppens energiforbrug."}, +{"category": "Kroppen", "prompt": "Den danske læge Niels Finsen fik Nobelprisen i 1903 for at behandle hudtuberkulose med _____.", "answer": "lys", "alternatives": ["lysbehandling", "koncentreret lys", "UV-lys"], "decoys": ["kulde", "radium", "honning"], "note": "Finsen brugte koncentreret lys mod lupus vulgaris og var Danmarks første nobelpristager i medicin."}, +{"category": "Kroppen", "prompt": "K-vitamin har sit bogstav fra ordet _____.", "answer": "koagulation", "alternatives": ["koagulering", "Koagulation"], "decoys": ["kalk", "kartoffel", "kost"], "note": "Danske Henrik Dam opdagede vitaminet og opkaldte det efter dets rolle i blodets størkning. Han fik Nobelprisen i 1943."}, +{"category": "Kroppen", "prompt": "Et voksent menneskehjerte slår omkring _____ gange i døgnet.", "answer": "100.000", "alternatives": ["100000", "hundrede tusind", "100 000"], "decoys": ["10.000", "30.000", "1 million"], "note": "Med en hvilepuls på 60-80 slag i minuttet giver det ca. 86.000-115.000 slag i døgnet."}, + +{"category": "Mad og drikke", "prompt": "Jordnødder er botanisk set ikke nødder, men _____.", "answer": "bælgfrugter", "alternatives": ["bælgplanter", "ærteplanter", "bælgfrugt"], "decoys": ["rodknolde", "kornsorter", "citrusfrugter"], "note": "Jordnødden er i familie med ærter og bønner og modner sine bælge under jorden."}, +{"category": "Mad og drikke", "prompt": "Kiwifrugten hed oprindeligt _____.", "answer": "kinesisk stikkelsbær", "alternatives": ["kinesiske stikkelsbær", "Chinese gooseberry"], "decoys": ["australsk kartoffel", "abeæble", "behåret blomme"], "note": "Frugten stammer fra Kina. New Zealandske eksportører omdøbte den til kiwifrugt omkring 1959."}, +{"category": "Mad og drikke", "prompt": "De første dyrkede gulerødder var ikke orange, men _____.", "answer": "lilla", "alternatives": ["gule", "lilla og gule", "violette"], "decoys": ["blå", "grønne", "lyserøde"], "note": "Tidlige gulerødder fra Centralasien var lilla eller gule. Den orange gulerod blev udviklet i Europa, især i Holland."}, +{"category": "Mad og drikke", "prompt": "Kaffebønner er egentlig _____ fra en bærlignende frugt.", "answer": "frø", "alternatives": ["kerner", "stenkerner", "frøene"], "decoys": ["rødder", "blomsterknopper", "tørrede bær"], "note": "Hvert kaffebær indeholder normalt to frø, som ristes til det, vi kalder kaffebønner."}, +{"category": "Mad og drikke", "prompt": "Den wasabi, man får til sushi i Europa, er oftest farvet _____.", "answer": "peberrod", "alternatives": ["peberrødder"], "decoys": ["ingefær", "rucola", "ærter"], "note": "Ægte wasabi er dyr og svær at dyrke, så de fleste tuber indeholder peberrod med grøn farve."}, +{"category": "Mad og drikke", "prompt": "Frisk ananas får gelatine-dessert til ikke at stivne, fordi et enzym i frugten nedbryder _____.", "answer": "protein", "alternatives": ["proteiner", "proteinet"], "decoys": ["sukker", "fedt", "stivelse"], "note": "Ananas indeholder bromelain, der klipper gelatinens proteinkæder over. Dåseananas er varmebehandlet og virker fint."}, +{"category": "Mad og drikke", "prompt": "En grøn peberfrugt er som regel bare en _____ rød peberfrugt.", "answer": "umoden", "alternatives": ["ikke moden", "uopmoden"], "decoys": ["syg", "tørret", "frossen"], "note": "De fleste peberfrugter skifter fra grøn til gul, orange eller rød, når de modner på planten."}, +{"category": "Mad og drikke", "prompt": "Ægte parmesan er ikke vegetarisk, fordi den laves med løbe fra maven af _____.", "answer": "kalve", "alternatives": ["kalv", "unge kalve", "kalvemaver"], "decoys": ["grise", "heste", "kaniner"], "note": "Reglerne for Parmigiano Reggiano kræver naturlig kalveløbe, et enzym fra kalvens mave."}, +{"category": "Mad og drikke", "prompt": "Cashewnødder sælges aldrig i skal, fordi skallen indeholder samme hudirriterende stof som _____.", "answer": "giftsumak", "alternatives": ["poison ivy", "giftsumakken"], "decoys": ["brændenælder", "bjørneklo", "chili"], "note": "Skallen indeholder urushiol, det samme stof som i giftsumak. Cashew og giftsumak er i samme plantefamilie."}, +{"category": "Mad og drikke", "prompt": "Carlsberg er opkaldt efter brygger J.C. Jacobsens _____.", "answer": "søn", "alternatives": ["søn Carl", "Carl"], "decoys": ["hest", "hund", "kone"], "note": "Bryggeriet fra 1847 blev opkaldt efter sønnen Carl Jacobsen plus \"bjerg\" for bakken i Valby."}, +{"category": "Mad og drikke", "prompt": "Sort te og grøn te er lavet af blade fra _____, forskellen ligger kun i forarbejdningen.", "answer": "samme plante", "alternatives": ["den samme plante", "samme busk", "den samme busk", "teplanten"], "decoys": ["to forskellige buske", "to forskellige træer", "hver sin palme"], "note": "Begge kommer fra Camellia sinensis. Sort te oxideres, grøn te varmebehandles hurtigt for at undgå det."}, +{"category": "Mad og drikke", "prompt": "Bananer er en lille smule _____ på grund af deres indhold af kalium.", "answer": "radioaktive", "alternatives": ["radioaktiv"], "decoys": ["magnetiske", "selvlysende", "giftige"], "note": "En lille del af naturligt kalium er den radioaktive isotop kalium-40. Strålingen er helt ufarlig."}, +{"category": "Mad og drikke", "prompt": "Safran er tørrede støvfang fra en _____.", "answer": "krokus", "alternatives": ["safrankrokus", "krokusblomst"], "decoys": ["tulipan", "lilje", "orkidé"], "note": "Hver blomst af Crocus sativus giver kun tre støvfang, som plukkes i hånden. Derfor er safran så dyrt."}, +{"category": "Mad og drikke", "prompt": "Vanilje kommer fra frugten af en _____.", "answer": "orkidé", "alternatives": ["orkide", "vaniljeorkidé", "orkidéart"], "decoys": ["palme", "lilje", "krokus"], "note": "Vaniljestangen er en frøkapsel fra slyngplanten Vanilla planifolia, som er en orkidé."}, +{"category": "Mad og drikke", "prompt": "_____ kan ikke mærke det brændende i chili og spreder derfor frøene.", "answer": "fugle", "alternatives": ["fuglene", "fugl"], "decoys": ["køer", "rotter", "heste"], "note": "Fuglenes receptor reagerer ikke på capsaicin. Pattedyr, der ville knuse frøene, skræmmes væk af styrken."}, + +{"category": "Rummet og naturen", "prompt": "Én omdrejning om Venus' egen akse tager længere tid end ét _____ på Venus.", "answer": "år", "alternatives": ["venusår", "Venus-år", "år på Venus"], "decoys": ["århundrede", "årti", "årtusinde"], "note": "Venus roterer på 243 jorddøgn, men kredser om Solen på kun 225 jorddøgn."}, +{"category": "Rummet og naturen", "prompt": "Månen bevæger sig hvert år ca. _____ længere væk fra Jorden.", "answer": "3,8 centimeter", "alternatives": ["3,8 cm", "4 centimeter", "4 cm", "knap 4 centimeter"], "decoys": ["3 millimeter", "40 centimeter", "1 meter"], "note": "Målt med laserstråler mod spejle, som Apollo-astronauterne efterlod på Månen."}, +{"category": "Rummet og naturen", "prompt": "Et lyn kan opvarme luften til ca. _____ gange Solens overfladetemperatur.", "answer": "fem", "alternatives": ["5"], "decoys": ["to", "hundrede", "tusind"], "note": "Lynkanalen kan nå op mod 30.000 °C, mens Solens overflade er ca. 5.500 °C."}, +{"category": "Rummet og naturen", "prompt": "Planeten Saturn har lavere massefylde end _____.", "answer": "vand", "alternatives": ["vand har", "almindeligt vand"], "decoys": ["luft", "helium", "vanddamp"], "note": "Saturns gennemsnitlige massefylde er ca. 0,69 g/cm³, mindre end vands 1 g/cm³."}, +{"category": "Rummet og naturen", "prompt": "Jorden er den eneste planet i solsystemet, der ikke er opkaldt efter en _____.", "answer": "gud", "alternatives": ["guddom", "gudinde", "gud eller gudinde", "mytologisk figur"], "decoys": ["farve", "stjerne", "romersk kejser"], "note": "De øvrige planeter har navne fra græsk eller romersk mytologi. Jorden har et gammelt germansk navn."}, +{"category": "Rummet og naturen", "prompt": "Den danske astronom Ole Rømer viste i 1676 ved hjælp af Jupiters måne Io, at _____ ikke bevæger sig uendelig hurtigt.", "answer": "lyset", "alternatives": ["lys"], "decoys": ["tyngdekraften", "magnetismen", "tiden"], "note": "Io's formørkelser kom for sent, når Jorden var langt fra Jupiter. Rømer forklarede det med lysets endelige hastighed."}, +{"category": "Rummet og naturen", "prompt": "Visse bambusarter kan vokse næsten _____ på ét døgn.", "answer": "en meter", "alternatives": ["1 meter", "91 centimeter", "90 centimeter"], "decoys": ["en millimeter", "en centimeter", "ti meter"], "note": "Nogle bambusarter er målt til at vokse op til 91 cm på 24 timer, blandt de hurtigste planter i verden."}, +{"category": "Rummet og naturen", "prompt": "Diamant og blyantens grafit består begge af rent _____.", "answer": "kulstof", "alternatives": ["carbon", "kul"], "decoys": ["silicium", "jern", "kvarts"], "note": "Forskellen ligger kun i, hvordan kulstofatomerne er bundet sammen."}, +{"category": "Rummet og naturen", "prompt": "Rav er forstenet _____.", "answer": "harpiks", "alternatives": ["træharpiks", "kåde", "harpiks fra træer"], "decoys": ["honning", "fiskeolie", "mos"], "note": "Det baltiske rav, der skyller op på danske strande, er omkring 40 millioner år gammel harpiks fra nåletræer."}, +{"category": "Rummet og naturen", "prompt": "Omkring halvdelen af Jordens ilt produceres af _____ i havet.", "answer": "plankton", "alternatives": ["planteplankton", "fytoplankton", "mikroalger", "alger"], "decoys": ["hvaler", "vandmænd", "fisk"], "note": "Mikroskopiske planteplankton står ifølge NOAA for ca. halvdelen af Jordens iltproduktion."}, +{"category": "Rummet og naturen", "prompt": "Toppen af Mount Everest består af kalksten med fossiler af _____.", "answer": "havdyr", "alternatives": ["dyr fra havet", "havdyr som trilobitter", "trilobitter", "søliljer"], "decoys": ["dinosaurer", "mammutter", "palmer"], "note": "Kalkstenen blev dannet på havbunden for ca. 450 millioner år siden og senere presset op af kontinentalpladerne."}, +{"category": "Rummet og naturen", "prompt": "Jordens indre kerne er omtrent lige så varm som _____ overflade.", "answer": "Solens", "alternatives": ["solens"], "decoys": ["Månens", "Mars'", "Venus'"], "note": "Den indre kerne er ca. 5.200-5.400 °C, næsten som Solens overflade på ca. 5.500 °C."}, +{"category": "Rummet og naturen", "prompt": "Ifølge en opgørelse fra 2015 findes der omkring 3.000 milliarder _____ på Jorden, flere end der er stjerner i Mælkevejen.", "answer": "træer", "alternatives": ["træ"], "decoys": ["fugle", "katte", "mennesker"], "note": "Studiet i Nature (Crowther m.fl., 2015) kom frem til ca. 3,04 billioner træer. Mælkevejen har 100-400 milliarder stjerner."}, +{"category": "Rummet og naturen", "prompt": "Målt på nedbør er Antarktis verdens største _____.", "answer": "ørken", "alternatives": ["ørkenområde", "polarørken"], "decoys": ["regnskov", "sump", "steppe"], "note": "En ørken defineres ud fra lidt nedbør, ikke varme. Indlandet på Antarktis får kun få centimeter om året."}, +{"category": "Rummet og naturen", "prompt": "Dværgplaneten Pluto blev i 1930 navngivet efter forslag fra en _____-årig skolepige.", "answer": "11", "alternatives": ["elleve"], "decoys": ["6", "8", "16"], "note": "Den 11-årige Venetia Burney fra Oxford foreslog navnet efter underverdenens romerske gud."}, + +{"category": "Opfindelser", "prompt": "Den danske kemiker S.P.L. Sørensen indførte pH-skalaen i 1909, mens han arbejdede for _____.", "answer": "Carlsberg", "alternatives": ["Carlsberg Laboratorium", "Carlsbergs laboratorium", "Carlsberg Laboratoriet", "Carlsberg Laboratoriet i Valby"], "decoys": ["Tuborg", "Mærsk", "Københavns Zoo"], "note": "Sørensen ledte kemiafdelingen på Carlsberg Laboratorium og havde brug for et præcist mål for surhed i bryggeprocessen."}, +{"category": "Opfindelser", "prompt": "Navnet LEGO kommer af de danske ord _____.", "answer": "leg godt", "alternatives": ["\"leg godt\"", "leg-godt"], "decoys": ["lær godt", "let og god", "lege og gøre"], "note": "Ole Kirk Christiansen fandt på navnet i 1934 ud fra \"leg godt\"."}, +{"category": "Opfindelser", "prompt": "H.C. Ørsted var i 1825 den første til at fremstille metallet _____.", "answer": "aluminium", "alternatives": ["aluminum"], "decoys": ["titan", "zink", "magnesium"], "note": "Ørsted fremstillede som den første (uren) aluminium. Wöhler forbedrede metoden få år senere."}, +{"category": "Opfindelser", "prompt": "Danske Valdemar Poulsen opfandt i 1898 den første maskine, der kunne optage lyd _____.", "answer": "magnetisk", "alternatives": ["magnetisk på ståltråd", "på ståltråd", "magnetiske"], "decoys": ["digitalt", "optisk", "i stereo"], "note": "Poulsens telegrafon optog lyd magnetisk på ståltråd og er forfader til båndoptageren og harddisken."}, +{"category": "Opfindelser", "prompt": "Bobleplast blev oprindeligt opfundet som _____.", "answer": "tapet", "alternatives": ["tapet med struktur", "vægtapet"], "decoys": ["badeforhæng", "legetøj", "regnfrakker"], "note": "Al Fielding og Marc Chavannes forsøgte i 1957 at lave et moderne strukturtapet. Det blev ikke en succes."}, +{"category": "Opfindelser", "prompt": "Mikrobølgeovnen blev opfundet, efter at radarudstyr havde smeltet en _____ i ingeniørens lomme.", "answer": "chokoladebar", "alternatives": ["slikbar", "chokolade", "slik", "chokoladeplade"], "decoys": ["smørklat", "stearinlys", "ostemad"], "note": "Percy Spencer opdagede i 1945 effekten ved en magnetron og fik patent på mikrobølgeovnen."}, +{"category": "Opfindelser", "prompt": "Limen på Post-it-sedler var egentlig et fejlslagent forsøg på at lave en _____ lim.", "answer": "superstærk", "alternatives": ["meget stærk", "ekstra stærk", "stærk"], "decoys": ["spiselig", "vandopløselig", "selvlysende"], "note": "Spencer Silver hos 3M ville i 1968 lave en superstærk lim, men fik en svag lim, der kunne genbruges."}, +{"category": "Opfindelser", "prompt": "Burrebåndet (velcro) blev opfundet, efter at opfinderen undrede sig over _____ i sin hunds pels.", "answer": "burrer", "alternatives": ["burresnerre", "burresnerrer", "borrer", "frøstande"], "decoys": ["fuglefjer", "edderkoppespind", "tang"], "note": "Schweizeren George de Mestral studerede burrernes små kroge under mikroskop efter en gåtur i 1941."}, +{"category": "Opfindelser", "prompt": "Den allerførste computermus fra 1964 var lavet af _____.", "answer": "træ", "alternatives": ["en træklods", "træklods"], "decoys": ["glas", "porcelæn", "kork"], "note": "Douglas Engelbarts prototype var en træklods med to metalhjul under."}, +{"category": "Opfindelser", "prompt": "Bluetooth er opkaldt efter den danske konge _____.", "answer": "Harald Blåtand", "alternatives": ["Harald", "Harald Bluetooth", "Harald 1."], "decoys": ["Gorm den Gamle", "Svend Tveskæg", "Knud den Store"], "note": "Harald Blåtand samlede Danmark og Norge, ligesom teknologien skulle samle enheder. Logoet er hans runeinitialer."}, +{"category": "Opfindelser", "prompt": "Coca-Cola blev i 1886 oprindeligt solgt som _____.", "answer": "medicin", "alternatives": ["nervemedicin", "patentmedicin", "hovedpinemedicin", "et lægemiddel", "lægemiddel", "tonic"], "decoys": ["hårvand", "rengøringsmiddel", "rottegift"], "note": "Apotekeren John Pemberton markedsførte drikken som et middel mod bl.a. hovedpine og nervøsitet."}, +{"category": "Opfindelser", "prompt": "Danske Karl Krøyer hævede i 1964 et sunket skib i Kuwait ved at fylde det med _____.", "answer": "isoporkugler", "alternatives": ["flamingokugler", "polystyrenkugler", "isopor", "flamingo", "plastkugler", "polystyren"], "decoys": ["bordtennisbolde", "badebolde", "balloner"], "note": "Omkring 27 millioner små polystyrenkugler blev pumpet ned i skibet, så det fik opdrift nok til at flyde."}, +{"category": "Opfindelser", "prompt": "Verdens første webcam blev sat op i 1991 for at holde øje med en _____.", "answer": "kaffekande", "alternatives": ["kaffemaskine", "kaffekolbe", "kaffekande på universitetet"], "decoys": ["fuglerede", "parkeringsplads", "fiskedam"], "note": "Forskere på Cambridge Universitet kunne fra skærmen se, om der var kaffe i kanden i Trojan Room."}, +{"category": "Opfindelser", "prompt": "Den første vare, der blev scannet med stregkode i et supermarked i 1974, var en pakke _____.", "answer": "tyggegummi", "alternatives": ["Wrigley's tyggegummi", "Juicy Fruit"], "decoys": ["cigaretter", "havregryn", "kaffe"], "note": "En pakke Wrigley's Juicy Fruit blev scannet i Marsh Supermarket i Ohio den 26. juni 1974."}, +{"category": "Opfindelser", "prompt": "H.C. Ørsted opdagede i 1820, at elektrisk strøm kan få en _____ til at bevæge sig.", "answer": "kompasnål", "alternatives": ["magnetnål", "kompas"], "decoys": ["pendul", "termometersøjle", "barometernål"], "note": "Opdagelsen af elektromagnetismen lagde grunden til elmotoren, generatoren og telegrafen."} +] diff --git a/fupogfakta/data/questions_b.json b/fupogfakta/data/questions_b.json new file mode 100644 index 0000000..170b66b --- /dev/null +++ b/fupogfakta/data/questions_b.json @@ -0,0 +1,1198 @@ +[ + { + "category": "Danmark", + "prompt": "Navnet LEGO er dannet af de to danske ord _____.", + "answer": "leg godt", + "alternatives": [ + "leg godt", + "\"leg godt\"", + "leggodt" + ], + "decoys": [ + "lille gode", + "lad os lege", + "let og god" + ], + "note": "Ole Kirk Christiansen dannede i 1934 navnet LEGO af \"LEg GOdt\"." + }, + { + "category": "Danmark", + "prompt": "Den Lille Havfrues krop er modelleret efter billedhuggerens _____.", + "answer": "kone", + "alternatives": [ + "hustru", + "kone", + "egen kone", + "eline eriksen" + ], + "decoys": [ + "datter", + "søster", + "mor" + ], + "note": "Edvard Eriksens hustru Eline stod model til kroppen; ballerinaen Ellen Price til hovedet." + }, + { + "category": "Danmark", + "prompt": "I 1716 red den russiske zar _____ til hest op ad sneglegangen i Rundetårn.", + "answer": "Peter den Store", + "alternatives": [ + "peter den store", + "peter 1.", + "peter i", + "zar peter" + ], + "decoys": [ + "Ivan den Grusomme", + "Alexander 1.", + "Nikolaj 1." + ], + "note": "Peter den Store red op i Rundetårn i 1716, og zarinaen fulgte efter i karet." + }, + { + "category": "Danmark", + "prompt": "Astronomen Ole Rømer, der påviste lysets hastighed, var også Københavns _____.", + "answer": "politimester", + "alternatives": [ + "politimester", + "politichef", + "politidirektør" + ], + "decoys": [ + "bøddel", + "domprovst", + "hofnar" + ], + "note": "Ole Rømer blev i 1705 udnævnt til politimester i København." + }, + { + "category": "Danmark", + "prompt": "For at skjule to Nobelmedaljer for nazisterne opløste man dem i 1940 i _____ på Niels Bohr Instituttet.", + "answer": "kongevand", + "alternatives": [ + "kongevand", + "aqua regia" + ], + "decoys": [ + "svovlsyre", + "kviksølv", + "saltsyre" + ], + "note": "George de Hevesy opløste von Laues og Francks guldmedaljer i kongevand; guldet blev senere genvundet og medaljerne støbt på ny." + }, + { + "category": "Danmark", + "prompt": "Under pseudonymet Ingahild Grathmer illustrerede dronning Margrethe den danske udgave af _____.", + "answer": "Ringenes Herre", + "alternatives": [ + "ringenes herre", + "lord of the rings", + "ringenes herre-trilogien" + ], + "decoys": [ + "Peter Pan", + "Narnia", + "Harry Potter" + ], + "note": "Dronningens tegninger blev brugt i den danske udgave af Tolkiens Ringenes Herre fra 1977." + }, + { + "category": "Danmark", + "prompt": "Under søslaget ved Kolberger Heide i 1644 mistede Christian 4. _____.", + "answer": "et øje", + "alternatives": [ + "øje", + "et øje", + "sit øje", + "sit højre øje", + "højre øje" + ], + "decoys": [ + "en arm", + "et ben", + "sin højre hånd" + ], + "note": "Kongen blev ramt af splinter og mistede sit højre øje, men blev på dækket af Trefoldigheden." + }, + { + "category": "Danmark", + "prompt": "Ved danske bryllupper er det tradition, at gæsterne klipper i brudgommens _____.", + "answer": "sokker", + "alternatives": [ + "sokker", + "strømper", + "tæer på sokkerne" + ], + "decoys": [ + "seler", + "skjorteærmer", + "skolisser" + ], + "note": "Efter brudevalsen løfter gæsterne brudgommen og klipper tæerne af hans sokker." + }, + { + "category": "Danmark", + "prompt": "En ugift dansker bliver på sin 25-års fødselsdag overhældt med _____.", + "answer": "kanel", + "alternatives": [ + "kanel", + "stødt kanel" + ], + "decoys": [ + "kakao", + "karry", + "sukker" + ], + "note": "Pebersvende og pebermøer får kanel som 25-årige; som 30-årige bliver det peber." + }, + { + "category": "Danmark", + "prompt": "Filosoffen Friedrich Nietzsche skrev på en dansk opfindelse kaldet en _____.", + "answer": "skrivekugle", + "alternatives": [ + "skrivekugle", + "malling-hansens skrivekugle" + ], + "decoys": [ + "stenografimaskine", + "skrivetavle", + "trykpresse" + ], + "note": "Præsten Rasmus Malling-Hansens skrivekugle var en af de første kommercielle skrivemaskiner; Nietzsche brugte en i 1882." + }, + { + "category": "Danmark", + "prompt": "Den trådløse teknologi Bluetooth er opkaldt efter _____.", + "answer": "Harald Blåtand", + "alternatives": [ + "harald blåtand", + "harald bluetooth", + "harald 1.", + "kong harald" + ], + "decoys": [ + "Gorm den Gamle", + "Svend Tveskæg", + "Knud den Store" + ], + "note": "Navnet hylder vikingekongen der samlede Danmark, ligesom teknologien skulle samle enheder; logoet er hans runeinitialer." + }, + { + "category": "Danmark", + "prompt": "Dyrehavsbakken, der regnes for verdens ældste forlystelsespark, går tilbage til år _____.", + "answer": "1583", + "alternatives": [ + "1583" + ], + "decoys": [ + "1721", + "1843", + "1652" + ], + "note": "Bakken opstod omkring Kirsten Piils Kilde, der blev fundet i 1583 og trak folk til." + }, + { + "category": "Danmark", + "prompt": "Af frygt for at blive begravet levende lagde H.C. Andersen om natten en seddel ved sengen om, at han kun var _____.", + "answer": "skindød", + "alternatives": [ + "skindød", + "skin-død", + "tilsyneladende død" + ], + "decoys": [ + "i dvale", + "besvimet", + "i trance" + ], + "note": "Andersen led af en stærk frygt for at blive erklæret død og begravet levende." + }, + { + "category": "Danmark", + "prompt": "Kong Frederik 9. havde fra sin tid i flåden flere _____.", + "answer": "tatoveringer", + "alternatives": [ + "tatoveringer", + "tatoveringer på kroppen", + "tatoos" + ], + "decoys": [ + "papegøjer", + "guldtænder", + "øreringe" + ], + "note": "Frederik 9. var søofficer og fik bl.a. en drage tatoveret, hvilket var velkendt." + }, + { + "category": "Danmark", + "prompt": "Tycho Brahe mistede en del af sin _____ i en duel og bar en metalprotese resten af livet.", + "answer": "næse", + "alternatives": [ + "næse", + "næseryg" + ], + "decoys": [ + "øre", + "finger", + "hage" + ], + "note": "I 1566 duellerede den unge Tycho med fætteren Manderup Parsberg og mistede en del af næsen." + }, + { + "category": "Sprog og ord", + "prompt": "Det meget danske ord hygge stammer oprindeligt fra _____.", + "answer": "norsk", + "alternatives": [ + "norsk", + "oldnorsk", + "norrønt" + ], + "decoys": [ + "tysk", + "hollandsk", + "finsk" + ], + "note": "Hygge kom ind i dansk fra norsk omkring 1800, med rod i norrønt hugga, 'trøste'." + }, + { + "category": "Sprog og ord", + "prompt": "Ordet robot kommer fra et tjekkisk ord for _____.", + "answer": "tvangsarbejde", + "alternatives": [ + "tvangsarbejde", + "slavearbejde", + "hoveri", + "pligtarbejde" + ], + "decoys": [ + "jernmand", + "maskine", + "tjener" + ], + "note": "Karel Čapek brugte ordet i skuespillet R.U.R. (1920); tjekkisk robota betyder hoveri/tvangsarbejde." + }, + { + "category": "Sprog og ord", + "prompt": "Ordet muskel kommer af et latinsk ord, der betyder lille _____.", + "answer": "mus", + "alternatives": [ + "mus" + ], + "decoys": [ + "slange", + "fisk", + "orm" + ], + "note": "Latin musculus betyder 'lille mus'; en spændt muskel blev sammenlignet med en mus under huden." + }, + { + "category": "Sprog og ord", + "prompt": "Ordet vaccine kommer af det latinske ord for _____.", + "answer": "ko", + "alternatives": [ + "ko", + "køer", + "vacca" + ], + "decoys": [ + "nål", + "gift", + "kur" + ], + "note": "Latin vacca = ko; Edward Jenner brugte kokopper til at vaccinere mod kopper." + }, + { + "category": "Sprog og ord", + "prompt": "Ordet panik er afledt af navnet på den græske gud _____.", + "answer": "Pan", + "alternatives": [ + "pan" + ], + "decoys": [ + "Hermes", + "Ares", + "Dionysos" + ], + "note": "Hyrdeguden Pan kunne ifølge grækerne sprede pludselig, ustyrlig rædsel." + }, + { + "category": "Sprog og ord", + "prompt": "Nikotin er opkaldt efter den franske diplomat _____.", + "answer": "Jean Nicot", + "alternatives": [ + "jean nicot", + "nicot" + ], + "decoys": [ + "Pierre Tabard", + "Louis Nicolet", + "Jacques Tobin" + ], + "note": "Jean Nicot, fransk gesandt i Portugal, sendte tobak til det franske hof omkring 1560." + }, + { + "category": "Sprog og ord", + "prompt": "Ordet algoritme er en forvanskning af navnet på matematikeren _____.", + "answer": "al-Khwarizmi", + "alternatives": [ + "al-khwarizmi", + "alkhwarizmi", + "al khwarizmi", + "al-khwārizmī", + "al-kwarizmi", + "al-chwarizmi", + "khwarizmi" + ], + "decoys": [ + "Ibn Sina", + "al-Biruni", + "Omar Khayyam" + ], + "note": "Den persiske matematiker al-Khwarizmi (ca. 780-850) blev på latin til 'Algoritmi'." + }, + { + "category": "Sprog og ord", + "prompt": "Det engelske ord window kommer fra oldnordisk og betyder egentlig _____.", + "answer": "vindøje", + "alternatives": [ + "vindøje", + "vind-øje", + "vindauga", + "vindens øje" + ], + "decoys": [ + "vindhul", + "lyskarm", + "husøje" + ], + "note": "Norrønt vindauga = vind + øje, et hul i taget hvor vinden kom ind." + }, + { + "category": "Sprog og ord", + "prompt": "Ordet karantæne kommer af det italienske ord for _____.", + "answer": "fyrre dage", + "alternatives": [ + "fyrre dage", + "40 dage", + "fyrretyve dage", + "fyrre" + ], + "decoys": [ + "tretten dage", + "syv uger", + "lukket havn" + ], + "note": "I Venedig skulle skibe under pest ligge isoleret i quaranta giorni, 40 dage." + }, + { + "category": "Sprog og ord", + "prompt": "Ordet slogan kommer fra skotsk gælisk, hvor det betød _____.", + "answer": "krigsråb", + "alternatives": [ + "krigsråb", + "kampråb", + "hærråb" + ], + "decoys": [ + "kampsang", + "hyrdekald", + "bordbøn" + ], + "note": "Gælisk sluagh-ghairm = 'hærens råb', klanernes krigsråb." + }, + { + "category": "Sprog og ord", + "prompt": "Ordet alkohol kommer fra arabisk al-kuhl, et sort pulver brugt som _____.", + "answer": "øjensminke", + "alternatives": [ + "øjensminke", + "sminke", + "eyeliner", + "kajal", + "kohl" + ], + "decoys": [ + "krudt", + "blæk", + "tandpulver" + ], + "note": "Kohl var fint antimonpulver til øjensminke; senere kom ordet til at betyde 'essensen' af et stof." + }, + { + "category": "Sprog og ord", + "prompt": "Ordet tulipan kommer fra et tyrkisk-persisk ord for _____.", + "answer": "turban", + "alternatives": [ + "turban" + ], + "decoys": [ + "hjelm", + "bæger", + "lanterne" + ], + "note": "Blomstens form mindede om en turban (tyrkisk tülbend, persisk dulband)." + }, + { + "category": "Sprog og ord", + "prompt": "Det engelske ord ombudsman er lånt fra _____.", + "answer": "svensk", + "alternatives": [ + "svensk", + "sverige" + ], + "decoys": [ + "hollandsk", + "tysk", + "finsk" + ], + "note": "Sverige indførte embedet justitieombudsman i 1809, og ordet bredte sig derfra til engelsk." + }, + { + "category": "Sprog og ord", + "prompt": "Ordet dollar kommer fra en sølvmønt præget i en dal i det nuværende _____.", + "answer": "Tjekkiet", + "alternatives": [ + "tjekkiet", + "bøhmen", + "tjekkoslovakiet" + ], + "decoys": [ + "Holland", + "Spanien", + "Skotland" + ], + "note": "Joachimsthaler-mønten fra Joachimsthal (nu Jáchymov) blev til thaler, daler og dollar." + }, + { + "category": "Sprog og ord", + "prompt": "Ordet kiosk kommer fra tyrkisk, hvor det betød _____.", + "answer": "pavillon", + "alternatives": [ + "pavillon", + "havepavillon", + "lysthus", + "havehus" + ], + "decoys": [ + "markedsbod", + "vagthus", + "skur" + ], + "note": "Tyrkisk köşk var en åben havepavillon; ordet kom via fransk kiosque." + }, + { + "category": "Sport", + "prompt": "Da basketball blev opfundet i 1891, brugte man _____ som kurve.", + "answer": "ferskenkurve", + "alternatives": [ + "ferskenkurve", + "fersken-kurve", + "frugtkurve", + "kurve til ferskner" + ], + "decoys": [ + "fiskenet", + "mælkespande", + "hummerteiner" + ], + "note": "James Naismith hængte to ferskenkurve op i en gymnastiksal; bolden måtte hentes ned efter hver scoring." + }, + { + "category": "Sport", + "prompt": "Astronauten Alan Shepard spillede i 1971 _____ på Månen.", + "answer": "golf", + "alternatives": [ + "golf" + ], + "decoys": [ + "baseball", + "tennis", + "fodbold" + ], + "note": "På Apollo 14 slog Shepard to golfbolde med et hjemmelavet jern." + }, + { + "category": "Sport", + "prompt": "Under OL i Paris i 1900 blev der afholdt skydning efter levende _____.", + "answer": "duer", + "alternatives": [ + "duer", + "due", + "tamduer" + ], + "decoys": [ + "kaniner", + "fasaner", + "høns" + ], + "note": "Det var eneste gang, dyr bevidst blev dræbt ved legene; omkring 300 duer blev skudt." + }, + { + "category": "Sport", + "prompt": "OL's grundlægger Pierre de Coubertin vandt selv en guldmedalje i 1912 i _____.", + "answer": "litteratur", + "alternatives": [ + "litteratur", + "digtning", + "poesi", + "kunst", + "kunstkonkurrencen" + ], + "decoys": [ + "fægtning", + "ridning", + "skydning" + ], + "note": "Kunstkonkurrencer var med 1912-1948; Coubertin vandt med 'Ode til sporten' under pseudonym." + }, + { + "category": "Sport", + "prompt": "Fra 1900 til 1920 var _____ en olympisk disciplin.", + "answer": "tovtrækning", + "alternatives": [ + "tovtrækning", + "tov-trækning" + ], + "decoys": [ + "sækkeløb", + "stylteløb", + "æggeløb" + ], + "note": "Tovtrækning var med ved fem olympiske lege; i 1900 vandt et dansk-svensk hold guld." + }, + { + "category": "Sport", + "prompt": "Danmark kom kun med til EM i fodbold i 1992, fordi _____ blev udelukket.", + "answer": "Jugoslavien", + "alternatives": [ + "jugoslavien", + "jugoslaviens landshold" + ], + "decoys": [ + "Sovjetunionen", + "Rumænien", + "Bulgarien" + ], + "note": "Jugoslavien blev udelukket pga. FN-sanktioner, Danmark rykkede ind og vandt hele turneringen." + }, + { + "category": "Sport", + "prompt": "Maratonløbets officielle længde på 42,195 km stammer fra OL i _____.", + "answer": "London", + "alternatives": [ + "london", + "london 1908" + ], + "decoys": [ + "Paris", + "Stockholm", + "Athen" + ], + "note": "I 1908 gik ruten fra Windsor Castle til stadion; distancen blev senere standard." + }, + { + "category": "Sport", + "prompt": "Volleyball hed oprindeligt _____, da det blev opfundet i 1895.", + "answer": "Mintonette", + "alternatives": [ + "mintonette" + ], + "decoys": [ + "Ballonette", + "Netonia", + "Smashball" + ], + "note": "William G. Morgan kaldte spillet Mintonette; navnet volleyball kom året efter." + }, + { + "category": "Sport", + "prompt": "Badminton er opkaldt efter et engelsk _____, hvor spillet blev populært i 1800-tallet.", + "answer": "gods", + "alternatives": [ + "gods", + "herregård", + "herresæde", + "landsted", + "slot", + "badminton house" + ], + "decoys": [ + "fyrtårn", + "kloster", + "værtshus" + ], + "note": "Badminton House i Gloucestershire tilhørte hertugen af Beaufort." + }, + { + "category": "Sport", + "prompt": "Ved OL-maratonløbet i 1904 fik vinderen Thomas Hicks undervejs rottegiften _____ for at holde ham kørende.", + "answer": "stryknin", + "alternatives": [ + "stryknin", + "strychnin", + "strychnine" + ], + "decoys": [ + "arsenik", + "cyanid", + "kviksølv" + ], + "note": "Hans hjælpere gav ham stryknin blandet med æggehvide og brandy; han var tæt på at kollapse." + }, + { + "category": "Sport", + "prompt": "Fred Lorz kom først i mål ved OL-maratonløbet i 1904, men havde kørt en stor del af ruten i _____.", + "answer": "bil", + "alternatives": [ + "bil", + "en bil", + "automobil" + ], + "decoys": [ + "hestevogn", + "tog", + "på cykel" + ], + "note": "Lorz fik et lift i bil i ca. 11 miles og blev diskvalificeret." + }, + { + "category": "Sport", + "prompt": "Danmarks første olympiske guldmedalje blev vundet i 1896 af Viggo Jensen i _____.", + "answer": "vægtløftning", + "alternatives": [ + "vægtløftning", + "tohåndsløft", + "vægtløft" + ], + "decoys": [ + "brydning", + "cykling", + "svømning" + ], + "note": "Viggo Jensen vandt tohåndsløftet ved de første moderne olympiske lege i Athen." + }, + { + "category": "Sport", + "prompt": "Det første VM i fodbold blev spillet i 1930 i _____.", + "answer": "Uruguay", + "alternatives": [ + "uruguay", + "montevideo" + ], + "decoys": [ + "Brasilien", + "Italien", + "Frankrig" + ], + "note": "Uruguay var vært og vandt også turneringen." + }, + { + "category": "Sport", + "prompt": "Olympiske curlingsten laves traditionelt af granit fra den ubeboede skotske ø _____.", + "answer": "Ailsa Craig", + "alternatives": [ + "ailsa craig" + ], + "decoys": [ + "Isle of Skye", + "Arran", + "Iona" + ], + "note": "Firmaet Kays of Scotland laver olympiske sten af den særligt tætte granit fra Ailsa Craig." + }, + { + "category": "Sport", + "prompt": "Den gule trøje i Tour de France er gul, fordi løbets arrangør var en _____ trykt på gult papir.", + "answer": "avis", + "alternatives": [ + "avis", + "sportsavis", + "l'auto", + "dagblad" + ], + "decoys": [ + "plakatfabrik", + "kortforlag", + "reklamebureau" + ], + "note": "Sportsavisen L'Auto, der grundlagde løbet i 1903, blev trykt på gult papir." + }, + { + "category": "Historie", + "prompt": "I 1932 førte det australske militær krig mod _____ og tabte.", + "answer": "emuer", + "alternatives": [ + "emuer", + "emu", + "emu'er" + ], + "decoys": [ + "kænguruer", + "kaniner", + "krokodiller" + ], + "note": "Den såkaldte Emukrig: soldater med maskingeværer skulle bekæmpe emuer på markerne og opgav." + }, + { + "category": "Historie", + "prompt": "Hundredårskrigen mellem England og Frankrig varede i virkeligheden _____ år.", + "answer": "116", + "alternatives": [ + "116", + "hundrede og seksten" + ], + "decoys": [ + "99", + "104", + "121" + ], + "note": "Krigen varede fra 1337 til 1453, med flere pauser." + }, + { + "category": "Historie", + "prompt": "Krigen mellem Storbritannien og Zanzibar i 1896 varede kun omkring _____.", + "answer": "40 minutter", + "alternatives": [ + "40 minutter", + "38 minutter", + "45 minutter", + "fyrre minutter", + "under en time" + ], + "decoys": [ + "tre dage", + "to uger", + "seks timer" + ], + "note": "Den regnes som historiens korteste krig, ca. 38-45 minutter." + }, + { + "category": "Historie", + "prompt": "I Boston døde 21 mennesker i 1919, da en kæmpe tank med _____ bristede.", + "answer": "melasse", + "alternatives": [ + "melasse", + "sirup", + "sukkersirup" + ], + "decoys": [ + "øl", + "mælk", + "whisky" + ], + "note": "Den store melasseflod sendte en bølge af sirup gennem North End i Boston." + }, + { + "category": "Historie", + "prompt": "Den romerske kejser Vespasian indførte en skat på _____.", + "answer": "urin", + "alternatives": [ + "urin", + "tis", + "pis", + "offentlige toiletter" + ], + "decoys": [ + "skæg", + "hatte", + "hunde" + ], + "note": "Urin fra offentlige latriner blev brugt til garvning og vask; heraf 'pecunia non olet', penge lugter ikke." + }, + { + "category": "Historie", + "prompt": "Abraham Lincoln og _____ blev født på præcis samme dag i 1809.", + "answer": "Charles Darwin", + "alternatives": [ + "charles darwin", + "darwin" + ], + "decoys": [ + "Charles Dickens", + "Edgar Allan Poe", + "Napoleon 3." + ], + "note": "Begge blev født den 12. februar 1809." + }, + { + "category": "Historie", + "prompt": "Da Keopspyramiden blev bygget, levede der stadig _____ på Wrangeløen i Sibirien.", + "answer": "mammutter", + "alternatives": [ + "mammutter", + "mammut", + "uldhårede mammutter" + ], + "decoys": [ + "sabeltandtigre", + "huleløver", + "kæmpehjorte" + ], + "note": "De sidste mammutter på Wrangeløen uddøde for ca. 4.000 år siden; pyramiden er ca. 4.500 år gammel." + }, + { + "category": "Historie", + "prompt": "Ifølge historikeren Suetonius ville kejser Caligula gøre sin _____ til konsul.", + "answer": "hest", + "alternatives": [ + "hest", + "yndlingshest", + "incitatus" + ], + "decoys": [ + "kok", + "hund", + "frisør" + ], + "note": "Hesten Incitatus havde ifølge kilderne en marmorstald og krybbe af elfenben." + }, + { + "category": "Historie", + "prompt": "Nintendo blev grundlagt i 1889 og lavede oprindeligt _____.", + "answer": "spillekort", + "alternatives": [ + "spillekort", + "kort", + "hanafuda", + "hanafuda-kort" + ], + "decoys": [ + "symaskiner", + "paraplyer", + "tændstikker" + ], + "note": "Fusajiro Yamauchi startede med håndlavede japanske hanafuda-kort." + }, + { + "category": "Historie", + "prompt": "Stewardessen Violet Jessop overlevede både Titanic og søsterskibet _____, der sank i 1916.", + "answer": "Britannic", + "alternatives": [ + "britannic", + "hmhs britannic" + ], + "decoys": [ + "Lusitania", + "Carpathia", + "Empress of Ireland" + ], + "note": "Hun var også om bord på Olympic, da den kolliderede i 1911." + }, + { + "category": "Historie", + "prompt": "Kort efter månelandingen i 1969 indtog Buzz Aldrin _____ i månelandingsfartøjet.", + "answer": "nadver", + "alternatives": [ + "nadver", + "nadveren", + "altergang", + "brød og vin" + ], + "decoys": [ + "kaffe", + "en appelsin", + "whisky" + ], + "note": "Aldrin tog nadver med medbragt brød og vin, før han gik ud på Månen." + }, + { + "category": "Historie", + "prompt": "Eiffeltårnet skulle efter planen være revet ned efter _____.", + "answer": "20 år", + "alternatives": [ + "20 år", + "tyve år" + ], + "decoys": [ + "et år", + "fem år", + "50 år" + ], + "note": "Tilladelsen gjaldt 20 år; tårnet blev reddet bl.a. fordi det var nyttigt som radioantenne." + }, + { + "category": "Historie", + "prompt": "Berlinmuren åbnede i 1989, fordi talsmanden Günter Schabowski ved en fejl sagde, at de nye rejseregler gjaldt _____.", + "answer": "straks", + "alternatives": [ + "straks", + "øjeblikkeligt", + "omgående", + "med det samme", + "sofort", + "nu" + ], + "decoys": [ + "fra jul", + "fra nytår", + "om en uge" + ], + "note": "Han svarede 'sofort, unverzüglich' på et pressemøde den 9. november, og folk strømmede til grænsen." + }, + { + "category": "Historie", + "prompt": "Kleopatra tilhørte et kongehus af _____ afstamning.", + "answer": "græsk", + "alternatives": [ + "græsk", + "makedonsk", + "græsk-makedonsk" + ], + "decoys": [ + "romersk", + "persisk", + "nubisk" + ], + "note": "Hun var den sidste af det ptolemæiske dynasti, grundlagt af en af Alexander den Stores makedonske generaler." + }, + { + "category": "Historie", + "prompt": "Den japanske soldat Hiroo Onoda blev ved med at kæmpe i Filippinernes jungle indtil _____.", + "answer": "1974", + "alternatives": [ + "1974" + ], + "decoys": [ + "1946", + "1952", + "1961" + ], + "note": "Onoda troede ikke på, at krigen var slut, og overgav sig først, da hans gamle chef kom og gav ordre til det." + }, + { + "category": "Geografi", + "prompt": "Mellem Rusland og USA er der ved Diomedeøerne i Beringstrædet kun omkring _____.", + "answer": "4 km", + "alternatives": [ + "4 km", + "4 kilometer", + "fire kilometer", + "3,8 km", + "3,8 kilometer" + ], + "decoys": [ + "25 km", + "60 km", + "120 km" + ], + "note": "Store Diomede (Rusland) og Lille Diomede (USA) ligger ca. 3,8 km fra hinanden." + }, + { + "category": "Geografi", + "prompt": "Det land i verden, der har flest tidszoner, er _____.", + "answer": "Frankrig", + "alternatives": [ + "frankrig" + ], + "decoys": [ + "Rusland", + "USA", + "Canada" + ], + "note": "Med sine oversøiske områder har Frankrig 12 tidszoner (13 med kravet i Antarktis)." + }, + { + "category": "Geografi", + "prompt": "Selvom Kina geografisk spænder over fem tidszoner, bruger hele landet officielt kun _____.", + "answer": "én tidszone", + "alternatives": [ + "en tidszone", + "én tidszone", + "1 tidszone", + "beijing-tid", + "pekingtid", + "én" + ], + "decoys": [ + "to tidszoner", + "tre tidszoner", + "fire tidszoner" + ], + "note": "Hele Kina følger Beijing-tid (UTC+8)." + }, + { + "category": "Geografi", + "prompt": "Det punkt på Jordens overflade, der ligger længst fra Jordens centrum, er toppen af _____.", + "answer": "Chimborazo", + "alternatives": [ + "chimborazo" + ], + "decoys": [ + "Kilimanjaro", + "Aconcagua", + "Cotopaxi" + ], + "note": "Jorden buler ud ved ækvator, så vulkanen Chimborazo i Ecuador rager længere ud end Everest." + }, + { + "category": "Geografi", + "prompt": "Verdens dybeste sø, der er over 1.600 meter dyb, hedder _____.", + "answer": "Bajkalsøen", + "alternatives": [ + "bajkalsøen", + "bajkal", + "baikal", + "baikalsøen", + "bajkalsjøen" + ], + "decoys": [ + "Tanganyikasøen", + "Ladogasøen", + "Titicacasøen" + ], + "note": "Bajkalsøen i Sibirien er ca. 1.640 m dyb og også verdens ældste sø." + }, + { + "category": "Geografi", + "prompt": "Alaska er både USA's nordligste, vestligste og _____ stat.", + "answer": "østligste", + "alternatives": [ + "østligste", + "mest østlige" + ], + "decoys": [ + "sydligste", + "fladeste", + "ældste" + ], + "note": "Aleuterne krydser 180. længdegrad, så Alaskas yderste øer ligger på østlig længde." + }, + { + "category": "Geografi", + "prompt": "Verdens største ørken er _____.", + "answer": "Antarktis", + "alternatives": [ + "antarktis", + "antarktika", + "sydpolen", + "antarktisk ørken" + ], + "decoys": [ + "Gobi", + "Arabiske Ørken", + "Kalahari" + ], + "note": "En ørken defineres ved lav nedbør; Antarktis er ca. 14 mio. km², større end Sahara." + }, + { + "category": "Geografi", + "prompt": "Det sted i havet, der ligger længst fra land, kaldes Point _____.", + "answer": "Nemo", + "alternatives": [ + "nemo", + "point nemo" + ], + "decoys": [ + "Zero", + "Omega", + "Blue" + ], + "note": "Opkaldt efter Kaptajn Nemo; ca. 2.700 km fra nærmeste land i Stillehavet, og bruges som kirkegård for satellitter." + }, + { + "category": "Geografi", + "prompt": "Mount Everest er opkaldt efter en britisk _____.", + "answer": "landmåler", + "alternatives": [ + "landmåler", + "opmåler", + "geodæt", + "kartograf", + "overlandmåler" + ], + "decoys": [ + "bjergbestiger", + "missionær", + "guvernør" + ], + "note": "Sir George Everest var Surveyor General of India; han protesterede selv mod navnet." + }, + { + "category": "Geografi", + "prompt": "Kongeriget Lesotho ligger helt omgivet af _____.", + "answer": "Sydafrika", + "alternatives": [ + "sydafrika" + ], + "decoys": [ + "Botswana", + "Namibia", + "Mozambique" + ], + "note": "Lesotho er en enklave og samtidig det land, hvis laveste punkt ligger højest (ca. 1.400 m)." + }, + { + "category": "Geografi", + "prompt": "Afrika er i virkeligheden omkring _____ gange større end Grønland.", + "answer": "14", + "alternatives": [ + "14", + "fjorten" + ], + "decoys": [ + "3", + "6", + "25" + ], + "note": "Mercator-kortet får Grønland til at ligne Afrika i størrelse; reelt er Afrika ca. 30,4 mio. km² mod 2,2 mio." + }, + { + "category": "Geografi", + "prompt": "I 2022 fik Danmark sin anden landegrænse, da Hans Ø blev delt med _____.", + "answer": "Canada", + "alternatives": [ + "canada" + ], + "decoys": [ + "Norge", + "Island", + "USA" + ], + "note": "Efter årtiers 'whiskykrig' blev den lille ø mellem Grønland og Canada delt i juni 2022." + }, + { + "category": "Geografi", + "prompt": "Ud over Liechtenstein er _____ det eneste land, hvor man skal krydse to landegrænser for at nå et hav.", + "answer": "Usbekistan", + "alternatives": [ + "usbekistan", + "uzbekistan" + ], + "decoys": [ + "Schweiz", + "Mongoliet", + "Bolivia" + ], + "note": "Begge er dobbelt indlandsstater: alle nabolande er selv uden kyst." + }, + { + "category": "Geografi", + "prompt": "Målt fra fod til top er verdens højeste bjerg _____ på Hawaii.", + "answer": "Mauna Kea", + "alternatives": [ + "mauna kea" + ], + "decoys": [ + "Kilauea", + "Haleakala", + "Waialeale" + ], + "note": "Mauna Kea er over 10.000 m fra havbunden, men kun 4.207 m over havets overflade." + }, + { + "category": "Geografi", + "prompt": "Det nordligste faste land på Jorden er en ø ud for Grønland ved navn _____ Ø.", + "answer": "Kaffeklubben", + "alternatives": [ + "kaffeklubben", + "kaffeklubben ø", + "kaffeklubbens" + ], + "decoys": [ + "Frokostklubben", + "Kakkelovns", + "Søndagsskole" + ], + "note": "Lauge Koch opkaldte øen efter kaffeklubben på Mineralogisk Museum i København." + } +] \ No newline at end of file diff --git a/fupogfakta/engine.py b/fupogfakta/engine.py new file mode 100644 index 0000000..d890adb --- /dev/null +++ b/fupogfakta/engine.py @@ -0,0 +1,577 @@ +"""Spilmotoren for Fup og Fakta. + +Ren Python uden Django og uden I/O: alle handlinger tager `now` (sekunder siden +epoch) som parameter, så hele spillet kan testes deterministisk. Rummet i +`party.rooms` ejer en instans pr. spil, kalder handlingerne fra WebSocket'en og +`tick()` når en deadline udløber, og sender `snapshot()` til hver skærm. +""" + +from __future__ import annotations + +import difflib +import random +import re +import secrets +import unicodedata +from dataclasses import dataclass, field + +# Runder: (navn, pointmultiplikator, antal spørgsmål). Sidste runde er finalen. +ROUNDS = [("Runde 1", 1, 3), ("Runde 2", 2, 3), ("Finale", 3, 1)] + +POINTS_CORRECT = 1000 +POINTS_FOOLED = 500 +POINTS_LIKE = 100 +POINTS_AUDIENCE = 500 + +CATEGORY_SECONDS = 15 +LIE_SECONDS = 60 +GUESS_SECONDS = 30 +SCORES_SECONDS = 10 +REVEAL_STEP_MS = 4000 +REVEAL_TAIL_MS = 3000 + +MIN_PLAYERS = 2 +MAX_PLAYERS = 8 +MAX_NAME = 12 +MAX_LIE = 40 +CATEGORY_OPTIONS = 4 + +COLORS = ["#ff4d6d", "#4cc9f0", "#ffd166", "#06d6a0", "#b388ff", "#ff9f1c", "#f15bb5", "#90e0ef"] + +TIMED_PHASES = {"category", "lie", "guess", "reveal", "scores"} + + +class GameError(Exception): + def __init__(self, code: str, message: str): + super().__init__(message) + self.code = code + self.message = message + + +def normalize(text: str) -> str: + """Til sammenligning af svar: små bogstaver, ingen tegnsætning, ingen indledende kendeord.""" + text = unicodedata.normalize("NFC", text).casefold() + text = re.sub(r"[^\w\s]", " ", text) + text = re.sub(r"\s+", " ", text).strip() + text = re.sub(r"^(en|et|den|det|de|the|a|an) ", "", text) + return text + + +def clean(text: str) -> str: + return re.sub(r"\s+", " ", (text or "")).strip() + + +@dataclass +class Player: + id: str + name: str + color: str + token: str + role: str = "player" # player | audience + score: int = 0 + connected: bool = False + joined_order: int = 0 + fooled: int = 0 # antal gange andre valgte spillerens løgn + correct: int = 0 + likes: int = 0 + round_delta: int = 0 + last_delta: int | None = None + + +@dataclass +class Answer: + id: str + text: str + is_truth: bool = False + house: bool = False + authors: list[str] = field(default_factory=list) + + +class Game: + def __init__(self, code: str, questions: list[dict], join_url: str = "", rng: random.Random | None = None): + self.code = code + self.join_url = join_url + self.rng = rng or random.Random() + self.questions = list(questions) + self.used_questions: set = set() + self.players: dict[str, Player] = {} + self.audience: dict[str, Player] = {} + self._counter = 0 + self.phase = "lobby" + self.deadline: float | None = None + self.round_idx = 0 + self.q_idx = 0 + self.chooser_rotation = 0 + self.chooser_id: str | None = None + self.category_options: list[dict] = [] + self.question: dict | None = None + self.lies: dict[str, str] = {} + self.answers: list[Answer] = [] + self.guesses: dict[str, str] = {} + self.audience_votes: dict[str, str] = {} + self.likes: dict[str, set[str]] = {} + self.reveal_steps: list[dict] = [] + self.final_result: dict | None = None + self.version = 0 + + # ── hjælpere ──────────────────────────────────────────────────────────── + def _next_id(self, prefix: str) -> str: + self._counter += 1 + return f"{prefix}{self._counter}" + + def _changed(self): + self.version += 1 + + def _member(self, member_id: str) -> Player | None: + return self.players.get(member_id) or self.audience.get(member_id) + + def _require_phase(self, *phases): + if self.phase not in phases: + raise GameError("wrong_phase", "Det kan du ikke lige nu.") + + def _require_player(self, member_id: str) -> Player: + p = self.players.get(member_id) + if not p: + raise GameError("not_allowed", "Det kan kun spillere.") + return p + + @property + def round(self): + return ROUNDS[self.round_idx] + + @property + def multiplier(self) -> int: + return self.round[1] + + @property + def is_final_round(self) -> bool: + return self.round_idx == len(ROUNDS) - 1 + + def vip_id(self) -> str | None: + return next(iter(self.players), None) + + def _active_players(self) -> list[Player]: + """Spillere, der kan holde spillet tilbage: dem der er forbundet.""" + return [p for p in self.players.values() if p.connected] + + # ── medlemmer ─────────────────────────────────────────────────────────── + def join(self, name: str) -> Player: + name = clean(name) + if not name or len(name) > MAX_NAME: + raise GameError("name_invalid", f"Navnet skal være 1-{MAX_NAME} tegn.") + taken = {normalize(p.name) for p in list(self.players.values()) + list(self.audience.values())} + if normalize(name) in taken: + raise GameError("name_taken", "Det navn er taget. Vælg et andet.") + as_player = self.phase == "lobby" and len(self.players) < MAX_PLAYERS + used_colors = {p.color for p in self.players.values()} + color = next((c for c in COLORS if c not in used_colors), COLORS[0]) if as_player else "#8d99ae" + p = Player(id=self._next_id("p" if as_player else "u"), name=name, color=color, + token=secrets.token_urlsafe(16), role="player" if as_player else "audience", + connected=True, joined_order=self._counter) + (self.players if as_player else self.audience)[p.id] = p + self._changed() + return p + + def resume(self, token: str) -> Player: + for p in list(self.players.values()) + list(self.audience.values()): + if secrets.compare_digest(p.token, token or ""): + return p + raise GameError("unknown_token", "Vi kunne ikke finde dig i spillet. Join igen.") + + def set_connected(self, member_id: str, connected: bool, now: float) -> None: + p = self._member(member_id) + if p and p.connected != connected: + p.connected = connected + self._changed() + # En der forsvinder, må ikke holde de andre tilbage. + if not connected: + self._maybe_finish_early(now) + + def kick(self, member_id: str, now: float) -> None: + p = self.players.pop(member_id, None) or self.audience.pop(member_id, None) + if not p: + return + self.lies.pop(member_id, None) + self.guesses.pop(member_id, None) + self.audience_votes.pop(member_id, None) + if self.phase == "category" and self.chooser_id == member_id: + self._choose(self.rng.choice(self.category_options)["id"], now) + else: + self._maybe_finish_early(now) + if self.phase != "lobby" and len(self.players) == 0: + self._to_lobby() + self._changed() + + # ── spilforløb ────────────────────────────────────────────────────────── + def start(self, member_id: str | None, now: float) -> None: + """member_id None = værten.""" + self._require_phase("lobby") + if member_id is not None and member_id != self.vip_id(): + raise GameError("not_allowed", "Det er kun VIP'en, der kan starte spillet.") + if len(self.players) < MIN_PLAYERS: + raise GameError("too_few_players", f"Der skal være mindst {MIN_PLAYERS} spillere.") + self._reset_scores() + self.round_idx = 0 + self.q_idx = 0 + self._begin_question(now) + + def play_again(self, member_id: str | None, now: float) -> None: + self._require_phase("final") + if member_id is not None and member_id != self.vip_id(): + raise GameError("not_allowed", "Det er kun VIP'en, der kan starte et nyt spil.") + # Publikum, der kom til undervejs, rykker op som spillere, hvis der er plads. + for a in list(self.audience.values()): + if len(self.players) >= MAX_PLAYERS: + break + del self.audience[a.id] + a.role = "player" + used = {p.color for p in self.players.values()} + a.color = next((c for c in COLORS if c not in used), COLORS[0]) + self.players[a.id] = a + self.phase = "lobby" + self.start(None, now) + + def _to_lobby(self): + self.phase = "lobby" + self.deadline = None + self.question = None + + def _reset_scores(self): + for p in self.players.values(): + p.score = p.fooled = p.correct = p.likes = p.round_delta = 0 + p.last_delta = None + self.final_result = None + + def _available_categories(self) -> dict: + cats: dict = {} + for q in self.questions: + if q["id"] not in self.used_questions: + cats.setdefault(q["category_id"], q["category"]) + if not cats and self.questions: + self.used_questions.clear() # banken er brugt op: start forfra + return self._available_categories() + return cats + + def _begin_question(self, now: float): + cats = self._available_categories() + if not cats: + raise GameError("no_questions", "Der er ingen spørgsmål i banken.") + self.round_deltas_reset_if_new_round() + if self.is_final_round: + self._choose(self.rng.choice(list(cats)), now) + return + players = list(self.players.values()) + connected = [p for p in players if p.connected] or players + chooser = connected[self.chooser_rotation % len(connected)] + self.chooser_rotation += 1 + self.chooser_id = chooser.id + picks = self.rng.sample(list(cats.items()), min(CATEGORY_OPTIONS, len(cats))) + self.category_options = [{"id": cid, "name": name} for cid, name in picks] + self.phase = "category" + self.deadline = now + CATEGORY_SECONDS + self._changed() + + def round_deltas_reset_if_new_round(self): + if self.q_idx == 0: + for p in self.players.values(): + p.round_delta = 0 + + def choose_category(self, member_id: str, category_id, now: float) -> None: + self._require_phase("category") + if member_id != self.chooser_id: + raise GameError("not_allowed", "Det er ikke dig, der vælger kategori.") + if category_id not in {c["id"] for c in self.category_options}: + raise GameError("answer_invalid", "Ukendt kategori.") + self._choose(category_id, now) + + def _choose(self, category_id, now: float): + pool = [q for q in self.questions if q["category_id"] == category_id and q["id"] not in self.used_questions] + if not pool: + pool = [q for q in self.questions if q["id"] not in self.used_questions] or self.questions + self.question = self.rng.choice(pool) + self.used_questions.add(self.question["id"]) + self.lies = {} + self.guesses = {} + self.audience_votes = {} + self.likes = {} + self.answers = [] + self.reveal_steps = [] + for p in self.players.values(): + p.last_delta = None + self.phase = "lie" + self.deadline = now + LIE_SECONDS + self._changed() + + def _truths(self) -> list[str]: + q = self.question + return [normalize(t) for t in [q["answer"], *q.get("alternatives", [])] if normalize(t)] + + def _is_truth(self, text: str) -> bool: + n = normalize(text) + for t in self._truths(): + if n == t: + return True + if len(t) >= 4 and difflib.SequenceMatcher(None, n, t).ratio() >= 0.85: + return True + return False + + def submit_lie(self, member_id: str, text: str, now: float) -> None: + self._require_phase("lie") + self._require_player(member_id) + if member_id in self.lies: + raise GameError("already_done", "Du har allerede afleveret din løgn.") + text = clean(text) + if not text or len(text) > MAX_LIE or not normalize(text): + raise GameError("lie_invalid", f"Løgnen skal være 1-{MAX_LIE} tegn.") + if self._is_truth(text): + raise GameError("lie_is_truth", "Det er faktisk sandheden! Find på en løgn i stedet.") + self.lies[member_id] = text + self._changed() + self._maybe_finish_early(now) + + def lie_for_me(self, member_id: str) -> str: + self._require_phase("lie") + self._require_player(member_id) + taken = {normalize(t) for t in self.lies.values()} + options = [d for d in self.question.get("decoys", []) if normalize(d) not in taken] + if not options: + options = self.question.get("decoys", []) or ["noget helt andet"] + return self.rng.choice(options) + + def _start_guess(self, now: float): + grouped: dict[str, Answer] = {} + for pid, text in self.lies.items(): + key = normalize(text) + if key not in grouped: + grouped[key] = Answer(id="", text=text) + grouped[key].authors.append(pid) + answers = list(grouped.values()) + answers.append(Answer(id="", text=self.question["answer"], is_truth=True)) + target = max(4, len(self.players) + 1) + taken = set(grouped) | set(self._truths()) + decoys = [d for d in self.question.get("decoys", []) if normalize(d) not in taken] + self.rng.shuffle(decoys) + while len(answers) < target and decoys: + answers.append(Answer(id="", text=decoys.pop(), house=True)) + self.rng.shuffle(answers) + for i, a in enumerate(answers, 1): + a.id = f"a{i}" + self.answers = answers + self.phase = "guess" + self.deadline = now + GUESS_SECONDS + self._changed() + + def _answer(self, answer_id: str) -> Answer: + for a in self.answers: + if a.id == answer_id: + return a + raise GameError("answer_invalid", "Det svar findes ikke.") + + def guess(self, member_id: str, answer_id: str, now: float) -> None: + self._require_phase("guess") + member = self._member(member_id) + if not member: + raise GameError("not_allowed", "Du er ikke med i spillet.") + answer = self._answer(answer_id) + if member.role == "player": + if member_id in self.guesses: + raise GameError("already_done", "Du har allerede gættet.") + if member_id in answer.authors: + raise GameError("answer_invalid", "Du kan ikke vælge din egen løgn.") + self.guesses[member_id] = answer_id + else: + if member_id in self.audience_votes: + raise GameError("already_done", "Du har allerede stemt.") + self.audience_votes[member_id] = answer_id + self._changed() + self._maybe_finish_early(now) + + def like(self, member_id: str, answer_id: str) -> None: + self._require_phase("guess", "reveal") + member = self._member(member_id) + if not member: + raise GameError("not_allowed", "Du er ikke med i spillet.") + if self.phase == "guess" and member.role == "player" and member_id not in self.guesses: + raise GameError("wrong_phase", "Gæt først, så kan du like bagefter.") + answer = self._answer(answer_id) + if member_id in answer.authors: + raise GameError("answer_invalid", "Du kan ikke like din egen løgn.") + voters = self.likes.setdefault(answer_id, set()) + sign = -1 if member_id in voters else 1 + (voters.discard if sign < 0 else voters.add)(member_id) + for author in answer.authors: + if author in self.players: + a = self.players[author] + a.score += sign * POINTS_LIKE + a.round_delta += sign * POINTS_LIKE + a.likes += sign + a.last_delta = (a.last_delta or 0) + sign * POINTS_LIKE + self._changed() + + def _maybe_finish_early(self, now: float): + active = self._active_players() + if self.phase == "lie" and active and all(p.id in self.lies for p in active): + self._start_guess(now) + elif self.phase == "guess" and active and all(p.id in self.guesses for p in active): + self._start_reveal(now) + + def _award(self, pid: str, delta: int, step: dict): + p = self.players.get(pid) + if not p or delta == 0: + return + p.score += delta + p.round_delta += delta + p.last_delta = (p.last_delta or 0) + delta + step["points"].append({"player_id": pid, "delta": delta}) + + def _start_reveal(self, now: float): + m = self.multiplier + audience_total = len(self.audience_votes) + steps = [] + for a in self.answers: + picked_by = [pid for pid, aid in self.guesses.items() if aid == a.id] + votes = sum(1 for aid in self.audience_votes.values() if aid == a.id) + if not a.is_truth and not picked_by and not votes: + continue + step = {"answer_id": a.id, "text": a.text, "is_truth": a.is_truth, "house": a.house, + "authors": list(a.authors), "guessed_by": picked_by, "audience_votes": votes, + "likes": len(self.likes.get(a.id, ())), "points": []} + if a.is_truth: + for pid in picked_by: + self.players[pid].correct += 1 + self._award(pid, POINTS_CORRECT * m, step) + else: + for author in a.authors: + if author in self.players: + self.players[author].fooled += len(picked_by) + bonus = POINTS_FOOLED * m * len(picked_by) + if audience_total: + bonus += round(POINTS_AUDIENCE * m * votes / audience_total) + self._award(author, bonus, step) + steps.append(step) + # Løgne først (den mindst valgte først), sandheden til sidst. + steps.sort(key=lambda s: (s["is_truth"], len(s["guessed_by"]) + s["audience_votes"])) + self.reveal_steps = steps + self.phase = "reveal" + self.deadline = now + (len(steps) * REVEAL_STEP_MS + REVEAL_TAIL_MS) / 1000 + self._changed() + + def _after_reveal(self, now: float): + self.q_idx += 1 + if self.q_idx < self.round[2]: + self._begin_question(now) + elif self.is_final_round: + self._finish() + else: + self.phase = "scores" + self.deadline = now + SCORES_SECONDS + self._changed() + + def _after_scores(self, now: float): + self.round_idx += 1 + self.q_idx = 0 + self._begin_question(now) + + def _finish(self): + ranking = sorted(self.players.values(), key=lambda p: (-p.score, p.joined_order)) + places, last_score, place = [], None, 0 + for i, p in enumerate(ranking, 1): + if p.score != last_score: + place, last_score = i, p.score + places.append({"player_id": p.id, "score": p.score, "place": place}) + awards = [] + + def best(attr, title, detail): + top = max(self.players.values(), key=lambda p: getattr(p, attr), default=None) + if top and getattr(top, attr) > 0: + awards.append({"title": title, "player_id": top.id, "detail": detail(getattr(top, attr))}) + + best("fooled", "Største løgner", lambda n: f"narrede andre {n} gang{'e' if n != 1 else ''}") + best("correct", "Sandhedsvidne", lambda n: f"fandt sandheden {n} gang{'e' if n != 1 else ''}") + best("likes", "Folkets favorit", lambda n: f"fik {n} like{'s' if n != 1 else ''}") + self.final_result = {"ranking": places, "awards": awards} + self.phase = "final" + self.deadline = None + self._changed() + + def skip(self, now: float) -> None: + if self.phase not in TIMED_PHASES: + raise GameError("wrong_phase", "Der er ikke noget at springe over.") + self._advance(now) + + def tick(self, now: float) -> bool: + """Kaldes når deadline er nået. Returnerer True hvis noget ændrede sig.""" + if self.deadline is None or now < self.deadline: + return False + self._advance(now) + return True + + def _advance(self, now: float): + if self.phase == "category": + self._choose(self.rng.choice(self.category_options)["id"], now) + elif self.phase == "lie": + self._start_guess(now) + elif self.phase == "guess": + self._start_reveal(now) + elif self.phase == "reveal": + self._after_reveal(now) + elif self.phase == "scores": + self._after_scores(now) + + # ── snapshot ──────────────────────────────────────────────────────────── + def snapshot(self, now: float, viewer: Player | None = None, host: bool = False) -> dict: + vip = self.vip_id() + done_set = set(self.lies) if self.phase == "lie" else set(self.guesses) if self.phase == "guess" else set() + state = { + "code": self.code, + "phase": self.phase, + "server_time": int(now * 1000), + "deadline": int(self.deadline * 1000) if self.deadline else None, + "join_url": self.join_url, + "min_players": MIN_PLAYERS, + "max_players": MAX_PLAYERS, + "round": None, + "players": [ + {"id": p.id, "name": p.name, "color": p.color, "score": p.score, "connected": p.connected, + "vip": p.id == vip, "done": p.id in done_set} + for p in self.players.values() + ], + "audience_count": len(self.audience), + } + if self.phase not in ("lobby", "final"): + name, mult, count = self.round + state["round"] = {"number": self.round_idx + 1, "name": name, "multiplier": mult, + "question_number": self.q_idx + 1, "questions_in_round": count} + if self.phase == "category": + state["category"] = {"chooser_id": self.chooser_id, "options": self.category_options} + if self.phase in ("lie", "guess", "reveal") and self.question: + state["question"] = {"category": self.question["category"], "prompt": self.question["prompt"]} + if self.phase in ("guess", "reveal"): + hide = viewer.id if viewer and viewer.role == "player" and self.phase == "guess" else None + state["answers"] = [{"id": a.id, "text": a.text} for a in self.answers if hide not in a.authors] + if self.phase == "reveal": + state["reveal"] = {"steps": self.reveal_steps, "step_ms": REVEAL_STEP_MS, + "note": self.question.get("note", "") if self.question else ""} + if self.phase == "scores": + state["deltas"] = [{"player_id": p.id, "delta": p.round_delta} for p in self.players.values()] + if self.phase == "final": + state["final"] = self.final_result + if host: + state["host"] = True + elif viewer: + own_answer = next((a.id for a in self.answers if viewer.id in a.authors), None) + state["me"] = { + "id": viewer.id, + "name": viewer.name, + "role": viewer.role, + "vip": viewer.id == vip, + "lie": self.lies.get(viewer.id) if self.phase in ("lie", "guess", "reveal") else None, + "own_answer_id": own_answer if self.phase in ("guess", "reveal") else None, + "guess": (self.guesses if viewer.role == "player" else self.audience_votes).get(viewer.id) + if self.phase in ("guess", "reveal") else None, + "likes": sorted(aid for aid, voters in self.likes.items() if viewer.id in voters), + "is_chooser": self.phase == "category" and viewer.id == self.chooser_id, + "last_delta": viewer.last_delta if self.phase in ("reveal", "scores") else None, + "score": viewer.score, + } + return state diff --git a/core_admin/__init__.py b/fupogfakta/management/__init__.py similarity index 100% rename from core_admin/__init__.py rename to fupogfakta/management/__init__.py diff --git a/core_admin/migrations/__init__.py b/fupogfakta/management/commands/__init__.py similarity index 100% rename from core_admin/migrations/__init__.py rename to fupogfakta/management/commands/__init__.py diff --git a/fupogfakta/management/commands/load_questions.py b/fupogfakta/management/commands/load_questions.py new file mode 100644 index 0000000..678e84a --- /dev/null +++ b/fupogfakta/management/commands/load_questions.py @@ -0,0 +1,33 @@ +import json +from pathlib import Path + +from django.core.management.base import BaseCommand +from django.db import transaction + +from fupogfakta.models import Category, Question + +DATA_DIR = Path(__file__).resolve().parents[2] / "data" + + +class Command(BaseCommand): + help = "Indlæser/opdaterer spørgsmålsbanken fra fupogfakta/data/*.json (matcher på spørgsmålsteksten)." + + def add_arguments(self, parser): + parser.add_argument("files", nargs="*", help="JSON-filer (standard: alle i fupogfakta/data/)") + + @transaction.atomic + def handle(self, *args, files, **opts): + paths = [Path(f) for f in files] or sorted(DATA_DIR.glob("*.json")) + created = updated = 0 + for path in paths: + for item in json.loads(path.read_text(encoding="utf-8")): + cat, _ = Category.objects.get_or_create(name=item["category"].strip()) + _, was_created = Question.objects.update_or_create( + prompt=item["prompt"].strip(), + defaults={"category": cat, "answer": item["answer"].strip(), + "alternatives": item.get("alternatives", []), + "decoys": item.get("decoys", []), "note": item.get("note", "")}, + ) + created += was_created + updated += not was_created + self.stdout.write(self.style.SUCCESS(f"{created} nye, {updated} opdaterede spørgsmål fra {len(paths)} filer.")) diff --git a/fupogfakta/migrations/0001_initial.py b/fupogfakta/migrations/0001_initial.py index ec80c9f..84b91fc 100644 --- a/fupogfakta/migrations/0001_initial.py +++ b/fupogfakta/migrations/0001_initial.py @@ -1,7 +1,6 @@ -# Generated by Django 6.0.2 on 2026-02-27 10:47 +# Generated by Django 6.0.8 on 2026-10-01 18:10 import django.db.models.deletion -from django.conf import settings from django.db import migrations, models @@ -10,7 +9,6 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -18,128 +16,32 @@ class Migration(migrations.Migration): name='Category', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=120, unique=True)), - ('slug', models.SlugField(max_length=140, unique=True)), - ('is_active', models.BooleanField(default=True)), + ('name', models.CharField(max_length=60, unique=True, verbose_name='navn')), + ('active', models.BooleanField(default=True, verbose_name='aktiv')), ], options={ + 'verbose_name': 'kategori', + 'verbose_name_plural': 'kategorier', 'ordering': ['name'], }, ), - migrations.CreateModel( - name='GameSession', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('code', models.CharField(max_length=8, unique=True)), - ('status', models.CharField(choices=[('lobby', 'Lobby'), ('lie', 'Løgnfase'), ('guess', 'Gættefase'), ('reveal', 'Reveal'), ('finished', 'Afsluttet')], default='lobby', max_length=16)), - ('current_round', models.PositiveIntegerField(default=1)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('host', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='hosted_sessions', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'ordering': ['-created_at'], - }, - ), - migrations.CreateModel( - name='Player', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('nickname', models.CharField(max_length=40)), - ('score', models.IntegerField(default=0)), - ('is_connected', models.BooleanField(default=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='players', to='fupogfakta.gamesession')), - ], - options={ - 'ordering': ['-score', 'nickname'], - 'unique_together': {('session', 'nickname')}, - }, - ), migrations.CreateModel( name='Question', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('prompt', models.TextField()), - ('correct_answer', models.CharField(max_length=255)), - ('is_active', models.BooleanField(default=True)), - ('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='questions', to='fupogfakta.category')), - ], - ), - migrations.CreateModel( - name='RoundConfig', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('number', models.PositiveIntegerField()), - ('questions_count', models.PositiveIntegerField(default=3)), - ('points_correct', models.IntegerField(default=5)), - ('points_bluff', models.IntegerField(default=2)), - ('lie_seconds', models.PositiveIntegerField(default=45)), - ('guess_seconds', models.PositiveIntegerField(default=30)), - ('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fupogfakta.category')), - ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rounds', to='fupogfakta.gamesession')), + ('prompt', models.CharField(help_text='Skriv _____ hvor svaret skal stå.', max_length=300, unique=True, verbose_name='spørgsmål')), + ('answer', models.CharField(max_length=60, verbose_name='sandheden')), + ('alternatives', models.JSONField(blank=True, default=list, verbose_name='andre stavemåder af sandheden')), + ('decoys', models.JSONField(blank=True, default=list, help_text='Plausible forkerte svar, bruges når der mangler løgne.', verbose_name='husets løgne')), + ('note', models.CharField(blank=True, max_length=300, verbose_name='forklaring')), + ('active', models.BooleanField(default=True, verbose_name='aktiv')), + ('times_used', models.PositiveIntegerField(default=0, verbose_name='brugt')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='questions', to='fupogfakta.category', verbose_name='kategori')), ], options={ - 'ordering': ['number'], + 'verbose_name': 'spørgsmål', + 'verbose_name_plural': 'spørgsmål', + 'ordering': ['category__name', 'prompt'], }, ), - migrations.CreateModel( - name='RoundQuestion', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('round_number', models.PositiveIntegerField()), - ('correct_answer', models.CharField(max_length=255)), - ('question', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fupogfakta.question')), - ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='round_questions', to='fupogfakta.gamesession')), - ], - ), - migrations.CreateModel( - name='LieAnswer', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('text', models.CharField(max_length=255)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fupogfakta.player')), - ('round_question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lies', to='fupogfakta.roundquestion')), - ], - ), - migrations.CreateModel( - name='Guess', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('selected_text', models.CharField(max_length=255)), - ('is_correct', models.BooleanField(default=False)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('fooled_player', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='fooled_guesses', to='fupogfakta.player')), - ('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fupogfakta.player')), - ('round_question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='guesses', to='fupogfakta.roundquestion')), - ], - ), - migrations.CreateModel( - name='ScoreEvent', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('delta', models.IntegerField()), - ('reason', models.CharField(max_length=80)), - ('meta', models.JSONField(blank=True, default=dict)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='score_events', to='fupogfakta.player')), - ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='score_events', to='fupogfakta.gamesession')), - ], - ), - migrations.AddIndex( - model_name='question', - index=models.Index(fields=['category', 'is_active'], name='fupogfakta__categor_358d00_idx'), - ), - migrations.AlterUniqueTogether( - name='roundconfig', - unique_together={('session', 'number')}, - ), - migrations.AlterUniqueTogether( - name='lieanswer', - unique_together={('round_question', 'player')}, - ), - migrations.AlterUniqueTogether( - name='guess', - unique_together={('round_question', 'player')}, - ), ] diff --git a/fupogfakta/migrations/0002_roundquestion_shown_at.py b/fupogfakta/migrations/0002_roundquestion_shown_at.py deleted file mode 100644 index 0a9ccb4..0000000 --- a/fupogfakta/migrations/0002_roundquestion_shown_at.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-27 13:32 - -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("fupogfakta", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="roundquestion", - name="shown_at", - field=models.DateTimeField(default=django.utils.timezone.now), - ), - ] diff --git a/fupogfakta/migrations/0003_roundquestion_mixed_answers.py b/fupogfakta/migrations/0003_roundquestion_mixed_answers.py deleted file mode 100644 index 8472c0a..0000000 --- a/fupogfakta/migrations/0003_roundquestion_mixed_answers.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-27 21:57 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('fupogfakta', '0002_roundquestion_shown_at'), - ] - - operations = [ - migrations.AddField( - model_name='roundquestion', - name='mixed_answers', - field=models.JSONField(blank=True, default=list), - ), - ] diff --git a/fupogfakta/migrations/0004_player_session_token.py b/fupogfakta/migrations/0004_player_session_token.py deleted file mode 100644 index eb23044..0000000 --- a/fupogfakta/migrations/0004_player_session_token.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 6.0.2 on 2026-02-27 22:08 - -import fupogfakta.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('fupogfakta', '0003_roundquestion_mixed_answers'), - ] - - operations = [ - migrations.AddField( - model_name='player', - name='session_token', - field=models.CharField(db_index=True, default=fupogfakta.models._generate_player_session_token, max_length=64), - ), - ] diff --git a/fupogfakta/migrations/0005_alter_gamesession_status.py b/fupogfakta/migrations/0005_alter_gamesession_status.py deleted file mode 100644 index 202f7be..0000000 --- a/fupogfakta/migrations/0005_alter_gamesession_status.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0.2 on 2026-03-13 16:05 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('fupogfakta', '0004_player_session_token'), - ] - - operations = [ - migrations.AlterField( - model_name='gamesession', - name='status', - field=models.CharField(choices=[('lobby', 'Lobby'), ('lie', 'Løgnfase'), ('guess', 'Gættefase'), ('reveal', 'Reveal'), ('scoreboard', 'Scoreboard'), ('finished', 'Afsluttet')], default='lobby', max_length=16), - ), - ] diff --git a/fupogfakta/migrations/0005_gamesession_scoreboard_status.py b/fupogfakta/migrations/0005_gamesession_scoreboard_status.py deleted file mode 100644 index d49f650..0000000 --- a/fupogfakta/migrations/0005_gamesession_scoreboard_status.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("fupogfakta", "0004_player_session_token"), - ] - - operations = [ - migrations.AlterField( - model_name="gamesession", - name="status", - field=models.CharField( - choices=[ - ("lobby", "Lobby"), - ("lie", "Løgnfase"), - ("guess", "Gættefase"), - ("reveal", "Reveal"), - ("scoreboard", "Scoreboard"), - ("finished", "Afsluttet"), - ], - default="lobby", - max_length=16, - ), - ), - ] diff --git a/fupogfakta/migrations/0006_merge_20260315_1249.py b/fupogfakta/migrations/0006_merge_20260315_1249.py deleted file mode 100644 index 863c15a..0000000 --- a/fupogfakta/migrations/0006_merge_20260315_1249.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("fupogfakta", "0005_alter_gamesession_status"), - ("fupogfakta", "0005_gamesession_scoreboard_status"), - ] - - operations = [] diff --git a/fupogfakta/migrations/0007_roundconfig_started_from_scoreboard.py b/fupogfakta/migrations/0007_roundconfig_started_from_scoreboard.py deleted file mode 100644 index bfa0e77..0000000 --- a/fupogfakta/migrations/0007_roundconfig_started_from_scoreboard.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0.2 on 2026-03-17 08:24 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('fupogfakta', '0006_merge_20260315_1249'), - ] - - operations = [ - migrations.AddField( - model_name='roundconfig', - name='started_from_scoreboard', - field=models.BooleanField(default=False), - ), - ] diff --git a/fupogfakta/models.py b/fupogfakta/models.py index 3668e21..b09bae8 100644 --- a/fupogfakta/models.py +++ b/fupogfakta/models.py @@ -1,22 +1,13 @@ -import secrets - from django.db import models -from django.contrib.auth import get_user_model -from django.utils import timezone - -User = get_user_model() - -def _generate_player_session_token() -> str: - return secrets.token_urlsafe(24) - class Category(models.Model): - name = models.CharField(max_length=120, unique=True) - slug = models.SlugField(max_length=140, unique=True) - is_active = models.BooleanField(default=True) + name = models.CharField("navn", max_length=60, unique=True) + active = models.BooleanField("aktiv", default=True) class Meta: + verbose_name = "kategori" + verbose_name_plural = "kategorier" ordering = ["name"] def __str__(self): @@ -24,107 +15,31 @@ class Category(models.Model): class Question(models.Model): - category = models.ForeignKey(Category, on_delete=models.PROTECT, related_name="questions") - prompt = models.TextField() - correct_answer = models.CharField(max_length=255) - is_active = models.BooleanField(default=True) + category = models.ForeignKey(Category, on_delete=models.PROTECT, related_name="questions", verbose_name="kategori") + prompt = models.CharField("spørgsmål", max_length=300, unique=True, + help_text="Skriv _____ hvor svaret skal stå.") + answer = models.CharField("sandheden", max_length=60) + alternatives = models.JSONField("andre stavemåder af sandheden", default=list, blank=True) + decoys = models.JSONField("husets løgne", default=list, blank=True, + help_text="Plausible forkerte svar, bruges når der mangler løgne.") + note = models.CharField("forklaring", max_length=300, blank=True) + active = models.BooleanField("aktiv", default=True) + times_used = models.PositiveIntegerField("brugt", default=0) class Meta: - indexes = [models.Index(fields=["category", "is_active"])] + verbose_name = "spørgsmål" + verbose_name_plural = "spørgsmål" + ordering = ["category__name", "prompt"] def __str__(self): - return f"{self.category.name}: {self.prompt[:60]}" + return self.prompt + + def as_game_dict(self): + return {"id": self.id, "category_id": self.category_id, "category": self.category.name, + "prompt": self.prompt, "answer": self.answer, "alternatives": self.alternatives, + "decoys": self.decoys, "note": self.note} -class GameSession(models.Model): - class Status(models.TextChoices): - LOBBY = "lobby", "Lobby" - LIE = "lie", "Løgnfase" - GUESS = "guess", "Gættefase" - REVEAL = "reveal", "Reveal" - SCOREBOARD = "scoreboard", "Scoreboard" - FINISHED = "finished", "Afsluttet" - - host = models.ForeignKey(User, on_delete=models.PROTECT, related_name="hosted_sessions") - code = models.CharField(max_length=8, unique=True) - status = models.CharField(max_length=16, choices=Status.choices, default=Status.LOBBY) - current_round = models.PositiveIntegerField(default=1) - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - ordering = ["-created_at"] - - def __str__(self): - return f"{self.code} ({self.status})" - - -class Player(models.Model): - session = models.ForeignKey(GameSession, on_delete=models.CASCADE, related_name="players") - nickname = models.CharField(max_length=40) - session_token = models.CharField(max_length=64, db_index=True, default=_generate_player_session_token) - score = models.IntegerField(default=0) - is_connected = models.BooleanField(default=True) - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - unique_together = (("session", "nickname"),) - ordering = ["-score", "nickname"] - - def __str__(self): - return f"{self.nickname} ({self.session.code})" - - -class RoundConfig(models.Model): - session = models.ForeignKey(GameSession, on_delete=models.CASCADE, related_name="rounds") - number = models.PositiveIntegerField() - category = models.ForeignKey(Category, on_delete=models.PROTECT) - questions_count = models.PositiveIntegerField(default=3) - points_correct = models.IntegerField(default=5) - points_bluff = models.IntegerField(default=2) - lie_seconds = models.PositiveIntegerField(default=45) - guess_seconds = models.PositiveIntegerField(default=30) - started_from_scoreboard = models.BooleanField(default=False) - - class Meta: - unique_together = (("session", "number"),) - ordering = ["number"] - - -class RoundQuestion(models.Model): - session = models.ForeignKey(GameSession, on_delete=models.CASCADE, related_name="round_questions") - round_number = models.PositiveIntegerField() - question = models.ForeignKey(Question, on_delete=models.PROTECT) - correct_answer = models.CharField(max_length=255) - shown_at = models.DateTimeField(default=timezone.now) - mixed_answers = models.JSONField(default=list, blank=True) - - -class LieAnswer(models.Model): - round_question = models.ForeignKey(RoundQuestion, on_delete=models.CASCADE, related_name="lies") - player = models.ForeignKey(Player, on_delete=models.CASCADE) - text = models.CharField(max_length=255) - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - unique_together = (("round_question", "player"),) - - -class Guess(models.Model): - round_question = models.ForeignKey(RoundQuestion, on_delete=models.CASCADE, related_name="guesses") - player = models.ForeignKey(Player, on_delete=models.CASCADE) - selected_text = models.CharField(max_length=255) - is_correct = models.BooleanField(default=False) - fooled_player = models.ForeignKey(Player, on_delete=models.SET_NULL, null=True, blank=True, related_name="fooled_guesses") - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - unique_together = (("round_question", "player"),) - - -class ScoreEvent(models.Model): - session = models.ForeignKey(GameSession, on_delete=models.CASCADE, related_name="score_events") - player = models.ForeignKey(Player, on_delete=models.CASCADE, related_name="score_events") - delta = models.IntegerField() - reason = models.CharField(max_length=80) - meta = models.JSONField(default=dict, blank=True) - created_at = models.DateTimeField(auto_now_add=True) +def load_game_questions(): + qs = Question.objects.filter(active=True, category__active=True).select_related("category") + return [q.as_game_dict() for q in qs if "_____" in q.prompt] diff --git a/fupogfakta/payloads.py b/fupogfakta/payloads.py deleted file mode 100644 index 15fce65..0000000 --- a/fupogfakta/payloads.py +++ /dev/null @@ -1,275 +0,0 @@ -from datetime import timedelta - -from .models import GameSession, Player, RoundConfig, RoundQuestion - - -def build_player_ref(player: Player | None) -> dict | None: - if player is None: - return None - - return { - "player_id": player.id, - "nickname": player.nickname, - } - - -def build_round_question_payload(round_question: RoundQuestion | None) -> dict | None: - if round_question is None: - return None - - return { - "id": round_question.id, - "round_number": round_question.round_number, - "prompt": round_question.question.prompt, - "shown_at": round_question.shown_at.isoformat(), - "answers": [{"text": text} for text in (round_question.mixed_answers or [])], - } - - -def build_reveal_payload(round_question: RoundQuestion | None) -> dict | None: - if round_question is None: - return None - - lies = [ - { - **build_player_ref(lie.player), - "text": lie.text, - "created_at": lie.created_at.isoformat(), - } - for lie in round_question.lies.select_related("player").order_by("created_at", "id") - ] - - guesses = [] - for guess in round_question.guesses.select_related("player", "fooled_player").order_by("created_at", "id"): - guess_payload = { - **build_player_ref(guess.player), - "selected_text": guess.selected_text, - "is_correct": guess.is_correct, - "created_at": guess.created_at.isoformat(), - "fooled_player_id": guess.fooled_player_id, - } - if guess.fooled_player is not None: - guess_payload["fooled_player_nickname"] = guess.fooled_player.nickname - guesses.append(guess_payload) - - return { - "round_question_id": round_question.id, - "round_number": round_question.round_number, - "prompt": round_question.question.prompt, - "correct_answer": round_question.correct_answer, - "lies": lies, - "guesses": guesses, - } - - -def build_leaderboard(session: GameSession) -> list[dict]: - return list( - Player.objects.filter(session=session) - .order_by("-score", "nickname") - .values("id", "nickname", "score") - ) - - -def build_lie_started_payload(session: GameSession, round_config: RoundConfig, round_question: RoundQuestion) -> dict: - lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) - return { - "round_number": session.current_round, - "category": {"slug": round_config.category.slug, "name": round_config.category.name}, - "round_question_id": round_question.id, - "prompt": round_question.question.prompt, - "shown_at": round_question.shown_at.isoformat(), - "lie_deadline_at": lie_deadline_at.isoformat(), - "lie_seconds": round_config.lie_seconds, - } - - -def build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict: - status = session.status - in_lobby = status == GameSession.Status.LOBBY - in_lie = status == GameSession.Status.LIE - in_guess = status == GameSession.Status.GUESS - in_scoreboard = status == GameSession.Status.SCOREBOARD - in_finished = status == GameSession.Status.FINISHED - - min_players_reached = players_count >= 3 - max_players_allowed = players_count <= 5 - - return { - "status": status, - "current_phase": status, - "round_number": session.current_round, - "players_count": players_count, - "constraints": { - "min_players_to_start": 3, - "max_players_mvp": 5, - "min_players_reached": min_players_reached, - "max_players_allowed": max_players_allowed, - }, - "readiness": { - "question_ready": has_round_question, - "scoreboard_ready": status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}, - "can_advance_to_next_round": in_scoreboard, - }, - "host": { - "can_start_round": in_lobby and min_players_reached and max_players_allowed, - "can_show_question": False, - "can_mix_answers": False, - "can_calculate_scores": False, - "can_reveal_scoreboard": False, - "can_start_next_round": in_scoreboard, - "can_finish_game": in_scoreboard, - }, - "player": { - "can_join": status in { - GameSession.Status.LOBBY, - GameSession.Status.LIE, - GameSession.Status.GUESS, - GameSession.Status.REVEAL, - GameSession.Status.SCOREBOARD, - }, - "can_submit_lie": in_lie and has_round_question, - "can_submit_guess": in_guess and has_round_question, - "can_view_final_result": in_finished, - }, - } - - -def build_session_detail_gameplay_payload( - session: GameSession, - *, - current_round_question: RoundQuestion | None, - players_count: int, -) -> dict: - return { - "round_question": build_round_question_payload(current_round_question), - "reveal": build_reveal_payload(current_round_question) - if session.status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD} and current_round_question - else None, - "scoreboard": build_scoreboard_phase_event(session)["payload"]["leaderboard"] - if session.status in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED} - else None, - "phase_view_model": build_phase_view_model( - session, - players_count=players_count, - has_round_question=bool(current_round_question), - ), - } - - -def build_start_round_response( - session: GameSession, - round_config: RoundConfig, - round_question: RoundQuestion, -) -> dict: - lie_started_payload = build_lie_started_payload(session, round_config, round_question) - return { - "session": { - "code": session.code, - "status": session.status, - "current_round": session.current_round, - }, - "round": { - "number": round_config.number, - "category": { - "slug": round_config.category.slug, - "name": round_config.category.name, - }, - }, - "round_question": { - "id": round_question.id, - "prompt": round_question.question.prompt, - "round_number": round_question.round_number, - "shown_at": round_question.shown_at.isoformat(), - "lie_deadline_at": lie_started_payload["lie_deadline_at"], - }, - "config": { - "lie_seconds": round_config.lie_seconds, - }, - } - - -def build_question_shown_payload(round_question: RoundQuestion, lie_deadline_at: str, lie_seconds: int) -> dict: - return { - "round_question_id": round_question.id, - "prompt": round_question.question.prompt, - "shown_at": round_question.shown_at.isoformat(), - "lie_deadline_at": lie_deadline_at, - "lie_seconds": lie_seconds, - } - - -def build_question_shown_response(round_question: RoundQuestion, lie_deadline_at: str, lie_seconds: int) -> dict: - return { - "round_question": { - "id": round_question.id, - "prompt": round_question.question.prompt, - "round_number": round_question.round_number, - "shown_at": round_question.shown_at.isoformat(), - "lie_deadline_at": lie_deadline_at, - }, - "config": { - "lie_seconds": lie_seconds, - }, - } - - -def build_start_next_round_response( - session: GameSession, - round_config: RoundConfig, - round_question: RoundQuestion, -) -> dict: - return build_start_round_response(session, round_config, round_question) - - -def build_start_next_round_phase_event( - session: GameSession, - round_config: RoundConfig, - round_question: RoundQuestion, -) -> dict: - return { - "name": "phase.lie_started", - "payload": build_lie_started_payload(session, round_config, round_question), - } - - -def build_scoreboard_phase_event(session: GameSession, leaderboard: list[dict] | None = None) -> dict: - return { - "name": "phase.scoreboard", - "payload": { - "leaderboard": leaderboard if leaderboard is not None else build_leaderboard(session), - "current_round": session.current_round, - }, - } - - -def build_reveal_scoreboard_response(session: GameSession, leaderboard: list[dict]) -> dict: - return { - "session": { - "code": session.code, - "status": session.status, - "current_round": session.current_round, - }, - "leaderboard": leaderboard, - } - - -def build_finish_game_phase_event(session: GameSession) -> dict: - leaderboard = build_leaderboard(session) - winner = leaderboard[0] if leaderboard else None - return { - "name": "phase.game_over", - "payload": {"winner": winner, "leaderboard": leaderboard}, - } - - -def build_finish_game_response(session: GameSession) -> dict: - finish_event = build_finish_game_phase_event(session) - return { - "session": { - "code": session.code, - "status": GameSession.Status.FINISHED, - "current_round": session.current_round, - }, - "winner": finish_event["payload"]["winner"], - "leaderboard": finish_event["payload"]["leaderboard"], - } diff --git a/fupogfakta/services.py b/fupogfakta/services.py deleted file mode 100644 index 7ef9a6f..0000000 --- a/fupogfakta/services.py +++ /dev/null @@ -1,479 +0,0 @@ -import random -from datetime import timedelta -from dataclasses import dataclass -from typing import Any - -from django.db import transaction -from django.utils import timezone - -from .models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent -from .payloads import ( - build_finish_game_phase_event, - build_finish_game_response, - build_lie_started_payload, - build_question_shown_payload, - build_question_shown_response, - build_reveal_scoreboard_response, - build_scoreboard_phase_event, - build_start_next_round_phase_event, - build_start_next_round_response, - build_start_round_response, -) - - -@dataclass(frozen=True) -class RoundTransitionResult: - session: GameSession - round_config: RoundConfig - round_question: RoundQuestion - should_broadcast: bool - response_payload: dict[str, Any] - phase_event_name: str | None = None - phase_event_payload: dict[str, Any] | None = None - - -@dataclass(frozen=True) -class FinishGameResult: - session: GameSession - should_broadcast: bool - response_payload: dict[str, Any] - phase_event_name: str | None = None - phase_event_payload: dict[str, Any] | None = None - - -@dataclass(frozen=True) -class ScoreboardTransitionResult: - session: GameSession - leaderboard: list[dict] - should_broadcast: bool - response_payload: dict[str, Any] | None = None - phase_event_name: str | None = None - phase_event_payload: dict[str, Any] | None = None - - -def get_round_question(session: GameSession, round_number: int) -> RoundQuestion | None: - return ( - RoundQuestion.objects.filter(session=session, round_number=round_number) - .select_related("question") - .order_by("-id") - .first() - ) - - - -def get_current_round_question(session: GameSession) -> RoundQuestion | None: - return get_round_question(session, session.current_round) - - - -def reset_round_question_bootstrap_state(round_question: RoundQuestion) -> RoundQuestion: - Guess.objects.filter(round_question=round_question).delete() - LieAnswer.objects.filter(round_question=round_question).delete() - - update_fields: list[str] = [] - if round_question.mixed_answers: - round_question.mixed_answers = [] - update_fields.append("mixed_answers") - - round_question.shown_at = timezone.now() - update_fields.append("shown_at") - - round_question.save(update_fields=update_fields) - return round_question - - - -def select_round_question( - session: GameSession, - round_config: RoundConfig, - *, - round_number: int | None = None, -) -> RoundQuestion: - target_round_number = session.current_round if round_number is None else round_number - existing_round_question = get_round_question(session, target_round_number) - if existing_round_question is not None and existing_round_question.question.category_id == round_config.category_id: - return existing_round_question - - used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True) - available_questions = Question.objects.filter( - category=round_config.category, - is_active=True, - ).exclude(pk__in=used_question_ids) - - if not available_questions.exists(): - raise ValueError("no_available_questions") - - question = random.choice(list(available_questions)) - if existing_round_question is not None: - existing_round_question.question = question - existing_round_question.correct_answer = question.correct_answer - existing_round_question.save(update_fields=["question", "correct_answer"]) - return existing_round_question - - return RoundQuestion.objects.create( - session=session, - round_number=target_round_number, - question=question, - correct_answer=question.correct_answer, - ) - - - -def prepare_mixed_answers(round_question: RoundQuestion) -> list[str]: - deduped_answers = list(round_question.mixed_answers or []) - if deduped_answers: - return deduped_answers - - lie_texts = list(round_question.lies.values_list("text", flat=True)) - seen = set() - for text in [round_question.correct_answer, *lie_texts]: - normalized = text.strip().casefold() - if not normalized or normalized in seen: - continue - seen.add(normalized) - deduped_answers.append(text.strip()) - - if len(deduped_answers) < 2: - raise ValueError("not_enough_answers_to_mix") - - random.shuffle(deduped_answers) - round_question.mixed_answers = deduped_answers - round_question.save(update_fields=["mixed_answers"]) - return deduped_answers - - - - - -def start_round(session: GameSession, category_slug: str) -> RoundTransitionResult: - try: - category = Category.objects.get(slug=category_slug, is_active=True) - except Category.DoesNotExist: - raise ValueError("category_not_found") - - if not Question.objects.filter(category=category, is_active=True).exists(): - raise ValueError("category_has_no_questions") - - with transaction.atomic(): - locked_session = GameSession.objects.select_for_update().get(pk=session.pk) - if locked_session.status != GameSession.Status.LOBBY: - raise ValueError("round_start_invalid_phase") - - if RoundConfig.objects.filter(session=locked_session, number=locked_session.current_round).exists(): - raise ValueError("round_already_configured") - - round_config = RoundConfig( - session=locked_session, - number=locked_session.current_round, - category=category, - ) - - round_question = select_round_question(locked_session, round_config) - - round_config.save() - locked_session.status = GameSession.Status.LIE - locked_session.save(update_fields=["status"]) - - phase_event = { - "name": "phase.lie_started", - "payload": build_lie_started_payload(locked_session, round_config, round_question), - } - return RoundTransitionResult( - session=locked_session, - round_config=round_config, - round_question=round_question, - should_broadcast=True, - response_payload=build_start_round_response(locked_session, round_config, round_question), - phase_event_name=phase_event["name"], - phase_event_payload=phase_event["payload"], - ) - - -def show_question(session: GameSession) -> RoundTransitionResult: - if session.status != GameSession.Status.LIE: - raise ValueError("show_question_invalid_phase") - - try: - round_config = RoundConfig.objects.get(session=session, number=session.current_round) - except RoundConfig.DoesNotExist: - raise ValueError("round_config_missing") - - round_question = get_current_round_question(session) - if round_question is None: - round_question = select_round_question(session, round_config) - - lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) - lie_deadline_iso = lie_deadline_at.isoformat() - phase_event = { - "name": "phase.question_shown", - "payload": build_question_shown_payload(round_question, lie_deadline_iso, round_config.lie_seconds), - } - return RoundTransitionResult( - session=session, - round_config=round_config, - round_question=round_question, - should_broadcast=True, - response_payload=build_question_shown_response(round_question, lie_deadline_iso, round_config.lie_seconds), - phase_event_name=phase_event["name"], - phase_event_payload=phase_event["payload"], - ) - -def start_next_round(session: GameSession) -> RoundTransitionResult: - with transaction.atomic(): - locked_session = GameSession.objects.select_for_update().get(pk=session.pk) - next_round_config = None - round_question = None - should_broadcast = False - - phase_event_name = None - phase_event_payload = None - - if locked_session.status == GameSession.Status.SCOREBOARD: - previous_round_config = RoundConfig.objects.filter( - session=locked_session, - number=locked_session.current_round, - ).select_related("category").first() - if previous_round_config is None: - raise ValueError("round_config_missing") - - next_round_number = locked_session.current_round + 1 - next_round_config, _created = RoundConfig.objects.get_or_create( - session=locked_session, - number=next_round_number, - defaults={ - "category": previous_round_config.category, - "lie_seconds": previous_round_config.lie_seconds, - "guess_seconds": previous_round_config.guess_seconds, - "points_correct": previous_round_config.points_correct, - "points_bluff": previous_round_config.points_bluff, - "started_from_scoreboard": True, - }, - ) - round_config_update_fields: list[str] = [] - if next_round_config.category_id != previous_round_config.category_id: - next_round_config.category = previous_round_config.category - round_config_update_fields.append("category") - if next_round_config.lie_seconds != previous_round_config.lie_seconds: - next_round_config.lie_seconds = previous_round_config.lie_seconds - round_config_update_fields.append("lie_seconds") - if next_round_config.guess_seconds != previous_round_config.guess_seconds: - next_round_config.guess_seconds = previous_round_config.guess_seconds - round_config_update_fields.append("guess_seconds") - if next_round_config.points_correct != previous_round_config.points_correct: - next_round_config.points_correct = previous_round_config.points_correct - round_config_update_fields.append("points_correct") - if next_round_config.points_bluff != previous_round_config.points_bluff: - next_round_config.points_bluff = previous_round_config.points_bluff - round_config_update_fields.append("points_bluff") - if not next_round_config.started_from_scoreboard: - next_round_config.started_from_scoreboard = True - round_config_update_fields.append("started_from_scoreboard") - if round_config_update_fields: - next_round_config.save(update_fields=round_config_update_fields) - - locked_session.current_round = next_round_number - - round_question = reset_round_question_bootstrap_state( - select_round_question(locked_session, next_round_config, round_number=next_round_number) - ) - - locked_session.status = GameSession.Status.LIE - locked_session.save(update_fields=["current_round", "status"]) - should_broadcast = True - phase_event = build_start_next_round_phase_event(locked_session, next_round_config, round_question) - phase_event_name = phase_event["name"] - phase_event_payload = phase_event["payload"] - elif locked_session.status == GameSession.Status.LIE: - if locked_session.current_round <= 1: - raise ValueError("next_round_invalid_phase") - - next_round_config = RoundConfig.objects.filter( - session=locked_session, - number=locked_session.current_round, - ).select_related("category").first() - round_question = get_current_round_question(locked_session) - if ( - next_round_config is None - or not next_round_config.started_from_scoreboard - or round_question is None - ): - raise ValueError("next_round_invalid_phase") - else: - raise ValueError("next_round_invalid_phase") - - return RoundTransitionResult( - session=locked_session, - round_config=next_round_config, - round_question=round_question, - should_broadcast=should_broadcast, - response_payload=build_start_next_round_response( - locked_session, - next_round_config, - round_question, - ), - phase_event_name=phase_event_name, - phase_event_payload=phase_event_payload, - ) - - - -def finish_game(session: GameSession) -> FinishGameResult: - with transaction.atomic(): - locked_session = GameSession.objects.select_for_update().get(pk=session.pk) - should_broadcast = False - phase_event_name = None - phase_event_payload = None - - if locked_session.status == GameSession.Status.SCOREBOARD: - locked_session.status = GameSession.Status.FINISHED - locked_session.save(update_fields=["status"]) - should_broadcast = True - phase_event = build_finish_game_phase_event(locked_session) - phase_event_name = phase_event["name"] - phase_event_payload = phase_event["payload"] - elif locked_session.status != GameSession.Status.FINISHED: - raise ValueError("finish_game_invalid_phase") - - return FinishGameResult( - session=locked_session, - should_broadcast=should_broadcast, - response_payload=build_finish_game_response(locked_session), - phase_event_name=phase_event_name, - phase_event_payload=phase_event_payload, - ) - - - -def promote_reveal_to_scoreboard(session: GameSession) -> ScoreboardTransitionResult: - if session.status != GameSession.Status.REVEAL: - leaderboard = list( - Player.objects.filter(session=session) - .order_by("-score", "nickname") - .values("id", "nickname", "score") - ) - return ScoreboardTransitionResult( - session=session, - leaderboard=leaderboard, - should_broadcast=False, - response_payload=build_reveal_scoreboard_response(session, leaderboard), - ) - - current_round_question = get_current_round_question(session) - if current_round_question is None: - leaderboard = list( - Player.objects.filter(session=session) - .order_by("-score", "nickname") - .values("id", "nickname", "score") - ) - return ScoreboardTransitionResult( - session=session, - leaderboard=leaderboard, - should_broadcast=False, - response_payload=build_reveal_scoreboard_response(session, leaderboard), - ) - - players_count = Player.objects.filter(session=session).count() - guess_count = Guess.objects.filter(round_question=current_round_question).count() - has_score_events = ScoreEvent.objects.filter( - session=session, - meta__round_question_id=current_round_question.id, - ).exists() - reveal_is_resolved = has_score_events or (players_count > 0 and guess_count >= players_count) - if not reveal_is_resolved: - leaderboard = list( - Player.objects.filter(session=session) - .order_by("-score", "nickname") - .values("id", "nickname", "score") - ) - return ScoreboardTransitionResult( - session=session, - leaderboard=leaderboard, - should_broadcast=False, - response_payload=build_reveal_scoreboard_response(session, leaderboard), - ) - - with transaction.atomic(): - locked_session = GameSession.objects.select_for_update().get(pk=session.pk) - if locked_session.status != GameSession.Status.REVEAL: - scoreboard_session = locked_session - should_broadcast = False - else: - locked_session.status = GameSession.Status.SCOREBOARD - locked_session.save(update_fields=["status"]) - scoreboard_session = locked_session - should_broadcast = True - - leaderboard = list( - Player.objects.filter(session=scoreboard_session) - .order_by("-score", "nickname") - .values("id", "nickname", "score") - ) - phase_event_name = None - phase_event_payload = None - if should_broadcast: - phase_event = build_scoreboard_phase_event(scoreboard_session, leaderboard) - phase_event_name = phase_event["name"] - phase_event_payload = phase_event["payload"] - return ScoreboardTransitionResult( - session=scoreboard_session, - leaderboard=leaderboard, - should_broadcast=should_broadcast, - response_payload=build_reveal_scoreboard_response(scoreboard_session, leaderboard), - phase_event_name=phase_event_name, - phase_event_payload=phase_event_payload, - ) - - - -def resolve_scores( - session: GameSession, - round_question: RoundQuestion, - round_config: RoundConfig, -) -> tuple[list[ScoreEvent], list[dict]]: - guesses = list(round_question.guesses.select_related("player")) - if not guesses: - raise ValueError("no_guesses_submitted") - - bluff_counts: dict[int, int] = {} - for guess in guesses: - if guess.fooled_player_id: - bluff_counts[guess.fooled_player_id] = bluff_counts.get(guess.fooled_player_id, 0) + 1 - - score_events = [] - - for guess in guesses: - if guess.is_correct: - guess.player.score += round_config.points_correct - guess.player.save(update_fields=["score"]) - score_events.append( - ScoreEvent( - session=session, - player=guess.player, - delta=round_config.points_correct, - reason="guess_correct", - meta={"round_question_id": round_question.id, "guess_id": guess.id}, - ) - ) - - for player_id, fooled_count in bluff_counts.items(): - delta = fooled_count * round_config.points_bluff - player = Player.objects.get(pk=player_id, session=session) - player.score += delta - player.save(update_fields=["score"]) - score_events.append( - ScoreEvent( - session=session, - player=player, - delta=delta, - reason="bluff_success", - meta={"round_question_id": round_question.id, "fooled_count": fooled_count}, - ) - ) - - ScoreEvent.objects.bulk_create(score_events) - leaderboard = list( - Player.objects.filter(session=session) - .order_by("-score", "nickname") - .values("id", "nickname", "score") - ) - return score_events, leaderboard diff --git a/fupogfakta/tests.py b/fupogfakta/tests.py deleted file mode 100644 index 6978ad5..0000000 --- a/fupogfakta/tests.py +++ /dev/null @@ -1,410 +0,0 @@ -from datetime import timedelta -from unittest.mock import patch - -from django.contrib.auth import get_user_model -from django.test import TestCase -from django.utils import timezone - -from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent -from fupogfakta.payloads import ( - build_lie_started_payload, - build_phase_view_model, - build_reveal_payload, - build_round_question_payload, - build_session_detail_gameplay_payload, -) -from fupogfakta.services import ( - finish_game, - get_current_round_question, - prepare_mixed_answers, - promote_reveal_to_scoreboard, - resolve_scores, - select_round_question, - start_next_round, -) - -User = get_user_model() - - -class FupOgFaktaExtractionSliceTests(TestCase): - def setUp(self): - self.host = User.objects.create_user(username="host", password="secret123") - self.session = GameSession.objects.create(host=self.host, code="ABCD23") - self.category = Category.objects.create(name="Historie", slug="historie", is_active=True) - self.question_one = Question.objects.create( - category=self.category, - prompt="Hvornår faldt muren?", - correct_answer="1989", - is_active=True, - ) - self.question_two = Question.objects.create( - category=self.category, - prompt="Hvornår kom euroen?", - correct_answer="1999", - is_active=True, - ) - self.round_config = RoundConfig.objects.create(session=self.session, number=1, category=self.category) - self.alice = Player.objects.create(session=self.session, nickname="Alice") - self.bob = Player.objects.create(session=self.session, nickname="Bob") - self.clara = Player.objects.create(session=self.session, nickname="Clara") - - def test_select_round_question_skips_already_used_questions_for_session(self): - RoundQuestion.objects.create( - session=self.session, - round_number=99, - question=self.question_one, - correct_answer=self.question_one.correct_answer, - ) - - round_question = select_round_question(self.session, self.round_config) - - self.assertEqual(round_question.question, self.question_two) - self.assertEqual(get_current_round_question(self.session), round_question) - - def test_prepare_mixed_answers_dedupes_blank_and_case_variants(self): - round_question = RoundQuestion.objects.create( - session=self.session, - round_number=1, - question=self.question_one, - correct_answer="1989", - ) - LieAnswer.objects.create(round_question=round_question, player=self.alice, text=" 1989 ") - LieAnswer.objects.create(round_question=round_question, player=self.bob, text="Nitten niogfirs") - LieAnswer.objects.create(round_question=round_question, player=self.clara, text=" ") - - with patch("fupogfakta.services.random.shuffle", side_effect=lambda answers: None): - answers = prepare_mixed_answers(round_question) - - self.assertEqual(answers, ["1989", "Nitten niogfirs"]) - round_question.refresh_from_db() - self.assertEqual(round_question.mixed_answers, answers) - - def test_start_next_round_moves_scoreboard_transition_into_service(self): - self.session.status = GameSession.Status.SCOREBOARD - self.session.save(update_fields=["status"]) - - result = start_next_round(self.session) - - self.session.refresh_from_db() - self.assertTrue(result.should_broadcast) - self.assertEqual(result.session.status, GameSession.Status.LIE) - self.assertEqual(result.session.current_round, 2) - self.assertEqual(result.round_config.number, 2) - self.assertTrue(result.round_config.started_from_scoreboard) - self.assertEqual(result.round_question.round_number, 2) - - def test_start_next_round_rejects_plain_lie_without_scoreboard_marker(self): - self.session.status = GameSession.Status.LIE - self.session.current_round = 2 - self.session.save(update_fields=["status", "current_round"]) - RoundConfig.objects.create(session=self.session, number=2, category=self.category, started_from_scoreboard=False) - RoundQuestion.objects.create( - session=self.session, - round_number=2, - question=self.question_two, - correct_answer=self.question_two.correct_answer, - ) - - with self.assertRaisesMessage(ValueError, "next_round_invalid_phase"): - start_next_round(self.session) - - def test_start_next_round_refreshes_shown_at_for_reused_bootstrap_question(self): - self.session.status = GameSession.Status.SCOREBOARD - self.session.save(update_fields=["status"]) - stale_shown_at = timezone.now() - timedelta(minutes=10) - stale_round_question = RoundQuestion.objects.create( - session=self.session, - round_number=2, - question=self.question_two, - correct_answer=self.question_two.correct_answer, - shown_at=stale_shown_at, - mixed_answers=["Stale truth", "Stale lie"], - ) - LieAnswer.objects.create(round_question=stale_round_question, player=self.alice, text="Stale lie") - Guess.objects.create( - round_question=stale_round_question, - player=self.bob, - selected_text="Stale truth", - is_correct=True, - ) - - before_transition = timezone.now() - result = start_next_round(self.session) - after_transition = timezone.now() - - stale_round_question.refresh_from_db() - self.assertEqual(result.round_question.id, stale_round_question.id) - self.assertGreaterEqual(stale_round_question.shown_at, before_transition) - self.assertLessEqual(stale_round_question.shown_at, after_transition) - self.assertNotEqual(stale_round_question.shown_at, stale_shown_at) - self.assertEqual(result.response_payload["round_question"]["shown_at"], stale_round_question.shown_at.isoformat()) - expected_deadline = stale_round_question.shown_at + timedelta(seconds=result.round_config.lie_seconds) - self.assertEqual(result.response_payload["round_question"]["lie_deadline_at"], expected_deadline.isoformat()) - self.assertGreater(expected_deadline, before_transition) - self.assertEqual(stale_round_question.mixed_answers, []) - self.assertEqual(stale_round_question.lies.count(), 0) - self.assertEqual(stale_round_question.guesses.count(), 0) - - def test_start_next_round_reuses_existing_bootstrap_round_config_with_fresh_canonical_values(self): - self.session.status = GameSession.Status.SCOREBOARD - self.session.save(update_fields=["status"]) - stale_category = Category.objects.create(name="Sport", slug="sport", is_active=True) - stale_round_config = RoundConfig.objects.create( - session=self.session, - number=2, - category=stale_category, - lie_seconds=12, - guess_seconds=18, - points_correct=9, - points_bluff=7, - started_from_scoreboard=False, - ) - stale_round_question = RoundQuestion.objects.create( - session=self.session, - round_number=2, - question=self.question_two, - correct_answer=self.question_two.correct_answer, - shown_at=timezone.now() - timedelta(minutes=10), - mixed_answers=["Stale truth"], - ) - - result = start_next_round(self.session) - - stale_round_config.refresh_from_db() - stale_round_question.refresh_from_db() - self.assertEqual(result.round_config.id, stale_round_config.id) - self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1) - self.assertEqual(stale_round_config.category_id, self.round_config.category_id) - self.assertEqual(stale_round_config.lie_seconds, self.round_config.lie_seconds) - self.assertEqual(stale_round_config.guess_seconds, self.round_config.guess_seconds) - self.assertEqual(stale_round_config.points_correct, self.round_config.points_correct) - self.assertEqual(stale_round_config.points_bluff, self.round_config.points_bluff) - self.assertTrue(stale_round_config.started_from_scoreboard) - self.assertEqual(result.round_question.id, stale_round_question.id) - self.assertEqual(stale_round_question.mixed_answers, []) - - def test_start_next_round_repairs_reused_bootstrap_question_when_category_drifted(self): - self.session.status = GameSession.Status.SCOREBOARD - self.session.save(update_fields=["status"]) - RoundQuestion.objects.create( - session=self.session, - round_number=1, - question=self.question_one, - correct_answer=self.question_one.correct_answer, - ) - stale_category = Category.objects.create(name="Sport drift", slug="sport-drift", is_active=True) - stale_question = Question.objects.create( - category=stale_category, - prompt="Hvem vandt EM i 1992?", - correct_answer="Danmark", - is_active=True, - ) - stale_round_question = RoundQuestion.objects.create( - session=self.session, - round_number=2, - question=stale_question, - correct_answer=stale_question.correct_answer, - shown_at=timezone.now() - timedelta(minutes=10), - mixed_answers=["Stale truth", "Stale lie"], - ) - LieAnswer.objects.create(round_question=stale_round_question, player=self.alice, text="Tyskland") - Guess.objects.create( - round_question=stale_round_question, - player=self.bob, - selected_text="Stale truth", - is_correct=True, - ) - - result = start_next_round(self.session) - - stale_round_question.refresh_from_db() - self.assertEqual(result.round_question.id, stale_round_question.id) - self.assertEqual(stale_round_question.question.category_id, self.round_config.category_id) - self.assertEqual(stale_round_question.question_id, self.question_two.id) - self.assertEqual(stale_round_question.correct_answer, self.question_two.correct_answer) - self.assertEqual(stale_round_question.mixed_answers, []) - self.assertEqual(stale_round_question.lies.count(), 0) - self.assertEqual(stale_round_question.guesses.count(), 0) - - def test_start_next_round_does_not_reuse_previous_round_question_when_category_matches(self): - self.session.status = GameSession.Status.SCOREBOARD - self.session.save(update_fields=["status"]) - previous_round_question = RoundQuestion.objects.create( - session=self.session, - round_number=1, - question=self.question_one, - correct_answer=self.question_one.correct_answer, - mixed_answers=["1989", "1991"], - ) - LieAnswer.objects.create(round_question=previous_round_question, player=self.alice, text="1991") - Guess.objects.create( - round_question=previous_round_question, - player=self.bob, - selected_text="1991", - is_correct=False, - fooled_player=self.alice, - ) - - result = start_next_round(self.session) - - previous_round_question.refresh_from_db() - self.session.refresh_from_db() - self.assertEqual(self.session.current_round, 2) - self.assertEqual(result.round_question.round_number, 2) - self.assertNotEqual(result.round_question.id, previous_round_question.id) - self.assertEqual(result.round_question.question_id, self.question_two.id) - self.assertEqual(previous_round_question.round_number, 1) - self.assertEqual(previous_round_question.question_id, self.question_one.id) - self.assertEqual(previous_round_question.mixed_answers, ["1989", "1991"]) - self.assertEqual(previous_round_question.lies.count(), 1) - self.assertEqual(previous_round_question.guesses.count(), 1) - - def test_finish_game_moves_scoreboard_transition_into_service(self): - self.session.status = GameSession.Status.SCOREBOARD - self.session.save(update_fields=["status"]) - - result = finish_game(self.session) - - self.session.refresh_from_db() - self.assertTrue(result.should_broadcast) - self.assertEqual(result.session.status, GameSession.Status.FINISHED) - self.assertEqual(self.session.status, GameSession.Status.FINISHED) - - def test_promote_reveal_to_scoreboard_moves_transition_into_service(self): - round_question = RoundQuestion.objects.create( - session=self.session, - round_number=1, - question=self.question_one, - correct_answer=self.question_one.correct_answer, - ) - self.session.status = GameSession.Status.REVEAL - self.session.save(update_fields=["status"]) - - LieAnswer.objects.create(round_question=round_question, player=self.alice, text="Elbil") - Guess.objects.create( - round_question=round_question, - player=self.bob, - selected_text="Elbil", - is_correct=False, - fooled_player=self.alice, - ) - ScoreEvent.objects.create( - session=self.session, - player=self.alice, - delta=5, - reason="bluff_success", - meta={"round_question_id": round_question.id}, - ) - self.alice.score = 5 - self.alice.save(update_fields=["score"]) - - result = promote_reveal_to_scoreboard(self.session) - - self.session.refresh_from_db() - self.assertTrue(result.should_broadcast) - self.assertEqual(result.session.status, GameSession.Status.SCOREBOARD) - self.assertEqual(result.leaderboard[0]["nickname"], self.alice.nickname) - - def test_resolve_scores_applies_correct_and_bluff_points(self): - round_question = RoundQuestion.objects.create( - session=self.session, - round_number=1, - question=self.question_one, - correct_answer="1989", - ) - Guess.objects.create( - round_question=round_question, - player=self.alice, - selected_text="1989", - is_correct=True, - ) - Guess.objects.create( - round_question=round_question, - player=self.bob, - selected_text="Berlin", - is_correct=False, - fooled_player=self.clara, - ) - Guess.objects.create( - round_question=round_question, - player=self.clara, - selected_text="Berlin", - is_correct=False, - fooled_player=self.clara, - ) - - score_events, leaderboard = resolve_scores(self.session, round_question, self.round_config) - - self.assertEqual(len(score_events), 2) - self.alice.refresh_from_db() - self.clara.refresh_from_db() - self.assertEqual(self.alice.score, self.round_config.points_correct) - self.assertEqual(self.clara.score, self.round_config.points_bluff * 2) - self.assertEqual(ScoreEvent.objects.filter(session=self.session, meta__round_question_id=round_question.id).count(), 2) - self.assertEqual([entry["nickname"] for entry in leaderboard], ["Alice", "Clara", "Bob"]) - - def test_payload_builders_expose_fupogfakta_round_contract(self): - round_question = RoundQuestion.objects.create( - session=self.session, - round_number=1, - question=self.question_one, - correct_answer="1989", - ) - lie = LieAnswer.objects.create(round_question=round_question, player=self.bob, text="1991") - Guess.objects.create( - round_question=round_question, - player=self.alice, - selected_text="1991", - is_correct=False, - fooled_player=self.bob, - ) - - round_question_payload = build_round_question_payload(round_question) - lie_payload = build_lie_started_payload(self.session, self.round_config, round_question) - reveal_payload = build_reveal_payload(round_question) - phase_view_model = build_phase_view_model( - self.session, - players_count=3, - has_round_question=True, - ) - - self.assertEqual(round_question_payload["prompt"], self.question_one.prompt) - self.assertEqual(round_question_payload["answers"], []) - self.assertEqual(lie_payload["category"], {"slug": self.category.slug, "name": self.category.name}) - self.assertEqual(lie_payload["round_question_id"], round_question.id) - self.assertEqual(reveal_payload["correct_answer"], "1989") - self.assertEqual(reveal_payload["lies"][0]["player_id"], lie.player_id) - self.assertEqual(reveal_payload["guesses"][0]["fooled_player_nickname"], self.bob.nickname) - self.assertTrue(phase_view_model["host"]["can_start_round"]) - self.assertFalse(phase_view_model["host"]["can_finish_game"]) - - def test_build_session_detail_gameplay_payload_keeps_session_detail_semantics_in_cartridge(self): - self.session.status = GameSession.Status.SCOREBOARD - self.session.save(update_fields=["status"]) - round_question = RoundQuestion.objects.create( - session=self.session, - round_number=1, - question=self.question_one, - correct_answer=self.question_one.correct_answer, - ) - lie = LieAnswer.objects.create(round_question=round_question, player=self.bob, text="1991") - Guess.objects.create( - round_question=round_question, - player=self.alice, - selected_text="1991", - is_correct=False, - fooled_player=self.bob, - ) - - gameplay_payload = build_session_detail_gameplay_payload( - self.session, - current_round_question=round_question, - players_count=3, - ) - - self.assertEqual(gameplay_payload["round_question"]["id"], round_question.id) - self.assertEqual(gameplay_payload["reveal"]["lies"][0]["player_id"], lie.player_id) - self.assertEqual(gameplay_payload["scoreboard"], [{"id": self.alice.id, "nickname": self.alice.nickname, "score": self.alice.score}, {"id": self.bob.id, "nickname": self.bob.nickname, "score": self.bob.score}, {"id": self.clara.id, "nickname": self.clara.nickname, "score": self.clara.score}]) - self.assertEqual(gameplay_payload["phase_view_model"]["status"], GameSession.Status.SCOREBOARD) - self.assertTrue(gameplay_payload["phase_view_model"]["host"]["can_start_next_round"]) - self.assertTrue(gameplay_payload["phase_view_model"]["host"]["can_finish_game"]) diff --git a/lobby/__init__.py b/fupogfakta/tests/__init__.py similarity index 100% rename from lobby/__init__.py rename to fupogfakta/tests/__init__.py diff --git a/fupogfakta/tests/test_engine.py b/fupogfakta/tests/test_engine.py new file mode 100644 index 0000000..0c8c186 --- /dev/null +++ b/fupogfakta/tests/test_engine.py @@ -0,0 +1,198 @@ +import random + +from django.test import SimpleTestCase + +from fupogfakta import engine +from fupogfakta.engine import Game, GameError + + +def bank(n_cats=5, per_cat=6): + qs = [] + for c in range(1, n_cats + 1): + for i in range(per_cat): + qs.append({"id": c * 100 + i, "category_id": c, "category": f"Kategori {c}", + "prompt": f"Spørgsmål {c}-{i} har svaret _____.", "answer": f"sandhed{c}{i}", + "alternatives": [], "decoys": [f"lok{c}{i}a", f"lok{c}{i}b", f"lok{c}{i}c"], "note": "Fordi."}) + return qs + + +class GameTestCase(SimpleTestCase): + def setUp(self): + self.now = 1000.0 + self.g = Game("TEST", bank(), join_url="http://x/?code=TEST", rng=random.Random(4)) + + def join(self, *names): + return [self.g.join(n) for n in names] + + def through_category(self): + if self.g.phase == "category": + self.g.choose_category(self.g.chooser_id, self.g.category_options[0]["id"], self.now) + self.assertEqual(self.g.phase, "lie") + + def truth_id(self): + return next(a.id for a in self.g.answers if a.is_truth) + + +class LobbyTests(GameTestCase): + def test_vip_is_first_player_and_only_vip_starts(self): + a, b = self.join("Asger", "Sus") + with self.assertRaises(GameError) as e: + self.g.start(b.id, self.now) + self.assertEqual(e.exception.code, "not_allowed") + self.g.start(a.id, self.now) + self.assertEqual(self.g.phase, "category") + + def test_too_few_players(self): + self.join("Asger") + with self.assertRaises(GameError) as e: + self.g.start(None, self.now) + self.assertEqual(e.exception.code, "too_few_players") + + def test_name_rules(self): + self.join("Asger") + for bad, code in [("asger", "name_taken"), ("", "name_invalid"), ("x" * 13, "name_invalid")]: + with self.assertRaises(GameError) as e: + self.g.join(bad) + self.assertEqual(e.exception.code, code) + + def test_ninth_and_late_joiners_become_audience(self): + players = self.join(*[f"P{i}" for i in range(8)]) + self.assertTrue(all(p.role == "player" for p in players)) + self.assertEqual(self.g.join("Ni").role, "audience") + self.g.start(None, self.now) + self.assertEqual(self.g.join("Sen").role, "audience") + + def test_resume_by_token(self): + a, = self.join("Asger") + self.assertIs(self.g.resume(a.token), a) + with self.assertRaises(GameError): + self.g.resume("nope") + + +class RoundTests(GameTestCase): + def setUp(self): + super().setUp() + self.a, self.b, self.c = self.join("A", "B", "C") + self.g.start(None, self.now) + self.through_category() + + def test_only_chooser_may_choose(self): + self.g._begin_question(self.now) + other = next(p for p in self.g.players if p != self.g.chooser_id) + with self.assertRaises(GameError): + self.g.choose_category(other, self.g.category_options[0]["id"], self.now) + + def test_lie_equal_or_close_to_truth_is_rejected(self): + truth = self.g.question["answer"] + for attempt in [truth, truth.upper() + "!", " " + truth + " ", "en " + truth, truth[:-1]]: + with self.assertRaises(GameError) as e: + self.g.submit_lie(self.a.id, attempt, self.now) + self.assertEqual(e.exception.code, "lie_is_truth", attempt) + + def test_all_lies_moves_to_guess_and_hides_own_lie(self): + self.g.submit_lie(self.a.id, "katte", self.now) + self.g.submit_lie(self.b.id, "hunde", self.now) + self.assertEqual(self.g.phase, "lie") + self.g.submit_lie(self.c.id, "KATTE", self.now) # samme løgn som A + self.assertEqual(self.g.phase, "guess") + texts = [a.text for a in self.g.answers] + self.assertEqual(len(self.g.answers), 4) # katte, hunde, sandhed + husets løgn + shared = next(a for a in self.g.answers if a.text == "katte") + self.assertEqual(sorted(shared.authors), sorted([self.a.id, self.c.id])) + snap = self.g.snapshot(self.now, viewer=self.a) + self.assertNotIn(shared.id, [x["id"] for x in snap["answers"]]) + self.assertIn(self.g.question["answer"], texts) + + def test_cannot_guess_own_lie_and_scoring(self): + for p, lie in [(self.a, "katte"), (self.b, "hunde"), (self.c, "heste")]: + self.g.submit_lie(p.id, lie, self.now) + by_text = {a.text: a.id for a in self.g.answers} + with self.assertRaises(GameError): + self.g.guess(self.a.id, by_text["katte"], self.now) + self.g.guess(self.a.id, self.truth_id(), self.now) + self.g.guess(self.b.id, by_text["katte"], self.now) + self.g.guess(self.c.id, by_text["katte"], self.now) + self.assertEqual(self.g.phase, "reveal") + # A: rigtigt (1000) + narrede B og C (2 × 500) + self.assertEqual(self.a.score, 2000) + self.assertEqual(self.b.score, 0) + steps = self.g.reveal_steps + self.assertTrue(steps[-1]["is_truth"]) + self.assertEqual(steps[0]["text"], "katte") + + def test_like_gives_points_and_toggles(self): + for p, lie in [(self.a, "katte"), (self.b, "hunde"), (self.c, "heste")]: + self.g.submit_lie(p.id, lie, self.now) + katte = next(a.id for a in self.g.answers if a.text == "katte") + with self.assertRaises(GameError): # skal gætte først + self.g.like(self.b.id, katte) + self.g.guess(self.b.id, self.truth_id(), self.now) + self.g.like(self.b.id, katte) + self.assertEqual(self.a.score, engine.POINTS_LIKE) + self.g.like(self.b.id, katte) + self.assertEqual(self.a.score, 0) + + def test_timeouts_advance_and_fill_with_house_lies(self): + self.g.submit_lie(self.a.id, "katte", self.now) + self.assertTrue(self.g.tick(self.now + engine.LIE_SECONDS + 1)) + self.assertEqual(self.g.phase, "guess") + self.assertGreaterEqual(len(self.g.answers), 4) + self.assertTrue(any(a.house for a in self.g.answers)) + self.g.tick(self.now + 200) + self.assertEqual(self.g.phase, "reveal") + + def test_disconnected_player_does_not_block(self): + self.g.set_connected(self.c.id, False, self.now) + self.g.submit_lie(self.a.id, "katte", self.now) + self.g.submit_lie(self.b.id, "hunde", self.now) + self.assertEqual(self.g.phase, "guess") + + def test_audience_votes_give_bonus(self): + u = self.g.join("Publikum") + for p, lie in [(self.a, "katte"), (self.b, "hunde"), (self.c, "heste")]: + self.g.submit_lie(p.id, lie, self.now) + katte = next(a.id for a in self.g.answers if a.text == "katte") + self.g.guess(u.id, katte, self.now) + for p in (self.a, self.b, self.c): + self.g.guess(p.id, self.truth_id(), self.now) + self.assertEqual(self.a.score, 1000 + engine.POINTS_AUDIENCE) + + +class FullGameTests(GameTestCase): + def test_full_game_reaches_final_with_awards(self): + players = self.join("A", "B", "C") + self.g.start(players[0].id, self.now) + seen_phases = [] + for _ in range(200): + if self.g.phase == "final": + break + seen_phases.append(self.g.phase) + if self.g.phase == "lie": + for i, p in enumerate(players): + self.g.submit_lie(p.id, f"løgn {p.name} {self.g.q_idx} {self.g.round_idx}", self.now) + elif self.g.phase == "guess": + for p in players: + if p.id not in self.g.guesses: + opts = [a for a in self.g.answers if p.id not in a.authors] + self.g.guess(p.id, opts[0].id, self.now) + else: + self.now += 100 + self.g.tick(self.now) + self.assertEqual(self.g.phase, "final") + self.assertEqual(seen_phases.count("lie"), 7) + self.assertEqual(seen_phases.count("scores"), 2) + self.assertEqual(seen_phases.count("category"), 6) # finalen vælger selv + final = self.g.snapshot(self.now, host=True)["final"] + self.assertEqual(len(final["ranking"]), 3) + self.assertTrue(final["awards"]) + self.assertEqual(len(self.g.used_questions), 7) + self.g.play_again(None, self.now) + self.assertEqual(self.g.phase, "category") + self.assertTrue(all(p.score == 0 for p in players)) + + def test_kick_chooser_picks_random_category(self): + a, b, c = self.join("A", "B", "C") + self.g.start(None, self.now) + self.g.kick(self.g.chooser_id, self.now) + self.assertEqual(self.g.phase, "lie") + self.assertEqual(len(self.g.players), 2) diff --git a/fupogfakta/views.py b/fupogfakta/views.py deleted file mode 100644 index b8e4ee0..0000000 --- a/fupogfakta/views.py +++ /dev/null @@ -1,2 +0,0 @@ - -# Create your views here. diff --git a/infra/env/.env.prod.example b/infra/env/.env.prod.example deleted file mode 100644 index cd1f4d6..0000000 --- a/infra/env/.env.prod.example +++ /dev/null @@ -1,12 +0,0 @@ -DJANGO_SECRET_KEY=change-me-prod -DJANGO_DEBUG=false -DJANGO_ALLOWED_HOSTS=party.weircon.dk -DB_ENGINE=django.db.backends.mysql -DB_NAME=wpp_prod -DB_USER=wpp_prod -DB_PASSWORD=change-me -DB_HOST=127.0.0.1 -DB_PORT=3306 -TEST_DB_NAME= -CHANNEL_REDIS_HOST=127.0.0.1 -CHANNEL_REDIS_PORT=6379 diff --git a/infra/env/.env.staging.example b/infra/env/.env.staging.example deleted file mode 100644 index 0f02781..0000000 --- a/infra/env/.env.staging.example +++ /dev/null @@ -1,12 +0,0 @@ -DJANGO_SECRET_KEY=change-me-staging -DJANGO_DEBUG=false -DJANGO_ALLOWED_HOSTS=staging.party.weircon.dk -DB_ENGINE=django.db.backends.mysql -DB_NAME=wpp_staging -DB_USER=wpp_staging -DB_PASSWORD=change-me -DB_HOST=127.0.0.1 -DB_PORT=3306 -TEST_DB_NAME= -CHANNEL_REDIS_HOST=127.0.0.1 -CHANNEL_REDIS_PORT=6379 diff --git a/infra/env/.env.test.example b/infra/env/.env.test.example deleted file mode 100644 index 5a15b3b..0000000 --- a/infra/env/.env.test.example +++ /dev/null @@ -1,12 +0,0 @@ -DJANGO_SECRET_KEY=change-me-test -DJANGO_DEBUG=true -DJANGO_ALLOWED_HOSTS=* -DB_ENGINE=django.db.backends.mysql -DB_NAME=wpp_test -DB_USER=wpp_test -DB_PASSWORD=change-me -DB_HOST=127.0.0.1 -DB_PORT=3306 -TEST_DB_NAME=wpp_test -CHANNEL_REDIS_HOST=127.0.0.1 -CHANNEL_REDIS_PORT=6379 diff --git a/infra/staging/DB_SETUP.md b/infra/staging/DB_SETUP.md deleted file mode 100644 index 29e0e36..0000000 --- a/infra/staging/DB_SETUP.md +++ /dev/null @@ -1,35 +0,0 @@ -# DB setup runbook (Issue #21) - -> Credentials ligger i Secrets-repo, ikke i applikationsrepo. - -## Databaser -- `wpp_test` -- `wpp_staging` -- `wpp_prod` - -## Brugere -- `wpp_test_user` (least privilege på `wpp_test`) -- `wpp_staging_user` (least privilege på `wpp_staging`) -- `wpp_prod_user` (least privilege på `wpp_prod`) - -## Secrets placering -I Secrets-repo: -- `wpp/wpp_test.env` -- `wpp/wpp_staging.env` -- `wpp/wpp_prod.env` - -Forventede felter: -- `DB_HOST` -- `DB_PORT` -- `DB_NAME` -- `DB_USER` -- `DB_PASSWORD` - -## Verifikation (eksempel) -Kør fra staging-CT eller anden tilladt klient: - -```bash -mysql -h -u -p -e "SELECT 1" -``` - -Alle forbindelser skal returnere `1`. diff --git a/infra/staging/README.md b/infra/staging/README.md deleted file mode 100644 index d3b7c91..0000000 --- a/infra/staging/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# Staging runbook (Issue #20) - -## Mål -Staging-miljø for WPP i Proxmox LXC, så release-klar kode kan deployes og smoke-testes sikkert. - -## Miljø -- LXC: CT 143 (wpp-staging) -- App path: /opt/wpp-staging/app -- Service: wpp-staging.service -- Health endpoint: GET /healthz -- Database: MySQL (staging må ikke bruge SQLite, issue #133) - -## Verifikation -Kør fra devops-shell med Proxmox-adgang: - - ssh proxmox-lan "sudo -n pct status 143" - ssh proxmox-lan "sudo -n pct exec 143 -- systemctl is-active wpp-staging.service" - ssh proxmox-lan "sudo -n pct exec 143 -- curl -fsS http://127.0.0.1:8000/healthz" - -Forventet: -- CT er running -- service er active -- healthz returnerer JSON med ok=true - -Smoke-suite skriver nu et gameplay-artifact som JSON under `/opt/wpp-staging/app/artifacts/smoke/` (kan overrides via `ARTIFACT_DIR`/`ARTIFACT_FILE`). - -Efter deploy validerer scriptet, at `DB_ENGINE` ikke er `django.db.backends.sqlite3` før migrations køres. - -Deploy-scriptet bruger en release-candidate mappe og promoverer først til `/opt/wpp-staging/app` efter succesfuld `migrate`. Det reducerer schema/code drift ved afbrudte deploys (issue #130) og understøtter release-readiness gate (issue #90). - -## Deploy (canonical execution context) -Deploy skal altid køres via Proxmox host over SSH (ikke fra lokal coder-shell med direkte sudo pct). - -Officiel kommando: - - ./infra/staging/deploy_staging.sh [ref] - -Scriptet bruger default PROXMOX_HOST=proxmox-lan og kører sudo -n pct exec på hosten. - -Eksempler: - - ./infra/staging/deploy_staging.sh - ./infra/staging/deploy_staging.sh v0.3.0 - PROXMOX_HOST=proxmox-prod ./infra/staging/deploy_staging.sh main - -## Policy-kobling -Før deploy: -1. Bekræft at tester ikke er aktiv (ingen aktiv smoke-run). -2. Deploy til staging skal lykkes. -3. Først derefter må release-tag oprettes (se docs/RELEASE_POLICY.md). diff --git a/infra/staging/deploy_staging.sh b/infra/staging/deploy_staging.sh deleted file mode 100755 index 5bbe01a..0000000 --- a/infra/staging/deploy_staging.sh +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -CT_ID="${CT_ID:-143}" -REF_NAME="${1:-main}" -ARCHIVE_URL="https://gitea.weircon.dk/wpp/weirsoe-party-protocol/archive/${REF_NAME}.tar.gz" -PROXMOX_HOST="${PROXMOX_HOST:-proxmox-lan}" - -echo "[deploy] host=${PROXMOX_HOST} CT_ID=${CT_ID} REF=${REF_NAME}" - -echo "[deploy] extracting source + installing deps + migrate + restart" -ssh "${PROXMOX_HOST}" sudo -n /usr/sbin/pct exec "${CT_ID}" -- bash -s -- "${ARCHIVE_URL}" <<'REMOTE' -set -euo pipefail - -ARCHIVE_URL="$1" -RELEASES_DIR=/opt/wpp-staging/releases -CANDIDATE_DIR="${RELEASES_DIR}/src" -APP_DIR=/opt/wpp-staging/app - -install -d -m 0755 -o wpp -g wpp "${RELEASES_DIR}" "${APP_DIR}" -mkdir -p "${CANDIDATE_DIR}" -cd "${RELEASES_DIR}" -curl -fsSL "${ARCHIVE_URL}" -o app.tar.gz -rm -rf "${CANDIDATE_DIR}" && mkdir -p "${CANDIDATE_DIR}" -tar -xzf app.tar.gz -C "${CANDIDATE_DIR}" --strip-components=1 - -# Ensure deploy artifact copied as root does not leave candidate tree non-writable for wpp. -chown -R wpp:wpp "${CANDIDATE_DIR}" -# Staging must not run on SQLite (issue #133). Remove bundled sqlite artifact from candidate. -rm -f "${CANDIDATE_DIR}/db.sqlite3" -cd "${CANDIDATE_DIR}" - -# Load staging env before any manage.py call (issue #133 follow-up). -ENV_LOADED=0 -for ENV_FILE in \ - /opt/wpp-staging/.env.staging \ - /opt/wpp-staging/.env \ - /opt/wpp-staging/env/wpp_staging.env \ - /opt/wpp-staging/secrets/wpp_staging.env - do - if [ -f "${ENV_FILE}" ]; then - set -a - . "${ENV_FILE}" - set +a - echo "[deploy] loaded staging env: ${ENV_FILE}" - ENV_LOADED=1 - break - fi -done -if [ "${ENV_LOADED}" -ne 1 ]; then - echo "[deploy] ERROR: no staging env file found" - exit 1 -fi - -runuser -u wpp -- python3 -m venv .venv -runuser -u wpp -- .venv/bin/pip install -U pip >/dev/null -runuser -u wpp -- .venv/bin/pip install -r requirements.txt >/dev/null -runuser -u wpp -- .venv/bin/python manage.py shell -c "from django.conf import settings; import sys; engine = settings.DATABASES['default']['ENGINE']; print(f'DB_ENGINE={engine}'); sys.exit(0 if engine != 'django.db.backends.sqlite3' else 1)" -runuser -u wpp -- .venv/bin/python manage.py migrate --noinput - -# Promote candidate only after migrations succeed (issue #130): avoid code/schema drift after failed deploy. -# Use find instead of rm "${APP_DIR}"/* to reliably remove dotfiles between deploys. -find "${APP_DIR}" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + -cp -a "${CANDIDATE_DIR}"/. "${APP_DIR}/" -chown -R wpp:wpp "${APP_DIR}" -runuser -u wpp -- test -w "${APP_DIR}" - -systemctl restart wpp-staging.service - -HEALTH_URL="http://127.0.0.1:8000/healthz" -MAX_ATTEMPTS=7 -SLEEP_SECONDS=1 -ATTEMPT=1 - -until curl -fsS "${HEALTH_URL}" >/dev/null; do - if [ "${ATTEMPT}" -ge "${MAX_ATTEMPTS}" ]; then - echo "[deploy] ERROR: health check failed after ${MAX_ATTEMPTS} attempts: ${HEALTH_URL}" >&2 - echo "[deploy] service status (wpp-staging.service):" >&2 - systemctl --no-pager --full status wpp-staging.service >&2 || true - echo "[deploy] recent service logs (wpp-staging.service):" >&2 - journalctl --no-pager -u wpp-staging.service -n 80 >&2 || true - exit 1 - fi - - echo "[deploy] health check not ready (attempt ${ATTEMPT}/${MAX_ATTEMPTS}); retrying in ${SLEEP_SECONDS}s" - sleep "${SLEEP_SECONDS}" - ATTEMPT=$((ATTEMPT + 1)) - if [ "${SLEEP_SECONDS}" -lt 8 ]; then - SLEEP_SECONDS=$((SLEEP_SECONDS * 2)) - fi -done - -echo "[deploy] health check passed: ${HEALTH_URL}" -REMOTE - -echo "[deploy] OK: staging deploy complete for CT ${CT_ID} (${REF_NAME})" diff --git a/infra/staging/smoke_suite.sh b/infra/staging/smoke_suite.sh deleted file mode 100755 index 34bd546..0000000 --- a/infra/staging/smoke_suite.sh +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -BASE_URL="${BASE_URL:-http://127.0.0.1:8000}" -APP_DIR="${APP_DIR:-/opt/wpp-staging/app}" -ISSUE_ON_FAIL="${ISSUE_ON_FAIL:-1}" - -fail() { - local message="$1" - echo "[smoke] FAIL: ${message}" >&2 - - if [[ "${ISSUE_ON_FAIL}" == "1" ]] && [[ -n "${GITEA_BASE:-}" ]] && [[ -n "${GITEA_REPO:-}" ]] && [[ -n "${GITEA_USER:-}" ]] && [[ -n "${GITEA_TOKEN:-}" ]]; then - python3 - </dev/null || { SMOKE_FAIL_MESSAGE="healthz check failed" fail "healthz check failed"; } - -ENV_FILE="${ENV_FILE:-/etc/wpp/staging.env}" - -run_manage() { - local cmd="$1" - ( - cd "${APP_DIR}" - if [[ -f "${ENV_FILE}" ]]; then - set -a - # shellcheck disable=SC1090 - source "${ENV_FILE}" - set +a - fi - .venv/bin/python manage.py ${cmd} - ) -} - -echo "[smoke] migration consistency check" -run_manage "migrate --check --noinput" || { SMOKE_FAIL_MESSAGE="schema drift: unapplied migrations in staging" fail "schema drift: unapplied migrations in staging"; } - -ARTIFACT_DIR="${ARTIFACT_DIR:-${APP_DIR}/artifacts/smoke}" -ARTIFACT_FILE="${ARTIFACT_FILE:-${ARTIFACT_DIR}/smoke-$(date -u +%Y%m%dT%H%M%SZ).json}" - -echo "[smoke] gameplay flow via management command" -run_manage "smoke_staging --artifact ${ARTIFACT_FILE}" || { SMOKE_FAIL_MESSAGE="manage.py smoke_staging failed" fail "manage.py smoke_staging failed"; } - -echo "[smoke] artifact: ${ARTIFACT_FILE}" -echo "[smoke] OK" diff --git a/lobby/admin.py b/lobby/admin.py deleted file mode 100644 index b97a94f..0000000 --- a/lobby/admin.py +++ /dev/null @@ -1,2 +0,0 @@ - -# Register your models here. diff --git a/lobby/apps.py b/lobby/apps.py deleted file mode 100644 index 5688267..0000000 --- a/lobby/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class LobbyConfig(AppConfig): - name = 'lobby' diff --git a/lobby/feature_flags.py b/lobby/feature_flags.py deleted file mode 100644 index 83ae495..0000000 --- a/lobby/feature_flags.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.conf import settings - - -def use_spa_ui() -> bool: - """Central read-point for SPA cutover flag.""" - return bool(getattr(settings, "USE_SPA_UI", False)) diff --git a/lobby/i18n.py b/lobby/i18n.py deleted file mode 100644 index a40d8dd..0000000 --- a/lobby/i18n.py +++ /dev/null @@ -1,100 +0,0 @@ -import logging - -from django.http import HttpRequest, JsonResponse -from django.utils.translation import get_language_from_request - -from partyhub.i18n_bootstrap import locale_config, shared_i18n_catalog - -LOGGER = logging.getLogger(__name__) - - -def lobby_i18n_catalog() -> dict: - return shared_i18n_catalog() - - -def i18n_locale_config() -> tuple[str, tuple[str, ...]]: - return locale_config() - - -def lobby_i18n_errors() -> dict: - return shared_i18n_catalog().get("backend", {}).get("error_codes", {}) - - -def lobby_i18n_error_messages() -> dict: - return shared_i18n_catalog().get("backend", {}).get("errors", {}) - - -def resolve_error_key(code: str) -> str: - resolved = lobby_i18n_errors().get(code) - if isinstance(resolved, str) and resolved: - return resolved - - LOGGER.warning("i18n error code missing in shared catalog", extra={"code": code}) - return code - - -def _quality_value(language_candidate: str) -> float | None: - for parameter in language_candidate.split(";")[1:]: - key, separator, value = parameter.partition("=") - if separator and key.strip().lower() == "q": - try: - return float(value.strip()) - except ValueError: - return None - return None - - -def resolve_locale(request: HttpRequest) -> str: - default_locale, supported_locales = i18n_locale_config() - - accept_language = request.META.get("HTTP_ACCEPT_LANGUAGE") or "" - ranked_candidates: list[tuple[float, int, str]] = [] - for index, candidate in enumerate(accept_language.split(",")): - quality = _quality_value(candidate) - if quality is not None and quality <= 0: - continue - - tag = candidate.split(";", 1)[0] - normalized = tag.strip().replace("_", "-").split("-", 1)[0].lower() - if normalized not in supported_locales: - continue - - ranked_candidates.append((quality if quality is not None else 1.0, index, normalized)) - - if ranked_candidates: - ranked_candidates.sort(key=lambda entry: (-entry[0], entry[1])) - return ranked_candidates[0][2] - - requested = (get_language_from_request(request) or "").replace("_", "-").split("-", 1)[0].lower() - if requested in supported_locales: - return requested - return default_locale - - -def resolve_error_message(*, key: str, locale: str) -> str: - default_locale, _supported_locales = i18n_locale_config() - translations = lobby_i18n_error_messages().get(key) - if not isinstance(translations, dict): - LOGGER.warning("i18n key missing in shared catalog", extra={"key": key, "locale": locale}) - return key - - if locale in translations and translations[locale]: - return translations[locale] - if default_locale in translations and translations[default_locale]: - return translations[default_locale] - - LOGGER.warning("i18n translation missing for key", extra={"key": key, "locale": locale}) - return key - - -def api_error(request: HttpRequest, *, code: str, status: int) -> JsonResponse: - locale = resolve_locale(request) - key = resolve_error_key(code) - return JsonResponse( - { - "error": resolve_error_message(key=key, locale=locale), - "error_code": code, - "locale": locale, - }, - status=status, - ) diff --git a/lobby/management/__init__.py b/lobby/management/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/lobby/management/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/lobby/management/commands/__init__.py b/lobby/management/commands/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/lobby/management/commands/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/lobby/management/commands/smoke_staging.py b/lobby/management/commands/smoke_staging.py deleted file mode 100644 index 8de8782..0000000 --- a/lobby/management/commands/smoke_staging.py +++ /dev/null @@ -1,320 +0,0 @@ -import json -from datetime import datetime, timezone -from pathlib import Path - -from django.contrib.auth import get_user_model -from django.core.management.base import BaseCommand, CommandError -from django.test import Client - -from fupogfakta.models import Category, GameSession, Player, Question, RoundQuestion - - -class Command(BaseCommand): - help = "Run canonical gameplay smoke/regression flow for bluff -> guess -> reveal -> scoreboard" - - def add_arguments(self, parser): - parser.add_argument( - "--artifact", - help="Optional path to write smoke result artifact as JSON", - ) - - def _fail(self, step: str, detail: str, payload=None): - message = f"{step} failed: {detail}" - if payload is not None: - message += f" | payload={json.dumps(payload, sort_keys=True)}" - raise CommandError(message) - - def _expect_status(self, response, expected_status: int, step: str): - if response.status_code != expected_status: - try: - payload = response.json() - except ValueError: - payload = {"raw": response.content.decode("utf-8", errors="replace")} - self._fail(step, f"expected HTTP {expected_status}, got {response.status_code}", payload) - return response.json() - - def _expect_session_status(self, payload: dict, expected_status: str, step: str): - actual_status = payload.get("session", {}).get("status") - if actual_status != expected_status: - self._fail(step, f"expected session.status={expected_status}, got {actual_status}", payload) - - def handle(self, *args, **options): - GameSession.objects.all().delete() - Player.objects.all().delete() - RoundQuestion.objects.all().delete() - - category, _ = Category.objects.get_or_create( - slug="smoke", - defaults={"name": "Smoke", "is_active": True}, - ) - category.is_active = True - category.save(update_fields=["is_active"]) - - question, _ = Question.objects.get_or_create( - category=category, - prompt="Smoke prompt?", - defaults={"correct_answer": "Correct", "is_active": True}, - ) - if not question.is_active: - question.is_active = True - question.save(update_fields=["is_active"]) - - User = get_user_model() - host, _ = User.objects.get_or_create(username="smoke-host") - host.set_password("smoke-pass") - host.is_staff = True - host.save() - - artifact = { - "ok": True, - "command": "python manage.py smoke_staging --artifact ", - "generated_at": datetime.now(timezone.utc).isoformat(), - "question": { - "prompt": question.prompt, - "correct_answer": question.correct_answer, - }, - "steps": [], - } - - host_client = Client() - host_client.force_login(host) - - create_payload = self._expect_status( - host_client.post("/lobby/sessions/create", content_type="application/json"), - 201, - "create_session", - ) - code = create_payload["session"]["code"] - artifact["session_code"] = code - artifact["steps"].append( - { - "step": "create_session", - "session_status": create_payload["session"]["status"], - } - ) - - players = [] - for nickname in ["P1", "P2", "P3"]: - join_payload = self._expect_status( - Client().post( - "/lobby/sessions/join", - data=json.dumps({"code": code, "nickname": nickname}), - content_type="application/json", - ), - 201, - f"join_session[{nickname}]", - ) - players.append(join_payload["player"]) - artifact["players"] = [player["nickname"] for player in players] - artifact["steps"].append( - { - "step": "join_players", - "players_count": len(players), - } - ) - - start_payload = self._expect_status( - host_client.post( - f"/lobby/sessions/{code}/rounds/start", - data=json.dumps({"category_slug": category.slug}), - content_type="application/json", - ), - 201, - "start_round", - ) - self._expect_session_status(start_payload, GameSession.Status.LIE, "start_round") - - round_question_id = start_payload["round_question"]["id"] - artifact["round_question_id"] = round_question_id - artifact["steps"].append( - { - "step": "start_round", - "session_status": start_payload["session"]["status"], - "round_question_id": round_question_id, - } - ) - - answers = [] - lie_transition_payload = None - for player in players: - nickname = player["nickname"] - lie_payload = self._expect_status( - Client().post( - f"/lobby/sessions/{code}/questions/{round_question_id}/lies/submit", - data=json.dumps( - { - "player_id": player["id"], - "session_token": player["session_token"], - "text": f"Lie from {nickname}", - } - ), - content_type="application/json", - ), - 201, - f"submit_lie[{nickname}]", - ) - if lie_payload.get("answers"): - answers = lie_payload["answers"] - lie_transition_payload = lie_payload - - if not answers: - detail_payload = self._expect_status(host_client.get(f"/lobby/sessions/{code}"), 200, "session_detail_after_lies") - answers = detail_payload.get("round_question", {}).get("answers", []) - self._expect_session_status(detail_payload, GameSession.Status.GUESS, "session_detail_after_lies") - lie_transition_payload = detail_payload - - if not answers: - self._fail("auto_guess_transition", "canonical lie->guess transition returned empty answers") - - if not any(answer.get("text") == question.correct_answer for answer in answers): - self._fail("auto_guess_transition", "mixed answers missing correct answer", {"answers": answers}) - if len(answers) < len(players) + 1: - self._fail( - "auto_guess_transition", - "mixed answers shorter than expected bluff set", - {"answers": answers, "players_count": len(players)}, - ) - - self._expect_session_status(lie_transition_payload, GameSession.Status.GUESS, "auto_guess_transition") - artifact["steps"].append( - { - "step": "auto_guess_transition", - "session_status": lie_transition_payload["session"]["status"], - "answers": [answer["text"] for answer in answers], - } - ) - - answer_texts = {answer["text"] for answer in answers} - correct_answer = next((answer["text"] for answer in answers if answer.get("text") == question.correct_answer), None) - if correct_answer is None: - self._fail("submit_guesses", "could not resolve correct answer from mixed answers", {"answers": answers}) - - guess_plan = { - players[0]["nickname"]: "Lie from P2", - players[1]["nickname"]: correct_answer, - players[2]["nickname"]: "Lie from P1", - } - missing_guess_targets = {text for text in guess_plan.values() if text not in answer_texts} - if missing_guess_targets: - self._fail( - "submit_guesses", - "expected bluff targets missing from mixed answers", - {"answers": answers, "missing_guess_targets": sorted(missing_guess_targets)}, - ) - artifact["guess_plan"] = guess_plan - - guess_payloads = [] - for player in players: - nickname = player["nickname"] - guess_payload = self._expect_status( - Client().post( - f"/lobby/sessions/{code}/questions/{round_question_id}/guesses/submit", - data=json.dumps( - { - "player_id": player["id"], - "session_token": player["session_token"], - "selected_text": guess_plan[nickname], - } - ), - content_type="application/json", - ), - 201, - f"submit_guess[{nickname}]", - ) - guess_payloads.append(guess_payload) - - reveal_payload = guess_payloads[-1] - self._expect_session_status(reveal_payload, GameSession.Status.REVEAL, "auto_reveal_transition") - if not reveal_payload.get("phase_transition", {}).get("auto_advanced"): - self._fail("auto_reveal_transition", "expected auto_advanced=true on final guess", reveal_payload) - reveal = reveal_payload.get("reveal") - if not reveal: - self._fail("auto_reveal_transition", "missing canonical reveal payload", reveal_payload) - if reveal.get("correct_answer") != question.correct_answer: - self._fail( - "auto_reveal_transition", - "reveal payload returned wrong correct answer", - {"expected": question.correct_answer, "reveal": reveal}, - ) - if len(reveal.get("lies", [])) != len(players): - self._fail("auto_reveal_transition", "unexpected lie count in reveal payload", reveal) - if len(reveal.get("guesses", [])) != len(players): - self._fail("auto_reveal_transition", "unexpected guess count in reveal payload", reveal) - - fooled_guesses = [guess for guess in reveal["guesses"] if not guess.get("is_correct")] - correct_guesses = [guess for guess in reveal["guesses"] if guess.get("is_correct")] - if len(fooled_guesses) != 2: - self._fail("auto_reveal_transition", "expected exactly two bluff guesses", reveal) - if len(correct_guesses) != 1: - self._fail("auto_reveal_transition", "expected exactly one correct guess", reveal) - if any(guess.get("fooled_player_id") is None for guess in fooled_guesses): - self._fail("auto_reveal_transition", "bluff guesses missing fooled_player_id", reveal) - - artifact["steps"].append( - { - "step": "submit_guesses", - "guess_results": [ - { - "player_id": payload["guess"]["player_id"], - "selected_text": payload["guess"]["selected_text"], - "is_correct": payload["guess"]["is_correct"], - "fooled_player_id": payload["guess"].get("fooled_player_id"), - } - for payload in guess_payloads - ], - } - ) - artifact["steps"].append( - { - "step": "auto_reveal_transition", - "session_status": reveal_payload["session"]["status"], - "reveal": { - "correct_answer": reveal["correct_answer"], - "lies_count": len(reveal["lies"]), - "guesses_count": len(reveal["guesses"]), - "fooled_player_ids": sorted(guess["fooled_player_id"] for guess in fooled_guesses), - "correct_guess_player_ids": sorted(guess["player_id"] for guess in correct_guesses), - }, - } - ) - - detail_payload = self._expect_status(host_client.get(f"/lobby/sessions/{code}"), 200, "session_detail_after_guesses") - self._expect_session_status(detail_payload, GameSession.Status.SCOREBOARD, "auto_scoreboard_transition") - if detail_payload.get("reveal") != reveal: - self._fail("auto_scoreboard_transition", "scoreboard promotion changed canonical reveal payload", detail_payload) - scoreboard = detail_payload.get("scoreboard") - if not scoreboard: - self._fail("auto_scoreboard_transition", "missing scoreboard payload after promotion", detail_payload) - if len(scoreboard) != len(players): - self._fail("auto_scoreboard_transition", "unexpected scoreboard length", detail_payload) - if not detail_payload.get("phase_view_model", {}).get("readiness", {}).get("scoreboard_ready"): - self._fail("auto_scoreboard_transition", "scoreboard_ready=false after promotion", detail_payload) - - artifact["steps"].append( - { - "step": "auto_scoreboard_transition", - "session_status": detail_payload["session"]["status"], - "leaderboard": scoreboard, - } - ) - - finish_payload = self._expect_status( - host_client.post(f"/lobby/sessions/{code}/finish", content_type="application/json"), - 200, - "finish_game", - ) - self._expect_session_status(finish_payload, GameSession.Status.FINISHED, "finish_game") - artifact["steps"].append( - { - "step": "finish_game", - "session_status": finish_payload["session"]["status"], - } - ) - - artifact_path = options.get("artifact") - if artifact_path: - output_path = Path(artifact_path) - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(json.dumps(artifact, indent=2) + "\n", encoding="utf-8") - - self.stdout.write(self.style.SUCCESS(f"Smoke flow OK for session {code}")) diff --git a/lobby/models.py b/lobby/models.py deleted file mode 100644 index 35e0d64..0000000 --- a/lobby/models.py +++ /dev/null @@ -1,2 +0,0 @@ - -# Create your models here. diff --git a/lobby/templates/lobby/host_screen.html b/lobby/templates/lobby/host_screen.html deleted file mode 100644 index 6ffdee5..0000000 --- a/lobby/templates/lobby/host_screen.html +++ /dev/null @@ -1,124 +0,0 @@ - -WPP Host - - - -

Host panel (MVP)

-

Kræver login som host-bruger.

- -

Opret session er klar.

- - - -

Kræver 3-5 spillere i lobbyen.

-

Spillere i session: ukendt

-

Kategori er kun redigérbar i lobby-fasen.

- -

Fase: ukendt (opdatér session-status).

-

Aktiv round question: afventer session-status.

-

Round question-id styres server-side i canonical flow og er kun read-only kontekst for host.

- - -

Angiv sessionkode for at aktivere host-actions.

-

Ingen fejl.

- -

Session-opdatering klar.

- -

Auto-refresh er slået fra.

-

Session-data ikke opdateret endnu.

- -
-

Indlæser host-overblik…

-
-
-
-
- -
Ready.
-{{ lobby_i18n|json_script:"wppHostI18n" }} - - diff --git a/lobby/templates/lobby/player_screen.html b/lobby/templates/lobby/player_screen.html deleted file mode 100644 index 751ae72..0000000 --- a/lobby/templates/lobby/player_screen.html +++ /dev/null @@ -1,174 +0,0 @@ - -WPP Player - - - -

Player panel (MVP)

- - - -

Klar til join.

-

Kontekst er ikke låst endnu.

- - - - - -

Skriv din løgn.

- -
-

Vælg et svar.

- - -

Session-opdatering klar.

-

Runde-kontekst afventer session-opdatering.

- -

Auto-refresh er slået fra.

-

Session-data ikke opdateret endnu.

-

Fase: ukendt (opdatér session-status).

-

Ingen fejl.

-
Forbindelsen til serveren blev afbrudt.
-
Der opstod en kritisk fejl i app-skallen. - - -
-
-

Indlæser spiller-overblik…

-
-
-
-
- -
Ready.
-{{ lobby_i18n|json_script:"wppPlayerI18n" }} - - diff --git a/lobby/templates/lobby/spa_shell.html b/lobby/templates/lobby/spa_shell.html deleted file mode 100644 index fb51798..0000000 --- a/lobby/templates/lobby/spa_shell.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - WPP SPA Shell - - - - Indlæser Angular app-shell… - - - diff --git a/lobby/tests.py b/lobby/tests.py deleted file mode 100644 index 6e5140b..0000000 --- a/lobby/tests.py +++ /dev/null @@ -1,2739 +0,0 @@ -import inspect -import json -import tempfile -from datetime import timedelta -from pathlib import Path -from unittest.mock import patch - -from django.contrib.auth import get_user_model -from django.core.management import call_command -from django.test import TestCase, override_settings -from django.urls import reverse -from django.utils import timezone - -from fupogfakta import payloads as gameplay_payloads, services as gameplay_services -from fupogfakta.models import ( - Category, - GameSession, - Guess, - LieAnswer, - Player, - Question, - RoundConfig, - RoundQuestion, - ScoreEvent, -) -from lobby import views as lobby_views -from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message, resolve_locale - -User = get_user_model() - - -class LobbyGameplayExtractionTests(TestCase): - def setUp(self): - self.host = User.objects.create_user(username="extract_host", password="secret123") - self.client.login(username="extract_host", password="secret123") - self.session = GameSession.objects.create( - host=self.host, - code="EXTR42", - status=GameSession.Status.SCOREBOARD, - ) - self.category = Category.objects.create(name="Historie", slug="historie-extract", is_active=True) - self.round_config = RoundConfig.objects.create( - session=self.session, - number=1, - category=self.category, - ) - self.question = Question.objects.create( - category=self.category, - prompt="Hvornår faldt muren?", - correct_answer="1989", - is_active=True, - ) - - def test_lobby_views_use_extracted_gameplay_helpers(self): - self.assertIs(lobby_views._get_current_round_question, gameplay_services.get_current_round_question) - self.assertIs(lobby_views._select_round_question, gameplay_services.select_round_question) - self.assertIs(lobby_views._prepare_mixed_answers, gameplay_services.prepare_mixed_answers) - self.assertIs(lobby_views._resolve_scores, gameplay_services.resolve_scores) - self.assertIs(lobby_views._promote_reveal_to_scoreboard, gameplay_services.promote_reveal_to_scoreboard) - self.assertIs(lobby_views._start_round, gameplay_services.start_round) - self.assertIs(lobby_views._show_question, gameplay_services.show_question) - self.assertIs(lobby_views._start_next_round, gameplay_services.start_next_round) - self.assertIs(lobby_views._finish_game, gameplay_services.finish_game) - self.assertIs(lobby_views._build_session_detail_gameplay_payload, gameplay_payloads.build_session_detail_gameplay_payload) - self.assertIs(lobby_views._build_scoreboard_phase_event, gameplay_payloads.build_scoreboard_phase_event) - - def test_start_round_view_source_stays_http_thin(self): - source = inspect.getsource(inspect.unwrap(lobby_views.start_round)) - - self.assertIn("transition = _start_round(session, category_slug)", source) - self.assertNotIn("RoundConfig", source) - self.assertNotIn("RoundQuestion", source) - self.assertNotIn("build_start_round_response", source) - - def test_show_question_view_source_stays_http_thin(self): - source = inspect.getsource(inspect.unwrap(lobby_views.show_question)) - - self.assertIn("transition = _show_question(session)", source) - self.assertNotIn("RoundConfig", source) - self.assertNotIn("RoundQuestion", source) - self.assertNotIn("build_question_shown_response", source) - - def test_start_next_round_view_source_stays_http_thin(self): - source = inspect.getsource(inspect.unwrap(lobby_views.start_next_round)) - - self.assertIn("transition = _start_next_round(session)", source) - self.assertNotIn("RoundConfig", source) - self.assertNotIn("RoundQuestion", source) - self.assertNotIn("build_start_next_round_response", source) - self.assertNotIn("build_start_next_round_phase_event", source) - - def test_finish_game_view_source_stays_http_thin(self): - source = inspect.getsource(inspect.unwrap(lobby_views.finish_game)) - - self.assertIn("transition = _finish_game(session)", source) - self.assertNotIn("RoundConfig", source) - self.assertNotIn("RoundQuestion", source) - self.assertNotIn("build_finish_game_response", source) - self.assertNotIn("build_finish_game_phase_event", source) - - def test_reveal_scoreboard_view_source_stays_http_thin(self): - source = inspect.getsource(inspect.unwrap(lobby_views.reveal_scoreboard)) - - self.assertIn("transition = _promote_reveal_to_scoreboard(session)", source) - self.assertNotIn("Player.objects.filter(session=session)", source) - self.assertNotIn("ScoreEvent.objects.filter", source) - self.assertNotIn("build_reveal_scoreboard_response", source) - self.assertNotIn("build_scoreboard_phase_event", source) - - def test_issue_310_transition_views_keep_gameplay_logic_out_of_lobby(self): - transition_sources = { - "reveal_scoreboard": inspect.getsource(inspect.unwrap(lobby_views.reveal_scoreboard)), - "start_next_round": inspect.getsource(inspect.unwrap(lobby_views.start_next_round)), - "finish_game": inspect.getsource(inspect.unwrap(lobby_views.finish_game)), - } - - forbidden_snippets = ( - "RoundConfig.objects.get_or_create(", - "RoundConfig.objects.create(", - "RoundQuestion.objects.create(", - "select_round_question(", - "reset_round_question_bootstrap_state(", - "session.current_round =", - "session.status = GameSession.Status.LIE", - "session.status = GameSession.Status.SCOREBOARD", - "session.status = GameSession.Status.FINISHED", - "build_start_next_round_response(", - "build_start_next_round_phase_event(", - "build_finish_game_response(", - "build_finish_game_phase_event(", - "build_reveal_scoreboard_response(", - "build_scoreboard_phase_event(", - "ScoreEvent.objects.filter(", - "Player.objects.filter(", - ) - - for view_name, source in transition_sources.items(): - for snippet in forbidden_snippets: - self.assertNotIn(snippet, source, msg=f"{view_name} leaked gameplay snippet: {snippet}") - - def test_session_detail_view_source_stays_http_thin(self): - source = inspect.getsource(inspect.unwrap(lobby_views.session_detail)) - - self.assertIn("session = _maybe_promote_reveal_to_scoreboard(session)", source) - self.assertIn("current_round_question = _get_current_round_question(session)", source) - self.assertIn("gameplay_payload = _build_session_detail_gameplay_payload(", source) - self.assertIn("**gameplay_payload", source) - self.assertNotIn("build_round_question_payload", source) - self.assertNotIn("build_phase_view_model", source) - self.assertNotIn("build_reveal_payload", source) - self.assertNotIn("build_scoreboard_phase_event(session)[\"payload\"][\"leaderboard\"]", source) - self.assertNotIn("lies.select_related", source) - self.assertNotIn("guesses.select_related", source) - self.assertNotIn("Player.objects.filter(session=session)", source) - self.assertNotIn("leaderboard =", source) - - - @patch("lobby.views.sync_broadcast_phase_event") - @patch("lobby.views._start_round") - def test_start_round_view_delegates_transition_to_service( - self, - mock_start_round, - mock_sync_broadcast_phase_event, - ): - lobby_session = GameSession.objects.create(host=self.host, code="LOBBY1", status=GameSession.Status.LOBBY) - transition = gameplay_services.RoundTransitionResult( - session=lobby_session, - round_config=self.round_config, - round_question=RoundQuestion.objects.create( - session=lobby_session, - round_number=1, - question=self.question, - correct_answer=self.question.correct_answer, - ), - should_broadcast=True, - response_payload={"ok": True}, - phase_event_name="phase.lie_started", - phase_event_payload={"round_question_id": 123}, - ) - mock_start_round.return_value = transition - - response = self.client.post( - reverse("lobby:start_round", kwargs={"code": lobby_session.code}), - data=json.dumps({"category_slug": self.category.slug}), - content_type="application/json", - ) - - self.assertEqual(response.status_code, 201) - self.assertEqual(response.json(), {"ok": True}) - mock_start_round.assert_called_once_with(lobby_session, self.category.slug) - mock_sync_broadcast_phase_event.assert_called_once_with( - lobby_session.code, - "phase.lie_started", - {"round_question_id": 123}, - ) - - @patch("lobby.views.sync_broadcast_phase_event") - @patch("lobby.views._show_question") - def test_show_question_view_delegates_transition_to_service( - self, - mock_show_question, - mock_sync_broadcast_phase_event, - ): - lie_session = GameSession.objects.create(host=self.host, code="LIE123", status=GameSession.Status.LIE) - transition = gameplay_services.RoundTransitionResult( - session=lie_session, - round_config=self.round_config, - round_question=RoundQuestion.objects.create( - session=lie_session, - round_number=1, - question=self.question, - correct_answer=self.question.correct_answer, - ), - should_broadcast=True, - response_payload={"ok": True}, - phase_event_name="phase.question_shown", - phase_event_payload={"round_question_id": 456}, - ) - mock_show_question.return_value = transition - - response = self.client.post(reverse("lobby:show_question", kwargs={"code": lie_session.code})) - - self.assertEqual(response.status_code, 201) - self.assertEqual(response.json(), {"ok": True}) - mock_show_question.assert_called_once_with(lie_session) - mock_sync_broadcast_phase_event.assert_called_once_with( - lie_session.code, - "phase.question_shown", - {"round_question_id": 456}, - ) - - @patch("lobby.views.sync_broadcast_phase_event") - @patch("lobby.views._start_next_round") - def test_start_next_round_view_delegates_transition_to_service( - self, - mock_start_next_round, - mock_sync_broadcast_phase_event, - ): - next_round_config = RoundConfig.objects.create( - session=self.session, - number=2, - category=self.category, - started_from_scoreboard=True, - ) - round_question = RoundQuestion.objects.create( - session=self.session, - round_number=2, - question=self.question, - correct_answer=self.question.correct_answer, - ) - transition = gameplay_services.RoundTransitionResult( - session=self.session, - round_config=next_round_config, - round_question=round_question, - should_broadcast=True, - response_payload={"ok": True}, - phase_event_name="phase.lie_started", - phase_event_payload={"round_question_id": round_question.id}, - ) - mock_start_next_round.return_value = transition - - response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"ok": True}) - mock_start_next_round.assert_called_once_with(self.session) - mock_sync_broadcast_phase_event.assert_called_once_with( - self.session.code, - "phase.lie_started", - {"round_question_id": round_question.id}, - ) - - @patch("lobby.views.sync_broadcast_phase_event") - @patch("lobby.views._finish_game") - def test_finish_game_view_delegates_transition_to_service( - self, - mock_finish_game, - mock_sync_broadcast_phase_event, - ): - finished_session = GameSession.objects.get(pk=self.session.pk) - finished_session.status = GameSession.Status.FINISHED - transition = gameplay_services.FinishGameResult( - session=finished_session, - should_broadcast=True, - response_payload={"ok": True}, - phase_event_name="phase.game_over", - phase_event_payload={"winner": None, "leaderboard": []}, - ) - mock_finish_game.return_value = transition - - response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"ok": True}) - mock_finish_game.assert_called_once_with(self.session) - mock_sync_broadcast_phase_event.assert_called_once_with( - self.session.code, - "phase.game_over", - {"winner": None, "leaderboard": []}, - ) - - @patch("lobby.views.sync_broadcast_phase_event") - @patch("lobby.views._start_next_round") - def test_start_next_round_view_skips_broadcast_on_service_replay( - self, - mock_start_next_round, - mock_sync_broadcast_phase_event, - ): - replay_round_config = RoundConfig.objects.create( - session=self.session, - number=2, - category=self.category, - started_from_scoreboard=True, - ) - round_question = RoundQuestion.objects.create( - session=self.session, - round_number=2, - question=self.question, - correct_answer=self.question.correct_answer, - ) - replay_session = GameSession.objects.get(pk=self.session.pk) - replay_session.status = GameSession.Status.LIE - replay_session.current_round = 2 - transition = gameplay_services.RoundTransitionResult( - session=replay_session, - round_config=replay_round_config, - round_question=round_question, - should_broadcast=False, - response_payload={"ok": True}, - ) - mock_start_next_round.return_value = transition - - response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"ok": True}) - mock_start_next_round.assert_called_once_with(self.session) - mock_sync_broadcast_phase_event.assert_not_called() - - @patch("lobby.views.sync_broadcast_phase_event") - @patch("lobby.views._finish_game") - def test_finish_game_view_skips_broadcast_on_service_replay( - self, - mock_finish_game, - mock_sync_broadcast_phase_event, - ): - finished_session = GameSession.objects.get(pk=self.session.pk) - finished_session.status = GameSession.Status.FINISHED - transition = gameplay_services.FinishGameResult( - session=finished_session, - should_broadcast=False, - response_payload={"ok": True}, - ) - mock_finish_game.return_value = transition - - response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"ok": True}) - mock_finish_game.assert_called_once_with(self.session) - mock_sync_broadcast_phase_event.assert_not_called() - -class LobbyFlowTests(TestCase): - def setUp(self): - self.host = User.objects.create_user(username="host", password="secret123") - - def test_create_session_requires_login(self): - response = self.client.post(reverse("lobby:create_session")) - - self.assertEqual(response.status_code, 302) - self.assertEqual(GameSession.objects.count(), 0) - - def test_host_can_create_session(self): - self.client.login(username="host", password="secret123") - - response = self.client.post(reverse("lobby:create_session")) - - self.assertEqual(response.status_code, 201) - body = response.json() - self.assertEqual(body["session"]["status"], GameSession.Status.LOBBY) - self.assertEqual(len(body["session"]["code"]), 6) - - session = GameSession.objects.get(code=body["session"]["code"]) - self.assertEqual(session.host, self.host) - - def test_player_can_join_with_code(self): - session = GameSession.objects.create(host=self.host, code="ABCD23") - - response = self.client.post( - reverse("lobby:join_session"), - data={"code": "abcd23", "nickname": "Luna"}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 201) - body = response.json() - self.assertEqual(body["session"]["code"], "ABCD23") - self.assertEqual(body["player"]["nickname"], "Luna") - self.assertIn("session_token", body["player"]) - self.assertTrue(body["player"]["session_token"]) - self.assertTrue(Player.objects.filter(session=session, nickname="Luna").exists()) - - def test_player_can_join_with_trimmed_code(self): - session = GameSession.objects.create(host=self.host, code="ABCD23") - - response = self.client.post( - reverse("lobby:join_session"), - data={"code": " abcd23 ", "nickname": "Luna"}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 201) - self.assertTrue(Player.objects.filter(session=session, nickname="Luna").exists()) - - def test_join_rejects_code_empty_after_trim(self): - response = self.client.post( - reverse("lobby:join_session"), - data={"code": " ", "nickname": "Luna"}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "Session code is required") - - def test_join_rejects_duplicate_nickname_case_insensitive(self): - session = GameSession.objects.create(host=self.host, code="QWER12") - Player.objects.create(session=session, nickname="Luna") - - response = self.client.post( - reverse("lobby:join_session"), - data={"code": "QWER12", "nickname": "lUna"}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 409) - self.assertEqual(response.json()["error"], "Nickname already taken") - - def test_player_can_join_during_scoreboard_phase(self): - session = GameSession.objects.create(host=self.host, code="ZXCV97", status=GameSession.Status.SCOREBOARD) - - response = self.client.post( - reverse("lobby:join_session"), - data={"code": "ZXCV97", "nickname": "Kai"}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 201) - body = response.json() - self.assertEqual(body["session"]["status"], GameSession.Status.SCOREBOARD) - self.assertEqual(body["player"]["nickname"], "Kai") - self.assertTrue(Player.objects.filter(session=session, nickname="Kai").exists()) - - def test_join_rejects_non_joinable_session(self): - GameSession.objects.create(host=self.host, code="ZXCV98", status=GameSession.Status.FINISHED) - - response = self.client.post( - reverse("lobby:join_session"), - data={"code": "ZXCV98", "nickname": "Kai"}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "Session is not joinable") - - def test_join_error_localizes_to_danish_with_accept_language_header(self): - response = self.client.post( - reverse("lobby:join_session"), - data={"code": " ", "nickname": "Luna"}, - content_type="application/json", - HTTP_ACCEPT_LANGUAGE="da", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error_code"], "session_code_required") - self.assertEqual(response.json()["locale"], "da") - self.assertEqual(response.json()["error"], "Sessionskode er påkrævet") - - def test_join_error_falls_back_to_english_for_unsupported_locale(self): - response = self.client.post( - reverse("lobby:join_session"), - data={"code": " ", "nickname": "Luna"}, - content_type="application/json", - HTTP_ACCEPT_LANGUAGE="fr", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error_code"], "session_code_required") - self.assertEqual(response.json()["locale"], "en") - self.assertEqual(response.json()["error"], "Session code is required") - - def test_join_error_payload_uses_stable_i18n_contract_keys(self): - response = self.client.post( - reverse("lobby:join_session"), - data={"code": " ", "nickname": "Luna"}, - content_type="application/json", - HTTP_ACCEPT_LANGUAGE="da", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(sorted(response.json().keys()), ["error", "error_code", "locale"]) - - def test_session_detail_returns_players(self): - session = GameSession.objects.create(host=self.host, code="LMNO45") - Player.objects.create(session=session, nickname="Mia", score=7) - Player.objects.create(session=session, nickname="Bo", score=2) - - response = self.client.get(reverse("lobby:session_detail", kwargs={"code": "lmno45"})) - - self.assertEqual(response.status_code, 200) - payload = response.json() - self.assertEqual(payload["session"]["players_count"], 2) - self.assertEqual([p["nickname"] for p in payload["players"]], ["Bo", "Mia"]) - - -class StartRoundTests(TestCase): - def setUp(self): - self.host = User.objects.create_user(username="host", password="secret123") - self.other_user = User.objects.create_user(username="other", password="secret123") - self.session = GameSession.objects.create(host=self.host, code="ABCD23") - self.category = Category.objects.create(name="Historie", slug="historie", is_active=True) - Question.objects.create( - category=self.category, - prompt="Hvilket år faldt muren?", - correct_answer="1989", - is_active=True, - ) - - def test_host_can_start_round_with_selected_category(self): - self.client.login(username="host", password="secret123") - - response = self.client.post( - reverse("lobby:start_round", kwargs={"code": self.session.code}), - data={"category_slug": self.category.slug}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 201) - body = response.json() - self.assertEqual(body["session"]["status"], GameSession.Status.LIE) - self.assertEqual(body["round"]["number"], 1) - self.assertEqual(body["round"]["category"]["slug"], self.category.slug) - self.assertEqual(body["round_question"]["prompt"], "Hvilket år faldt muren?") - self.assertIn("lie_deadline_at", body["round_question"]) - - self.session.refresh_from_db() - self.assertEqual(self.session.status, GameSession.Status.LIE) - round_config = RoundConfig.objects.get(session=self.session, number=1) - self.assertEqual(round_config.category, self.category) - self.assertTrue(RoundQuestion.objects.filter(session=self.session, round_number=1).exists()) - - def test_host_start_round_uses_normalized_session_code_from_path(self): - self.client.login(username="host", password="secret123") - - response = self.client.post( - reverse("lobby:start_round", kwargs={"code": " abcd23 "}), - data={"category_slug": self.category.slug}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 201) - self.session.refresh_from_db() - self.assertEqual(self.session.status, GameSession.Status.LIE) - round_config = RoundConfig.objects.get(session=self.session, number=1) - self.assertEqual(round_config.category, self.category) - - def test_start_round_requires_host(self): - self.client.login(username="other", password="secret123") - - response = self.client.post( - reverse("lobby:start_round", kwargs={"code": self.session.code}), - data={"category_slug": self.category.slug}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error_code"], "host_only_start_round") - self.assertEqual(response.json()["locale"], "en") - self.assertEqual(response.json()["error"], "Only host can start round") - - def test_start_round_requires_existing_active_category_with_questions(self): - self.client.login(username="host", password="secret123") - - response = self.client.post( - reverse("lobby:start_round", kwargs={"code": self.session.code}), - data={"category_slug": "ukendt"}, - content_type="application/json", - ) - self.assertEqual(response.status_code, 404) - - empty_category = Category.objects.create(name="Sport", slug="sport", is_active=True) - response = self.client.post( - reverse("lobby:start_round", kwargs={"code": self.session.code}), - data={"category_slug": empty_category.slug}, - content_type="application/json", - ) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error_code"], "category_has_no_questions") - self.assertEqual(response.json()["locale"], "en") - self.assertEqual(response.json()["error"], "Category has no active questions") - - def test_start_round_rejects_non_lobby_session(self): - self.client.login(username="host", password="secret123") - self.session.status = GameSession.Status.GUESS - self.session.save(update_fields=["status"]) - - response = self.client.post( - reverse("lobby:start_round", kwargs={"code": self.session.code}), - data={"category_slug": self.category.slug}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "Round can only be started from lobby") - - def test_start_round_error_localizes_to_danish(self): - self.client.login(username="other", password="secret123") - - response = self.client.post( - reverse("lobby:start_round", kwargs={"code": self.session.code}), - data={"category_slug": self.category.slug}, - content_type="application/json", - HTTP_ACCEPT_LANGUAGE="da", - ) - - self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error_code"], "host_only_start_round") - self.assertEqual(response.json()["locale"], "da") - self.assertEqual(response.json()["error"], "Kun værten kan starte runden") - - def test_start_round_error_falls_back_to_english_for_unsupported_locale(self): - self.client.login(username="other", password="secret123") - - response = self.client.post( - reverse("lobby:start_round", kwargs={"code": self.session.code}), - data={"category_slug": self.category.slug}, - content_type="application/json", - HTTP_ACCEPT_LANGUAGE="fr", - ) - - self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error_code"], "host_only_start_round") - self.assertEqual(response.json()["locale"], "en") - self.assertEqual(response.json()["error"], "Only host can start round") - - @patch("fupogfakta.services.select_round_question", side_effect=ValueError("no_available_questions")) - def test_start_round_does_not_persist_round_config_when_question_selection_fails(self, _mock_select_round_question): - self.client.login(username="host", password="secret123") - - response = self.client.post( - reverse("lobby:start_round", kwargs={"code": self.session.code}), - data={"category_slug": self.category.slug}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error_code"], "no_available_questions") - self.session.refresh_from_db() - self.assertEqual(self.session.status, GameSession.Status.LOBBY) - self.assertFalse(RoundConfig.objects.filter(session=self.session, number=1).exists()) - self.assertFalse(RoundQuestion.objects.filter(session=self.session, round_number=1).exists()) - - -class LieSubmissionTests(TestCase): - def setUp(self): - self.host = User.objects.create_user(username="host", password="secret123") - self.session = GameSession.objects.create(host=self.host, code="ABCD23", status=GameSession.Status.LIE) - self.category = Category.objects.create(name="Geografi", slug="geografi", is_active=True) - self.question = Question.objects.create( - category=self.category, - prompt="Hvad er hovedstaden i Australien?", - correct_answer="Canberra", - is_active=True, - ) - RoundConfig.objects.create( - session=self.session, - number=1, - category=self.category, - lie_seconds=45, - ) - self.player = Player.objects.create(session=self.session, nickname="Luna") - - def test_host_can_show_question_and_get_lie_deadline(self): - self.client.login(username="host", password="secret123") - - response = self.client.post(reverse("lobby:show_question", kwargs={"code": self.session.code})) - - self.assertEqual(response.status_code, 201) - payload = response.json() - self.assertEqual(payload["config"]["lie_seconds"], 45) - self.assertIn("lie_deadline_at", payload["round_question"]) - self.assertTrue(RoundQuestion.objects.filter(session=self.session, round_number=1).exists()) - - def test_player_can_submit_lie_before_deadline(self): - round_question = RoundQuestion.objects.create( - session=self.session, - round_number=1, - question=self.question, - correct_answer=self.question.correct_answer, - ) - - response = self.client.post( - reverse( - "lobby:submit_lie", - kwargs={"code": self.session.code, "round_question_id": round_question.id}, - ), - data={"player_id": self.player.id, "session_token": self.player.session_token, "text": "Sydney"}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 201) - self.assertTrue(LieAnswer.objects.filter(round_question=round_question, player=self.player).exists()) - - def test_submit_lie_rejects_after_time_window(self): - round_question = RoundQuestion.objects.create( - session=self.session, - round_number=1, - question=self.question, - correct_answer=self.question.correct_answer, - ) - round_question.shown_at = timezone.now() - timedelta(seconds=46) - round_question.save(update_fields=["shown_at"]) - - response = self.client.post( - reverse( - "lobby:submit_lie", - kwargs={"code": self.session.code, "round_question_id": round_question.id}, - ), - data={"player_id": self.player.id, "session_token": self.player.session_token, "text": "Melbourne"}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error_code"], "lie_submission_closed") - self.assertEqual(response.json()["locale"], "en") - self.assertEqual(response.json()["error"], "Lie submission window has closed") - - def test_submit_lie_rejects_duplicate_submission(self): - round_question = RoundQuestion.objects.create( - session=self.session, - round_number=1, - question=self.question, - correct_answer=self.question.correct_answer, - ) - LieAnswer.objects.create(round_question=round_question, player=self.player, text="Perth") - - response = self.client.post( - reverse( - "lobby:submit_lie", - kwargs={"code": self.session.code, "round_question_id": round_question.id}, - ), - data={"player_id": self.player.id, "session_token": self.player.session_token, "text": "Brisbane"}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 409) - self.assertEqual(response.json()["error_code"], "lie_already_submitted") - self.assertEqual(response.json()["locale"], "en") - self.assertEqual(response.json()["error"], "Lie already submitted for this player") - - def test_submit_lie_requires_session_token(self): - round_question = RoundQuestion.objects.create( - session=self.session, - round_number=1, - question=self.question, - correct_answer=self.question.correct_answer, - ) - - response = self.client.post( - reverse( - "lobby:submit_lie", - kwargs={"code": self.session.code, "round_question_id": round_question.id}, - ), - data={"player_id": self.player.id, "text": "Sydney"}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error_code"], "session_token_required") - self.assertEqual(response.json()["locale"], "en") - self.assertEqual(response.json()["error"], "session_token is required") - - def test_submit_lie_rejects_invalid_session_token(self): - round_question = RoundQuestion.objects.create( - session=self.session, - round_number=1, - question=self.question, - correct_answer=self.question.correct_answer, - ) - - response = self.client.post( - reverse( - "lobby:submit_lie", - kwargs={"code": self.session.code, "round_question_id": round_question.id}, - ), - data={"player_id": self.player.id, "session_token": "invalid-token", "text": "Sydney"}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error_code"], "invalid_player_session_token") - self.assertEqual(response.json()["locale"], "en") - self.assertEqual(response.json()["error"], "Invalid player session token") - - def test_submit_lie_uses_danish_locale_payload_from_accept_language(self): - round_question = RoundQuestion.objects.create( - session=self.session, - round_number=1, - question=self.question, - correct_answer=self.question.correct_answer, - ) - - response = self.client.post( - reverse( - "lobby:submit_lie", - kwargs={"code": self.session.code, "round_question_id": round_question.id}, - ), - data={"player_id": self.player.id, "session_token": "invalid-token", "text": "Sydney"}, - content_type="application/json", - HTTP_ACCEPT_LANGUAGE="da-DK,da;q=0.9,en;q=0.1", - ) - - self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error_code"], "invalid_player_session_token") - self.assertEqual(response.json()["locale"], "da") - self.assertEqual(response.json()["error"], "Ugyldigt spiller-session-token") - -class MixAnswersTests(TestCase): - def setUp(self): - self.host = User.objects.create_user(username="host", password="secret123") - self.other_user = User.objects.create_user(username="other", password="secret123") - self.session = GameSession.objects.create(host=self.host, code="ABCD23", status=GameSession.Status.LIE) - self.category = Category.objects.create(name="Historie", slug="historie", is_active=True) - self.question = Question.objects.create( - category=self.category, - prompt="Hvilken by er Danmarks hovedstad?", - correct_answer="København", - is_active=True, - ) - RoundConfig.objects.create(session=self.session, number=1, category=self.category) - self.round_question = RoundQuestion.objects.create( - session=self.session, - round_number=1, - question=self.question, - correct_answer="København", - ) - self.player_one = Player.objects.create(session=self.session, nickname="Luna") - self.player_two = Player.objects.create(session=self.session, nickname="Mads") - - def test_host_can_mix_answers_and_transition_to_guess(self): - LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="Aarhus") - LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Odense") - - self.client.login(username="host", password="secret123") - response = self.client.post( - reverse( - "lobby:mix_answers", - kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, - ) - ) - - self.assertEqual(response.status_code, 200) - payload = response.json() - answer_texts = [entry["text"] for entry in payload["answers"]] - self.assertEqual(set(answer_texts), {"København", "Aarhus", "Odense"}) - self.assertEqual(payload["session"]["status"], GameSession.Status.GUESS) - - self.session.refresh_from_db() - self.round_question.refresh_from_db() - self.assertEqual(self.session.status, GameSession.Status.GUESS) - self.assertEqual(self.round_question.mixed_answers, answer_texts) - - def test_mix_answers_requires_host(self): - self.client.login(username="other", password="secret123") - - response = self.client.post( - reverse( - "lobby:mix_answers", - kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, - ) - ) - - self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error_code"], "host_only_mix_answers") - self.assertEqual(response.json()["locale"], "en") - self.assertEqual(response.json()["error"], "Only host can mix answers") - - def test_mix_answers_deduplicates_case_insensitive_lies(self): - LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="københavn") - LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Aarhus") - - self.client.login(username="host", password="secret123") - response = self.client.post( - reverse( - "lobby:mix_answers", - kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, - ) - ) - - self.assertEqual(response.status_code, 200) - answer_texts = [entry["text"] for entry in response.json()["answers"]] - self.assertEqual(set(answer_texts), {"København", "Aarhus"}) - - def test_mix_answers_is_idempotent_after_transition_to_guess(self): - LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="Aarhus") - LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Odense") - - self.client.login(username="host", password="secret123") - first = self.client.post(reverse("lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id})) - second = self.client.post(reverse("lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id})) - - self.assertEqual(first.status_code, 200) - self.assertEqual(second.status_code, 200) - self.assertEqual([entry["text"] for entry in first.json()["answers"]], [entry["text"] for entry in second.json()["answers"]]) - - def test_session_detail_returns_persisted_mixed_answers_for_reconnect(self): - LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="Aarhus") - LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Odense") - - self.client.login(username="host", password="secret123") - mix_response = self.client.post(reverse("lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id})) - detail_response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})) - - self.assertEqual(mix_response.status_code, 200) - self.assertEqual(detail_response.status_code, 200) - self.assertEqual([entry["text"] for entry in mix_response.json()["answers"]], [entry["text"] for entry in detail_response.json()["round_question"]["answers"]]) - - -class GuessSubmissionTests(TestCase): - def setUp(self): - self.host = User.objects.create_user(username="host_guess", password="secret123") - self.session = GameSession.objects.create(host=self.host, code="GU3551", status=GameSession.Status.GUESS) - self.category = Category.objects.create(name="Videnskab", slug="videnskab", is_active=True) - self.question = Question.objects.create( - category=self.category, - prompt="Hvilken planet kaldes den røde planet?", - correct_answer="Mars", - is_active=True, - ) - self.round_config = RoundConfig.objects.create( - session=self.session, - number=1, - category=self.category, - lie_seconds=45, - guess_seconds=30, - ) - self.round_question = RoundQuestion.objects.create( - session=self.session, - round_number=1, - question=self.question, - correct_answer="Mars", - ) - self.player = Player.objects.create(session=self.session, nickname="Luna") - self.liar = Player.objects.create(session=self.session, nickname="Mads") - LieAnswer.objects.create(round_question=self.round_question, player=self.liar, text="Jupiter") - - def test_player_can_submit_guess_in_guess_phase(self): - response = self.client.post( - reverse( - "lobby:submit_guess", - kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, - ), - data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Mars"}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 201) - payload = response.json() - self.assertTrue(payload["guess"]["is_correct"]) - self.assertIsNone(payload["guess"]["fooled_player_id"]) - self.assertIn("guess_deadline_at", payload["window"]) - - def test_submit_guess_rejects_when_not_in_guess_phase(self): - self.session.status = GameSession.Status.LIE - self.session.save(update_fields=["status"]) - - response = self.client.post( - reverse( - "lobby:submit_guess", - kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, - ), - data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Mars"}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error_code"], "guess_submission_invalid_phase") - self.assertEqual(response.json()["locale"], "en") - self.assertEqual(response.json()["error"], "Guess submission is only allowed in guess phase") - - def test_submit_guess_rejects_unknown_answer(self): - response = self.client.post( - reverse( - "lobby:submit_guess", - kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, - ), - data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Venus"}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error_code"], "selected_answer_invalid") - self.assertEqual(response.json()["locale"], "en") - self.assertEqual(response.json()["error"], "Selected answer is not part of this round") - - def test_submit_guess_rejects_duplicate_submission(self): - Guess.objects.create(round_question=self.round_question, player=self.player, selected_text="Mars", is_correct=True) - - response = self.client.post( - reverse( - "lobby:submit_guess", - kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, - ), - data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Jupiter"}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 409) - self.assertEqual(response.json()["error_code"], "guess_already_submitted") - self.assertEqual(response.json()["locale"], "en") - self.assertEqual(response.json()["error"], "Guess already submitted for this player") - - def test_submit_guess_rejects_after_deadline(self): - self.round_question.shown_at = timezone.now() - timedelta(seconds=76) - self.round_question.save(update_fields=["shown_at"]) - - response = self.client.post( - reverse( - "lobby:submit_guess", - kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, - ), - data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Mars"}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error_code"], "guess_submission_closed") - self.assertEqual(response.json()["locale"], "en") - self.assertEqual(response.json()["error"], "Guess submission window has closed") - - - - def test_submit_guess_requires_session_token(self): - response = self.client.post( - reverse( - "lobby:submit_guess", - kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, - ), - data={"player_id": self.player.id, "selected_text": "Mars"}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error_code"], "session_token_required") - self.assertEqual(response.json()["locale"], "en") - self.assertEqual(response.json()["error"], "session_token is required") - - def test_submit_guess_rejects_invalid_session_token(self): - response = self.client.post( - reverse( - "lobby:submit_guess", - kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, - ), - data={"player_id": self.player.id, "session_token": "wrong-token", "selected_text": "Mars"}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error_code"], "invalid_player_session_token") - self.assertEqual(response.json()["locale"], "en") - self.assertEqual(response.json()["error"], "Invalid player session token") - - -class CanonicalRoundFlowTests(TestCase): - def setUp(self): - self.host = User.objects.create_user(username="host_canonical", password="secret123") - self.session = GameSession.objects.create(host=self.host, code="CN2871") - self.category = Category.objects.create(name="Kanon", slug="kanon", is_active=True) - self.question = Question.objects.create( - category=self.category, - prompt="Hvem skrev Hamlet?", - correct_answer="Shakespeare", - is_active=True, - ) - self.players = [ - Player.objects.create(session=self.session, nickname="Luna"), - Player.objects.create(session=self.session, nickname="Mads"), - Player.objects.create(session=self.session, nickname="Nora"), - ] - - def test_canonical_round_flow_auto_advances_from_start_to_scoreboard(self): - self.client.login(username="host_canonical", password="secret123") - - start_response = self.client.post( - reverse("lobby:start_round", kwargs={"code": self.session.code}), - data={"category_slug": self.category.slug}, - content_type="application/json", - ) - self.assertEqual(start_response.status_code, 201) - round_question_id = start_response.json()["round_question"]["id"] - self.assertEqual(start_response.json()["session"]["status"], GameSession.Status.LIE) - - lie_responses = [] - for index, player in enumerate(self.players, start=1): - lie_responses.append( - self.client.post( - reverse("lobby:submit_lie", kwargs={"code": self.session.code, "round_question_id": round_question_id}), - data={"player_id": player.id, "session_token": player.session_token, "text": f"Løgn {index}"}, - content_type="application/json", - ) - ) - - self.assertTrue(all(response.status_code == 201 for response in lie_responses)) - self.assertEqual(lie_responses[-1].json()["session"]["status"], GameSession.Status.GUESS) - self.assertTrue(lie_responses[-1].json()["phase_transition"]["auto_advanced"]) - self.assertGreaterEqual(len(lie_responses[-1].json()["answers"]), 2) - - guess_targets = ["Shakespeare", "Løgn 1", "Shakespeare"] - guess_responses = [] - for player, selected_text in zip(self.players, guess_targets, strict=True): - guess_responses.append( - self.client.post( - reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": round_question_id}), - data={"player_id": player.id, "session_token": player.session_token, "selected_text": selected_text}, - content_type="application/json", - ) - ) - - self.assertTrue(all(response.status_code == 201 for response in guess_responses)) - self.assertEqual(guess_responses[-1].json()["session"]["status"], GameSession.Status.REVEAL) - self.assertTrue(guess_responses[-1].json()["phase_transition"]["auto_advanced"]) - self.assertIsNotNone(guess_responses[-1].json()["reveal"]) - - detail_response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})) - self.assertEqual(detail_response.status_code, 200) - payload = detail_response.json() - self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) - self.assertEqual(payload["phase_view_model"]["current_phase"], GameSession.Status.SCOREBOARD) - self.assertTrue(payload["phase_view_model"]["readiness"]["scoreboard_ready"]) - self.assertEqual([entry["nickname"] for entry in payload["scoreboard"]], ["Luna", "Nora", "Mads"]) - self.assertEqual(payload["reveal"]["correct_answer"], "Shakespeare") - - @patch("lobby.views.sync_broadcast_phase_event") - @patch("lobby.views._resolve_scores") - def test_session_detail_promotes_zero_score_event_reveal_to_scoreboard(self, mock_resolve_scores, mock_sync_broadcast): - self.client.login(username="host_canonical", password="secret123") - self.session.status = GameSession.Status.GUESS - self.session.save(update_fields=["status"]) - - round_config = RoundConfig.objects.create( - session=self.session, - number=1, - category=self.category, - points_correct=5, - points_bluff=2, - ) - round_question = RoundQuestion.objects.create( - session=self.session, - round_number=1, - question=self.question, - correct_answer="Shakespeare", - ) - LieAnswer.objects.create(round_question=round_question, player=self.players[0], text="Marlowe") - LieAnswer.objects.create(round_question=round_question, player=self.players[1], text="Chaucer") - LieAnswer.objects.create(round_question=round_question, player=self.players[2], text="Austen") - - mock_resolve_scores.return_value = ([], [ - {"id": player.id, "nickname": player.nickname, "score": player.score} - for player in sorted(self.players, key=lambda player: player.nickname) - ]) - - guess_targets = ["Shakespeare", "Shakespeare", "Shakespeare"] - guess_responses = [] - for player, selected_text in zip(self.players, guess_targets, strict=True): - guess_responses.append( - self.client.post( - reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": round_question.id}), - data={"player_id": player.id, "session_token": player.session_token, "selected_text": selected_text}, - content_type="application/json", - ) - ) - - self.assertTrue(all(response.status_code == 201 for response in guess_responses)) - self.assertEqual(guess_responses[-1].json()["session"]["status"], GameSession.Status.REVEAL) - self.assertEqual(ScoreEvent.objects.filter(session=self.session, meta__round_question_id=round_question.id).count(), 0) - - detail_response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})) - - self.assertEqual(detail_response.status_code, 200) - payload = detail_response.json() - self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) - self.assertEqual(payload["phase_view_model"]["current_phase"], GameSession.Status.SCOREBOARD) - self.assertTrue(payload["phase_view_model"]["readiness"]["scoreboard_ready"]) - self.assertEqual(payload["reveal"]["correct_answer"], "Shakespeare") - mock_resolve_scores.assert_called_once_with(self.session, round_question, round_config) - mock_sync_broadcast.assert_any_call( - self.session.code, - "phase.scoreboard", - { - "leaderboard": payload["scoreboard"], - "current_round": self.session.current_round, - }, - ) - - def test_canonical_round_flow_bootstraps_second_round_without_first_round_carry_over(self): - self.client.login(username="host_canonical", password="secret123") - extra_question = Question.objects.create( - category=self.category, - prompt="Hvem malede Mona Lisa?", - correct_answer="Da Vinci", - is_active=True, - ) - - start_response = self.client.post( - reverse("lobby:start_round", kwargs={"code": self.session.code}), - data={"category_slug": self.category.slug}, - content_type="application/json", - ) - self.assertEqual(start_response.status_code, 201) - first_round_question_id = start_response.json()["round_question"]["id"] - first_round_prompt = start_response.json()["round_question"]["prompt"] - first_round_correct_answer = RoundQuestion.objects.get(pk=first_round_question_id).correct_answer - second_question = extra_question if first_round_prompt == self.question.prompt else self.question - - final_lie_response = None - for index, player in enumerate(self.players, start=1): - lie_response = self.client.post( - reverse("lobby:submit_lie", kwargs={"code": self.session.code, "round_question_id": first_round_question_id}), - data={"player_id": player.id, "session_token": player.session_token, "text": f"Første løgn {index}"}, - content_type="application/json", - ) - self.assertEqual(lie_response.status_code, 201) - final_lie_response = lie_response - - self.assertIsNotNone(final_lie_response) - - for player, selected_text in zip( - self.players, - [first_round_correct_answer, first_round_correct_answer, first_round_correct_answer], - strict=True, - ): - guess_response = self.client.post( - reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": first_round_question_id}), - data={"player_id": player.id, "session_token": player.session_token, "selected_text": selected_text}, - content_type="application/json", - ) - self.assertEqual(guess_response.status_code, 201) - - scoreboard_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() - self.assertEqual(scoreboard_payload["session"]["status"], GameSession.Status.SCOREBOARD) - self.assertEqual(scoreboard_payload["round_question"]["id"], first_round_question_id) - self.assertIsNotNone(scoreboard_payload["reveal"]) - self.assertIsNotNone(scoreboard_payload["scoreboard"]) - self.assertGreaterEqual(len(scoreboard_payload["reveal"]["guesses"]), 1) - - next_round_response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) - self.assertEqual(next_round_response.status_code, 200) - self.assertEqual(next_round_response.json()["session"]["status"], GameSession.Status.LIE) - self.assertEqual(next_round_response.json()["session"]["current_round"], 2) - - detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() - self.assertEqual(detail_payload["session"]["status"], GameSession.Status.LIE) - self.assertEqual(detail_payload["session"]["current_round"], 2) - self.assertEqual(detail_payload["phase_view_model"]["current_phase"], GameSession.Status.LIE) - self.assertIsNone(detail_payload["reveal"]) - self.assertIsNone(detail_payload["scoreboard"]) - self.assertEqual(detail_payload["round_question"]["round_number"], 2) - self.assertNotEqual(detail_payload["round_question"]["id"], first_round_question_id) - self.assertEqual(detail_payload["round_question"]["prompt"], second_question.prompt) - self.assertEqual(detail_payload["round_question"]["answers"], []) - - round_two_question = RoundQuestion.objects.get(session=self.session, round_number=2) - self.assertEqual(round_two_question.question, second_question) - self.assertEqual(round_two_question.lies.count(), 0) - self.assertEqual(round_two_question.guesses.count(), 0) - self.assertEqual(round_two_question.mixed_answers, []) - - @patch("lobby.views.sync_broadcast_phase_event") - @patch("lobby.views._resolve_scores") - @patch("lobby.views.GameSession.objects.get") - def test_submit_guess_skips_rescore_when_locked_session_is_already_revealing( - self, - mock_session_get, - mock_resolve_scores, - mock_sync_broadcast, - ): - round_config = RoundConfig.objects.create( - session=self.session, - number=1, - category=self.category, - points_correct=5, - points_bluff=2, - ) - round_question = RoundQuestion.objects.create( - session=self.session, - round_number=1, - question=self.question, - correct_answer="Shakespeare", - ) - LieAnswer.objects.create(round_question=round_question, player=self.players[0], text="Marlowe") - Guess.objects.create( - round_question=round_question, - player=self.players[0], - selected_text="Shakespeare", - is_correct=True, - ) - Guess.objects.create( - round_question=round_question, - player=self.players[1], - selected_text="Marlowe", - is_correct=False, - fooled_player=self.players[0], - ) - self.players[0].score = round_config.points_correct + round_config.points_bluff - self.players[0].save(update_fields=["score"]) - ScoreEvent.objects.create( - session=self.session, - player=self.players[0], - delta=round_config.points_correct, - reason="guess_correct", - meta={"round_question_id": round_question.id, "guess_id": 1}, - ) - ScoreEvent.objects.create( - session=self.session, - player=self.players[0], - delta=round_config.points_bluff, - reason="bluff_success", - meta={"round_question_id": round_question.id, "fooled_count": 1}, - ) - self.session.status = GameSession.Status.REVEAL - self.session.save(update_fields=["status"]) - - stale_session = GameSession( - pk=self.session.pk, - host=self.host, - code=self.session.code, - status=GameSession.Status.GUESS, - current_round=self.session.current_round, - ) - mock_session_get.return_value = stale_session - - response = self.client.post( - reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": round_question.id}), - data={ - "player_id": self.players[2].id, - "session_token": self.players[2].session_token, - "selected_text": "Shakespeare", - }, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 201) - self.assertEqual(response.json()["session"]["status"], GameSession.Status.REVEAL) - self.assertTrue(response.json()["phase_transition"]["auto_advanced"]) - self.assertIsNotNone(response.json()["reveal"]) - mock_resolve_scores.assert_not_called() - mock_sync_broadcast.assert_not_called() - self.assertEqual(ScoreEvent.objects.filter(session=self.session, meta__round_question_id=round_question.id).count(), 2) - - -class ScoreCalculationTests(TestCase): - def setUp(self): - self.host = User.objects.create_user(username="host_score", password="secret123") - self.other_user = User.objects.create_user(username="other_score", password="secret123") - self.session = GameSession.objects.create(host=self.host, code="SC0RE1", status=GameSession.Status.GUESS) - self.category = Category.objects.create(name="Sport", slug="sport", is_active=True) - self.question = Question.objects.create( - category=self.category, - prompt="Hvilken sport spiller man i Wimbledon?", - correct_answer="Tennis", - is_active=True, - ) - RoundConfig.objects.create( - session=self.session, - number=1, - category=self.category, - points_correct=5, - points_bluff=2, - ) - self.round_question = RoundQuestion.objects.create( - session=self.session, - round_number=1, - question=self.question, - correct_answer="Tennis", - ) - self.player_one = Player.objects.create(session=self.session, nickname="Luna") - self.player_two = Player.objects.create(session=self.session, nickname="Mads") - self.player_three = Player.objects.create(session=self.session, nickname="Nora") - - def test_host_can_calculate_scores_and_transition_to_reveal(self): - LieAnswer.objects.create(round_question=self.round_question, player=self.player_three, text="Padel") - - Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True) - Guess.objects.create( - round_question=self.round_question, - player=self.player_two, - selected_text="Padel", - is_correct=False, - fooled_player=self.player_three, - ) - Guess.objects.create( - round_question=self.round_question, - player=self.player_three, - selected_text="Padel", - is_correct=False, - fooled_player=self.player_three, - ) - - self.client.login(username="host_score", password="secret123") - response = self.client.post( - reverse( - "lobby:calculate_scores", - kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, - ) - ) - - self.assertEqual(response.status_code, 200) - payload = response.json() - self.assertEqual(payload["session"]["status"], GameSession.Status.REVEAL) - self.assertEqual(payload["events_created"], 2) - self.assertEqual(payload["reveal"]["round_question_id"], self.round_question.id) - self.assertEqual(payload["reveal"]["correct_answer"], "Tennis") - self.assertEqual( - payload["reveal"]["lies"], - [ - { - "player_id": self.player_three.id, - "nickname": "Nora", - "text": "Padel", - "created_at": payload["reveal"]["lies"][0]["created_at"], - } - ], - ) - self.assertEqual( - payload["reveal"]["guesses"], - [ - { - "player_id": self.player_one.id, - "nickname": "Luna", - "selected_text": "Tennis", - "is_correct": True, - "created_at": payload["reveal"]["guesses"][0]["created_at"], - "fooled_player_id": None, - }, - { - "player_id": self.player_two.id, - "nickname": "Mads", - "selected_text": "Padel", - "is_correct": False, - "created_at": payload["reveal"]["guesses"][1]["created_at"], - "fooled_player_id": self.player_three.id, - "fooled_player_nickname": "Nora", - }, - { - "player_id": self.player_three.id, - "nickname": "Nora", - "selected_text": "Padel", - "is_correct": False, - "created_at": payload["reveal"]["guesses"][2]["created_at"], - "fooled_player_id": self.player_three.id, - "fooled_player_nickname": "Nora", - }, - ], - ) - - self.player_one.refresh_from_db() - self.player_three.refresh_from_db() - self.session.refresh_from_db() - - self.assertEqual(self.player_one.score, 5) - self.assertEqual(self.player_three.score, 4) - self.assertEqual(self.session.status, GameSession.Status.REVEAL) - - def test_calculate_scores_requires_host(self): - self.client.login(username="other_score", password="secret123") - - response = self.client.post( - reverse( - "lobby:calculate_scores", - kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, - ) - ) - - self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error_code"], "host_only_calculate_scores") - self.assertEqual(response.json()["locale"], "en") - self.assertEqual(response.json()["error"], "Only host can calculate scores") - - def test_calculate_scores_rejects_duplicate_calculation(self): - Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True) - - self.client.login(username="host_score", password="secret123") - first = self.client.post( - reverse( - "lobby:calculate_scores", - kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, - ) - ) - second = self.client.post( - reverse( - "lobby:calculate_scores", - kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, - ) - ) - - self.assertEqual(first.status_code, 200) - self.assertEqual(second.status_code, 409) - self.assertEqual(second.json()["error_code"], "scores_already_calculated") - self.assertEqual(second.json()["locale"], "en") - self.assertEqual(second.json()["error"], "Scores already calculated for this round question") - - -class RevealRoundFlowTests(TestCase): - def setUp(self): - self.host = User.objects.create_user(username="host_reveal", password="secret123") - self.other_user = User.objects.create_user(username="other_reveal", password="secret123") - self.session = GameSession.objects.create(host=self.host, code="RVL123", status=GameSession.Status.REVEAL) - self.player_one = Player.objects.create(session=self.session, nickname="Luna", score=9) - self.player_two = Player.objects.create(session=self.session, nickname="Mads", score=3) - self.category = Category.objects.create(name="Reveal", slug="reveal", is_active=True) - self.question = Question.objects.create( - category=self.category, - prompt="Hvad er Danmarks hovedstad?", - correct_answer="København", - is_active=True, - ) - self.next_question = Question.objects.create( - category=self.category, - prompt="Hvad er Sveriges hovedstad?", - correct_answer="Stockholm", - is_active=True, - ) - self.round_config = RoundConfig.objects.create(session=self.session, number=1, category=self.category) - self.round_question = RoundQuestion.objects.create( - session=self.session, - round_number=1, - question=self.question, - correct_answer=self.question.correct_answer, - ) - ScoreEvent.objects.create( - session=self.session, - player=self.player_one, - delta=5, - reason="guess_correct", - meta={"round_question_id": self.round_question.id}, - ) - - @patch("lobby.views.sync_broadcast_phase_event") - def test_host_can_get_reveal_scoreboard(self, mock_sync_broadcast_phase_event): - self.client.login(username="host_reveal", password="secret123") - - response = self.client.get( - reverse( - "lobby:reveal_scoreboard", - kwargs={"code": self.session.code}, - ) - ) - - self.assertEqual(response.status_code, 200) - payload = response.json() - self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) - self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"]) - mock_sync_broadcast_phase_event.assert_called_once_with( - self.session.code, - "phase.scoreboard", - { - "leaderboard": [ - {"id": self.player_one.id, "nickname": "Luna", "score": 9}, - {"id": self.player_two.id, "nickname": "Mads", "score": 3}, - ], - "current_round": 1, - }, - ) - - self.session.refresh_from_db() - self.assertEqual(self.session.status, GameSession.Status.SCOREBOARD) - - def test_reveal_scoreboard_requires_host(self): - self.client.login(username="other_reveal", password="secret123") - - response = self.client.get( - reverse( - "lobby:reveal_scoreboard", - kwargs={"code": self.session.code}, - ), - HTTP_ACCEPT_LANGUAGE="fr", - ) - - self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error_code"], "host_only_view_scoreboard") - self.assertEqual(response.json()["locale"], "en") - self.assertEqual(response.json()["error"], "Only host can view scoreboard") - - def test_reveal_scoreboard_is_idempotent_in_scoreboard_phase(self): - self.session.status = GameSession.Status.SCOREBOARD - self.session.save(update_fields=["status"]) - self.client.login(username="host_reveal", password="secret123") - - response = self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) - - self.assertEqual(response.status_code, 200) - payload = response.json() - self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) - self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"]) - - @patch("lobby.views.sync_broadcast_phase_event") - def test_host_can_finish_game_from_scoreboard(self, _mock_sync_broadcast_phase_event): - self.client.login(username="host_reveal", password="secret123") - self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) - - response = self.client.post( - reverse( - "lobby:finish_game", - kwargs={"code": self.session.code}, - ) - ) - - self.assertEqual(response.status_code, 200) - payload = response.json() - self.assertEqual(payload["session"]["status"], GameSession.Status.FINISHED) - self.assertEqual(payload["winner"]["nickname"], "Luna") - self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"]) - - self.session.refresh_from_db() - self.assertEqual(self.session.status, GameSession.Status.FINISHED) - - @patch("lobby.views.sync_broadcast_phase_event") - def test_finish_game_is_idempotent_after_transition_to_finished(self, mock_sync_broadcast_phase_event): - self.client.login(username="host_reveal", password="secret123") - self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) - - first_response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) - second_response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) - - self.assertEqual(first_response.status_code, 200) - self.assertEqual(second_response.status_code, 200) - self.assertEqual(first_response.json(), second_response.json()) - self.assertEqual(second_response.json()["session"]["status"], GameSession.Status.FINISHED) - - self.session.refresh_from_db() - self.assertEqual(self.session.status, GameSession.Status.FINISHED) - self.assertEqual(mock_sync_broadcast_phase_event.call_count, 2) - self.assertEqual(mock_sync_broadcast_phase_event.call_args_list[0].args[1], "phase.scoreboard") - self.assertEqual(mock_sync_broadcast_phase_event.call_args_list[1].args[1], "phase.game_over") - - def test_finish_game_requires_host(self): - self.client.login(username="other_reveal", password="secret123") - - response = self.client.post( - reverse( - "lobby:finish_game", - kwargs={"code": self.session.code}, - ), - HTTP_ACCEPT_LANGUAGE="da", - ) - - self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error_code"], "host_only_finish_game") - self.assertEqual(response.json()["locale"], "da") - self.assertEqual(response.json()["error"], "Kun værten kan afslutte spillet") - - def test_finish_game_rejects_wrong_phase(self): - self.client.login(username="host_reveal", password="secret123") - self.session.status = GameSession.Status.GUESS - self.session.save(update_fields=["status"]) - - response = self.client.post( - reverse( - "lobby:finish_game", - kwargs={"code": self.session.code}, - ), - HTTP_ACCEPT_LANGUAGE="fr", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error_code"], "finish_game_invalid_phase") - self.assertEqual(response.json()["locale"], "en") - self.assertEqual(response.json()["error"], "Game can only be finished from scoreboard phase") - - @patch("lobby.views.sync_broadcast_phase_event") - def test_host_can_start_next_round_from_scoreboard(self, mock_sync_broadcast_phase_event): - self.client.login(username="host_reveal", password="secret123") - self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) - mock_sync_broadcast_phase_event.reset_mock() - - response = self.client.post( - reverse( - "lobby:start_next_round", - kwargs={"code": self.session.code}, - ) - ) - - self.assertEqual(response.status_code, 200) - payload = response.json() - self.assertEqual(payload["session"]["status"], GameSession.Status.LIE) - self.assertEqual(payload["session"]["current_round"], 2) - self.assertEqual(payload["round"]["category"]["slug"], self.category.slug) - self.assertEqual(payload["round_question"]["prompt"], self.next_question.prompt) - self.assertEqual(payload["config"]["lie_seconds"], self.round_config.lie_seconds) - - self.session.refresh_from_db() - self.assertEqual(self.session.status, GameSession.Status.LIE) - self.assertEqual(self.session.current_round, 2) - self.assertTrue( - RoundConfig.objects.filter( - session=self.session, - number=2, - category=self.category, - started_from_scoreboard=True, - ).exists() - ) - self.assertTrue( - RoundQuestion.objects.filter(session=self.session, round_number=2, question=self.next_question).exists() - ) - mock_sync_broadcast_phase_event.assert_called_once() - self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[0], self.session.code) - self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started") - - @patch("lobby.views.sync_broadcast_phase_event") - def test_start_next_round_bootstraps_new_round_question_instead_of_reusing_current_round(self, mock_sync_broadcast_phase_event): - self.client.login(username="host_reveal", password="secret123") - self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) - mock_sync_broadcast_phase_event.reset_mock() - - stale_shown_at = timezone.now() - timedelta(minutes=10) - current_round_question = RoundQuestion.objects.get(session=self.session, round_number=1) - current_round_question.shown_at = stale_shown_at - current_round_question.mixed_answers = ["Stale truth", "Stale lie"] - current_round_question.save(update_fields=["shown_at", "mixed_answers"]) - LieAnswer.objects.create(round_question=current_round_question, player=self.player_one, text="Stale lie") - Guess.objects.create( - round_question=current_round_question, - player=self.player_two, - selected_text="Stale truth", - is_correct=True, - ) - - response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) - - self.assertEqual(response.status_code, 200) - self.session.refresh_from_db() - current_round_question.refresh_from_db() - self.assertEqual(self.session.status, GameSession.Status.LIE) - self.assertEqual(self.session.current_round, 2) - payload = response.json() - self.assertEqual(payload["round_question"]["id"], RoundQuestion.objects.get(session=self.session, round_number=2).id) - self.assertEqual(payload["round_question"]["prompt"], self.next_question.prompt) - self.assertEqual(current_round_question.round_number, 1) - self.assertEqual(current_round_question.question_id, self.question.id) - self.assertEqual(current_round_question.shown_at, stale_shown_at) - self.assertEqual(current_round_question.mixed_answers, ["Stale truth", "Stale lie"]) - self.assertEqual(current_round_question.lies.count(), 1) - self.assertEqual(current_round_question.guesses.count(), 1) - detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() - self.assertEqual(detail_payload["round_question"]["id"], payload["round_question"]["id"]) - self.assertEqual(detail_payload["round_question"]["prompt"], self.next_question.prompt) - mock_sync_broadcast_phase_event.assert_called_once() - self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started") - - @patch("lobby.views.sync_broadcast_phase_event") - def test_start_next_round_is_idempotent_after_transition_to_lie(self, mock_sync_broadcast_phase_event): - self.client.login(username="host_reveal", password="secret123") - self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) - mock_sync_broadcast_phase_event.reset_mock() - - first_response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) - second_response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) - - self.assertEqual(first_response.status_code, 200) - self.assertEqual(second_response.status_code, 200) - self.assertEqual(first_response.json(), second_response.json()) - self.assertEqual(second_response.json()["session"]["status"], GameSession.Status.LIE) - self.assertEqual(second_response.json()["session"]["current_round"], 2) - - self.session.refresh_from_db() - self.assertEqual(self.session.status, GameSession.Status.LIE) - self.assertEqual(self.session.current_round, 2) - self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1) - self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=2).count(), 1) - mock_sync_broadcast_phase_event.assert_called_once() - self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started") - - def test_start_next_round_rejects_plain_lie_phase_without_prior_scoreboard_transition(self): - self.client.login(username="host_reveal", password="secret123") - ScoreEvent.objects.filter(session=self.session).delete() - self.session.status = GameSession.Status.LIE - self.session.current_round = 2 - self.session.save(update_fields=["status", "current_round"]) - RoundConfig.objects.create(session=self.session, number=2, category=self.category, started_from_scoreboard=False) - RoundQuestion.objects.create( - session=self.session, - round_number=2, - question=self.next_question, - correct_answer=self.next_question.correct_answer, - ) - - response = self.client.post( - reverse( - "lobby:start_next_round", - kwargs={"code": self.session.code}, - ), - HTTP_ACCEPT_LANGUAGE="en", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error_code"], "next_round_invalid_phase") - self.session.refresh_from_db() - self.assertEqual(self.session.status, GameSession.Status.LIE) - self.assertEqual(self.session.current_round, 2) - self.assertEqual(RoundConfig.objects.filter(session=self.session, number=1).count(), 1) - self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1) - self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=1).count(), 1) - self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=2).count(), 1) - - def test_start_next_round_clears_existing_next_round_bootstrap_state(self): - self.client.login(username="host_reveal", password="secret123") - self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) - - stale_shown_at = timezone.now() - timedelta(minutes=10) - stale_round_question = RoundQuestion.objects.create( - session=self.session, - round_number=2, - question=self.next_question, - correct_answer=self.next_question.correct_answer, - shown_at=stale_shown_at, - mixed_answers=["Stale truth", "Stale lie"], - ) - LieAnswer.objects.create(round_question=stale_round_question, player=self.player_one, text="Stale lie") - Guess.objects.create( - round_question=stale_round_question, - player=self.player_two, - selected_text="Stale truth", - is_correct=True, - ) - - response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) - - self.assertEqual(response.status_code, 200) - self.session.refresh_from_db() - stale_round_question.refresh_from_db() - self.assertEqual(self.session.status, GameSession.Status.LIE) - self.assertEqual(self.session.current_round, 2) - response_payload = response.json() - self.assertEqual(response_payload["round_question"]["id"], stale_round_question.id) - self.assertEqual(stale_round_question.mixed_answers, []) - self.assertEqual(stale_round_question.lies.count(), 0) - self.assertEqual(stale_round_question.guesses.count(), 0) - self.assertNotEqual(stale_round_question.shown_at, stale_shown_at) - self.assertGreater(stale_round_question.shown_at, stale_shown_at) - self.assertEqual(response_payload["round_question"]["shown_at"], stale_round_question.shown_at.isoformat()) - expected_deadline = stale_round_question.shown_at + timedelta(seconds=self.round_config.lie_seconds) - self.assertEqual(response_payload["round_question"]["lie_deadline_at"], expected_deadline.isoformat()) - self.assertGreater(expected_deadline, timezone.now()) - - detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() - self.assertEqual(detail_payload["session"]["status"], GameSession.Status.LIE) - - def test_start_next_round_reuses_existing_next_round_config_with_refreshed_canonical_values(self): - self.client.login(username="host_reveal", password="secret123") - self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) - - stale_category = Category.objects.create(name="Sport reveal", slug="sport-reveal", is_active=True) - stale_round_config = RoundConfig.objects.create( - session=self.session, - number=2, - category=stale_category, - lie_seconds=12, - guess_seconds=18, - points_correct=9, - points_bluff=7, - started_from_scoreboard=False, - ) - stale_round_question = RoundQuestion.objects.create( - session=self.session, - round_number=2, - question=self.next_question, - correct_answer=self.next_question.correct_answer, - shown_at=timezone.now() - timedelta(minutes=10), - mixed_answers=["Stale truth"], - ) - - response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) - - self.assertEqual(response.status_code, 200) - self.session.refresh_from_db() - stale_round_config.refresh_from_db() - stale_round_question.refresh_from_db() - self.assertEqual(self.session.status, GameSession.Status.LIE) - self.assertEqual(self.session.current_round, 2) - self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1) - self.assertEqual(stale_round_config.category_id, self.round_config.category_id) - self.assertEqual(stale_round_config.lie_seconds, self.round_config.lie_seconds) - self.assertEqual(stale_round_config.guess_seconds, self.round_config.guess_seconds) - self.assertEqual(stale_round_config.points_correct, self.round_config.points_correct) - self.assertEqual(stale_round_config.points_bluff, self.round_config.points_bluff) - self.assertTrue(stale_round_config.started_from_scoreboard) - self.assertEqual(response.json()["round_question"]["id"], stale_round_question.id) - self.assertEqual(response.json()["config"]["lie_seconds"], self.round_config.lie_seconds) - expected_deadline = stale_round_question.shown_at + timedelta(seconds=self.round_config.lie_seconds) - self.assertEqual(response.json()["round_question"]["lie_deadline_at"], expected_deadline.isoformat()) - detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() - self.assertEqual(detail_payload["session"]["current_round"], 2) - self.assertEqual(detail_payload["round_question"]["id"], stale_round_question.id) - self.assertEqual(detail_payload["round_question"]["answers"], []) - self.assertIsNone(detail_payload["reveal"]) - self.assertIsNone(detail_payload["scoreboard"]) - - def test_start_next_round_repairs_reused_bootstrap_question_with_drifted_category(self): - self.client.login(username="host_reveal", password="secret123") - self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) - - stale_category = Category.objects.create(name="Drift reveal", slug="drift-reveal", is_active=True) - stale_question = Question.objects.create( - category=stale_category, - prompt="Hvem vandt EM i 1992?", - correct_answer="Danmark", - is_active=True, - ) - stale_round_question = RoundQuestion.objects.create( - session=self.session, - round_number=2, - question=stale_question, - correct_answer=stale_question.correct_answer, - shown_at=timezone.now() - timedelta(minutes=10), - mixed_answers=["Stale truth", "Stale lie"], - ) - LieAnswer.objects.create(round_question=stale_round_question, player=self.player_one, text="Tyskland") - Guess.objects.create( - round_question=stale_round_question, - player=self.player_two, - selected_text="Stale truth", - is_correct=True, - ) - - response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) - - self.assertEqual(response.status_code, 200) - self.session.refresh_from_db() - stale_round_question.refresh_from_db() - self.assertEqual(self.session.status, GameSession.Status.LIE) - self.assertEqual(self.session.current_round, 2) - self.assertEqual(stale_round_question.question.category_id, self.round_config.category_id) - self.assertEqual(stale_round_question.question_id, self.next_question.id) - self.assertEqual(stale_round_question.correct_answer, self.next_question.correct_answer) - self.assertEqual(stale_round_question.mixed_answers, []) - self.assertEqual(stale_round_question.lies.count(), 0) - self.assertEqual(stale_round_question.guesses.count(), 0) - payload = response.json() - self.assertEqual(payload["round_question"]["id"], stale_round_question.id) - self.assertEqual(payload["round_question"]["prompt"], self.next_question.prompt) - detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() - self.assertEqual(detail_payload["round_question"]["id"], stale_round_question.id) - self.assertEqual(detail_payload["round_question"]["prompt"], self.next_question.prompt) - - def test_start_next_round_requires_host(self): - self.session.status = GameSession.Status.SCOREBOARD - self.session.save(update_fields=["status"]) - self.client.login(username="other_reveal", password="secret123") - - response = self.client.post( - reverse( - "lobby:start_next_round", - kwargs={"code": self.session.code}, - ), - HTTP_ACCEPT_LANGUAGE="fr", - ) - - self.assertEqual(response.status_code, 403) - self.assertEqual(response.json(), { - "error": "Only host can start next round", - "error_code": "host_only_start_next_round", - "locale": "en", - }) - - @patch("lobby.views.sync_broadcast_phase_event") - def test_reveal_scoreboard_allows_repeated_reads_after_promotion(self, mock_sync_broadcast_phase_event): - self.client.login(username="host_reveal", password="secret123") - - first_response = self.client.get( - reverse( - "lobby:reveal_scoreboard", - kwargs={"code": self.session.code}, - ) - ) - second_response = self.client.get( - reverse( - "lobby:reveal_scoreboard", - kwargs={"code": self.session.code}, - ) - ) - - self.assertEqual(first_response.status_code, 200) - self.assertEqual(second_response.status_code, 200) - self.assertEqual(first_response.json()["session"]["status"], GameSession.Status.SCOREBOARD) - self.assertEqual(second_response.json()["session"]["status"], GameSession.Status.SCOREBOARD) - self.assertEqual([item["nickname"] for item in second_response.json()["leaderboard"]], ["Luna", "Mads"]) - self.assertEqual(mock_sync_broadcast_phase_event.call_count, 1) - - def test_start_next_round_rejects_wrong_phase(self): - self.client.login(username="host_reveal", password="secret123") - self.session.status = GameSession.Status.GUESS - self.session.save(update_fields=["status"]) - - response = self.client.post( - reverse( - "lobby:start_next_round", - kwargs={"code": self.session.code}, - ), - HTTP_ACCEPT_LANGUAGE="da", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error_code"], "next_round_invalid_phase") - self.assertEqual(response.json()["locale"], "da") - self.assertEqual(response.json()["error"], "Næste runde kan kun starte fra scoreboard-fasen") - - def test_start_next_round_does_not_persist_round_config_when_question_selection_fails(self): - self.client.login(username="host_reveal", password="secret123") - self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) - self.next_question.delete() - - response = self.client.post( - reverse( - "lobby:start_next_round", - kwargs={"code": self.session.code}, - ) - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error_code"], "no_available_questions") - self.session.refresh_from_db() - self.assertEqual(self.session.status, GameSession.Status.SCOREBOARD) - self.assertEqual(self.session.current_round, 1) - self.assertFalse(RoundConfig.objects.filter(session=self.session, number=2).exists()) - self.assertFalse(RoundQuestion.objects.filter(session=self.session, round_number=2).exists()) - - def test_reveal_scoreboard_unsupported_locale_falls_back_to_en_deterministically(self): - self.client.login(username="other_reveal", password="secret123") - - response = self.client.get( - reverse( - "lobby:reveal_scoreboard", - kwargs={"code": self.session.code}, - ), - HTTP_ACCEPT_LANGUAGE="fr-FR,fr;q=0.9", - ) - - self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error_code"], "host_only_view_scoreboard") - self.assertEqual(response.json()["locale"], "en") - self.assertEqual(response.json()["error"], "Only host can view scoreboard") - -class UiScreenTests(TestCase): - def setUp(self): - self.host = User.objects.create_user(username="host_ui", password="secret123") - - def test_host_screen_requires_login(self): - response = self.client.get(reverse("lobby:host_screen")) - self.assertEqual(response.status_code, 302) - - def test_host_screen_renders_for_logged_in_user(self): - self.client.login(username="host_ui", password="secret123") - response = self.client.get(reverse("lobby:host_screen")) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "Host panel") - self.assertContains(response, "id=\"createSessionBtn\"") - self.assertContains(response, "id=\"createSessionHint\"") - self.assertContains(response, "saveHostContext") - self.assertContains(response, "restoreHostContext") - self.assertContains(response, "id=\"roundQuestionStatus\"") - self.assertContains(response, "id=\"hostActionHint\"") - self.assertContains(response, "id=\"categoryGuardHint\"") - - self.assertContains(response, "id=\"phaseStatus\"") - self.assertContains(response, "updateHostActionState") - self.assertContains(response, "phaseLabel") - self.assertContains(response, "Opdatér session-status for fasebaserede host-actions.") - self.assertContains(response, "Angiv sessionkode for at aktivere host-actions.") - self.assertContains(response, "Kategori er kun redigérbar i lobby-fasen.") - self.assertContains(response, "Kræver 3-5 spillere i lobbyen.") - self.assertContains(response, "For mange spillere: maks 5 i MVP før runde-start.") - self.assertContains(response, "Round question-id styres server-side i canonical flow og er kun read-only kontekst for host.") - self.assertContains(response, "Round question-id styres server-side i canonical flow og er read-only i fase:") - self.assertContains(response, "categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==\"lobby\"") - self.assertContains(response, "hostActionInFlight") - self.assertContains(response, "withHostActionLock") - self.assertContains(response, "updateCreateSessionState") - self.assertContains(response, "btn.disabled=hostActionInFlight||sessionDetailInFlight") - self.assertContains(response, "Opret session er låst mens en host-handling kører.") - self.assertContains(response, "Opret session er låst mens session-opdatering kører.") - self.assertContains(response, "Handling kører… afvent svar før næste klik.") - self.assertContains(response, "Session-data ikke opdateret endnu.") - self.assertContains(response, "Sidst opdateret:") - self.assertContains(response, "Session-data kan være forældet") - self.assertContains(response, "id=\"sessionDetailBtn\"") - self.assertContains(response, "id=\"sessionDetailHint\"") - self.assertContains(response, "updateSessionDetailState") - self.assertContains(response, "sessionDetailInFlight") - self.assertContains(response, "session_detail_in_flight") - self.assertContains(response, "codeInput.readOnly=sessionDetailInFlight||hostActionInFlight") - self.assertContains(response, "id=\"autoRefreshToggleBtn\"") - self.assertContains(response, "id=\"autoRefreshHint\"") - self.assertContains(response, "btn.disabled=hostActionInFlight||sessionDetailInFlight||!code()") - self.assertContains(response, "Auto-refresh-lås: afvent aktiv host-handling.") - self.assertContains(response, "Auto-refresh-lås: afvent aktiv session-opdatering.") - self.assertContains(response, "Opdaterer session-status…") - self.assertContains(response, "Session-opdatering er låst mens en host-handling kører.") - self.assertContains(response, "Angiv sessionkode for at opdatere session-status.") - self.assertContains(response, "markSessionRefresh") - self.assertContains(response, "updateLastRefreshStatus") - self.assertContains(response, "isSessionDetailRead") - self.assertContains(response, "Mid-round faseskift er server-styrede i canonical flow. Host monitorerer kun fremdrift i fase:") - self.assertContains(response, "Host-actions er klar: vælg næste runde eller afslut spillet.") - self.assertContains(response, 'if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}') - self.assertContains(response, "Host-actions er låst mens session-opdatering kører.") - self.assertContains(response, "Round question-id er låst mens session-opdatering kører.") - self.assertContains(response, "Kategori er låst mens session-opdatering kører.") - self.assertContains(response, "categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==") - self.assertContains(response, "hostShellRouteFromPath") - self.assertContains(response, "syncHostShellRoute") - self.assertContains(response, "Deep-link route guard: omdirigeret") - self.assertContains(response, "id=\"hostShellErrorBoundary\"") - self.assertContains(response, "recoverHostShell('retry')") - self.assertContains(response, "recoverHostShell('reload')") - self.assertContains(response, "setHostShellFatalError") - self.assertContains(response, "clearHostShellFatalError") - self.assertContains(response, "updateHostShellErrorBoundary") - self.assertContains(response, "host_shell_runtime_error") - self.assertContains(response, "window.addEventListener(\"unhandledrejection\"") - - def test_host_screen_deeplink_requires_login(self): - response = self.client.get(reverse("lobby:host_screen_deeplink", kwargs={"spa_path": "guess"})) - self.assertEqual(response.status_code, 302) - - def test_host_screen_deeplink_renders_for_logged_in_user(self): - self.client.login(username="host_ui", password="secret123") - response = self.client.get(reverse("lobby:host_screen_deeplink", kwargs={"spa_path": "guess"})) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "Host panel") - - def test_player_screen_is_public(self): - response = self.client.get(reverse("lobby:player_screen")) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "Player panel") - self.assertContains(response, "id=\"sessionToken\"") - self.assertContains(response, "session_token") - self.assertContains(response, "id=\"answerOptions\"") - self.assertContains(response, "renderAnswerOptions") - self.assertContains(response, "renderAnswerOptions(null)") - self.assertContains(response, "availableAnswers") - self.assertContains(response, "guessStorageKey") - self.assertContains(response, "persistGuessState") - self.assertContains(response, "savePlayerContext") - self.assertContains(response, "restorePlayerContext") - self.assertContains(response, "id=\"lieSubmitBtn\"") - self.assertContains(response, "id=\"lieStatus\"") - self.assertContains(response, "id=\"phaseStatus\"") - self.assertContains(response, "currentSessionStatus") - self.assertContains(response, "updatePhaseStatus") - self.assertContains(response, "Løgn-input er låst i fase") - self.assertContains(response, "Gæt er låst i fase") - self.assertContains(response, "Afvent aktivt spørgsmål fra host før du kan gætte.") - self.assertContains(response, "persistLieState") - self.assertContains(response, "updateLieSubmitState") - self.assertContains(response, "hasSubmitContext") - self.assertContains(response, "hasRoundQuestionContext") - self.assertContains(response, "canAttemptJoin") - self.assertContains(response, "missing_join_input") - self.assertContains(response, "Udfyld kode og nickname for at join.") - self.assertContains(response, "Afvent aktiv session-opdatering før join.") - self.assertContains(response, "btn.disabled=joinInFlight||sessionDetailInFlight||joined||!canJoin") - self.assertContains(response, "id=\"connectionBanner\"") - self.assertContains(response, "id=\"connectionRetryBtn\"") - self.assertContains(response, "retryConnection") - self.assertContains(response, "setConnectionLost") - self.assertContains(response, "connection_lost") - self.assertContains(response, "id=\"contextLockHint\"") - self.assertContains(response, "updateContextLockState") - self.assertContains(response, "Spillerkontekst er låst efter join.") - self.assertContains(response, "already_joined_client") - self.assertContains(response, "missing_submit_context") - self.assertContains(response, "invalid_client_guess") - self.assertContains(response, "lieSubmitInFlight") - self.assertContains(response, "guessSubmitInFlight") - self.assertContains(response, "Sender løgn…") - self.assertContains(response, "Sender gæt…") - self.assertContains(response, "Afvent aktiv session-opdatering før løgn-submit.") - self.assertContains(response, "Afvent aktiv session-opdatering før gæt-submit.") - self.assertContains(response, "sessionDetailInFlight||!hasValid||!hasContext||!hasRoundContext||!inGuessPhase") - self.assertContains(response, "btn.disabled=guessSubmitted||guessSubmitInFlight||sessionDetailInFlight") - self.assertContains(response, "lie_submit_in_flight") - self.assertContains(response, "guess_submit_in_flight") - self.assertContains(response, "guess_already_submitted_client") - self.assertContains(response, "id=\"playerAutoRefreshToggleBtn\"") - self.assertContains(response, "id=\"playerAutoRefreshHint\"") - self.assertContains(response, "id=\"playerLastRefreshStatus\"") - self.assertContains(response, "id=\"sessionDetailBtn\"") - self.assertContains(response, "id=\"sessionRefreshHint\"") - self.assertContains(response, "id=\"roundContextHint\"") - self.assertContains(response, "resetRoundContextForManualChange") - self.assertContains(response, "Runde-kontekst afventer session-opdatering.") - self.assertContains(response, "togglePlayerAutoRefresh") - self.assertContains(response, "btn.disabled=sessionDetailInFlight||joinInFlight||submitInFlight||!code()") - self.assertContains(response, "Auto-refresh-lås: afvent aktiv session-opdatering.") - self.assertContains(response, "Auto-refresh-lås: afvent aktiv join.") - self.assertContains(response, "Auto-refresh kræver sessionkode.") - self.assertContains(response, "markPlayerSessionRefresh") - self.assertContains(response, "updatePlayerLastRefreshStatus") - self.assertContains(response, "updateSessionDetailState") - self.assertContains(response, "session_detail_in_flight") - self.assertContains(response, "Opdaterer session-status…") - self.assertContains(response, "Session-opdatering er låst mens join kører.") - self.assertContains(response, "Session-opdatering er låst mens submit kører.") - self.assertContains(response, "Session-data ikke opdateret endnu.") - self.assertContains(response, "Sidst opdateret:") - self.assertContains(response, "Session-data kan være forældet") - self.assertContains(response, "id=\"playerShellErrorBoundary\"") - self.assertContains(response, "recoverPlayerShell('retry')") - self.assertContains(response, "recoverPlayerShell('reload')") - self.assertContains(response, "setPlayerShellFatalError") - self.assertContains(response, "clearPlayerShellFatalError") - self.assertContains(response, "updatePlayerShellErrorBoundary") - self.assertContains(response, "player_shell_runtime_error") - self.assertContains(response, "installSecondaryDeviceAudioGuard") - self.assertContains(response, "silencePlayerMediaElements") - self.assertContains(response, "querySelectorAll(\"audio,video\")") - self.assertNotContains(response, "") - self.assertEqual(payload["players"], ["P1", "P2", "P3"]) - self.assertIn("generated_at", payload) - self.assertIn("session_code", payload) - self.assertEqual(payload["question"]["correct_answer"], "Correct") - self.assertEqual(payload["guess_plan"]["P2"], "Correct") - - step_names = [step["step"] for step in payload["steps"]] - self.assertEqual( - step_names, - [ - "create_session", - "join_players", - "start_round", - "auto_guess_transition", - "submit_guesses", - "auto_reveal_transition", - "auto_scoreboard_transition", - "finish_game", - ], - ) - - reveal_step = payload["steps"][5] - self.assertEqual(reveal_step["session_status"], GameSession.Status.REVEAL) - self.assertEqual(reveal_step["reveal"]["correct_answer"], "Correct") - self.assertEqual(reveal_step["reveal"]["lies_count"], 3) - self.assertEqual(reveal_step["reveal"]["guesses_count"], 3) - self.assertEqual(len(reveal_step["reveal"]["fooled_player_ids"]), 2) - self.assertEqual(len(reveal_step["reveal"]["correct_guess_player_ids"]), 1) - - scoreboard_step = payload["steps"][6] - self.assertEqual(scoreboard_step["session_status"], GameSession.Status.SCOREBOARD) - self.assertEqual(len(scoreboard_step["leaderboard"]), 3) - - -class I18nResolverTests(TestCase): - def test_resolve_locale_accepts_language_tags_and_normalizes_to_supported_base_locale(self): - response = self.client.post( - reverse("lobby:join_session"), - data={"code": "", "nickname": "Luna"}, - content_type="application/json", - HTTP_ACCEPT_LANGUAGE="da-DK,da;q=0.9,en;q=0.8", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(resolve_locale(response.wsgi_request), "da") - - def test_resolve_locale_accepts_underscore_language_tags(self): - response = self.client.post( - reverse("lobby:join_session"), - data={"code": "", "nickname": "Luna"}, - content_type="application/json", - HTTP_ACCEPT_LANGUAGE="da_DK", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(resolve_locale(response.wsgi_request), "da") - - def test_resolve_locale_uses_next_supported_language_when_primary_is_unsupported(self): - response = self.client.post( - reverse("lobby:join_session"), - data={"code": "", "nickname": "Luna"}, - content_type="application/json", - HTTP_ACCEPT_LANGUAGE="fr-FR, da;q=0.9, en;q=0.8", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(resolve_locale(response.wsgi_request), "da") - - def test_resolve_locale_skips_q0_languages_and_falls_back_to_next_supported(self): - response = self.client.post( - reverse("lobby:join_session"), - data={"code": "", "nickname": "Luna"}, - content_type="application/json", - HTTP_ACCEPT_LANGUAGE="da;q=0, en;q=0.8", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(resolve_locale(response.wsgi_request), "en") - - def test_resolve_locale_prefers_highest_quality_supported_language(self): - response = self.client.post( - reverse("lobby:join_session"), - data={"code": "", "nickname": "Luna"}, - content_type="application/json", - HTTP_ACCEPT_LANGUAGE="da;q=0.8, en;q=0.9", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(resolve_locale(response.wsgi_request), "en") - - def test_resolve_locale_defaults_to_en_when_header_missing(self): - response = self.client.post( - reverse("lobby:join_session"), - data={"code": "", "nickname": "Luna"}, - content_type="application/json", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual(resolve_locale(response.wsgi_request), "en") - - def test_missing_backend_key_returns_key_deterministically(self): - self.assertEqual(resolve_error_message(key="missing_key", locale="da"), "missing_key") - - def test_missing_backend_key_is_logged_with_context(self): - with self.assertLogs("lobby.i18n", level="WARNING") as logs: - result = resolve_error_message(key="missing_key", locale="da") - - self.assertEqual(result, "missing_key") - self.assertTrue(any("i18n key missing in shared catalog" in entry for entry in logs.output)) - - def test_missing_backend_error_code_is_logged_with_context(self): - from lobby.i18n import resolve_error_key - - with self.assertLogs("lobby.i18n", level="WARNING") as logs: - result = resolve_error_key("missing_code") - - self.assertEqual(result, "missing_code") - self.assertTrue(any("i18n error code missing in shared catalog" in entry for entry in logs.output)) - - def test_missing_locale_translation_falls_back_to_default_locale(self): - with patch( - "lobby.i18n.lobby_i18n_error_messages", - return_value={"session_code_required": {"en": "Session code is required"}}, - ): - self.assertEqual( - resolve_error_message(key="session_code_required", locale="da"), - "Session code is required", - ) - - def test_shared_catalog_uses_en_default_and_da_en_matrix(self): - default_locale, supported_locales = i18n_locale_config() - catalog = lobby_i18n_catalog() - - self.assertEqual(default_locale, "en") - self.assertIn("en", supported_locales) - self.assertIn("da", supported_locales) - - for key, translations in catalog["backend"]["errors"].items(): - self.assertTrue(translations.get("en"), f"backend key {key} missing en") - self.assertTrue(translations.get("da"), f"backend key {key} missing da") - - for key, translations in catalog["frontend"]["errors"].items(): - self.assertTrue(translations.get("en"), f"frontend key {key} missing en") - self.assertTrue(translations.get("da"), f"frontend key {key} missing da") - - def test_backend_error_codes_map_via_shared_backend_frontend_key_map(self): - catalog = lobby_i18n_catalog() - backend_errors = catalog["backend"]["errors"] - frontend_errors = catalog["frontend"]["errors"] - shared_map = catalog["contract"]["backend_to_frontend_error_keys"] - - for code, backend_key in catalog["backend"]["error_codes"].items(): - frontend_key = shared_map.get(code) - self.assertIn(backend_key, backend_errors) - self.assertTrue(frontend_key, f"missing frontend mapping for backend code: {code}") - self.assertIn(frontend_key, frontend_errors) diff --git a/lobby/ui_views.py b/lobby/ui_views.py deleted file mode 100644 index ba94cc6..0000000 --- a/lobby/ui_views.py +++ /dev/null @@ -1,56 +0,0 @@ -from django.conf import settings -from django.contrib.auth.decorators import login_required -from django.shortcuts import render - -from fupogfakta.models import Category - -from .feature_flags import use_spa_ui -from .i18n import lobby_i18n_catalog, resolve_locale - - -def _render_spa_shell(request, shell_route: str, shell_kind: str): - return render( - request, - "lobby/spa_shell.html", - { - "shell_route": shell_route, - "shell_kind": shell_kind, - "spa_asset_base": settings.WPP_SPA_ASSET_BASE, - "spa_asset_version": getattr(settings, "WPP_SPA_ASSET_VERSION", "dev"), - "lobby_i18n": lobby_i18n_catalog(), - "shell_locale": resolve_locale(request), - }, - ) - - -@login_required -def host_screen(request, spa_path=None): - if use_spa_ui(): - host_route = "/host" - if spa_path: - normalized_spa_path = "/".join(segment for segment in spa_path.split("/") if segment) - if normalized_spa_path: - host_route = f"/host/{normalized_spa_path}" - return _render_spa_shell(request, host_route, "host") - - categories = Category.objects.filter(is_active=True).order_by("name") - return render( - request, - "lobby/host_screen.html", - { - "categories": categories, - "lobby_i18n": lobby_i18n_catalog(), - "shell_locale": resolve_locale(request), - }, - ) - - -def player_screen(request): - if use_spa_ui(): - return _render_spa_shell(request, "/player", "player") - - return render( - request, - "lobby/player_screen.html", - {"lobby_i18n": lobby_i18n_catalog(), "shell_locale": resolve_locale(request)}, - ) diff --git a/lobby/urls.py b/lobby/urls.py deleted file mode 100644 index 7a44918..0000000 --- a/lobby/urls.py +++ /dev/null @@ -1,39 +0,0 @@ -from django.urls import path - -from . import ui_views, views - -app_name = "lobby" - -urlpatterns = [ - path("ui/host", ui_views.host_screen, name="host_screen"), - path("ui/host/", ui_views.host_screen, name="host_screen_deeplink"), - path("ui/player", ui_views.player_screen, name="player_screen"), - path("sessions/create", views.create_session, name="create_session"), - path("sessions/join", views.join_session, name="join_session"), - path("sessions/", views.session_detail, name="session_detail"), - path("sessions//rounds/start", views.start_round, name="start_round"), - path("sessions//questions/show", views.show_question, name="show_question"), - path( - "sessions//questions//lies/submit", - views.submit_lie, - name="submit_lie", - ), - path( - "sessions//questions//answers/mix", - views.mix_answers, - name="mix_answers", - ), - path( - "sessions//questions//guesses/submit", - views.submit_guess, - name="submit_guess", - ), - path( - "sessions//questions//scores/calculate", - views.calculate_scores, - name="calculate_scores", - ), - path("sessions//scoreboard", views.reveal_scoreboard, name="reveal_scoreboard"), - path("sessions//finish", views.finish_game, name="finish_game"), - path("sessions//rounds/next", views.start_next_round, name="start_next_round"), -] diff --git a/lobby/views.py b/lobby/views.py deleted file mode 100644 index 251888d..0000000 --- a/lobby/views.py +++ /dev/null @@ -1,891 +0,0 @@ -from datetime import timedelta - -import json -import random -from django.contrib.auth.decorators import login_required -from django.db import IntegrityError, transaction -from django.http import HttpRequest, JsonResponse -from django.utils import timezone -from django.views.decorators.http import require_GET, require_POST - -from fupogfakta.models import GameSession, Guess, LieAnswer, Player, RoundConfig, RoundQuestion, ScoreEvent -from fupogfakta.payloads import ( - build_leaderboard as _build_leaderboard, - build_reveal_payload as _build_reveal_payload, - build_scoreboard_phase_event as _build_scoreboard_phase_event, - build_session_detail_gameplay_payload as _build_session_detail_gameplay_payload, -) -from fupogfakta.services import ( - finish_game as _finish_game, - get_current_round_question as _get_current_round_question, - prepare_mixed_answers as _prepare_mixed_answers, - promote_reveal_to_scoreboard as _promote_reveal_to_scoreboard, - resolve_scores as _resolve_scores, - select_round_question as _select_round_question, - show_question as _show_question, - start_next_round as _start_next_round, - start_round as _start_round, -) -from realtime.broadcast import sync_broadcast_phase_event -from .i18n import api_error - -_GAMEPLAY_SERVICE_OWNERSHIP_EXPORTS = ( - _select_round_question, - _build_scoreboard_phase_event, -) - -SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" -SESSION_CODE_LENGTH = 6 -MAX_CODE_GENERATION_ATTEMPTS = 20 -JOINABLE_STATUSES = { - GameSession.Status.LOBBY, - GameSession.Status.LIE, - GameSession.Status.GUESS, - GameSession.Status.REVEAL, - GameSession.Status.SCOREBOARD, -} - - - - -def _json_body(request: HttpRequest) -> dict: - if not request.body: - return {} - - try: - return json.loads(request.body) - except json.JSONDecodeError: - return {} - - -def _generate_session_code() -> str: - return "".join(random.choices(SESSION_CODE_ALPHABET, k=SESSION_CODE_LENGTH)) - - -def _normalize_session_code(code: str) -> str: - return code.strip().upper() - - -def _create_unique_session_code() -> str: - for _ in range(MAX_CODE_GENERATION_ATTEMPTS): - code = _generate_session_code() - if not GameSession.objects.filter(code=code).exists(): - return code - - raise RuntimeError("Could not generate unique session code") - - - -def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession: - transition = _promote_reveal_to_scoreboard(session) - if transition.should_broadcast: - sync_broadcast_phase_event( - transition.session.code, - transition.phase_event_name, - transition.phase_event_payload, - ) - return transition.session - - - -@require_POST -@login_required -def create_session(request: HttpRequest) -> JsonResponse: - code = _create_unique_session_code() - session = GameSession.objects.create(host=request.user, code=code) - - return JsonResponse( - { - "session": { - "code": session.code, - "status": session.status, - "host_id": session.host_id, - "current_round": session.current_round, - } - }, - status=201, - ) - - -@require_POST -def join_session(request: HttpRequest) -> JsonResponse: - payload = _json_body(request) - - code = _normalize_session_code(str(payload.get("code", ""))) - nickname = str(payload.get("nickname", "")).strip() - - if not code: - return api_error( - request, - code="session_code_required", - status=400, - ) - - if len(nickname) < 2 or len(nickname) > 40: - return api_error( - request, - code="nickname_invalid", - status=400, - ) - - try: - session = GameSession.objects.get(code=code) - except GameSession.DoesNotExist: - return api_error( - request, - code="session_not_found", - status=404, - ) - - if session.status not in JOINABLE_STATUSES: - return api_error( - request, - code="session_not_joinable", - status=400, - ) - - if Player.objects.filter(session=session, nickname__iexact=nickname).exists(): - return api_error( - request, - code="nickname_taken", - status=409, - ) - - player = Player.objects.create(session=session, nickname=nickname) - - return JsonResponse( - { - "player": { - "id": player.id, - "nickname": player.nickname, - "session_token": player.session_token, - "score": player.score, - }, - "session": { - "code": session.code, - "status": session.status, - }, - }, - status=201, - ) - - -@require_GET -def session_detail(request: HttpRequest, code: str) -> JsonResponse: - session_code = _normalize_session_code(code) - - try: - session = GameSession.objects.get(code=session_code) - except GameSession.DoesNotExist: - return api_error( - request, - code="session_not_found", - status=404, - ) - - players = list( - session.players.order_by("nickname").values( - "id", - "nickname", - "score", - "is_connected", - ) - ) - - session = _maybe_promote_reveal_to_scoreboard(session) - current_round_question = _get_current_round_question(session) - gameplay_payload = _build_session_detail_gameplay_payload( - session, - current_round_question=current_round_question, - players_count=len(players), - ) - - return JsonResponse( - { - "session": { - "code": session.code, - "status": session.status, - "host_id": session.host_id, - "current_round": session.current_round, - "players_count": len(players), - }, - "players": players, - **gameplay_payload, - } - ) - - -@require_POST -@login_required -def start_round(request: HttpRequest, code: str) -> JsonResponse: - payload = _json_body(request) - category_slug = str(payload.get("category_slug", "")).strip() - - if not category_slug: - return api_error( - request, - code="category_slug_required", - status=400, - ) - - session_code = _normalize_session_code(code) - - try: - session = GameSession.objects.get(code=session_code) - except GameSession.DoesNotExist: - return api_error( - request, - code="session_not_found", - status=404, - ) - - if session.host_id != request.user.id: - return api_error( - request, - code="host_only_start_round", - status=403, - ) - - try: - transition = _start_round(session, category_slug) - except ValueError as exc: - error_code = str(exc) - error_status = { - "category_not_found": 404, - "round_already_configured": 409, - }.get(error_code, 400) - return api_error(request, code=error_code, status=error_status) - - sync_broadcast_phase_event( - transition.session.code, - transition.phase_event_name, - transition.phase_event_payload, - ) - - return JsonResponse(transition.response_payload, status=201) - - -@require_POST -@login_required -def show_question(request: HttpRequest, code: str) -> JsonResponse: - session_code = _normalize_session_code(code) - - try: - session = GameSession.objects.get(code=session_code) - except GameSession.DoesNotExist: - return api_error( - request, - code="session_not_found", - status=404, - ) - - if session.host_id != request.user.id: - return api_error( - request, - code="host_only_show_question", - status=403, - ) - - try: - transition = _show_question(session) - except ValueError as exc: - return api_error(request, code=str(exc), status=400) - - sync_broadcast_phase_event( - transition.session.code, - transition.phase_event_name, - transition.phase_event_payload, - ) - - return JsonResponse(transition.response_payload, status=201) - - -@require_POST -def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse: - payload = _json_body(request) - session_code = _normalize_session_code(code) - - player_id = payload.get("player_id") - session_token = str(payload.get("session_token", "")).strip() - lie_text = str(payload.get("text", "")).strip() - - if not player_id: - return api_error(request, code="player_id_required", status=400) - - if not session_token: - return api_error(request, code="session_token_required", status=400) - - if not lie_text or len(lie_text) > 255: - return api_error(request, code="lie_text_invalid", status=400) - - try: - session = GameSession.objects.get(code=session_code) - except GameSession.DoesNotExist: - return api_error(request, code="session_not_found", status=404) - - if session.status != GameSession.Status.LIE: - return api_error(request, code="lie_submission_invalid_phase", status=400) - - try: - player = Player.objects.get(pk=player_id, session=session) - except Player.DoesNotExist: - return api_error(request, code="player_not_found_in_session", status=404) - - if player.session_token != session_token: - return api_error(request, code="invalid_player_session_token", status=403) - - try: - round_question = RoundQuestion.objects.get( - pk=round_question_id, - session=session, - round_number=session.current_round, - ) - except RoundQuestion.DoesNotExist: - return api_error(request, code="round_question_not_found", status=404) - - try: - round_config = RoundConfig.objects.get(session=session, number=round_question.round_number) - except RoundConfig.DoesNotExist: - return api_error(request, code="round_config_missing", status=400) - - lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) - if timezone.now() > lie_deadline_at: - return api_error(request, code="lie_submission_closed", status=400) - - try: - lie = LieAnswer.objects.create(round_question=round_question, player=player, text=lie_text) - except IntegrityError: - return api_error(request, code="lie_already_submitted", status=409) - - players_count = Player.objects.filter(session=session).count() - lie_count = LieAnswer.objects.filter(round_question=round_question).count() - session_status = session.status - mixed_answers_payload = None - - if players_count > 0 and lie_count >= players_count: - try: - mixed_answers = _prepare_mixed_answers(round_question) - except ValueError as exc: - return api_error(request, code=str(exc), status=400) - - session.status = GameSession.Status.GUESS - session.save(update_fields=["status"]) - session_status = session.status - mixed_answers_payload = [{"text": text} for text in mixed_answers] - sync_broadcast_phase_event( - session.code, - "phase.guess_started", - { - "round_question_id": round_question.id, - "answers": mixed_answers_payload, - "guess_seconds": round_config.guess_seconds, - }, - ) - - return JsonResponse( - { - "lie": { - "id": lie.id, - "player_id": player.id, - "round_question_id": round_question.id, - "text": lie.text, - "created_at": lie.created_at.isoformat(), - }, - "window": { - "lie_deadline_at": lie_deadline_at.isoformat(), - }, - "session": { - "code": session.code, - "status": session_status, - "current_round": session.current_round, - }, - "phase_transition": { - "current_phase": session_status, - "lies_submitted": lie_count, - "players_expected": players_count, - "auto_advanced": session_status == GameSession.Status.GUESS, - }, - "answers": mixed_answers_payload, - }, - status=201, - ) - -@require_POST -@login_required -def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse: - session_code = _normalize_session_code(code) - - try: - session = GameSession.objects.get(code=session_code) - except GameSession.DoesNotExist: - return api_error( - request, - code="session_not_found", - status=404, - ) - - if session.host_id != request.user.id: - return api_error( - request, - code="host_only_mix_answers", - status=403, - ) - - if session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}: - return api_error( - request, - code="mix_answers_invalid_phase", - status=400, - ) - - try: - round_question = RoundQuestion.objects.get( - pk=round_question_id, - session=session, - round_number=session.current_round, - ) - except RoundQuestion.DoesNotExist: - return api_error( - request, - code="round_question_not_found", - status=404, - ) - - with transaction.atomic(): - locked_session = GameSession.objects.select_for_update().get(pk=session.pk) - if locked_session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}: - return api_error( - request, - code="mix_answers_invalid_phase", - status=400, - ) - - locked_round_question = RoundQuestion.objects.select_for_update().get(pk=round_question.pk) - - try: - deduped_answers = _prepare_mixed_answers(locked_round_question) - except ValueError as exc: - return api_error(request, code=str(exc), status=400) - - if locked_session.status == GameSession.Status.LIE: - locked_session.status = GameSession.Status.GUESS - locked_session.save(update_fields=["status"]) - - try: - _guess_config = RoundConfig.objects.get(session=session, number=session.current_round) - _guess_seconds = _guess_config.guess_seconds - except RoundConfig.DoesNotExist: - _guess_seconds = None - - sync_broadcast_phase_event( - session.code, - "phase.guess_started", - { - "round_question_id": round_question.id, - "answers": [{"text": t} for t in deduped_answers], - "guess_seconds": _guess_seconds, - }, - ) - - return JsonResponse( - { - "session": { - "code": session.code, - "status": GameSession.Status.GUESS, - "current_round": session.current_round, - }, - "round_question": { - "id": round_question.id, - "round_number": round_question.round_number, - }, - "answers": [{"text": text} for text in deduped_answers], - } - ) - - -@require_POST -def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse: - payload = _json_body(request) - session_code = _normalize_session_code(code) - - player_id = payload.get("player_id") - session_token = str(payload.get("session_token", "")).strip() - selected_text = str(payload.get("selected_text", "")).strip() - - if not player_id: - return api_error(request, code="player_id_required", status=400) - - if not session_token: - return api_error(request, code="session_token_required", status=400) - - if not selected_text or len(selected_text) > 255: - return api_error(request, code="selected_text_invalid", status=400) - - try: - session = GameSession.objects.get(code=session_code) - except GameSession.DoesNotExist: - return api_error(request, code="session_not_found", status=404) - - if session.status != GameSession.Status.GUESS: - return api_error(request, code="guess_submission_invalid_phase", status=400) - - try: - player = Player.objects.get(pk=player_id, session=session) - except Player.DoesNotExist: - return api_error(request, code="player_not_found_in_session", status=404) - - if player.session_token != session_token: - return api_error(request, code="invalid_player_session_token", status=403) - - try: - round_question = RoundQuestion.objects.get( - pk=round_question_id, - session=session, - round_number=session.current_round, - ) - except RoundQuestion.DoesNotExist: - return api_error(request, code="round_question_not_found", status=404) - - try: - round_config = RoundConfig.objects.get(session=session, number=round_question.round_number) - except RoundConfig.DoesNotExist: - return api_error(request, code="round_config_missing", status=400) - - guess_deadline_at = round_question.shown_at + timedelta( - seconds=round_config.lie_seconds + round_config.guess_seconds - ) - if timezone.now() > guess_deadline_at: - return api_error(request, code="guess_submission_closed", status=400) - - allowed_answers = { - round_question.correct_answer.strip().casefold(), - *( - text.strip().casefold() - for text in round_question.lies.values_list("text", flat=True) - if text.strip() - ), - } - - selected_normalized = selected_text.casefold() - if selected_normalized not in allowed_answers: - return api_error(request, code="selected_answer_invalid", status=400) - - correct_normalized = round_question.correct_answer.strip().casefold() - fooled_player_id = None - if selected_normalized != correct_normalized: - fooled_player_id = ( - round_question.lies.filter(text__iexact=selected_text).values_list("player_id", flat=True).first() - ) - - try: - guess = Guess.objects.create( - round_question=round_question, - player=player, - selected_text=selected_text, - is_correct=selected_normalized == correct_normalized, - fooled_player_id=fooled_player_id, - ) - except IntegrityError: - return api_error(request, code="guess_already_submitted", status=409) - - players_count = Player.objects.filter(session=session).count() - guess_count = Guess.objects.filter(round_question=round_question).count() - session_status = session.status - reveal_payload = None - leaderboard = None - - if players_count > 0 and guess_count >= players_count: - score_events = [] - should_broadcast_scores = False - - with transaction.atomic(): - locked_session = GameSession.objects.select_for_update().get(pk=session.pk) - - if locked_session.status == GameSession.Status.GUESS: - already_calculated = ScoreEvent.objects.filter( - session=locked_session, - meta__round_question_id=round_question.id, - ).exists() - if not already_calculated: - score_events, leaderboard = _resolve_scores(locked_session, round_question, round_config) - should_broadcast_scores = True - else: - score_events = list( - ScoreEvent.objects.filter( - session=locked_session, - meta__round_question_id=round_question.id, - ).select_related("player") - ) - leaderboard = _build_leaderboard(locked_session) - - locked_session.status = GameSession.Status.REVEAL - locked_session.save(update_fields=["status"]) - - elif locked_session.status == GameSession.Status.REVEAL: - score_events = list( - ScoreEvent.objects.filter( - session=locked_session, - meta__round_question_id=round_question.id, - ).select_related("player") - ) - leaderboard = _build_leaderboard(locked_session) - - session_status = locked_session.status - - reveal_payload = _build_reveal_payload(round_question) - - if should_broadcast_scores: - score_deltas = [ - {"player_id": ev.player_id, "delta": ev.delta, "reason": ev.reason} - for ev in score_events - ] - sync_broadcast_phase_event( - session.code, - "phase.scores_calculated", - { - "round_question_id": round_question.id, - "score_deltas": score_deltas, - "leaderboard": list(leaderboard), - }, - ) - - return JsonResponse( - { - "guess": { - "id": guess.id, - "player_id": player.id, - "round_question_id": round_question.id, - "selected_text": guess.selected_text, - "is_correct": guess.is_correct, - "fooled_player_id": guess.fooled_player_id, - "created_at": guess.created_at.isoformat(), - }, - "window": { - "guess_deadline_at": guess_deadline_at.isoformat(), - }, - "session": { - "code": session.code, - "status": session_status, - "current_round": session.current_round, - }, - "phase_transition": { - "current_phase": session_status, - "guesses_submitted": guess_count, - "players_expected": players_count, - "auto_advanced": session_status == GameSession.Status.REVEAL, - }, - "reveal": reveal_payload, - "leaderboard": leaderboard, - }, - status=201, - ) - - - - -@require_GET -@login_required -def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse: - session_code = _normalize_session_code(code) - - try: - session = GameSession.objects.get(code=session_code) - except GameSession.DoesNotExist: - return api_error(request, code="session_not_found", status=404) - - if session.host_id != request.user.id: - return api_error(request, code="host_only_view_scoreboard", status=403) - - transition = _promote_reveal_to_scoreboard(session) - if transition.should_broadcast: - sync_broadcast_phase_event( - transition.session.code, - transition.phase_event_name, - transition.phase_event_payload, - ) - session = transition.session - if session.status not in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}: - return api_error(request, code="scoreboard_invalid_phase", status=400) - - return JsonResponse(transition.response_payload) - - -@require_POST -@login_required -def start_next_round(request: HttpRequest, code: str) -> JsonResponse: - session_code = _normalize_session_code(code) - - try: - session = GameSession.objects.get(code=session_code) - except GameSession.DoesNotExist: - return api_error(request, code="session_not_found", status=404) - - if session.host_id != request.user.id: - return api_error(request, code="host_only_start_next_round", status=403) - - try: - transition = _start_next_round(session) - except ValueError as exc: - return api_error(request, code=str(exc), status=400) - - if transition.should_broadcast: - sync_broadcast_phase_event( - transition.session.code, - transition.phase_event_name, - transition.phase_event_payload, - ) - - return JsonResponse(transition.response_payload) - -@require_POST -@login_required -def finish_game(request: HttpRequest, code: str) -> JsonResponse: - session_code = _normalize_session_code(code) - - try: - session = GameSession.objects.get(code=session_code) - except GameSession.DoesNotExist: - return api_error(request, code="session_not_found", status=404) - - if session.host_id != request.user.id: - return api_error(request, code="host_only_finish_game", status=403) - - try: - transition = _finish_game(session) - except ValueError as exc: - return api_error(request, code=str(exc), status=400) - - if transition.should_broadcast: - sync_broadcast_phase_event( - transition.session.code, - transition.phase_event_name, - transition.phase_event_payload, - ) - - return JsonResponse(transition.response_payload) - - -@require_POST -@login_required -def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse: - session_code = _normalize_session_code(code) - - try: - session = GameSession.objects.get(code=session_code) - except GameSession.DoesNotExist: - return api_error(request, code="session_not_found", status=404) - - if session.host_id != request.user.id: - return api_error(request, code="host_only_calculate_scores", status=403) - - already_calculated = ScoreEvent.objects.filter( - session=session, - meta__round_question_id=round_question_id, - ).exists() - if already_calculated: - return api_error(request, code="scores_already_calculated", status=409) - - if session.status != GameSession.Status.GUESS: - return api_error(request, code="calculate_scores_invalid_phase", status=400) - - try: - round_question = RoundQuestion.objects.get( - pk=round_question_id, - session=session, - round_number=session.current_round, - ) - except RoundQuestion.DoesNotExist: - return api_error(request, code="round_question_not_found", status=404) - - try: - round_config = RoundConfig.objects.get(session=session, number=round_question.round_number) - except RoundConfig.DoesNotExist: - return api_error(request, code="round_config_missing", status=400) - - guesses = list(round_question.guesses.select_related("player")) - if not guesses: - return api_error(request, code="no_guesses_submitted", status=400) - - bluff_counts = {} - for guess in guesses: - if guess.fooled_player_id: - bluff_counts[guess.fooled_player_id] = bluff_counts.get(guess.fooled_player_id, 0) + 1 - - with transaction.atomic(): - locked_session = GameSession.objects.select_for_update().get(pk=session.pk) - if locked_session.status != GameSession.Status.GUESS: - return api_error(request, code="calculate_scores_invalid_phase", status=400) - - score_events = [] - - for guess in guesses: - if guess.is_correct: - guess.player.score += round_config.points_correct - guess.player.save(update_fields=["score"]) - score_events.append( - ScoreEvent( - session=session, - player=guess.player, - delta=round_config.points_correct, - reason="guess_correct", - meta={"round_question_id": round_question.id, "guess_id": guess.id}, - ) - ) - - for player_id, fooled_count in bluff_counts.items(): - delta = fooled_count * round_config.points_bluff - player = Player.objects.get(pk=player_id, session=session) - player.score += delta - player.save(update_fields=["score"]) - score_events.append( - ScoreEvent( - session=session, - player=player, - delta=delta, - reason="bluff_success", - meta={"round_question_id": round_question.id, "fooled_count": fooled_count}, - ) - ) - - ScoreEvent.objects.bulk_create(score_events) - - locked_session.status = GameSession.Status.REVEAL - locked_session.save(update_fields=["status"]) - - leaderboard = list( - Player.objects.filter(session=session) - .order_by("-score", "nickname") - .values("id", "nickname", "score") - ) - - score_deltas = [ - {"player_id": ev.player_id, "delta": ev.delta, "reason": ev.reason} - for ev in score_events - ] - - sync_broadcast_phase_event( - session.code, - "phase.scores_calculated", - { - "round_question_id": round_question.id, - "score_deltas": score_deltas, - "leaderboard": list(leaderboard), - }, - ) - - return JsonResponse( - { - "session": { - "code": session.code, - "status": GameSession.Status.REVEAL, - "current_round": session.current_round, - }, - "round_question": { - "id": round_question.id, - "round_number": round_question.round_number, - }, - "reveal": _build_reveal_payload(round_question), - "events_created": len(score_events), - "leaderboard": leaderboard, - } - ) diff --git a/manage.py b/manage.py index e94e1da..82a1170 100755 --- a/manage.py +++ b/manage.py @@ -1,22 +1,9 @@ #!/usr/bin/env python -"""Django's command-line utility for administrative tasks.""" import os import sys +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "partyhub.settings") + from django.core.management import execute_from_command_line -def main(): - """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'partyhub.settings') - try: - from django.core.management import execute_from_command_line - except ImportError as exc: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) from exc execute_from_command_line(sys.argv) - - -if __name__ == '__main__': - main() diff --git a/lobby/migrations/__init__.py b/party/__init__.py similarity index 100% rename from lobby/migrations/__init__.py rename to party/__init__.py diff --git a/party/apps.py b/party/apps.py new file mode 100644 index 0000000..30a59c1 --- /dev/null +++ b/party/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PartyConfig(AppConfig): + name = "party" + verbose_name = "Party" diff --git a/party/auth.py b/party/auth.py new file mode 100644 index 0000000..c3ca1ed --- /dev/null +++ b/party/auth.py @@ -0,0 +1,66 @@ +"""Vært-login via Authentik forward-auth i NPMplus. + +NPMplus beskytter /host/, /ws/host/ og /admin/ med Authentik og sætter +X-authentik-username og X-authentik-groups. Vi stoler kun på headerne, når +forespørgslen kommer direkte fra proxyen, og kun på de beskyttede stier, så en +spiller ikke kan sende headeren selv via de åbne stier. +""" + +from dataclasses import dataclass + +from django.conf import settings +from django.contrib.auth import get_user_model, login + +PROTECTED_PREFIXES = ("/host/", "/ws/host/", "/admin/") + + +@dataclass +class HostIdentity: + username: str + groups: list[str] + + @property + def is_admin(self) -> bool: + return settings.HOST_ADMIN_GROUP in self.groups + + +def identity_from(path: str, client_ip: str | None, headers: dict) -> HostIdentity | None: + """headers: små bogstaver, uden HTTP_-præfiks, fx {'x-authentik-username': 'agw'}.""" + if settings.DEV_HOST_USER: + return HostIdentity(settings.DEV_HOST_USER, [settings.HOST_ADMIN_GROUP]) + if not path.startswith(PROTECTED_PREFIXES) or client_ip not in settings.TRUSTED_PROXIES: + return None + username = (headers.get("x-authentik-username") or "").strip() + if not username: + return None + groups = [g.strip() for g in (headers.get("x-authentik-groups") or "").split("|") if g.strip()] + return HostIdentity(username, groups) + + +def _request_headers(request) -> dict: + return {k.lower(): v for k, v in request.headers.items()} + + +def scope_identity(scope) -> HostIdentity | None: + headers = {k.decode("latin-1").lower(): v.decode("utf-8", "replace") for k, v in scope.get("headers", [])} + client = scope.get("client") or (None, None) + return identity_from(scope.get("path", ""), client[0], headers) + + +class AuthentikHeaderMiddleware: + """Sætter request.host_identity og logger Authentik-admins ind i Django admin.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + ident = identity_from(request.path, request.META.get("REMOTE_ADDR"), _request_headers(request)) + request.host_identity = ident + if ident and request.path.startswith("/admin/") and ident.is_admin: + if not request.user.is_authenticated or request.user.get_username() != ident.username: + user, _ = get_user_model().objects.get_or_create(username=ident.username) + if not (user.is_staff and user.is_superuser): + user.is_staff = user.is_superuser = True + user.save(update_fields=["is_staff", "is_superuser"]) + login(request, user, backend="django.contrib.auth.backends.ModelBackend") + return self.get_response(request) diff --git a/party/consumers.py b/party/consumers.py new file mode 100644 index 0000000..35a6b30 --- /dev/null +++ b/party/consumers.py @@ -0,0 +1,157 @@ +"""WebSocket-forbindelser for storskærm (vært) og telefoner (spillere/publikum).""" + +import logging +import time + +from channels.generic.websocket import AsyncJsonWebsocketConsumer + +from fupogfakta.engine import GameError + +from .auth import scope_identity +from .rooms import get_room + +log = logging.getLogger(__name__) + + +class _RoomConsumer(AsyncJsonWebsocketConsumer): + room = None + + async def connect(self): + self.room = get_room(self.scope["url_route"]["kwargs"]["code"]) + if not self.authorized(): + await self.close(code=4403) + return + await self.accept() + if not self.room: + await self.send_error("unknown_room", "Det spil findes ikke. Tjek koden.") + await self.close(code=4404) + return + self.room.connections.add(self) + self.room.touch() + await self.on_open() + + def authorized(self) -> bool: + return True + + async def on_open(self): + pass + + async def disconnect(self, code): + if self.room: + self.room.connections.discard(self) + await self.on_close() + + async def on_close(self): + pass + + async def send_error(self, code, message): + await self.send_json({"type": "error", "code": code, "message": message}) + + async def receive_json(self, content, **kwargs): + if not self.room or not isinstance(content, dict): + return + action = content.get("action") + if action == "ping": + await self.send_json({"type": "pong"}) + return + version = self.room.game.version + try: + await self.handle(action, content, time.time()) + except GameError as e: + await self.send_error(e.code, e.message) + except (TypeError, ValueError, KeyError): + await self.send_error("bad_request", "Ugyldig besked.") + if self.room.game.version != version: + self.room.changed() + + async def handle(self, action, content, now): + raise NotImplementedError + + async def send_state(self): + raise NotImplementedError + + +class HostConsumer(_RoomConsumer): + def authorized(self): + return scope_identity(self.scope) is not None + + async def on_open(self): + await self.send_state() + + async def send_state(self): + await self.send_json({"type": "state", "state": self.room.game.snapshot(time.time(), host=True)}) + + async def handle(self, action, content, now): + g = self.room.game + if action == "start": + g.start(None, now) + elif action == "skip": + g.skip(now) + elif action == "play_again": + g.play_again(None, now) + elif action == "kick": + g.kick(str(content["player_id"]), now) + else: + await self.send_error("bad_request", "Ukendt handling.") + + +class PlayConsumer(_RoomConsumer): + member_id = None + + async def on_close(self): + if self.member_id: + # Er spilleren stadig forbundet fra en anden fane/telefon, er de ikke væk. + still = any(getattr(c, "member_id", None) == self.member_id for c in self.room.connections) + if not still: + version = self.room.game.version + self.room.game.set_connected(self.member_id, False, time.time()) + if self.room.game.version != version: + self.room.changed() + + def _member(self): + g = self.room.game + return g.players.get(self.member_id) or g.audience.get(self.member_id) + + async def send_state(self): + if not self.member_id: + return + member = self._member() + if not member: # smidt ud af værten + self.member_id = None + await self.send_error("unknown_token", "Du er ikke længere med i spillet.") + return + await self.send_json({"type": "state", "state": self.room.game.snapshot(time.time(), viewer=member)}) + + async def _attach(self, member, now): + self.member_id = member.id + self.room.game.set_connected(member.id, True, now) + await self.send_json({"type": "joined", "token": member.token, "player_id": member.id, "role": member.role}) + await self.send_state() + + async def handle(self, action, content, now): + g = self.room.game + if action == "join": + await self._attach(g.join(str(content.get("name", ""))), now) + return + if action == "resume": + await self._attach(g.resume(str(content.get("token", ""))), now) + return + if not self.member_id or not self._member(): + raise GameError("unknown_token", "Join spillet først.") + mid = self.member_id + if action == "start": + g.start(mid, now) + elif action == "choose_category": + g.choose_category(mid, int(content["category_id"]), now) + elif action == "submit_lie": + g.submit_lie(mid, str(content.get("text", "")), now) + elif action == "lie_for_me": + await self.send_json({"type": "suggestion", "text": g.lie_for_me(mid)}) + elif action == "guess": + g.guess(mid, str(content["answer_id"]), now) + elif action == "like": + g.like(mid, str(content["answer_id"])) + elif action == "play_again": + g.play_again(mid, now) + else: + await self.send_error("bad_request", "Ukendt handling.") diff --git a/party/rooms.py b/party/rooms.py new file mode 100644 index 0000000..5dc61d2 --- /dev/null +++ b/party/rooms.py @@ -0,0 +1,115 @@ +"""Rum: ét kørende spil, dets forbindelser og dets timer. + +Alt kører i én proces og på ét event loop. Spilmotoren er synkron og +foretager ingen I/O, så en handling kører færdig uden afbrydelse: der er ingen +kapløb mellem to spillere, der afleverer samtidig. Derfor skal serveren køre +med præcis én worker. +""" + +from __future__ import annotations + +import asyncio +import logging +import random +import time + +from fupogfakta.engine import Game + +log = logging.getLogger(__name__) + +CODE_LETTERS = "BCDFGHJKLMNPQRSTVWXZ" +IDLE_TIMEOUT = 3 * 3600 + +_rooms: dict[str, "Room"] = {} + + +class Room: + def __init__(self, code: str, game: Game, host_username: str): + self.code = code + self.game = game + self.host_username = host_username + self.connections: set = set() # consumers med .send_state() + self.last_activity = time.time() + self._timer: asyncio.TimerHandle | None = None + self._broadcast_pending = False + + def touch(self): + self.last_activity = time.time() + + def changed(self): + """Kald efter hver ændring: planlæg timer og send nyt snapshot til alle.""" + self.touch() + self._schedule_timer() + if not self._broadcast_pending: + self._broadcast_pending = True + asyncio.get_running_loop().create_task(self._broadcast()) + + async def _broadcast(self): + await asyncio.sleep(0) # saml flere ændringer i samme tick til én udsendelse + self._broadcast_pending = False + for conn in list(self.connections): + try: + await conn.send_state() + except Exception: # en død forbindelse må ikke stoppe de andre + log.exception("send_state fejlede i rum %s", self.code) + + def _schedule_timer(self): + if self._timer: + self._timer.cancel() + self._timer = None + deadline = self.game.deadline + if deadline is not None: + loop = asyncio.get_running_loop() + delay = max(0.0, deadline - time.time()) + 0.05 + self._timer = loop.call_later(delay, self._on_timer) + + def _on_timer(self): + self._timer = None + try: + changed = self.game.tick(time.time()) + except Exception: + log.exception("tick fejlede i rum %s", self.code) + return + if changed: + self.changed() + else: + self._schedule_timer() + + def close(self): + if self._timer: + self._timer.cancel() + + +def new_code() -> str: + for _ in range(1000): + code = "".join(random.choice(CODE_LETTERS) for _ in range(4)) + if code not in _rooms: + return code + raise RuntimeError("Ingen ledige rumkoder") + + +def create_room(questions: list[dict], host_username: str, public_url: str) -> Room: + cleanup() + code = new_code() + game = Game(code, questions, join_url=f"{public_url}/?code={code}") + room = Room(code, game, host_username) + _rooms[code] = room + log.info("Rum %s oprettet af %s med %d spørgsmål", code, host_username, len(questions)) + return room + + +def get_room(code: str) -> Room | None: + return _rooms.get((code or "").upper()) + + +def cleanup(now: float | None = None): + now = now or time.time() + for code, room in list(_rooms.items()): + if not room.connections and now - room.last_activity > IDLE_TIMEOUT: + room.close() + del _rooms[code] + log.info("Rum %s ryddet op", code) + + +def all_rooms() -> dict[str, Room]: + return _rooms diff --git a/party/routing.py b/party/routing.py new file mode 100644 index 0000000..273e732 --- /dev/null +++ b/party/routing.py @@ -0,0 +1,8 @@ +from django.urls import path + +from . import consumers + +websocket_urlpatterns = [ + path("ws/play//", consumers.PlayConsumer.as_asgi()), + path("ws/host//", consumers.HostConsumer.as_asgi()), +] diff --git a/realtime/__init__.py b/party/tests/__init__.py similarity index 100% rename from realtime/__init__.py rename to party/tests/__init__.py diff --git a/party/tests/test_views.py b/party/tests/test_views.py new file mode 100644 index 0000000..0d51e03 --- /dev/null +++ b/party/tests/test_views.py @@ -0,0 +1,35 @@ +from django.test import TestCase, override_settings + +from fupogfakta.models import Category, Question +from party import rooms + + +@override_settings(TRUSTED_PROXIES=["127.0.0.1"], DEV_HOST_USER=None) +class ViewTests(TestCase): + def setUp(self): + rooms.all_rooms().clear() + cat = Category.objects.create(name="Kat") + Question.objects.create(category=cat, prompt="Svaret er _____.", answer="ja", decoys=["a", "b", "c"]) + + def test_play_page_prefills_code(self): + r = self.client.get("/?code=klmt") + self.assertContains(r, "KLMT") + + def test_host_needs_header(self): + self.assertEqual(self.client.get("/host/").status_code, 403) + r = self.client.get("/host/", headers={"x-authentik-username": "agw"}) + self.assertEqual(r.status_code, 200) + + def test_header_on_open_path_does_not_log_in_admin(self): + self.client.get("/", headers={"x-authentik-username": "evil", "x-authentik-groups": "authentik Admins"}) + r = self.client.get("/admin/") + self.assertEqual(r.status_code, 302) # til login + + def test_admin_sso_for_admins_group(self): + r = self.client.get("/admin/", headers={"x-authentik-username": "agw", "x-authentik-groups": "authentik Admins|Familie"}) + self.assertEqual(r.status_code, 200) + + def test_qr_svg(self): + r = self.client.get("/qr/KLMT.svg") + self.assertEqual(r["Content-Type"], "image/svg+xml") + self.assertIn(b"/", views.host, name="host"), + path("qr/.svg", views.qr, name="qr"), + path("healthz", views.healthz, name="healthz"), +] diff --git a/party/views.py b/party/views.py new file mode 100644 index 0000000..bbad132 --- /dev/null +++ b/party/views.py @@ -0,0 +1,66 @@ +import re + +import segno +from asgiref.sync import sync_to_async +from django.conf import settings +from django.http import Http404, HttpResponse, HttpResponseForbidden +from django.shortcuts import redirect, render +from django.views.decorators.http import require_GET, require_POST + +from fupogfakta.models import load_game_questions + +from .rooms import create_room, get_room + +CODE_RE = re.compile(r"^[A-Za-z]{4}$") + + +def _forbidden(): + return HttpResponseForbidden("Du skal være logget ind som vært for at oprette et spil.") + + +@require_GET +def play(request): + code = request.GET.get("code", "") + return render(request, "party/play.html", {"prefill_code": code.upper() if CODE_RE.match(code) else ""}) + + +@require_GET +def host_new_page(request): + if not request.host_identity: + return _forbidden() + return render(request, "party/host_new.html", {"username": request.host_identity.username}) + + +@require_POST +async def host_new(request): + ident = request.host_identity + if not ident: + return _forbidden() + questions = await sync_to_async(load_game_questions)() + if not questions: + return HttpResponse("Der er ingen spørgsmål i banken. Kør 'manage.py load_questions'.", status=503) + room = create_room(questions, ident.username, settings.PUBLIC_URL) + return redirect("party:host", code=room.code) + + +@require_GET +def host(request, code): + if not request.host_identity: + return _forbidden() + if not get_room(code): + return redirect("party:host_new_page") + return render(request, "party/host.html", {"code": code.upper()}) + + +@require_GET +def qr(request, code): + if not CODE_RE.match(code): + raise Http404 + svg = segno.make(f"{settings.PUBLIC_URL}/?code={code.upper()}", error="m").svg_inline( + scale=10, border=2, dark="#1b1035", light="#ffffff") + return HttpResponse(svg, content_type="image/svg+xml") + + +@require_GET +def healthz(request): + return HttpResponse("ok", content_type="text/plain") diff --git a/partyhub/asgi.py b/partyhub/asgi.py index 54ca5e3..967b8ae 100644 --- a/partyhub/asgi.py +++ b/partyhub/asgi.py @@ -1,15 +1,15 @@ import os -from channels.routing import ProtocolTypeRouter, URLRouter from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'partyhub.settings') - +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "partyhub.settings") django_asgi_app = get_asgi_application() -from realtime.routing import websocket_urlpatterns # noqa: E402 — must come after env setup +from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402 + +from party.routing import websocket_urlpatterns # noqa: E402 application = ProtocolTypeRouter({ - 'http': django_asgi_app, - 'websocket': URLRouter(websocket_urlpatterns), + "http": django_asgi_app, + "websocket": URLRouter(websocket_urlpatterns), }) diff --git a/partyhub/i18n_bootstrap.py b/partyhub/i18n_bootstrap.py deleted file mode 100644 index fcbeee8..0000000 --- a/partyhub/i18n_bootstrap.py +++ /dev/null @@ -1,30 +0,0 @@ -import json -from functools import lru_cache -from pathlib import Path - -_LOCALE_LABELS = { - "en": "English", - "da": "Danish", -} - - -@lru_cache(maxsize=1) -def shared_i18n_catalog() -> dict: - catalog_path = Path(__file__).resolve().parents[1] / "shared" / "i18n" / "lobby.json" - with catalog_path.open(encoding="utf-8") as handle: - return json.load(handle) - - -@lru_cache(maxsize=1) -def locale_config() -> tuple[str, tuple[str, ...]]: - locales = shared_i18n_catalog().get("locales", {}) - default_locale = str(locales.get("default", "en")).strip().lower() or "en" - supported_locales = tuple( - locale.strip().lower() for locale in locales.get("supported", ["en", "da"]) if str(locale).strip() - ) or ("en", "da") - return default_locale, supported_locales - - -def django_languages() -> list[tuple[str, str]]: - _default_locale, supported_locales = locale_config() - return [(locale, _LOCALE_LABELS.get(locale, locale.upper())) for locale in supported_locales] diff --git a/partyhub/settings.py b/partyhub/settings.py index 9ea5b48..1992b4b 100644 --- a/partyhub/settings.py +++ b/partyhub/settings.py @@ -1,129 +1,103 @@ -from pathlib import Path -import os +"""Indstillinger. Alt der varierer mellem dev og drift kommer fra miljøvariabler.""" -from partyhub.i18n_bootstrap import django_languages, locale_config +import os +from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent -def env(key: str, default: str | None = None) -> str | None: - return os.getenv(key, default) +def env(name, default=None): + return os.environ.get(name, default) -SECRET_KEY = env('DJANGO_SECRET_KEY', 'dev-insecure-key-change-me') -DEBUG = env('DJANGO_DEBUG', 'true').lower() == 'true' -ALLOWED_HOSTS = [h.strip() for h in env('DJANGO_ALLOWED_HOSTS', '*').split(',') if h.strip()] +DEBUG = env("DJANGO_DEBUG", "false").lower() == "true" +SECRET_KEY = env("DJANGO_SECRET_KEY") or ("dev-only-not-secret" if DEBUG else None) +if not SECRET_KEY: + raise RuntimeError("DJANGO_SECRET_KEY skal sættes, når DEBUG er slået fra.") + +PUBLIC_URL = env("PUBLIC_URL", "http://localhost:8000").rstrip("/") +ALLOWED_HOSTS = [h for h in env("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") if h] +CSRF_TRUSTED_ORIGINS = [PUBLIC_URL] +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +USE_X_FORWARDED_HOST = True +SESSION_COOKIE_SECURE = CSRF_COOKIE_SECURE = PUBLIC_URL.startswith("https://") + +# Authentik: forward-auth i NPMplus sætter X-authentik-*-headers på /host og /admin. +# Headerne stoles kun på, når forespørgslen kommer fra disse adresser. +TRUSTED_PROXIES = [h for h in env("TRUSTED_PROXIES", "127.0.0.1,192.168.10.201").split(",") if h] +HOST_ADMIN_GROUP = env("HOST_ADMIN_GROUP", "authentik Admins") +# Kun til lokal udvikling: lader som om denne bruger er logget ind som vært. +DEV_HOST_USER = env("DEV_HOST_USER") if DEBUG else None INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'channels', - 'core_admin', - 'fupogfakta', - 'lobby', - 'realtime', - 'voice', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "channels", + "fupogfakta", + "party", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.locale.LocaleMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "party.auth.AuthentikHeaderMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'partyhub.urls' +ROOT_URLCONF = "partyhub.urls" +ASGI_APPLICATION = "partyhub.asgi.application" +CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR / 'templates'], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'partyhub.wsgi.application' -ASGI_APPLICATION = 'partyhub.asgi.application' - -DB_ENGINE = env('DB_ENGINE', 'django.db.backends.sqlite3') -if DB_ENGINE == 'django.db.backends.sqlite3': - DATABASES = { - 'default': { - 'ENGINE': DB_ENGINE, - 'NAME': env('DB_NAME', str(BASE_DIR / 'db.sqlite3')), - } - } -else: - DATABASES = { - 'default': { - 'ENGINE': DB_ENGINE, - 'NAME': env('DB_NAME', 'wpp_test'), - 'USER': env('DB_USER', 'wpp_test'), - 'PASSWORD': env('DB_PASSWORD', ''), - 'HOST': env('DB_HOST', '127.0.0.1'), - 'PORT': env('DB_PORT', '3306'), - 'OPTIONS': {'charset': 'utf8mb4'}, - } +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": env("DB_PATH", str(BASE_DIR / "db.sqlite3")), } +} +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -TEST_DB_NAME = env('TEST_DB_NAME') -if TEST_DB_NAME: - DATABASES['default']['TEST'] = {'NAME': TEST_DB_NAME} - -AUTH_PASSWORD_VALIDATORS = [ - {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, - {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, - {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, - {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, -] - -LANGUAGE_CODE, _SUPPORTED_LOCALES = locale_config() -LANGUAGES = django_languages() -LOCALE_PATHS = [BASE_DIR / 'locale'] -TIME_ZONE = 'Europe/Copenhagen' +LANGUAGE_CODE = "da" +TIME_ZONE = "Europe/Copenhagen" USE_I18N = True USE_TZ = True -STATIC_URL = '/static/' -STATIC_ROOT = BASE_DIR / 'staticfiles' - -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' - -USE_SPA_UI_RAW = env('USE_SPA_UI') -if USE_SPA_UI_RAW is None: - # Backward-compatible fallback while cutover is rolling out. - USE_SPA_UI_RAW = env('WPP_SPA_ENABLED', 'false') -USE_SPA_UI = USE_SPA_UI_RAW.lower() == 'true' -WPP_SPA_ASSET_BASE = env('WPP_SPA_ASSET_BASE', '/static/frontend/angular/browser').rstrip('/') -# Cache-busting token for SPA shell static asset URLs (querystring versioning). -# Set to release id / commit SHA in deploy env for deterministic invalidation. -WPP_SPA_ASSET_VERSION = env('WPP_SPA_ASSET_VERSION', 'dev') - -CHANNEL_REDIS_HOST = env('CHANNEL_REDIS_HOST', '127.0.0.1') -CHANNEL_REDIS_PORT = int(env('CHANNEL_REDIS_PORT', '6379')) - -import sys # noqa: E402 -_testing = 'test' in sys.argv -CHANNEL_LAYERS = { - 'default': { - 'BACKEND': 'channels.layers.InMemoryChannelLayer', - } if _testing else { - 'BACKEND': 'channels_redis.core.RedisChannelLayer', - 'CONFIG': {'hosts': [(CHANNEL_REDIS_HOST, CHANNEL_REDIS_PORT)]}, - } +STATIC_URL = "/static/" +STATIC_ROOT = env("STATIC_ROOT", str(BASE_DIR / "staticfiles")) +STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage" + if DEBUG else "whitenoise.storage.CompressedManifestStaticFilesStorage" + }, +} + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": {"console": {"class": "logging.StreamHandler"}}, + "root": {"handlers": ["console"], "level": "INFO"}, } diff --git a/partyhub/tests_i18n_bootstrap.py b/partyhub/tests_i18n_bootstrap.py deleted file mode 100644 index 73b2fa8..0000000 --- a/partyhub/tests_i18n_bootstrap.py +++ /dev/null @@ -1,28 +0,0 @@ -from django.conf import settings -from django.test import TestCase - -from partyhub.i18n_bootstrap import django_languages, locale_config, shared_i18n_catalog - - -class I18nBootstrapTests(TestCase): - def test_shared_catalog_bootstraps_default_and_supported_locales(self): - default_locale, supported_locales = locale_config() - - self.assertEqual(default_locale, "en") - self.assertEqual(supported_locales, ("en", "da")) - - def test_django_language_settings_bootstrap_from_shared_i18n_catalog(self): - default_locale, supported_locales = locale_config() - - self.assertEqual(settings.LANGUAGE_CODE, default_locale) - self.assertEqual(tuple(code for code, _ in settings.LANGUAGES), supported_locales) - self.assertEqual(settings.LANGUAGES, django_languages()) - - def test_locale_contract_in_catalog_matches_django_language_codes(self): - catalog_locales = shared_i18n_catalog()["locales"] - - self.assertEqual(settings.LANGUAGE_CODE, catalog_locales["default"]) - self.assertEqual( - [code for code, _ in settings.LANGUAGES], - catalog_locales["supported"], - ) diff --git a/partyhub/urls.py b/partyhub/urls.py index dbc31a6..1d3567e 100644 --- a/partyhub/urls.py +++ b/partyhub/urls.py @@ -1,14 +1,10 @@ from django.contrib import admin -from django.http import JsonResponse from django.urls import include, path - -def health(_request): - return JsonResponse({"ok": True, "service": "weirsoe-party-protocol"}) - +admin.site.site_header = "Weirsøe Party Protocol" +admin.site.site_title = "Party admin" urlpatterns = [ path("admin/", admin.site.urls), - path("healthz", health, name="healthz"), - path("lobby/", include("lobby.urls")), + path("", include("party.urls")), ] diff --git a/partyhub/wsgi.py b/partyhub/wsgi.py deleted file mode 100644 index 720d0a5..0000000 --- a/partyhub/wsgi.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -WSGI config for partyhub project. - -It exposes the WSGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ -""" - -import os - -from django.core.wsgi import get_wsgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'partyhub.settings') - -application = get_wsgi_application() diff --git a/realtime/admin.py b/realtime/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/realtime/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/realtime/apps.py b/realtime/apps.py deleted file mode 100644 index c3885f8..0000000 --- a/realtime/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class RealtimeConfig(AppConfig): - name = 'realtime' diff --git a/realtime/broadcast.py b/realtime/broadcast.py deleted file mode 100644 index ba58864..0000000 --- a/realtime/broadcast.py +++ /dev/null @@ -1,35 +0,0 @@ -from asgiref.sync import async_to_sync -from channels.exceptions import InvalidChannelLayerError -from channels.layers import get_channel_layer - -try: - from redis.exceptions import ConnectionError as RedisConnectionError -except Exception: # pragma: no cover - optional dependency in local/test runtimes - RedisConnectionError = RuntimeError - - -async def broadcast_phase_event(session_code: str, event_type: str, payload: dict) -> None: - """Send a phase event to all WebSocket clients connected to a game session.""" - try: - channel_layer = get_channel_layer() - if channel_layer is None: - return - group_name = f"game_{session_code.upper()}" - await channel_layer.group_send( - group_name, - { - "type": "phase.event", - "event_type": event_type, - "payload": payload, - }, - ) - except (InvalidChannelLayerError, RedisConnectionError): - return - - -def sync_broadcast_phase_event(session_code: str, event_type: str, payload: dict) -> None: - """Sync wrapper for calling broadcast_phase_event from synchronous Django views.""" - try: - async_to_sync(broadcast_phase_event)(session_code, event_type, payload) - except (InvalidChannelLayerError, RedisConnectionError): - return diff --git a/realtime/consumers.py b/realtime/consumers.py deleted file mode 100644 index a3149c7..0000000 --- a/realtime/consumers.py +++ /dev/null @@ -1,63 +0,0 @@ -import json -from urllib.parse import parse_qs - -from channels.generic.websocket import AsyncJsonWebsocketConsumer - -from fupogfakta.models import Player - - -class GameConsumer(AsyncJsonWebsocketConsumer): - """ - WebSocket consumer for a game session. - - URL: ws/game// - - Query params: - - session_token: player session token (players only) - - role=host: skip token check for host in MVP - """ - - async def connect(self): - self.session_code = self.scope["url_route"]["kwargs"]["session_code"].upper() - self.group_name = f"game_{self.session_code}" - - query_string = self.scope.get("query_string", b"").decode() - params = parse_qs(query_string) - - role = params.get("role", [None])[0] - session_token = params.get("session_token", [None])[0] - - if role != "host": - if not session_token: - await self.close(code=4001) - return - - try: - self.player = await Player.objects.aget( - session_token=session_token, - session__code=self.session_code, - ) - except Player.DoesNotExist: - await self.close(code=4003) - return - else: - self.player = None - - await self.channel_layer.group_add(self.group_name, self.channel_name) - await self.accept() - - async def disconnect(self, close_code): - if hasattr(self, "group_name"): - await self.channel_layer.group_discard(self.group_name, self.channel_name) - - async def receive_json(self, content, **kwargs): - if content.get("type") == "ping": - await self.send_json({"type": "pong"}) - - # --- Group message handlers --- - - async def phase_event(self, event): - """Forward any phase_event broadcast to the WebSocket client.""" - payload = dict(event.get("payload") or {}) - payload["type"] = event["event_type"] - await self.send_json(payload) diff --git a/realtime/migrations/__init__.py b/realtime/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/realtime/models.py b/realtime/models.py deleted file mode 100644 index 71a8362..0000000 --- a/realtime/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/realtime/routing.py b/realtime/routing.py deleted file mode 100644 index 3137721..0000000 --- a/realtime/routing.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.urls import re_path - -from . import consumers - -websocket_urlpatterns = [ - re_path(r"^ws/game/(?P[A-Z0-9]{4,8})/$", consumers.GameConsumer.as_asgi()), -] diff --git a/realtime/tests.py b/realtime/tests.py deleted file mode 100644 index e87677e..0000000 --- a/realtime/tests.py +++ /dev/null @@ -1,123 +0,0 @@ -import unittest -from unittest.mock import AsyncMock, Mock, patch - -from channels.exceptions import InvalidChannelLayerError -from django.contrib.auth import get_user_model -from django.test import SimpleTestCase, TestCase - -try: - from channels.testing import WebsocketCommunicator -except Exception: # pragma: no cover - optional test dependency - WebsocketCommunicator = None - -from fupogfakta.models import GameSession, Player -from partyhub.asgi import application -from realtime.broadcast import broadcast_phase_event, sync_broadcast_phase_event -from realtime.consumers import GameConsumer - -User = get_user_model() - - -class BroadcastPhaseEventTests(SimpleTestCase): - @patch("realtime.broadcast.get_channel_layer", return_value=None) - async def test_broadcast_phase_event_noops_without_channel_layer(self, _mock_get_channel_layer): - await broadcast_phase_event("ABCD", "phase.scoreboard", {"phase": "scoreboard"}) - - @patch("realtime.broadcast.async_to_sync") - def test_sync_broadcast_phase_event_noops_when_channel_layer_is_unavailable(self, mock_async_to_sync): - mock_async_to_sync.return_value.side_effect = InvalidChannelLayerError("missing channel layer") - - sync_broadcast_phase_event("ABCD", "phase.scoreboard", {"phase": "scoreboard"}) - - @patch("realtime.broadcast.async_to_sync") - def test_sync_broadcast_phase_event_still_broadcasts_when_channel_layer_exists(self, mock_async_to_sync): - sender = Mock() - mock_async_to_sync.return_value = sender - - sync_broadcast_phase_event("ABCD", "phase.scoreboard", {"phase": "scoreboard"}) - - sender.assert_called_once_with("ABCD", "phase.scoreboard", {"phase": "scoreboard"}) - - -class GameConsumerPhaseEventTests(SimpleTestCase): - async def test_phase_event_restores_external_type_field(self): - consumer = GameConsumer() - consumer.send_json = AsyncMock() - - await consumer.phase_event( - { - "event_type": "phase.test_event", - "payload": {"hello": "world"}, - } - ) - - consumer.send_json.assert_awaited_once_with( - { - "type": "phase.test_event", - "hello": "world", - } - ) - - -@unittest.skipIf(WebsocketCommunicator is None, "channels.testing dependencies unavailable") -class GameConsumerConnectTest(TestCase): - def setUp(self): - self.user = User.objects.create_user(username="host", password="pw") - self.session = GameSession.objects.create(host=self.user, code="AABBCC") - self.player = Player.objects.create(session=self.session, nickname="Tester") - - async def test_player_connect_and_ping(self): - token = self.player.session_token - communicator = WebsocketCommunicator( - application, - f"/ws/game/AABBCC/?session_token={token}", - ) - connected, _ = await communicator.connect() - self.assertTrue(connected) - - await communicator.send_json_to({"type": "ping"}) - response = await communicator.receive_json_from() - self.assertEqual(response["type"], "pong") - - await communicator.disconnect() - - async def test_connect_without_token_rejected(self): - communicator = WebsocketCommunicator(application, "/ws/game/AABBCC/") - connected, code = await communicator.connect() - self.assertFalse(connected) - self.assertEqual(code, 4001) - - async def test_connect_invalid_token_rejected(self): - communicator = WebsocketCommunicator( - application, - "/ws/game/AABBCC/?session_token=invalid-token", - ) - connected, code = await communicator.connect() - self.assertFalse(connected) - self.assertEqual(code, 4003) - - async def test_host_connect_without_token(self): - communicator = WebsocketCommunicator( - application, - "/ws/game/AABBCC/?role=host", - ) - connected, _ = await communicator.connect() - self.assertTrue(connected) - await communicator.disconnect() - - async def test_broadcast_reaches_connected_client(self): - token = self.player.session_token - communicator = WebsocketCommunicator( - application, - f"/ws/game/AABBCC/?session_token={token}", - ) - connected, _ = await communicator.connect() - self.assertTrue(connected) - - await broadcast_phase_event("AABBCC", "phase.test_event", {"hello": "world"}) - - message = await communicator.receive_json_from(timeout=2) - self.assertEqual(message["type"], "phase.test_event") - self.assertEqual(message["hello"], "world") - - await communicator.disconnect() diff --git a/realtime/views.py b/realtime/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/realtime/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..27d439e --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +# channels.testing importerer daphne +daphne>=4.1 diff --git a/requirements.txt b/requirements.txt index ee62f92..c35d7b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ -Django==6.0.2 -channels>=4.1,<5 -channels-redis>=4.2,<5 -daphne>=4.1,<5 -mysqlclient>=2.2,<3 -python-dotenv>=1.0,<2 +Django>=6.0,<6.1 +channels>=4.2,<5 +uvicorn[standard]>=0.30 +whitenoise>=6.7 +segno>=1.6 diff --git a/scripts/build_i18n_parity_report.py b/scripts/build_i18n_parity_report.py deleted file mode 100644 index 93e238e..0000000 --- a/scripts/build_i18n_parity_report.py +++ /dev/null @@ -1,214 +0,0 @@ -#!/usr/bin/env python3 -"""Build the shared i18n parity artifact for MVP-critical Django/Angular keys.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import re -from collections import defaultdict -from pathlib import Path -from typing import Any - -REPO_ROOT = Path(__file__).resolve().parents[1] -CATALOG_PATH = REPO_ROOT / "shared" / "i18n" / "lobby.json" -ARTIFACT_PATH = REPO_ROOT / "shared" / "i18n" / "artifacts" / "lobby-mvp-keyspace-parity-report.v1.json" -DJANGO_VIEWS_PATH = REPO_ROOT / "lobby" / "views.py" -FRONTEND_VERTICAL_SLICE_PATH = REPO_ROOT / "frontend" / "src" / "spa" / "vertical-slice.ts" -ANGULAR_I18N_PATH = REPO_ROOT / "frontend" / "angular" / "src" / "app" / "lobby-i18n.ts" -ANGULAR_HOST_PATH = REPO_ROOT / "frontend" / "angular" / "src" / "app" / "features" / "host" / "host-shell.component.ts" -ANGULAR_PLAYER_PATH = REPO_ROOT / "frontend" / "angular" / "src" / "app" / "features" / "player" / "player-shell.component.ts" - -ARTIFACT_NAME = "shared.i18n.lobby.mvp_keyspace_parity_report" -ARTIFACT_VERSION = "v1" - - -class ParityError(RuntimeError): - pass - - -def _read_text(path: Path) -> str: - return path.read_text(encoding="utf-8") - - -def _load_catalog() -> dict[str, Any]: - return json.loads(_read_text(CATALOG_PATH)) - - -def _catalog_hash() -> str: - return hashlib.sha256(CATALOG_PATH.read_bytes()).hexdigest() - - -def _sorted_unique(values: list[str]) -> list[str]: - return sorted({value for value in values}) - - -def _extract_matches(path: Path, pattern: str) -> list[str]: - return re.findall(pattern, _read_text(path), re.MULTILINE) - - -def _resolve_frontend_ui_key(key: str) -> str: - if key.startswith("lobby.shell."): - return key.replace("lobby.shell.", "app.", 1) - if key.startswith("game.host."): - return key.replace("game.host.", "host.", 1) - if key.startswith("game.player."): - return key.replace("game.player.", "player.", 1) - return key - - -def _has_nested_key(root: dict[str, Any], dotted_key: str) -> bool: - current: Any = root - for part in dotted_key.split("."): - if not isinstance(current, dict) or part not in current: - return False - current = current[part] - return isinstance(current, dict) - - -def build_report() -> dict[str, Any]: - catalog = _load_catalog() - locales = list(catalog["locales"]["supported"]) - frontend_errors: dict[str, Any] = catalog["frontend"]["errors"] - frontend_ui: dict[str, Any] = catalog["frontend"]["ui"] - backend_error_codes: dict[str, str] = catalog["backend"]["error_codes"] - backend_errors: dict[str, Any] = catalog["backend"]["errors"] - backend_to_frontend: dict[str, str] = catalog["contract"]["backend_to_frontend_error_keys"] - - django_error_codes = _sorted_unique( - _extract_matches(DJANGO_VIEWS_PATH, r'ERROR_CODES\.get\("([a-z0-9_]+)"') - ) - frontend_runtime_error_fallbacks = _sorted_unique( - _extract_matches(FRONTEND_VERTICAL_SLICE_PATH, r"lobbyMessageFromApiPayload\([^\n]+?'([a-z0-9_]+)'\)") - + _extract_matches(FRONTEND_VERTICAL_SLICE_PATH, r"lobbyMessage\('([a-z0-9_]+)'\)") - ) - angular_copy_keys = _sorted_unique( - _extract_matches(ANGULAR_HOST_PATH, r"copy\('([A-Za-z0-9_\.]+)'\)") - + _extract_matches(ANGULAR_PLAYER_PATH, r"copy\('([A-Za-z0-9_\.]+)'\)") - ) - angular_catalog_paths = [_resolve_frontend_ui_key(key) for key in angular_copy_keys] - - missing_backend_codes = [code for code in django_error_codes if code not in backend_error_codes] - missing_backend_translations = [code for code in django_error_codes if code not in backend_errors] - missing_contract_mappings = [code for code in django_error_codes if code not in backend_to_frontend] - mapped_frontend_error_keys = _sorted_unique( - [backend_to_frontend[code] for code in django_error_codes if code in backend_to_frontend] - ) - missing_frontend_error_keys = [key for key in mapped_frontend_error_keys if key not in frontend_errors] - missing_frontend_runtime_fallbacks = [key for key in frontend_runtime_error_fallbacks if key not in frontend_errors] - missing_angular_catalog_paths = [path for path in angular_catalog_paths if not _has_nested_key(frontend_ui, path)] - - dead_contract_aliases = _sorted_unique([code for code in backend_to_frontend if code not in backend_error_codes]) - many_to_one_mappings: dict[str, list[str]] = defaultdict(list) - for code in django_error_codes: - frontend_key = backend_to_frontend.get(code) - if frontend_key: - many_to_one_mappings[frontend_key].append(code) - many_to_one_mappings = { - frontend_key: sorted(codes) - for frontend_key, codes in sorted(many_to_one_mappings.items()) - if len(codes) > 1 - } - - blocking_issues = { - "missing_backend_codes": missing_backend_codes, - "missing_backend_translations": missing_backend_translations, - "missing_contract_mappings": missing_contract_mappings, - "missing_frontend_error_keys": missing_frontend_error_keys, - "missing_frontend_runtime_fallbacks": missing_frontend_runtime_fallbacks, - "missing_angular_catalog_paths": missing_angular_catalog_paths, - } - status = "pass" if not any(blocking_issues.values()) else "fail" - - follow_ups: list[dict[str, str]] = [] - if dead_contract_aliases: - follow_ups.append( - { - "priority": "need-to-have", - "item": "Either add missing backend/error_codes + backend/errors entries for dead contract aliases or remove them from contract.backend_to_frontend_error_keys.", - "evidence": ", ".join(dead_contract_aliases), - } - ) - if many_to_one_mappings: - follow_ups.append( - { - "priority": "nice-to-have", - "item": "Decide whether grouped backend codes should keep collapsing into one Angular fallback key or be split into more specific frontend error copy as UX matures.", - "evidence": "; ".join( - f"{frontend_key} <= {', '.join(codes)}" for frontend_key, codes in many_to_one_mappings.items() - ), - } - ) - - return { - "artifact_name": ARTIFACT_NAME, - "artifact_version": ARTIFACT_VERSION, - "naming_version_rule": "Keep a stable artifact_name and append only explicit schema-major suffixes to the filename/version (v1, v2, ...). Update artifact_version only when the report shape changes; refresh content in-place for catalog/keyspace changes.", - "source_of_truth": { - "catalog": str(CATALOG_PATH.relative_to(REPO_ROOT)), - "catalog_sha256": _catalog_hash(), - "source_paths": [ - str(path.relative_to(REPO_ROOT)) - for path in [ - DJANGO_VIEWS_PATH, - FRONTEND_VERTICAL_SLICE_PATH, - ANGULAR_I18N_PATH, - ANGULAR_HOST_PATH, - ANGULAR_PLAYER_PATH, - ] - ], - }, - "scope": { - "issue": 277, - "related_epic": 175, - "mvp_locales": locales, - "definition": "MVP-critical keys are the Django error codes emitted by lobby/views.py plus the Angular fallback/UI keys consumed by the host/player MVP shells.", - }, - "parity": { - "status": status, - "django_backend_error_codes_used_by_mvp": django_error_codes, - "angular_frontend_error_fallback_keys_used_by_mvp": frontend_runtime_error_fallbacks, - "angular_ui_keys_used_by_mvp": angular_copy_keys, - "angular_ui_catalog_paths": angular_catalog_paths, - "backend_codes_mapped_to_frontend_error_keys": {code: backend_to_frontend[code] for code in django_error_codes if code in backend_to_frontend}, - "unique_frontend_error_keys_reached_from_django": mapped_frontend_error_keys, - "blocking_issues": blocking_issues, - "follow_ups": follow_ups, - }, - } - - -def write_report() -> None: - report = build_report() - ARTIFACT_PATH.parent.mkdir(parents=True, exist_ok=True) - ARTIFACT_PATH.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") - - -def check_report() -> None: - report = build_report() - if not ARTIFACT_PATH.exists(): - raise ParityError(f"artifact missing: {ARTIFACT_PATH}") - existing = json.loads(_read_text(ARTIFACT_PATH)) - if existing != report: - raise ParityError("artifact out of date; run scripts/build_i18n_parity_report.py --write") - if report["parity"]["status"] != "pass": - raise ParityError(json.dumps(report["parity"]["blocking_issues"], indent=2)) - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--write", action="store_true") - parser.add_argument("--check", action="store_true") - args = parser.parse_args() - if args.write: - write_report() - if args.check: - check_report() - if not args.write and not args.check: - print(json.dumps(build_report(), indent=2, ensure_ascii=False)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/check_i18n_drift.py b/scripts/check_i18n_drift.py deleted file mode 100755 index a8bd92d..0000000 --- a/scripts/check_i18n_drift.py +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env python3 -"""Read-only drift check for shared i18n key coverage. - -Compares `shared/i18n/key-manifest.json` against `shared/i18n/lobby.json` and -fails fast when keyspaces drift between frontend/backend contract sections. -""" - -from __future__ import annotations - -import json -from pathlib import Path -import sys -from typing import Any - - -REPO_ROOT = Path(__file__).resolve().parents[1] -CATALOG_PATH = REPO_ROOT / "shared" / "i18n" / "lobby.json" -MANIFEST_PATH = REPO_ROOT / "shared" / "i18n" / "key-manifest.json" - - -def _load_json(path: Path) -> dict[str, Any]: - with path.open("r", encoding="utf-8") as handle: - return json.load(handle) - - -def _as_set(value: Any) -> set[str]: - if not isinstance(value, list): - return set() - return {str(item) for item in value} - - -def _require_translations( - errors: dict[str, Any], - locales: set[str], - label: str, - failures: list[str], -) -> None: - for key, translations in errors.items(): - if not isinstance(translations, dict): - failures.append(f"{label}.{key} must be an object of locale->message") - continue - - for locale in sorted(locales): - value = translations.get(locale) - if not isinstance(value, str) or not value.strip(): - failures.append(f"{label}.{key} missing non-empty '{locale}' translation") - - -def main() -> int: - catalog = _load_json(CATALOG_PATH) - manifest = _load_json(MANIFEST_PATH) - - failures: list[str] = [] - - manifest_locales = _as_set(manifest.get("locales")) - catalog_locales = _as_set(catalog.get("locales", {}).get("supported")) - if manifest_locales != catalog_locales: - failures.append( - "locale set mismatch: " - f"manifest={sorted(manifest_locales)} catalog={sorted(catalog_locales)}" - ) - - frontend_manifest = _as_set(manifest.get("frontend_error_keys")) - frontend_catalog = set(catalog.get("frontend", {}).get("errors", {}).keys()) - if frontend_manifest != frontend_catalog: - failures.append( - "frontend error key mismatch: " - f"manifest-only={sorted(frontend_manifest - frontend_catalog)} " - f"catalog-only={sorted(frontend_catalog - frontend_manifest)}" - ) - - backend_code_manifest = _as_set(manifest.get("backend_error_codes")) - backend_code_catalog = set(catalog.get("backend", {}).get("error_codes", {}).keys()) - if backend_code_manifest != backend_code_catalog: - failures.append( - "backend error code mismatch: " - f"manifest-only={sorted(backend_code_manifest - backend_code_catalog)} " - f"catalog-only={sorted(backend_code_catalog - backend_code_manifest)}" - ) - - backend_key_manifest = _as_set(manifest.get("backend_error_keys")) - backend_key_catalog = set(catalog.get("backend", {}).get("errors", {}).keys()) - if backend_key_manifest != backend_key_catalog: - failures.append( - "backend error key mismatch: " - f"manifest-only={sorted(backend_key_manifest - backend_key_catalog)} " - f"catalog-only={sorted(backend_key_catalog - backend_key_manifest)}" - ) - - backend_to_frontend = catalog.get("contract", {}).get("backend_to_frontend_error_keys", {}) - if not isinstance(backend_to_frontend, dict): - failures.append("contract.backend_to_frontend_error_keys must be an object") - backend_to_frontend = {} - - for code in sorted(backend_code_catalog): - frontend_key = backend_to_frontend.get(code) - if frontend_key is None: - failures.append(f"missing contract mapping for backend code '{code}'") - continue - if frontend_key not in frontend_catalog: - failures.append( - f"mapping for backend code '{code}' points to unknown frontend key '{frontend_key}'" - ) - - allowed_contract_only_codes = _as_set(manifest.get("allowed_contract_only_backend_codes")) - unknown_mapping_codes = set(backend_to_frontend.keys()) - backend_code_catalog - disallowed_unknown_mapping_codes = unknown_mapping_codes - allowed_contract_only_codes - if disallowed_unknown_mapping_codes: - failures.append( - "contract contains mappings for unknown backend codes: " - f"{sorted(disallowed_unknown_mapping_codes)}" - ) - - _require_translations(catalog.get("frontend", {}).get("errors", {}), manifest_locales, "frontend.errors", failures) - _require_translations(catalog.get("backend", {}).get("errors", {}), manifest_locales, "backend.errors", failures) - - if failures: - print("i18n drift check FAILED") - for failure in failures: - print(f" - {failure}") - return 1 - - print("i18n drift check OK") - print(f" - locales: {sorted(manifest_locales)}") - print(f" - frontend error keys: {len(frontend_catalog)}") - print(f" - backend error codes: {len(backend_code_catalog)}") - print(f" - backend error keys: {len(backend_key_catalog)}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/check_i18n_parity_artifact.py b/scripts/check_i18n_parity_artifact.py deleted file mode 100644 index d8eb091..0000000 --- a/scripts/check_i18n_parity_artifact.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python3 -"""Guard issue #277 parity artifact against non-deterministic regeneration.""" - -from __future__ import annotations - -import hashlib -import subprocess -import sys -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[1] -ARTIFACT_PATH = REPO_ROOT / "docs" / "ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md" -REPORT_SCRIPT = REPO_ROOT / "scripts" / "report_i18n_parity.py" - - -def sha256(path: Path) -> str: - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def main() -> int: - before = sha256(ARTIFACT_PATH) - - for run in range(1, 3): - subprocess.run([sys.executable, str(REPORT_SCRIPT)], cwd=REPO_ROOT, check=True) - after = sha256(ARTIFACT_PATH) - if after != before: - raise SystemExit( - f"issue #277 parity artifact is not deterministic after run {run}: {before} != {after}" - ) - - print(f"issue #277 parity artifact deterministic: {before}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/report_i18n_parity.py b/scripts/report_i18n_parity.py deleted file mode 100644 index 7cdc812..0000000 --- a/scripts/report_i18n_parity.py +++ /dev/null @@ -1,196 +0,0 @@ -#!/usr/bin/env python3 -"""Generate issue #277 shared i18n parity artifact. - -Read-only report over the shared lobby i18n catalog, with focus on MVP-critical -backend/frontend parity used by Django and Angular. -""" - -from __future__ import annotations - -import json -from dataclasses import dataclass -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[1] -CATALOG_PATH = REPO_ROOT / "shared" / "i18n" / "lobby.json" -OUTPUT_PATH = REPO_ROOT / "docs" / "ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md" -ARTIFACT_ID = "issue-277-shared-i18n-parity-report" -ARTIFACT_VERSION = "1.0" - -MVP_FRONTEND_UI_KEYS = [ - "frontend.ui.host.title", - "frontend.ui.player.title", - "frontend.ui.common.session_code", - "frontend.ui.player.nickname", - "frontend.ui.player.join", - "frontend.ui.host.start_round", - "frontend.ui.host.show_question", - "frontend.ui.player.lie_label", - "frontend.ui.player.submit_lie", - "frontend.ui.player.submit_guess", - "frontend.ui.host.mix_answers", - "frontend.ui.host.calculate_scores", - "frontend.ui.host.load_scoreboard", - "frontend.ui.host.final_leaderboard", - "frontend.ui.player.final_leaderboard", - "frontend.ui.common.points_short", -] - -MVP_FRONTEND_ERROR_KEYS = [ - "frontend.errors.session_code_required", - "frontend.errors.session_not_found", - "frontend.errors.nickname_invalid", - "frontend.errors.nickname_taken", - "frontend.errors.join_failed", - "frontend.errors.start_round_failed", - "frontend.errors.unknown", -] - -MVP_BACKEND_CODES = [ - "backend.error_codes.session_code_required", - "backend.error_codes.nickname_invalid", - "backend.error_codes.session_not_found", - "backend.error_codes.session_not_joinable", - "backend.error_codes.nickname_taken", - "backend.error_codes.category_slug_required", - "backend.error_codes.category_not_found", - "backend.error_codes.round_start_invalid_phase", - "backend.error_codes.round_already_configured", -] - - -@dataclass(frozen=True) -class MappingRow: - backend_code: str - backend_key: str - frontend_key: str - locales_ok: bool - parity_status: str - note: str - - -def load_catalog() -> dict: - return json.loads(CATALOG_PATH.read_text(encoding="utf-8")) - - -def get_path(data: dict, dotted: str): - node = data - for part in dotted.split("."): - node = node[part] - return node - - -def translation_state(data: dict, dotted: str) -> tuple[bool, list[str]]: - translations = get_path(data, dotted) - missing = [locale for locale in ("en", "da") if not isinstance(translations.get(locale), str) or not translations[locale].strip()] - return (not missing, missing) - - -def build_mapping_rows(catalog: dict) -> list[MappingRow]: - rows: list[MappingRow] = [] - mapping = catalog["contract"]["backend_to_frontend_error_keys"] - backend_errors = catalog["backend"]["errors"] - - for dotted_code in MVP_BACKEND_CODES: - code = dotted_code.removeprefix("backend.error_codes.") - backend_key = catalog["backend"]["error_codes"][code] - frontend_key = mapping[code] - backend_locales_ok, _ = translation_state(catalog, f"backend.errors.{backend_key}") - frontend_locales_ok, _ = translation_state(catalog, f"frontend.errors.{frontend_key}") - note = "1:1" if code == frontend_key else "many:1 collapse" if frontend_key == "start_round_failed" else "mapped alias" - rows.append( - MappingRow( - backend_code=code, - backend_key=backend_key, - frontend_key=frontend_key, - locales_ok=backend_locales_ok and frontend_locales_ok, - parity_status="aligned" if frontend_key in backend_errors or code == frontend_key else "mapped", - note=note, - ) - ) - return rows - - -def render_report(catalog: dict) -> str: - mapping_rows = build_mapping_rows(catalog) - frontend_ui_ok = all(translation_state(catalog, key)[0] for key in MVP_FRONTEND_UI_KEYS) - frontend_error_ok = all(translation_state(catalog, key)[0] for key in MVP_FRONTEND_ERROR_KEYS) - backend_code_ok = all(translation_state(catalog, f"backend.errors.{catalog['backend']['error_codes'][key.removeprefix('backend.error_codes.')]}" )[0] for key in MVP_BACKEND_CODES) - mapped_frontend_keys = sorted({row.frontend_key for row in mapping_rows}) - collapsed_codes = [row.backend_code for row in mapping_rows if row.frontend_key == "start_round_failed"] - - lines: list[str] = [] - lines.append("# ISSUE-277 Artifact — shared i18n registry parity report (Django ↔ Angular MVP)") - lines.append("") - lines.append("Issue: **#277** (`[READY][#175][P3] Shared i18n registry artifact: backend/frontend keyspace parity report`)") - lines.append("") - lines.append("## Artifact metadata") - lines.append("") - lines.append(f"- `artifact_id`: `{ARTIFACT_ID}`") - lines.append(f"- `artifact_version`: `{ARTIFACT_VERSION}`") - lines.append(f"- `catalog_source`: `{CATALOG_PATH.relative_to(REPO_ROOT)}`") - lines.append(f"- `generator`: `scripts/{Path(__file__).name}`") - lines.append("") - lines.append("## Naming/version rules (email-manager-inspired strategy)") - lines.append("") - lines.append("- **Single canonical artifact per issue**: issue-bundne rapporter navngives `docs/ISSUE---ARTIFACT.md`.") - lines.append("- **Stable artifact identity**: `artifact_id` ændres ikke ved tekstlige opdateringer i samme rapporttype; det er den faste reference i review/ops.") - lines.append("- **Explicit artifact versioning**: `artifact_version` bumpes, når rapportlogik eller scope ændres, så drift/review kan se forskel på format- vs. dataændringer.") - lines.append("- **Shared namespace first**: keys refereres med fulde navnerum (`frontend.ui.*`, `frontend.errors.*`, `backend.error_codes.*`, `backend.errors.*`) i stedet for lokale aliases i artefakter.") - lines.append("- **Source-of-truth before consumers**: rapporten afledes fra `shared/i18n/lobby.json`; Django/Angular beskrives som consumers af samme registry og ikke som parallelle kontrakter.") - lines.append("") - lines.append("## MVP-critical parity summary") - lines.append("") - lines.append(f"- Frontend UI gameplay keys checked: **{len(MVP_FRONTEND_UI_KEYS)}** → `{'OK' if frontend_ui_ok else 'DRIFT'}`") - lines.append(f"- Frontend error keys checked: **{len(MVP_FRONTEND_ERROR_KEYS)}** → `{'OK' if frontend_error_ok else 'DRIFT'}`") - lines.append(f"- Backend gameplay/error codes checked: **{len(MVP_BACKEND_CODES)}** → `{'OK' if backend_code_ok else 'DRIFT'}`") - lines.append(f"- Distinct frontend error keys reached from backend MVP flow: **{len(mapped_frontend_keys)}** (`{', '.join(mapped_frontend_keys)}`)") - lines.append("") - lines.append("Status: **Shared locale matrix is aligned (`en`, `da`) and backend→frontend error handling is contract-complete for MVP-critical flow.**") - lines.append("") - lines.append("## Django ↔ Angular parity matrix (MVP-critical error contract)") - lines.append("") - lines.append("| Backend code (`backend.error_codes.*`) | Django message key (`backend.errors.*`) | Angular key (`frontend.errors.*`) | Locales `en/da` | Parity note |") - lines.append("|---|---|---|---|---|") - for row in mapping_rows: - lines.append( - f"| `{row.backend_code}` | `{row.backend_key}` | `{row.frontend_key}` | `{'OK' if row.locales_ok else 'DRIFT'}` | {row.note} |" - ) - lines.append("") - lines.append("## Scope notes") - lines.append("") - lines.append("- **Django** consumes backend codes/messages directly from `shared/i18n/lobby.json` via `lobby/i18n.py`.") - lines.append("- **Angular** consumes the same registry via `frontend/shared/i18n/lobby-loader.ts` and runtime helpers in `frontend/angular/src/app/lobby-i18n.ts`.") - lines.append("- **Parity in MVP** is therefore strongest on the shared error contract and locale matrix; gameplay UI labels are frontend-owned but still live in the same registry.") - lines.append("") - lines.append("## Verified MVP gameplay UI keyspace present in the shared registry") - lines.append("") - for key in MVP_FRONTEND_UI_KEYS: - lines.append(f"- `{key}`") - lines.append("") - lines.append("## Concrete deviations / follow-up items") - lines.append("") - lines.append(f"1. **Error granularity collapse remains intentional**: backend codes `{', '.join(collapsed_codes)}` all map to `frontend.errors.start_round_failed`. Follow-up only if product wants case-specific Angular copy instead of one shared host failure message.") - lines.append("2. **Frontend-only fallback copy is not mirrored in Django**: `frontend.errors.unknown` and `frontend.errors.session_fetch_failed` are Angular-side resilience keys, not backend contract keys. Follow-up if API responses should expose stable backend equivalents for these states.") - lines.append("3. **Gameplay UI labels are registry-shared but not backend-rendered**: `frontend.ui.host.*`, `frontend.ui.player.*`, and `frontend.ui.common.*` are available in the shared artifact, but Django currently consumes only the backend error slice. Follow-up only if server-rendered views must guarantee the same UI label surface as Angular.") - lines.append("") - lines.append("## Re-run") - lines.append("") - lines.append("```bash") - lines.append("python3 scripts/check_i18n_drift.py") - lines.append("python3 scripts/report_i18n_parity.py") - lines.append("python3 scripts/check_i18n_parity_artifact.py") - lines.append("```") - lines.append("") - return "\n".join(lines) - - -def main() -> int: - catalog = load_catalog() - OUTPUT_PATH.write_text(render_report(catalog), encoding="utf-8") - print(OUTPUT_PATH.relative_to(REPO_ROOT)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/shared/i18n/artifacts/lobby-mvp-keyspace-parity-report.v1.json b/shared/i18n/artifacts/lobby-mvp-keyspace-parity-report.v1.json deleted file mode 100644 index 7ad6e7f..0000000 --- a/shared/i18n/artifacts/lobby-mvp-keyspace-parity-report.v1.json +++ /dev/null @@ -1,158 +0,0 @@ -{ - "artifact_name": "shared.i18n.lobby.mvp_keyspace_parity_report", - "artifact_version": "v1", - "naming_version_rule": "Keep a stable artifact_name and append only explicit schema-major suffixes to the filename/version (v1, v2, ...). Update artifact_version only when the report shape changes; refresh content in-place for catalog/keyspace changes.", - "source_of_truth": { - "catalog": "shared/i18n/lobby.json", - "catalog_sha256": "d9f7227bddd007f2c56f33dfd0015bcffb3b60c52dc756126a02b7e4de638adb", - "source_paths": [ - "lobby/views.py", - "frontend/src/spa/vertical-slice.ts", - "frontend/angular/src/app/lobby-i18n.ts", - "frontend/angular/src/app/features/host/host-shell.component.ts", - "frontend/angular/src/app/features/player/player-shell.component.ts" - ] - }, - "scope": { - "issue": 277, - "related_epic": 175, - "mvp_locales": [ - "en", - "da" - ], - "definition": "MVP-critical keys are the Django error codes emitted by lobby/views.py plus the Angular fallback/UI keys consumed by the host/player MVP shells." - }, - "parity": { - "status": "pass", - "django_backend_error_codes_used_by_mvp": [], - "angular_frontend_error_fallback_keys_used_by_mvp": [ - "join_failed", - "session_code_required", - "session_fetch_failed", - "start_round_failed" - ], - "angular_ui_keys_used_by_mvp": [ - "common.back_to_join", - "common.points_short", - "common.prompt", - "common.refresh", - "common.retry", - "common.round", - "common.round_question_id", - "common.session_code", - "common.status", - "common.unknown_error", - "host.audio_locale_hint", - "host.calculate_scores", - "host.category", - "host.final_leaderboard", - "host.finish_game", - "host.finish_game_failed", - "host.load_scoreboard", - "host.mix_answers", - "host.next_round_failed", - "host.retry_finish", - "host.retry_next_round", - "host.retry_scoreboard", - "host.scoreboard_failed", - "host.session_code_required", - "host.session_refresh_failed", - "host.show_question", - "host.start_next_round", - "host.start_round", - "host.title", - "host.winner", - "player.audio_policy_notice", - "player.final_leaderboard", - "player.guess_submit_failed", - "player.join", - "player.join_failed", - "player.lie_label", - "player.lie_submit_failed", - "player.loading_join", - "player.loading_refresh", - "player.loading_submit_guess", - "player.loading_submit_lie", - "player.nickname", - "player.offline_text", - "player.reconnecting_text", - "player.retry_guess_submit", - "player.retry_lie_submit", - "player.retry_now", - "player.session_refresh_failed", - "player.submit_guess", - "player.submit_lie", - "player.title" - ], - "angular_ui_catalog_paths": [ - "common.back_to_join", - "common.points_short", - "common.prompt", - "common.refresh", - "common.retry", - "common.round", - "common.round_question_id", - "common.session_code", - "common.status", - "common.unknown_error", - "host.audio_locale_hint", - "host.calculate_scores", - "host.category", - "host.final_leaderboard", - "host.finish_game", - "host.finish_game_failed", - "host.load_scoreboard", - "host.mix_answers", - "host.next_round_failed", - "host.retry_finish", - "host.retry_next_round", - "host.retry_scoreboard", - "host.scoreboard_failed", - "host.session_code_required", - "host.session_refresh_failed", - "host.show_question", - "host.start_next_round", - "host.start_round", - "host.title", - "host.winner", - "player.audio_policy_notice", - "player.final_leaderboard", - "player.guess_submit_failed", - "player.join", - "player.join_failed", - "player.lie_label", - "player.lie_submit_failed", - "player.loading_join", - "player.loading_refresh", - "player.loading_submit_guess", - "player.loading_submit_lie", - "player.nickname", - "player.offline_text", - "player.reconnecting_text", - "player.retry_guess_submit", - "player.retry_lie_submit", - "player.retry_now", - "player.session_refresh_failed", - "player.submit_guess", - "player.submit_lie", - "player.title" - ], - "backend_codes_mapped_to_frontend_error_keys": {}, - "unique_frontend_error_keys_reached_from_django": [], - "blocking_issues": { - "missing_backend_codes": [], - "missing_backend_translations": [], - "missing_contract_mappings": [], - "missing_frontend_error_keys": [], - "missing_frontend_runtime_fallbacks": [], - "missing_angular_catalog_paths": [] - }, - "follow_ups": [ - { - "priority": "need-to-have", - "item": "Either add missing backend/error_codes + backend/errors entries for dead contract aliases or remove them from contract.backend_to_frontend_error_keys.", - "evidence": "host_only_action" - } - ] - } -} diff --git a/shared/i18n/key-manifest.json b/shared/i18n/key-manifest.json deleted file mode 100644 index e3b8ab3..0000000 --- a/shared/i18n/key-manifest.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "locales": [ - "en", - "da" - ], - "frontend_error_keys": [ - "join_failed", - "nickname_invalid", - "nickname_taken", - "session_code_required", - "session_fetch_failed", - "session_not_found", - "start_round_failed", - "unknown" - ], - "backend_error_codes": [ - "calculate_scores_invalid_phase", - "category_has_no_questions", - "category_not_found", - "category_slug_required", - "finish_game_invalid_phase", - "guess_already_submitted", - "guess_submission_closed", - "guess_submission_invalid_phase", - "host_only_calculate_scores", - "host_only_finish_game", - "host_only_mix_answers", - "host_only_show_question", - "host_only_start_next_round", - "host_only_start_round", - "host_only_view_scoreboard", - "invalid_player_session_token", - "lie_already_submitted", - "lie_submission_closed", - "lie_submission_invalid_phase", - "lie_text_invalid", - "mix_answers_invalid_phase", - "next_round_invalid_phase", - "nickname_invalid", - "nickname_taken", - "no_available_questions", - "no_guesses_submitted", - "not_enough_answers_to_mix", - "player_id_required", - "player_not_found_in_session", - "question_already_shown", - "round_already_configured", - "round_config_missing", - "round_question_not_found", - "round_start_invalid_phase", - "scoreboard_invalid_phase", - "scores_already_calculated", - "selected_answer_invalid", - "selected_text_invalid", - "session_code_required", - "session_not_found", - "session_not_joinable", - "session_token_required", - "show_question_invalid_phase" - ], - "allowed_contract_only_backend_codes": [ - "host_only_action" - ], - "backend_error_keys": [ - "calculate_scores_invalid_phase", - "category_has_no_questions", - "category_not_found", - "category_slug_required", - "finish_game_invalid_phase", - "guess_already_submitted", - "guess_submission_closed", - "guess_submission_invalid_phase", - "host_only_calculate_scores", - "host_only_finish_game", - "host_only_mix_answers", - "host_only_show_question", - "host_only_start_next_round", - "host_only_start_round", - "host_only_view_scoreboard", - "invalid_player_session_token", - "lie_already_submitted", - "lie_submission_closed", - "lie_submission_invalid_phase", - "lie_text_invalid", - "mix_answers_invalid_phase", - "next_round_invalid_phase", - "nickname_invalid", - "nickname_taken", - "no_available_questions", - "no_guesses_submitted", - "not_enough_answers_to_mix", - "player_id_required", - "player_not_found_in_session", - "question_already_shown", - "round_already_configured", - "round_config_missing", - "round_question_not_found", - "round_start_invalid_phase", - "scoreboard_invalid_phase", - "scores_already_calculated", - "selected_answer_invalid", - "selected_text_invalid", - "session_code_required", - "session_not_found", - "session_not_joinable", - "session_token_required", - "show_question_invalid_phase" - ] -} diff --git a/shared/i18n/lobby.json b/shared/i18n/lobby.json deleted file mode 100644 index 2b5d19a..0000000 --- a/shared/i18n/lobby.json +++ /dev/null @@ -1,743 +0,0 @@ -{ - "locales": { - "default": "en", - "supported": [ - "en", - "da" - ] - }, - "frontend": { - "errors": { - "session_code_required": { - "en": "Session code is required.", - "da": "Sessionskoden er påkrævet." - }, - "session_fetch_failed": { - "en": "Could not load lobby status.", - "da": "Kunne ikke indlæse lobby-status." - }, - "join_failed": { - "en": "Join failed. Check code or nickname and try again.", - "da": "Kunne ikke joine. Tjek kode eller kaldenavn og prøv igen." - }, - "start_round_failed": { - "en": "Could not start round. Refresh the lobby and try again.", - "da": "Kunne ikke starte runden. Opdater lobbyen og prøv igen." - }, - "session_not_found": { - "en": "Session code is invalid or the session no longer exists.", - "da": "Sessionskoden er ugyldig, eller sessionen findes ikke længere." - }, - "nickname_invalid": { - "en": "Nickname must be between 2 and 40 characters.", - "da": "Kaldenavn skal være mellem 2 og 40 tegn." - }, - "nickname_taken": { - "en": "Nickname is already taken.", - "da": "Kaldenavnet er allerede taget." - }, - "unknown": { - "en": "Action failed. Refresh status and try again.", - "da": "Handlingen fejlede. Opdater status og prøv igen." - } - }, - "ui": { - "common": { - "refresh": { - "en": "Refresh", - "da": "Opdatér" - }, - "retry": { - "en": "Retry", - "da": "Prøv igen" - }, - "back_to_join": { - "en": "Back to join", - "da": "Tilbage til join" - }, - "session_code": { - "en": "Session code", - "da": "Sessionskode" - }, - "status": { - "en": "Status", - "da": "Status" - }, - "prompt": { - "en": "Prompt", - "da": "Spørgsmål" - }, - "round_question_id": { - "en": "Round question id", - "da": "Rundespørgsmål-id" - }, - "round": { - "en": "round", - "da": "runde" - }, - "points_short": { - "en": "pts", - "da": "point" - }, - "unknown_error": { - "en": "Unknown error", - "da": "Ukendt fejl" - } - }, - "app": { - "title": { - "en": "WPP Angular Shell", - "da": "WPP Angular Shell" - }, - "host_nav": { - "en": "Host", - "da": "Vært" - }, - "player_nav": { - "en": "Player", - "da": "Spiller" - }, - "language_label": { - "en": "Language", - "da": "Sprog" - } - }, - "host": { - "title": { - "en": "Host gameplay flow", - "da": "Vært gameplay-flow" - }, - "category": { - "en": "Category", - "da": "Kategori" - }, - "start_round": { - "en": "Start round", - "da": "Start runde" - }, - "show_question": { - "en": "Show question", - "da": "Vis spørgsmål" - }, - "mix_answers": { - "en": "Mix answers → guess", - "da": "Bland svar → gæt" - }, - "calculate_scores": { - "en": "Calculate scores → reveal", - "da": "Udregn score → afslør" - }, - "load_scoreboard": { - "en": "Load scoreboard", - "da": "Hent scoreboard" - }, - "start_next_round": { - "en": "Start next round", - "da": "Start næste runde" - }, - "finish_game": { - "en": "Finish game", - "da": "Afslut spil" - }, - "retry_scoreboard": { - "en": "Retry scoreboard", - "da": "Prøv scoreboard igen" - }, - "retry_next_round": { - "en": "Retry next round", - "da": "Prøv næste runde igen" - }, - "retry_finish": { - "en": "Retry finish game", - "da": "Prøv afslutning igen" - }, - "session_refresh_failed": { - "en": "Session refresh failed", - "da": "Kunne ikke opdatere session" - }, - "scoreboard_failed": { - "en": "Scoreboard failed", - "da": "Scoreboard fejlede" - }, - "next_round_failed": { - "en": "Next round failed", - "da": "Næste runde fejlede" - }, - "finish_game_failed": { - "en": "Finish game failed", - "da": "Afslutning fejlede" - }, - "session_code_required": { - "en": "Session code is required", - "da": "Sessionskode er påkrævet" - }, - "final_leaderboard": { - "en": "Final leaderboard", - "da": "Finale leaderboard" - }, - "winner": { - "en": "Winner", - "da": "Vinder" - }, - "audio_locale_hint": { - "en": "Host locale for audio references", - "da": "Værtens locale for lydreferencer" - } - }, - "player": { - "title": { - "en": "Player gameplay flow", - "da": "Spiller gameplay-flow" - }, - "nickname": { - "en": "Nickname", - "da": "Kaldenavn" - }, - "join": { - "en": "Join", - "da": "Join" - }, - "lie_label": { - "en": "Lie", - "da": "Løgn" - }, - "submit_lie": { - "en": "Submit lie", - "da": "Send løgn" - }, - "retry_lie_submit": { - "en": "Retry lie submit", - "da": "Prøv løgn igen" - }, - "submit_guess": { - "en": "Submit guess", - "da": "Send gæt" - }, - "retry_guess_submit": { - "en": "Retry guess submit", - "da": "Prøv gæt igen" - }, - "final_leaderboard": { - "en": "Final leaderboard", - "da": "Finale leaderboard" - }, - "reconnecting_text": { - "en": "Reconnecting… trying to refresh session state.", - "da": "Forbinder igen… prøver at opdatere session." - }, - "offline_text": { - "en": "You are offline. Reconnect to continue gameplay.", - "da": "Du er offline. Forbind igen for at fortsætte." - }, - "retry_now": { - "en": "Retry now", - "da": "Prøv nu" - }, - "loading_refresh": { - "en": "Loading latest session state…", - "da": "Indlæser seneste session-status…" - }, - "loading_join": { - "en": "Joining session… restoring your player state.", - "da": "Joiner session… gendanner spillerstatus." - }, - "loading_submit_lie": { - "en": "Submitting lie… waiting for guess phase.", - "da": "Sender løgn… venter på gættefase." - }, - "loading_submit_guess": { - "en": "Submitting guess… waiting for reveal.", - "da": "Sender gæt… venter på afsløring." - }, - "session_refresh_failed": { - "en": "Session refresh failed", - "da": "Kunne ikke opdatere session" - }, - "join_failed": { - "en": "Join failed", - "da": "Join fejlede" - }, - "lie_submit_failed": { - "en": "Lie submit failed", - "da": "Løgn-fejl" - }, - "guess_submit_failed": { - "en": "Guess submit failed", - "da": "Gætte-fejl" - }, - "audio_policy_notice": { - "en": "Audio playback is disabled on phone clients. Sound is available on the primary host device.", - "da": "Lydafspilning er slået fra på telefon-klienten. Lyd afspilles kun på den primære værtsenhed." - } - } - }, - "capabilities": { - "client_has_no_audio_output": true - } - }, - "backend": { - "error_codes": { - "calculate_scores_invalid_phase": "calculate_scores_invalid_phase", - "category_has_no_questions": "category_has_no_questions", - "category_not_found": "category_not_found", - "category_slug_required": "category_slug_required", - "finish_game_invalid_phase": "finish_game_invalid_phase", - "guess_already_submitted": "guess_already_submitted", - "guess_submission_invalid_phase": "guess_submission_invalid_phase", - "guess_submission_window_closed": "guess_submission_window_closed", - "host_only_calculate_scores": "host_only_calculate_scores", - "host_only_finish_game": "host_only_finish_game", - "host_only_mix_answers": "host_only_mix_answers", - "host_only_show_question": "host_only_show_question", - "host_only_start_next_round": "host_only_start_next_round", - "host_only_start_round": "host_only_start_round", - "host_only_view_scoreboard": "host_only_view_scoreboard", - "invalid_player_session_token": "invalid_player_session_token", - "lie_already_submitted": "lie_already_submitted", - "lie_submission_invalid_phase": "lie_submission_invalid_phase", - "lie_submission_window_closed": "lie_submission_window_closed", - "lie_text_invalid": "lie_text_invalid", - "mix_answers_invalid_phase": "mix_answers_invalid_phase", - "nickname_invalid": "nickname_invalid", - "nickname_taken": "nickname_taken", - "no_available_questions": "no_available_questions", - "no_guesses_submitted": "no_guesses_submitted", - "not_enough_answers_to_mix": "not_enough_answers_to_mix", - "player_id_required": "player_id_required", - "player_not_found_in_session": "player_not_found_in_session", - "question_already_shown": "question_already_shown", - "round_already_configured": "round_already_configured", - "round_config_missing": "round_config_missing", - "round_question_not_found": "round_question_not_found", - "round_start_invalid_phase": "round_start_invalid_phase", - "scoreboard_invalid_phase": "scoreboard_invalid_phase", - "scores_already_calculated": "scores_already_calculated", - "selected_answer_invalid": "selected_answer_invalid", - "selected_text_invalid": "selected_text_invalid", - "session_code_required": "session_code_required", - "session_not_found": "session_not_found", - "session_not_joinable": "session_not_joinable", - "session_token_required": "session_token_required", - "show_question_invalid_phase": "show_question_invalid_phase", - "round_config_missing": "round_config_missing", - "question_already_shown": "question_already_shown", - "no_available_questions": "no_available_questions", - "mix_answers_invalid_phase": "mix_answers_invalid_phase", - "round_question_not_found": "round_question_not_found", - "not_enough_answers_to_mix": "not_enough_answers_to_mix", - "host_only_start_round": "host_only_start_round", - "host_only_show_question": "host_only_show_question", - "host_only_mix_answers": "host_only_mix_answers", - "player_id_required": "player_id_required", - "session_token_required": "session_token_required", - "lie_text_invalid": "lie_text_invalid", - "player_not_found_in_session": "player_not_found_in_session", - "invalid_player_session_token": "invalid_player_session_token", - "lie_submission_closed": "lie_submission_closed", - "lie_already_submitted": "lie_already_submitted", - "host_only_view_scoreboard": "host_only_view_scoreboard", - "scoreboard_invalid_phase": "scoreboard_invalid_phase", - "host_only_start_next_round": "host_only_start_next_round", - "next_round_invalid_phase": "next_round_invalid_phase", - "host_only_finish_game": "host_only_finish_game", - "finish_game_invalid_phase": "finish_game_invalid_phase", - "host_only_calculate_scores": "host_only_calculate_scores", - "scores_already_calculated": "scores_already_calculated", - "calculate_scores_invalid_phase": "calculate_scores_invalid_phase", - "no_guesses_submitted": "no_guesses_submitted", - "guess_submission_closed": "guess_submission_closed", - "selected_answer_invalid": "selected_answer_invalid", - "guess_already_submitted": "guess_already_submitted", - "lie_submission_invalid_phase": "lie_submission_invalid_phase", - "selected_text_invalid": "selected_text_invalid", - "guess_submission_invalid_phase": "guess_submission_invalid_phase" - }, - "errors": { - "calculate_scores_invalid_phase": { - "en": "Scores can only be calculated in guess phase.", - "da": "Score kan kun udregnes i gættefasen." - }, - "category_has_no_questions": { - "en": "Category has no active questions", - "da": "Kategorien har ingen aktive spørgsmål" - }, - "category_not_found": { - "en": "Category not found", - "da": "Kategori blev ikke fundet" - }, - "category_slug_required": { - "en": "category_slug is required", - "da": "category_slug er påkrævet" - }, - "finish_game_invalid_phase": { - "en": "Game can only be finished from scoreboard phase.", - "da": "Spillet kan kun afsluttes fra scoreboard-fasen." - }, - "guess_already_submitted": { - "en": "Guess has already been submitted for this player.", - "da": "Gættet er allerede indsendt for denne spiller." - }, - "guess_submission_invalid_phase": { - "en": "Guess submission is only allowed in guess phase.", - "da": "Gæt kan kun sendes i gættefasen." - }, - "guess_submission_window_closed": { - "en": "Guess submission window has closed.", - "da": "Vinduet for gætindsendelse er lukket." - }, - "host_only_calculate_scores": { - "en": "Only the host can calculate scores.", - "da": "Kun værten kan udregne score." - }, - "host_only_finish_game": { - "en": "Only the host can finish the game.", - "da": "Kun værten kan afslutte spillet." - }, - "host_only_mix_answers": { - "en": "Only host can mix answers", - "da": "Kun værten kan blande svar" - }, - "host_only_show_question": { - "en": "Only host can show question", - "da": "Kun værten kan vise spørgsmålet" - }, - "host_only_start_next_round": { - "en": "Only the host can start the next round.", - "da": "Kun værten kan starte næste runde." - }, - "host_only_start_round": { - "en": "Only host can start round", - "da": "Kun værten kan starte runden" - }, - "host_only_view_scoreboard": { - "en": "Only the host can view the scoreboard.", - "da": "Kun værten kan se scoreboardet." - }, - "invalid_player_session_token": { - "en": "Player session token is invalid.", - "da": "Spillerens session-token er ugyldigt." - }, - "lie_already_submitted": { - "en": "Lie has already been submitted for this player.", - "da": "Løgnen er allerede indsendt for denne spiller." - }, - "lie_submission_invalid_phase": { - "en": "Lie submission is only allowed in lie phase.", - "da": "Løgn kan kun sendes i løgnefasen." - }, - "lie_submission_window_closed": { - "en": "Lie submission window has closed.", - "da": "Vinduet for løgnindsendelse er lukket." - }, - "lie_text_invalid": { - "en": "Text must be between 1 and 255 characters.", - "da": "Tekst skal være mellem 1 og 255 tegn." - }, - "mix_answers_invalid_phase": { - "en": "Answers can only be mixed in lie or guess phase", - "da": "Svar kan kun blandes i løgne- eller gættefasen" - }, - "nickname_invalid": { - "en": "Nickname must be between 2 and 40 characters", - "da": "Kaldenavn skal være mellem 2 og 40 tegn" - }, - "nickname_taken": { - "en": "Nickname already taken", - "da": "Kaldenavnet er allerede taget" - }, - "no_available_questions": { - "en": "No available questions in category", - "da": "Ingen tilgængelige spørgsmål i kategorien" - }, - "no_guesses_submitted": { - "en": "No guesses have been submitted for this round question.", - "da": "Der er ikke indsendt gæt for dette rundespørgsmål." - }, - "not_enough_answers_to_mix": { - "en": "Not enough answers to mix", - "da": "Ikke nok svar at blande" - }, - "player_id_required": { - "en": "Player id is required.", - "da": "Spiller-id er påkrævet." - }, - "player_not_found_in_session": { - "en": "Player was not found in this session.", - "da": "Spilleren blev ikke fundet i denne session." - }, - "question_already_shown": { - "en": "Question already shown for this round", - "da": "Spørgsmålet er allerede vist for denne runde" - }, - "round_already_configured": { - "en": "Round already configured", - "da": "Runden er allerede konfigureret" - }, - "round_config_missing": { - "en": "Round config missing", - "da": "Rundekonfiguration mangler" - }, - "round_question_not_found": { - "en": "Round question not found", - "da": "Rundespørgsmål blev ikke fundet" - }, - "round_start_invalid_phase": { - "en": "Round can only be started from lobby", - "da": "Runden kan kun startes fra lobbyen" - }, - "scoreboard_invalid_phase": { - "en": "Scoreboard is only available in scoreboard phase.", - "da": "Scoreboard er kun tilgængeligt i scoreboard-fasen." - }, - "scores_already_calculated": { - "en": "Scores have already been calculated for this round question.", - "da": "Score er allerede udregnet for dette rundespørgsmål." - }, - "selected_answer_invalid": { - "en": "Selected answer is not part of this round.", - "da": "Det valgte svar er ikke en del af denne runde." - }, - "selected_text_invalid": { - "en": "Selected text must be between 1 and 255 characters.", - "da": "Valgt tekst skal være mellem 1 og 255 tegn." - }, - "session_code_required": { - "en": "Session code is required", - "da": "Sessionskode er påkrævet" - }, - "session_not_found": { - "en": "Session not found", - "da": "Session blev ikke fundet" - }, - "session_not_joinable": { - "en": "Session is not joinable", - "da": "Sessionen kan ikke joine nu" - }, - "session_token_required": { - "en": "Session token is required.", - "da": "Session-token er påkrævet." - }, - "show_question_invalid_phase": { - "en": "Question can only be shown in lie phase", - "da": "Spørgsmålet kan kun vises i løgnefasen" - }, - "round_config_missing": { - "en": "Round config missing", - "da": "Rundekonfiguration mangler" - }, - "question_already_shown": { - "en": "Question already shown for this round", - "da": "Spørgsmålet er allerede vist for denne runde" - }, - "no_available_questions": { - "en": "No available questions in category", - "da": "Ingen tilgængelige spørgsmål i kategorien" - }, - "mix_answers_invalid_phase": { - "en": "Answers can only be mixed in lie or guess phase", - "da": "Svar kan kun blandes i løgne- eller gættefasen" - }, - "round_question_not_found": { - "en": "Round question not found", - "da": "Rundespørgsmål blev ikke fundet" - }, - "not_enough_answers_to_mix": { - "en": "Not enough answers to mix", - "da": "Ikke nok svar at blande" - }, - "host_only_start_round": { - "en": "Only host can start round", - "da": "Kun værten kan starte runden" - }, - "host_only_show_question": { - "en": "Only host can show question", - "da": "Kun værten kan vise spørgsmålet" - }, - "host_only_mix_answers": { - "en": "Only host can mix answers", - "da": "Kun værten kan blande svar" - }, - "player_id_required": { - "en": "player_id is required", - "da": "player_id er påkrævet" - }, - "session_token_required": { - "en": "session_token is required", - "da": "session_token er påkrævet" - }, - "lie_text_invalid": { - "en": "text must be between 1 and 255 characters", - "da": "text skal være mellem 1 og 255 tegn" - }, - "player_not_found_in_session": { - "en": "Player not found in session", - "da": "Spiller blev ikke fundet i sessionen" - }, - "invalid_player_session_token": { - "en": "Invalid player session token", - "da": "Ugyldigt spiller-session-token" - }, - "lie_submission_closed": { - "en": "Lie submission window has closed", - "da": "Vinduet for løgn-indsendelse er lukket" - }, - "lie_already_submitted": { - "en": "Lie already submitted for this player", - "da": "Løgnen er allerede indsendt for denne spiller" - }, - "host_only_view_scoreboard": { - "en": "Only host can view scoreboard", - "da": "Kun værten kan se scoreboard" - }, - "scoreboard_invalid_phase": { - "en": "Scoreboard is only available in reveal/scoreboard phase", - "da": "Scoreboard er kun tilgængeligt i reveal-/scoreboard-fasen" - }, - "host_only_start_next_round": { - "en": "Only host can start next round", - "da": "Kun værten kan starte næste runde" - }, - "next_round_invalid_phase": { - "en": "Next round can only start from scoreboard phase", - "da": "Næste runde kan kun starte fra scoreboard-fasen" - }, - "host_only_finish_game": { - "en": "Only host can finish game", - "da": "Kun værten kan afslutte spillet" - }, - "finish_game_invalid_phase": { - "en": "Game can only be finished from scoreboard phase", - "da": "Spillet kan kun afsluttes fra scoreboard-fasen" - }, - "host_only_calculate_scores": { - "en": "Only host can calculate scores", - "da": "Kun værten kan udregne score" - }, - "scores_already_calculated": { - "en": "Scores already calculated for this round question", - "da": "Score er allerede udregnet for dette rundespørgsmål" - }, - "calculate_scores_invalid_phase": { - "en": "Scores can only be calculated in guess phase", - "da": "Score kan kun udregnes i gættefasen" - }, - "no_guesses_submitted": { - "en": "No guesses submitted for this round question", - "da": "Ingen gæt er indsendt for dette rundespørgsmål" - }, - "guess_submission_closed": { - "en": "Guess submission window has closed", - "da": "Vinduet for gæt-indsendelse er lukket" - }, - "selected_answer_invalid": { - "en": "Selected answer is not part of this round", - "da": "Det valgte svar er ikke en del af denne runde" - }, - "guess_already_submitted": { - "en": "Guess already submitted for this player", - "da": "Gættet er allerede indsendt for denne spiller" - }, - "lie_submission_invalid_phase": { - "en": "Lie submission is only allowed in lie phase", - "da": "Løgn kan kun indsendes i løgnefasen" - }, - "selected_text_invalid": { - "en": "selected_text must be between 1 and 255 characters", - "da": "selected_text skal være mellem 1 og 255 tegn" - }, - "guess_submission_invalid_phase": { - "en": "Guess submission is only allowed in guess phase", - "da": "Gæt kan kun indsendes i gættefasen" - } - } - }, - "contract": { - "ownership": { - "artifact": "shared/i18n/lobby.json", - "backend": "lobby/* reads backend/errors + backend/error_codes", - "frontend": "frontend/* reads frontend/errors + frontend/ui + contract/backend_to_frontend_error_keys" - }, - "locale": { - "default": "en", - "supported": [ - "en", - "da" - ], - "fallback": "Use default locale when requested locale is unsupported or key translation is missing." - }, - "backend_to_frontend_error_keys": { - "calculate_scores_invalid_phase": "unknown", - "category_has_no_questions": "start_round_failed", - "category_not_found": "start_round_failed", - "category_slug_required": "start_round_failed", - "finish_game_invalid_phase": "unknown", - "guess_already_submitted": "unknown", - "guess_submission_invalid_phase": "unknown", - "guess_submission_window_closed": "unknown", - "host_only_action": "start_round_failed", - "host_only_calculate_scores": "unknown", - "host_only_finish_game": "unknown", - "host_only_mix_answers": "start_round_failed", - "host_only_show_question": "start_round_failed", - "host_only_start_next_round": "unknown", - "host_only_start_round": "start_round_failed", - "host_only_view_scoreboard": "unknown", - "invalid_player_session_token": "unknown", - "lie_already_submitted": "unknown", - "lie_submission_invalid_phase": "unknown", - "lie_submission_window_closed": "unknown", - "lie_text_invalid": "unknown", - "mix_answers_invalid_phase": "start_round_failed", - "nickname_invalid": "nickname_invalid", - "nickname_taken": "nickname_taken", - "no_available_questions": "start_round_failed", - "no_guesses_submitted": "unknown", - "not_enough_answers_to_mix": "start_round_failed", - "player_id_required": "unknown", - "player_not_found_in_session": "unknown", - "question_already_shown": "start_round_failed", - "round_already_configured": "start_round_failed", - "round_config_missing": "start_round_failed", - "round_question_not_found": "start_round_failed", - "round_start_invalid_phase": "start_round_failed", - "scoreboard_invalid_phase": "unknown", - "scores_already_calculated": "unknown", - "selected_answer_invalid": "unknown", - "selected_text_invalid": "unknown", - "session_code_required": "session_code_required", - "session_not_found": "session_not_found", - "session_not_joinable": "join_failed", - "session_token_required": "unknown", - "show_question_invalid_phase": "start_round_failed", - "round_config_missing": "start_round_failed", - "question_already_shown": "start_round_failed", - "no_available_questions": "start_round_failed", - "mix_answers_invalid_phase": "start_round_failed", - "round_question_not_found": "start_round_failed", - "not_enough_answers_to_mix": "start_round_failed", - "player_id_required": "unknown", - "session_token_required": "unknown", - "lie_text_invalid": "unknown", - "lie_submission_invalid_phase": "unknown", - "player_not_found_in_session": "unknown", - "invalid_player_session_token": "unknown", - "lie_submission_closed": "unknown", - "lie_already_submitted": "unknown", - "selected_text_invalid": "unknown", - "guess_submission_invalid_phase": "unknown", - "guess_submission_closed": "unknown", - "selected_answer_invalid": "unknown", - "guess_already_submitted": "unknown", - "host_only_view_scoreboard": "unknown", - "scoreboard_invalid_phase": "unknown", - "host_only_start_next_round": "unknown", - "next_round_invalid_phase": "unknown", - "host_only_finish_game": "unknown", - "finish_game_invalid_phase": "unknown", - "host_only_calculate_scores": "unknown", - "scores_already_calculated": "unknown", - "calculate_scores_invalid_phase": "unknown", - "no_guesses_submitted": "unknown" - } - } -} diff --git a/voice/__init__.py b/voice/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/voice/admin.py b/voice/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/voice/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/voice/apps.py b/voice/apps.py deleted file mode 100644 index e3d3469..0000000 --- a/voice/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class VoiceConfig(AppConfig): - name = 'voice' diff --git a/voice/migrations/__init__.py b/voice/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/voice/models.py b/voice/models.py deleted file mode 100644 index 71a8362..0000000 --- a/voice/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/voice/tests.py b/voice/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/voice/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/voice/views.py b/voice/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/voice/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here.