feat: add current match killfeed overlay and player stats
This commit is contained in:
@@ -19,3 +19,4 @@ Date | Task | Duration | Result | Notes
|
|||||||
2026-05-19T17:20:44 | worker-cycle | 2240.22 sec | success | codex-runner
|
2026-05-19T17:20:44 | worker-cycle | 2240.22 sec | success | codex-runner
|
||||||
2026-05-21T14:43:44 | worker-cycle | 5580.9 sec | success | codex-runner
|
2026-05-21T14:43:44 | worker-cycle | 5580.9 sec | success | codex-runner
|
||||||
2026-05-21T15:17:18 | worker-cycle | 688.86 sec | success | codex-runner
|
2026-05-21T15:17:18 | worker-cycle | 688.86 sec | success | codex-runner
|
||||||
|
2026-05-21T18:59:27 | worker-cycle | 527.54 sec | success | codex-runner
|
||||||
|
|||||||
198
ai/tasks/done/TASK-154-current-match-killfeed-overlay-layout.md
Normal file
198
ai/tasks/done/TASK-154-current-match-killfeed-overlay-layout.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# TASK-154 - Current match killfeed overlay layout
|
||||||
|
|
||||||
|
Status: in-progress
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Redesign the current-match kill feed frontend so it behaves like a compact live killfeed overlay.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
The current-match page already renders a kill feed using:
|
||||||
|
|
||||||
|
- `GET /api/current-match/kills?server=...`
|
||||||
|
|
||||||
|
The feed currently appears as a vertical list of large historical-style cards. This is not the desired UX.
|
||||||
|
|
||||||
|
## Desired UX
|
||||||
|
|
||||||
|
The kill feed should look like a compact live FPS-style overlay:
|
||||||
|
|
||||||
|
- A rectangular live panel.
|
||||||
|
- Events rendered as compact rows/chips.
|
||||||
|
- Each event should show:
|
||||||
|
- killer name
|
||||||
|
- weapon icon or weapon label fallback
|
||||||
|
- victim name
|
||||||
|
- Events should be arranged in three visual columns inside the panel.
|
||||||
|
- New events should appear progressively.
|
||||||
|
- Older events should move left and eventually disappear.
|
||||||
|
- The feed should feel like a real-time combat screen, not a historical list.
|
||||||
|
|
||||||
|
The user explicitly wants:
|
||||||
|
|
||||||
|
- "una especie de pantalla en tiempo real"
|
||||||
|
- "simplemente se muestre el texto, el que mata, el arma y alguien mata"
|
||||||
|
- "iconos del arma que se utiliza"
|
||||||
|
- "una pequeña pantalla rectangular"
|
||||||
|
- "se irá poniendo de arriba abajo en tres columnas"
|
||||||
|
- "se irá desplazando hacia la izquierda e irán desapareciendo las más antiguas"
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Replace the current large-card kill feed with a compact live overlay on the current-match page.
|
||||||
|
|
||||||
|
Allowed changes:
|
||||||
|
|
||||||
|
- `frontend/assets/js/partida-actual.js`
|
||||||
|
- `frontend/partida-actual.html` if needed
|
||||||
|
- `frontend/assets/css/historico.css` or the relevant CSS used by `partida-actual.html`
|
||||||
|
- `frontend/assets/css/styles.css` only if the current-match page depends on it
|
||||||
|
- `frontend/assets/img/weapons/*` if local weapon icons/placeholders are added
|
||||||
|
- backend only if a small weapon normalization field is needed, but prefer frontend-side mapping first
|
||||||
|
- focused tests or node validation
|
||||||
|
|
||||||
|
## Constraints - DO NOT BREAK
|
||||||
|
|
||||||
|
- Do not break `/api/current-match/kills`.
|
||||||
|
- Do not expose raw AdminLog lines.
|
||||||
|
- Do not fabricate kill events.
|
||||||
|
- Do not show stale kills as live kills.
|
||||||
|
- Do not break the current-match scoreboard/header.
|
||||||
|
- Do not break historical match detail pages.
|
||||||
|
- Do not query RCON directly from the frontend.
|
||||||
|
- Do not depend on server #03.
|
||||||
|
- Do not require external/CDN assets at runtime.
|
||||||
|
- If weapon icons are added, they must be local/static assets or generated lightweight inline/SVG placeholders.
|
||||||
|
- Keep the UI responsive.
|
||||||
|
|
||||||
|
## Files to inspect first
|
||||||
|
|
||||||
|
Read:
|
||||||
|
|
||||||
|
- `frontend/partida-actual.html`
|
||||||
|
- `frontend/assets/js/partida-actual.js`
|
||||||
|
- the CSS currently used by `frontend/partida-actual.html`
|
||||||
|
- focused current-match kill feed tests or validation scripts if present
|
||||||
|
|
||||||
|
Inspect the current kill feed rendering, `event_id` handling, scope copy, and current polling behavior before changing code.
|
||||||
|
|
||||||
|
## Implementation requirements
|
||||||
|
|
||||||
|
### 1. Compact live panel
|
||||||
|
|
||||||
|
Replace the current large-card feed rendering with a compact live killfeed panel.
|
||||||
|
|
||||||
|
The panel must be visually rectangular and compact. It should look like a live combat overlay, not a list of historical cards.
|
||||||
|
|
||||||
|
### 2. Layout
|
||||||
|
|
||||||
|
- Use a three-column visual layout on desktop.
|
||||||
|
- Events should fill vertically within a column, then continue through the next visual position.
|
||||||
|
- Newer events should be visually prioritized.
|
||||||
|
- Older events should shift left and disappear once the maximum number of visible events is exceeded.
|
||||||
|
- On narrow/mobile widths, fall back to one or two columns without overflow.
|
||||||
|
|
||||||
|
### 3. Event content
|
||||||
|
|
||||||
|
Each kill event must show:
|
||||||
|
|
||||||
|
- killer name
|
||||||
|
- weapon icon or weapon label
|
||||||
|
- victim name
|
||||||
|
- optional timestamp only if it does not make the UI noisy
|
||||||
|
- teamkill indicator if `is_teamkill` is true
|
||||||
|
|
||||||
|
### 4. Weapon icons
|
||||||
|
|
||||||
|
Add a safe mapping for common weapons currently seen in AdminLog examples:
|
||||||
|
|
||||||
|
- `M1 GARAND`
|
||||||
|
- `MP40`
|
||||||
|
- `M1A1 THOMPSON`
|
||||||
|
- `UNKNOWN`
|
||||||
|
|
||||||
|
Prefer local SVG/icon placeholders if real weapon assets are not available.
|
||||||
|
|
||||||
|
- Do not hotlink external images.
|
||||||
|
- Unknown weapons must show a generic weapon icon/label fallback.
|
||||||
|
- The icon mapping should be easy to extend later.
|
||||||
|
|
||||||
|
### 5. Motion and transition
|
||||||
|
|
||||||
|
- Use CSS transitions/animations only if they are subtle.
|
||||||
|
- Avoid layout jumps.
|
||||||
|
- Respect users with reduced motion if possible.
|
||||||
|
- The feed should not flicker every poll.
|
||||||
|
|
||||||
|
### 6. Deduplication
|
||||||
|
|
||||||
|
- Preserve existing `event_id` deduplication.
|
||||||
|
- Re-rendering must not duplicate rows.
|
||||||
|
- Repeated polling must keep already visible events stable.
|
||||||
|
|
||||||
|
### 7. Maximum visible events
|
||||||
|
|
||||||
|
- Limit visible events to a reasonable number, for example 12 or 15.
|
||||||
|
- Older events should be dropped from the visual panel.
|
||||||
|
- Do not render an infinitely growing list.
|
||||||
|
|
||||||
|
### 8. Copy
|
||||||
|
|
||||||
|
Use these messages:
|
||||||
|
|
||||||
|
- If there are no events: "Todavía no se han detectado bajas en esta partida."
|
||||||
|
- If scope is open match window: "Bajas detectadas en la partida actual."
|
||||||
|
- If scope is `recent-admin-log-window`: "Cobertura parcial desde AdminLog reciente."
|
||||||
|
- If stale/no current events: "Sin bajas recientes asociadas a la partida actual."
|
||||||
|
|
||||||
|
### 9. Accessibility
|
||||||
|
|
||||||
|
- Keep `aria-live` polite or equivalent.
|
||||||
|
- Event text must remain readable even if icons fail.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
- `node --check frontend/assets/js/partida-actual.js`
|
||||||
|
- any existing frontend validation if available
|
||||||
|
|
||||||
|
## Manual verification checklist
|
||||||
|
|
||||||
|
- Open `http://localhost:8080/partida-actual.html?server=comunidad-hispana-01`.
|
||||||
|
- Open `http://localhost:8080/partida-actual.html?server=comunidad-hispana-02`.
|
||||||
|
- Verify kill feed appears as a compact rectangular live overlay.
|
||||||
|
- Verify events render as killer -> weapon/icon -> victim.
|
||||||
|
- Verify it uses three columns on desktop.
|
||||||
|
- Verify old events disappear instead of growing endlessly.
|
||||||
|
- Verify repeated polling does not duplicate events.
|
||||||
|
- Verify teamkills are visually distinguishable.
|
||||||
|
- Verify unknown weapons use a clean fallback.
|
||||||
|
- Verify no raw AdminLog line is shown.
|
||||||
|
- Verify no external image URLs are required.
|
||||||
|
|
||||||
|
## Expected outcome
|
||||||
|
|
||||||
|
The current-match kill feed looks and behaves like a compact live FPS-style killfeed overlay with local/fallback weapon icon support.
|
||||||
|
|
||||||
|
## AI Platform lifecycle
|
||||||
|
|
||||||
|
After implementation and validation:
|
||||||
|
|
||||||
|
- Move this task according to the lifecycle defined in `AGENTS.md`.
|
||||||
|
- Do not mark unrelated tasks as done automatically.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- Replaced the historical-style kill cards with a capped 15-event rectangular overlay in `partida-actual`.
|
||||||
|
- Kept `event_id` deduplication and avoided poll flicker by only re-rendering when the visible event-id set changes.
|
||||||
|
- Rendered older visible events first so they occupy the left side of the three-column desktop panel while newer events remain visually prioritized on the right.
|
||||||
|
- Added local text-glyph weapon placeholders for `M1 GARAND`, `MP40`, `M1A1 THOMPSON` and unknown/other weapons without external assets.
|
||||||
|
- Validation run:
|
||||||
|
- `node --check frontend/assets/js/partida-actual.js`
|
||||||
|
- `scripts/run-historical-ui-regression-tests.ps1`
|
||||||
|
- `git diff --check`
|
||||||
|
- `git diff --name-only`
|
||||||
|
- Scope review: changed product files are `frontend/assets/js/partida-actual.js` and `frontend/assets/css/historico.css`.
|
||||||
|
- Manual/rendered Browser QA remains to be repeated when the Browser automation entry point is exposed; the local frontend and backend current-match endpoints were reachable during validation.
|
||||||
245
ai/tasks/done/TASK-155-current-match-live-player-stats.md
Normal file
245
ai/tasks/done/TASK-155-current-match-live-player-stats.md
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
# TASK-155 - Current match live player stats
|
||||||
|
|
||||||
|
Status: in-progress
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Investigate and implement live player/statistics rendering for the current-match page using the safest available data source.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
The current-match page has an "Estadísticas en vivo" section, but it currently remains a placeholder:
|
||||||
|
|
||||||
|
> "Las estadísticas en vivo aparecerán cuando haya datos suficientes."
|
||||||
|
|
||||||
|
The user reports that players are not being shown.
|
||||||
|
|
||||||
|
Known context:
|
||||||
|
|
||||||
|
- `/api/current-match` exposes players and team player counts from RCON `getSession`, but those counts may be marked as `rcon-session-unverified`.
|
||||||
|
- Public scoreboard can show player population even when RCON `getSession` reports 0.
|
||||||
|
- AdminLog kill events already expose player names through normalized kill feed rows.
|
||||||
|
- Historical materialization already derives player stats from AdminLog data for completed matches.
|
||||||
|
- The current-match page should eventually show useful live player rows, but only if data is reliable enough.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Investigate the available live player data sources and wire the current-match page to a safe current player statistics payload.
|
||||||
|
|
||||||
|
Allowed changes:
|
||||||
|
|
||||||
|
- `backend/app/routes.py`
|
||||||
|
- `backend/app/payloads.py`
|
||||||
|
- `backend/app/rcon_admin_log_storage.py`
|
||||||
|
- backend read-model/helper files if needed
|
||||||
|
- `frontend/assets/js/partida-actual.js`
|
||||||
|
- `frontend/partida-actual.html`
|
||||||
|
- `frontend/assets/css/historico.css` or relevant CSS
|
||||||
|
- focused backend tests
|
||||||
|
|
||||||
|
## Constraints - DO NOT BREAK
|
||||||
|
|
||||||
|
- Do not fabricate player stats.
|
||||||
|
- Do not show an empty/misleading table as if it were real data.
|
||||||
|
- Do not show RCON `getSession` `playerCount=0` as reliable if quality is `rcon-session-unverified`.
|
||||||
|
- Do not expose raw AdminLog lines.
|
||||||
|
- Do not expose admin-only/sensitive data.
|
||||||
|
- Do not break historical materialization.
|
||||||
|
- Do not break historical match detail stats.
|
||||||
|
- Do not query RCON directly from the frontend.
|
||||||
|
- Do not depend on server #03.
|
||||||
|
- Do not scrape the public scoreboard unless explicitly chosen and documented as a safe server-side fallback.
|
||||||
|
- Keep current public scoreboard URLs trusted and without `/games`.
|
||||||
|
|
||||||
|
## Files to inspect first
|
||||||
|
|
||||||
|
Read:
|
||||||
|
|
||||||
|
- `frontend/partida-actual.html`
|
||||||
|
- `frontend/assets/js/partida-actual.js`
|
||||||
|
- `backend/app/routes.py`
|
||||||
|
- `backend/app/payloads.py`
|
||||||
|
- `backend/app/rcon_admin_log_storage.py`
|
||||||
|
- focused tests and historical player stat materialization helpers related to current-match or AdminLog data
|
||||||
|
|
||||||
|
Inspect the existing RCON session payload, current kill feed freshness logic, open AdminLog match window support, and historical player stat aggregation before choosing a source.
|
||||||
|
|
||||||
|
## Required investigation before implementation
|
||||||
|
|
||||||
|
Perform RCA first and determine what live player data sources currently exist:
|
||||||
|
|
||||||
|
- RCON session payload
|
||||||
|
- any RCON player-list command/wrapper already available in the codebase
|
||||||
|
- AdminLog kill/death events
|
||||||
|
- existing historical player stat materialization
|
||||||
|
- current open AdminLog match window if available
|
||||||
|
|
||||||
|
Document the chosen source in the done task notes:
|
||||||
|
|
||||||
|
- what was available
|
||||||
|
- what was not available
|
||||||
|
- whether stats are complete, partial, or event-derived
|
||||||
|
|
||||||
|
## Implementation requirements
|
||||||
|
|
||||||
|
### 1. Backend endpoint
|
||||||
|
|
||||||
|
Add or extend a backend endpoint for current-match player stats.
|
||||||
|
|
||||||
|
Preferred route:
|
||||||
|
|
||||||
|
- `GET /api/current-match/players?server=...`
|
||||||
|
|
||||||
|
### 2. Supported servers
|
||||||
|
|
||||||
|
Support:
|
||||||
|
|
||||||
|
- `comunidad-hispana-01`
|
||||||
|
- `comunidad-hispana-02`
|
||||||
|
|
||||||
|
### 3. Unknown server values
|
||||||
|
|
||||||
|
- Return a safe 400/404 style response.
|
||||||
|
- Do not query arbitrary targets.
|
||||||
|
|
||||||
|
### 4. Data model
|
||||||
|
|
||||||
|
The endpoint should return:
|
||||||
|
|
||||||
|
- `server_slug`
|
||||||
|
- `server_name`
|
||||||
|
- `scope`
|
||||||
|
- `confidence`
|
||||||
|
- `source`
|
||||||
|
- `captured_at` or `updated_at`
|
||||||
|
- `items`: array of player rows
|
||||||
|
|
||||||
|
### 5. Player row fields
|
||||||
|
|
||||||
|
Include only fields that can be supported reliably:
|
||||||
|
|
||||||
|
- `player_name`
|
||||||
|
- `team` if known
|
||||||
|
- `kills` if known
|
||||||
|
- `deaths` if known
|
||||||
|
- `teamkills` if known
|
||||||
|
- `deaths_by_teamkill` if known
|
||||||
|
- `favorite_weapon` or `most_used_weapon` if known
|
||||||
|
- `last_seen_at` if known
|
||||||
|
- `confidence`/`source` if needed
|
||||||
|
|
||||||
|
### 6. AdminLog-derived data
|
||||||
|
|
||||||
|
If using AdminLog-derived data:
|
||||||
|
|
||||||
|
- Clearly mark scope/confidence as partial/event-derived.
|
||||||
|
- Only include players observed in recent/current match event windows.
|
||||||
|
- Do not imply this is the full server roster.
|
||||||
|
- Use a freshness threshold consistent with kill feed freshness logic.
|
||||||
|
- If no current/open event window exists, return empty items with a clear scope.
|
||||||
|
|
||||||
|
### 7. RCON player-list data
|
||||||
|
|
||||||
|
If a real RCON player-list command exists:
|
||||||
|
|
||||||
|
- Prefer it for live roster.
|
||||||
|
- Combine it with AdminLog-derived kills/deaths only when safe.
|
||||||
|
- Mark fields not supported by RCON as null.
|
||||||
|
- Do not block the endpoint indefinitely on RCON timeouts.
|
||||||
|
|
||||||
|
### 8. Frontend rendering
|
||||||
|
|
||||||
|
Replace the static "Estadísticas en vivo" placeholder with dynamic rendering.
|
||||||
|
|
||||||
|
If items exist:
|
||||||
|
|
||||||
|
- Show a compact table or cards with player stats.
|
||||||
|
- Sort by kills descending, then deaths ascending, then name.
|
||||||
|
- Indicate if stats are partial.
|
||||||
|
|
||||||
|
If items are empty, show:
|
||||||
|
|
||||||
|
- "Todavía no hay estadísticas fiables de jugadores para esta partida."
|
||||||
|
|
||||||
|
If data is partial, show:
|
||||||
|
|
||||||
|
- "Estadísticas parciales derivadas de eventos recientes."
|
||||||
|
|
||||||
|
### 9. Frontend polling
|
||||||
|
|
||||||
|
- Poll player stats with the current-match page refresh cycle or a safe separate interval.
|
||||||
|
- Avoid overlapping requests.
|
||||||
|
- Do not flicker table rows on every poll.
|
||||||
|
- Avoid duplicate player rows.
|
||||||
|
|
||||||
|
### 10. Tests
|
||||||
|
|
||||||
|
Add focused backend tests for:
|
||||||
|
|
||||||
|
- unsupported server rejection
|
||||||
|
- empty current stats response
|
||||||
|
- AdminLog-derived player aggregation if used
|
||||||
|
- stale event filtering
|
||||||
|
- no raw AdminLog exposure
|
||||||
|
- stable sorting
|
||||||
|
- partial confidence metadata
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
- `python -m compileall backend/app`
|
||||||
|
- focused backend tests if available
|
||||||
|
- `node --check frontend/assets/js/partida-actual.js`
|
||||||
|
|
||||||
|
## Manual verification checklist
|
||||||
|
|
||||||
|
- Call `Invoke-RestMethod "http://localhost:8000/api/current-match/players?server=comunidad-hispana-01" | ConvertTo-Json -Depth 20`.
|
||||||
|
- Call `Invoke-RestMethod "http://localhost:8000/api/current-match/players?server=comunidad-hispana-02" | ConvertTo-Json -Depth 20`.
|
||||||
|
- Verify unsupported server values are rejected safely.
|
||||||
|
- Open `http://localhost:8080/partida-actual.html?server=comunidad-hispana-01`.
|
||||||
|
- Open `http://localhost:8080/partida-actual.html?server=comunidad-hispana-02`.
|
||||||
|
- Verify the "Estadísticas en vivo" section no longer remains permanently empty when current reliable/partial player data exists.
|
||||||
|
- Verify the UI clearly says when stats are partial or unavailable.
|
||||||
|
- Verify no fake players are shown.
|
||||||
|
- Verify no raw AdminLog lines are shown.
|
||||||
|
- Verify existing historical detail stats still work.
|
||||||
|
|
||||||
|
## Expected outcome
|
||||||
|
|
||||||
|
The current-match page can show live or partial player statistics when supported by reliable/current data, and otherwise shows a clear honest empty state.
|
||||||
|
|
||||||
|
## AI Platform lifecycle
|
||||||
|
|
||||||
|
After implementation and validation:
|
||||||
|
|
||||||
|
- Move this task according to the lifecycle defined in `AGENTS.md`.
|
||||||
|
- Do not mark unrelated tasks as done automatically.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
### RCA and source choice
|
||||||
|
|
||||||
|
- `RCON getSession` remains available for the current-match header but its current player count is explicitly marked `rcon-session-unverified`.
|
||||||
|
- No dedicated RCON player-list wrapper or route exists in the current `rcon_client` implementation.
|
||||||
|
- AdminLog kill events already expose safe normalized player names, teams, weapons and current-match window/freshness handling through the kill-feed read model.
|
||||||
|
- The historical AdminLog materializer derives player combat stats from the same kind of kill events for closed matches.
|
||||||
|
- Chosen source: current/fresh normalized AdminLog kill rows. The new current-match player projection is intentionally `event-derived-partial`, not a complete live roster.
|
||||||
|
|
||||||
|
### Change summary
|
||||||
|
|
||||||
|
- Added `GET /api/current-match/players?server=...` for trusted active current-match servers only.
|
||||||
|
- Added a safe AdminLog-derived player aggregator with kills, deaths, teamkills, teamkill deaths, team, last-seen timestamp and favorite weapon when observed.
|
||||||
|
- Kept stale fallback filtering aligned with the current kill feed and kept raw AdminLog messages out of the payload.
|
||||||
|
- Replaced the static current-match player placeholder with a polled partial-stat table and an honest empty state.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Ran `python -m compileall backend/app`.
|
||||||
|
- Ran `node --check frontend/assets/js/partida-actual.js`.
|
||||||
|
- Attempted `python -m pytest backend/tests/test_current_match_payload.py backend/tests/test_rcon_admin_log_storage.py`; the active Python environment does not have `pytest` installed.
|
||||||
|
- Ran a direct Python smoke harness for trusted/unsupported route resolution, AdminLog aggregation, teamkill handling, stale filtering and raw-log exclusion.
|
||||||
|
- Ran `scripts/run-historical-ui-regression-tests.ps1`.
|
||||||
|
- Ran `git diff --check` and `git diff --name-only`.
|
||||||
|
- Task-specific changed product files are `backend/app/routes.py`, `backend/app/payloads.py`, `backend/app/rcon_admin_log_storage.py`, `backend/tests/test_current_match_payload.py` and `frontend/assets/js/partida-actual.js`. The current worktree also contains `frontend/assets/css/historico.css` from completed `TASK-154`.
|
||||||
|
- Rendered Browser QA remains to be repeated when the Browser automation entry point is exposed in-session.
|
||||||
@@ -50,7 +50,7 @@ from .historical_storage import (
|
|||||||
from .rcon_historical_read_model import get_rcon_historical_match_detail
|
from .rcon_historical_read_model import get_rcon_historical_match_detail
|
||||||
from .normalizers import normalize_map_name
|
from .normalizers import normalize_map_name
|
||||||
from .rcon_client import load_rcon_targets, query_live_server_sample
|
from .rcon_client import load_rcon_targets, query_live_server_sample
|
||||||
from .rcon_admin_log_storage import list_current_match_kill_feed
|
from .rcon_admin_log_storage import list_current_match_kill_feed, list_current_match_player_stats
|
||||||
from .scoreboard_origins import get_trusted_public_scoreboard_origin
|
from .scoreboard_origins import get_trusted_public_scoreboard_origin
|
||||||
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history
|
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history
|
||||||
|
|
||||||
@@ -370,6 +370,22 @@ def build_current_match_kill_feed_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_current_match_player_stats_payload(*, server_slug: str) -> dict[str, object]:
|
||||||
|
"""Return current player stats only when safe AdminLog evidence exists."""
|
||||||
|
origin = get_trusted_public_scoreboard_origin(server_slug)
|
||||||
|
if origin is None:
|
||||||
|
raise ValueError("Unsupported current match server.")
|
||||||
|
stats = list_current_match_player_stats(server_key=origin.slug)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"data": {
|
||||||
|
"server_slug": origin.slug,
|
||||||
|
"server_name": origin.display_name,
|
||||||
|
**stats,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _query_current_match_rcon_sample(server_slug: str) -> dict[str, object] | None:
|
def _query_current_match_rcon_sample(server_slug: str) -> dict[str, object] | None:
|
||||||
"""Read one configured trusted RCON target for the current-match view."""
|
"""Read one configured trusted RCON target for the current-match view."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from collections import Counter
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@@ -422,6 +423,80 @@ def list_current_match_kill_feed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_current_match_player_stats(
|
||||||
|
*,
|
||||||
|
server_key: str,
|
||||||
|
db_path: Path | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Return partial current player stats derived from safe AdminLog kill rows."""
|
||||||
|
feed = list_current_match_kill_feed(
|
||||||
|
server_key=server_key,
|
||||||
|
limit=100,
|
||||||
|
db_path=db_path,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
players: dict[str, dict[str, object]] = {}
|
||||||
|
weapon_counts: dict[str, Counter[str]] = {}
|
||||||
|
for item in feed["items"]:
|
||||||
|
if not isinstance(item, Mapping):
|
||||||
|
continue
|
||||||
|
killer = _ensure_current_match_player(
|
||||||
|
players,
|
||||||
|
item.get("killer_name"),
|
||||||
|
team=item.get("killer_team"),
|
||||||
|
event_timestamp=item.get("event_timestamp"),
|
||||||
|
)
|
||||||
|
victim = _ensure_current_match_player(
|
||||||
|
players,
|
||||||
|
item.get("victim_name"),
|
||||||
|
team=item.get("victim_team"),
|
||||||
|
event_timestamp=item.get("event_timestamp"),
|
||||||
|
)
|
||||||
|
if killer is not None:
|
||||||
|
weapon = _safe_event_field(item.get("weapon")) or "UNKNOWN"
|
||||||
|
weapon_counts.setdefault(str(killer["player_name"]), Counter())[weapon] += 1
|
||||||
|
if item.get("is_teamkill"):
|
||||||
|
killer["teamkills"] = int(killer["teamkills"]) + 1
|
||||||
|
else:
|
||||||
|
killer["kills"] = int(killer["kills"]) + 1
|
||||||
|
if victim is not None:
|
||||||
|
victim["deaths"] = int(victim["deaths"]) + 1
|
||||||
|
if item.get("is_teamkill"):
|
||||||
|
victim["deaths_by_teamkill"] = int(victim["deaths_by_teamkill"]) + 1
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for player in players.values():
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
**player,
|
||||||
|
"favorite_weapon": _favorite_weapon_for_player(
|
||||||
|
weapon_counts.get(str(player["player_name"]))
|
||||||
|
),
|
||||||
|
"source": "rcon-admin-log-kill-events",
|
||||||
|
"confidence": "event-derived-partial",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
items.sort(
|
||||||
|
key=lambda player: (
|
||||||
|
-int(player["kills"]),
|
||||||
|
int(player["deaths"]),
|
||||||
|
str(player["player_name"]).casefold(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"scope": feed["scope"],
|
||||||
|
"confidence": "event-derived-partial" if items else feed["confidence"],
|
||||||
|
"source": "rcon-admin-log-kill-events",
|
||||||
|
"updated_at": max(
|
||||||
|
(str(item["last_seen_at"]) for item in items if item.get("last_seen_at")),
|
||||||
|
default=None,
|
||||||
|
),
|
||||||
|
"stale_events_filtered": feed["stale_events_filtered"],
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_latest_rcon_player_profile_summaries(
|
def get_latest_rcon_player_profile_summaries(
|
||||||
*,
|
*,
|
||||||
target_key: str,
|
target_key: str,
|
||||||
@@ -543,6 +618,45 @@ def _safe_event_field(value: object) -> str | None:
|
|||||||
return normalized or None
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_current_match_player(
|
||||||
|
players: dict[str, dict[str, object]],
|
||||||
|
player_name: object,
|
||||||
|
*,
|
||||||
|
team: object,
|
||||||
|
event_timestamp: object,
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
safe_name = _safe_event_field(player_name)
|
||||||
|
if safe_name is None:
|
||||||
|
return None
|
||||||
|
player = players.setdefault(
|
||||||
|
safe_name,
|
||||||
|
{
|
||||||
|
"player_name": safe_name,
|
||||||
|
"team": None,
|
||||||
|
"kills": 0,
|
||||||
|
"deaths": 0,
|
||||||
|
"teamkills": 0,
|
||||||
|
"deaths_by_teamkill": 0,
|
||||||
|
"last_seen_at": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
safe_team = _safe_event_field(team)
|
||||||
|
if safe_team:
|
||||||
|
player["team"] = safe_team
|
||||||
|
safe_timestamp = _safe_event_field(event_timestamp)
|
||||||
|
if safe_timestamp and (
|
||||||
|
player["last_seen_at"] is None or safe_timestamp > str(player["last_seen_at"])
|
||||||
|
):
|
||||||
|
player["last_seen_at"] = safe_timestamp
|
||||||
|
return player
|
||||||
|
|
||||||
|
|
||||||
|
def _favorite_weapon_for_player(weapons: Counter[str] | None) -> str | None:
|
||||||
|
if not weapons:
|
||||||
|
return None
|
||||||
|
return min(weapons.items(), key=lambda item: (-item[1], item[0]))[0]
|
||||||
|
|
||||||
|
|
||||||
def _row_is_current_match_fallback_fresh(
|
def _row_is_current_match_fallback_fresh(
|
||||||
row: Mapping[str, object],
|
row: Mapping[str, object],
|
||||||
freshness_anchor: datetime,
|
freshness_anchor: datetime,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from urllib.parse import parse_qs, urlparse
|
|||||||
from .payloads import (
|
from .payloads import (
|
||||||
build_community_payload,
|
build_community_payload,
|
||||||
build_current_match_kill_feed_payload,
|
build_current_match_kill_feed_payload,
|
||||||
|
build_current_match_player_stats_payload,
|
||||||
build_current_match_payload,
|
build_current_match_payload,
|
||||||
build_discord_payload,
|
build_discord_payload,
|
||||||
build_elo_mmr_leaderboard_payload,
|
build_elo_mmr_leaderboard_payload,
|
||||||
@@ -85,6 +86,14 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
|
|||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if parsed.path == "/api/current-match/players":
|
||||||
|
server_slug = parse_qs(parsed.query).get("server", [None])[0]
|
||||||
|
if not server_slug:
|
||||||
|
return HTTPStatus.BAD_REQUEST, build_error_payload("Server parameter is required")
|
||||||
|
if get_trusted_public_scoreboard_origin(server_slug) is None:
|
||||||
|
return HTTPStatus.NOT_FOUND, build_error_payload("Current match server is not supported")
|
||||||
|
return HTTPStatus.OK, build_current_match_player_stats_payload(server_slug=server_slug)
|
||||||
|
|
||||||
if parsed.path == "/api/historical/weekly-top-kills":
|
if parsed.path == "/api/historical/weekly-top-kills":
|
||||||
limit = _parse_limit(parsed.query)
|
limit = _parse_limit(parsed.query)
|
||||||
if limit is None:
|
if limit is None:
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from datetime import datetime, timezone
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from app.payloads import build_current_match_payload
|
from app.payloads import build_current_match_payload
|
||||||
|
from app.rcon_admin_log_storage import list_current_match_player_stats, persist_rcon_admin_log_entries
|
||||||
from app.rcon_client import RconServerTarget
|
from app.rcon_client import RconServerTarget
|
||||||
from app.routes import resolve_get_payload
|
from app.routes import resolve_get_payload
|
||||||
|
|
||||||
@@ -109,6 +111,104 @@ def test_current_match_route_rejects_unsupported_server():
|
|||||||
assert payload["status"] == "error"
|
assert payload["status"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_match_player_route_rejects_unsupported_server():
|
||||||
|
status, payload = resolve_get_payload("/api/current-match/players?server=not-trusted")
|
||||||
|
|
||||||
|
assert status == HTTPStatus.NOT_FOUND
|
||||||
|
assert payload["status"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_match_player_stats_aggregate_safe_admin_log_rows(tmp_path):
|
||||||
|
db_path = tmp_path / "admin-log.sqlite3"
|
||||||
|
persist_rcon_admin_log_entries(
|
||||||
|
target={
|
||||||
|
"target_key": "comunidad-hispana-01",
|
||||||
|
"external_server_id": "comunidad-hispana-01",
|
||||||
|
},
|
||||||
|
entries=[
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-21T10:00:00Z",
|
||||||
|
"message": "[1:00 min (100)] MATCH START Mortain Warfare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-21T10:01:00Z",
|
||||||
|
"message": (
|
||||||
|
"[2:00 min (120)] KILL: Bravo(Axis/steam-bravo) -> "
|
||||||
|
"Alpha(Allies/steam-alpha) with MP40"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-21T10:02:00Z",
|
||||||
|
"message": (
|
||||||
|
"[3:00 min (140)] KILL: Alpha(Allies/steam-alpha) -> "
|
||||||
|
"Charlie(Allies/steam-charlie) with M1 GARAND"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-21T10:03:00Z",
|
||||||
|
"message": (
|
||||||
|
"[4:00 min (160)] KILL: Alpha(Allies/steam-alpha) -> "
|
||||||
|
"Bravo(Axis/steam-bravo) with M1 GARAND"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = list_current_match_player_stats(
|
||||||
|
server_key="comunidad-hispana-01",
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert stats["scope"] == "open-admin-log-match-window"
|
||||||
|
assert stats["confidence"] == "event-derived-partial"
|
||||||
|
assert stats["source"] == "rcon-admin-log-kill-events"
|
||||||
|
assert [item["player_name"] for item in stats["items"]] == ["Alpha", "Bravo", "Charlie"]
|
||||||
|
assert stats["items"][0] == {
|
||||||
|
"player_name": "Alpha",
|
||||||
|
"team": "Allies",
|
||||||
|
"kills": 1,
|
||||||
|
"deaths": 1,
|
||||||
|
"teamkills": 1,
|
||||||
|
"deaths_by_teamkill": 0,
|
||||||
|
"last_seen_at": "2026-05-21T10:03:00Z",
|
||||||
|
"favorite_weapon": "M1 GARAND",
|
||||||
|
"source": "rcon-admin-log-kill-events",
|
||||||
|
"confidence": "event-derived-partial",
|
||||||
|
}
|
||||||
|
assert "raw_message" not in stats["items"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_match_player_stats_filter_stale_recent_events(tmp_path):
|
||||||
|
db_path = tmp_path / "admin-log.sqlite3"
|
||||||
|
persist_rcon_admin_log_entries(
|
||||||
|
target={
|
||||||
|
"target_key": "comunidad-hispana-01",
|
||||||
|
"external_server_id": "comunidad-hispana-01",
|
||||||
|
},
|
||||||
|
entries=[
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-21T09:30:00Z",
|
||||||
|
"message": (
|
||||||
|
"[1:00 min (1779355800)] KILL: Old Killer(Allies/steam-old) -> "
|
||||||
|
"Old Victim(Axis/steam-victim-old) with M1 GARAND"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = list_current_match_player_stats(
|
||||||
|
server_key="comunidad-hispana-01",
|
||||||
|
db_path=db_path,
|
||||||
|
now=datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert stats["scope"] == "no-current-match-events"
|
||||||
|
assert stats["confidence"] == "stale-filtered"
|
||||||
|
assert stats["items"] == []
|
||||||
|
|
||||||
|
|
||||||
def _build_with_rcon_sample(sample: dict[str, object]) -> dict[str, object]:
|
def _build_with_rcon_sample(sample: dict[str, object]) -> dict[str, object]:
|
||||||
with (
|
with (
|
||||||
patch("app.payloads.load_rcon_targets", return_value=(TARGET,)),
|
patch("app.payloads.load_rcon_targets", return_value=(TARGET,)),
|
||||||
|
|||||||
@@ -589,6 +589,118 @@
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.current-match-killfeed {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-template-rows: repeat(5, minmax(0, 1fr));
|
||||||
|
grid-auto-columns: minmax(0, 1fr);
|
||||||
|
gap: 10px 12px;
|
||||||
|
min-height: 250px;
|
||||||
|
max-height: 320px;
|
||||||
|
padding: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(210, 182, 118, 0.26);
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(210, 182, 118, 0.08) 1px, transparent 1px),
|
||||||
|
linear-gradient(180deg, rgba(183, 201, 125, 0.06) 1px, transparent 1px),
|
||||||
|
linear-gradient(180deg, rgba(12, 18, 11, 0.96), rgba(7, 10, 7, 0.98));
|
||||||
|
background-size: 33.333% 100%, 100% 62px, auto;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
||||||
|
0 18px 38px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-match-killfeed__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(159, 168, 141, 0.16);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(24, 31, 21, 0.86);
|
||||||
|
animation: current-match-killfeed-enter 180ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-match-killfeed__row.is-teamkill {
|
||||||
|
border-color: rgba(210, 182, 118, 0.42);
|
||||||
|
background: rgba(73, 49, 27, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-match-killfeed__player {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-match-killfeed__player--killer {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-match-killfeed__player--victim {
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-match-killfeed__weapon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 34px;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border: 1px solid rgba(183, 201, 125, 0.24);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(7, 10, 7, 0.8);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-match-killfeed__weapon em {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-match-killfeed__teamkill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 20px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border: 1px solid rgba(210, 182, 118, 0.36);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--accent-warm);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes current-match-killfeed-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.historical-comparison-grid {
|
.historical-comparison-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
|
||||||
@@ -806,6 +918,38 @@
|
|||||||
.historical-table {
|
.historical-table {
|
||||||
min-width: 540px;
|
min-width: 540px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.current-match-killfeed {
|
||||||
|
grid-template-rows: repeat(8, minmax(0, 1fr));
|
||||||
|
min-height: 312px;
|
||||||
|
max-height: 380px;
|
||||||
|
background-size: 50% 100%, 100% 48px, auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-match-killfeed__row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-match-killfeed__teamkill {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.current-match-killfeed {
|
||||||
|
grid-auto-flow: row;
|
||||||
|
grid-template-rows: none;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
|
background-size: 100% 48px, 100% 48px, auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.current-match-killfeed__row {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.historical-match-card--clean {
|
.historical-match-card--clean {
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ const CURRENT_MATCH_SCOREBOARDS = Object.freeze({
|
|||||||
"comunidad-hispana-01": "https://scoreboard.comunidadhll.es",
|
"comunidad-hispana-01": "https://scoreboard.comunidadhll.es",
|
||||||
"comunidad-hispana-02": "https://scoreboard.comunidadhll.es:5443",
|
"comunidad-hispana-02": "https://scoreboard.comunidadhll.es:5443",
|
||||||
});
|
});
|
||||||
|
const CURRENT_MATCH_KILL_FEED_LIMIT = 15;
|
||||||
|
const CURRENT_MATCH_WEAPONS = Object.freeze({
|
||||||
|
"m1 garand": { label: "M1 Garand", glyph: "M1" },
|
||||||
|
mp40: { label: "MP40", glyph: "MP" },
|
||||||
|
"m1a1 thompson": { label: "M1A1 Thompson", glyph: "TH" },
|
||||||
|
unknown: { label: "Arma desconocida", glyph: "?" },
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -20,6 +27,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
state: document.getElementById("current-match-state"),
|
state: document.getElementById("current-match-state"),
|
||||||
grid: document.getElementById("current-match-grid"),
|
grid: document.getElementById("current-match-grid"),
|
||||||
feedTitle: document.getElementById("current-match-feed-title"),
|
feedTitle: document.getElementById("current-match-feed-title"),
|
||||||
|
playersTitle: document.getElementById("current-match-players-title"),
|
||||||
mapHero: document.getElementById("current-match-map-hero"),
|
mapHero: document.getElementById("current-match-map-hero"),
|
||||||
mapImage: document.getElementById("current-match-map-image"),
|
mapImage: document.getElementById("current-match-map-image"),
|
||||||
mapPlaceholder: document.getElementById("current-match-map-placeholder"),
|
mapPlaceholder: document.getElementById("current-match-map-placeholder"),
|
||||||
@@ -34,6 +42,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
nodes.history.href = `./historico.html?server=${encodeURIComponent(serverSlug)}`;
|
nodes.history.href = `./historico.html?server=${encodeURIComponent(serverSlug)}`;
|
||||||
const killFeedState = initializeKillFeed(nodes);
|
const killFeedState = initializeKillFeed(nodes);
|
||||||
|
const playerStatsState = initializePlayerStats(nodes);
|
||||||
let refreshInFlight = false;
|
let refreshInFlight = false;
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
if (refreshInFlight) {
|
if (refreshInFlight) {
|
||||||
@@ -44,6 +53,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }),
|
loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }),
|
||||||
loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }),
|
loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }),
|
||||||
|
loadPlayerStats({ backendBaseUrl, serverSlug, nodes, playerStatsState }),
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
refreshInFlight = false;
|
refreshInFlight = false;
|
||||||
@@ -71,7 +81,7 @@ async function loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }) {
|
|||||||
async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }) {
|
async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }) {
|
||||||
try {
|
try {
|
||||||
const payload = await fetchJson(
|
const payload = await fetchJson(
|
||||||
`${backendBaseUrl}/api/current-match/kills?server=${encodeURIComponent(serverSlug)}&limit=30`,
|
`${backendBaseUrl}/api/current-match/kills?server=${encodeURIComponent(serverSlug)}&limit=${CURRENT_MATCH_KILL_FEED_LIMIT}`,
|
||||||
);
|
);
|
||||||
renderKillFeed(payload?.data || {}, nodes, killFeedState);
|
renderKillFeed(payload?.data || {}, nodes, killFeedState);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -79,6 +89,17 @@ async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadPlayerStats({ backendBaseUrl, serverSlug, nodes, playerStatsState }) {
|
||||||
|
try {
|
||||||
|
const payload = await fetchJson(
|
||||||
|
`${backendBaseUrl}/api/current-match/players?server=${encodeURIComponent(serverSlug)}`,
|
||||||
|
);
|
||||||
|
renderPlayerStats(payload?.data || {}, nodes, playerStatsState);
|
||||||
|
} catch (error) {
|
||||||
|
setState(nodes.playerStatsState, "No se pudieron actualizar las estadisticas en vivo.", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderCurrentMatch(data, nodes) {
|
function renderCurrentMatch(data, nodes) {
|
||||||
const rawServerName = data.server_name || data.server_slug || "Servidor no disponible";
|
const rawServerName = data.server_name || data.server_slug || "Servidor no disponible";
|
||||||
const serverName = formatServerDisplayName(data, rawServerName);
|
const serverName = formatServerDisplayName(data, rawServerName);
|
||||||
@@ -117,7 +138,11 @@ function initializeKillFeed(nodes) {
|
|||||||
<p class="historical-state" id="current-match-feed-state" aria-live="polite">
|
<p class="historical-state" id="current-match-feed-state" aria-live="polite">
|
||||||
Cargando feed de combate...
|
Cargando feed de combate...
|
||||||
</p>
|
</p>
|
||||||
<div class="historical-match-list" id="current-match-feed-list"></div>
|
<section
|
||||||
|
class="current-match-killfeed"
|
||||||
|
id="current-match-feed-list"
|
||||||
|
aria-label="Bajas recientes en la partida actual"
|
||||||
|
></section>
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -125,6 +150,27 @@ function initializeKillFeed(nodes) {
|
|||||||
nodes.feedList = document.getElementById("current-match-feed-list");
|
nodes.feedList = document.getElementById("current-match-feed-list");
|
||||||
return {
|
return {
|
||||||
byId: new Map(),
|
byId: new Map(),
|
||||||
|
visibleSignature: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializePlayerStats(nodes) {
|
||||||
|
const shell = nodes.playersTitle?.closest(".panel__shell");
|
||||||
|
if (shell) {
|
||||||
|
shell.insertAdjacentHTML(
|
||||||
|
"beforeend",
|
||||||
|
`
|
||||||
|
<p class="historical-state" id="current-match-player-stats-state" aria-live="polite">
|
||||||
|
Cargando estadisticas en vivo...
|
||||||
|
</p>
|
||||||
|
<div class="historical-table-shell" id="current-match-player-stats-shell" hidden></div>
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
nodes.playerStatsState = document.getElementById("current-match-player-stats-state");
|
||||||
|
nodes.playerStatsShell = document.getElementById("current-match-player-stats-shell");
|
||||||
|
return {
|
||||||
|
visibleSignature: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,13 +184,21 @@ function renderKillFeed(data, nodes, state) {
|
|||||||
state.byId.set(event.event_id, event);
|
state.byId.set(event.event_id, event);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const events = [...state.byId.values()].sort(compareKillFeedEvents).slice(0, 30);
|
const events = [...state.byId.values()]
|
||||||
|
.sort(compareKillFeedEvents)
|
||||||
|
.slice(0, CURRENT_MATCH_KILL_FEED_LIMIT);
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
nodes.feedList.innerHTML = "";
|
nodes.feedList.innerHTML = "";
|
||||||
setState(nodes.feedState, "Todavia no se han detectado bajas en esta partida.");
|
state.visibleSignature = "";
|
||||||
|
setState(nodes.feedState, formatKillFeedCoverage(data.scope));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
nodes.feedList.innerHTML = events.map(renderKillFeedRow).join("");
|
const visualEvents = [...events].reverse();
|
||||||
|
const visibleSignature = visualEvents.map((event) => event.event_id).join("|");
|
||||||
|
if (visibleSignature !== state.visibleSignature) {
|
||||||
|
nodes.feedList.innerHTML = visualEvents.map(renderKillFeedRow).join("");
|
||||||
|
state.visibleSignature = visibleSignature;
|
||||||
|
}
|
||||||
nodes.feedState.textContent = formatKillFeedCoverage(data.scope);
|
nodes.feedState.textContent = formatKillFeedCoverage(data.scope);
|
||||||
nodes.feedState.classList.remove("historical-state--error");
|
nodes.feedState.classList.remove("historical-state--error");
|
||||||
}
|
}
|
||||||
@@ -159,35 +213,124 @@ function compareKillFeedEvents(left, right) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderKillFeedRow(event) {
|
function renderKillFeedRow(event) {
|
||||||
|
const weapon = resolveKillFeedWeapon(event.weapon);
|
||||||
const teamkillBadge = event.is_teamkill
|
const teamkillBadge = event.is_teamkill
|
||||||
? '<span class="status-chip status-chip--fallback">TK</span>'
|
? '<span class="current-match-killfeed__teamkill">TK</span>'
|
||||||
: "";
|
: "";
|
||||||
const eventTime = formatEventTime(event);
|
|
||||||
return `
|
return `
|
||||||
<article class="historical-match-card">
|
<article class="current-match-killfeed__row${event.is_teamkill ? " is-teamkill" : ""}">
|
||||||
<div class="historical-match-card__top">
|
<strong class="current-match-killfeed__player current-match-killfeed__player--killer">
|
||||||
<div class="historical-match-card__title">
|
${escapeHtml(event.killer_name || "Jugador no disponible")}
|
||||||
<p>${escapeHtml(eventTime)}</p>
|
</strong>
|
||||||
<strong>${escapeHtml(event.killer_name || "Jugador no disponible")}</strong>
|
<span
|
||||||
</div>
|
class="current-match-killfeed__weapon"
|
||||||
${teamkillBadge}
|
title="${escapeHtml(weapon.label)}"
|
||||||
</div>
|
aria-label="${escapeHtml(weapon.label)}"
|
||||||
<p>
|
>
|
||||||
${escapeHtml(event.weapon || "Arma no disponible")}
|
<span aria-hidden="true">${escapeHtml(weapon.glyph)}</span>
|
||||||
-> ${escapeHtml(event.victim_name || "Objetivo no disponible")}
|
<em>${escapeHtml(weapon.label)}</em>
|
||||||
</p>
|
</span>
|
||||||
|
<span class="current-match-killfeed__player current-match-killfeed__player--victim">
|
||||||
|
${escapeHtml(event.victim_name || "Objetivo no disponible")}
|
||||||
|
</span>
|
||||||
|
${teamkillBadge}
|
||||||
</article>
|
</article>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEventTime(event) {
|
function resolveKillFeedWeapon(value) {
|
||||||
const timestamp = formatTimestamp(event.event_timestamp);
|
const key = normalizeLookupText(value);
|
||||||
if (timestamp !== "No disponible") {
|
return CURRENT_MATCH_WEAPONS[key] || {
|
||||||
return timestamp;
|
label: String(value || CURRENT_MATCH_WEAPONS.unknown.label),
|
||||||
|
glyph: CURRENT_MATCH_WEAPONS.unknown.glyph,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlayerStats(data, nodes, state) {
|
||||||
|
const items = Array.isArray(data.items) ? sortPlayerStats(data.items) : [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
state.visibleSignature = "";
|
||||||
|
nodes.playerStatsShell.innerHTML = "";
|
||||||
|
nodes.playerStatsShell.hidden = true;
|
||||||
|
setState(
|
||||||
|
nodes.playerStatsState,
|
||||||
|
"Todavia no hay estadisticas fiables de jugadores para esta partida.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return Number.isFinite(Number(event.server_time))
|
const signature = items
|
||||||
? `Tiempo servidor ${Number(event.server_time)}`
|
.map((item) =>
|
||||||
: "Tiempo no disponible";
|
[
|
||||||
|
item.player_name,
|
||||||
|
item.kills,
|
||||||
|
item.deaths,
|
||||||
|
item.teamkills,
|
||||||
|
item.deaths_by_teamkill,
|
||||||
|
item.favorite_weapon,
|
||||||
|
item.last_seen_at,
|
||||||
|
].join(":"),
|
||||||
|
)
|
||||||
|
.join("|");
|
||||||
|
if (signature !== state.visibleSignature) {
|
||||||
|
nodes.playerStatsShell.innerHTML = renderPlayerStatsTable(items);
|
||||||
|
state.visibleSignature = signature;
|
||||||
|
}
|
||||||
|
nodes.playerStatsShell.hidden = false;
|
||||||
|
setState(nodes.playerStatsState, "Estadisticas parciales derivadas de eventos recientes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortPlayerStats(items) {
|
||||||
|
return [...items].sort(
|
||||||
|
(left, right) =>
|
||||||
|
toStatNumber(right.kills) - toStatNumber(left.kills) ||
|
||||||
|
toStatNumber(left.deaths) - toStatNumber(right.deaths) ||
|
||||||
|
String(left.player_name || "").localeCompare(String(right.player_name || ""), "es", {
|
||||||
|
sensitivity: "base",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlayerStatsTable(items) {
|
||||||
|
return `
|
||||||
|
<table class="historical-table historical-table--players">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Jugador</th>
|
||||||
|
<th>Equipo</th>
|
||||||
|
<th>Bajas</th>
|
||||||
|
<th>Muertes</th>
|
||||||
|
<th>TK</th>
|
||||||
|
<th>Muertes TK</th>
|
||||||
|
<th>Arma frecuente</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${items.map(renderPlayerStatsRow).join("")}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlayerStatsRow(item) {
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(item.player_name || "Jugador no disponible")}</td>
|
||||||
|
<td>${escapeHtml(item.team || "No disponible")}</td>
|
||||||
|
<td>${escapeHtml(formatStatNumber(item.kills))}</td>
|
||||||
|
<td>${escapeHtml(formatStatNumber(item.deaths))}</td>
|
||||||
|
<td>${escapeHtml(formatStatNumber(item.teamkills))}</td>
|
||||||
|
<td>${escapeHtml(formatStatNumber(item.deaths_by_teamkill))}</td>
|
||||||
|
<td>${escapeHtml(item.favorite_weapon || "No disponible")}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStatNumber(value) {
|
||||||
|
return Number.isFinite(Number(value)) ? Number(value) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatNumber(value) {
|
||||||
|
return Number.isFinite(Number(value)) ? String(Number(value)) : "0";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCompactMeta(label, value) {
|
function renderCompactMeta(label, value) {
|
||||||
@@ -410,6 +553,9 @@ function formatKillFeedCoverage(scope) {
|
|||||||
if (scope === "recent-admin-log-window") {
|
if (scope === "recent-admin-log-window") {
|
||||||
return "Cobertura parcial desde AdminLog reciente.";
|
return "Cobertura parcial desde AdminLog reciente.";
|
||||||
}
|
}
|
||||||
|
if (scope === "no-current-match-events") {
|
||||||
|
return "Sin bajas recientes asociadas a la partida actual.";
|
||||||
|
}
|
||||||
return "Todavia no se han detectado bajas en esta partida.";
|
return "Todavia no se han detectado bajas en esta partida.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user