From 95bb09118fdf4a12b631d5d86bbb04bf26b06b7b Mon Sep 17 00:00:00 2001 From: devRaGonSa <97627393+devRaGonSa@users.noreply.github.com> Date: Tue, 19 May 2026 11:13:17 +0200 Subject: [PATCH] feat: resolve scoreboard links and enrich match details Implement trusted scoreboard origins, persisted scoreboard links, RCON-to-scoreboard correlation, match link UX priority, enriched internal match details, and historical UI regression validation. --- ...14-centralize-scoreboard-origin-catalog.md | 151 +++++++++++ ...-115-resolve-persisted-scoreboard-links.md | 141 ++++++++++ ...te-rcon-windows-with-scoreboard-matches.md | 173 ++++++++++++ .../done/TASK-117-prioritize-match-link-ux.md | 150 +++++++++++ ...K-118-enrich-internal-match-detail-page.md | 164 ++++++++++++ ...add-historical-ui-regression-validation.md | 154 +++++++++++ backend/app/historical_storage.py | 101 ++++--- backend/app/payloads.py | 8 +- backend/app/rcon_historical_read_model.py | 14 +- backend/app/rcon_scoreboard_correlation.py | 218 ++++++++++++++++ backend/app/scoreboard_origins.py | 76 ++++++ backend/tests/test_scoreboard_match_links.py | 247 ++++++++++++++++++ docs/decisions.md | 13 + frontend/assets/css/historico.css | 19 ++ frontend/assets/js/historico-partida.js | 141 +++++++++- frontend/assets/js/historico.js | 2 +- frontend/historico-partida.html | 30 +++ .../run-historical-ui-regression-tests.ps1 | 95 +++++++ scripts/run-integration-tests.ps1 | 2 + 19 files changed, 1851 insertions(+), 48 deletions(-) create mode 100644 ai/tasks/done/TASK-114-centralize-scoreboard-origin-catalog.md create mode 100644 ai/tasks/done/TASK-115-resolve-persisted-scoreboard-links.md create mode 100644 ai/tasks/done/TASK-116-correlate-rcon-windows-with-scoreboard-matches.md create mode 100644 ai/tasks/done/TASK-117-prioritize-match-link-ux.md create mode 100644 ai/tasks/done/TASK-118-enrich-internal-match-detail-page.md create mode 100644 ai/tasks/done/TASK-119-add-historical-ui-regression-validation.md create mode 100644 backend/app/rcon_scoreboard_correlation.py create mode 100644 backend/app/scoreboard_origins.py create mode 100644 backend/tests/test_scoreboard_match_links.py create mode 100644 scripts/run-historical-ui-regression-tests.ps1 diff --git a/ai/tasks/done/TASK-114-centralize-scoreboard-origin-catalog.md b/ai/tasks/done/TASK-114-centralize-scoreboard-origin-catalog.md new file mode 100644 index 0000000..bdc7c40 --- /dev/null +++ b/ai/tasks/done/TASK-114-centralize-scoreboard-origin-catalog.md @@ -0,0 +1,151 @@ +--- +id: TASK-114 +title: Centralize scoreboard origin catalog +status: done +type: backend +team: Backend Senior +supporting_teams: + - Arquitecto Python + - PM +roadmap_item: historical +priority: high +--- + +# TASK-114 - Centralize scoreboard origin catalog + +## Goal + +Create a single safe source of truth for trusted public scoreboard origins per active server. + +## Context + +Recent match cards can now fall back to internal details for RCON synthetic matches. The next step is to make trusted external scoreboard origin handling explicit and reusable so future link resolution remains safe. + +Required origin behavior: + +- Comunidad Hispana #01 must use the configured base scoreboard origin for #01, without a custom port. +- Comunidad Hispana #02 must use the same scoreboard host with port `5443`. +- Comunidad Hispana #03 must not be included in public/default origin flows. + +Use branch: + +- `plan/scoreboard-match-linking-tasks` + +## Steps + +1. Work from this task only after moving it to `ai/tasks/in-progress/`. +2. Inspect the listed files before changing anything. +3. Inspect historical storage, RCON historical read/storage modules, config, env example, Docker Compose and relevant docs. +4. Centralize trusted scoreboard origins in a backend helper/config location. +5. Ensure URL validation accepts the trusted origins for #01 and #02, including the #02 `5443` port. +6. Ensure Comunidad Hispana #03 is not part of the trusted scoreboard origin catalog. +7. Keep existing safe `raw_payload_ref` behavior intact. +8. Update or add a concise docs decision if this creates or clarifies a backend contract. +9. Validate the result. +10. Move this task to `ai/tasks/done/` only after validation is complete and document the outcome in this file. +11. Commit and push the completed implementation branch. + +## Files to Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/backend-senior.md` +- `backend/app/config.py` +- `backend/app/historical_storage.py` +- `backend/app/rcon_historical_read_model.py` +- `backend/app/rcon_historical_storage.py` +- `backend/app/payloads.py` +- `backend/app/providers/public_scoreboard_provider.py` +- `backend/.env.example` +- `docker-compose.yml` +- `docs/decisions.md` +- `scripts/run-integration-tests.ps1` + +## Expected Files to Modify + +- likely one backend helper/config module for trusted scoreboard origins +- possibly `backend/app/config.py` +- possibly `backend/app/payloads.py` +- possibly `backend/app/historical_storage.py` +- possibly tests under `backend/tests/` +- possibly `docs/decisions.md` +- this task file, moved to `ai/tasks/done/` + +If additional files become necessary, explain why in the task outcome and commit message. + +## Expected Files Not to Modify + +- `frontend/**` +- local `.env` +- database migrations +- persisted data +- Elo/MMR implementation files +- unrelated backend modules + +## Constraints + +- Do not reintroduce Comunidad Hispana #03. +- Do not reintroduce paused MVP/Elo UI. +- Do not change historical ingestion policy. +- Do not add real credentials. +- Do not modify local `.env`. +- Do not delete persisted data, migrations, backend endpoints or historical ingestion code. +- Do not use the public word "snapshot" in user-facing UI. +- Keep the change focused on trusted scoreboard origin configuration and validation. + +## Validation + +Before completing the task, run and document: + +- `git status` +- Python compile checks for touched backend modules, for example `python -m compileall backend/app` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- focused check confirming #01 and #02 trusted origins exist +- focused check confirming Comunidad Hispana #03 is absent from trusted public/default origins +- focused check confirming #02 preserves port `5443` +- focused check confirming existing safe `raw_payload_ref` behavior still works +- `git diff --name-only` and confirmation that changed files match the expected scope + +If a configured validation command cannot be run, document the exact reason in the outcome. + +## Commit And Push Requirements + +- Run validation before committing. +- Run `git status`. +- Stage only intended files. +- Commit with message: `chore: centralize scoreboard origin catalog` +- Push the branch to origin. +- Do not leave completed work only in local. + +## Outcome + +Completed. + +Implementation decisions: + +- Added `backend/app/scoreboard_origins.py` as the single trusted public scoreboard origin catalog for active default servers. +- Kept only `comunidad-hispana-01` and `comunidad-hispana-02` in the trusted catalog; #02 preserves port `5443`. +- Derived `DEFAULT_HISTORICAL_SERVERS` from the trusted catalog so new default seeds do not reintroduce `comunidad-hispana-03`. +- Routed safe match URL resolution through the trusted catalog instead of trusting each persisted `scoreboard_base_url` row. Existing persisted data is not deleted. +- Updated `docs/decisions.md` with the backend contract for trusted active public scoreboard origins. + +Validation performed: + +- `git status --short --branch` confirmed branch `plan/scoreboard-match-linking-tasks`. +- `python -m compileall backend/app` passed. +- Focused Python check confirmed #01 and #02 trusted origins exist. +- Focused Python check confirmed `comunidad-hispana-03` is absent from trusted public/default origins. +- Focused Python check confirmed #02 preserves port `5443`. +- Focused Python check confirmed safe `raw_payload_ref` behavior still accepts trusted `/games/` URLs and rejects #03, non-`/games/` paths and credentialed URLs. +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` passed. +- `git diff --name-only` / `git status --short` reviewed. Changed files match the expected scope plus the new backend helper module and this task file. + +Follow-up: + +- Continue with TASK-115 for persisted scoreboard link resolution behavior instead of expanding this task. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split follow-up work into a new task if the scope grows. diff --git a/ai/tasks/done/TASK-115-resolve-persisted-scoreboard-links.md b/ai/tasks/done/TASK-115-resolve-persisted-scoreboard-links.md new file mode 100644 index 0000000..19b972b --- /dev/null +++ b/ai/tasks/done/TASK-115-resolve-persisted-scoreboard-links.md @@ -0,0 +1,141 @@ +--- +id: TASK-115 +title: Resolve persisted scoreboard links +status: done +type: backend +team: Backend Senior +supporting_teams: + - Frontend Senior + - PM +roadmap_item: historical +priority: high +--- + +# TASK-115 - Resolve persisted scoreboard links + +## Goal + +Make sure all existing persisted public-scoreboard matches expose a safe `match_url` consistently. + +## Context + +The UI should prefer safe external scoreboard links when the match is already a persisted public-scoreboard match. This task is limited to persisted scoreboard data and must not use RCON synthetic IDs to construct external URLs. + +Use branch: + +- `plan/scoreboard-match-linking-tasks` + +## Steps + +1. Work from this task only after moving it to `ai/tasks/in-progress/`. +2. Inspect the listed files before changing anything. +3. Inspect historical storage detail and recent match payloads. +4. Ensure recent match list responses return `match_url` when `raw_payload_ref` exists and passes trusted origin validation. +5. Ensure the match detail endpoint returns `match_url` for persisted scoreboard matches when `raw_payload_ref` exists and passes trusted origin validation. +6. Do not use RCON synthetic IDs for this task. +7. Do not fabricate external URLs when no trusted persisted URL exists. +8. Add tests or focused checks if feasible in the current repo. +9. Validate the result. +10. Move this task to `ai/tasks/done/` only after validation is complete and document the outcome in this file. +11. Commit and push the completed implementation branch. + +## Files to Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/backend-senior.md` +- `backend/app/historical_storage.py` +- `backend/app/historical_snapshots.py` +- `backend/app/payloads.py` +- `backend/app/routes.py` +- backend trusted scoreboard origin helper/config from TASK-114 +- `backend/tests/` if present +- `scripts/run-integration-tests.ps1` + +## Expected Files to Modify + +- `backend/app/historical_storage.py` +- possibly `backend/app/historical_snapshots.py` +- possibly `backend/app/payloads.py` +- possibly `backend/app/routes.py` +- possibly backend tests +- this task file, moved to `ai/tasks/done/` + +If additional files become necessary, explain why in the task outcome and commit message. + +## Expected Files Not to Modify + +- `frontend/**` +- local `.env` +- database migrations +- persisted data +- Docker/Compose config +- Elo/MMR implementation files +- historical ingestion policy/config + +## Constraints + +- Do not reintroduce Comunidad Hispana #03. +- Do not reintroduce paused MVP/Elo UI. +- Do not change historical ingestion policy. +- Do not add real credentials. +- Do not modify local `.env`. +- Do not delete persisted data, migrations, backend endpoints or historical ingestion code. +- Do not use the public word "snapshot" in user-facing UI. +- Keep the change limited to safe `match_url` exposure for persisted public-scoreboard matches. + +## Validation + +Before completing the task, run and document: + +- `git status` +- Python compile checks for touched backend modules +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- recent matches endpoint check confirming persisted scoreboard matches expose safe `match_url` when available +- match detail endpoint check for a persisted scoreboard match if fixture/data exists +- focused check confirming unsafe or untrusted URLs are not accepted +- focused check confirming RCON synthetic IDs are not used to construct external URLs +- `git diff --name-only` and confirmation that changed files match the expected scope + +If a configured validation command cannot be run, document the exact reason in the outcome. + +## Commit And Push Requirements + +- Run validation before committing. +- Run `git status`. +- Stage only intended files. +- Commit with message: `fix: resolve persisted scoreboard match links` +- Push the branch to origin. +- Do not leave completed work only in local. + +## Outcome + +Completed. + +Implementation decisions: + +- No additional production-code change was needed in this task because TASK-114 already routed recent-match and detail `match_url` resolution through the trusted scoreboard origin catalog. +- Added `backend/tests/test_scoreboard_match_links.py` as a focused stdlib `unittest` regression check for persisted public-scoreboard match links. +- The test verifies recent-match and match-detail payloads expose safe persisted URLs, rejects an untrusted #03 origin, and confirms RCON synthetic match IDs are not used to fabricate external scoreboard URLs. + +Validation performed: + +- `git status --short --branch` confirmed branch `plan/scoreboard-match-linking-tasks`. +- `$env:PYTHONPATH='backend'; python -m unittest backend.tests.test_scoreboard_match_links` passed. +- `python -m compileall backend/app backend/tests` passed. +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` passed. +- Recent matches endpoint behavior was covered by the regression test through `list_recent_historical_matches`. +- Match detail endpoint behavior was covered by the regression test through `get_historical_match_detail`. +- Unsafe/untrusted URL rejection was covered with a persisted #03-origin `raw_payload_ref`. +- RCON synthetic ID non-fabrication was covered with `get_rcon_historical_match_detail`. +- `git diff --name-only` and `git status --short` were reviewed. Changed files match the expected scope: backend test coverage plus this task file. + +Note: + +- The focused unittest emits existing SQLite `ResourceWarning` messages from the repository connection helper pattern during forced cleanup, but all assertions pass and no temp database remains locked. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split follow-up work into a new task if the scope grows. diff --git a/ai/tasks/done/TASK-116-correlate-rcon-windows-with-scoreboard-matches.md b/ai/tasks/done/TASK-116-correlate-rcon-windows-with-scoreboard-matches.md new file mode 100644 index 0000000..5122d13 --- /dev/null +++ b/ai/tasks/done/TASK-116-correlate-rcon-windows-with-scoreboard-matches.md @@ -0,0 +1,173 @@ +--- +id: TASK-116 +title: Correlate RCON windows with scoreboard matches +status: done +type: backend +team: Backend Senior +supporting_teams: + - Arquitecto Python + - Analista + - PM +roadmap_item: historical +priority: high +--- + +# TASK-116 - Correlate RCON windows with scoreboard matches + +## Goal + +Resolve external scoreboard URLs for RCON competitive-window synthetic matches only when there is strong evidence. + +## Context + +RCON competitive-window matches can have synthetic IDs such as `31:2026-04-12T16:28:55.761810Z`. Those IDs must stay internal and must never be used to fabricate external scoreboard URLs. + +Known validation reference from the user: + +- Internal RCON match: + - server: Comunidad Hispana #01 + - synthetic match id: `31:2026-04-12T16:28:55.761810Z` + - map shown: St. Mere Eglise + - UI start/end around 12/4/26 18:28 to 18:43 local time + - players: 94 average / 98 peak +- Equivalent real scoreboard game known by the user: + - game id: `1561515` + - external scoreboard page exists under the server #01 scoreboard origin + +This task must use that example as a fixture/reference without hardcoding a one-off special case. + +Use branch: + +- `plan/scoreboard-match-linking-tasks` + +## Steps + +1. Work from this task only after moving it to `ai/tasks/in-progress/`. +2. Inspect the listed files before changing anything. +3. Build a resolver that takes server, synthetic session key, map, `started_at`, `ended_at`, duration and player counts. +4. Search existing persisted scoreboard data first. +5. If existing provider code can query recent scoreboard data without credentials, use it carefully; otherwise document the limitation and keep the resolver local-only. +6. Match by server, normalized map and time proximity at minimum. +7. Use additional evidence such as duration and player counts when available. +8. Avoid false positives; return no URL when confidence is low. +9. Return only URLs that pass trusted origin validation. +10. Ensure the match detail endpoint exposes `match_url` for RCON matches only when resolver confidence is sufficient. +11. Add a focused unit or fixture test for the correlation logic if feasible. +12. Validate the result. +13. Move this task to `ai/tasks/done/` only after validation is complete and document the outcome in this file. +14. Commit and push the completed implementation branch. + +## Files to Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/backend-senior.md` +- `ai/orchestrator/analyst.md` +- `backend/app/rcon_historical_read_model.py` +- `backend/app/rcon_historical_storage.py` +- `backend/app/historical_storage.py` +- `backend/app/historical_snapshots.py` +- `backend/app/routes.py` +- `backend/app/providers/public_scoreboard_provider.py` +- backend trusted scoreboard origin helper/config from TASK-114 +- `backend/tests/` if present +- `scripts/run-integration-tests.ps1` + +## Expected Files to Modify + +- likely a new or existing backend resolver module +- possibly `backend/app/rcon_historical_read_model.py` +- possibly `backend/app/historical_storage.py` +- possibly `backend/app/routes.py` +- possibly backend tests or fixtures +- this task file, moved to `ai/tasks/done/` + +If additional files become necessary, explain why in the task outcome and commit message. + +## Expected Files Not to Modify + +- `frontend/**` +- local `.env` +- database migrations unless absolutely required and justified +- persisted data +- Docker/Compose config +- Elo/MMR implementation files +- historical ingestion policy/config + +## Constraints + +- Do not reintroduce Comunidad Hispana #03. +- Do not reintroduce paused MVP/Elo UI. +- Do not change historical ingestion policy. +- Do not add real credentials. +- Do not modify local `.env`. +- Do not delete persisted data, migrations, backend endpoints or historical ingestion code. +- Do not use the public word "snapshot" in user-facing UI. +- Do not fabricate unsafe URLs from synthetic RCON IDs. +- Prefer no link over a low-confidence or ambiguous link. + +## Validation + +Before completing the task, run and document: + +- `git status` +- Python compile checks for touched backend modules +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- focused unit or fixture test for correlation logic if feasible +- endpoint check confirming `match_url` appears only when confidence is sufficient +- negative test/check confirming low-confidence or ambiguous candidates return no URL +- check using the user reference example when data or fixture setup allows it +- check confirming generated/exposed URLs pass trusted origin validation +- `git diff --name-only` and confirmation that changed files match the expected scope + +If a configured validation command cannot be run, document the exact reason in the outcome. + +## Commit And Push Requirements + +- Run validation before committing. +- Run `git status`. +- Stage only intended files. +- Commit with message: `feat: correlate rcon matches with scoreboard links` +- Push the branch to origin. +- Do not leave completed work only in local. + +## Outcome + +Completed. + +Implementation decisions: + +- Added `backend/app/rcon_scoreboard_correlation.py` as a local-only resolver over already persisted public-scoreboard matches. +- The resolver does not query the public provider or network during request handling. It searches persisted `historical_matches` first and returns no URL when no strong local match exists. +- `backend/app/rcon_historical_read_model.py` now calls the resolver for RCON match-detail payloads and keeps the synthetic RCON session key internal. +- No URL is built from a synthetic RCON ID. Returned URLs must come from persisted `raw_payload_ref` and pass the trusted origin validation from TASK-114. + +Confidence rules chosen: + +- Required evidence: same server, normalized map match, parseable RCON and scoreboard time windows, and a trusted persisted `raw_payload_ref`. +- Scored evidence: time overlap, RCON midpoint inside the scoreboard match, endpoint proximity, duration compatibility, and optional player-count compatibility. +- Minimum score: `5`. +- Ambiguity handling: if two candidates tie for the best score, the resolver returns no URL. +- Preference: no link over a low-confidence or ambiguous link. + +Validation performed: + +- `git status --short --branch` confirmed branch `plan/scoreboard-match-linking-tasks`. +- `$env:PYTHONPATH='backend'; python -m unittest backend.tests.test_scoreboard_match_links` passed. +- The focused test includes the user reference shape: `comunidad-hispana-01`, RCON synthetic session `1:2026-04-12T16:28:55.761810Z`, map `St. Mere Eglise`, and correlated scoreboard game `1561515`. +- The focused test confirms `match_url` appears only with strong evidence. +- The focused test confirms low-confidence/wrong-map candidates return no URL. +- The focused tests confirm exposed URLs pass trusted origin validation because only persisted trusted `raw_payload_ref` values are returned. +- `python -m compileall backend/app backend/tests` passed. +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` passed. +- `git diff --name-only` and `git status --short` were reviewed. Changed files match the expected scope: new resolver module, RCON read model integration, focused backend tests and this task file. + +Note: + +- The focused unittest still emits existing SQLite `ResourceWarning` messages from the repository connection helper pattern during forced cleanup, but all assertions pass and cleanup is not blocked. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split follow-up work into a new task if the scope grows. diff --git a/ai/tasks/done/TASK-117-prioritize-match-link-ux.md b/ai/tasks/done/TASK-117-prioritize-match-link-ux.md new file mode 100644 index 0000000..add17d6 --- /dev/null +++ b/ai/tasks/done/TASK-117-prioritize-match-link-ux.md @@ -0,0 +1,150 @@ +--- +id: TASK-117 +title: Prioritize match link UX +status: done +type: frontend +team: Frontend Senior +supporting_teams: + - Experto en interfaz + - Backend Senior +roadmap_item: historical-ui +priority: high +--- + +# TASK-117 - Prioritize match link UX + +## Goal + +Adjust the historical UI link priority so safe external scoreboard links are primary when available, and internal details remain the fallback. + +## Context + +Recent cards currently show internal `Ver detalles` links when no safe external match URL exists. After persisted and correlated `match_url` support is improved, the UI must prefer safe external scoreboard links without making unsafe assumptions. + +Use branch: + +- `plan/scoreboard-match-linking-tasks` + +## Steps + +1. Work from this task only after moving it to `ai/tasks/in-progress/`. +2. Inspect the listed files before changing anything. +3. On recent match cards: + - If safe external `match_url` exists, show primary `Ver partida`. + - If no `match_url` exists, show internal `Ver detalles`. +4. On the internal match detail page: + - If `match_url` exists, show `Abrir en scoreboard`. + - If no `match_url` exists, keep internal details only. +5. Ensure external links use `target="_blank"` and `rel="noopener noreferrer"`. +6. Do not show both buttons as competing primary actions unless the existing design clearly requires it. +7. Preserve the current sober military/Vietnam tactical visual style. +8. Validate the result. +9. Move this task to `ai/tasks/done/` only after validation is complete and document the outcome in this file. +10. Commit and push the completed implementation branch. + +## Files to Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/frontend-senior.md` +- `ai/orchestrator/ui-expert.md` +- `frontend/historico.html` +- `frontend/assets/js/historico.js` +- `frontend/assets/css/historico.css` +- `frontend/historico-partida.html` +- `frontend/assets/js/historico-partida.js` +- `backend/app/routes.py` +- `scripts/run-integration-tests.ps1` + +## Expected Files to Modify + +- `frontend/assets/js/historico.js` +- possibly `frontend/assets/css/historico.css` +- possibly `frontend/historico-partida.html` +- possibly `frontend/assets/js/historico-partida.js` +- this task file, moved to `ai/tasks/done/` + +If additional files become necessary, explain why in the task outcome and commit message. + +## Expected Files Not to Modify + +- backend historical ingestion modules +- database migrations +- persisted data +- local `.env` +- Docker/Compose config +- Elo/MMR implementation files +- unrelated frontend pages + +## Constraints + +- Do not reintroduce Comunidad Hispana #03. +- Do not reintroduce paused MVP/Elo UI. +- Do not change historical ingestion policy. +- Do not add real credentials. +- Do not modify local `.env`. +- Do not delete persisted data, migrations, backend endpoints or historical ingestion code. +- Do not use the public word "snapshot" in user-facing UI. +- Do not fabricate or transform external URLs in the frontend; trust only backend-provided `match_url`. + +## Validation + +Before completing the task, run and document: + +- `git status` +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-partida.js` if present +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- served or static HTML check confirming recent cards show `Ver partida` when `match_url` exists +- served or static HTML check confirming recent cards show `Ver detalles` when `match_url` is absent +- served or static HTML check confirming detail page shows `Abrir en scoreboard` when `match_url` exists +- served or static HTML check confirming no Comunidad Hispana #03 appears +- served or static HTML check confirming paused MVP/Elo UI remains absent +- check confirming external links include `target="_blank"` and `rel="noopener noreferrer"` +- `git diff --name-only` and confirmation that changed files match the expected scope + +If a configured validation command cannot be run, document the exact reason in the outcome. + +## Commit And Push Requirements + +- Run validation before committing. +- Run `git status`. +- Stage only intended files. +- Commit with message: `chore: prioritize match link ux` +- Push the branch to origin. +- Do not leave completed work only in local. + +## Outcome + +Completed. + +Implementation decisions: + +- Recent match cards now trust only backend-provided `match_url` for the external primary action. +- Existing priority remains: `Ver partida` when `match_url` exists, otherwise internal `Ver detalles`. +- Existing detail-page behavior already matched the task: it shows `Abrir en scoreboard` only when `match_url` exists and uses `target="_blank"` plus `rel="noopener noreferrer"`. +- No CSS or markup change was needed. + +Validation performed: + +- `git status --short --branch` confirmed branch `plan/scoreboard-match-linking-tasks`. +- `node --check frontend/assets/js/historico.js` passed. +- `node --check frontend/assets/js/historico-partida.js` passed. +- Static Node render check confirmed recent cards show `Ver partida` when `match_url` exists. +- Static Node render check confirmed recent cards show `Ver detalles` when `match_url` is absent. +- Static Node render check confirmed recent external links include `target="_blank"` and `rel="noopener noreferrer"`. +- Static Node render check confirmed the detail-page action shows `Abrir en scoreboard` when `match_url` exists and hides when absent. +- Static Node render check confirmed no `Comunidad Hispana #03` appears in the checked match-card states. +- Static Node render check confirmed paused MVP/Elo UI does not surface in the checked match-card states. +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` passed. +- `git diff --name-only` and `git status --short` were reviewed. Changed files match the expected scope: `frontend/assets/js/historico.js` and this task file. + +Note: + +- The Browser plugin was not available as a callable browser automation tool in this session, so validation used the task-allowed static HTML/JS render checks instead of an in-browser screenshot pass. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split follow-up work into a new task if the scope grows. diff --git a/ai/tasks/done/TASK-118-enrich-internal-match-detail-page.md b/ai/tasks/done/TASK-118-enrich-internal-match-detail-page.md new file mode 100644 index 0000000..c58c0df --- /dev/null +++ b/ai/tasks/done/TASK-118-enrich-internal-match-detail-page.md @@ -0,0 +1,164 @@ +--- +id: TASK-118 +title: Enrich internal match detail page +status: done +type: frontend +team: Frontend Senior +supporting_teams: + - Backend Senior + - Experto en interfaz +roadmap_item: historical-ui +priority: medium +--- + +# TASK-118 - Enrich internal match detail page + +## Goal + +Make the internal match detail page more useful when no scoreboard link exists. + +## Context + +The internal match detail page is the safe fallback for RCON synthetic matches and any match without a trusted external scoreboard URL. It should present all available local data clearly without trying to clone the external scoreboard site. + +Use branch: + +- `plan/scoreboard-match-linking-tasks` + +## Steps + +1. Work from this task only after moving it to `ai/tasks/in-progress/`. +2. Inspect the listed files before changing anything. +3. Show all available RCON/persisted fields clearly: + - server + - map + - start + - end + - duration + - average players + - peak players + - sample count + - result if available + - capture basis + - capabilities +4. If player-level data exists in persisted scoreboard storage, show a simple player table. +5. If player-level data is unavailable, show a clear friendly message. +6. Keep the visual style consistent with HLL Vietnam's sober military/Vietnam tactical identity. +7. Do not attempt to fully clone the scoreboard site yet. +8. Keep the page graceful for partial RCON data. +9. Validate the result. +10. Move this task to `ai/tasks/done/` only after validation is complete and document the outcome in this file. +11. Commit and push the completed implementation branch. + +## Files to Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/frontend-senior.md` +- `ai/orchestrator/ui-expert.md` +- `ai/orchestrator/backend-senior.md` +- `frontend/historico-partida.html` +- `frontend/assets/js/historico-partida.js` +- `frontend/assets/css/historico.css` +- `backend/app/routes.py` +- `backend/app/historical_storage.py` +- `backend/app/rcon_historical_read_model.py` +- `scripts/run-integration-tests.ps1` + +## Expected Files to Modify + +- `frontend/historico-partida.html` +- `frontend/assets/js/historico-partida.js` +- possibly `frontend/assets/css/historico.css` +- possibly `backend/app/routes.py` +- possibly `backend/app/historical_storage.py` +- possibly `backend/app/rcon_historical_read_model.py` +- this task file, moved to `ai/tasks/done/` + +If additional files become necessary, explain why in the task outcome and commit message. + +## Expected Files Not to Modify + +- historical ingestion policy/config +- database migrations unless absolutely required and justified +- persisted data +- local `.env` +- Docker/Compose config +- Elo/MMR implementation files +- unrelated frontend pages + +## Constraints + +- Do not reintroduce Comunidad Hispana #03. +- Do not reintroduce paused MVP/Elo UI. +- Do not change historical ingestion policy. +- Do not add real credentials. +- Do not modify local `.env`. +- Do not delete persisted data, migrations, backend endpoints or historical ingestion code. +- Do not use the public word "snapshot" in user-facing UI. +- Keep the page focused on local/internal match detail presentation. + +## Validation + +Before completing the task, run and document: + +- `git status` +- `node --check frontend/assets/js/historico-partida.js` +- Python compile checks for touched backend modules, if any +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- served or static check confirming the internal detail page renders partial RCON data gracefully +- served or static check confirming persisted scoreboard details render richer data when available +- check confirming a friendly message appears when player-level data is unavailable +- check confirming no Comunidad Hispana #03 appears +- check confirming paused MVP/Elo UI remains absent +- check confirming no public "snapshot" wording appears +- `git diff --name-only` and confirmation that changed files match the expected scope + +If a configured validation command cannot be run, document the exact reason in the outcome. + +## Commit And Push Requirements + +- Run validation before committing. +- Run `git status`. +- Stage only intended files. +- Commit with message: `feat: enrich internal match detail page` +- Push the branch to origin. +- Do not leave completed work only in local. + +## Outcome + +Completed. + +Implementation decisions: + +- Enriched persisted public-scoreboard match detail payloads with `players` rows from local historical storage. +- Added an empty `players` list for RCON competitive-window details so the frontend can treat partial RCON data gracefully. +- Expanded the internal detail page summary to show server, map, start, end, duration, average players, peak players, sample count, result, capture basis and capabilities. +- Added a simple player table for persisted player-level rows. +- Added a friendly unavailable message for RCON windows or persisted matches without player rows. +- Kept the page as a local/internal detail view and did not attempt to clone the external scoreboard. + +Validation performed: + +- `git status --short --branch` confirmed branch `plan/scoreboard-match-linking-tasks`. +- `node --check frontend/assets/js/historico-partida.js` passed. +- `python -m compileall backend/app` passed. +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` passed. +- Static Node render check confirmed partial RCON data renders gracefully with sample count, peak players and capability fields. +- Static Node render check confirmed persisted scoreboard details render a player table when player rows are present. +- Static Node render check confirmed the friendly no-player-data message appears when player-level data is unavailable. +- Static Node render check confirmed no `Comunidad Hispana #03` appears. +- Static Node render check confirmed paused MVP/Elo UI remains absent in the checked detail states. +- Static Node render check confirmed no public `snapshot` wording appears in the checked detail states. +- `$env:PYTHONPATH='backend'; python -m unittest backend.tests.test_scoreboard_match_links` passed after backend payload changes. +- `git diff --name-only` and `git status --short` were reviewed. Changed files match the expected scope: match detail HTML/JS/CSS, backend detail payloads and this task file. + +Note: + +- The focused backend unittest still emits existing SQLite `ResourceWarning` messages from the repository connection helper pattern during forced cleanup, but all assertions pass. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split follow-up work into a new task if the scope grows. diff --git a/ai/tasks/done/TASK-119-add-historical-ui-regression-validation.md b/ai/tasks/done/TASK-119-add-historical-ui-regression-validation.md new file mode 100644 index 0000000..fd8da93 --- /dev/null +++ b/ai/tasks/done/TASK-119-add-historical-ui-regression-validation.md @@ -0,0 +1,154 @@ +--- +id: TASK-119 +title: Add historical UI regression validation +status: done +type: platform +team: Frontend Senior +supporting_teams: + - Backend Senior + - PM +roadmap_item: historical-ui +priority: medium +--- + +# TASK-119 - Add historical UI regression validation + +## Goal + +Add lightweight validation so future changes do not reintroduce removed UI or unsafe historical match-link behavior. + +## Context + +HLL Vietnam has intentionally removed Comunidad Hispana #03 from public/default flows, paused MVP/Elo UI, restored RCON-first historical policy and added internal match details. A small regression validation script/check should protect those decisions without adding heavy test infrastructure. + +Use branch: + +- `plan/scoreboard-match-linking-tasks` + +## Steps + +1. Work from this task only after moving it to `ai/tasks/in-progress/`. +2. Inspect the listed files before changing anything. +3. Extend or add validation scripts/checks compatible with the current repo. +4. Check served or static frontend for: + - no Comunidad Hispana #03 selector. + - no MVP mensual V1/V2 blocks. + - no Comparativa V1 vs V2. + - no Elo/MMR public block. + - no public "snapshot" wording. + - recent cards include either `Ver partida` or `Ver detalles`. +5. Check backend: + - `/health` works. + - historical source remains RCON-first. + - match detail endpoint works. +6. Keep validation lightweight and compatible with local PowerShell usage. +7. Update documentation only if needed to explain how to run the new check. +8. Validate the result. +9. Move this task to `ai/tasks/done/` only after validation is complete and document the outcome in this file. +10. Commit and push the completed implementation branch. + +## Files to Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/frontend-senior.md` +- `ai/orchestrator/backend-senior.md` +- `scripts/run-integration-tests.ps1` +- `frontend/historico.html` +- `frontend/assets/js/historico.js` +- `frontend/historico-partida.html` +- `frontend/assets/js/historico-partida.js` +- `backend/app/config.py` +- `backend/app/routes.py` +- `docs/decisions.md` + +## Expected Files to Modify + +- `scripts/run-integration-tests.ps1` or a focused new validation script under `scripts/` +- possibly documentation for the new validation command +- possibly backend/frontend test fixtures only if needed +- this task file, moved to `ai/tasks/done/` + +If additional files become necessary, explain why in the task outcome and commit message. + +## Expected Files Not to Modify + +- product frontend behavior except minimal test hooks if absolutely required and justified +- backend product behavior except minimal validation hooks if absolutely required and justified +- database migrations +- persisted data +- local `.env` +- Docker/Compose config unless required for validation and explicitly justified +- Elo/MMR implementation files + +## Constraints + +- Do not reintroduce Comunidad Hispana #03. +- Do not reintroduce paused MVP/Elo UI. +- Do not change historical ingestion policy. +- Do not add real credentials. +- Do not modify local `.env`. +- Do not delete persisted data, migrations, backend endpoints or historical ingestion code. +- Do not use the public word "snapshot" in user-facing UI. +- Keep validation lightweight; do not introduce unnecessary frameworks or dependencies. + +## Validation + +Before completing the task, run and document: + +- `git status` +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-partida.js` if present +- Python compile checks for touched backend modules, if any +- the new or updated historical UI regression validation +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- check confirming `/health` works when the backend is served for validation +- check confirming historical source remains RCON-first +- check confirming the match detail endpoint works +- check confirming no Comunidad Hispana #03, MVP mensual V1/V2, Comparativa V1 vs V2, Elo/MMR public block or public "snapshot" wording is present +- check confirming recent cards include either `Ver partida` or `Ver detalles` +- `git diff --name-only` and confirmation that changed files match the expected scope + +If a configured validation command cannot be run, document the exact reason in the outcome. + +## Commit And Push Requirements + +- Run validation before committing. +- Run `git status`. +- Stage only intended files. +- Commit with message: `test: add historical ui regression validation` +- Push the branch to origin. +- Do not leave completed work only in local. + +## Outcome + +Completed. + +Implementation decisions: + +- Added `scripts/run-historical-ui-regression-tests.ps1` as a lightweight PowerShell validation for historical UI guardrails. +- Wired the new script into `scripts/run-integration-tests.ps1` so the regression checks run with the existing platform validation command. +- Kept validation static/route-level by default to avoid adding frontend test frameworks or browser dependencies. +- Added a separate served-backend validation during task execution for `/health` and match detail. + +Validation performed: + +- `git status --short --branch` confirmed branch `plan/scoreboard-match-linking-tasks`. +- `node --check frontend/assets/js/historico.js` passed. +- `node --check frontend/assets/js/historico-partida.js` passed. +- `powershell -ExecutionPolicy Bypass -File scripts/run-historical-ui-regression-tests.ps1` passed. +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` passed and now includes the historical UI regression validation. +- Served backend check passed on `http://127.0.0.1:8765/health`. +- Served backend check confirmed `/health` reports `historical_data_source` as `rcon`. +- Served backend check passed for `/api/historical/matches/detail?server=comunidad-hispana-01&match=regression-check`. +- Regression script confirms no `Comunidad Hispana #03` selector, no MVP mensual V1/V2 blocks, no Comparativa V1 vs V2 block, no Elo/MMR public block and no visible public `snapshot` wording in historical HTML. +- Regression script confirms recent-card code includes `Ver partida` and `Ver detalles`. +- Regression script confirms recent-card code does not trust legacy `source_url` fallback. +- Regression script confirms external detail links keep `target="_blank"` and `rel="noopener noreferrer"`. +- `git diff --name-only` and `git status --short` were reviewed. Changed files match the expected scope: validation scripts and this task file. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split follow-up work into a new task if the scope grows. diff --git a/backend/app/historical_storage.py b/backend/app/historical_storage.py index 4f34051..379773d 100644 --- a/backend/app/historical_storage.py +++ b/backend/app/historical_storage.py @@ -6,7 +6,6 @@ import sqlite3 from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Mapping -from urllib.parse import urlparse from .config import ( get_historical_refresh_overlap_hours, @@ -17,28 +16,22 @@ from .config import ( from .historical_models import HistoricalServerDefinition from .monthly_mvp import build_monthly_mvp_rankings from .monthly_mvp_v2 import build_monthly_mvp_v2_rankings +from .scoreboard_origins import ( + list_trusted_public_scoreboard_origins, + resolve_trusted_scoreboard_match_url, +) from .sqlite_utils import connect_sqlite_writer -DEFAULT_HISTORICAL_SERVERS = ( +DEFAULT_HISTORICAL_SERVERS = tuple( HistoricalServerDefinition( - slug="comunidad-hispana-01", - display_name="Comunidad Hispana #01", - scoreboard_base_url="https://scoreboard.comunidadhll.es", - server_number=1, - ), - HistoricalServerDefinition( - slug="comunidad-hispana-02", - display_name="Comunidad Hispana #02", - scoreboard_base_url="https://scoreboard.comunidadhll.es:5443", - server_number=2, - ), - HistoricalServerDefinition( - slug="comunidad-hispana-03", - display_name="Comunidad Hispana #03", - scoreboard_base_url="https://scoreboard.comunidadhll.es:3443", - server_number=3, - ), + slug=origin.slug, + display_name=origin.display_name, + scoreboard_base_url=origin.base_url, + server_number=origin.server_number, + source_kind=origin.source_kind, + ) + for origin in list_trusted_public_scoreboard_origins() ) ALL_SERVERS_SLUG = "all-servers" ALL_SERVERS_DISPLAY_NAME = "Todos" @@ -768,6 +761,7 @@ def list_recent_historical_matches( historical_matches.allied_score, historical_matches.axis_score, historical_matches.raw_payload_ref, + historical_servers.slug, historical_servers.scoreboard_base_url, COUNT(historical_player_match_stats.id) AS player_count FROM historical_matches @@ -809,7 +803,7 @@ def list_recent_historical_matches( "player_count": int(row["player_count"] or 0), "match_url": _resolve_safe_match_url( row["raw_payload_ref"], - row["scoreboard_base_url"], + row["server_slug"], ), } ) @@ -832,6 +826,7 @@ def get_historical_match_detail( row = connection.execute( """ SELECT + historical_matches.id AS match_pk, historical_servers.slug AS server_slug, historical_servers.display_name AS server_name, historical_matches.external_match_id, @@ -842,6 +837,7 @@ def get_historical_match_detail( historical_matches.allied_score, historical_matches.axis_score, historical_matches.raw_payload_ref, + historical_servers.slug, historical_servers.scoreboard_base_url, COUNT(historical_player_match_stats.id) AS player_count, SUM(COALESCE(historical_player_match_stats.time_seconds, 0)) AS total_time_seconds @@ -857,6 +853,33 @@ def get_historical_match_detail( """, (normalized_server_slug, normalized_match_id), ).fetchone() + player_rows = [] + if row is not None: + player_rows = connection.execute( + """ + SELECT + historical_players.display_name, + historical_players.stable_player_key, + historical_player_match_stats.team_side, + historical_player_match_stats.level, + historical_player_match_stats.kills, + historical_player_match_stats.deaths, + historical_player_match_stats.teamkills, + historical_player_match_stats.combat, + historical_player_match_stats.offense, + historical_player_match_stats.defense, + historical_player_match_stats.support, + historical_player_match_stats.time_seconds + FROM historical_player_match_stats + INNER JOIN historical_players + ON historical_players.id = historical_player_match_stats.historical_player_id + WHERE historical_player_match_stats.historical_match_id = ? + ORDER BY + COALESCE(historical_player_match_stats.kills, 0) DESC, + historical_players.display_name ASC + """, + (row["match_pk"],), + ).fetchall() if row is None: return None started_at = row["started_at"] @@ -885,10 +908,27 @@ def get_historical_match_detail( }, "player_count": int(row["player_count"] or 0), "total_time_seconds": _coerce_int(row["total_time_seconds"]), + "players": [ + { + "name": player_row["display_name"], + "stable_player_key": player_row["stable_player_key"], + "team_side": player_row["team_side"], + "level": _coerce_int(player_row["level"]), + "kills": _coerce_int(player_row["kills"]), + "deaths": _coerce_int(player_row["deaths"]), + "teamkills": _coerce_int(player_row["teamkills"]), + "combat": _coerce_int(player_row["combat"]), + "offense": _coerce_int(player_row["offense"]), + "defense": _coerce_int(player_row["defense"]), + "support": _coerce_int(player_row["support"]), + "time_seconds": _coerce_int(player_row["time_seconds"]), + } + for player_row in player_rows + ], "capture_basis": "public-scoreboard-match", "match_url": _resolve_safe_match_url( row["raw_payload_ref"], - row["scoreboard_base_url"], + row["server_slug"], ), } @@ -3198,23 +3238,8 @@ def _is_all_servers_selector(value: str | None) -> bool: return isinstance(value, str) and value.strip() == ALL_SERVERS_SLUG -def _resolve_safe_match_url(raw_payload_ref: object, scoreboard_base_url: object) -> str | None: - candidate = _stringify(raw_payload_ref) - base_url = _stringify(scoreboard_base_url) - if not candidate or not base_url: - return None - - candidate_parts = urlparse(candidate) - base_parts = urlparse(base_url) - if candidate_parts.scheme not in {"http", "https"}: - return None - if candidate_parts.scheme != base_parts.scheme or candidate_parts.netloc != base_parts.netloc: - return None - if not candidate_parts.path.startswith("/games/"): - return None - if candidate_parts.username or candidate_parts.password: - return None - return candidate +def _resolve_safe_match_url(raw_payload_ref: object, server_slug: object) -> str | None: + return resolve_trusted_scoreboard_match_url(raw_payload_ref, server_slug) def _calculate_match_duration_seconds(started_at: object, ended_at: object) -> int | None: diff --git a/backend/app/payloads.py b/backend/app/payloads.py index 94f8cb4..17c913b 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -39,7 +39,6 @@ from .historical_snapshots import ( ) from .historical_storage import ( ALL_SERVERS_SLUG, - DEFAULT_HISTORICAL_SERVERS, get_historical_match_detail, get_historical_player_profile, list_historical_server_summaries, @@ -50,6 +49,7 @@ from .historical_storage import ( ) from .rcon_historical_read_model import get_rcon_historical_match_detail from .normalizers import normalize_map_name +from .scoreboard_origins import get_trusted_public_scoreboard_origin from .storage import list_latest_snapshots, list_server_history, list_snapshot_history @@ -1910,7 +1910,5 @@ def _resolve_community_history_url(external_server_id: object) -> str | None: normalized_server_id = str(external_server_id or "").strip() if not normalized_server_id: return None - for server in DEFAULT_HISTORICAL_SERVERS: - if server.slug == normalized_server_id: - return f"{server.scoreboard_base_url}/games" - return None + origin = get_trusted_public_scoreboard_origin(normalized_server_id) + return f"{origin.base_url}/games" if origin else None diff --git a/backend/app/rcon_historical_read_model.py b/backend/app/rcon_historical_read_model.py index 689c95d..96792e8 100644 --- a/backend/app/rcon_historical_read_model.py +++ b/backend/app/rcon_historical_read_model.py @@ -6,6 +6,7 @@ from datetime import datetime, timezone from .historical_storage import ALL_SERVERS_SLUG from .normalizers import normalize_map_name +from .rcon_scoreboard_correlation import resolve_rcon_scoreboard_match_url from .rcon_historical_storage import ( find_rcon_historical_competitive_window, get_rcon_historical_competitive_window_by_session, @@ -136,6 +137,8 @@ def get_rcon_historical_match_detail( ) if item is None: return None + player_count = int(round(float(item.get("average_players") or 0))) + server_slug = item["external_server_id"] or item["target_key"] return { "server": { "slug": item["target_key"], @@ -160,9 +163,18 @@ def get_rcon_historical_match_detail( "player_count": int(round(float(item.get("average_players") or 0))), "peak_players": item.get("peak_players"), "sample_count": item.get("sample_count"), + "players": [], "capture_basis": "rcon-competitive-window", "capabilities": item.get("capabilities"), - "match_url": None, + "match_url": resolve_rcon_scoreboard_match_url( + server_slug=server_slug, + map_name=item.get("map_pretty_name") or item.get("map_name"), + started_at=item["first_seen_at"], + ended_at=item["last_seen_at"], + duration_seconds=item.get("duration_seconds"), + player_count=player_count, + peak_players=item.get("peak_players"), + ), } diff --git a/backend/app/rcon_scoreboard_correlation.py b/backend/app/rcon_scoreboard_correlation.py new file mode 100644 index 0000000..5446793 --- /dev/null +++ b/backend/app/rcon_scoreboard_correlation.py @@ -0,0 +1,218 @@ +"""Correlate RCON competitive windows with trusted persisted scoreboard matches.""" + +from __future__ import annotations + +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +from .config import get_storage_path +from .normalizers import normalize_map_name +from .scoreboard_origins import resolve_trusted_scoreboard_match_url +from .sqlite_utils import connect_sqlite_readonly + + +MIN_CONFIDENCE_SCORE = 5 +MAX_CANDIDATES = 200 + + +def resolve_rcon_scoreboard_match_url( + *, + server_slug: object, + map_name: object, + started_at: object, + ended_at: object, + duration_seconds: object = None, + player_count: object = None, + peak_players: object = None, + db_path: Path | None = None, +) -> str | None: + """Return a trusted scoreboard URL for an RCON window only on strong evidence.""" + normalized_server_slug = str(server_slug or "").strip() + normalized_map = normalize_map_name(map_name) + rcon_start = _parse_timestamp(started_at) + rcon_end = _parse_timestamp(ended_at) + if not normalized_server_slug or not normalized_map or not rcon_start or not rcon_end: + return None + if rcon_end < rcon_start: + rcon_start, rcon_end = rcon_end, rcon_start + + candidates = _list_persisted_scoreboard_candidates( + server_slug=normalized_server_slug, + db_path=db_path or get_storage_path(), + ) + scored_candidates = [ + scored + for candidate in candidates + if (scored := _score_candidate( + candidate, + normalized_map=normalized_map, + rcon_start=rcon_start, + rcon_end=rcon_end, + duration_seconds=_coerce_int(duration_seconds), + player_count=_coerce_int(player_count), + peak_players=_coerce_int(peak_players), + )) + is not None + ] + if not scored_candidates: + return None + + scored_candidates.sort(key=lambda item: item["score"], reverse=True) + best = scored_candidates[0] + if int(best["score"]) < MIN_CONFIDENCE_SCORE: + return None + if len(scored_candidates) > 1 and int(scored_candidates[1]["score"]) >= int(best["score"]): + return None + return str(best["match_url"]) + + +def _list_persisted_scoreboard_candidates( + *, + server_slug: str, + db_path: Path, +) -> list[dict[str, object]]: + try: + with connect_sqlite_readonly(db_path) as connection: + rows = connection.execute( + """ + SELECT + historical_matches.external_match_id, + historical_matches.started_at, + historical_matches.ended_at, + historical_matches.map_name, + historical_matches.map_pretty_name, + historical_matches.raw_payload_ref, + historical_servers.slug AS server_slug, + COUNT(historical_player_match_stats.id) AS player_count + FROM historical_matches + INNER JOIN historical_servers + ON historical_servers.id = historical_matches.historical_server_id + LEFT JOIN historical_player_match_stats + ON historical_player_match_stats.historical_match_id = historical_matches.id + WHERE historical_servers.slug = ? + AND historical_matches.raw_payload_ref IS NOT NULL + GROUP BY historical_matches.id + ORDER BY COALESCE(historical_matches.ended_at, historical_matches.started_at) DESC + LIMIT ? + """, + (server_slug, MAX_CANDIDATES), + ).fetchall() + except sqlite3.Error: + return [] + + items: list[dict[str, object]] = [] + for row in rows: + match_url = resolve_trusted_scoreboard_match_url( + row["raw_payload_ref"], + row["server_slug"], + ) + if not match_url: + continue + items.append( + { + "external_match_id": row["external_match_id"], + "started_at": row["started_at"], + "ended_at": row["ended_at"], + "map_name": row["map_name"], + "map_pretty_name": row["map_pretty_name"], + "player_count": row["player_count"], + "match_url": match_url, + } + ) + return items + + +def _score_candidate( + candidate: dict[str, object], + *, + normalized_map: str, + rcon_start: datetime, + rcon_end: datetime, + duration_seconds: int | None, + player_count: int | None, + peak_players: int | None, +) -> dict[str, object] | None: + candidate_map = normalize_map_name( + candidate.get("map_pretty_name") or candidate.get("map_name") + ) + if candidate_map != normalized_map: + return None + + candidate_start = _parse_timestamp(candidate.get("started_at")) + candidate_end = _parse_timestamp(candidate.get("ended_at")) + if not candidate_start or not candidate_end: + return None + if candidate_end < candidate_start: + candidate_start, candidate_end = candidate_end, candidate_start + + score = 0 + overlap_seconds = _overlap_seconds(rcon_start, rcon_end, candidate_start, candidate_end) + rcon_midpoint = rcon_start + (rcon_end - rcon_start) / 2 + if overlap_seconds > 0: + score += 3 + if candidate_start <= rcon_midpoint <= candidate_end: + score += 2 + + closest_edge_distance = min( + abs((rcon_start - candidate_start).total_seconds()), + abs((rcon_start - candidate_end).total_seconds()), + abs((rcon_end - candidate_start).total_seconds()), + abs((rcon_end - candidate_end).total_seconds()), + ) + if closest_edge_distance <= 1800: + score += 2 + elif closest_edge_distance <= 3600: + score += 1 + + candidate_duration = int((candidate_end - candidate_start).total_seconds()) + if duration_seconds and candidate_duration > 0: + if abs(candidate_duration - duration_seconds) <= 1800: + score += 1 + elif overlap_seconds > 0 and duration_seconds <= candidate_duration: + score += 1 + + candidate_players = _coerce_int(candidate.get("player_count")) + reference_players = peak_players or player_count + if candidate_players and reference_players: + if abs(candidate_players - reference_players) <= 20: + score += 1 + elif candidate_players >= int(reference_players * 0.75): + score += 1 + + if score <= 0: + return None + return { + "score": score, + "match_url": candidate["match_url"], + } + + +def _overlap_seconds( + first_start: datetime, + first_end: datetime, + second_start: datetime, + second_end: datetime, +) -> int: + return max(0, int((min(first_end, second_end) - max(first_start, second_start)).total_seconds())) + + +def _parse_timestamp(value: object) -> datetime | None: + if not isinstance(value, str) or not value.strip(): + return None + try: + parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _coerce_int(value: object) -> int | None: + if value is None: + return None + try: + return int(round(float(value))) + except (TypeError, ValueError): + return None diff --git a/backend/app/scoreboard_origins.py b/backend/app/scoreboard_origins.py new file mode 100644 index 0000000..0c14a09 --- /dev/null +++ b/backend/app/scoreboard_origins.py @@ -0,0 +1,76 @@ +"""Trusted public scoreboard origins for active community servers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from urllib.parse import urlparse + + +@dataclass(frozen=True, slots=True) +class TrustedScoreboardOrigin: + """Public scoreboard origin trusted for one active community server.""" + + slug: str + display_name: str + base_url: str + server_number: int + source_kind: str = "crcon-scoreboard-json" + + +TRUSTED_PUBLIC_SCOREBOARD_ORIGINS = ( + TrustedScoreboardOrigin( + slug="comunidad-hispana-01", + display_name="Comunidad Hispana #01", + base_url="https://scoreboard.comunidadhll.es", + server_number=1, + ), + TrustedScoreboardOrigin( + slug="comunidad-hispana-02", + display_name="Comunidad Hispana #02", + base_url="https://scoreboard.comunidadhll.es:5443", + server_number=2, + ), +) + + +def list_trusted_public_scoreboard_origins() -> tuple[TrustedScoreboardOrigin, ...]: + """Return trusted public scoreboard origins for active default servers.""" + return TRUSTED_PUBLIC_SCOREBOARD_ORIGINS + + +def get_trusted_public_scoreboard_origin( + server_slug: object, +) -> TrustedScoreboardOrigin | None: + """Return the trusted public scoreboard origin for one active server.""" + normalized_slug = str(server_slug or "").strip() + if not normalized_slug: + return None + for origin in TRUSTED_PUBLIC_SCOREBOARD_ORIGINS: + if origin.slug == normalized_slug: + return origin + return None + + +def resolve_trusted_scoreboard_match_url( + raw_payload_ref: object, + server_slug: object, +) -> str | None: + """Return a match URL only when it belongs to the trusted server origin.""" + origin = get_trusted_public_scoreboard_origin(server_slug) + candidate = str(raw_payload_ref or "").strip() + if origin is None or not candidate: + return None + + candidate_parts = urlparse(candidate) + origin_parts = urlparse(origin.base_url) + if candidate_parts.scheme not in {"http", "https"}: + return None + if candidate_parts.scheme != origin_parts.scheme: + return None + if candidate_parts.netloc != origin_parts.netloc: + return None + if candidate_parts.username or candidate_parts.password: + return None + if not candidate_parts.path.startswith("/games/"): + return None + return candidate diff --git a/backend/tests/test_scoreboard_match_links.py b/backend/tests/test_scoreboard_match_links.py new file mode 100644 index 0000000..da68f44 --- /dev/null +++ b/backend/tests/test_scoreboard_match_links.py @@ -0,0 +1,247 @@ +"""Regression checks for persisted public-scoreboard match links.""" + +from __future__ import annotations + +import gc +import os +import sqlite3 +import tempfile +import unittest +from pathlib import Path + +from app.historical_storage import ( + get_historical_match_detail, + initialize_historical_storage, + list_recent_historical_matches, + upsert_historical_match, +) +from app.rcon_historical_storage import initialize_rcon_historical_storage +from app.rcon_historical_storage import persist_rcon_historical_sample +from app.rcon_historical_storage import start_rcon_historical_capture_run +from app.rcon_historical_read_model import get_rcon_historical_match_detail + + +class PersistedScoreboardMatchLinkTests(unittest.TestCase): + def test_recent_and_detail_payloads_expose_safe_persisted_match_url(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + match_url = "https://scoreboard.comunidadhll.es:5443/games/12345" + _persist_match(db_path, server_slug="comunidad-hispana-02", match_id="12345") + + recent_items = list_recent_historical_matches( + server_slug="comunidad-hispana-02", + limit=5, + db_path=db_path, + ) + detail = get_historical_match_detail( + server_slug="comunidad-hispana-02", + match_id="12345", + db_path=db_path, + ) + + self.assertEqual(recent_items[0]["match_url"], match_url) + self.assertIsNotNone(detail) + self.assertEqual(detail["match_url"], match_url) + gc.collect() + + def test_untrusted_persisted_match_url_is_not_exposed(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + _persist_match(db_path, server_slug="comunidad-hispana-01", match_id="999") + _set_raw_payload_ref( + db_path, + match_id="999", + raw_payload_ref="https://scoreboard.comunidadhll.es:3443/games/999", + ) + + recent_items = list_recent_historical_matches( + server_slug="comunidad-hispana-01", + limit=5, + db_path=db_path, + ) + detail = get_historical_match_detail( + server_slug="comunidad-hispana-01", + match_id="999", + db_path=db_path, + ) + + self.assertIsNone(recent_items[0]["match_url"]) + self.assertIsNotNone(detail) + self.assertIsNone(detail["match_url"]) + gc.collect() + + def test_rcon_match_detail_does_not_fabricate_external_scoreboard_url(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH") + os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path) + try: + initialize_rcon_historical_storage(db_path=db_path) + detail = get_rcon_historical_match_detail( + server_key="comunidad-hispana-01", + match_id="rcon:synthetic-window", + ) + finally: + if previous_storage_path is None: + os.environ.pop("HLL_BACKEND_STORAGE_PATH", None) + else: + os.environ["HLL_BACKEND_STORAGE_PATH"] = previous_storage_path + + self.assertIsNone(detail) + gc.collect() + + def test_rcon_match_detail_exposes_correlated_scoreboard_url_on_strong_evidence(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH") + os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path) + try: + _persist_match( + db_path, + server_slug="comunidad-hispana-01", + match_id="1561515", + map_name="St. Mere Eglise", + started_at="2026-04-12T16:20:00Z", + ended_at="2026-04-12T17:45:00Z", + ) + session_key = _persist_rcon_window( + db_path, + map_name="St. Mere Eglise", + first_seen_at="2026-04-12T16:28:55.761810Z", + last_seen_at="2026-04-12T16:43:55.761810Z", + players=94, + max_players=98, + ) + + detail = get_rcon_historical_match_detail( + server_key="comunidad-hispana-01", + match_id=session_key, + ) + finally: + if previous_storage_path is None: + os.environ.pop("HLL_BACKEND_STORAGE_PATH", None) + else: + os.environ["HLL_BACKEND_STORAGE_PATH"] = previous_storage_path + + self.assertIsNotNone(detail) + self.assertEqual( + detail["match_url"], + "https://scoreboard.comunidadhll.es/games/1561515", + ) + gc.collect() + + def test_rcon_match_detail_keeps_low_confidence_correlation_unlinked(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH") + os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path) + try: + _persist_match( + db_path, + server_slug="comunidad-hispana-01", + match_id="1561515", + map_name="Carentan", + started_at="2026-04-12T10:00:00Z", + ended_at="2026-04-12T11:30:00Z", + ) + session_key = _persist_rcon_window( + db_path, + map_name="St. Mere Eglise", + first_seen_at="2026-04-12T16:28:55.761810Z", + last_seen_at="2026-04-12T16:43:55.761810Z", + players=94, + max_players=98, + ) + + detail = get_rcon_historical_match_detail( + server_key="comunidad-hispana-01", + match_id=session_key, + ) + finally: + if previous_storage_path is None: + os.environ.pop("HLL_BACKEND_STORAGE_PATH", None) + else: + os.environ["HLL_BACKEND_STORAGE_PATH"] = previous_storage_path + + self.assertIsNotNone(detail) + self.assertIsNone(detail["match_url"]) + gc.collect() + + +def _persist_match( + db_path: Path, + *, + server_slug: str, + match_id: str, + map_name: str = "carentan", + started_at: str = "2026-05-01T10:00:00Z", + ended_at: str = "2026-05-01T11:20:00Z", +) -> None: + upsert_historical_match( + server_slug=server_slug, + match_payload={ + "id": match_id, + "creation_time": started_at, + "start": started_at, + "end": ended_at, + "map": {"name": map_name}, + "result": {"allied": 3, "axis": 2}, + "player_stats": [], + }, + db_path=db_path, + ) + + +def _persist_rcon_window( + db_path: Path, + *, + map_name: str, + first_seen_at: str, + last_seen_at: str, + players: int, + max_players: int, +) -> str: + initialize_rcon_historical_storage(db_path=db_path) + run_id = start_rcon_historical_capture_run( + mode="test", + target_scope="comunidad-hispana-01", + db_path=db_path, + ) + target = { + "target_key": "comunidad-hispana-01", + "external_server_id": "comunidad-hispana-01", + "name": "Comunidad Hispana #01", + "host": "127.0.0.1", + "port": 7779, + } + for captured_at in (first_seen_at, last_seen_at): + persist_rcon_historical_sample( + run_id=run_id, + captured_at=captured_at, + target=target, + normalized_payload={ + "status": "online", + "players": players, + "max_players": max_players, + "current_map": map_name, + }, + raw_payload={}, + db_path=db_path, + ) + return f"1:{first_seen_at}" + + +def _set_raw_payload_ref(db_path: Path, *, match_id: str, raw_payload_ref: str) -> None: + with sqlite3.connect(db_path) as connection: + connection.execute( + """ + UPDATE historical_matches + SET raw_payload_ref = ? + WHERE external_match_id = ? + """, + (raw_payload_ref, match_id), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/decisions.md b/docs/decisions.md index 7e040f8..e8f20f1 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -151,3 +151,16 @@ Esta decision no reactiva Elo/MMR dentro del arranque normal del backend. Las piezas Elo/MMR, migraciones, datos persistidos y modulos historicos se conservan, pero su operativa compleja sigue pausada y desacoplada salvo task explicita. + +## Decision 016: catalogo confiable de scoreboards publicos activos + +Los origenes publicos de scoreboard que el backend puede exponer o validar se +centralizan en un catalogo explicito de servidores activos. En esta fase solo +son confiables `comunidad-hispana-01`, con origen +`https://scoreboard.comunidadhll.es`, y `comunidad-hispana-02`, con origen +`https://scoreboard.comunidadhll.es:5443`. + +`comunidad-hispana-03` no forma parte de ese catalogo ni de los seeds por +defecto nuevos. Los datos historicos ya persistidos no se eliminan, pero las +URLs publicas de partidas solo se aceptan si el `raw_payload_ref` usa HTTP(S), +apunta al origen confiable del servidor activo y mantiene una ruta `/games/`. diff --git a/frontend/assets/css/historico.css b/frontend/assets/css/historico.css index f6abbc5..f54853e 100644 --- a/frontend/assets/css/historico.css +++ b/frontend/assets/css/historico.css @@ -444,6 +444,24 @@ overflow-x: auto; } +.historical-detail-section { + display: grid; + gap: 14px; + margin-top: 18px; +} + +.historical-detail-section__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.historical-detail-section__header h3 { + margin: 0; + font-size: 1.08rem; +} + .historical-table { width: 100%; border-collapse: collapse; @@ -628,6 +646,7 @@ .historical-hero__layout, .historical-hero__topline, .historical-panel__header, + .historical-detail-section__header, .historical-match-card__top { flex-direction: column; align-items: flex-start; diff --git a/frontend/assets/js/historico-partida.js b/frontend/assets/js/historico-partida.js index 8b9003b..b33754b 100644 --- a/frontend/assets/js/historico-partida.js +++ b/frontend/assets/js/historico-partida.js @@ -10,6 +10,11 @@ document.addEventListener("DOMContentLoaded", () => { const stateNode = document.getElementById("match-detail-state"); const gridNode = document.getElementById("match-detail-grid"); const actionsNode = document.getElementById("match-detail-actions"); + const playersSectionNode = document.getElementById("match-detail-players-section"); + const playersNoteNode = document.getElementById("match-detail-players-note"); + const playersStateNode = document.getElementById("match-detail-players-state"); + const playersTableShellNode = document.getElementById("match-detail-players-table-shell"); + const playersBodyNode = document.getElementById("match-detail-players-body"); if (!serverSlug || !matchId) { titleNode.textContent = "Partida no seleccionada"; @@ -29,6 +34,11 @@ document.addEventListener("DOMContentLoaded", () => { stateNode, gridNode, actionsNode, + playersSectionNode, + playersNoteNode, + playersStateNode, + playersTableShellNode, + playersBodyNode, }); }); @@ -42,6 +52,11 @@ async function loadMatchDetail({ stateNode, gridNode, actionsNode, + playersSectionNode, + playersNoteNode, + playersStateNode, + playersTableShellNode, + playersBodyNode, }) { try { const payload = await fetchJson( @@ -68,6 +83,11 @@ async function loadMatchDetail({ stateNode, gridNode, actionsNode, + playersSectionNode, + playersNoteNode, + playersStateNode, + playersTableShellNode, + playersBodyNode, }); } catch (error) { titleNode.textContent = "Detalle no disponible"; @@ -79,7 +99,19 @@ async function loadMatchDetail({ function renderMatchDetail( item, - { titleNode, summaryNode, noteNode, stateNode, gridNode, actionsNode }, + { + titleNode, + summaryNode, + noteNode, + stateNode, + gridNode, + actionsNode, + playersSectionNode, + playersNoteNode, + playersStateNode, + playersTableShellNode, + playersBodyNode, + }, ) { const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible"; const serverName = item.server?.name || "Servidor no disponible"; @@ -88,19 +120,78 @@ function renderMatchDetail( noteNode.textContent = buildDetailNote(item); gridNode.innerHTML = [ renderDetailCard("Servidor", serverName), + renderDetailCard("Mapa", mapName), renderDetailCard("Inicio", formatTimestamp(item.started_at)), - renderDetailCard("Cierre", formatTimestamp(item.closed_at || item.ended_at)), + renderDetailCard("Fin", formatTimestamp(item.closed_at || item.ended_at)), renderDetailCard("Duracion", formatDuration(item.duration_seconds)), - renderDetailCard("Jugadores", formatPlayerCount(item)), + renderDetailCard("Jugadores media", formatNumber(item.player_count)), + renderDetailCard("Pico jugadores", formatOptionalNumber(item.peak_players)), + renderDetailCard("Muestras RCON", formatOptionalNumber(item.sample_count)), renderDetailCard("Marcador", formatScore(item.result)), renderDetailCard("Resultado", formatMatchResult(item.result)), renderDetailCard("Base de captura", formatCaptureBasis(item.capture_basis)), + renderDetailCard("Capacidades", formatCapabilities(item.capabilities)), ].join(""); + renderPlayerSection(item, { + playersSectionNode, + playersNoteNode, + playersStateNode, + playersTableShellNode, + playersBodyNode, + }); renderActions(item, actionsNode); stateNode.hidden = true; gridNode.hidden = false; } +function renderPlayerSection( + item, + { + playersSectionNode, + playersNoteNode, + playersStateNode, + playersTableShellNode, + playersBodyNode, + }, +) { + const players = Array.isArray(item.players) ? item.players : []; + playersSectionNode.hidden = false; + if (players.length === 0) { + playersNoteNode.textContent = + "Esta partida no tiene estadisticas por jugador disponibles en el detalle interno."; + setState( + playersStateNode, + item.capture_basis === "rcon-competitive-window" + ? "Las ventanas RCON actuales no incluyen desglose por jugador." + : "No hay filas de jugador registradas para esta partida.", + ); + playersTableShellNode.hidden = true; + playersBodyNode.innerHTML = ""; + return; + } + + playersNoteNode.textContent = `${formatNumber(players.length)} jugadores con estadisticas locales.`; + playersStateNode.hidden = true; + playersBodyNode.innerHTML = players.map((player) => renderPlayerRow(player)).join(""); + playersTableShellNode.hidden = false; +} + +function renderPlayerRow(player) { + return ` + + ${escapeHtml(player.name || "Jugador no identificado")} + ${escapeHtml(formatTeamSide(player.team_side))} + ${escapeHtml(formatOptionalNumber(player.level))} + ${escapeHtml(formatOptionalNumber(player.kills))} + ${escapeHtml(formatOptionalNumber(player.deaths))} + ${escapeHtml(formatOptionalNumber(player.teamkills))} + ${escapeHtml(formatOptionalNumber(player.combat))} + ${escapeHtml(formatOptionalNumber(player.support))} + ${escapeHtml(formatDuration(player.time_seconds))} + + `; +} + function renderActions(item, actionsNode) { const matchUrl = normalizeExternalMatchUrl(item.match_url); if (!matchUrl) { @@ -154,6 +245,50 @@ function formatPlayerCount(item) { return formatNumber(item.player_count); } +function formatOptionalNumber(value) { + return value === null || value === undefined ? "No disponible" : formatNumber(value); +} + +function formatCapabilities(capabilities) { + if (!capabilities || typeof capabilities !== "object") { + return "No disponibles"; + } + const labels = Object.entries(capabilities) + .filter(([, value]) => value !== null && value !== undefined) + .map(([key, value]) => `${formatCapabilityKey(key)}: ${formatCapabilityValue(value)}`); + return labels.length > 0 ? labels.join(" | ") : "No disponibles"; +} + +function formatCapabilityKey(key) { + return String(key).replaceAll("_", " "); +} + +function formatCapabilityValue(value) { + if (value === "exact") { + return "exacto"; + } + if (value === "approximate") { + return "aproximado"; + } + if (value === "partial") { + return "parcial"; + } + if (value === "unavailable") { + return "no disponible"; + } + return String(value); +} + +function formatTeamSide(value) { + if (value === "allies") { + return "Aliados"; + } + if (value === "axis") { + return "Axis"; + } + return value || "No disponible"; +} + function formatDuration(value) { const seconds = Number(value); if (!Number.isFinite(seconds) || seconds <= 0) { diff --git a/frontend/assets/js/historico.js b/frontend/assets/js/historico.js index 6fa9bf4..601a4ab 100644 --- a/frontend/assets/js/historico.js +++ b/frontend/assets/js/historico.js @@ -802,7 +802,7 @@ function hydrateMvpComparison( function renderRecentMatchCard(item) { const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible"; - const matchUrl = normalizeExternalMatchUrl(item.match_url || item.source_url); + const matchUrl = normalizeExternalMatchUrl(item.match_url); const detailUrl = buildInternalMatchDetailUrl(item); const matchLink = matchUrl ? ` diff --git a/frontend/historico-partida.html b/frontend/historico-partida.html index b6905a4..9074b5e 100644 --- a/frontend/historico-partida.html +++ b/frontend/historico-partida.html @@ -63,6 +63,36 @@ Cargando detalle...

+ diff --git a/scripts/run-historical-ui-regression-tests.ps1 b/scripts/run-historical-ui-regression-tests.ps1 new file mode 100644 index 0000000..f4eb42a --- /dev/null +++ b/scripts/run-historical-ui-regression-tests.ps1 @@ -0,0 +1,95 @@ +$ErrorActionPreference = "Stop" + +Write-Host "Historical UI regression validation" + +function Assert-Contains { + param( + [string] $Content, + [string] $Pattern, + [string] $Message + ) + + if ($Content -notmatch [regex]::Escape($Pattern)) { + throw $Message + } +} + +function Assert-NotContains { + param( + [string] $Content, + [string] $Pattern, + [string] $Message + ) + + if ($Content -match [regex]::Escape($Pattern)) { + throw $Message + } +} + +function Get-VisibleText { + param([string] $Html) + + return ($Html -replace "", " " ` + -replace "", " " ` + -replace "<[^>]+>", " ") +} + +$historicoHtml = Get-Content -Raw "frontend/historico.html" +$historicoPartidaHtml = Get-Content -Raw "frontend/historico-partida.html" +$historicoJs = Get-Content -Raw "frontend/assets/js/historico.js" +$historicoPartidaJs = Get-Content -Raw "frontend/assets/js/historico-partida.js" +$visibleHistoricalText = "$(Get-VisibleText $historicoHtml) $(Get-VisibleText $historicoPartidaHtml)" + +Assert-NotContains $historicoHtml 'data-server-slug="comunidad-hispana-03"' ` + "Comunidad Hispana #03 selector was reintroduced." +Assert-NotContains $historicoHtml "MVP mensual V1" ` + "MVP mensual V1 block was reintroduced in visible historical HTML." +Assert-NotContains $historicoHtml "MVP mensual V2" ` + "MVP mensual V2 block was reintroduced in visible historical HTML." +Assert-NotContains $historicoHtml "Comparativa V1 vs V2" ` + "Comparativa V1 vs V2 block was reintroduced in visible historical HTML." +Assert-NotContains $historicoHtml "Elo/MMR" ` + "Elo/MMR public block was reintroduced in visible historical HTML." +Assert-NotContains $visibleHistoricalText "snapshot" ` + "Public snapshot wording was reintroduced in visible historical text." + +Assert-Contains $historicoJs "Ver partida" ` + "Recent match cards no longer include the external match action label." +Assert-Contains $historicoJs "Ver detalles" ` + "Recent match cards no longer include the internal detail fallback label." +Assert-NotContains $historicoJs "item.match_url || item.source_url" ` + "Recent match cards must not trust legacy source_url fallback." +Assert-Contains $historicoPartidaJs "Abrir en scoreboard" ` + "Match detail page no longer includes the external scoreboard action label." +Assert-Contains $historicoPartidaJs 'rel="noopener noreferrer"' ` + "External scoreboard links must keep rel noopener noreferrer." +Assert-Contains $historicoPartidaJs 'target="_blank"' ` + "External scoreboard links must open in a new tab." + +$backendCheck = @' +import sys +sys.path.insert(0, "backend") + +from app.config import get_historical_data_source_kind +from app.routes import resolve_get_payload + +status, payload = resolve_get_payload("/health") +if status is None or payload.get("status") != "ok": + raise SystemExit("/health did not resolve to an ok payload.") +if payload.get("historical_data_source") != "rcon": + raise SystemExit("/health no longer reports RCON-first historical source.") +if get_historical_data_source_kind() != "rcon": + raise SystemExit("Configured historical source is no longer rcon.") + +detail_status, detail_payload = resolve_get_payload( + "/api/historical/matches/detail?server=comunidad-hispana-01&match=regression-check" +) +if detail_status is None or detail_payload.get("status") != "ok": + raise SystemExit("Match detail endpoint did not resolve successfully.") +if detail_payload.get("data", {}).get("context") != "historical-match-detail": + raise SystemExit("Match detail endpoint context changed unexpectedly.") +'@ + +$backendCheck | python - + +Write-Host "Historical UI regression validation passed." diff --git a/scripts/run-integration-tests.ps1 b/scripts/run-integration-tests.ps1 index 9cf8438..7fab467 100644 --- a/scripts/run-integration-tests.ps1 +++ b/scripts/run-integration-tests.ps1 @@ -55,6 +55,8 @@ if status is None or payload.get("status") != "ok": $backendImportCheck | python - +powershell -ExecutionPolicy Bypass -File scripts/run-historical-ui-regression-tests.ps1 + Write-Host "No product integration tests are configured for this platform-only scope." Write-Host "Backend startup import check passed." Write-Host "Platform validation passed."