Author SHA1 Message Date
devRaGonSa 0da8338ba8 Fix 2026-06-05 16:57:25 +02:00
259 changed files with 985 additions and 30630 deletions
-33
View File
@@ -1,33 +0,0 @@
name: Codex Worker
on:
workflow_dispatch:
push:
paths:
- 'ai/tasks/pending/**'
- 'AGENTS.md'
- 'ai/**'
- 'scripts/codex-runner.ps1'
jobs:
run-codex-worker:
runs-on: ubuntu-latest
if: ${{ secrets.OPENAI_API_KEY != '' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Codex CLI
run: npm install -g @openai/codex
- name: Run Codex Worker
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
codex --auto "Follow AGENTS.md, read the AI platform files in ai/, and process only the pending tasks within scoped repository rules."
-11
View File
@@ -24,14 +24,3 @@ backend/data/snapshots/**
.env
backend/data/*.sqlite3-shm
backend/data/*.sqlite3-wal
tmp/
frontend/assets/img/weapons/black - copia/
frontend/assets/img/weapons/black.zip
.ai/
/ai/
/.ai/
/tmp/
/frontend/assets/img/weapons/black - copia/
/frontend/assets/img/weapons/black.zip
-110
View File
@@ -1,110 +0,0 @@
# HLL Vietnam Agent Operating Rules
This repository uses an AI-driven task workflow adapted to HLL Vietnam.
## Project Context
- Product: HLL Vietnam
- Product type: community website
- Current frontend: HTML, CSS and vanilla JavaScript
- Planned backend: Python
- Current product scope: simple landing page and repository foundation
- Visual identity: military, Vietnam, tactical, sober
## Task System
Task locations:
- Pending: `ai/tasks/pending`
- In progress: `ai/tasks/in-progress`
- Review: `ai/tasks/review`
- Blocked: `ai/tasks/blocked`
- Obsolete: `ai/tasks/obsolete`
- Done: `ai/tasks/done`
Every new task must follow:
- `ai/task-template.md`
Local platform scripts should read repository-specific paths and worker settings from:
- `ai-platform.json`
## Core Workflow
1. The orchestrator reviews repository context and relevant code.
2. The orchestrator writes or refines a task in `ai/tasks/pending`.
3. A worker moves the selected task to `ai/tasks/in-progress`.
4. The worker reads the files listed in `Files to Read First`.
5. The worker performs only the scoped change defined by the task.
6. The worker validates the change with the documented checks.
7. The worker moves completed work to `ai/tasks/done` when validation is complete, or to `ai/tasks/review` when human/orchestrator review is explicitly required.
8. The worker documents any relevant architectural or process decision.
Codex must not act freely outside tasks except for repository inspection, platform maintenance, or explicitly requested integration work like this one.
## Roles Used In This Repository
- PM
- Analista
- Backend Senior
- Frontend Senior
- Arquitecto de Base de Datos
- Arquitecto Python
- Disenador grafico
- Experto en interfaz
Role guidance is stored in:
- `ai/orchestrator/`
## Rules
- Do not break repository structure without explicit technical justification.
- Do not make destructive changes without explicit justification.
- Keep changes small, verifiable and documented.
- Do not overwrite existing project context with generic template content.
- Preserve HLL Vietnam branding and product identity.
- Do not introduce unnecessary frameworks in the current phase.
- Do not build backend functionality until a task explicitly requires it.
- Do not modify unrelated files.
## Technical Constraints
- Frontend changes must remain compatible with direct browser opening when applicable.
- Backend architecture decisions must assume Python as the primary backend language.
- AI platform files are support infrastructure, not product features.
- If a template utility is copied from the platform template, it must remain clearly identified as platform infrastructure.
## Planning Rules
Before drafting or executing a task:
1. Read `ai/architecture-index.md`.
2. Read `ai/repo-context.md`.
3. Read the relevant role file in `ai/orchestrator/`.
4. Read the small set of project files directly related to the requested change.
When no pending product task exists:
1. Do not invent a large backlog.
2. Only create a minimal technical validation task if needed to verify platform readiness.
3. Avoid feature planning that changes product scope without instruction.
## Change Budget
- Prefer fewer than 5 modified files per task.
- Prefer changes under 200 lines when feasible.
- Split work into follow-up tasks if the scope grows.
## Validation
Before marking a task as done:
1. Run the validation listed in the task.
2. Review `git diff --name-only`.
3. Confirm that changed files match the expected scope.
4. Update documentation if the task changed workflow or architecture assumptions.
If integration tests are relevant and `scripts/run-integration-tests.ps1` exists, use it.
If no integration tests are configured for the affected scope, document that explicitly in the task outcome.
-34
View File
@@ -1,34 +0,0 @@
{
"schema_version": 1,
"project": {
"name": "HLL Vietnam",
"type": "community website",
"identity": "Spanish-speaking HLL Vietnam Discord community",
"visual_direction": "military, Vietnam, tactical, sober"
},
"workflow": {
"orchestrator": "ChatGPT coordinates with the human client/product owner and prepares scoped tasks.",
"worker": "Codex CLI workers execute only explicit tasks and follow AGENTS.md.",
"task_template": "ai/task-template.md",
"task_paths": {
"pending": "ai/tasks/pending",
"in_progress": "ai/tasks/in-progress",
"review": "ai/tasks/review",
"blocked": "ai/tasks/blocked",
"obsolete": "ai/tasks/obsolete",
"done": "ai/tasks/done"
}
},
"runner": {
"lock_file": "ai/worker.lock",
"metrics_file": "ai/system-metrics.md",
"reports_path": "ai/reports",
"integration_tests_script": "scripts/run-integration-tests.ps1",
"codex_prompt": "Follow AGENTS.md, read the platform context in ai/, and process the pending tasks without acting outside task scope."
},
"constraints": {
"frontend": "Keep HTML, CSS and vanilla JavaScript compatible with direct browser opening when applicable.",
"backend": "Python is the planned backend baseline. Do not add backend behavior without a task.",
"scope": "Preserve HLL Vietnam context. Do not expand Elo/MMR, historical workers or RCON server #03 handling from platform tasks."
}
}
-167
View File
@@ -21,16 +21,6 @@ DEFAULT_HISTORICAL_CRCON_RETRY_DELAY_SECONDS = 0.5
DEFAULT_HISTORICAL_REFRESH_INTERVAL_SECONDS = 1800
DEFAULT_HISTORICAL_REFRESH_OVERLAP_HOURS = 12
DEFAULT_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS = 900
DEFAULT_PUBLIC_FULL_REFRESH_ENABLED = True
DEFAULT_PUBLIC_FULL_REFRESH_TIME = "06:00"
DEFAULT_PUBLIC_FULL_REFRESH_TIMEZONE = "Europe/Madrid"
DEFAULT_PUBLIC_RANKING_REFRESH_INTERVAL_SECONDS = 900
DEFAULT_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS = 60
DEFAULT_PUBLIC_RANKING_WEEKLY_REFRESH_MINUTE = 10
DEFAULT_PUBLIC_RANKING_MONTHLY_REFRESH_TIMES = ("07:00", "19:00")
DEFAULT_PUBLIC_HISTORICAL_WEEKLY_REFRESH_MINUTE = 25
DEFAULT_PUBLIC_HISTORICAL_MONTHLY_REFRESH_MINUTE = 40
DEFAULT_PUBLIC_HISTORICAL_MONTHLY_REFRESH_HOUR_INTERVAL = 2
DEFAULT_HISTORICAL_REFRESH_MAX_RETRIES = 2
DEFAULT_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS = 30
DEFAULT_HISTORICAL_FULL_SNAPSHOT_EVERY_RUNS = 4
@@ -47,10 +37,6 @@ DEFAULT_RCON_HISTORICAL_CAPTURE_MAX_RETRIES = 2
DEFAULT_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS = 15
DEFAULT_RCON_CURRENT_MATCH_CAPTURE_INTERVAL_SECONDS = 5
DEFAULT_RCON_CURRENT_MATCH_WRITER_LOCK_TIMEOUT_SECONDS = 4.0
DEFAULT_CURRENT_MATCH_ADMINLOG_INTERVAL_SECONDS = 10
DEFAULT_CURRENT_MATCH_ADMINLOG_LOOKBACK_SECONDS = 180
DEFAULT_CURRENT_MATCH_ADMINLOG_ENABLED = False
DEFAULT_KPM_MIN_ACTIVE_SECONDS = 60
DEFAULT_RCON_BACKFILL_CHUNK_HOURS = 6
DEFAULT_RCON_BACKFILL_SLEEP_SECONDS = 1.0
DEFAULT_RCON_BACKFILL_MAX_DAYS_BACK = 45
@@ -309,29 +295,6 @@ def _read_bool_env(name: str, *, default: bool) -> bool:
raise ValueError(f"{name} must be a boolean value.")
def _normalize_hh_mm_value(name: str, configured_value: str) -> str:
"""Normalize one HH:MM value used by public refresh scheduler settings."""
normalized_value = configured_value.strip()
parts = normalized_value.split(":")
if len(parts) != 2:
raise ValueError(f"{name} must use HH:MM format.")
try:
hour, minute = (int(part) for part in parts)
except (TypeError, ValueError) as error:
raise ValueError(f"{name} must use HH:MM format.") from error
if hour < 0 or hour > 23 or minute < 0 or minute > 59:
raise ValueError(f"{name} must use HH:MM format.")
return f"{hour:02d}:{minute:02d}"
def _read_minute_env(name: str, default_value: int) -> int:
"""Read one scheduler minute offset constrained to one wall-clock hour."""
minute = _read_int_env(name, str(default_value), minimum=0)
if minute > 59:
raise ValueError(f"{name} must be at most 59.")
return minute
def get_historical_refresh_overlap_hours() -> int:
"""Return the overlap window used by incremental historical refreshes."""
configured_value = os.getenv(
@@ -547,101 +510,6 @@ def get_rcon_historical_capture_retry_delay_seconds() -> int:
return retry_delay_seconds
def get_public_full_refresh_enabled() -> bool:
"""Return whether the runner should execute the daily public full refresh."""
return _read_bool_env(
"HLL_PUBLIC_FULL_REFRESH_ENABLED",
default=DEFAULT_PUBLIC_FULL_REFRESH_ENABLED,
)
def get_public_full_refresh_time() -> str:
"""Return the local HH:MM time for the daily public full refresh."""
configured_value = os.getenv(
"HLL_PUBLIC_FULL_REFRESH_TIME",
DEFAULT_PUBLIC_FULL_REFRESH_TIME,
)
return _normalize_hh_mm_value("HLL_PUBLIC_FULL_REFRESH_TIME", configured_value)
def get_public_full_refresh_timezone() -> str:
"""Return the IANA timezone for the daily public full refresh."""
configured_value = os.getenv(
"HLL_PUBLIC_FULL_REFRESH_TIMEZONE",
DEFAULT_PUBLIC_FULL_REFRESH_TIMEZONE,
).strip()
if not configured_value:
raise ValueError("HLL_PUBLIC_FULL_REFRESH_TIMEZONE cannot be empty.")
return configured_value
def get_public_ranking_refresh_interval_seconds() -> int:
"""Return how often weekly/monthly public ranking snapshots should refresh."""
return _read_int_env(
"HLL_PUBLIC_RANKING_REFRESH_INTERVAL_SECONDS",
str(DEFAULT_PUBLIC_RANKING_REFRESH_INTERVAL_SECONDS),
minimum=1,
)
def get_public_recent_matches_refresh_interval_seconds() -> int:
"""Return how often recent-match public snapshots should refresh without event hooks."""
return _read_int_env(
"HLL_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS",
str(DEFAULT_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS),
minimum=1,
)
def get_public_ranking_weekly_refresh_minute() -> int:
"""Return the minute offset used by hourly weekly ranking snapshot refreshes."""
return _read_minute_env(
"HLL_PUBLIC_RANKING_WEEKLY_REFRESH_MINUTE",
DEFAULT_PUBLIC_RANKING_WEEKLY_REFRESH_MINUTE,
)
def get_public_ranking_monthly_refresh_times() -> tuple[str, ...]:
"""Return the local HH:MM slots used by monthly ranking refreshes."""
configured_value = os.getenv(
"HLL_PUBLIC_RANKING_MONTHLY_REFRESH_TIMES",
",".join(DEFAULT_PUBLIC_RANKING_MONTHLY_REFRESH_TIMES),
)
values = tuple(
_normalize_hh_mm_value("HLL_PUBLIC_RANKING_MONTHLY_REFRESH_TIMES", item)
for item in configured_value.split(",")
if item.strip()
)
if not values:
raise ValueError("HLL_PUBLIC_RANKING_MONTHLY_REFRESH_TIMES cannot be empty.")
return values
def get_public_historical_weekly_refresh_minute() -> int:
"""Return the minute offset used by hourly historical weekly refreshes."""
return _read_minute_env(
"HLL_PUBLIC_HISTORICAL_WEEKLY_REFRESH_MINUTE",
DEFAULT_PUBLIC_HISTORICAL_WEEKLY_REFRESH_MINUTE,
)
def get_public_historical_monthly_refresh_minute() -> int:
"""Return the minute offset used by periodic historical monthly refreshes."""
return _read_minute_env(
"HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_MINUTE",
DEFAULT_PUBLIC_HISTORICAL_MONTHLY_REFRESH_MINUTE,
)
def get_public_historical_monthly_refresh_hour_interval() -> int:
"""Return the hour interval used by periodic historical monthly refreshes."""
return _read_int_env(
"HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_HOUR_INTERVAL",
str(DEFAULT_PUBLIC_HISTORICAL_MONTHLY_REFRESH_HOUR_INTERVAL),
minimum=1,
)
def get_rcon_capture_mode() -> str:
"""Return whether the worker runs the normal historical pipeline or live capture only."""
configured_mode = os.getenv("HLL_RCON_CAPTURE_MODE")
@@ -681,41 +549,6 @@ def get_rcon_current_match_writer_lock_timeout_seconds() -> float:
)
def get_current_match_adminlog_interval_seconds() -> int:
"""Return the dedicated current-match AdminLog worker interval."""
return _read_int_env(
"CURRENT_MATCH_ADMINLOG_INTERVAL_SECONDS",
str(DEFAULT_CURRENT_MATCH_ADMINLOG_INTERVAL_SECONDS),
minimum=1,
)
def get_current_match_adminlog_lookback_seconds() -> int:
"""Return the overlap-safe AdminLog lookback for the dedicated current-match worker."""
return _read_int_env(
"CURRENT_MATCH_ADMINLOG_LOOKBACK_SECONDS",
str(DEFAULT_CURRENT_MATCH_ADMINLOG_LOOKBACK_SECONDS),
minimum=1,
)
def get_current_match_adminlog_enabled() -> bool:
"""Return whether a deployment should enable the dedicated current-match worker."""
return _read_bool_env(
"CURRENT_MATCH_ADMINLOG_ENABLED",
default=DEFAULT_CURRENT_MATCH_ADMINLOG_ENABLED,
)
def get_kpm_min_active_seconds() -> int:
"""Return the minimum observed active seconds required before KPM is considered valid."""
return _read_int_env(
"HLL_KPM_MIN_ACTIVE_SECONDS",
str(DEFAULT_KPM_MIN_ACTIVE_SECONDS),
minimum=1,
)
def get_rcon_backfill_chunk_hours() -> int:
"""Return the AdminLog backfill chunk size in hours."""
return _read_int_env(
+5 -28
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
from .a2s_client import DEFAULT_A2S_TIMEOUT
from .collector import collect_server_snapshots
from .config import get_historical_data_source_kind, get_live_data_source_kind
from .providers.public_scoreboard_provider import PublicScoreboardHistoricalDataSource
@@ -49,12 +48,7 @@ class LiveDataSource(Protocol):
source_kind: str
def collect_snapshots(
self,
*,
persist: bool,
timeout_seconds: float | None = None,
) -> dict[str, object]:
def collect_snapshots(self, *, persist: bool) -> dict[str, object]:
"""Collect one live snapshot batch."""
def build_target_index(self) -> dict[str | None, object]:
@@ -67,17 +61,11 @@ class A2SLiveDataSource:
source_kind: str = LIVE_SOURCE_A2S
def collect_snapshots(
self,
*,
persist: bool,
timeout_seconds: float | None = None,
) -> dict[str, object]:
def collect_snapshots(self, *, persist: bool) -> dict[str, object]:
return collect_server_snapshots(
source_mode="a2s",
allow_controlled_fallback=False,
persist=persist,
timeout=timeout_seconds if timeout_seconds is not None else DEFAULT_A2S_TIMEOUT,
)
def build_target_index(self) -> dict[str | None, A2SServerTarget]:
@@ -96,20 +84,12 @@ class RconFirstLiveDataSource:
fallback_source: A2SLiveDataSource = A2SLiveDataSource()
source_kind: str = SOURCE_KIND_RCON
def collect_snapshots(
self,
*,
persist: bool,
timeout_seconds: float | None = None,
) -> dict[str, object]:
def collect_snapshots(self, *, persist: bool) -> dict[str, object]:
attempts: list[dict[str, object]] = []
fallback_reason: str | None = None
try:
primary_payload = self.primary_source.collect_snapshots(
persist=persist,
timeout_seconds=timeout_seconds,
)
primary_payload = self.primary_source.collect_snapshots(persist=persist)
except Exception as error: # noqa: BLE001 - source arbitration keeps fallback controlled
attempts.append(
build_source_attempt(
@@ -153,10 +133,7 @@ class RconFirstLiveDataSource:
fallback_reason = "rcon-live-returned-no-usable-snapshots"
try:
fallback_payload = self.fallback_source.collect_snapshots(
persist=persist,
timeout_seconds=timeout_seconds,
)
fallback_payload = self.fallback_source.collect_snapshots(persist=persist)
except Exception as error: # noqa: BLE001 - keep combined failure explicit
attempts.append(
build_source_attempt(
File diff suppressed because it is too large Load Diff
-227
View File
@@ -392,193 +392,6 @@ def generate_and_persist_priority_historical_snapshots(
}
def generate_and_persist_historical_leaderboard_snapshots(
*,
timeframe: str,
server_keys: tuple[str, ...] = PREWARM_SNAPSHOT_SERVER_KEYS,
generated_at: datetime | None = None,
leaderboard_limit: int = DEFAULT_WEEKLY_LEADERBOARD_LIMIT,
ensure_storage: bool = True,
db_path: Path | None = None,
) -> dict[str, object]:
"""Build and persist only the weekly or monthly leaderboard snapshot set."""
from .historical_snapshot_storage import persist_historical_snapshot_batch
generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc))
leaderboard_limit = _normalize_snapshot_limit("leaderboard_limit", leaderboard_limit)
normalized_timeframe = str(timeframe or "").strip().lower()
if normalized_timeframe not in {"weekly", "monthly"}:
raise ValueError("timeframe must be 'weekly' or 'monthly'.")
snapshots: list[dict[str, object]] = []
for server_key in server_keys:
for metric in SNAPSHOT_LEADERBOARD_METRICS:
if normalized_timeframe == "weekly":
_log_snapshot_build_started(
server_key,
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
metric=metric,
)
snapshots.append(
_build_weekly_leaderboard_snapshot(
server_key,
metric,
generated_at_value,
limit=leaderboard_limit,
ensure_storage=ensure_storage if not snapshots else False,
db_path=db_path,
)
)
continue
_log_snapshot_build_started(
server_key,
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD,
metric=metric,
)
snapshots.append(
_build_monthly_leaderboard_snapshot(
server_key,
metric,
generated_at_value,
limit=leaderboard_limit,
ensure_storage=ensure_storage if not snapshots else False,
db_path=db_path,
)
)
persisted_records = persist_historical_snapshot_batch(snapshots, db_path=db_path)
snapshots_by_server: dict[str, int] = {}
for record in persisted_records:
snapshots_by_server.setdefault(record.server_key, 0)
snapshots_by_server[record.server_key] += 1
return {
"generated_at": _to_iso(generated_at_value),
"timeframe": normalized_timeframe,
"snapshot_policy": "leaderboard-subset",
"server_keys": list(server_keys),
"metrics": list(SNAPSHOT_LEADERBOARD_METRICS),
"snapshot_count": len(persisted_records),
"servers_processed": len(snapshots_by_server),
"snapshots_by_server": snapshots_by_server,
}
def generate_and_persist_historical_monthly_ui_snapshots(
*,
server_keys: tuple[str, ...] = PREWARM_SNAPSHOT_SERVER_KEYS,
generated_at: datetime | None = None,
leaderboard_limit: int = DEFAULT_WEEKLY_LEADERBOARD_LIMIT,
ensure_storage: bool = True,
db_path: Path | None = None,
) -> dict[str, object]:
"""Build and persist the monthly historical subset rendered by the public UI."""
from .historical_snapshot_storage import persist_historical_snapshot_batch
generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc))
leaderboard_limit = _normalize_snapshot_limit("leaderboard_limit", leaderboard_limit)
snapshots: list[dict[str, object]] = []
for server_key in server_keys:
for metric in SNAPSHOT_LEADERBOARD_METRICS:
_log_snapshot_build_started(
server_key,
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD,
metric=metric,
)
snapshots.append(
_build_monthly_leaderboard_snapshot(
server_key,
metric,
generated_at_value,
limit=leaderboard_limit,
ensure_storage=ensure_storage if not snapshots else False,
db_path=db_path,
)
)
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_MVP)
snapshots.append(
_build_monthly_mvp_snapshot(
server_key,
generated_at_value,
limit=leaderboard_limit,
db_path=db_path,
)
)
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_MVP_V2)
snapshots.append(
_build_monthly_mvp_v2_snapshot(
server_key,
generated_at_value,
limit=leaderboard_limit,
tolerate_missing_player_event_ledger=True,
db_path=db_path,
)
)
persisted_records = persist_historical_snapshot_batch(snapshots, db_path=db_path)
snapshots_by_server: dict[str, int] = {}
for record in persisted_records:
snapshots_by_server.setdefault(record.server_key, 0)
snapshots_by_server[record.server_key] += 1
return {
"generated_at": _to_iso(generated_at_value),
"timeframe": "monthly",
"snapshot_policy": "monthly-ui-subset",
"server_keys": list(server_keys),
"metrics": list(SNAPSHOT_LEADERBOARD_METRICS),
"includes_monthly_mvp": True,
"snapshot_count": len(persisted_records),
"servers_processed": len(snapshots_by_server),
"snapshots_by_server": snapshots_by_server,
}
def generate_and_persist_recent_historical_snapshots(
*,
server_key: str | None = None,
generated_at: datetime | None = None,
recent_matches_limit: int = DEFAULT_RECENT_MATCHES_LIMIT,
db_path: Path | None = None,
) -> dict[str, object]:
"""Build and persist only the public recent-matches snapshots."""
from .historical_snapshot_storage import persist_historical_snapshot_batch
generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc))
recent_matches_limit = _normalize_snapshot_limit(
"recent_matches_limit",
recent_matches_limit,
)
snapshots = [
_build_recent_matches_snapshot(
target_server_key,
generated_at_value,
limit=recent_matches_limit,
db_path=db_path,
)
for target_server_key in _resolve_snapshot_target_keys(
server_key=server_key,
db_path=db_path,
)
]
persisted_records = persist_historical_snapshot_batch(snapshots, db_path=db_path)
snapshots_by_server: dict[str, int] = {}
for record in persisted_records:
snapshots_by_server.setdefault(record.server_key, 0)
snapshots_by_server[record.server_key] += 1
return {
"generated_at": _to_iso(generated_at_value),
"server_slug": server_key,
"snapshot_policy": "recent-matches-only",
"snapshot_count": len(persisted_records),
"servers_processed": len(snapshots_by_server),
"snapshots_by_server": snapshots_by_server,
}
def _build_server_summary_snapshot(
server_key: str,
generated_at: datetime,
@@ -619,7 +432,6 @@ def _build_weekly_leaderboard_snapshot(
generated_at: datetime,
*,
limit: int,
ensure_storage: bool = True,
db_path: Path | None = None,
) -> dict[str, object]:
if get_historical_data_source_kind() == SOURCE_KIND_RCON:
@@ -630,7 +442,6 @@ def _build_weekly_leaderboard_snapshot(
server_key=server_key,
metric=metric,
timeframe="weekly",
ensure_storage=ensure_storage,
db_path=db_path,
now=generated_at,
)
@@ -666,7 +477,6 @@ def _build_monthly_leaderboard_snapshot(
generated_at: datetime,
*,
limit: int,
ensure_storage: bool = True,
db_path: Path | None = None,
) -> dict[str, object]:
if get_historical_data_source_kind() == SOURCE_KIND_RCON:
@@ -677,7 +487,6 @@ def _build_monthly_leaderboard_snapshot(
server_key=server_key,
metric=metric,
timeframe="monthly",
ensure_storage=ensure_storage,
db_path=db_path,
now=generated_at,
)
@@ -839,49 +648,13 @@ def _build_monthly_mvp_v2_snapshot(
generated_at: datetime,
*,
limit: int,
tolerate_missing_player_event_ledger: bool = False,
db_path: Path | None = None,
) -> dict[str, object]:
try:
ranking_result = list_monthly_mvp_v2_ranking(
limit=limit,
server_id=server_key,
db_path=db_path,
)
except sqlite3.OperationalError as error:
if not tolerate_missing_player_event_ledger or "player_event_raw_ledger" not in str(error):
raise
ranking_result = {
"timeframe": "monthly",
"metric": "mvp-v2",
"ranking_version": "v2",
"window_start": None,
"window_end": None,
"window_days": None,
"window_kind": "missing-player-event-ledger",
"window_label": "Sin ledger de eventos",
"uses_fallback": True,
"selection_reason": "player-event-raw-ledger-missing",
"current_month_start": None,
"current_month_closed_matches": 0,
"previous_month_closed_matches": 0,
"sufficient_sample": {
"minimum_closed_matches": 0,
"current_month_closed_matches": 0,
"current_month_has_sufficient_sample": False,
"is_early_month": False,
},
"event_coverage": {
"ready": False,
"reason": "player-event-raw-ledger-missing",
"source_range_start": None,
"source_range_end": None,
},
"eligibility": None,
"eligible_players_count": 0,
"items": [],
"error": str(error),
}
month_key = str(ranking_result.get("window_start") or "")[:7] or None
event_coverage = ranking_result.get("event_coverage")
source_range_start = None
-11
View File
@@ -1761,22 +1761,11 @@ def list_monthly_mvp_v2_ranking(
window_start = monthly_window["window_start"]
window_end = monthly_window["window_end"]
month_key = window_start.strftime("%Y-%m")
try:
event_coverage = _get_monthly_player_event_coverage(
server_id=server_id,
month_key=month_key,
db_path=resolved_path,
)
except sqlite3.OperationalError as error:
if "player_event_raw_ledger" not in str(error):
raise
event_coverage = {
"ready": False,
"reason": "player-event-raw-ledger-missing",
"source_range_start": None,
"source_range_end": None,
"error": str(error),
}
window_days = _calculate_window_days(window_start=window_start, window_end=window_end)
empty_result = {
+69 -614
View File
@@ -49,22 +49,12 @@ from .historical_storage import (
list_weekly_top_kills,
)
from .rcon_historical_read_model import get_rcon_historical_match_detail
from .rcon_annual_rankings import get_annual_ranking_snapshot
from .rcon_historical_leaderboards import (
get_latest_ranking_snapshot,
is_ranking_runtime_fallback_enabled,
list_rcon_materialized_leaderboard,
)
from .rcon_historical_player_stats import search_rcon_materialized_players
from .rcon_historical_player_stats import get_rcon_materialized_player_stats
from .normalizers import normalize_map_name
from .rcon_client import load_rcon_targets, query_live_server_sample
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 .storage import list_latest_snapshots, list_server_history, list_snapshot_history
PUBLIC_SERVER_STATUS_TIMEOUT_SECONDS = 2.5
def build_health_payload() -> dict[str, str]:
"""Return a small status payload without committing to business contracts."""
@@ -161,9 +151,7 @@ def build_servers_payload() -> dict[str, object]:
if persisted_items:
refresh_status = "failed" if refresh_attempted else "not-needed"
response_source = (
"persisted-stale-snapshot"
if refresh_attempted
else "persisted-fresh-snapshot"
"persisted-stale-snapshot" if refresh_attempted else "persisted-fresh-snapshot"
)
return _build_servers_response(
items=persisted_items,
@@ -419,27 +407,16 @@ def build_current_match_kill_feed_payload(
origin = get_trusted_public_scoreboard_origin(server_slug)
if origin is None:
raise ValueError("Unsupported current match server.")
try:
feed = list_current_match_kill_feed(
server_key=origin.slug,
limit=limit,
since_event_id=since_event_id,
ensure_storage=False,
)
source_policy = _build_current_match_admin_log_source_policy(status="success")
except Exception as error: # noqa: BLE001 - public live read must degrade cleanly
feed = _empty_current_match_kill_feed_payload()
source_policy = _build_current_match_admin_log_source_policy(
status="error",
error_reason=_public_error_reason(error),
message=str(error),
)
return {
"status": "ok",
"data": {
"server_slug": origin.slug,
"server_name": origin.display_name,
**source_policy,
**feed,
},
}
@@ -450,86 +427,17 @@ def build_current_match_player_stats_payload(*, server_slug: str) -> dict[str, o
origin = get_trusted_public_scoreboard_origin(server_slug)
if origin is None:
raise ValueError("Unsupported current match server.")
try:
stats = list_current_match_player_stats(
server_key=origin.slug,
ensure_storage=False,
)
source_policy = _build_current_match_admin_log_source_policy(status="success")
except Exception as error: # noqa: BLE001 - public live read must degrade cleanly
stats = _empty_current_match_player_stats_payload()
source_policy = _build_current_match_admin_log_source_policy(
status="error",
error_reason=_public_error_reason(error),
message=str(error),
)
stats = list_current_match_player_stats(server_key=origin.slug)
return {
"status": "ok",
"data": {
"server_slug": origin.slug,
"server_name": origin.display_name,
**source_policy,
**stats,
},
}
def _empty_current_match_kill_feed_payload() -> dict[str, object]:
return {
"scope": "no-current-match-events",
"confidence": "unavailable",
"stale_events_filtered": 0,
"items": [],
}
def _empty_current_match_player_stats_payload() -> dict[str, object]:
return {
"scope": "no-current-match-events",
"confidence": "unavailable",
"source": "rcon-admin-log-current-match-summary",
"updated_at": None,
"stale_events_filtered": 0,
"items": [],
}
def _build_current_match_admin_log_source_policy(
*,
status: str,
error_reason: str | None = None,
message: str | None = None,
) -> dict[str, object]:
return build_source_policy(
primary_source=SOURCE_KIND_RCON,
selected_source="rcon-admin-log",
fallback_used=status != "success",
fallback_reason=error_reason,
source_attempts=[
build_source_attempt(
source="rcon-admin-log",
role="read-model",
status=status,
reason=error_reason,
message=message,
)
],
)
def _public_error_reason(error: Exception) -> str:
if isinstance(error, FileNotFoundError):
return "admin-log-read-model-unavailable"
if isinstance(error, TimeoutError):
return "admin-log-read-timeout"
message = str(error).lower()
if "timeout" in message or "timed out" in message:
return "admin-log-read-timeout"
if "does not exist" in message or "no such table" in message:
return "admin-log-read-model-unavailable"
return "admin-log-read-failed"
def _query_current_match_rcon_sample(server_slug: str) -> dict[str, object] | None:
"""Read one configured trusted RCON target for the current-match view."""
try:
@@ -552,75 +460,6 @@ def _utc_timestamp_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _to_iso_or_none(value: object) -> str | None:
if value is None:
return None
if isinstance(value, datetime):
parsed = value
elif isinstance(value, str) and value.strip():
try:
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError:
return None
else:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def _normalize_public_server_id(server_id: str | None) -> str:
normalized = str(server_id or "").strip().lower()
if not normalized or normalized == "all":
return ALL_SERVERS_SLUG
return str(server_id).strip()
def _serialize_public_server_id(server_id: object) -> str:
normalized = str(server_id or "").strip()
if not normalized or normalized == ALL_SERVERS_SLUG:
return "all"
return normalized
def _normalize_global_ranking_items(items: object) -> list[dict[str, object]]:
normalized_items: list[dict[str, object]] = []
for item in items if isinstance(items, list) else []:
if not isinstance(item, dict):
continue
matches_considered = int(item.get("matches_considered") or 0)
kills = int(item.get("kills") or 0)
normalized_items.append(
{
"ranking_position": int(item.get("ranking_position") or 0),
"player_id": item.get("player_id"),
"player_name": item.get("player_name"),
"metric_value": _coerce_public_metric_value(item.get("metric_value")),
"matches_considered": matches_considered,
"kills": kills,
"deaths": int(item.get("deaths") or 0),
"teamkills": int(item.get("teamkills") or 0),
"kd_ratio": float(item.get("kd_ratio") or 0.0),
"kills_per_match": float(
item.get("kills_per_match")
if item.get("kills_per_match") is not None
else round(kills / matches_considered, 2) if matches_considered else 0.0
),
}
)
return normalized_items
def _coerce_public_metric_value(value: object) -> int | float:
try:
numeric = float(value or 0)
except (TypeError, ValueError):
return 0
if numeric.is_integer():
return int(numeric)
return round(numeric, 2)
def _source_when_present(*values: object, source: str) -> str | None:
return source if any(value is not None for value in values) else None
@@ -734,321 +573,6 @@ def build_historical_leaderboard_payload(
}
def build_stats_player_search_payload(
*,
query: str,
server_id: str | None = None,
limit: int = 10,
) -> dict[str, object]:
"""Return lightweight player search results for future stats UX flows."""
normalized_query = query.strip()
if not normalized_query:
raise ValueError("Query cannot be empty.")
result = search_rcon_materialized_players(
query=normalized_query,
server_id=server_id,
limit=limit,
)
return {
"status": "ok",
"data": {
"query": result["query"],
"server_id": result["server_id"],
"source": result.get("source"),
"items": result["items"],
},
}
def build_stats_player_profile_payload(
*,
player_id: str,
server_id: str | None = None,
timeframe: str = "weekly",
) -> dict[str, object]:
"""Return personal RCON materialized stats and weekly/monthly ranking context."""
result = get_rcon_materialized_player_stats(
player_id=player_id,
server_id=server_id,
timeframe=timeframe,
)
kills = int(result.get("kills", 0) or 0)
deaths = int(result.get("deaths", 0) or 0)
matches_considered = int(result.get("matches_considered", 0) or 0)
teamkills = int(result.get("teamkills", 0) or 0)
kills_per_match = round(kills / matches_considered, 2) if matches_considered else 0.0
deaths_per_match = round(deaths / matches_considered, 2) if matches_considered else 0.0
kd_ratio = round(kills / deaths, 2) if deaths else float(kills)
return {
"status": "ok",
"data": {
"player_id": result.get("player_id"),
"player_name": result.get("player_name"),
"platform": result.get("platform"),
"steam_id_64": result.get("steam_id_64"),
"epic_id": result.get("epic_id"),
"external_profile_links": result.get("external_profile_links") or {},
"server_id": result.get("server_id"),
"timeframe": result.get("timeframe"),
"window_start": _to_iso_or_none(result.get("window_start")),
"window_end": _to_iso_or_none(result.get("window_end")),
"window_kind": result.get("window_kind"),
"matches_considered": matches_considered,
"kills": kills,
"deaths": deaths,
"teamkills": teamkills,
"kd_ratio": kd_ratio,
"kills_per_match": kills_per_match,
"deaths_per_match": deaths_per_match,
"player_active_seconds": result.get("player_active_seconds"),
"player_active_minutes": result.get("player_active_minutes"),
"kpm": result.get("kpm"),
"kpm_status": result.get("kpm_status"),
"active_time_source": result.get("active_time_source"),
"active_time_coverage": result.get("active_time_coverage"),
"weekly_ranking": result.get("weekly_ranking"),
"monthly_ranking": result.get("monthly_ranking"),
"source": result.get("source"),
},
}
def build_annual_ranking_snapshot_payload(
*,
year: int,
server_id: str | None = None,
metric: str = "kills",
limit: int = 20,
) -> dict[str, object]:
"""Return an annual ranking payload from precomputed snapshots."""
result = get_annual_ranking_snapshot(
year=year,
server_key=server_id,
metric=metric,
limit=limit,
)
items = result.get("items") or []
return {
"status": "ok",
"data": {
"year": result.get("year"),
"server_id": result.get("server_id"),
"metric": result.get("metric"),
"limit": result.get("limit"),
"requested_limit": result.get("requested_limit"),
"effective_limit": result.get("effective_limit"),
"snapshot_limit": result.get("snapshot_limit"),
"item_count": result.get("item_count"),
"source": result.get("source"),
"snapshot_status": result.get("snapshot_status"),
"generated_at": result.get("generated_at"),
"window_start": result.get("window_start"),
"window_end": result.get("window_end"),
"source_matches_count": int(result.get("source_matches_count") or 0),
"items": [
{
"ranking_position": int(item.get("ranking_position") or 0),
"player_id": item.get("player_id"),
"player_name": item.get("player_name"),
"metric_value": _coerce_public_metric_value(item.get("metric_value")),
"matches_considered": int(item.get("matches_considered") or 0),
"kills": int(item.get("kills") or 0),
"deaths": int(item.get("deaths") or 0),
"teamkills": int(item.get("teamkills") or 0),
"kd_ratio": float(item.get("kd_ratio") or 0.0),
"kills_per_match": (
float(item.get("kills_per_match"))
if item.get("kills_per_match") is not None
else (
round(
int(item.get("kills") or 0)
/ int(item.get("matches_considered") or 0),
2,
)
if int(item.get("matches_considered") or 0) > 0
else 0.0
)
),
}
for item in items
if isinstance(item, dict)
],
},
}
def build_global_ranking_payload(
*,
timeframe: str = "weekly",
server_id: str | None = None,
metric: str = "kills",
limit: int = 20,
year: int | None = None,
) -> dict[str, object]:
"""Return the dedicated Ranking page payload without changing Stats contracts."""
normalized_timeframe = str(timeframe or "weekly").strip().lower()
normalized_server_id = _normalize_public_server_id(server_id)
if normalized_timeframe == "annual":
if year is None:
raise ValueError("year is required when timeframe=annual")
result = get_annual_ranking_snapshot(
year=year,
server_key=normalized_server_id,
metric=metric,
limit=limit,
)
return {
"status": "ok",
"data": {
"page_kind": "global-ranking",
"title": "Ranking global anual",
"context": "global-ranking-annual",
"timeframe": "annual",
"server_id": _serialize_public_server_id(result.get("server_id")),
"metric": result.get("metric"),
"limit": int(result.get("limit") or 0),
"requested_limit": int(result.get("requested_limit") or 0),
"effective_limit": int(result.get("effective_limit") or 0),
"year": int(result.get("year") or year),
"window_start": result.get("window_start"),
"window_end": result.get("window_end"),
"window_kind": "annual-snapshot",
"window_label": "Anual",
"snapshot_status": result.get("snapshot_status"),
"generated_at": result.get("generated_at"),
"freshness": (
"snapshot" if result.get("snapshot_status") == "ready" else "missing"
),
"fallback_used": False,
"snapshot_limit": result.get("snapshot_limit"),
"item_count": int(result.get("item_count") or 0),
"source_matches_count": int(result.get("source_matches_count") or 0),
"source": {
"primary_source": "rcon",
"read_model": "rcon-annual-ranking-snapshot",
"generated_at": result.get("generated_at"),
"freshness": (
"snapshot" if result.get("snapshot_status") == "ready" else "missing"
),
},
"items": _normalize_global_ranking_items(result.get("items")),
},
}
snapshot_result = get_latest_ranking_snapshot(
server_key=normalized_server_id,
timeframe=normalized_timeframe,
metric=metric,
limit=limit,
)
if snapshot_result.get("snapshot_status") == "ready":
return {
"status": "ok",
"data": {
"page_kind": "global-ranking",
"title": "Ranking global",
"context": f"global-ranking-{normalized_timeframe}",
"timeframe": normalized_timeframe,
"server_id": _serialize_public_server_id(snapshot_result.get("server_id")),
"metric": snapshot_result.get("metric"),
"limit": int(snapshot_result.get("limit") or 0),
"requested_limit": int(snapshot_result.get("requested_limit") or limit),
"effective_limit": int(snapshot_result.get("effective_limit") or 0),
"window_start": snapshot_result.get("window_start"),
"window_end": snapshot_result.get("window_end"),
"window_kind": snapshot_result.get("window_kind"),
"window_label": snapshot_result.get("window_label"),
"snapshot_status": "ready",
"generated_at": snapshot_result.get("generated_at"),
"freshness": snapshot_result.get("freshness") or "fresh",
"fallback_used": False,
"source_matches_count": int(snapshot_result.get("source_matches_count") or 0),
"source": {
"primary_source": "rcon",
"read_model": "ranking-snapshot",
"snapshot_source": snapshot_result.get("source"),
"generated_at": snapshot_result.get("generated_at"),
"freshness": snapshot_result.get("freshness") or "fresh",
},
"items": _normalize_global_ranking_items(snapshot_result.get("items")),
},
}
runtime_fallback_enabled = is_ranking_runtime_fallback_enabled()
if not runtime_fallback_enabled:
return {
"status": "ok",
"data": {
"page_kind": "global-ranking",
"title": "Ranking global",
"context": f"global-ranking-{normalized_timeframe}",
"timeframe": normalized_timeframe,
"server_id": _serialize_public_server_id(snapshot_result.get("server_id")),
"metric": snapshot_result.get("metric"),
"limit": int(snapshot_result.get("limit") or limit),
"requested_limit": int(snapshot_result.get("requested_limit") or limit),
"effective_limit": int(snapshot_result.get("effective_limit") or 0),
"window_start": snapshot_result.get("window_start"),
"window_end": snapshot_result.get("window_end"),
"window_kind": snapshot_result.get("window_kind"),
"window_label": snapshot_result.get("window_label"),
"snapshot_status": "missing",
"generated_at": None,
"freshness": "missing",
"fallback_used": False,
"source_matches_count": 0,
"source": {
"primary_source": "rcon",
"read_model": "ranking-snapshot",
"snapshot_source": snapshot_result.get("source"),
"generated_at": None,
"freshness": "missing",
},
"items": [],
},
}
result = list_rcon_materialized_leaderboard(
server_key=normalized_server_id,
timeframe=normalized_timeframe,
metric=metric,
limit=limit,
)
return {
"status": "ok",
"data": {
"page_kind": "global-ranking",
"title": "Ranking global",
"context": f"global-ranking-{normalized_timeframe}",
"timeframe": normalized_timeframe,
"server_id": _serialize_public_server_id(result.get("server_key")),
"metric": result.get("metric"),
"limit": int(result.get("limit") or limit),
"requested_limit": int(limit),
"effective_limit": int(result.get("limit") or limit),
"window_start": result.get("window_start"),
"window_end": result.get("window_end"),
"window_kind": result.get("window_kind"),
"window_label": result.get("window_label"),
"selection_reason": result.get("selection_reason"),
"snapshot_status": "missing",
"generated_at": None,
"freshness": "runtime",
"fallback_used": True,
"source": {
"primary_source": "rcon",
"read_model": "rcon-materialized-admin-log-leaderboard",
"snapshot_source": "ranking-snapshot",
"generated_at": _utc_timestamp_now(),
"freshness": "runtime",
},
"items": _normalize_global_ranking_items(result.get("items")),
},
}
def build_weekly_leaderboard_payload(
*,
limit: int = 10,
@@ -1085,12 +609,6 @@ def build_recent_historical_matches_payload(
server_slug: str | None = None,
) -> dict[str, object]:
"""Return recent historical matches from persisted CRCON data."""
if server_slug:
return _build_recent_historical_matches_legacy_snapshot_payload(
limit=limit,
server_slug=server_slug,
)
if get_historical_data_source_kind() == "rcon":
data_source = get_rcon_historical_read_model()
if data_source is not None:
@@ -1213,29 +731,6 @@ def build_recent_historical_matches_payload(
}
def _build_recent_historical_matches_legacy_snapshot_payload(
*,
limit: int,
server_slug: str,
) -> dict[str, object]:
snapshot_payload = build_recent_historical_matches_snapshot_payload(
limit=limit,
server_slug=server_slug,
)
data = dict(snapshot_payload.get("data") or {})
data.update(
{
"title": "Partidas recientes por servidor",
"context": "historical-recent-matches",
"source": "historical-precomputed-snapshots",
"historical_data_source": get_historical_data_source_kind(),
"coverage_basis": "precomputed-recent-matches-snapshot",
"legacy_endpoint_policy": "snapshot-read-only-fast-path",
}
)
return {"status": snapshot_payload.get("status", "ok"), "data": data}
def build_historical_match_detail_payload(
*,
server_slug: str,
@@ -1270,30 +765,6 @@ def build_historical_match_detail_payload(
"item": item,
},
}
return {
"status": "ok",
"data": {
"title": "Detalle de partida historica",
"context": "historical-match-detail",
"source": "rcon-historical-competitive-read-model",
"found": False,
**build_source_policy(
primary_source=SOURCE_KIND_RCON,
selected_source=SOURCE_KIND_RCON,
fallback_used=False,
fallback_reason=None,
source_attempts=[
build_source_attempt(
source=SOURCE_KIND_RCON,
role="primary",
status="empty",
reason="historical-match-detail-read-model-missing",
)
],
),
"item": None,
},
}
item = get_historical_match_detail(server_slug=server_slug, match_id=match_id)
return {
@@ -1485,6 +956,17 @@ def build_leaderboard_snapshot_payload(
payload = snapshot.get("payload") if snapshot else {}
items = payload.get("items") if isinstance(payload, dict) else None
sliced_items = list(items[:limit]) if isinstance(items, list) else []
runtime_enrichment_applied = False
if _leaderboard_snapshot_items_need_playtime_enrichment(sliced_items):
runtime_items = _load_runtime_leaderboard_items(
limit=limit,
server_id=server_id,
metric=metric,
timeframe=normalized_timeframe,
)
if runtime_items:
sliced_items = runtime_items[:limit]
runtime_enrichment_applied = True
is_all_servers = server_id == ALL_SERVERS_SLUG
return {
"status": "ok",
@@ -1527,8 +1009,12 @@ def build_leaderboard_snapshot_payload(
"snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None,
"limit": limit,
"runtime_enrichment": {
"applied": False,
"reason": "disabled-on-public-snapshot-path",
"applied": runtime_enrichment_applied,
"reason": (
"snapshot-items-missing-total-time-seconds"
if runtime_enrichment_applied
else None
),
},
**_resolve_historical_fallback_policy(
fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet",
@@ -1582,6 +1068,55 @@ def build_recent_historical_matches_snapshot_payload(
payload = snapshot.get("payload") if snapshot else {}
items = payload.get("items") if isinstance(payload, dict) else None
sliced_items = list(items[:limit]) if isinstance(items, list) else []
if (
get_historical_data_source_kind() == SOURCE_KIND_RCON
and 0 < len(sliced_items) < limit
):
fallback_items = list_recent_historical_matches(limit=limit, server_slug=server_slug)
merged_items = _merge_recent_match_items(
primary_items=sliced_items,
fallback_items=fallback_items,
limit=limit,
)
if len(merged_items) > len(sliced_items):
return {
"status": "ok",
"data": {
"title": "Snapshot historico de partidas recientes por servidor",
"context": "historical-recent-matches-snapshot",
"source": "historical-precomputed-snapshots",
"server_slug": server_slug,
"found": snapshot is not None,
**_build_historical_snapshot_metadata(snapshot),
"snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None,
"limit": limit,
**build_source_policy(
primary_source=SOURCE_KIND_RCON,
selected_source="hybrid-rcon-plus-public-scoreboard",
fallback_used=True,
fallback_reason="rcon-historical-recent-matches-did-not-reach-requested-limit",
source_attempts=[
build_source_attempt(
source=SOURCE_KIND_RCON,
role="primary",
status="success",
reason="recent-matches-snapshot-served-by-rcon-competitive-model",
),
build_source_attempt(
source=SOURCE_KIND_PUBLIC_SCOREBOARD,
role="fallback",
status="success",
reason="recent-matches-snapshot-completed-from-public-scoreboard",
message=(
f"RCON snapshot returned {len(sliced_items)} items, completed to "
f"{len(merged_items)} of requested {limit}."
),
),
],
),
"items": merged_items,
},
}
return {
"status": "ok",
"data": {
@@ -1785,11 +1320,6 @@ def build_historical_server_summary_payload(
server_slug: str | None = None,
) -> dict[str, object]:
"""Return aggregated historical metrics per server."""
if server_slug:
return _build_historical_server_summary_legacy_snapshot_payload(
server_slug=server_slug,
)
if get_historical_data_source_kind() == "rcon":
data_source = get_rcon_historical_read_model()
if data_source is not None:
@@ -1862,33 +1392,6 @@ def build_historical_server_summary_payload(
}
def _build_historical_server_summary_legacy_snapshot_payload(
*,
server_slug: str,
) -> dict[str, object]:
snapshot_payload = build_historical_server_summary_snapshot_payload(
server_slug=server_slug,
)
data = dict(snapshot_payload.get("data") or {})
item = data.get("item") if isinstance(data.get("item"), dict) else None
data.update(
{
"title": (
"Cobertura historica agregada de todos los servidores"
if server_slug == ALL_SERVERS_SLUG
else "Cobertura historica importada por servidor"
),
"context": "historical-server-summary",
"source": "historical-precomputed-snapshots",
"summary_basis": "precomputed-server-summary-snapshot",
"weekly_ranking_window_days": 7,
"legacy_endpoint_policy": "snapshot-read-only-fast-path",
"items": [item] if item is not None else [],
}
)
return {"status": snapshot_payload.get("status", "ok"), "data": data}
def build_historical_player_profile_payload(player_id: str) -> dict[str, object]:
"""Return aggregate historical metrics for one player identity."""
profile = get_historical_player_profile(player_id)
@@ -2469,51 +1972,12 @@ def _should_refresh_snapshot(
return snapshot_age_seconds > max_snapshot_age_seconds
def _is_snapshot_stale(
snapshot_age_seconds: int | None,
max_snapshot_age_seconds: int,
) -> bool:
return snapshot_age_seconds is None or snapshot_age_seconds > max_snapshot_age_seconds
def _try_collect_real_time_snapshot() -> tuple[
list[dict[str, object]],
list[dict[str, object]],
dict[str, object],
]:
try:
payload = get_live_data_source().collect_snapshots(
persist=False,
timeout_seconds=PUBLIC_SERVER_STATUS_TIMEOUT_SECONDS,
)
except Exception as error: # noqa: BLE001 - public server status must degrade cleanly
reason = _public_server_refresh_error_reason(error)
return (
[],
[
{
"source": get_live_data_source_kind(),
"reason": reason,
"error_type": type(error).__name__,
"message": str(error),
}
],
build_source_policy(
primary_source=get_live_data_source_kind(),
selected_source="none",
fallback_used=True,
fallback_reason=reason,
source_attempts=[
build_source_attempt(
source=get_live_data_source_kind(),
role="primary",
status="error",
reason=reason,
message=str(error),
)
],
),
)
payload = get_live_data_source().collect_snapshots(persist=False)
snapshots = payload.get("snapshots")
items = _select_primary_snapshot_items(_enrich_server_items(list(snapshots or [])))
errors = payload.get("errors")
@@ -2530,15 +1994,6 @@ def _try_collect_real_time_snapshot() -> tuple[
)
def _public_server_refresh_error_reason(error: Exception) -> str:
message = str(error).lower()
if isinstance(error, TimeoutError) or "timeout" in message or "timed out" in message:
return "live-refresh-timeout"
if "no rcon targets" in message or "no live" in message or "configured" in message:
return "live-refresh-unavailable"
return "live-refresh-failed"
def _build_servers_response(
*,
items: list[dict[str, object]],
+2 -296
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
import json
from collections.abc import Iterable, Iterator, Mapping
from collections.abc import Iterable, Mapping
from contextlib import contextmanager
from datetime import datetime, timezone
from typing import Any
@@ -17,13 +17,6 @@ COMPETITIVE_WINDOW_GAP_SECONDS = 1800
COMPETITIVE_MODE_PARTIAL = "partial"
COMPETITIVE_MODE_APPROXIMATE = "approximate"
COMPETITIVE_MODE_EXACT = "exact"
RUNNING_HISTORICAL_CAPTURE_CONFLICT_MESSAGE = (
"historical materialization capture already running"
)
HISTORICAL_CAPTURE_ADVISORY_LOCK_KEY = 2710001
DROP_LEGACY_HISTORICAL_GUARD_INDEX_SQL = """
DROP INDEX IF EXISTS idx_rcon_historical_single_running_historical;
"""
RCON_SCHEMA_SQL = """
@@ -190,122 +183,11 @@ CREATE TABLE IF NOT EXISTS rcon_match_player_stats (
death_by_json TEXT NOT NULL DEFAULT '{}',
first_seen_server_time BIGINT,
last_seen_server_time BIGINT,
player_active_seconds INTEGER,
active_time_source TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(target_key, match_key, player_id)
);
CREATE TABLE IF NOT EXISTS rcon_annual_ranking_snapshots (
id BIGSERIAL PRIMARY KEY,
year INTEGER NOT NULL,
server_key TEXT NOT NULL,
metric TEXT NOT NULL,
limit_size INTEGER NOT NULL DEFAULT 20,
source_basis TEXT NOT NULL DEFAULT 'rcon-admin-log',
window_start TEXT,
window_end TEXT,
generated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
status TEXT NOT NULL DEFAULT 'ready',
source_matches_count INTEGER NOT NULL DEFAULT 0,
CHECK (limit_size > 0),
CHECK (metric IN ('kills', 'deaths', 'teamkills', 'matches_considered', 'kd_ratio', 'kills_per_match')),
UNIQUE (year, server_key, metric)
);
CREATE TABLE IF NOT EXISTS rcon_annual_ranking_snapshot_items (
id BIGSERIAL PRIMARY KEY,
snapshot_id BIGINT NOT NULL REFERENCES rcon_annual_ranking_snapshots(id) ON DELETE CASCADE,
ranking_position INTEGER NOT NULL,
player_id TEXT NOT NULL,
player_name TEXT NOT NULL,
metric_value DOUBLE PRECISION NOT NULL DEFAULT 0,
matches_considered INTEGER NOT NULL DEFAULT 0,
kills BIGINT NOT NULL DEFAULT 0,
deaths BIGINT NOT NULL DEFAULT 0,
teamkills BIGINT NOT NULL DEFAULT 0,
kd_ratio DOUBLE PRECISION NOT NULL DEFAULT 0.0,
UNIQUE(snapshot_id, ranking_position),
UNIQUE(snapshot_id, player_id)
);
CREATE TABLE IF NOT EXISTS ranking_snapshots (
id BIGSERIAL PRIMARY KEY,
timeframe TEXT NOT NULL,
server_id TEXT NOT NULL,
metric TEXT NOT NULL,
window_start TEXT NOT NULL,
window_end TEXT NOT NULL,
generated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
source TEXT NOT NULL DEFAULT 'rcon-materialized-admin-log',
snapshot_status TEXT NOT NULL DEFAULT 'ready',
item_count INTEGER NOT NULL DEFAULT 0,
limit_size INTEGER NOT NULL DEFAULT 20,
source_matches_count INTEGER NOT NULL DEFAULT 0,
freshness TEXT NOT NULL DEFAULT 'fresh',
window_kind TEXT,
window_label TEXT,
error_message TEXT,
UNIQUE(timeframe, server_id, metric, window_start, window_end)
);
CREATE TABLE IF NOT EXISTS ranking_snapshot_items (
id BIGSERIAL PRIMARY KEY,
snapshot_id BIGINT NOT NULL REFERENCES ranking_snapshots(id) ON DELETE CASCADE,
ranking_position INTEGER NOT NULL,
player_id TEXT NOT NULL,
player_name TEXT NOT NULL,
metric_value DOUBLE PRECISION NOT NULL DEFAULT 0,
matches_considered INTEGER NOT NULL DEFAULT 0,
kills INTEGER NOT NULL DEFAULT 0,
deaths INTEGER NOT NULL DEFAULT 0,
teamkills INTEGER NOT NULL DEFAULT 0,
kd_ratio DOUBLE PRECISION NOT NULL DEFAULT 0.0,
kills_per_match DOUBLE PRECISION NOT NULL DEFAULT 0.0,
UNIQUE(snapshot_id, ranking_position),
UNIQUE(snapshot_id, player_id)
);
CREATE TABLE IF NOT EXISTS player_search_index (
id BIGSERIAL PRIMARY KEY,
server_id TEXT NOT NULL,
player_id TEXT NOT NULL,
player_name TEXT NOT NULL,
normalized_player_name TEXT NOT NULL,
first_seen_at TEXT,
last_seen_at TEXT,
servers_seen TEXT NOT NULL DEFAULT '[]',
matches_current_year INTEGER NOT NULL DEFAULT 0,
kills_current_year INTEGER NOT NULL DEFAULT 0,
deaths_current_year INTEGER NOT NULL DEFAULT 0,
teamkills_current_year INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(server_id, player_id)
);
CREATE TABLE IF NOT EXISTS player_period_stats (
id BIGSERIAL PRIMARY KEY,
period_type TEXT NOT NULL,
window_kind TEXT NOT NULL,
period_start TEXT NOT NULL,
period_end TEXT NOT NULL,
server_id TEXT NOT NULL,
player_id TEXT NOT NULL,
player_name TEXT NOT NULL,
matches_considered INTEGER NOT NULL DEFAULT 0,
kills INTEGER NOT NULL DEFAULT 0,
deaths INTEGER NOT NULL DEFAULT 0,
teamkills INTEGER NOT NULL DEFAULT 0,
ranking_position INTEGER,
kd_ratio DOUBLE PRECISION NOT NULL DEFAULT 0.0,
kills_per_match DOUBLE PRECISION NOT NULL DEFAULT 0.0,
first_seen_at TEXT,
last_seen_at TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(period_type, server_id, player_id)
);
CREATE TABLE IF NOT EXISTS rcon_scoreboard_match_candidates (
id BIGSERIAL PRIMARY KEY,
server_slug TEXT NOT NULL,
@@ -335,172 +217,18 @@ CREATE INDEX IF NOT EXISTS idx_rcon_player_profile_snapshots_player
ON rcon_player_profile_snapshots(target_key, player_id, source_server_time DESC);
CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_recent
ON rcon_materialized_matches(target_key, ended_at DESC, ended_server_time DESC);
CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_source_window_text
ON rcon_materialized_matches(
source_basis,
COALESCE(CAST(ended_at AS TEXT), CAST(started_at AS TEXT))
);
CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_target_source_window_text
ON rcon_materialized_matches(
target_key,
source_basis,
COALESCE(CAST(ended_at AS TEXT), CAST(started_at AS TEXT))
);
CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_external_source_window_text
ON rcon_materialized_matches(
external_server_id,
source_basis,
COALESCE(CAST(ended_at AS TEXT), CAST(started_at AS TEXT))
);
CREATE INDEX IF NOT EXISTS idx_rcon_match_player_stats_match
ON rcon_match_player_stats(target_key, match_key);
CREATE INDEX IF NOT EXISTS idx_rcon_match_player_stats_player_id_match
ON rcon_match_player_stats(player_id, target_key, match_key);
CREATE INDEX IF NOT EXISTS idx_rcon_annual_ranking_snapshots_year
ON rcon_annual_ranking_snapshots(year, server_key, metric);
CREATE INDEX IF NOT EXISTS idx_rcon_annual_ranking_snapshots_status
ON rcon_annual_ranking_snapshots(status);
CREATE INDEX IF NOT EXISTS idx_rcon_annual_snapshot_items_snapshot
ON rcon_annual_ranking_snapshot_items(snapshot_id, ranking_position);
CREATE INDEX IF NOT EXISTS idx_rcon_annual_snapshot_items_player
ON rcon_annual_ranking_snapshot_items(snapshot_id, player_id);
CREATE INDEX IF NOT EXISTS idx_ranking_snapshots_lookup
ON ranking_snapshots(timeframe, server_id, metric, snapshot_status, window_end DESC, generated_at DESC);
CREATE INDEX IF NOT EXISTS idx_ranking_snapshot_items_snapshot
ON ranking_snapshot_items(snapshot_id, ranking_position);
CREATE INDEX IF NOT EXISTS idx_ranking_snapshot_items_player
ON ranking_snapshot_items(snapshot_id, player_id);
CREATE INDEX IF NOT EXISTS idx_player_search_index_name
ON player_search_index(server_id, normalized_player_name);
CREATE INDEX IF NOT EXISTS idx_player_search_index_last_seen
ON player_search_index(server_id, last_seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_player_search_index_player
ON player_search_index(server_id, player_id);
CREATE INDEX IF NOT EXISTS idx_player_period_stats_player_period_server
ON player_period_stats(player_id, period_type, server_id);
CREATE INDEX IF NOT EXISTS idx_player_period_stats_server_period
ON player_period_stats(server_id, period_type);
CREATE INDEX IF NOT EXISTS idx_player_period_stats_last_seen
ON player_period_stats(last_seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_player_period_stats_updated
ON player_period_stats(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_rcon_scoreboard_candidates_server_end
ON rcon_scoreboard_match_candidates(server_slug, ended_at DESC, started_at DESC);
"""
POSTGRES_ADMIN_LOG_SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS rcon_admin_log_events (
id BIGSERIAL PRIMARY KEY,
target_key TEXT NOT NULL,
external_server_id TEXT,
event_timestamp TEXT,
server_time BIGINT,
relative_time TEXT,
event_type TEXT NOT NULL,
raw_message TEXT NOT NULL,
canonical_message TEXT NOT NULL,
parsed_payload_json TEXT NOT NULL,
raw_entry_json TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE NULLS NOT DISTINCT(target_key, server_time, canonical_message)
);
CREATE TABLE IF NOT EXISTS rcon_player_profile_snapshots (
id BIGSERIAL PRIMARY KEY,
target_key TEXT NOT NULL,
external_server_id TEXT,
player_id TEXT NOT NULL,
player_name TEXT NOT NULL,
source_server_time BIGINT NOT NULL,
event_timestamp TEXT,
first_seen TEXT,
sessions INTEGER,
matches_played INTEGER,
play_time TEXT,
total_kills INTEGER,
total_deaths INTEGER,
teamkills_done INTEGER,
teamkills_received INTEGER,
kd_ratio DOUBLE PRECISION,
favorite_weapons_json TEXT NOT NULL DEFAULT '{}',
victims_json TEXT NOT NULL DEFAULT '{}',
nemesis_json TEXT NOT NULL DEFAULT '{}',
averages_json TEXT NOT NULL DEFAULT '{}',
sanctions_json TEXT NOT NULL DEFAULT '{}',
raw_content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(target_key, player_id, source_server_time)
);
CREATE INDEX IF NOT EXISTS idx_rcon_admin_log_events_target_time
ON rcon_admin_log_events(target_key, server_time DESC);
CREATE INDEX IF NOT EXISTS idx_rcon_admin_log_events_type
ON rcon_admin_log_events(event_type);
CREATE INDEX IF NOT EXISTS idx_rcon_player_profile_snapshots_player
ON rcon_player_profile_snapshots(target_key, player_id, source_server_time DESC);
"""
POSTGRES_ANNUAL_RANKING_SCHEMA_MIGRATION_SQL = """
ALTER TABLE rcon_annual_ranking_snapshot_items
ALTER COLUMN metric_value TYPE DOUBLE PRECISION USING metric_value::double precision;
DO $$
DECLARE
constraint_record record;
BEGIN
FOR constraint_record IN
SELECT con.conname
FROM pg_constraint AS con
JOIN pg_class AS rel ON rel.oid = con.conrelid
JOIN pg_namespace AS nsp ON nsp.oid = rel.relnamespace
WHERE rel.relname = 'rcon_annual_ranking_snapshots'
AND con.contype = 'c'
AND pg_get_constraintdef(con.oid) LIKE '%metric%'
LOOP
EXECUTE format(
'ALTER TABLE rcon_annual_ranking_snapshots DROP CONSTRAINT %I',
constraint_record.conname
);
END LOOP;
ALTER TABLE rcon_annual_ranking_snapshots
ADD CONSTRAINT rcon_annual_ranking_snapshots_metric_check
CHECK (metric IN (
'kills',
'deaths',
'teamkills',
'matches_considered',
'kd_ratio',
'kills_per_match'
));
END $$;
"""
POSTGRES_RCON_MATCH_PLAYER_STATS_ACTIVE_TIME_MIGRATION_SQL = """
ALTER TABLE rcon_match_player_stats
ADD COLUMN IF NOT EXISTS player_active_seconds INTEGER;
ALTER TABLE rcon_match_player_stats
ADD COLUMN IF NOT EXISTS active_time_source TEXT;
"""
def initialize_postgres_rcon_storage() -> None:
"""Create deterministic PostgreSQL schema for migrated RCON domains."""
with connect_postgres() as connection:
with connection.cursor() as cursor:
cursor.execute(DROP_LEGACY_HISTORICAL_GUARD_INDEX_SQL)
cursor.execute(RCON_SCHEMA_SQL)
cursor.execute(POSTGRES_ANNUAL_RANKING_SCHEMA_MIGRATION_SQL)
cursor.execute(POSTGRES_RCON_MATCH_PLAYER_STATS_ACTIVE_TIME_MIGRATION_SQL)
def initialize_postgres_admin_log_storage() -> None:
"""Create only the PostgreSQL AdminLog structures used by the live worker."""
with connect_postgres() as connection:
with connection.cursor() as cursor:
cursor.execute(POSTGRES_ADMIN_LOG_SCHEMA_SQL)
@contextmanager
@@ -532,35 +260,13 @@ class PostgresCompatConnection:
@contextmanager
def connect_postgres_compat(*, initialize: bool = True):
def connect_postgres_compat():
"""Yield a query shim that accepts the phase-1 SQLite-style placeholders."""
if initialize:
initialize_postgres_rcon_storage()
with connect_postgres() as connection:
yield PostgresCompatConnection(connection)
@contextmanager
def postgres_historical_capture_advisory_guard() -> Iterator[bool]:
"""Hold one PostgreSQL advisory lock for the heavy historical capture path."""
with connect_postgres() as connection:
row = connection.execute(
"SELECT pg_try_advisory_lock(%s) AS acquired",
(HISTORICAL_CAPTURE_ADVISORY_LOCK_KEY,),
).fetchone()
acquired = bool(row and row["acquired"])
if not acquired:
yield False
return
try:
yield True
finally:
connection.execute(
"SELECT pg_advisory_unlock(%s)",
(HISTORICAL_CAPTURE_ADVISORY_LOCK_KEY,),
)
def start_capture_run(*, mode: str, target_scope: str) -> int:
initialize_postgres_rcon_storage()
with connect_postgres() as connection:
+2 -12
View File
@@ -19,12 +19,7 @@ class RconLiveDataSource:
source_kind: str = "rcon"
def collect_snapshots(
self,
*,
persist: bool,
timeout_seconds: float | None = None,
) -> dict[str, object]:
def collect_snapshots(self, *, persist: bool) -> dict[str, object]:
configured_targets = load_rcon_targets()
if not configured_targets:
raise RuntimeError("No RCON targets configured in HLL_BACKEND_RCON_TARGETS.")
@@ -35,12 +30,7 @@ class RconLiveDataSource:
for target in configured_targets:
try:
normalized_records.append(
query_live_server_sample(
target,
timeout_seconds=timeout_seconds,
)["normalized"]
)
normalized_records.append(query_live_server_sample(target)["normalized"])
except Exception as error: # noqa: BLE001 - keep provider failures controlled
errors.append(
{
+17 -34
View File
@@ -11,7 +11,7 @@ from .rcon_admin_log_storage import (
list_rcon_admin_log_event_counts,
persist_rcon_admin_log_entries,
)
from .rcon_client import HllRconConnection, RconServerTarget, build_rcon_target_key, load_rcon_targets
from .rcon_client import HllRconConnection, build_rcon_target_key, load_rcon_targets
@dataclass(slots=True)
@@ -37,14 +37,24 @@ def ingest_rcon_admin_logs(
for target in selected_targets:
stats.targets_seen += 1
target_metadata = serialize_rcon_target(target)
target_metadata = _serialize_target(target)
try:
normalized_entries = fetch_recent_admin_log_entries(
target,
lookback_seconds=minutes * 60,
timeout_seconds=timeout_seconds,
with HllRconConnection(timeout_seconds=timeout_seconds) as connection:
connection.connect(host=target.host, port=target.port, password=target.password)
payload = connection.execute_json(
"GetAdminLog",
{
"LogBackTrackTime": minutes * 60,
"Filters": [],
},
)
entries = payload.get("entries")
if not isinstance(entries, list):
entries = []
normalized_entries = [entry for entry in entries if isinstance(entry, dict)]
delta = persist_rcon_admin_log_entries(
target=target_metadata,
entries=normalized_entries,
@@ -107,34 +117,7 @@ def _select_targets(target_key: str | None) -> list[object]:
return selected
def fetch_recent_admin_log_entries(
target: RconServerTarget,
*,
lookback_seconds: int,
timeout_seconds: float | None = None,
) -> list[dict[str, object]]:
"""Fetch recent raw AdminLog entries for one configured target."""
if lookback_seconds <= 0:
raise ValueError("lookback_seconds must be positive.")
resolved_timeout = (
get_rcon_request_timeout_seconds() if timeout_seconds is None else timeout_seconds
)
with HllRconConnection(timeout_seconds=resolved_timeout) as connection:
connection.connect(host=target.host, port=target.port, password=target.password)
payload = connection.execute_json(
"GetAdminLog",
{
"LogBackTrackTime": lookback_seconds,
"Filters": [],
},
)
entries = payload.get("entries")
if not isinstance(entries, list):
return []
return [entry for entry in entries if isinstance(entry, dict)]
def serialize_rcon_target(target: object) -> dict[str, object]:
def _serialize_target(target: object) -> dict[str, object]:
return {
"target_key": build_rcon_target_key(target),
"external_server_id": target.external_server_id,
+4 -259
View File
@@ -59,26 +59,6 @@ def initialize_rcon_materialized_storage(*, db_path: Path | None = None) -> Path
CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_recent
ON rcon_materialized_matches(target_key, ended_at DESC, ended_server_time DESC);
CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_source_window_text
ON rcon_materialized_matches(
source_basis,
COALESCE(CAST(ended_at AS TEXT), CAST(started_at AS TEXT))
);
CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_target_source_window_text
ON rcon_materialized_matches(
target_key,
source_basis,
COALESCE(CAST(ended_at AS TEXT), CAST(started_at AS TEXT))
);
CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_external_source_window_text
ON rcon_materialized_matches(
external_server_id,
source_basis,
COALESCE(CAST(ended_at AS TEXT), CAST(started_at AS TEXT))
);
CREATE TABLE IF NOT EXISTS rcon_match_player_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_key TEXT NOT NULL,
@@ -96,8 +76,6 @@ def initialize_rcon_materialized_storage(*, db_path: Path | None = None) -> Path
death_by_json TEXT NOT NULL DEFAULT '{}',
first_seen_server_time INTEGER,
last_seen_server_time INTEGER,
player_active_seconds INTEGER,
active_time_source TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(target_key, match_key, player_id)
@@ -105,57 +83,8 @@ def initialize_rcon_materialized_storage(*, db_path: Path | None = None) -> Path
CREATE INDEX IF NOT EXISTS idx_rcon_match_player_stats_match
ON rcon_match_player_stats(target_key, match_key);
CREATE INDEX IF NOT EXISTS idx_rcon_match_player_stats_player_id_match
ON rcon_match_player_stats(player_id, target_key, match_key);
CREATE TABLE IF NOT EXISTS rcon_annual_ranking_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
year INTEGER NOT NULL,
server_key TEXT NOT NULL,
metric TEXT NOT NULL,
limit_size INTEGER NOT NULL DEFAULT 20,
source_basis TEXT NOT NULL DEFAULT 'rcon-admin-log',
window_start TEXT,
window_end TEXT,
generated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
status TEXT NOT NULL DEFAULT 'ready',
source_matches_count INTEGER NOT NULL DEFAULT 0,
CHECK (limit_size > 0),
CHECK (metric IN ('kills', 'deaths', 'teamkills', 'matches_considered', 'kd_ratio', 'kills_per_match')),
UNIQUE (year, server_key, metric)
);
CREATE INDEX IF NOT EXISTS idx_rcon_annual_ranking_snapshots_year
ON rcon_annual_ranking_snapshots(year, server_key, metric);
CREATE INDEX IF NOT EXISTS idx_rcon_annual_ranking_snapshots_status
ON rcon_annual_ranking_snapshots(status);
CREATE TABLE IF NOT EXISTS rcon_annual_ranking_snapshot_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_id INTEGER NOT NULL REFERENCES rcon_annual_ranking_snapshots(id) ON DELETE CASCADE,
ranking_position INTEGER NOT NULL,
player_id TEXT NOT NULL,
player_name TEXT NOT NULL,
metric_value REAL NOT NULL DEFAULT 0,
matches_considered INTEGER NOT NULL DEFAULT 0,
kills INTEGER NOT NULL DEFAULT 0,
deaths INTEGER NOT NULL DEFAULT 0,
teamkills INTEGER NOT NULL DEFAULT 0,
kd_ratio REAL NOT NULL DEFAULT 0.0,
UNIQUE(snapshot_id, ranking_position),
UNIQUE(snapshot_id, player_id)
);
CREATE INDEX IF NOT EXISTS idx_rcon_annual_snapshot_items_snapshot
ON rcon_annual_ranking_snapshot_items(snapshot_id, ranking_position);
CREATE INDEX IF NOT EXISTS idx_rcon_annual_snapshot_items_player
ON rcon_annual_ranking_snapshot_items(snapshot_id, player_id);
"""
)
_ensure_materialized_player_stat_columns(connection)
return resolved_path
@@ -313,21 +242,15 @@ def get_materialized_rcon_match_detail(
server_key: str,
match_key: str,
db_path: Path | None = None,
ensure_storage: bool = False,
) -> dict[str, object] | None:
"""Return one materialized match with player stats."""
resolved_path = (
initialize_rcon_materialized_storage(db_path=db_path)
if ensure_storage
else (db_path or get_storage_path())
)
resolved_path = initialize_rcon_materialized_storage(db_path=db_path)
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import connect_postgres_compat
connection_scope = connect_postgres_compat(initialize=ensure_storage)
connection_scope = connect_postgres_compat()
else:
connection_scope = closing(connect_sqlite_readonly(resolved_path))
try:
with connection_scope as connection:
match = connection.execute(
"""
@@ -379,8 +302,6 @@ def get_materialized_rcon_match_detail(
match["ended_server_time"],
),
).fetchall()
except Exception:
return None
return {
"match": dict(match),
@@ -755,12 +676,6 @@ def _derive_player_stats_for_match(
stats = []
for player in players.values():
active_time = _build_player_active_time_payload(
connection,
match=match,
player=player,
match_rows=rows,
)
stats.append(
{
"target_key": match["target_key"],
@@ -778,8 +693,6 @@ def _derive_player_stats_for_match(
"death_by_json": _dump_counter(player["death_by"]),
"first_seen_server_time": player.get("first_seen_server_time"),
"last_seen_server_time": player.get("last_seen_server_time"),
"player_active_seconds": active_time["player_active_seconds"],
"active_time_source": active_time["active_time_source"],
}
)
return stats
@@ -792,9 +705,8 @@ def _insert_player_stat(connection: sqlite3.Connection, stat: dict[str, object])
target_key, match_key, player_id, player_name, team,
kills, deaths, teamkills, deaths_by_teamkill,
weapons_json, death_by_weapons_json, most_killed_json, death_by_json,
first_seen_server_time, last_seen_server_time,
player_active_seconds, active_time_source
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
first_seen_server_time, last_seen_server_time
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
stat["target_key"],
@@ -812,8 +724,6 @@ def _insert_player_stat(connection: sqlite3.Connection, stat: dict[str, object])
stat["death_by_json"],
stat.get("first_seen_server_time"),
stat.get("last_seen_server_time"),
stat.get("player_active_seconds"),
stat.get("active_time_source"),
),
)
@@ -860,171 +770,6 @@ def _touch_player(player: dict[str, object], server_time: int | None) -> None:
player["last_seen_server_time"] = server_time if last_seen is None else max(last_seen, server_time)
def _calculate_event_span_seconds(
*,
first_seen_server_time: object,
last_seen_server_time: object,
) -> int | None:
first_seen = _coerce_int(first_seen_server_time)
last_seen = _coerce_int(last_seen_server_time)
if first_seen is None or last_seen is None:
return None
return max(0, last_seen - first_seen)
def _build_player_active_time_payload(
connection: sqlite3.Connection,
*,
match: dict[str, object],
player: dict[str, object],
match_rows: list[sqlite3.Row],
) -> dict[str, object]:
lower = _coerce_int(match.get("started_server_time"))
upper = _coerce_int(match.get("ended_server_time"))
fallback_seconds = _calculate_event_span_seconds(
first_seen_server_time=player.get("first_seen_server_time"),
last_seen_server_time=player.get("last_seen_server_time"),
)
player_id = str(player.get("player_id") or "").strip()
if lower is None or upper is None or upper < lower:
return {
"player_active_seconds": fallback_seconds,
"active_time_source": "event_span_fallback" if fallback_seconds is not None else "unavailable",
}
if not player_id or player_id.startswith("name:"):
return {
"player_active_seconds": fallback_seconds,
"active_time_source": "event_span_fallback" if fallback_seconds is not None else "unavailable",
}
interval_events = _collect_player_connection_events_from_match_rows(
match_rows,
player_id=player_id,
)
prior_connected = _player_was_connected_at_match_start(
connection,
target_key=str(match["target_key"]),
player_id=player_id,
match_start_server_time=lower,
)
interval_seconds, interval_source = _calculate_connection_interval_active_seconds(
match_start_server_time=lower,
match_end_server_time=upper,
prior_connected=prior_connected,
interval_events=interval_events,
)
if interval_source is not None:
return {
"player_active_seconds": interval_seconds,
"active_time_source": interval_source,
}
return {
"player_active_seconds": fallback_seconds,
"active_time_source": "event_span_fallback" if fallback_seconds is not None else "unavailable",
}
def _collect_player_connection_events_from_match_rows(
rows: list[sqlite3.Row],
*,
player_id: str,
) -> list[tuple[str, int]]:
events: list[tuple[str, int]] = []
for row in rows:
event_type = str(row["event_type"] or "")
if event_type not in {"connected", "disconnected"}:
continue
payload = _json_object(row["parsed_payload_json"])
event_player_id = str(payload.get("player_id") or "").strip()
server_time = _coerce_int(row["server_time"])
if event_player_id == player_id and server_time is not None:
events.append((event_type, server_time))
events.sort(key=lambda item: item[1])
return events
def _player_was_connected_at_match_start(
connection: sqlite3.Connection,
*,
target_key: str,
player_id: str,
match_start_server_time: int,
) -> bool:
row = connection.execute(
"""
SELECT event_type
FROM rcon_admin_log_events
WHERE target_key = ?
AND server_time IS NOT NULL
AND server_time < ?
AND event_type IN ('connected', 'disconnected')
AND parsed_payload_json LIKE ?
ORDER BY server_time DESC, id DESC
LIMIT 1
""",
(
target_key,
match_start_server_time,
f'%"player_id":"{player_id}"%',
),
).fetchone()
return bool(row and row["event_type"] == "connected")
def _calculate_connection_interval_active_seconds(
*,
match_start_server_time: int,
match_end_server_time: int,
prior_connected: bool,
interval_events: list[tuple[str, int]],
) -> tuple[int | None, str | None]:
open_since = match_start_server_time if prior_connected else None
total_seconds = 0
used_carryover = prior_connected
has_reliable_intervals = prior_connected
for event_type, server_time in interval_events:
clamped_time = max(match_start_server_time, min(match_end_server_time, server_time))
if event_type == "connected":
if open_since is None:
open_since = clamped_time
has_reliable_intervals = True
continue
if open_since is None:
continue
total_seconds += max(0, clamped_time - open_since)
open_since = None
has_reliable_intervals = True
if open_since is not None:
total_seconds += max(0, match_end_server_time - open_since)
if not has_reliable_intervals:
return None, None
return (
total_seconds,
"connection_intervals_carryover" if used_carryover else "connection_intervals",
)
def _ensure_materialized_player_stat_columns(connection: sqlite3.Connection) -> None:
columns = {
row["name"]
for row in connection.execute("PRAGMA table_info(rcon_match_player_stats)").fetchall()
}
if "player_active_seconds" not in columns:
connection.execute(
"ALTER TABLE rcon_match_player_stats ADD COLUMN player_active_seconds INTEGER"
)
if "active_time_source" not in columns:
connection.execute(
"ALTER TABLE rcon_match_player_stats ADD COLUMN active_time_source TEXT"
)
def _counter(player: dict[str, object], key: str) -> Counter[str]:
value = player[key]
if isinstance(value, Counter):
+24 -239
View File
@@ -31,14 +31,14 @@ CURRENT_MATCH_PLAYER_EVENT_TYPES = (
def initialize_rcon_admin_log_storage(*, db_path: Path | None = None) -> Path:
"""Create SQLite structures for parsed RCON AdminLog events."""
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import initialize_postgres_admin_log_storage
from .postgres_rcon_storage import initialize_postgres_rcon_storage
initialize_postgres_admin_log_storage()
initialize_postgres_rcon_storage()
return get_storage_path()
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
with closing(connect_sqlite_writer(resolved_path)) as connection:
with connect_sqlite_writer(resolved_path) as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS rcon_admin_log_events (
@@ -98,7 +98,6 @@ def initialize_rcon_admin_log_storage(*, db_path: Path | None = None) -> Path:
"""
)
_ensure_canonical_message_column(connection)
connection.commit()
return resolved_path
@@ -108,21 +107,12 @@ def persist_rcon_admin_log_entries(
target: Mapping[str, object],
entries: list[dict[str, object]],
db_path: Path | None = None,
ensure_storage: bool = True,
) -> dict[str, int]:
"""Persist raw and parsed AdminLog entries idempotently."""
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
return _persist_rcon_admin_log_entries_postgres(
target=target,
entries=entries,
ensure_storage=ensure_storage,
)
return _persist_rcon_admin_log_entries_postgres(target=target, entries=entries)
resolved_path = (
initialize_rcon_admin_log_storage(db_path=db_path)
if ensure_storage
else (db_path or get_storage_path())
)
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
target_key = str(target.get("target_key") or target.get("external_server_id") or "")
if not target_key:
raise ValueError("target must include target_key or external_server_id")
@@ -131,7 +121,7 @@ def persist_rcon_admin_log_entries(
inserted = 0
duplicates = 0
with closing(connect_sqlite_writer(resolved_path)) as connection:
with connect_sqlite_writer(resolved_path) as connection:
for entry in entries:
parsed = parse_rcon_admin_log_entry(entry)
raw_message = str(parsed.get("raw_message") or "")
@@ -185,7 +175,6 @@ def persist_rcon_admin_log_entries(
external_server_id=external_server_id,
parsed=parsed,
)
connection.commit()
return {
"events_seen": len(entries),
@@ -333,7 +322,7 @@ def list_rcon_admin_log_event_counts(*, db_path: Path | None = None) -> list[dic
resolved_path = db_path or get_storage_path()
initialize_rcon_admin_log_storage(db_path=resolved_path)
with closing(sqlite3.connect(resolved_path)) as connection:
with sqlite3.connect(resolved_path) as connection:
connection.row_factory = sqlite3.Row
rows = connection.execute(
"""
@@ -359,21 +348,16 @@ def list_current_match_kill_feed(
since_event_id: str | None = None,
db_path: Path | None = None,
now: datetime | None = None,
ensure_storage: bool = True,
) -> dict[str, object]:
"""Return safe recent kill rows for one AdminLog server window."""
resolved_path = (
initialize_rcon_admin_log_storage(db_path=db_path)
if ensure_storage
else db_path or get_storage_path()
)
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
since_row_id = _parse_current_match_event_row_id(since_event_id)
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import connect_postgres_compat
connection_scope = connect_postgres_compat(initialize=ensure_storage)
connection_scope = connect_postgres_compat()
else:
connection_scope = closing(_connect_admin_log_sqlite_read(resolved_path))
connection_scope = closing(sqlite3.connect(resolved_path))
with connection_scope as connection:
if isinstance(connection, sqlite3.Connection):
@@ -485,20 +469,15 @@ def list_current_match_player_stats(
server_key: str,
db_path: Path | None = None,
now: datetime | None = None,
ensure_storage: bool = True,
) -> dict[str, object]:
"""Return current-match participants and partial stats from the safe AdminLog window."""
resolved_path = (
initialize_rcon_admin_log_storage(db_path=db_path)
if ensure_storage
else db_path or get_storage_path()
)
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import connect_postgres_compat
connection_scope = connect_postgres_compat(initialize=ensure_storage)
connection_scope = connect_postgres_compat()
else:
connection_scope = closing(_connect_admin_log_sqlite_read(resolved_path))
connection_scope = closing(sqlite3.connect(resolved_path))
with connection_scope as connection:
if isinstance(connection, sqlite3.Connection):
@@ -520,7 +499,6 @@ def list_current_match_player_stats(
event_timestamp = row["event_timestamp"]
event_type = str(row["event_type"] or "")
if event_type == "kill":
event_key = _current_match_stat_event_key(row, payload)
killer = _ensure_current_match_player(
players,
player_name=payload.get("killer_name"),
@@ -541,15 +519,15 @@ def list_current_match_player_stats(
)
if killer is not None:
weapon = _safe_event_field(payload.get("weapon")) or "UNKNOWN"
_add_current_match_player_weapon(killer, weapon, event_key)
_player_weapon_counts(killer)[weapon] += 1
if payload.get("killer_team") and payload.get("killer_team") == payload.get("victim_team"):
_add_current_match_player_stat(killer, "teamkills", event_key)
killer["teamkills"] = int(killer["teamkills"]) + 1
else:
_add_current_match_player_stat(killer, "kills", event_key)
killer["kills"] = int(killer["kills"]) + 1
if victim is not None:
_add_current_match_player_stat(victim, "deaths", event_key)
victim["deaths"] = int(victim["deaths"]) + 1
if payload.get("killer_team") and payload.get("killer_team") == payload.get("victim_team"):
_add_current_match_player_stat(victim, "deaths_by_teamkill", event_key)
victim["deaths_by_teamkill"] = int(victim["deaths_by_teamkill"]) + 1
continue
if event_type == "team_switch":
@@ -614,14 +592,6 @@ def list_current_match_player_stats(
}
def _connect_admin_log_sqlite_read(db_path: Path) -> sqlite3.Connection:
"""Open AdminLog storage for read without creating files during public GETs."""
if not db_path.exists():
raise FileNotFoundError(f"AdminLog storage is not available: {db_path}")
uri = f"file:{db_path.as_posix()}?mode=ro"
return sqlite3.connect(uri, uri=True)
def _resolve_current_match_window(
connection: sqlite3.Connection | object,
*,
@@ -751,7 +721,7 @@ def get_latest_rcon_player_profile_summaries(
resolved_path = db_path or get_storage_path()
initialize_rcon_admin_log_storage(db_path=resolved_path)
placeholders = ",".join("?" for _ in requested_ids)
with closing(sqlite3.connect(resolved_path)) as connection:
with sqlite3.connect(resolved_path) as connection:
connection.row_factory = sqlite3.Row
rows = connection.execute(
f"""
@@ -858,7 +828,7 @@ def _ensure_current_match_player(
) -> dict[str, object] | None:
safe_name = _safe_event_field(player_name)
safe_id = _safe_event_field(player_id)
key = _resolve_current_match_player_key(players, player_id=safe_id, player_name=safe_name)
key = _current_match_player_key(safe_id, safe_name)
if key is None:
return None
player = players.setdefault(
@@ -874,8 +844,6 @@ def _ensure_current_match_player(
"is_connected": None,
"last_seen_at": None,
"_weapon_counts": Counter(),
"_weapon_event_keys": {},
"_stat_event_keys": {},
"_sources": set(),
},
)
@@ -886,10 +854,7 @@ def _ensure_current_match_player(
if current_name is None or len(safe_name) >= len(current_name):
player["player_name"] = safe_name
safe_team = _safe_event_field(team)
if safe_team and (
_is_known_current_match_team(safe_team)
or not _is_known_current_match_team(player.get("team"))
):
if safe_team:
player["team"] = safe_team
if is_connected is not None:
player["is_connected"] = is_connected
@@ -902,196 +867,17 @@ def _ensure_current_match_player(
return player
def _resolve_current_match_player_key(
players: dict[str, dict[str, object]],
*,
player_id: str | None,
player_name: str | None,
) -> str | None:
id_key = f"id:{player_id}" if player_id else None
name_key = _current_match_player_name_key(player_name)
if id_key is not None:
if name_key is not None and name_key in players:
named_player = players.pop(name_key)
if id_key in players:
_merge_current_match_player(players[id_key], named_player)
else:
players[id_key] = named_player
return id_key
if name_key is None:
return None
normalized_name = _normalize_current_match_player_name(player_name)
matching_id_keys = [
key
for key, player in players.items()
if key.startswith("id:")
and _normalize_current_match_player_name(player.get("player_name")) == normalized_name
]
if len(matching_id_keys) == 1:
return matching_id_keys[0]
return name_key
def _current_match_player_key(
player_id: str | None,
player_name: str | None,
) -> str | None:
if player_id:
return f"id:{player_id}"
return _current_match_player_name_key(player_name)
def _current_match_player_name_key(player_name: object) -> str | None:
normalized = _normalize_current_match_player_name(player_name)
return f"name:{normalized}" if normalized else None
def _normalize_current_match_player_name(player_name: object) -> str:
return " ".join(str(player_name or "").strip().casefold().split())
def _merge_current_match_player(
destination: dict[str, object],
source: Mapping[str, object],
) -> None:
if not destination.get("player_id") and source.get("player_id"):
destination["player_id"] = source.get("player_id")
source_name = _safe_event_field(source.get("player_name"))
if source_name:
destination_name = _safe_event_field(destination.get("player_name"))
if destination_name is None or len(source_name) >= len(destination_name):
destination["player_name"] = source_name
source_team = source.get("team")
if _is_known_current_match_team(source_team) or not _is_known_current_match_team(destination.get("team")):
if _safe_event_field(source_team):
destination["team"] = source_team
_merge_current_match_stat_events(destination, source)
_merge_current_match_weapon_events(destination, source)
source_last_seen = _safe_event_field(source.get("last_seen_at"))
destination_last_seen = _safe_event_field(destination.get("last_seen_at"))
if source_last_seen and (destination_last_seen is None or source_last_seen > destination_last_seen):
destination["last_seen_at"] = source_last_seen
if source.get("is_connected") is not None:
destination["is_connected"] = source.get("is_connected")
elif destination.get("is_connected") is None and source.get("is_connected") is not None:
destination["is_connected"] = source.get("is_connected")
destination_sources = destination.setdefault("_sources", set())
source_sources = source.get("_sources", set())
if isinstance(destination_sources, set) and isinstance(source_sources, set):
destination_sources.update(source_sources)
def _merge_current_match_stat_events(
destination: dict[str, object],
source: Mapping[str, object],
) -> None:
destination_keys = _stat_event_keys(destination)
source_keys = _stat_event_keys(source)
for stat_name in ("kills", "deaths", "teamkills", "deaths_by_teamkill"):
source_count = int(source.get(stat_name) or 0)
if source_count == 0:
continue
source_stat_keys = source_keys.get(stat_name, set())
destination_stat_keys = destination_keys.setdefault(stat_name, set())
overlap = len(destination_stat_keys & source_stat_keys)
destination[stat_name] = int(destination.get(stat_name) or 0) + max(0, source_count - overlap)
destination_stat_keys.update(source_stat_keys)
def _merge_current_match_weapon_events(
destination: dict[str, object],
source: Mapping[str, object],
) -> None:
destination_counts = _player_weapon_counts(destination)
source_counts = _player_weapon_counts(source)
destination_keys = _weapon_event_keys(destination)
source_keys = _weapon_event_keys(source)
for weapon, count in source_counts.items():
source_weapon_keys = source_keys.get(weapon, set())
destination_weapon_keys = destination_keys.setdefault(weapon, set())
overlap = len(destination_weapon_keys & source_weapon_keys)
destination_counts[weapon] += max(0, int(count) - overlap)
destination_weapon_keys.update(source_weapon_keys)
def _is_known_current_match_team(value: object) -> bool:
normalized = str(value or "").strip().casefold()
return normalized in {"allies", "allied", "axis"}
def _current_match_stat_event_key(row: Mapping[str, object], payload: Mapping[str, object]) -> str:
parts = [
"kill",
_row_value(row, "server_time"),
_row_value(row, "event_timestamp"),
payload.get("killer_name"),
payload.get("killer_team"),
payload.get("victim_name"),
payload.get("victim_team"),
payload.get("weapon"),
]
normalized_parts = [_normalize_current_match_event_value(part) for part in parts]
semantic_key = "|".join(normalized_parts)
return semantic_key if any(normalized_parts[1:]) else f"row:{_row_value(row, 'id')}"
def _row_value(row: Mapping[str, object], key: str) -> object:
if isinstance(row, Mapping):
return row.get(key)
try:
return row[key] # type: ignore[index]
except (IndexError, KeyError, TypeError):
if player_name:
return f"name:{player_name.casefold()}"
return None
def _normalize_current_match_event_value(value: object) -> str:
return " ".join(str(value or "").strip().casefold().split())
def _add_current_match_player_stat(
player: dict[str, object],
stat_name: str,
event_key: str,
) -> None:
event_keys = _stat_event_keys(player).setdefault(stat_name, set())
if event_key in event_keys:
return
player[stat_name] = int(player.get(stat_name) or 0) + 1
event_keys.add(event_key)
def _add_current_match_player_weapon(
player: dict[str, object],
weapon: str,
event_key: str,
) -> None:
weapon_keys = _weapon_event_keys(player).setdefault(weapon, set())
if event_key in weapon_keys:
return
_player_weapon_counts(player)[weapon] += 1
weapon_keys.add(event_key)
def _stat_event_keys(player: Mapping[str, object]) -> dict[str, set[str]]:
event_keys = player.get("_stat_event_keys")
if isinstance(event_keys, dict):
return event_keys
return {}
def _weapon_event_keys(player: Mapping[str, object]) -> dict[str, set[str]]:
event_keys = player.get("_weapon_event_keys")
if isinstance(event_keys, dict):
return event_keys
return {}
def _player_weapon_counts(player: Mapping[str, object]) -> Counter[str]:
weapon_counts = player.get("_weapon_counts")
if isinstance(weapon_counts, Counter):
@@ -1174,7 +960,6 @@ def _persist_rcon_admin_log_entries_postgres(
*,
target: Mapping[str, object],
entries: list[dict[str, object]],
ensure_storage: bool,
) -> dict[str, int]:
from .postgres_rcon_storage import connect_postgres_compat
@@ -1185,7 +970,7 @@ def _persist_rcon_admin_log_entries_postgres(
external_server_id = target.get("external_server_id")
inserted = 0
duplicates = 0
with connect_postgres_compat(initialize=ensure_storage) as connection:
with connect_postgres_compat() as connection:
for entry in entries:
parsed = parse_rcon_admin_log_entry(entry)
raw_message = str(parsed.get("raw_message") or "")
-699
View File
@@ -1,699 +0,0 @@
"""Annual ranking snapshot generator and reader over materialized RCON match stats."""
from __future__ import annotations
import argparse
import json
import sqlite3
from contextlib import closing
from contextlib import contextmanager
from contextlib import nullcontext
from datetime import date, datetime, timezone
from pathlib import Path
from .config import get_storage_path, use_postgres_rcon_storage
from .historical_storage import ALL_SERVERS_SLUG
from .rcon_admin_log_materialization import MATCH_RESULT_SOURCE, initialize_rcon_materialized_storage
from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer
SUPPORTED_ANNUAL_RANKING_METRICS = (
"kills",
"deaths",
"teamkills",
"matches_considered",
"kd_ratio",
"kills_per_match",
)
def generate_annual_ranking_snapshot(
*,
year: int,
server_key: str | None = None,
metric: str = "kills",
limit: int = 20,
replace_existing: bool = True,
db_path: Path | None = None,
) -> dict[str, object]:
"""Generate and persist an annual top-k ranking snapshot for materialized RCON data."""
normalized_year = _normalize_year(year)
normalized_server_key = _normalize_server_key(server_key)
normalized_metric = _normalize_metric(metric)
normalized_limit = _normalize_limit(limit)
window_start, window_end = _annual_window(normalized_year)
resolved_path = initialize_rcon_materialized_storage(db_path=db_path)
scope_sql, scope_params = _build_scope_sql(normalized_server_key)
postgres_enabled = use_postgres_rcon_storage(explicit_sqlite_path=db_path)
if postgres_enabled:
from .postgres_rcon_storage import connect_postgres_compat
connection_scope = connect_postgres_compat()
else:
connection_scope = closing(connect_sqlite_writer(resolved_path))
with connection_scope as connection:
transaction_scope = nullcontext() if postgres_enabled else connection
with transaction_scope:
source_matches_count = _count_matches_in_window(
connection=connection,
start=window_start,
end=window_end,
scope_sql=scope_sql,
scope_params=scope_params,
)
existing_snapshot_id = _find_existing_snapshot(
connection=connection,
year=normalized_year,
server_key=normalized_server_key,
metric=normalized_metric,
)
if existing_snapshot_id is not None and not replace_existing:
snapshot = _get_snapshot(connection=connection, snapshot_id=existing_snapshot_id)
items = _list_items(connection=connection, snapshot_id=existing_snapshot_id)
return {
"status": "ok",
"snapshot": snapshot,
"items": items,
"source_matches_count": source_matches_count,
"ranked_players": len(items),
"skipped_regeneration": True,
}
ranking_rows = _fetch_annual_ranking_rows(
connection=connection,
start=window_start,
end=window_end,
metric=normalized_metric,
limit=normalized_limit,
scope_sql=scope_sql,
scope_params=scope_params,
)
_delete_existing_snapshot(
connection=connection,
year=normalized_year,
server_key=normalized_server_key,
metric=normalized_metric,
)
snapshot_id = _insert_snapshot(
connection=connection,
year=normalized_year,
server_key=normalized_server_key,
metric=normalized_metric,
limit=normalized_limit,
source_matches_count=source_matches_count,
window_start=window_start,
window_end=window_end,
)
_insert_items(
connection=connection,
snapshot_id=snapshot_id,
rows=ranking_rows,
limit=normalized_limit,
)
snapshot = _get_snapshot(connection=connection, snapshot_id=snapshot_id)
items = _list_items(connection=connection, snapshot_id=snapshot_id)
return {
"status": "ok",
"snapshot": snapshot,
"items": items,
"source_matches_count": source_matches_count,
"ranked_players": len(items),
}
def get_annual_ranking_snapshot(
*,
year: int,
server_key: str | None = None,
metric: str = "kills",
limit: int = 20,
db_path: Path | None = None,
) -> dict[str, object]:
"""Load one annual ranking snapshot without recalculating the ranking."""
normalized_year = _normalize_year(year)
normalized_server_key = _normalize_server_key(server_key)
normalized_metric = _normalize_metric(metric)
normalized_limit = _normalize_limit(limit)
try:
with _open_annual_snapshot_read_connection(db_path=db_path) as connection:
snapshot = _find_snapshot(
connection=connection,
year=normalized_year,
server_key=normalized_server_key,
metric=normalized_metric,
)
if snapshot is None:
return _build_missing_snapshot_result(
year=normalized_year,
server_key=normalized_server_key,
metric=normalized_metric,
limit=normalized_limit,
)
snapshot_limit = _normalize_limit(snapshot.get("limit_size") or normalized_limit)
item_count = _count_items(
connection=connection,
snapshot_id=int(snapshot["id"]),
)
effective_limit = _resolve_effective_limit(
requested_limit=normalized_limit,
snapshot_limit=snapshot_limit,
item_count=item_count,
)
items = _list_items(
connection=connection,
snapshot_id=int(snapshot["id"]),
limit=effective_limit if effective_limit > 0 else None,
)
except (FileNotFoundError, sqlite3.OperationalError):
return _build_missing_snapshot_result(
year=normalized_year,
server_key=normalized_server_key,
metric=normalized_metric,
limit=normalized_limit,
)
return {
"snapshot_status": "ready",
"year": normalized_year,
"server_id": normalized_server_key,
"metric": normalized_metric,
"limit": effective_limit,
"requested_limit": normalized_limit,
"effective_limit": effective_limit,
"snapshot_limit": snapshot_limit,
"item_count": item_count,
"source": "rcon-annual-ranking-snapshot",
"generated_at": snapshot.get("generated_at"),
"window_start": snapshot.get("window_start"),
"window_end": snapshot.get("window_end"),
"source_matches_count": int(snapshot.get("source_matches_count") or 0),
"items": items,
}
@contextmanager
def _open_annual_snapshot_read_connection(*, db_path: Path | None = None):
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import PostgresCompatConnection, connect_postgres
with connect_postgres() as connection:
yield PostgresCompatConnection(connection)
return
resolved_path = _resolve_annual_snapshot_sqlite_path(db_path=db_path)
if not resolved_path.exists():
raise FileNotFoundError(resolved_path)
with closing(connect_sqlite_readonly(resolved_path)) as connection:
yield connection
def _resolve_annual_snapshot_sqlite_path(*, db_path: Path | None = None) -> Path:
return db_path if db_path is not None else get_storage_path()
def _build_missing_snapshot_result(
*,
year: int,
server_key: str,
metric: str,
limit: int,
) -> dict[str, object]:
return {
"snapshot_status": "missing",
"year": year,
"server_id": server_key,
"metric": metric,
"limit": limit,
"requested_limit": limit,
"effective_limit": 0,
"snapshot_limit": None,
"item_count": 0,
"source": "rcon-annual-ranking-snapshot",
"generated_at": None,
"window_start": None,
"window_end": None,
"source_matches_count": 0,
"items": [],
}
def _normalize_server_key(server_key: str | None) -> str:
normalized = str(server_key or "").strip()
normalized_lower = normalized.lower()
if not normalized or normalized_lower in {ALL_SERVERS_SLUG, "all"}:
return ALL_SERVERS_SLUG
return normalized
def _normalize_metric(metric: str) -> str:
normalized = str(metric or "kills").strip().lower()
if normalized not in SUPPORTED_ANNUAL_RANKING_METRICS:
raise ValueError(
f"Metric '{normalized}' is not supported for annual ranking snapshots."
)
return normalized
def _normalize_year(year: int) -> int:
normalized_year = int(year)
if normalized_year < 1 or normalized_year > 9999:
raise ValueError("year must be between 1 and 9999")
return normalized_year
def _normalize_limit(limit: object, *, maximum: int = 100) -> int:
normalized_limit = int(limit or 1)
if normalized_limit < 1:
raise ValueError("limit must be greater than zero")
return min(normalized_limit, maximum)
def _resolve_effective_limit(
*,
requested_limit: int,
snapshot_limit: int,
item_count: int,
) -> int:
return max(0, min(requested_limit, snapshot_limit, item_count))
def _annual_window(year: int) -> tuple[str, str]:
start = datetime(year, 1, 1, 0, 0, 0, tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
return start, end
def _fetch_annual_ranking_rows(
*,
connection: object,
start: str,
end: str,
metric: str,
limit: int,
scope_sql: str,
scope_params: list[object],
) -> list[dict[str, object]]:
metric_sql, having_sql = _resolve_metric_sql(metric)
rows = connection.execute(
f"""
SELECT
stats.player_id,
COALESCE(MAX(stats.player_name), stats.player_id) AS player_name,
{metric_sql} AS metric_value,
SUM(COALESCE(stats.kills, 0)) AS kills,
SUM(COALESCE(stats.deaths, 0)) AS deaths,
SUM(COALESCE(stats.teamkills, 0)) AS teamkills,
COUNT(DISTINCT stats.match_key) AS matches_considered
FROM rcon_match_player_stats AS stats
INNER JOIN rcon_materialized_matches AS matches
ON matches.target_key = stats.target_key
AND matches.match_key = stats.match_key
WHERE matches.source_basis = ?
AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) >= ?
AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) < ?
{scope_sql}
AND TRIM(COALESCE(stats.player_name, '')) != ''
GROUP BY stats.player_id
{having_sql}
ORDER BY metric_value DESC, matches_considered DESC, kills DESC, player_name ASC
LIMIT ?
""",
[MATCH_RESULT_SOURCE, start, end, *scope_params, limit],
).fetchall()
return [dict(row) for row in rows]
def _resolve_metric_sql(metric: str) -> tuple[str, str]:
metric_sql_by_metric = {
"kills": "SUM(COALESCE(stats.kills, 0))",
"deaths": "SUM(COALESCE(stats.deaths, 0))",
"teamkills": "SUM(COALESCE(stats.teamkills, 0))",
"matches_considered": "COUNT(DISTINCT stats.match_key)",
"kd_ratio": (
"CASE "
"WHEN SUM(COALESCE(stats.deaths, 0)) > 0 "
"THEN ROUND(CAST(SUM(COALESCE(stats.kills, 0)) AS NUMERIC) / "
"CAST(SUM(COALESCE(stats.deaths, 0)) AS NUMERIC), 2) "
"ELSE CAST(SUM(COALESCE(stats.kills, 0)) AS NUMERIC) "
"END"
),
"kills_per_match": (
"CASE "
"WHEN COUNT(DISTINCT stats.match_key) > 0 "
"THEN ROUND(CAST(SUM(COALESCE(stats.kills, 0)) AS NUMERIC) / "
"CAST(COUNT(DISTINCT stats.match_key) AS NUMERIC), 2) "
"ELSE CAST(0 AS NUMERIC) "
"END"
),
}
having_sql_by_metric = {
"kills": "HAVING SUM(COALESCE(stats.kills, 0)) > 0",
"deaths": "HAVING SUM(COALESCE(stats.deaths, 0)) > 0",
"teamkills": "HAVING SUM(COALESCE(stats.teamkills, 0)) > 0",
"matches_considered": "HAVING COUNT(DISTINCT stats.match_key) > 0",
"kd_ratio": "HAVING SUM(COALESCE(stats.kills, 0)) > 0",
"kills_per_match": (
"HAVING COUNT(DISTINCT stats.match_key) > 0 "
"AND SUM(COALESCE(stats.kills, 0)) > 0"
),
}
return metric_sql_by_metric[metric], having_sql_by_metric[metric]
def _count_matches_in_window(
*,
connection: object,
start: str,
end: str,
scope_sql: str,
scope_params: list[object],
) -> int:
row = connection.execute(
f"""
SELECT COUNT(*) AS source_matches_count
FROM (
SELECT matches.target_key, matches.match_key
FROM rcon_materialized_matches AS matches
WHERE matches.source_basis = ?
AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) >= ?
AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) < ?
{scope_sql}
GROUP BY matches.target_key, matches.match_key
) AS source_matches
""",
[MATCH_RESULT_SOURCE, start, end, *scope_params],
).fetchone()
return int(row["source_matches_count"] or 0) if row else 0
def _find_snapshot(
*,
connection: object,
year: int,
server_key: str,
metric: str,
) -> dict[str, object] | None:
row = connection.execute(
"""
SELECT
id,
year,
server_key,
metric,
limit_size,
source_basis,
window_start,
window_end,
status,
source_matches_count,
generated_at
FROM rcon_annual_ranking_snapshots
WHERE year = ? AND server_key = ? AND metric = ?
LIMIT 1
""",
[year, server_key, metric],
).fetchone()
return dict(row) if row else None
def _find_existing_snapshot(
*,
connection: object,
year: int,
server_key: str,
metric: str,
) -> int | None:
row = connection.execute(
"""
SELECT id
FROM rcon_annual_ranking_snapshots
WHERE year = ? AND server_key = ? AND metric = ?
LIMIT 1
""",
[year, server_key, metric],
).fetchone()
return int(row["id"]) if row else None
def _delete_existing_snapshot(
*,
connection: object,
year: int,
server_key: str,
metric: str,
) -> int | None:
existing_id = _find_existing_snapshot(
connection=connection,
year=year,
server_key=server_key,
metric=metric,
)
if existing_id is None:
return None
connection.execute(
"DELETE FROM rcon_annual_ranking_snapshot_items WHERE snapshot_id = ?",
(existing_id,),
)
connection.execute(
"DELETE FROM rcon_annual_ranking_snapshots WHERE id = ?",
(existing_id,),
)
return existing_id
def _insert_snapshot(
*,
connection: object,
year: int,
server_key: str,
metric: str,
limit: int,
source_matches_count: int,
window_start: str,
window_end: str,
) -> int:
cursor = connection.execute(
"""
INSERT INTO rcon_annual_ranking_snapshots (
year,
server_key,
metric,
limit_size,
source_basis,
window_start,
window_end,
source_matches_count
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id
""",
[
year,
server_key,
metric,
limit,
MATCH_RESULT_SOURCE,
window_start,
window_end,
source_matches_count,
],
)
try:
row = cursor.fetchone()
finally:
cursor.close()
if row is not None and row["id"] is not None:
return int(row["id"])
existing = _find_existing_snapshot(
connection=connection,
year=year,
server_key=server_key,
metric=metric,
)
if existing is None:
raise RuntimeError("Unable to resolve annual snapshot id after insert.")
return existing
def _insert_items(
*,
connection: object,
snapshot_id: int,
rows: list[dict[str, object]],
limit: int,
) -> None:
for index, row in enumerate(rows[:limit], start=1):
kills = int(row.get("kills") or 0)
deaths = int(row.get("deaths") or 0)
metric_value = _coerce_metric_value(row.get("metric_value"))
connection.execute(
"""
INSERT INTO rcon_annual_ranking_snapshot_items (
snapshot_id,
ranking_position,
player_id,
player_name,
metric_value,
matches_considered,
kills,
deaths,
teamkills,
kd_ratio
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
snapshot_id,
index,
str(row.get("player_id")),
str(row.get("player_name")),
metric_value,
int(row.get("matches_considered") or 0),
kills,
deaths,
int(row.get("teamkills") or 0),
float(kills / deaths) if deaths else float(kills),
],
)
def _get_snapshot(*, connection: object, snapshot_id: int) -> dict[str, object] | None:
row = connection.execute(
"""
SELECT
id,
year,
server_key,
metric,
limit_size,
source_basis,
window_start,
window_end,
status,
source_matches_count,
generated_at
FROM rcon_annual_ranking_snapshots
WHERE id = ?
LIMIT 1
""",
[snapshot_id],
).fetchone()
if not row:
return None
return dict(row)
def _list_items(*, connection: object, snapshot_id: int, limit: int | None = None) -> list[dict[str, object]]:
params: list[object] = [snapshot_id]
query = """
SELECT
ranking_position,
player_id,
player_name,
metric_value,
matches_considered,
kills,
deaths,
teamkills,
kd_ratio
FROM rcon_annual_ranking_snapshot_items
WHERE snapshot_id = ?
ORDER BY ranking_position ASC
"""
if limit is not None:
query = f"{query}\n LIMIT ?"
params.append(limit)
rows = connection.execute(query, params).fetchall()
return [dict(row) for row in rows]
def _coerce_metric_value(value: object) -> int | float:
try:
numeric = float(value or 0)
except (TypeError, ValueError):
return 0
if numeric.is_integer():
return int(numeric)
return round(numeric, 2)
def _count_items(*, connection: object, snapshot_id: int) -> int:
row = connection.execute(
"""
SELECT COUNT(*) AS item_count
FROM rcon_annual_ranking_snapshot_items
WHERE snapshot_id = ?
""",
[snapshot_id],
).fetchone()
return int(row["item_count"] or 0) if row else 0
def _build_scope_sql(server_key: str, *, table_alias: str = "matches") -> tuple[str, list[object]]:
if server_key == ALL_SERVERS_SLUG:
return "", []
return (
f"AND ({table_alias}.target_key = ? OR {table_alias}.external_server_id = ?)",
[server_key, server_key],
)
def _json_default(value: object) -> str:
if isinstance(value, datetime):
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
if isinstance(value, date):
return value.isoformat()
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
def _main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Generate annual ranking snapshots.")
subparsers = parser.add_subparsers(dest="command")
generate_parser = subparsers.add_parser("generate")
generate_parser.add_argument("--year", type=int, required=True)
generate_parser.add_argument("--server-key", default=None)
generate_parser.add_argument("--metric", default="kills")
generate_parser.add_argument("--limit", type=int, default=20)
generate_parser.add_argument(
"--sqlite-path",
type=Path,
default=None,
help="explicit local SQLite override; default operational mode uses PostgreSQL when configured",
)
generate_parser.add_argument("--replace-existing", action="store_true", default=True)
parser.set_defaults(command="generate")
args = parser.parse_args(argv)
if args.command == "generate":
payload = generate_annual_ranking_snapshot(
year=args.year,
server_key=args.server_key,
metric=args.metric,
limit=args.limit,
replace_existing=args.replace_existing,
db_path=args.sqlite_path,
)
print(
json.dumps(
{"status": "ok", "data": payload},
ensure_ascii=True,
indent=2,
default=_json_default,
)
)
return 0
parser.print_help()
return 2
if __name__ == "__main__":
raise SystemExit(_main())
-292
View File
@@ -1,292 +0,0 @@
"""Dedicated lightweight AdminLog freshness worker for current-match pages."""
from __future__ import annotations
import argparse
import json
import time
from collections.abc import Callable, Iterable, Sequence
from datetime import datetime, timezone
from pathlib import Path
from .config import (
get_current_match_adminlog_enabled,
get_current_match_adminlog_interval_seconds,
get_current_match_adminlog_lookback_seconds,
)
from .rcon_admin_log_ingestion import fetch_recent_admin_log_entries, serialize_rcon_target
from .rcon_admin_log_storage import (
initialize_rcon_admin_log_storage,
persist_rcon_admin_log_entries,
)
from .rcon_client import RconServerTarget, build_rcon_target_key, load_rcon_targets
from .scoreboard_origins import list_trusted_public_scoreboard_origins
def list_current_match_trusted_targets() -> list[RconServerTarget]:
"""Return only the configured RCON targets trusted for public current-match pages."""
trusted_keys = {
origin.slug for origin in list_trusted_public_scoreboard_origins()
}
return [
target
for target in load_rcon_targets()
if build_rcon_target_key(target) in trusted_keys
]
def run_current_match_adminlog_refresh_once(
*,
lookback_seconds: int | None = None,
targets: Sequence[RconServerTarget] | None = None,
fetch_entries_fn: Callable[..., list[dict[str, object]]] = fetch_recent_admin_log_entries,
persist_entries_fn: Callable[..., dict[str, int]] = persist_rcon_admin_log_entries,
db_path: object = None,
ensure_storage: bool = True,
) -> dict[str, object]:
"""Refresh recent AdminLog rows once for trusted current-match targets."""
return run_current_match_adminlog_refresh_once_unlocked(
lookback_seconds=lookback_seconds,
targets=targets,
fetch_entries_fn=fetch_entries_fn,
persist_entries_fn=persist_entries_fn,
db_path=db_path,
ensure_storage=ensure_storage,
)
def run_current_match_adminlog_refresh_once_unlocked(
*,
lookback_seconds: int | None = None,
targets: Sequence[RconServerTarget] | None = None,
fetch_entries_fn: Callable[..., list[dict[str, object]]] = fetch_recent_admin_log_entries,
persist_entries_fn: Callable[..., dict[str, int]] = persist_rcon_admin_log_entries,
db_path: object = None,
ensure_storage: bool = True,
) -> dict[str, object]:
"""Refresh recent AdminLog rows once using idempotent event inserts only."""
resolved_lookback_seconds = _resolve_lookback_seconds(
lookback_seconds=lookback_seconds,
)
if resolved_lookback_seconds <= 0:
raise ValueError("lookback_seconds must be positive.")
resolved_db_path = Path(db_path) if isinstance(db_path, str) else db_path
selected_targets = list(targets) if targets is not None else list_current_match_trusted_targets()
if not selected_targets:
raise RuntimeError("No trusted current-match RCON targets are configured.")
if ensure_storage:
initialize_rcon_admin_log_storage(db_path=resolved_db_path)
timeout_seconds = None
refreshed_at = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
items: list[dict[str, object]] = []
errors: list[dict[str, object]] = []
totals = {
"targets_seen": 0,
"entries_seen": 0,
"events_inserted": 0,
"duplicate_events": 0,
"failed_targets": 0,
}
for target in selected_targets:
totals["targets_seen"] += 1
target_metadata = serialize_rcon_target(target)
started = time.perf_counter()
try:
entries = fetch_entries_fn(
target,
lookback_seconds=resolved_lookback_seconds,
timeout_seconds=timeout_seconds,
)
delta = persist_entries_fn(
target=target_metadata,
entries=entries,
db_path=resolved_db_path,
ensure_storage=False,
)
duration_ms = round((time.perf_counter() - started) * 1000, 2)
totals["entries_seen"] += int(delta.get("events_seen") or 0)
totals["events_inserted"] += int(delta.get("events_inserted") or 0)
totals["duplicate_events"] += int(delta.get("duplicate_events") or 0)
items.append(
{
"target_key": target_metadata["target_key"],
"external_server_id": target_metadata["external_server_id"],
"name": target_metadata["name"],
"entries_seen": int(delta.get("events_seen") or 0),
"events_inserted": int(delta.get("events_inserted") or 0),
"duplicate_events": int(delta.get("duplicate_events") or 0),
"duration_ms": duration_ms,
}
)
except Exception as exc: # noqa: BLE001 - per-target failure must not kill the loop
totals["failed_targets"] += 1
errors.append(
{
"target_key": target_metadata["target_key"],
"external_server_id": target_metadata["external_server_id"],
"name": target_metadata["name"],
"error_type": type(exc).__name__,
"message": str(exc),
}
)
return {
"status": "ok" if not errors else ("partial" if items else "error"),
"worker_enabled": get_current_match_adminlog_enabled(),
"refreshed_at": refreshed_at,
"lookback_seconds": resolved_lookback_seconds,
"targets": items,
"errors": errors,
"totals": totals,
}
def run_current_match_adminlog_refresh_loop(
*,
interval_seconds: int | None = None,
lookback_seconds: int | None = None,
lookback_minutes: int | None = None,
max_runs: int | None = None,
) -> None:
"""Run the lightweight current-match AdminLog refresher in a loop."""
resolved_interval_seconds = (
get_current_match_adminlog_interval_seconds()
if interval_seconds is None
else int(interval_seconds)
)
if resolved_interval_seconds <= 0:
raise ValueError("interval_seconds must be positive.")
if max_runs is not None and max_runs <= 0:
raise ValueError("max_runs must be positive when provided.")
run_count = 0
_emit_worker_event(
"current-match-adminlog-worker-started",
enabled=get_current_match_adminlog_enabled(),
interval_seconds=resolved_interval_seconds,
lookback_seconds=_resolve_lookback_seconds(
lookback_seconds=lookback_seconds,
lookback_minutes=lookback_minutes,
),
targets=[
{
"target_key": build_rcon_target_key(target),
"external_server_id": target.external_server_id,
"name": target.name,
}
for target in list_current_match_trusted_targets()
],
)
initialize_rcon_admin_log_storage()
try:
while max_runs is None or run_count < max_runs:
run_count += 1
_emit_worker_event("current-match-adminlog-cycle-started", run=run_count)
result = run_current_match_adminlog_refresh_once(
lookback_seconds=_resolve_lookback_seconds(
lookback_seconds=lookback_seconds,
lookback_minutes=lookback_minutes,
),
ensure_storage=False,
)
_emit_worker_event(
"current-match-adminlog-cycle-finished",
run=run_count,
result=result,
)
if max_runs is not None and run_count >= max_runs:
break
_emit_worker_event(
"current-match-adminlog-sleep-started",
run=run_count,
sleep_seconds=resolved_interval_seconds,
)
time.sleep(resolved_interval_seconds)
except KeyboardInterrupt:
_emit_worker_event("current-match-adminlog-worker-stopped", reason="keyboard-interrupt")
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Lightweight current-match AdminLog freshness worker.",
)
parser.add_argument(
"mode",
choices=("once", "loop"),
help="run once or keep polling trusted current-match targets",
)
parser.add_argument(
"--interval",
type=int,
default=get_current_match_adminlog_interval_seconds(),
help="seconds between loop iterations",
)
parser.add_argument(
"--lookback-seconds",
type=int,
default=get_current_match_adminlog_lookback_seconds(),
help="overlap-safe AdminLog lookback window in seconds",
)
parser.add_argument(
"--lookback-minutes",
type=int,
help="optional overlap-safe AdminLog lookback window in minutes",
)
parser.add_argument(
"--max-runs",
type=int,
help="optional safety cap for loop mode",
)
return parser
def main(argv: Iterable[str] | None = None) -> int:
parser = build_arg_parser()
args = parser.parse_args(list(argv) if argv is not None else None)
if args.mode == "once":
lookback_seconds = _resolve_lookback_seconds(
lookback_seconds=args.lookback_seconds,
lookback_minutes=args.lookback_minutes,
)
print(
json.dumps(
run_current_match_adminlog_refresh_once(
lookback_seconds=lookback_seconds,
),
indent=2,
)
)
return 0
run_current_match_adminlog_refresh_loop(
interval_seconds=args.interval,
lookback_seconds=args.lookback_seconds,
lookback_minutes=args.lookback_minutes,
max_runs=args.max_runs,
)
return 0
def _resolve_lookback_seconds(
*,
lookback_seconds: int | None = None,
lookback_minutes: int | None = None,
) -> int:
if lookback_minutes is not None:
if int(lookback_minutes) <= 0:
raise ValueError("lookback_minutes must be positive.")
return int(lookback_minutes) * 60
if lookback_seconds is None:
return get_current_match_adminlog_lookback_seconds()
return int(lookback_seconds)
def _emit_worker_event(event: str, **fields: object) -> None:
print(json.dumps({"event": event, **fields}, indent=2, default=str), flush=True)
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+20 -64
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from .config import get_kpm_min_active_seconds
from .historical_storage import ALL_SERVERS_SLUG
from .normalizers import normalize_map_name
from .player_external_profiles import build_external_player_profile_fields
@@ -155,11 +154,7 @@ def get_rcon_historical_match_detail(
"""Return one RCON competitive window as a match-detail compatible payload."""
from .rcon_admin_log_materialization import get_materialized_rcon_match_detail
materialized = get_materialized_rcon_match_detail(
server_key=server_key,
match_key=match_id,
ensure_storage=False,
)
materialized = get_materialized_rcon_match_detail(server_key=server_key, match_key=match_id)
if materialized is not None:
return _build_materialized_detail_item(materialized)
@@ -258,9 +253,21 @@ def _build_materialized_recent_item(item: dict[str, object]) -> dict[str, object
def _build_materialized_detail_item(materialized: dict[str, object]) -> dict[str, object]:
from .rcon_admin_log_storage import get_latest_rcon_player_profile_summaries
match = materialized["match"]
recent_item = _build_materialized_recent_item(match)
players = [_build_player_row(row) for row in materialized["players"]]
profile_summaries = get_latest_rcon_player_profile_summaries(
target_key=str(match["target_key"]),
player_ids=[str(row["player_id"]) for row in materialized["players"] if row.get("player_id")],
)
players = [
_build_player_row(
row,
profile_summary=profile_summaries.get(str(row.get("player_id"))),
)
for row in materialized["players"]
]
player_count = len(players) if players else recent_item.get("player_count")
return {
**recent_item,
@@ -291,16 +298,12 @@ def _resolve_materialized_player_count(item: dict[str, object]) -> int | None:
def _build_player_row(
row: dict[str, object],
*,
profile_summary: dict[str, object] | None = None,
) -> dict[str, object]:
kills = _coerce_optional_int(row.get("kills")) or 0
deaths = _coerce_optional_int(row.get("deaths")) or 0
player_active_seconds = _coerce_optional_int(row.get("player_active_seconds"))
active_time_payload = _build_player_active_time_payload(
kills=kills,
player_active_seconds=player_active_seconds,
active_time_source=row.get("active_time_source"),
)
return {
player = {
"player_name": row.get("player_name"),
"team": row.get("team"),
"kills": kills,
@@ -310,58 +313,11 @@ def _build_player_row(
"top_weapons": _top_counter(row.get("weapons_json")),
"most_killed": _top_counter(row.get("most_killed_json")),
"death_by": _top_counter(row.get("death_by_json")),
**active_time_payload,
**build_external_player_profile_fields(player_id=row.get("player_id")),
}
def _build_player_active_time_payload(
*,
kills: int,
player_active_seconds: int | None,
active_time_source: object,
) -> dict[str, object]:
min_active_seconds = get_kpm_min_active_seconds()
resolved_source = str(active_time_source or "").strip() or None
is_real_connection_time = resolved_source in {
"connection_intervals",
"connection_intervals_carryover",
}
if player_active_seconds is None:
return {
"player_active_seconds": None,
"player_active_minutes": None,
"kpm": None,
"kpm_status": "missing_active_time",
"active_time_source": resolved_source,
}
player_active_minutes = round(player_active_seconds / 60, 3)
if not is_real_connection_time:
return {
"player_active_seconds": player_active_seconds,
"player_active_minutes": player_active_minutes,
"kpm": None,
"kpm_status": "missing_connection_intervals",
"active_time_source": resolved_source or "event_span_fallback",
}
if player_active_seconds < min_active_seconds:
return {
"player_active_seconds": player_active_seconds,
"player_active_minutes": player_active_minutes,
"kpm": None,
"kpm_status": "insufficient_active_time",
"active_time_source": resolved_source or "event_log",
}
kpm = round(kills / (player_active_seconds / 60), 2)
return {
"player_active_seconds": player_active_seconds,
"player_active_minutes": player_active_minutes,
"kpm": kpm,
"kpm_status": "ready",
"active_time_source": resolved_source or "event_log",
}
if profile_summary:
player["profile_summary"] = profile_summary
return player
def _top_counter(raw_value: object, *, limit: int = 5) -> list[dict[str, object]]:
+6 -91
View File
@@ -5,9 +5,7 @@ from __future__ import annotations
import json
import sqlite3
from collections.abc import Mapping
from collections.abc import Iterator
from contextlib import closing, contextmanager
from datetime import datetime, timedelta, timezone
from datetime import datetime, timezone
from pathlib import Path
from .config import get_storage_path, use_postgres_rcon_storage
@@ -20,20 +18,11 @@ COMPETITIVE_WINDOW_GAP_SECONDS = 1800
COMPETITIVE_MODE_PARTIAL = "partial"
COMPETITIVE_MODE_APPROXIMATE = "approximate"
COMPETITIVE_MODE_EXACT = "exact"
RUNNING_HISTORICAL_CAPTURE_CONFLICT_MESSAGE = (
"historical materialization capture already running"
)
HISTORICAL_RUNNING_STALE_TIMEOUT = timedelta(hours=6)
def initialize_rcon_historical_storage(
*,
db_path: Path | None = None,
ensure_storage: bool = True,
) -> Path:
def initialize_rcon_historical_storage(*, db_path: Path | None = None) -> Path:
"""Create the SQLite structures used by prospective RCON capture."""
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
if ensure_storage:
from .postgres_rcon_storage import initialize_postgres_rcon_storage
initialize_postgres_rcon_storage()
@@ -137,7 +126,6 @@ def initialize_rcon_historical_storage(
ON rcon_historical_competitive_windows(target_id, last_seen_at DESC);
"""
)
connection.execute("DROP INDEX IF EXISTS idx_rcon_historical_single_running_historical")
return resolved_path
@@ -156,19 +144,6 @@ def start_rcon_historical_capture_run(
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
with _connect(resolved_path) as connection:
if mode == "historical":
_mark_stale_historical_runs(connection)
active_running_row = connection.execute(
"""
SELECT id
FROM rcon_historical_capture_runs
WHERE mode = 'historical' AND status = 'running'
ORDER BY started_at DESC, id DESC
LIMIT 1
"""
).fetchone()
if active_running_row is not None:
raise RuntimeError(RUNNING_HISTORICAL_CAPTURE_CONFLICT_MESSAGE)
cursor = connection.execute(
"""
INSERT INTO rcon_historical_capture_runs (
@@ -183,35 +158,6 @@ def start_rcon_historical_capture_run(
return int(cursor.lastrowid)
@contextmanager
def historical_capture_runtime_guard(*, capture_mode: str, db_path: Path | None = None):
"""Guard the heavy historical path without relying on a schema-level unique index."""
if capture_mode != "historical":
yield True
return
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import postgres_historical_capture_advisory_guard
with postgres_historical_capture_advisory_guard() as acquired:
yield acquired
return
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
with _connect(resolved_path) as connection:
_mark_stale_historical_runs(connection)
active_running_row = connection.execute(
"""
SELECT id
FROM rcon_historical_capture_runs
WHERE mode = 'historical' AND status = 'running'
ORDER BY started_at DESC, id DESC
LIMIT 1
"""
).fetchone()
yield active_running_row is None
def finalize_rcon_historical_capture_run(
run_id: int,
*,
@@ -878,21 +824,12 @@ def get_rcon_historical_competitive_window_by_session(
}
@contextmanager
def _connect(db_path: Path) -> Iterator[sqlite3.Connection]:
connection = connect_sqlite_writer(db_path)
try:
yield connection
connection.commit()
except Exception:
connection.rollback()
raise
finally:
connection.close()
def _connect(db_path: Path) -> sqlite3.Connection:
return connect_sqlite_writer(db_path)
def _connect_readonly(db_path: Path):
return closing(connect_sqlite_readonly(db_path))
def _connect_readonly(db_path: Path) -> sqlite3.Connection:
return connect_sqlite_readonly(db_path)
def _resolve_db_path(db_path: Path | None) -> Path:
@@ -1170,25 +1107,3 @@ def _calculate_duration_seconds(first_seen_at: str | None, last_seen_at: str | N
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _mark_stale_historical_runs(connection: sqlite3.Connection) -> None:
stale_before = (
datetime.now(timezone.utc) - HISTORICAL_RUNNING_STALE_TIMEOUT
).isoformat().replace("+00:00", "Z")
connection.execute(
"""
UPDATE rcon_historical_capture_runs
SET status = 'stale',
completed_at = ?,
notes = CASE
WHEN notes IS NULL OR notes = ''
THEN 'auto-marked stale after runtime guard timeout'
ELSE notes
END
WHERE mode = 'historical'
AND status = 'running'
AND started_at < ?
""",
(_utc_now_iso(), stale_before),
)
-36
View File
@@ -30,7 +30,6 @@ from .rcon_client import (
)
from .rcon_historical_storage import (
finalize_rcon_historical_capture_run,
historical_capture_runtime_guard,
initialize_rcon_historical_storage,
list_rcon_historical_target_statuses,
mark_rcon_historical_capture_failure,
@@ -100,41 +99,6 @@ def run_rcon_historical_capture_unlocked(
capture_mode=capture_mode,
skip_materialization=skip_materialization,
)
with historical_capture_runtime_guard(capture_mode=resolved_capture_mode) as guard_acquired:
if not guard_acquired:
return {
"status": "skipped",
"run_status": "skipped",
"captured_at": utc_now().isoformat().replace("+00:00", "Z"),
"target_scope": target_key or "all-configured-rcon-targets",
"capture_mode": resolved_capture_mode,
"materialization_skipped": resolved_skip_materialization,
"admin_log_lookback_minutes": get_rcon_admin_log_lookback_minutes(),
"admin_log_events_seen": 0,
"admin_log_events_inserted": 0,
"duplicate_events": 0,
"samples_inserted": 0,
"targets": [],
"errors": [],
"admin_log_errors": [],
"materialization_result": {
"status": "skipped",
"reason": "already-running",
},
"storage_status": [],
"totals": {
"targets_seen": 0,
"samples_inserted": 0,
"duplicate_samples": 0,
"failed_targets": 0,
"admin_log_events_seen": 0,
"admin_log_events_inserted": 0,
"admin_log_duplicate_events": 0,
"admin_log_failed_targets": 0,
"materialized_matches_inserted": 0,
"materialized_matches_updated": 0,
},
}
initialize_rcon_historical_storage()
selected_targets = _select_targets(target_key)
selected_target_keys = {build_rcon_target_key(target) for target in selected_targets}
+23 -145
View File
@@ -3,12 +3,10 @@
from __future__ import annotations
from http import HTTPStatus
from datetime import datetime, timezone
from urllib.parse import parse_qs, urlparse
from .config import get_historical_data_source_kind
from .payloads import (
build_global_ranking_payload,
build_stats_player_profile_payload,
build_community_payload,
build_current_match_kill_feed_payload,
build_current_match_player_stats_payload,
@@ -18,7 +16,6 @@ from .payloads import (
build_elo_mmr_player_payload,
build_error_payload,
build_health_payload,
build_annual_ranking_snapshot_payload,
build_historical_leaderboard_payload,
build_historical_match_detail_payload,
build_monthly_mvp_payload,
@@ -43,19 +40,10 @@ from .payloads import (
build_weekly_leaderboard_snapshot_payload,
build_weekly_leaderboard_payload,
build_weekly_top_kills_payload,
build_stats_player_search_payload,
)
from .rcon_historical_leaderboards import build_rcon_materialized_leaderboard_snapshot_payload
from .scoreboard_origins import get_trusted_public_scoreboard_origin
RANKING_METRICS = {
"kills",
"deaths",
"teamkills",
"matches_considered",
"kd_ratio",
"kills_per_match",
}
GET_ROUTES = {
"/health": build_health_payload,
@@ -78,79 +66,6 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
return HTTPStatus.OK, build_server_history_payload(limit=limit)
if parsed.path == "/api/stats/players/search":
params = parse_qs(parsed.query)
query = str(params.get("q", [None])[0] or "").strip()
if not query:
return HTTPStatus.BAD_REQUEST, build_error_payload("Query parameter is required")
limit = _parse_limit_with_default(parsed.query, default=10)
if limit is None:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
server_id = params.get("server_id", [None])[0]
if server_id is None:
server_id = params.get("server", [None])[0]
return HTTPStatus.OK, build_stats_player_search_payload(
query=query,
server_id=server_id,
limit=limit,
)
if parsed.path == "/api/stats/rankings/annual":
params = parse_qs(parsed.query)
metric = params.get("metric", ["kills"])[0]
if metric != "kills":
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter")
year = _parse_year(parsed.query)
if year is None:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid year parameter")
limit = _parse_limit(parsed.query)
if limit is None:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
server_id = params.get("server_id", [None])[0]
if server_id is None:
server_id = params.get("server", [None])[0]
try:
return HTTPStatus.OK, build_annual_ranking_snapshot_payload(
year=year,
server_id=server_id,
metric=metric,
limit=limit,
)
except ValueError as error:
return HTTPStatus.BAD_REQUEST, build_error_payload(str(error))
if parsed.path == "/api/ranking":
params = parse_qs(parsed.query)
timeframe = params.get("timeframe", ["weekly"])[0]
if timeframe not in {"weekly", "monthly", "annual"}:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid timeframe parameter")
metric = params.get("metric", ["kills"])[0]
if metric not in RANKING_METRICS:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter")
limit = _parse_limit(parsed.query)
if limit is None:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
server_id = params.get("server_id", [None])[0]
if server_id is None:
server_id = params.get("server", [None])[0]
if not _is_supported_ranking_server_id(server_id):
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid server_id parameter")
year = None
if timeframe == "annual":
year = _parse_required_year(parsed.query)
if year is None:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid year parameter")
try:
return HTTPStatus.OK, build_global_ranking_payload(
timeframe=timeframe,
server_id=server_id,
metric=metric,
limit=limit,
year=year,
)
except ValueError as error:
return HTTPStatus.BAD_REQUEST, build_error_payload(str(error))
if parsed.path == "/api/current-match":
server_slug = parse_qs(parsed.query).get("server", [None])[0]
if not server_slug:
@@ -159,23 +74,6 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
return HTTPStatus.NOT_FOUND, build_error_payload("Current match server is not supported")
return HTTPStatus.OK, build_current_match_payload(server_slug=server_slug)
if parsed.path.startswith("/api/stats/players/"):
player_id = parsed.path.removeprefix("/api/stats/players/").strip()
if not player_id:
return HTTPStatus.BAD_REQUEST, build_error_payload("Player id is required")
params = parse_qs(parsed.query)
timeframe = params.get("timeframe", ["weekly"])[0] or "weekly"
if timeframe not in {"weekly", "monthly"}:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid timeframe parameter")
server_id = params.get("server_id", [None])[0]
if server_id is None:
server_id = params.get("server", [None])[0]
return HTTPStatus.OK, build_stats_player_profile_payload(
player_id=player_id,
server_id=server_id,
timeframe=timeframe,
)
if parsed.path == "/api/current-match/kills":
limit = _parse_limit(parsed.query)
if limit is None:
@@ -303,6 +201,13 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter")
if timeframe not in {"weekly", "monthly"}:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid timeframe parameter")
if get_historical_data_source_kind() == "rcon":
return HTTPStatus.OK, build_rcon_materialized_leaderboard_snapshot_payload(
limit=limit,
server_id=server_id,
metric=metric,
timeframe=timeframe,
)
return HTTPStatus.OK, build_leaderboard_snapshot_payload(
limit=limit,
server_id=server_id,
@@ -319,6 +224,13 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
metric = params.get("metric", ["kills"])[0]
if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter")
if get_historical_data_source_kind() == "rcon":
return HTTPStatus.OK, build_rcon_materialized_leaderboard_snapshot_payload(
limit=limit,
server_id=server_id,
metric=metric,
timeframe="monthly",
)
return HTTPStatus.OK, build_monthly_leaderboard_snapshot_payload(
limit=limit,
server_id=server_id,
@@ -369,6 +281,13 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
metric = params.get("metric", ["kills"])[0]
if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter")
if get_historical_data_source_kind() == "rcon":
return HTTPStatus.OK, build_rcon_materialized_leaderboard_snapshot_payload(
limit=limit,
server_id=server_id,
metric=metric,
timeframe="weekly",
)
return HTTPStatus.OK, build_weekly_leaderboard_snapshot_payload(
limit=limit,
server_id=server_id,
@@ -474,44 +393,3 @@ def _parse_limit(query: str) -> int | None:
return None
return limit
def _parse_year(query: str) -> int | None:
params = parse_qs(query)
raw_year = params.get("year", [None])[0]
if raw_year is None:
return datetime.now(timezone.utc).year
try:
year = int(raw_year)
except ValueError:
return None
if year <= 0:
return None
return year
def _parse_required_year(query: str) -> int | None:
params = parse_qs(query)
if "year" not in params:
return None
return _parse_year(query)
def _parse_limit_with_default(query: str, default: int = 20) -> int | None:
params = parse_qs(query)
if "limit" not in params:
return default
return _parse_limit(query)
def _is_supported_ranking_server_id(server_id: str | None) -> bool:
if server_id is None:
return True
normalized = str(server_id).strip().lower()
return normalized in {
"",
"all",
"all-servers",
"comunidad-hispana-01",
"comunidad-hispana-02",
}
@@ -28,8 +28,6 @@ RCON_TABLES = (
"rcon_player_profile_snapshots",
"rcon_materialized_matches",
"rcon_match_player_stats",
"rcon_annual_ranking_snapshots",
"rcon_annual_ranking_snapshot_items",
"rcon_scoreboard_match_candidates",
)
DISPLAY_TABLES = (
@@ -317,8 +315,6 @@ def _sync_sequences() -> None:
"rcon_player_profile_snapshots",
"rcon_materialized_matches",
"rcon_match_player_stats",
"rcon_annual_ranking_snapshots",
"rcon_annual_ranking_snapshot_items",
"rcon_scoreboard_match_candidates",
)
with connect_display_postgres() as connection:
@@ -1,136 +0,0 @@
import gc
import unittest
import warnings
from contextlib import closing, nullcontext
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from app.historical_storage import ALL_SERVERS_SLUG
from app.rcon_admin_log_materialization import (
MATCH_RESULT_SOURCE,
initialize_rcon_materialized_storage,
)
from app.rcon_annual_rankings import (
SUPPORTED_ANNUAL_RANKING_METRICS,
_normalize_metric,
generate_annual_ranking_snapshot,
get_annual_ranking_snapshot,
)
from app.sqlite_utils import connect_sqlite_writer
class AnnualRankingPayloadTests(unittest.TestCase):
def test_get_annual_ranking_snapshot_skips_storage_init_on_postgres_read(self):
with (
patch("app.rcon_annual_rankings.use_postgres_rcon_storage", return_value=True),
patch("app.rcon_annual_rankings.initialize_rcon_materialized_storage") as init_mock,
patch(
"app.rcon_annual_rankings._open_annual_snapshot_read_connection",
return_value=nullcontext(object()),
),
patch("app.rcon_annual_rankings._find_snapshot", return_value=None),
):
result = get_annual_ranking_snapshot(
year=2026,
server_key="all",
metric="kills",
limit=30,
)
init_mock.assert_not_called()
self.assertEqual(result["snapshot_status"], "missing")
self.assertEqual(result["source"], "rcon-annual-ranking-snapshot")
self.assertEqual(result["requested_limit"], 30)
def test_normalize_metric_accepts_supported_annual_metrics(self):
for metric in SUPPORTED_ANNUAL_RANKING_METRICS:
with self.subTest(metric=metric):
self.assertEqual(_normalize_metric(metric), metric)
def test_normalize_metric_rejects_unsupported_annual_metrics(self):
with self.assertRaises(ValueError):
_normalize_metric("kills_per_minute")
def test_generate_annual_snapshot_orders_kd_ratio_and_kills_per_match(self):
with TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "annual-ranking.sqlite3"
self._seed_materialized_stats(db_path)
kd_result = generate_annual_ranking_snapshot(
year=2026,
server_key=ALL_SERVERS_SLUG,
metric="kd_ratio",
limit=10,
db_path=db_path,
)
kpp_result = generate_annual_ranking_snapshot(
year=2026,
server_key=ALL_SERVERS_SLUG,
metric="kills_per_match",
limit=10,
db_path=db_path,
)
kd_items = kd_result["items"]
kpp_items = kpp_result["items"]
self.assertEqual(kd_items[0]["player_id"], "player-bravo")
self.assertEqual(kd_items[0]["metric_value"], 10)
self.assertEqual(kpp_items[0]["player_id"], "player-bravo")
self.assertEqual(kpp_items[0]["metric_value"], 20)
self.assertEqual(kpp_items[1]["player_id"], "player-alpha")
self.assertEqual(kpp_items[1]["metric_value"], 15)
with warnings.catch_warnings():
warnings.simplefilter("ignore", ResourceWarning)
gc.collect()
def _seed_materialized_stats(self, db_path: Path) -> None:
initialize_rcon_materialized_storage(db_path=db_path)
with closing(connect_sqlite_writer(db_path)) as connection:
with connection:
for match_key in ("match-1", "match-2", "match-3"):
connection.execute(
"""
INSERT INTO rcon_materialized_matches (
target_key,
external_server_id,
match_key,
started_at,
ended_at,
confidence_mode,
source_basis
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
[
"target-1",
"comunidad-hispana-01",
match_key,
"2026-01-01T19:00:00Z",
"2026-01-01T20:00:00Z",
"exact",
MATCH_RESULT_SOURCE,
],
)
for row in (
("match-1", "player-alpha", "Alpha", 12, 4, 0),
("match-2", "player-alpha", "Alpha", 18, 6, 1),
("match-3", "player-bravo", "Bravo", 20, 2, 0),
):
connection.execute(
"""
INSERT INTO rcon_match_player_stats (
target_key,
match_key,
player_id,
player_name,
kills,
deaths,
teamkills
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
["target-1", *row],
)
if __name__ == "__main__":
unittest.main()
+1 -401
View File
@@ -1,12 +1,7 @@
from http import HTTPStatus
from datetime import datetime, timezone
from pathlib import Path
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from unittest.mock import patch
from app import payloads
from app import rcon_admin_log_storage
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
@@ -499,305 +494,6 @@ def test_current_match_player_stats_filter_stale_recent_events(tmp_path):
assert stats["items"] == []
class CurrentMatchPublicEndpointHardeningTests(unittest.TestCase):
def test_current_match_player_stats_deduplicates_unknown_and_known_team(self) -> None:
stats = _build_admin_log_player_stats(
[
"[1:00 min (100)] MATCH START Mortain Warfare",
"[2:00 min (120)] CONNECTED Merge Allies ()",
(
"[3:00 min (140)] KILL: Merge Allies(Allies/steam-merge-allies) -> "
"Axis Target(Axis/steam-axis-target) with M1 GARAND"
),
"[4:00 min (160)] CONNECTED Merge Axis ()",
(
"[5:00 min (180)] KILL: Merge Axis(Axis/steam-merge-axis) -> "
"Allies Target(Allies/steam-allies-target) with MP40"
),
]
)
by_name = _player_stats_by_name(stats)
self.assertEqual(_count_player_stats_by_name(stats, "Merge Allies"), 1)
self.assertEqual(by_name["Merge Allies"]["team"], "Allies")
self.assertEqual(by_name["Merge Allies"]["kills"], 1)
self.assertEqual(_count_player_stats_by_name(stats, "Merge Axis"), 1)
self.assertEqual(by_name["Merge Axis"]["team"], "Axis")
self.assertEqual(by_name["Merge Axis"]["kills"], 1)
def test_current_match_player_stats_deduplicates_same_known_team_rows(self) -> None:
stats = _build_admin_log_player_stats(
[
"[1:00 min (100)] MATCH START Mortain Warfare",
"[2:00 min (120)] CHAT[Team][Known Duplicate(Allies/)]: listo",
(
"[3:00 min (140)] KILL: Known Duplicate(Allies/steam-known-duplicate) -> "
"Axis Target(Axis/steam-axis-target) with M1 GARAND"
),
]
)
by_name = _player_stats_by_name(stats)
self.assertEqual(_count_player_stats_by_name(stats, "Known Duplicate"), 1)
self.assertEqual(by_name["Known Duplicate"]["team"], "Allies")
self.assertEqual(by_name["Known Duplicate"]["kills"], 1)
def test_current_match_player_stats_preserves_split_stats_when_deduplicating(self) -> None:
stats = _build_admin_log_player_stats(
[
"[1:00 min (100)] MATCH START Mortain Warfare",
(
"[2:00 min (120)] KILL: Split Stats(Allies/) -> "
"First Victim(Axis/steam-first-victim) with MP40"
),
(
"[3:00 min (140)] KILL: Axis Killer(Axis/steam-axis-killer) -> "
"Split Stats(Allies/steam-split-stats) with M1 GARAND"
),
]
)
player = _player_stats_by_name(stats)["Split Stats"]
self.assertEqual(_count_player_stats_by_name(stats, "Split Stats"), 1)
self.assertEqual(player["team"], "Allies")
self.assertEqual(player["kills"], 1)
self.assertEqual(player["deaths"], 1)
self.assertEqual(player["teamkills"], 0)
self.assertEqual(player["deaths_by_teamkill"], 0)
self.assertEqual(player["favorite_weapon"], "MP40")
def test_current_match_player_stats_keeps_distinct_players(self) -> None:
stats = _build_admin_log_player_stats(
[
"[1:00 min (100)] MATCH START Mortain Warfare",
"[2:00 min (120)] CONNECTED First Player (steam-first)",
"[3:00 min (140)] CONNECTED Second Player (steam-second)",
]
)
names = {item["player_name"] for item in stats["items"]}
self.assertEqual(names, {"First Player", "Second Player"})
def test_current_match_player_stats_keeps_same_name_with_different_player_ids(self) -> None:
stats = _build_admin_log_player_stats(
[
"[1:00 min (100)] MATCH START Mortain Warfare",
(
"[2:00 min (120)] KILL: Shared Name(Allies/steam-shared-one) -> "
"First Victim(Axis/steam-first-victim) with M1 GARAND"
),
(
"[3:00 min (140)] KILL: Shared Name(Allies/steam-shared-two) -> "
"Second Victim(Axis/steam-second-victim) with BAR"
),
]
)
shared_rows = [
item for item in stats["items"] if item["player_name"] == "Shared Name"
]
self.assertEqual(len(shared_rows), 2)
self.assertEqual(
{item["player_id"] for item in shared_rows},
{"steam-shared-one", "steam-shared-two"},
)
def test_current_match_player_stats_preserves_real_favorite_weapon(self) -> None:
stats = _build_admin_log_player_stats(
[
"[1:00 min (100)] MATCH START Mortain Warfare",
"[2:00 min (120)] CONNECTED Weapon Player ()",
(
"[3:00 min (140)] KILL: Weapon Player(Allies/steam-weapon-player) -> "
"Axis Target(Axis/steam-axis-target) with M1 GARAND"
),
]
)
player = _player_stats_by_name(stats)["Weapon Player"]
self.assertEqual(player["favorite_weapon"], "M1 GARAND")
def test_kill_feed_degrades_when_admin_log_read_fails(self) -> None:
with patch.object(
payloads,
"list_current_match_kill_feed",
side_effect=TimeoutError("read timed out"),
):
result = payloads.build_current_match_kill_feed_payload(
server_slug="comunidad-hispana-01",
limit=30,
)
data = result["data"]
self.assertEqual(result["status"], "ok")
self.assertEqual(data["items"], [])
self.assertEqual(data["confidence"], "unavailable")
self.assertTrue(data["fallback_used"])
self.assertEqual(data["fallback_reason"], "admin-log-read-timeout")
def test_player_stats_degrades_when_admin_log_read_fails(self) -> None:
with patch.object(
payloads,
"list_current_match_player_stats",
side_effect=RuntimeError("no such table: rcon_admin_log_events"),
):
result = payloads.build_current_match_player_stats_payload(
server_slug="comunidad-hispana-01",
)
data = result["data"]
self.assertEqual(result["status"], "ok")
self.assertEqual(data["items"], [])
self.assertEqual(data["updated_at"], None)
self.assertTrue(data["fallback_used"])
self.assertEqual(data["fallback_reason"], "admin-log-read-model-unavailable")
def test_servers_payload_refreshes_live_when_no_snapshot_exists(self) -> None:
live_snapshot = {
"server_name": "Comunidad Hispana #01",
"external_server_id": "comunidad-hispana-01",
"captured_at": "2026-06-10T10:00:00Z",
"snapshot_origin": "real-rcon",
"current_map": "carentan",
"players": 74,
"max_players": 100,
}
fake_live_source = _FakeLiveSource(
collect_payload={
"snapshots": [live_snapshot],
"errors": [],
"primary_source": "rcon",
"selected_source": "rcon",
"fallback_used": False,
"fallback_reason": None,
"source_attempts": [
{
"source": "rcon",
"role": "primary",
"status": "success",
"reason": None,
"message": None,
}
],
}
)
with (
patch.object(payloads, "list_latest_snapshots", return_value=[]),
patch.object(payloads, "get_live_data_source", return_value=fake_live_source),
):
result = payloads.build_servers_payload()
data = result["data"]
self.assertEqual(result["status"], "ok")
self.assertEqual(data["source"], "real-time-rcon-refresh")
self.assertEqual(data["refresh_attempted"], True)
self.assertEqual(data["refresh_status"], "success")
self.assertEqual(data["items"][0]["external_server_id"], "comunidad-hispana-01")
self.assertEqual(data["items"][0]["players"], 74)
self.assertEqual(fake_live_source.collect_calls, [(False, 2.5)])
def test_servers_payload_returns_controlled_empty_response_when_live_fails_without_cache(self) -> None:
fake_live_source = _FakeLiveSource(collect_error=TimeoutError("RCON timed out"))
with (
patch.object(payloads, "list_latest_snapshots", return_value=[]),
patch.object(payloads, "get_live_data_source", return_value=fake_live_source),
):
result = payloads.build_servers_payload()
data = result["data"]
self.assertEqual(result["status"], "ok")
self.assertEqual(data["items"], [])
self.assertEqual(data["source"], "no-snapshot-available")
self.assertEqual(data["refresh_attempted"], True)
self.assertEqual(data["refresh_status"], "failed")
self.assertEqual(data["fallback_used"], True)
self.assertEqual(data["fallback_reason"], "live-refresh-timeout")
self.assertEqual(data["refresh_errors"][0]["reason"], "live-refresh-timeout")
def test_servers_payload_falls_back_to_stale_snapshot_when_live_refresh_fails(self) -> None:
stale_snapshot = {
"server_name": "Comunidad Hispana #01",
"external_server_id": "comunidad-hispana-01",
"captured_at": "2020-01-01T00:00:00Z",
"snapshot_origin": "real-rcon",
"current_map": "carentan",
}
fake_live_source = _FakeLiveSource(collect_error=RuntimeError("live source down"))
with (
patch.object(payloads, "list_latest_snapshots", return_value=[stale_snapshot]),
patch.object(payloads, "get_live_data_source", return_value=fake_live_source),
):
result = payloads.build_servers_payload()
data = result["data"]
self.assertEqual(result["status"], "ok")
self.assertEqual(data["items"][0]["external_server_id"], "comunidad-hispana-01")
self.assertEqual(data["refresh_attempted"], True)
self.assertEqual(data["refresh_status"], "failed")
self.assertEqual(data["source"], "persisted-stale-snapshot")
self.assertEqual(data["refresh_errors"][0]["reason"], "live-refresh-failed")
def test_kill_feed_postgres_read_only_does_not_initialize_storage(self) -> None:
connection = _FakeAdminLogConnection(
[
None,
[],
]
)
connection_scope = _FakeConnectionScope(connection)
with (
patch.object(rcon_admin_log_storage, "use_postgres_rcon_storage", return_value=True),
patch(
"app.postgres_rcon_storage.connect_postgres_compat",
return_value=connection_scope,
) as connect_postgres,
patch("app.rcon_admin_log_materialization.materialize_rcon_admin_log") as materialize,
):
result = rcon_admin_log_storage.list_current_match_kill_feed(
server_key="comunidad-hispana-01",
ensure_storage=False,
)
connect_postgres.assert_called_once_with(initialize=False)
materialize.assert_not_called()
self.assertEqual(result["items"], [])
def test_player_stats_postgres_read_only_does_not_initialize_storage(self) -> None:
connection = _FakeAdminLogConnection(
[
None,
[],
]
)
connection_scope = _FakeConnectionScope(connection)
with (
patch.object(rcon_admin_log_storage, "use_postgres_rcon_storage", return_value=True),
patch(
"app.postgres_rcon_storage.connect_postgres_compat",
return_value=connection_scope,
) as connect_postgres,
patch("app.rcon_admin_log_materialization.materialize_rcon_admin_log") as materialize,
):
result = rcon_admin_log_storage.list_current_match_player_stats(
server_key="comunidad-hispana-01",
ensure_storage=False,
)
connect_postgres.assert_called_once_with(initialize=False)
materialize.assert_not_called()
self.assertEqual(result["items"], [])
self.assertEqual(result["source"], "rcon-admin-log-current-match-summary")
def _build_with_rcon_sample(sample: dict[str, object]) -> dict[str, object]:
with (
patch("app.payloads.load_rcon_targets", return_value=(TARGET,)),
@@ -826,99 +522,3 @@ def _build_with_snapshot_fallback(
):
payload = build_current_match_payload(server_slug=server_slug)
return payload["data"]
class _FakeConnectionScope:
def __init__(self, connection: object) -> None:
self.connection = connection
def __enter__(self) -> object:
return self.connection
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
return None
class _FakeAdminLogConnection:
def __init__(self, query_results: list[object]) -> None:
self._query_results = list(query_results)
def execute(self, *_args: object, **_kwargs: object) -> MagicMock:
result = self._query_results.pop(0)
cursor = MagicMock()
cursor.fetchone.return_value = result
cursor.fetchall.return_value = result
return cursor
class _FakeLiveSource:
def __init__(
self,
*,
collect_payload: dict[str, object] | None = None,
collect_error: Exception | None = None,
) -> None:
self.collect_payload = collect_payload or {
"snapshots": [],
"errors": [],
"primary_source": "rcon",
"selected_source": "none",
"fallback_used": False,
"fallback_reason": None,
"source_attempts": [],
}
self.collect_error = collect_error
self.collect_calls: list[tuple[bool, float | None]] = []
def build_target_index(self) -> dict[str, object]:
return {}
def collect_snapshots(
self,
*,
persist: bool,
timeout_seconds: float | None = None,
) -> dict[str, object]:
self.collect_calls.append((persist, timeout_seconds))
if self.collect_error is not None:
raise self.collect_error
return self.collect_payload
def _build_admin_log_player_stats(messages: list[str]) -> dict[str, object]:
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "admin-log.sqlite3"
persist_rcon_admin_log_entries(
target={
"target_key": "comunidad-hispana-01",
"external_server_id": "comunidad-hispana-01",
},
entries=[
{
"timestamp": f"2026-05-21T10:{index:02d}:00Z",
"message": message,
}
for index, message in enumerate(messages)
],
db_path=db_path,
)
return list_current_match_player_stats(
server_key="comunidad-hispana-01",
db_path=db_path,
)
def _player_stats_by_name(stats: dict[str, object]) -> dict[str, dict[str, object]]:
return {
str(item["player_name"]): item
for item in stats["items"]
if isinstance(item, dict)
}
def _count_player_stats_by_name(stats: dict[str, object], player_name: str) -> int:
return sum(
1
for item in stats["items"]
if isinstance(item, dict) and item.get("player_name") == player_name
)
@@ -5,65 +5,26 @@ from __future__ import annotations
import io
import json
import os
import sqlite3
import unittest
from contextlib import nullcontext, redirect_stdout
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import patch
import app.historical_runner as historical_runner_module
from app.config import (
get_historical_refresh_interval_seconds,
get_historical_refresh_max_retries,
get_historical_refresh_retry_delay_seconds,
get_public_full_refresh_enabled,
get_public_full_refresh_time,
get_public_full_refresh_timezone,
get_public_historical_monthly_refresh_hour_interval,
get_public_historical_monthly_refresh_minute,
get_public_historical_weekly_refresh_minute,
get_public_ranking_monthly_refresh_times,
get_public_ranking_refresh_interval_seconds,
get_public_ranking_weekly_refresh_minute,
get_public_recent_matches_refresh_interval_seconds,
)
from app.payloads import (
build_historical_server_summary_payload,
build_leaderboard_snapshot_payload,
build_recent_historical_matches_payload,
build_recent_historical_matches_snapshot_payload,
)
from app.historical_runner import (
_maybe_run_public_read_model_refreshes,
_json_default,
_run_refresh_with_retries,
get_next_public_full_refresh_at,
run_public_refresh_job_once,
run_periodic_historical_refresh,
)
from app.historical_runner import _run_refresh_with_retries, run_periodic_historical_refresh
from app.historical_snapshots import _normalize_snapshot_limit
from app.historical_storage import list_monthly_mvp_v2_ranking
from app.postgres_display_storage import _json_payload_default
from app.rcon_historical_read_model import (
_calculate_coverage_hours,
_calculate_duration_seconds,
)
from app.rcon_historical_leaderboards import _dedupe_snapshot_rows
from app.rcon_historical_leaderboards import initialize_ranking_snapshot_storage
from app.rcon_historical_leaderboards import list_rcon_materialized_leaderboard
class HistoricalSnapshotRefreshTests(unittest.TestCase):
def setUp(self) -> None:
historical_runner_module._LAST_PUBLIC_FULL_REFRESH_LOCAL_DATE = None
historical_runner_module._LAST_PUBLIC_RECENT_MATCHES_REFRESH_AT = None
historical_runner_module._LAST_PUBLIC_RANKING_WEEKLY_REFRESH_SLOT = None
historical_runner_module._LAST_PUBLIC_RANKING_MONTHLY_REFRESH_SLOT = None
historical_runner_module._LAST_PUBLIC_HISTORICAL_WEEKLY_REFRESH_SLOT = None
historical_runner_module._LAST_PUBLIC_HISTORICAL_MONTHLY_REFRESH_SLOT = None
historical_runner_module._PUBLIC_REFRESH_IN_PROGRESS.clear()
def test_runner_numeric_env_values_are_parsed_before_use(self) -> None:
with patch.dict(
os.environ,
@@ -90,548 +51,6 @@ class HistoricalSnapshotRefreshTests(unittest.TestCase):
):
get_historical_refresh_interval_seconds()
def test_public_refresh_env_values_are_parsed_before_use(self) -> None:
with patch.dict(
os.environ,
{
"HLL_PUBLIC_FULL_REFRESH_ENABLED": "true",
"HLL_PUBLIC_FULL_REFRESH_TIME": "06:00",
"HLL_PUBLIC_FULL_REFRESH_TIMEZONE": "Europe/Madrid",
"HLL_PUBLIC_RANKING_REFRESH_INTERVAL_SECONDS": "900",
"HLL_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS": "60",
"HLL_PUBLIC_RANKING_WEEKLY_REFRESH_MINUTE": "10",
"HLL_PUBLIC_RANKING_MONTHLY_REFRESH_TIMES": "07:00,19:00",
"HLL_PUBLIC_HISTORICAL_WEEKLY_REFRESH_MINUTE": "25",
"HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_MINUTE": "40",
"HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_HOUR_INTERVAL": "2",
},
clear=False,
):
self.assertTrue(get_public_full_refresh_enabled())
self.assertEqual(get_public_full_refresh_time(), "06:00")
self.assertEqual(get_public_full_refresh_timezone(), "Europe/Madrid")
self.assertEqual(get_public_ranking_refresh_interval_seconds(), 900)
self.assertEqual(get_public_recent_matches_refresh_interval_seconds(), 60)
self.assertEqual(get_public_ranking_weekly_refresh_minute(), 10)
self.assertEqual(get_public_ranking_monthly_refresh_times(), ("07:00", "19:00"))
self.assertEqual(get_public_historical_weekly_refresh_minute(), 25)
self.assertEqual(get_public_historical_monthly_refresh_minute(), 40)
self.assertEqual(get_public_historical_monthly_refresh_hour_interval(), 2)
def test_next_public_full_refresh_uses_madrid_six_am(self) -> None:
with patch.dict(
os.environ,
{
"HLL_PUBLIC_FULL_REFRESH_TIME": "06:00",
"HLL_PUBLIC_FULL_REFRESH_TIMEZONE": "Europe/Madrid",
},
clear=False,
):
next_refresh = get_next_public_full_refresh_at(
now=datetime(2026, 6, 10, 3, 30, tzinfo=timezone.utc),
)
self.assertEqual(
next_refresh,
datetime(2026, 6, 10, 4, 0, tzinfo=timezone.utc),
)
def test_public_scheduler_runs_hourly_weekly_ranking_job(self) -> None:
historical_runner_module._LAST_PUBLIC_RECENT_MATCHES_REFRESH_AT = datetime(
2026, 6, 10, 6, 10, tzinfo=timezone.utc
)
historical_runner_module._LAST_PUBLIC_RANKING_MONTHLY_REFRESH_SLOT = "2026-06-10T05:00:00Z"
historical_runner_module._LAST_PUBLIC_HISTORICAL_WEEKLY_REFRESH_SLOT = "2026-06-10T05:25:00Z"
historical_runner_module._LAST_PUBLIC_HISTORICAL_MONTHLY_REFRESH_SLOT = "2026-06-10T04:40:00Z"
with (
patch.dict(
os.environ,
{
"HLL_PUBLIC_FULL_REFRESH_ENABLED": "false",
"HLL_PUBLIC_RANKING_WEEKLY_REFRESH_MINUTE": "10",
"HLL_PUBLIC_RANKING_MONTHLY_REFRESH_TIMES": "07:00,19:00",
"HLL_PUBLIC_HISTORICAL_WEEKLY_REFRESH_MINUTE": "25",
"HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_MINUTE": "40",
"HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_HOUR_INTERVAL": "2",
"HLL_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS": "3600",
},
clear=False,
),
patch(
"app.historical_runner.refresh_public_weekly_ranking_snapshots",
return_value={"status": "ok"},
) as weekly_ranking_refresh,
):
payload = _maybe_run_public_read_model_refreshes(
run_number=3,
now=datetime(2026, 6, 10, 6, 10, tzinfo=timezone.utc),
)
self.assertEqual(payload["status"], "ok")
self.assertIn("ranking_weekly_snapshot_result", payload)
weekly_ranking_refresh.assert_called_once()
def test_public_scheduler_runs_bi_hourly_monthly_historical_job(self) -> None:
historical_runner_module._LAST_PUBLIC_RECENT_MATCHES_REFRESH_AT = datetime(
2026, 6, 10, 6, 40, tzinfo=timezone.utc
)
historical_runner_module._LAST_PUBLIC_RANKING_MONTHLY_REFRESH_SLOT = "2026-06-10T05:00:00Z"
historical_runner_module._LAST_PUBLIC_RANKING_WEEKLY_REFRESH_SLOT = "2026-06-10T06:10:00Z"
historical_runner_module._LAST_PUBLIC_HISTORICAL_WEEKLY_REFRESH_SLOT = "2026-06-10T06:25:00Z"
with (
patch.dict(
os.environ,
{
"HLL_PUBLIC_FULL_REFRESH_ENABLED": "false",
"HLL_PUBLIC_RANKING_WEEKLY_REFRESH_MINUTE": "10",
"HLL_PUBLIC_RANKING_MONTHLY_REFRESH_TIMES": "07:00,19:00",
"HLL_PUBLIC_HISTORICAL_WEEKLY_REFRESH_MINUTE": "25",
"HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_MINUTE": "40",
"HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_HOUR_INTERVAL": "2",
"HLL_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS": "3600",
},
clear=False,
),
patch(
"app.historical_runner.refresh_public_monthly_historical_snapshots",
return_value={"status": "ok"},
) as historical_monthly_refresh,
):
payload = _maybe_run_public_read_model_refreshes(
run_number=4,
now=datetime(2026, 6, 10, 6, 40, tzinfo=timezone.utc),
)
self.assertEqual(payload["status"], "ok")
self.assertIn("historical_monthly_snapshot_result", payload)
historical_monthly_refresh.assert_called_once()
def test_public_scheduler_skips_lower_priority_jobs_when_heavy_job_runs(self) -> None:
historical_runner_module._LAST_PUBLIC_RECENT_MATCHES_REFRESH_AT = datetime(
2026, 6, 10, 5, 30, tzinfo=timezone.utc
)
with (
patch.dict(
os.environ,
{
"HLL_PUBLIC_FULL_REFRESH_ENABLED": "false",
"HLL_PUBLIC_RANKING_WEEKLY_REFRESH_MINUTE": "10",
"HLL_PUBLIC_RANKING_MONTHLY_REFRESH_TIMES": "07:00,19:00",
"HLL_PUBLIC_HISTORICAL_WEEKLY_REFRESH_MINUTE": "25",
"HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_MINUTE": "40",
"HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_HOUR_INTERVAL": "2",
"HLL_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS": "3600",
},
clear=False,
),
patch(
"app.historical_runner.refresh_public_monthly_ranking_snapshots",
return_value={"status": "ok"},
) as monthly_ranking_refresh,
patch("app.historical_runner.refresh_public_weekly_ranking_snapshots") as weekly_ranking_refresh,
patch("app.historical_runner.refresh_public_weekly_historical_snapshots") as weekly_historical_refresh,
):
payload = _maybe_run_public_read_model_refreshes(
run_number=5,
now=datetime(2026, 6, 10, 5, 30, tzinfo=timezone.utc),
)
self.assertEqual(payload["ranking_monthly_snapshot_result"]["status"], "ok")
self.assertEqual(payload["ranking_weekly_snapshot_result"]["status"], "skipped")
self.assertEqual(payload["historical_weekly_snapshot_result"]["status"], "skipped")
monthly_ranking_refresh.assert_called_once()
weekly_ranking_refresh.assert_not_called()
weekly_historical_refresh.assert_not_called()
def test_manual_public_job_runner_routes_to_supported_job(self) -> None:
with patch(
"app.historical_runner.refresh_public_weekly_historical_snapshots",
return_value={"status": "ok", "job_name": "public-historical-weekly"},
) as weekly_historical_refresh:
result = run_public_refresh_job_once(
"historical-weekly",
now=datetime(2026, 6, 10, 6, 25, tzinfo=timezone.utc),
)
self.assertEqual(result["job_name"], "public-historical-weekly")
weekly_historical_refresh.assert_called_once()
def test_historical_runner_json_default_serializes_datetime_payloads(self) -> None:
payload = {
"status": "ok",
"data": {
"generated_at": datetime(2026, 6, 11, 8, 0, tzinfo=timezone.utc),
},
}
encoded = json.loads(json.dumps(payload, default=_json_default))
self.assertEqual(encoded["data"]["generated_at"], "2026-06-11T08:00:00Z")
def test_ranking_snapshot_dedupes_duplicate_player_rows(self) -> None:
rows = [
{"player_id": "player-1", "player_name": "Alpha", "metric_value": 20},
{"player_id": "player-1", "player_name": "Alpha duplicate", "metric_value": 19},
{"player_id": "player-2", "player_name": "Bravo", "metric_value": 18},
]
deduplicated_rows = _dedupe_snapshot_rows(rows)
self.assertEqual([row["player_id"] for row in deduplicated_rows], ["player-1", "player-2"])
self.assertEqual(deduplicated_rows[0]["player_name"], "Alpha")
def test_monthly_mvp_v2_missing_player_event_ledger_returns_empty_payload(self) -> None:
monthly_window = {
"window_start": datetime(2026, 6, 1, tzinfo=timezone.utc),
"window_end": datetime(2026, 6, 11, tzinfo=timezone.utc),
"window_kind": "current-month",
"window_label": "Mes activo",
"uses_fallback": False,
"selection_reason": "test",
"current_month_closed_matches": 4,
"previous_month_closed_matches": 8,
"minimum_closed_matches": 3,
"current_month_has_sufficient_sample": True,
"is_early_month": False,
}
with (
patch("app.historical_storage.initialize_historical_storage", return_value=Path("dummy.sqlite3")),
patch("app.historical_storage._select_monthly_window", return_value=monthly_window),
patch(
"app.historical_storage._get_monthly_player_event_coverage",
side_effect=sqlite3.OperationalError("no such table: player_event_raw_ledger"),
),
):
payload = list_monthly_mvp_v2_ranking(server_id="all-servers", limit=10)
self.assertFalse(payload["event_coverage"]["ready"])
self.assertEqual(payload["event_coverage"]["reason"], "player-event-raw-ledger-missing")
self.assertEqual(payload["items"], [])
def test_initialize_ranking_snapshot_storage_uses_materialized_initializer_without_invalid_keyword(self) -> None:
db_path = Path("dummy.sqlite3")
with (
patch(
"app.rcon_historical_leaderboards.initialize_rcon_materialized_storage",
return_value=db_path,
) as materialized_initializer,
patch("app.rcon_historical_leaderboards.use_postgres_rcon_storage", return_value=False),
patch(
"app.rcon_historical_leaderboards.connect_sqlite_writer",
return_value=sqlite3.connect(":memory:"),
),
):
resolved_path = initialize_ranking_snapshot_storage(db_path=db_path, ensure_storage=True)
materialized_initializer.assert_called_once_with(db_path=db_path)
self.assertEqual(resolved_path, db_path)
def test_initialize_ranking_snapshot_storage_skips_materialized_initializer_when_disabled(self) -> None:
db_path = Path("dummy.sqlite3")
with (
patch("app.rcon_historical_leaderboards.initialize_rcon_materialized_storage") as materialized_initializer,
patch("app.rcon_historical_leaderboards.use_postgres_rcon_storage", return_value=False),
patch(
"app.rcon_historical_leaderboards.connect_sqlite_writer",
return_value=sqlite3.connect(":memory:"),
),
):
resolved_path = initialize_ranking_snapshot_storage(db_path=db_path, ensure_storage=False)
materialized_initializer.assert_not_called()
self.assertEqual(resolved_path, db_path)
def test_list_rcon_materialized_leaderboard_skips_materialized_initializer_when_disabled(self) -> None:
db_path = Path("dummy.sqlite3")
window = {
"start": datetime(2026, 6, 9, tzinfo=timezone.utc),
"end": datetime(2026, 6, 10, tzinfo=timezone.utc),
"days": 1,
"kind": "current-week",
"label": "Semana actual",
"selection_reason": "test",
"current_week_start": datetime(2026, 6, 9, tzinfo=timezone.utc),
"current_week_closed_matches": 1,
"previous_week_closed_matches": 2,
"current_month_start": datetime(2026, 6, 1, tzinfo=timezone.utc),
"selected_month_start": datetime(2026, 6, 1, tzinfo=timezone.utc),
"selected_month_end": datetime(2026, 6, 10, tzinfo=timezone.utc),
"current_month_closed_matches": 3,
"previous_month_closed_matches": 4,
"sufficient_sample": {"minimum_closed_matches": 1},
}
fake_connection = object()
with (
patch("app.rcon_historical_leaderboards.initialize_rcon_materialized_storage") as materialized_initializer,
patch(
"app.rcon_historical_leaderboards._connect_scope",
return_value=nullcontext(fake_connection),
) as connect_scope,
patch("app.rcon_historical_leaderboards.select_leaderboard_window", return_value=window),
patch("app.rcon_historical_leaderboards._fetch_leaderboard_rows", return_value=[]),
patch(
"app.rcon_historical_leaderboards._fetch_source_range",
return_value=(None, None),
),
):
payload = list_rcon_materialized_leaderboard(
server_key="all-servers",
timeframe="weekly",
metric="kills",
limit=10,
ensure_storage=False,
db_path=db_path,
now=datetime(2026, 6, 10, tzinfo=timezone.utc),
)
materialized_initializer.assert_not_called()
connect_scope.assert_called_once_with(db_path, db_path=db_path, initialize=False)
self.assertEqual(payload["items"], [])
def test_historical_leaderboard_snapshot_does_not_runtime_enrich_public_request(self) -> None:
snapshot = {
"generated_at": "2026-06-10T04:00:00Z",
"source_range_start": "2026-06-09T00:00:00Z",
"source_range_end": "2026-06-10T00:00:00Z",
"is_stale": False,
"payload": {
"items": [
{
"ranking_position": 1,
"player": {"name": "Player One"},
"metric_value": 12,
"matches_considered": 1,
"kills": 12,
}
],
"window_start": "2026-06-09T00:00:00Z",
"window_end": "2026-06-10T00:00:00Z",
"limit": 10,
},
}
with (
patch("app.payloads._get_historical_snapshot_record", return_value=snapshot),
patch("app.payloads._load_runtime_leaderboard_items") as runtime_loader,
):
payload = build_leaderboard_snapshot_payload(
server_id="all-servers",
timeframe="weekly",
metric="kills",
limit=10,
)
runtime_loader.assert_not_called()
self.assertEqual(payload["data"]["items"][0]["metric_value"], 12)
self.assertFalse(payload["data"]["runtime_enrichment"]["applied"])
def test_recent_matches_snapshot_does_not_complete_from_public_scoreboard(self) -> None:
snapshot = {
"generated_at": "2026-06-10T04:00:00Z",
"source_range_start": "2026-06-09T21:00:00Z",
"source_range_end": "2026-06-09T22:00:00Z",
"is_stale": False,
"payload": {
"items": [
{
"match_id": "match-1",
"closed_at": "2026-06-09T22:00:00Z",
}
],
"limit": 100,
},
}
with (
patch("app.payloads._get_historical_snapshot_record", return_value=snapshot),
patch("app.payloads.get_historical_data_source_kind", return_value="rcon"),
patch("app.payloads.list_recent_historical_matches") as fallback_loader,
):
payload = build_recent_historical_matches_snapshot_payload(
server_slug="all-servers",
limit=100,
)
fallback_loader.assert_not_called()
self.assertEqual(len(payload["data"]["items"]), 1)
self.assertFalse(payload["data"].get("fallback_used", False))
def test_legacy_recent_matches_uses_snapshot_fast_path(self) -> None:
snapshot = {
"generated_at": "2026-06-10T04:00:00Z",
"source_range_start": "2026-06-09T21:00:00Z",
"source_range_end": "2026-06-09T22:00:00Z",
"is_stale": False,
"payload": {
"items": [
{
"match_id": "match-1",
"closed_at": "2026-06-09T22:00:00Z",
}
],
"limit": 100,
},
}
with (
patch("app.payloads._get_historical_snapshot_record", return_value=snapshot),
patch("app.payloads.get_historical_data_source_kind", return_value="rcon"),
patch("app.payloads.get_rcon_historical_read_model") as rcon_loader,
patch("app.payloads.list_recent_historical_matches") as fallback_loader,
):
payload = build_recent_historical_matches_payload(
server_slug="comunidad-hispana-01",
limit=20,
)
rcon_loader.assert_not_called()
fallback_loader.assert_not_called()
self.assertEqual(payload["data"]["context"], "historical-recent-matches")
self.assertEqual(payload["data"]["legacy_endpoint_policy"], "snapshot-read-only-fast-path")
self.assertEqual(payload["data"]["server_slug"], "comunidad-hispana-01")
self.assertEqual(payload["data"]["items"][0]["match_id"], "match-1")
def test_legacy_second_server_recent_matches_uses_snapshot_fast_path(self) -> None:
snapshot = {
"generated_at": "2026-06-10T04:00:00Z",
"source_range_start": "2026-06-09T21:00:00Z",
"source_range_end": "2026-06-09T22:00:00Z",
"is_stale": False,
"payload": {
"items": [
{
"match_id": "match-2",
"closed_at": "2026-06-09T22:00:00Z",
}
],
"limit": 100,
},
}
with (
patch("app.payloads._get_historical_snapshot_record", return_value=snapshot),
patch("app.payloads.get_historical_data_source_kind", return_value="rcon"),
patch("app.payloads.get_rcon_historical_read_model") as rcon_loader,
patch("app.payloads.list_recent_historical_matches") as fallback_loader,
):
payload = build_recent_historical_matches_payload(
server_slug="comunidad-hispana-02",
limit=20,
)
rcon_loader.assert_not_called()
fallback_loader.assert_not_called()
self.assertEqual(payload["data"]["server_slug"], "comunidad-hispana-02")
self.assertEqual(payload["data"]["items"][0]["match_id"], "match-2")
def test_legacy_all_servers_recent_matches_still_uses_snapshot_fast_path(self) -> None:
snapshot = {
"generated_at": "2026-06-10T04:00:00Z",
"source_range_start": "2026-06-09T21:00:00Z",
"source_range_end": "2026-06-09T22:00:00Z",
"is_stale": False,
"payload": {
"items": [
{
"match_id": "match-all",
"closed_at": "2026-06-09T22:00:00Z",
}
],
"limit": 100,
},
}
with (
patch("app.payloads._get_historical_snapshot_record", return_value=snapshot),
patch("app.payloads.get_historical_data_source_kind", return_value="rcon"),
patch("app.payloads.get_rcon_historical_read_model") as rcon_loader,
patch("app.payloads.list_recent_historical_matches") as fallback_loader,
):
payload = build_recent_historical_matches_payload(
server_slug="all-servers",
limit=20,
)
rcon_loader.assert_not_called()
fallback_loader.assert_not_called()
self.assertEqual(payload["data"]["server_slug"], "all-servers")
self.assertEqual(payload["data"]["items"][0]["match_id"], "match-all")
def test_legacy_server_summary_uses_snapshot_fast_path(self) -> None:
snapshot = {
"generated_at": "2026-06-10T04:00:00Z",
"source_range_start": "2026-06-09T00:00:00Z",
"source_range_end": "2026-06-10T00:00:00Z",
"is_stale": False,
"payload": {
"item": {
"server": {"slug": "comunidad-hispana-01", "name": "Comunidad Hispana #01"},
"matches_count": 12,
},
},
}
with (
patch("app.payloads._get_historical_snapshot_record", return_value=snapshot),
patch("app.payloads.get_historical_data_source_kind", return_value="rcon"),
patch("app.payloads.get_rcon_historical_read_model") as rcon_loader,
patch("app.payloads.list_historical_server_summaries") as fallback_loader,
):
payload = build_historical_server_summary_payload(server_slug="comunidad-hispana-01")
rcon_loader.assert_not_called()
fallback_loader.assert_not_called()
self.assertEqual(payload["data"]["context"], "historical-server-summary")
self.assertEqual(payload["data"]["legacy_endpoint_policy"], "snapshot-read-only-fast-path")
self.assertEqual(payload["data"]["server_slug"], "comunidad-hispana-01")
self.assertEqual(payload["data"]["items"][0]["matches_count"], 12)
def test_legacy_second_server_summary_uses_snapshot_fast_path(self) -> None:
snapshot = {
"generated_at": "2026-06-10T04:00:00Z",
"source_range_start": "2026-06-09T00:00:00Z",
"source_range_end": "2026-06-10T00:00:00Z",
"is_stale": False,
"payload": {
"item": {
"server": {"slug": "comunidad-hispana-02", "name": "Comunidad Hispana #02"},
"matches_count": 8,
},
},
}
with (
patch("app.payloads._get_historical_snapshot_record", return_value=snapshot),
patch("app.payloads.get_historical_data_source_kind", return_value="rcon"),
patch("app.payloads.get_rcon_historical_read_model") as rcon_loader,
patch("app.payloads.list_historical_server_summaries") as fallback_loader,
):
payload = build_historical_server_summary_payload(server_slug="comunidad-hispana-02")
rcon_loader.assert_not_called()
fallback_loader.assert_not_called()
self.assertEqual(payload["data"]["server_slug"], "comunidad-hispana-02")
self.assertEqual(payload["data"]["items"][0]["matches_count"], 8)
def test_legacy_all_servers_summary_still_uses_snapshot_fast_path(self) -> None:
snapshot = {
"generated_at": "2026-06-10T04:00:00Z",
"source_range_start": "2026-06-09T00:00:00Z",
"source_range_end": "2026-06-10T00:00:00Z",
"is_stale": False,
"payload": {
"item": {
"server": {"slug": "all-servers", "name": "Todos los servidores"},
"matches_count": 20,
},
},
}
with (
patch("app.payloads._get_historical_snapshot_record", return_value=snapshot),
patch("app.payloads.get_historical_data_source_kind", return_value="rcon"),
patch("app.payloads.get_rcon_historical_read_model") as rcon_loader,
patch("app.payloads.list_historical_server_summaries") as fallback_loader,
):
payload = build_historical_server_summary_payload(server_slug="all-servers")
rcon_loader.assert_not_called()
fallback_loader.assert_not_called()
self.assertEqual(payload["data"]["server_slug"], "all-servers")
self.assertEqual(payload["data"]["items"][0]["matches_count"], 20)
def test_rcon_coverage_accepts_postgres_datetime_values(self) -> None:
start = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc)
end = datetime(2026, 5, 21, 11, 30, tzinfo=timezone.utc)
@@ -702,107 +121,6 @@ class HistoricalSnapshotRefreshTests(unittest.TestCase):
self.assertIn('"status": "ok"', stream.getvalue())
self.assertIn('"captured_at": "2026-05-22 00:00:00+00:00"', stream.getvalue())
def test_runner_continues_when_legacy_snapshot_refresh_fails(self) -> None:
with (
patch("app.historical_runner.backend_writer_lock", return_value=nullcontext()),
patch("app.historical_runner._run_primary_rcon_capture", return_value={"status": "ok", "targets": []}),
patch(
"app.historical_runner._resolve_classic_fallback_policy",
return_value=(False, "validation-rcon-primary-cycle"),
),
patch("app.historical_runner._rcon_capture_has_new_useful_data", return_value=True),
patch(
"app.historical_runner.generate_historical_snapshots",
side_effect=RuntimeError("legacy snapshot failure"),
),
patch(
"app.historical_runner._build_elo_mmr_rebuild_policy",
return_value={
"due": False,
"policy": "validation-policy",
"last_generated_at": None,
"samples_since_last_rebuild": 1,
"minutes_since_last_rebuild": None,
"rebuild_interval_minutes": 60,
"min_new_samples": 10,
},
),
patch("app.historical_runner.refresh_player_search_index", return_value={"status": "ok"}) as search_refresh,
patch("app.historical_runner.refresh_player_period_stats", return_value={"status": "ok"}) as period_refresh,
patch("app.historical_runner.refresh_ranking_snapshots", return_value={"status": "ok"}) as ranking_refresh,
patch(
"app.historical_runner._maybe_run_database_maintenance",
return_value={"status": "skipped", "reason": "disabled"},
),
):
result = _run_refresh_with_retries(
max_retries=0,
retry_delay_seconds=0,
server_slug=None,
max_pages=None,
page_size=None,
run_number=1,
)
self.assertEqual(result["status"], "partial")
self.assertEqual(result["historical_snapshot_result"]["status"], "error")
self.assertEqual(result["snapshot_result"]["status"], "error")
self.assertEqual(
result["historical_snapshot_result"]["error"],
"legacy snapshot failure",
)
search_refresh.assert_called_once()
period_refresh.assert_called_once()
ranking_refresh.assert_called_once()
def test_runner_returns_ok_when_legacy_snapshot_and_read_models_succeed(self) -> None:
with (
patch("app.historical_runner.backend_writer_lock", return_value=nullcontext()),
patch("app.historical_runner._run_primary_rcon_capture", return_value={"status": "ok", "targets": []}),
patch(
"app.historical_runner._resolve_classic_fallback_policy",
return_value=(False, "validation-rcon-primary-cycle"),
),
patch("app.historical_runner._rcon_capture_has_new_useful_data", return_value=True),
patch(
"app.historical_runner.generate_historical_snapshots",
return_value={"status": "ok", "generated_at": "2026-06-09T08:00:00Z"},
),
patch(
"app.historical_runner._build_elo_mmr_rebuild_policy",
return_value={
"due": False,
"policy": "validation-policy",
"last_generated_at": None,
"samples_since_last_rebuild": 1,
"minutes_since_last_rebuild": None,
"rebuild_interval_minutes": 60,
"min_new_samples": 10,
},
),
patch("app.historical_runner.refresh_player_search_index", return_value={"status": "ok"}),
patch("app.historical_runner.refresh_player_period_stats", return_value={"status": "ok"}),
patch("app.historical_runner.refresh_ranking_snapshots", return_value={"status": "ok"}),
patch(
"app.historical_runner._maybe_run_database_maintenance",
return_value={"status": "skipped", "reason": "disabled"},
),
):
result = _run_refresh_with_retries(
max_retries=0,
retry_delay_seconds=0,
server_slug=None,
max_pages=None,
page_size=None,
run_number=1,
)
self.assertEqual(result["status"], "ok")
self.assertEqual(result["historical_snapshot_result"]["status"], "ok")
self.assertIn("player_search_index_result", result)
self.assertIn("player_period_stats_result", result)
self.assertIn("ranking_snapshot_result", result)
if __name__ == "__main__":
unittest.main()
@@ -1,33 +0,0 @@
import unittest
from contextlib import nullcontext
from unittest.mock import patch
from app.rcon_historical_leaderboards import get_latest_ranking_snapshot
class RankingSnapshotPayloadTests(unittest.TestCase):
def test_get_latest_ranking_snapshot_skips_storage_init_on_postgres_read(self):
with (
patch("app.rcon_historical_leaderboards.use_postgres_rcon_storage", return_value=True),
patch("app.rcon_historical_leaderboards.initialize_ranking_snapshot_storage") as init_mock,
patch(
"app.rcon_historical_leaderboards._open_ranking_snapshot_read_connection",
return_value=nullcontext(object()),
),
patch("app.rcon_historical_leaderboards._find_latest_snapshot", return_value=None),
):
result = get_latest_ranking_snapshot(
server_key="all",
timeframe="weekly",
metric="kills",
limit=20,
)
init_mock.assert_not_called()
self.assertEqual(result["snapshot_status"], "missing")
self.assertEqual(result["source"], "ranking-snapshot")
self.assertEqual(result["requested_limit"], 20)
if __name__ == "__main__":
unittest.main()
@@ -1,438 +0,0 @@
from __future__ import annotations
import os
import sqlite3
import tempfile
import unittest
from contextlib import closing, contextmanager
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from app import postgres_rcon_storage
from app import rcon_admin_log_storage
from app.config import (
get_current_match_adminlog_enabled,
get_current_match_adminlog_interval_seconds,
get_current_match_adminlog_lookback_seconds,
)
from app.rcon_current_match_worker import (
list_current_match_trusted_targets,
run_current_match_adminlog_refresh_loop,
run_current_match_adminlog_refresh_once_unlocked,
)
TARGET_01 = SimpleNamespace(
external_server_id="comunidad-hispana-01",
name="Comunidad Hispana #01",
host="203.0.113.10",
port=7779,
password="secret-01",
region="ES",
game_port=None,
query_port=None,
source_name="community-hispana-rcon",
)
TARGET_02 = SimpleNamespace(
external_server_id="comunidad-hispana-02",
name="Comunidad Hispana #02",
host="203.0.113.11",
port=7879,
password="secret-02",
region="ES",
game_port=None,
query_port=None,
source_name="community-hispana-rcon",
)
TARGET_03 = SimpleNamespace(
external_server_id="comunidad-hispana-03",
name="Comunidad Hispana #03",
host="203.0.113.12",
port=7979,
password="secret-03",
region="ES",
game_port=None,
query_port=None,
source_name="community-hispana-rcon",
)
class RconCurrentMatchWorkerTests(unittest.TestCase):
def test_list_current_match_trusted_targets_filters_only_01_and_02(self) -> None:
with patch(
"app.rcon_current_match_worker.load_rcon_targets",
return_value=(TARGET_01, TARGET_02, TARGET_03),
):
selected = list_current_match_trusted_targets()
self.assertEqual(
[target.external_server_id for target in selected],
["comunidad-hispana-01", "comunidad-hispana-02"],
)
def test_once_unlocked_calls_existing_persistence_path(self) -> None:
fetch_calls: list[object] = []
persist_calls: list[dict[str, object]] = []
def fake_fetch(target, *, lookback_seconds, timeout_seconds):
fetch_calls.append((target.external_server_id, lookback_seconds, timeout_seconds))
return [{"timestamp": "2026-06-18T18:00:00Z", "message": "[1 (1)] Killer(Allies) -> Victim(Axis) with Rifle"}]
def fake_persist(*, target, entries, db_path=None, ensure_storage=True):
persist_calls.append({"target": target, "entries": entries, "db_path": db_path})
return {
"events_seen": len(entries),
"events_inserted": len(entries),
"duplicate_events": 0,
}
result = run_current_match_adminlog_refresh_once_unlocked(
lookback_seconds=180,
targets=[TARGET_01],
fetch_entries_fn=fake_fetch,
persist_entries_fn=fake_persist,
)
self.assertEqual(fetch_calls, [("comunidad-hispana-01", 180, None)])
self.assertEqual(len(persist_calls), 1)
self.assertEqual(persist_calls[0]["target"]["target_key"], "comunidad-hispana-01")
self.assertEqual(persist_calls[0]["db_path"], None)
self.assertEqual(result["totals"]["events_inserted"], 1)
self.assertEqual(result["status"], "ok")
def test_once_unlocked_initializes_storage_once_and_persists_without_repeated_ddl(self) -> None:
persist_calls: list[dict[str, object]] = []
def fake_fetch(target, *, lookback_seconds, timeout_seconds):
return [{"timestamp": "2026-06-18T18:00:00Z", "message": "[1 (1)] Killer(Allies) -> Victim(Axis) with Rifle"}]
def fake_persist(*, target, entries, db_path=None, ensure_storage=True):
persist_calls.append(
{
"target": target,
"entries": entries,
"db_path": db_path,
"ensure_storage": ensure_storage,
}
)
return {
"events_seen": len(entries),
"events_inserted": len(entries),
"duplicate_events": 0,
}
with patch("app.rcon_current_match_worker.initialize_rcon_admin_log_storage") as initialize:
result = run_current_match_adminlog_refresh_once_unlocked(
lookback_seconds=180,
targets=[TARGET_01],
fetch_entries_fn=fake_fetch,
persist_entries_fn=fake_persist,
)
initialize.assert_called_once_with(db_path=None)
self.assertEqual(persist_calls[0]["ensure_storage"], False)
self.assertEqual(result["status"], "ok")
def test_failing_target_does_not_block_other_target(self) -> None:
def fake_fetch(target, *, lookback_seconds, timeout_seconds):
if target.external_server_id == "comunidad-hispana-01":
raise RuntimeError("boom")
return [{"timestamp": "2026-06-18T18:00:00Z", "message": "[1 (1)] Killer(Allies) -> Victim(Axis) with Rifle"}]
def fake_persist(*, target, entries, db_path=None, ensure_storage=True):
return {
"events_seen": len(entries),
"events_inserted": len(entries),
"duplicate_events": 0,
}
result = run_current_match_adminlog_refresh_once_unlocked(
lookback_seconds=180,
targets=[TARGET_01, TARGET_02],
fetch_entries_fn=fake_fetch,
persist_entries_fn=fake_persist,
)
self.assertEqual(result["status"], "partial")
self.assertEqual(result["totals"]["failed_targets"], 1)
self.assertEqual(result["totals"]["events_inserted"], 1)
self.assertEqual(len(result["targets"]), 1)
self.assertEqual(result["targets"][0]["target_key"], "comunidad-hispana-02")
def test_overlapping_windows_remain_idempotent_via_existing_persistence(self) -> None:
entry = {
"timestamp": "2026-06-18T18:00:00Z",
"message": (
"[5:00 min (321)] KILL: Alpha(Allies/76561198000000001) -> "
"Bravo(Axis/76561198000000002) with M1 GARAND"
),
}
with tempfile.TemporaryDirectory() as temp_dir:
db_path = os.path.join(temp_dir, "current_match.sqlite3")
def fake_fetch(target, *, lookback_seconds, timeout_seconds):
return [entry]
first = run_current_match_adminlog_refresh_once_unlocked(
lookback_seconds=180,
targets=[TARGET_01],
fetch_entries_fn=fake_fetch,
db_path=db_path,
)
second = run_current_match_adminlog_refresh_once_unlocked(
lookback_seconds=180,
targets=[TARGET_01],
fetch_entries_fn=fake_fetch,
db_path=db_path,
)
self.assertEqual(first["totals"]["events_inserted"], 1)
self.assertEqual(first["totals"]["duplicate_events"], 0)
self.assertEqual(second["totals"]["events_inserted"], 0)
self.assertEqual(second["totals"]["duplicate_events"], 1)
def test_live_worker_starts_when_duplicate_historical_runs_exist(self) -> None:
entry = {
"timestamp": "2026-06-18T18:00:00Z",
"message": (
"[5:00 min (321)] KILL: Alpha(Allies/steam-alpha) -> "
"Victim(Axis/steam-victim) with Rifle"
),
}
with tempfile.TemporaryDirectory() as temp_dir:
db_path = os.path.join(temp_dir, "current_match.sqlite3")
rcon_admin_log_storage.initialize_rcon_admin_log_storage(db_path=Path(db_path))
with closing(sqlite3.connect(db_path)) as connection:
connection.execute(
"""
INSERT INTO rcon_historical_capture_runs (
mode, status, target_scope, started_at
) VALUES (?, ?, ?, ?)
""",
("historical", "running", "all-configured-rcon-targets", "2099-01-01T00:00:00Z"),
)
connection.execute(
"""
INSERT INTO rcon_historical_capture_runs (
mode, status, target_scope, started_at
) VALUES (?, ?, ?, ?)
""",
("historical", "running", "all-configured-rcon-targets", "2099-01-01T00:05:00Z"),
)
connection.commit()
result = run_current_match_adminlog_refresh_once_unlocked(
lookback_seconds=180,
targets=[TARGET_01],
fetch_entries_fn=lambda *_args, **_kwargs: [entry],
db_path=db_path,
)
self.assertEqual(result["status"], "ok")
self.assertEqual(result["totals"]["events_inserted"], 1)
self.assertEqual(result["totals"]["failed_targets"], 0)
def test_live_worker_does_not_trigger_admin_log_materialization(self) -> None:
def fake_fetch(target, *, lookback_seconds, timeout_seconds):
return [
{
"timestamp": "2026-06-18T18:00:00Z",
"message": "[1:00 min (60)] CONNECTED Alpha (steam-alpha)",
}
]
with (
tempfile.TemporaryDirectory() as temp_dir,
patch("app.rcon_admin_log_materialization.materialize_rcon_admin_log") as materialize,
):
db_path = os.path.join(temp_dir, "current_match.sqlite3")
result = run_current_match_adminlog_refresh_once_unlocked(
lookback_seconds=180,
targets=[TARGET_01],
fetch_entries_fn=fake_fetch,
db_path=db_path,
)
materialize.assert_not_called()
self.assertEqual(result["status"], "ok")
def test_live_worker_does_not_use_historical_runtime_guard(self) -> None:
def fake_fetch(target, *, lookback_seconds, timeout_seconds):
return [
{
"timestamp": "2026-06-18T18:00:00Z",
"message": "[1:00 min (60)] CONNECTED Alpha (steam-alpha)",
}
]
def fake_persist(*, target, entries, db_path=None, ensure_storage=True):
return {
"events_seen": len(entries),
"events_inserted": len(entries),
"duplicate_events": 0,
}
with (
patch("app.rcon_current_match_worker.initialize_rcon_admin_log_storage"),
patch("app.rcon_historical_storage.historical_capture_runtime_guard") as guard,
):
result = run_current_match_adminlog_refresh_once_unlocked(
lookback_seconds=180,
targets=[TARGET_01],
fetch_entries_fn=fake_fetch,
persist_entries_fn=fake_persist,
)
guard.assert_not_called()
self.assertEqual(result["status"], "ok")
def test_loop_honors_max_runs(self) -> None:
results = [{"status": "ok"}, {"status": "ok"}]
with (
patch("app.rcon_current_match_worker.initialize_rcon_admin_log_storage") as initialize,
patch(
"app.rcon_current_match_worker.run_current_match_adminlog_refresh_once",
side_effect=results,
) as run_once,
patch("app.rcon_current_match_worker.time.sleep") as sleep,
):
run_current_match_adminlog_refresh_loop(
interval_seconds=5,
lookback_seconds=900,
max_runs=2,
)
initialize.assert_called_once_with()
self.assertEqual(run_once.call_count, 2)
self.assertEqual(sleep.call_count, 1)
def test_postgres_admin_log_storage_uses_admin_log_bootstrap_only(self) -> None:
with (
patch.object(rcon_admin_log_storage, "use_postgres_rcon_storage", return_value=True),
patch("app.postgres_rcon_storage.initialize_postgres_admin_log_storage") as admin_log_init,
patch("app.postgres_rcon_storage.initialize_postgres_rcon_storage") as full_init,
):
rcon_admin_log_storage.initialize_rcon_admin_log_storage()
admin_log_init.assert_called_once_with()
full_init.assert_not_called()
def test_postgres_schema_strings_no_longer_create_historical_running_unique_index(self) -> None:
self.assertNotIn(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_rcon_historical_single_running_historical",
postgres_rcon_storage.RCON_SCHEMA_SQL,
)
self.assertNotIn(
"idx_rcon_historical_single_running_historical",
postgres_rcon_storage.POSTGRES_ADMIN_LOG_SCHEMA_SQL,
)
self.assertIn(
"DROP INDEX IF EXISTS idx_rcon_historical_single_running_historical",
postgres_rcon_storage.DROP_LEGACY_HISTORICAL_GUARD_INDEX_SQL,
)
def test_initialize_postgres_rcon_storage_does_not_execute_removed_unique_index(self) -> None:
executed_sql: list[str] = []
with patch.object(
postgres_rcon_storage,
"connect_postgres",
return_value=_FakePostgresConnectionScope(executed_sql),
):
postgres_rcon_storage.initialize_postgres_rcon_storage()
executed_text = "\n".join(executed_sql)
self.assertIn(
"DROP INDEX IF EXISTS idx_rcon_historical_single_running_historical",
executed_text,
)
self.assertNotIn(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_rcon_historical_single_running_historical",
executed_text,
)
def test_compose_nas_runs_split_live_worker_and_safe_historical_interval(self) -> None:
compose_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
"..",
"deploy",
"portainer",
"docker-compose.nas.yml",
)
with open(compose_path, encoding="utf-8") as handle:
compose_text = handle.read()
self.assertIn("rcon-live-adminlog-worker:", compose_text)
self.assertIn("app.rcon_current_match_worker", compose_text)
self.assertIn('--lookback-minutes\n - "15"', compose_text)
self.assertIn('HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS:-900}', compose_text)
self.assertNotIn('HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS:-2}', compose_text)
def test_current_match_adminlog_config_defaults_and_overrides(self) -> None:
with _temporary_env(
CURRENT_MATCH_ADMINLOG_INTERVAL_SECONDS=None,
CURRENT_MATCH_ADMINLOG_LOOKBACK_SECONDS=None,
CURRENT_MATCH_ADMINLOG_ENABLED=None,
):
self.assertEqual(get_current_match_adminlog_interval_seconds(), 10)
self.assertEqual(get_current_match_adminlog_lookback_seconds(), 180)
self.assertIs(get_current_match_adminlog_enabled(), False)
with _temporary_env(
CURRENT_MATCH_ADMINLOG_INTERVAL_SECONDS="15",
CURRENT_MATCH_ADMINLOG_LOOKBACK_SECONDS="120",
CURRENT_MATCH_ADMINLOG_ENABLED="true",
):
self.assertEqual(get_current_match_adminlog_interval_seconds(), 15)
self.assertEqual(get_current_match_adminlog_lookback_seconds(), 120)
self.assertIs(get_current_match_adminlog_enabled(), True)
@contextmanager
def _temporary_env(**values: str | None):
previous = {name: os.environ.get(name) for name in values}
try:
for name, value in values.items():
if value is None:
os.environ.pop(name, None)
else:
os.environ[name] = value
yield
finally:
for name, value in previous.items():
if value is None:
os.environ.pop(name, None)
else:
os.environ[name] = value
class _FakePostgresConnectionScope:
def __init__(self, executed_sql: list[str]) -> None:
self.connection = _FakePostgresConnection(executed_sql)
def __enter__(self):
return self.connection
def __exit__(self, exc_type, exc, traceback) -> None:
return None
class _FakePostgresConnection:
def __init__(self, executed_sql: list[str]) -> None:
self.executed_sql = executed_sql
@contextmanager
def cursor(self):
yield _FakePostgresCursor(self.executed_sql)
class _FakePostgresCursor:
def __init__(self, executed_sql: list[str]) -> None:
self.executed_sql = executed_sql
def execute(self, sql: str) -> None:
self.executed_sql.append(sql)
+1 -107
View File
@@ -1,15 +1,11 @@
from __future__ import annotations
import os
import sqlite3
import tempfile
import unittest
from contextlib import closing, contextmanager
from pathlib import Path
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import patch
from app import rcon_historical_storage
from app.rcon_historical_worker import (
CAPTURE_MODE_CURRENT_LIVE,
CAPTURE_MODE_HISTORICAL,
@@ -159,100 +155,6 @@ class RconHistoricalWorkerTests(unittest.TestCase):
self.assertEqual(seen["timeout_seconds"], 3.5)
def test_historical_capture_skips_when_previous_heavy_run_is_still_running(self) -> None:
with (
patch(
"app.rcon_historical_worker.historical_capture_runtime_guard",
side_effect=_yield_guard(False),
),
patch("app.rcon_historical_worker.start_rcon_historical_capture_run") as start_run,
):
payload = run_rcon_historical_capture_unlocked(capture_mode=CAPTURE_MODE_HISTORICAL)
start_run.assert_not_called()
self.assertEqual(payload["status"], "skipped")
self.assertEqual(payload["run_status"], "skipped")
self.assertEqual(payload["materialization_result"]["reason"], "already-running")
def test_historical_storage_can_start_with_duplicate_stale_running_rows(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "historical.sqlite3"
rcon_historical_storage.initialize_rcon_historical_storage(db_path=db_path)
with closing(sqlite3.connect(db_path)) as connection:
connection.execute(
"""
INSERT INTO rcon_historical_capture_runs (
mode, status, target_scope, started_at
) VALUES (?, ?, ?, ?)
""",
("historical", "running", "all-configured-rcon-targets", "2026-01-01T00:00:00Z"),
)
connection.execute(
"""
INSERT INTO rcon_historical_capture_runs (
mode, status, target_scope, started_at
) VALUES (?, ?, ?, ?)
""",
("historical", "running", "all-configured-rcon-targets", "2026-01-01T00:05:00Z"),
)
connection.commit()
run_id = rcon_historical_storage.start_rcon_historical_capture_run(
mode="historical",
target_scope="all-configured-rcon-targets",
db_path=db_path,
)
self.assertGreater(run_id, 0)
with closing(sqlite3.connect(db_path)) as connection:
rows = connection.execute(
"""
SELECT status
FROM rcon_historical_capture_runs
WHERE mode = 'historical'
ORDER BY id ASC
"""
).fetchall()
self.assertEqual([row[0] for row in rows[:2]], ["stale", "stale"])
self.assertEqual(rows[-1][0], "running")
def test_historical_runtime_guard_releases_non_stale_conflict_only(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "historical.sqlite3"
rcon_historical_storage.initialize_rcon_historical_storage(db_path=db_path)
with closing(sqlite3.connect(db_path)) as connection:
connection.execute(
"""
INSERT INTO rcon_historical_capture_runs (
mode, status, target_scope, started_at
) VALUES (?, ?, ?, ?)
""",
("historical", "running", "all-configured-rcon-targets", "2099-01-01T00:00:00Z"),
)
connection.commit()
with rcon_historical_storage.historical_capture_runtime_guard(
capture_mode="historical",
db_path=db_path,
) as acquired:
self.assertIs(acquired, False)
def test_postgres_historical_runtime_guard_uses_advisory_lock(self) -> None:
with (
patch.object(rcon_historical_storage, "use_postgres_rcon_storage", return_value=True),
patch(
"app.postgres_rcon_storage.postgres_historical_capture_advisory_guard",
side_effect=_yield_guard(False),
) as advisory_guard,
):
with rcon_historical_storage.historical_capture_runtime_guard(
capture_mode="historical",
) as acquired:
self.assertIs(acquired, False)
advisory_guard.assert_called_once_with()
@contextmanager
def _temporary_env(**values: str):
@@ -267,11 +169,3 @@ def _temporary_env(**values: str):
os.environ.pop(name, None)
else:
os.environ[name] = value
def _yield_guard(value: bool):
@contextmanager
def _guard(*_args, **_kwargs):
yield value
return _guard
@@ -4,20 +4,12 @@ from __future__ import annotations
import gc
import os
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from app.historical_storage import upsert_historical_match
from app.payloads import build_recent_historical_matches_payload
from app.rcon_historical_player_stats import (
get_rcon_materialized_player_stats,
initialize_player_period_stats_storage,
initialize_player_search_index_storage,
search_rcon_materialized_players,
)
from app.rcon_admin_log_materialization import (
get_materialized_rcon_match_detail,
materialize_rcon_admin_log,
@@ -25,7 +17,6 @@ from app.rcon_admin_log_materialization import (
)
from app.rcon_admin_log_storage import persist_rcon_admin_log_entries
from app.rcon_historical_read_model import (
_build_player_active_time_payload,
get_rcon_historical_match_detail,
list_rcon_historical_recent_activity,
)
@@ -60,10 +51,6 @@ class RconMaterializationPipelineTests(unittest.TestCase):
self.assertEqual(players["Alpha"]["teamkills"], 1)
self.assertEqual(players["Bravo"]["deaths"], 1)
self.assertEqual(players["Charlie"]["deaths_by_teamkill"], 1)
self.assertEqual(players["Alpha"]["player_active_seconds"], 350)
self.assertEqual(players["Alpha"]["active_time_source"], "connection_intervals")
self.assertEqual(players["Bravo"]["player_active_seconds"], 0)
self.assertEqual(players["Bravo"]["active_time_source"], "event_span_fallback")
self.assertEqual(status["materialized_matches"], 1)
self.assertEqual(status["matches_with_player_stats"], 1)
gc.collect()
@@ -90,11 +77,6 @@ class RconMaterializationPipelineTests(unittest.TestCase):
players = {row["player_name"]: row for row in detail["players"]}
self.assertNotIn("player_id", players["Alpha"])
self.assertIn("kd_ratio", players["Alpha"])
self.assertEqual(players["Alpha"]["player_active_seconds"], 350)
self.assertEqual(players["Alpha"]["kpm_status"], "ready")
self.assertEqual(players["Alpha"]["kpm"], 0.17)
self.assertEqual(players["Bravo"]["kpm_status"], "missing_connection_intervals")
self.assertIsNone(players["Bravo"]["kpm"])
self.assertEqual(players["Alpha"]["steam_id_64"], "76561198000000001")
self.assertEqual(players["Alpha"]["platform"], "steam")
self.assertEqual(
@@ -103,375 +85,9 @@ class RconMaterializationPipelineTests(unittest.TestCase):
)
self.assertEqual(players["Charlie"]["platform"], "unknown")
self.assertNotIn("steam_id_64", players["Charlie"])
self.assertEqual(players["Charlie"]["external_profile_links"], {})
self.assertNotIn("external_profile_links", players["Charlie"])
gc.collect()
def test_materialization_migrates_existing_player_stats_schema_with_active_time_columns(self) -> None:
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
db_path = Path(tmpdir) / "historical.sqlite3"
connection = sqlite3.connect(db_path)
try:
connection.executescript(
"""
CREATE TABLE rcon_admin_log_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_key TEXT NOT NULL,
external_server_id TEXT,
event_timestamp TEXT,
server_time INTEGER,
relative_time TEXT,
event_type TEXT NOT NULL,
raw_message TEXT NOT NULL,
canonical_message TEXT NOT NULL,
parsed_payload_json TEXT NOT NULL,
raw_entry_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE rcon_materialized_matches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_key TEXT NOT NULL,
external_server_id TEXT,
match_key TEXT NOT NULL,
map_name TEXT,
map_pretty_name TEXT,
game_mode TEXT,
started_server_time INTEGER,
ended_server_time INTEGER,
started_at TEXT,
ended_at TEXT,
allied_score INTEGER,
axis_score INTEGER,
winner TEXT,
confidence_mode TEXT NOT NULL,
source_basis TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(target_key, match_key)
);
CREATE TABLE rcon_match_player_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_key TEXT NOT NULL,
match_key TEXT NOT NULL,
player_id TEXT NOT NULL,
player_name TEXT NOT NULL,
team TEXT,
kills INTEGER NOT NULL DEFAULT 0,
deaths INTEGER NOT NULL DEFAULT 0,
teamkills INTEGER NOT NULL DEFAULT 0,
deaths_by_teamkill INTEGER NOT NULL DEFAULT 0,
weapons_json TEXT NOT NULL DEFAULT '{}',
death_by_weapons_json TEXT NOT NULL DEFAULT '{}',
most_killed_json TEXT NOT NULL DEFAULT '{}',
death_by_json TEXT NOT NULL DEFAULT '{}',
first_seen_server_time INTEGER,
last_seen_server_time INTEGER,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(target_key, match_key, player_id)
);
"""
)
finally:
connection.close()
materialize_rcon_admin_log(db_path=db_path)
connection = sqlite3.connect(db_path)
try:
columns = {
row[1]
for row in connection.execute("PRAGMA table_info(rcon_match_player_stats)")
}
finally:
connection.close()
self.assertIn("player_active_seconds", columns)
self.assertIn("active_time_source", columns)
gc.collect()
def test_match_detail_keeps_kpm_missing_for_legacy_rows_without_active_time(self) -> None:
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) 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:
materialize_rcon_admin_log(db_path=db_path)
connection = sqlite3.connect(db_path)
try:
connection.execute(
"""
INSERT INTO rcon_materialized_matches (
target_key, external_server_id, match_key, map_name, map_pretty_name,
game_mode, started_server_time, ended_server_time, started_at, ended_at,
allied_score, axis_score, winner, confidence_mode, source_basis
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"comunidad-hispana-01",
"comunidad-hispana-01",
"legacy-match",
"mortain_warfare",
"Mortain",
"warfare",
100,
500,
"2026-05-01T10:00:00Z",
"2026-05-01T11:00:00Z",
5,
3,
"allied",
"exact",
"admin-log-match-ended",
),
)
connection.execute(
"""
INSERT INTO rcon_match_player_stats (
target_key, match_key, player_id, player_name, team,
kills, deaths, teamkills, deaths_by_teamkill,
weapons_json, death_by_weapons_json, most_killed_json, death_by_json,
first_seen_server_time, last_seen_server_time, player_active_seconds, active_time_source
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"comunidad-hispana-01",
"legacy-match",
"steam-legacy",
"Legacy",
"Allies",
10,
5,
0,
0,
"{}",
"{}",
"{}",
"{}",
120,
480,
None,
None,
),
)
connection.commit()
finally:
connection.close()
detail = get_rcon_historical_match_detail(
server_key="comunidad-hispana-01",
match_id="legacy-match",
)
finally:
_restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path)
self.assertIsNotNone(detail)
player = detail["players"][0]
self.assertIsNone(player["kpm"])
self.assertEqual(player["kpm_status"], "missing_active_time")
gc.collect()
def test_active_time_counts_full_match_for_player_connected_before_start(self) -> None:
detail = _materialize_detail_from_entries(
entries=[
{
"timestamp": "2026-05-01T09:58:00Z",
"message": "[0 min (80)] CONNECTED Carry Over (steam-carry)",
},
{
"timestamp": "2026-05-01T10:00:00Z",
"message": "[1 min (100)] MATCH START Mortain Warfare",
},
{
"timestamp": "2026-05-01T10:30:00Z",
"message": (
"[31 min (1900)] KILL: Carry Over(Allies/steam-carry) -> "
"Victim(Axis/steam-victim) with M1 GARAND"
),
},
{
"timestamp": "2026-05-01T11:00:00Z",
"message": "[61 min (3700)] MATCH ENDED `Mortain Warfare` ALLIED (5 - 0) AXIS",
},
]
)
player = {row["player_name"]: row for row in detail["players"]}["Carry Over"]
self.assertEqual(player["player_active_seconds"], 3600)
self.assertEqual(player["active_time_source"], "connection_intervals_carryover")
self.assertEqual(player["kpm_status"], "ready")
self.assertEqual(player["kpm"], 0.02)
def test_active_time_counts_until_disconnect_for_player_connected_before_start(self) -> None:
detail = _materialize_detail_from_entries(
entries=[
{
"timestamp": "2026-05-01T09:58:00Z",
"message": "[0 min (80)] CONNECTED Carry Over (steam-carry)",
},
{
"timestamp": "2026-05-01T10:00:00Z",
"message": "[1 min (100)] MATCH START Mortain Warfare",
},
{
"timestamp": "2026-05-01T10:20:00Z",
"message": "[21 min (1300)] DISCONNECTED Carry Over (steam-carry)",
},
{
"timestamp": "2026-05-01T11:00:00Z",
"message": "[61 min (3700)] MATCH ENDED `Mortain Warfare` ALLIED (5 - 0) AXIS",
},
]
)
player = detail["players"][0]
self.assertEqual(player["player_active_seconds"], 1200)
self.assertEqual(player["active_time_source"], "connection_intervals_carryover")
def test_active_time_counts_from_connect_until_match_end(self) -> None:
detail = _materialize_detail_from_entries(
entries=[
{
"timestamp": "2026-05-01T10:00:00Z",
"message": "[1 min (100)] MATCH START Mortain Warfare",
},
{
"timestamp": "2026-05-01T10:10:00Z",
"message": "[11 min (700)] CONNECTED Late Join (steam-late)",
},
{
"timestamp": "2026-05-01T11:00:00Z",
"message": "[61 min (3700)] MATCH ENDED `Mortain Warfare` ALLIED (5 - 0) AXIS",
},
]
)
player = detail["players"][0]
self.assertEqual(player["player_active_seconds"], 3000)
self.assertEqual(player["active_time_source"], "connection_intervals")
def test_active_time_sums_multiple_connection_intervals(self) -> None:
detail = _materialize_detail_from_entries(
entries=[
{
"timestamp": "2026-05-01T10:00:00Z",
"message": "[1 min (100)] MATCH START Mortain Warfare",
},
{
"timestamp": "2026-05-01T10:05:00Z",
"message": "[6 min (400)] CONNECTED Reconnect (steam-reconnect)",
},
{
"timestamp": "2026-05-01T10:15:00Z",
"message": "[16 min (1000)] DISCONNECTED Reconnect (steam-reconnect)",
},
{
"timestamp": "2026-05-01T10:20:00Z",
"message": "[21 min (1300)] CONNECTED Reconnect (steam-reconnect)",
},
{
"timestamp": "2026-05-01T10:35:00Z",
"message": "[36 min (2200)] DISCONNECTED Reconnect (steam-reconnect)",
},
{
"timestamp": "2026-05-01T11:00:00Z",
"message": "[61 min (3700)] MATCH ENDED `Mortain Warfare` ALLIED (5 - 0) AXIS",
},
]
)
player = detail["players"][0]
self.assertEqual(player["player_active_seconds"], 1500)
self.assertEqual(player["active_time_source"], "connection_intervals")
def test_active_time_uses_event_span_fallback_without_ready_kpm_when_connection_intervals_missing(self) -> None:
detail = _materialize_detail_from_entries(
entries=[
{
"timestamp": "2026-05-01T10:00:00Z",
"message": "[1 min (100)] MATCH START Mortain Warfare",
},
{
"timestamp": "2026-05-01T10:05:00Z",
"message": (
"[6 min (400)] KILL: Fallback(Allies/steam-fallback) -> "
"Victim(Axis/steam-victim) with M1 GARAND"
),
},
{
"timestamp": "2026-05-01T10:20:00Z",
"message": "[21 min (1300)] CHAT[Team][Fallback(Allies/steam-fallback)]: test",
},
{
"timestamp": "2026-05-01T11:00:00Z",
"message": "[61 min (3700)] MATCH ENDED `Mortain Warfare` ALLIED (5 - 0) AXIS",
},
]
)
player = {row["player_name"]: row for row in detail["players"]}["Fallback"]
self.assertEqual(player["player_active_seconds"], 900)
self.assertEqual(player["active_time_source"], "event_span_fallback")
self.assertEqual(player["kpm_status"], "missing_connection_intervals")
self.assertIsNone(player["kpm"])
def test_kpm_is_null_when_active_time_is_below_threshold(self) -> None:
detail = _materialize_detail_from_entries(
entries=[
{
"timestamp": "2026-05-01T10:00:00Z",
"message": "[1 min (100)] MATCH START Mortain Warfare",
},
{
"timestamp": "2026-05-01T10:01:00Z",
"message": "[2 min (120)] CONNECTED Short (steam-short)",
},
{
"timestamp": "2026-05-01T10:01:30Z",
"message": "[2 min (150)] DISCONNECTED Short (steam-short)",
},
{
"timestamp": "2026-05-01T11:00:00Z",
"message": "[61 min (3700)] MATCH ENDED `Mortain Warfare` ALLIED (5 - 0) AXIS",
},
]
)
player = detail["players"][0]
self.assertEqual(player["player_active_seconds"], 30)
self.assertEqual(player["kpm_status"], "insufficient_active_time")
self.assertIsNone(player["kpm"])
def test_kpm_payload_helper_returns_ready_for_ten_kills_in_ten_minutes(self) -> None:
payload = _build_player_active_time_payload(
kills=10,
player_active_seconds=600,
active_time_source="connection_intervals",
)
self.assertEqual(payload["kpm"], 1.0)
self.assertEqual(payload["kpm_status"], "ready")
def test_kpm_payload_helper_returns_zero_for_zero_kills_with_valid_active_time(self) -> None:
payload = _build_player_active_time_payload(
kills=0,
player_active_seconds=600,
active_time_source="connection_intervals",
)
self.assertEqual(payload["kpm"], 0.0)
self.assertEqual(payload["kpm_status"], "ready")
def test_kpm_payload_helper_returns_missing_when_active_time_is_null(self) -> None:
payload = _build_player_active_time_payload(
kills=10,
player_active_seconds=None,
active_time_source="unavailable",
)
self.assertIsNone(payload["kpm"])
self.assertEqual(payload["kpm_status"], "missing_active_time")
def test_match_detail_marks_equal_materialized_timestamps_as_server_time_only(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "historical.sqlite3"
@@ -565,7 +181,7 @@ class RconMaterializationPipelineTests(unittest.TestCase):
)
gc.collect()
def test_match_detail_omits_profile_summary_when_snapshot_exists(self) -> None:
def test_match_detail_adds_safe_profile_summary_when_snapshot_exists(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "historical.sqlite3"
previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH")
@@ -606,11 +222,15 @@ class RconMaterializationPipelineTests(unittest.TestCase):
self.assertIsNotNone(detail)
players = {row["player_name"]: row for row in detail["players"]}
self.assertNotIn("profile_summary", players["Alpha"])
self.assertIn("profile_summary", players["Alpha"])
self.assertNotIn("profile_summary", players["Bravo"])
profile_summary = players["Alpha"]["profile_summary"]
self.assertEqual(profile_summary["sessions"], 12)
self.assertEqual(profile_summary["matches_played"], 9)
self.assertEqual(profile_summary["totals"]["kills"], 141)
self.assertEqual(profile_summary["favorite_weapons"], {"M1 Garand": 31})
self.assertNotIn("raw_content", profile_summary)
self.assertNotIn("player_id", players["Alpha"])
self.assertEqual(players["Alpha"]["steam_id_64"], "76561198000000001")
self.assertIn("external_profile_links", players["Alpha"])
gc.collect()
def test_recent_matches_prefer_materialized_rcon_over_scoreboard_fallback(self) -> None:
@@ -682,85 +302,6 @@ class RconMaterializationPipelineTests(unittest.TestCase):
self.assertEqual(payload["data"]["items"][0]["result_source"], "public-scoreboard-fallback")
gc.collect()
def test_public_player_search_uses_read_model_without_initialize_or_runtime_fallback(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "historical.sqlite3"
initialize_player_search_index_storage(db_path=db_path)
_insert_player_search_index_fixture(db_path)
with (
patch(
"app.rcon_historical_player_stats.initialize_player_search_index_storage",
side_effect=AssertionError("public read must not initialize player search storage"),
),
patch(
"app.rcon_historical_player_stats._search_rcon_materialized_players_runtime",
side_effect=AssertionError("public read must not use runtime player search fallback"),
),
):
payload = search_rcon_materialized_players(
query="Medu",
server_id="all",
limit=10,
db_path=db_path,
)
self.assertEqual(payload["source"]["read_model"], "player-search-index")
self.assertFalse(payload["source"]["fallback_used"])
self.assertEqual(payload["items"][0]["player_id"], "76561198092154180")
gc.collect()
def test_public_player_detail_returns_controlled_empty_without_initialize_or_runtime_fallback(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "missing.sqlite3"
with (
patch(
"app.rcon_historical_player_stats.initialize_player_period_stats_storage",
side_effect=AssertionError("public read must not initialize player period storage"),
),
patch(
"app.rcon_historical_player_stats._get_rcon_materialized_player_stats_runtime",
side_effect=AssertionError("public read must not use runtime player detail fallback"),
),
):
payload = get_rcon_materialized_player_stats(
player_id="76561198092154180",
server_id="all",
timeframe="weekly",
db_path=db_path,
)
self.assertEqual(payload["player_id"], "76561198092154180")
self.assertEqual(payload["matches_considered"], 0)
self.assertEqual(payload["source"]["read_model"], "player-period-stats")
self.assertEqual(payload["source"]["status"], "unavailable")
self.assertFalse(payload["source"]["fallback_used"])
gc.collect()
def test_public_match_detail_read_does_not_initialize_materialized_storage(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_admin_log_fixture(db_path)
materialize_rcon_admin_log(db_path=db_path)
with patch(
"app.rcon_admin_log_materialization.initialize_rcon_materialized_storage",
side_effect=AssertionError("public match detail read must not initialize storage"),
):
detail = get_rcon_historical_match_detail(
server_key="comunidad-hispana-01",
match_id="comunidad-hispana-01:100:500:stmariedumontwarfare",
)
finally:
_restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path)
self.assertIsNotNone(detail)
self.assertEqual(detail["result_source"], "admin-log-match-ended")
gc.collect()
def test_safe_scoreboard_match_url_allowlist_for_active_origins(self) -> None:
self.assertEqual(
resolve_trusted_scoreboard_match_url(
@@ -832,44 +373,6 @@ def _persist_admin_log_fixture(db_path: Path) -> None:
)
def _materialize_detail_from_entries(*, entries: list[dict[str, object]]) -> dict[str, object]:
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) 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_rcon_admin_log_entries(
target={
"target_key": "comunidad-hispana-01",
"external_server_id": "comunidad-hispana-01",
},
entries=entries,
db_path=db_path,
)
materialize_rcon_admin_log(db_path=db_path)
match_rows = list_materialized_rcon_matches_for_test(db_path)
detail = get_rcon_historical_match_detail(
server_key="comunidad-hispana-01",
match_id=str(match_rows[0]["match_key"]),
)
finally:
_restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path)
if detail is None:
raise AssertionError("expected materialized detail")
return detail
def list_materialized_rcon_matches_for_test(db_path: Path) -> list[dict[str, object]]:
from app.rcon_admin_log_materialization import list_materialized_rcon_matches
return list_materialized_rcon_matches(
target_key="comunidad-hispana-01",
only_ended=True,
limit=5,
db_path=db_path,
)
def _persist_scoreboard_match(db_path: Path) -> None:
upsert_historical_match(
server_slug="comunidad-hispana-01",
@@ -886,44 +389,6 @@ def _persist_scoreboard_match(db_path: Path) -> None:
)
def _insert_player_search_index_fixture(db_path: Path) -> None:
import sqlite3
with sqlite3.connect(db_path) as connection:
connection.execute(
"""
INSERT INTO player_search_index (
server_id,
player_id,
player_name,
normalized_player_name,
first_seen_at,
last_seen_at,
servers_seen,
matches_current_year,
kills_current_year,
deaths_current_year,
teamkills_current_year,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"all-servers",
"76561198092154180",
"Medu",
"medu",
"2026-01-01T00:00:00Z",
"2026-06-01T00:00:00Z",
'["comunidad-hispana-01"]',
12,
100,
50,
0,
"2026-06-01T00:00:00Z",
),
)
def _restore_env(name: str, previous_value: str | None) -> None:
if previous_value is None:
os.environ.pop(name, None)
@@ -1,232 +0,0 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from app.payloads import build_stats_player_profile_payload
from app.rcon_historical_player_stats import (
_build_profile_active_time_payload,
get_rcon_materialized_player_stats,
)
class StatsPlayerProfilePayloadTests(unittest.TestCase):
def test_player_stats_read_model_keeps_payload_valid_when_active_time_lookup_fails(self) -> None:
selected_row = {
"period_type": "weekly",
"window_kind": "current-week",
"period_start": "2026-06-01T00:00:00Z",
"period_end": "2026-06-08T00:00:00Z",
"server_id": "all-servers",
"player_id": "76561198000000000",
"player_name": "Steam Player",
"matches_considered": 4,
"kills": 20,
"deaths": 10,
"teamkills": 0,
"ranking_position": 4,
"updated_at": "2026-06-11T12:00:00Z",
"first_seen_at": "2026-06-01T01:00:00Z",
"last_seen_at": "2026-06-08T01:00:00Z",
}
with (
patch(
"app.rcon_historical_player_stats._get_player_period_stats_read_model",
return_value=(
{
"player_id": "76561198000000000",
"player_name": "Steam Player",
"server_id": "all-servers",
"timeframe": "weekly",
"window_start": None,
"window_end": None,
"window_kind": "current-week",
"matches_considered": 4,
"kills": 20,
"deaths": 10,
"teamkills": 0,
"player_active_seconds": None,
"player_active_minutes": None,
"kpm": None,
"kpm_status": "missing_active_time",
"active_time_source": "unavailable",
"active_time_coverage": {
"eligible_matches": 0,
"real_source_matches": 0,
"observed_matches": 0,
"total_matches_considered": 4,
"eligible_kills": 0,
"minimum_active_seconds": 60,
"sources": [],
},
"platform": "steam",
"steam_id_64": "76561198000000000",
"external_profile_links": {
"steam": "https://steamcommunity.com/profiles/76561198000000000",
"hellor": "https://hellor.pro/player/76561198000000000",
"hll_records": "https://hllrecords.com/profiles/76561198000000000",
"helo": "https://helo-system.de/statistics/players/76561198000000000?series=2024",
},
"weekly_ranking": {"metric": "kills", "ranking_position": 4},
"monthly_ranking": {"metric": "kills", "ranking_position": 7},
"source": {"primary_source": "rcon", "read_model": "player-period-stats"},
},
None,
),
),
):
payload = build_stats_player_profile_payload(
player_id="76561198000000000",
timeframe="weekly",
)
data = payload["data"]
self.assertEqual(data["matches_considered"], 4)
self.assertIsNone(data["kpm"])
self.assertEqual(data["kpm_status"], "missing_active_time")
self.assertIn("steam", data["external_profile_links"])
def test_player_stats_returns_lightweight_missing_payload_when_period_read_model_is_empty(self) -> None:
with patch(
"app.rcon_historical_player_stats._get_player_period_stats_read_model",
return_value=(None, "player-period-stats-empty"),
):
result = get_rcon_materialized_player_stats(
player_id="76561198000000000",
timeframe="weekly",
)
self.assertEqual(result["matches_considered"], 0)
self.assertEqual(result["kpm_status"], "missing_active_time")
self.assertEqual(result["platform"], "steam")
self.assertIn("steam", result["external_profile_links"])
self.assertEqual(result["source"]["missing_reason"], "player-period-stats-empty")
self.assertFalse(result["source"]["fallback_used"])
def test_build_stats_player_profile_payload_exposes_ready_real_kpm_and_external_links(self) -> None:
with patch(
"app.payloads.get_rcon_materialized_player_stats",
return_value={
"player_id": "76561198000000000",
"player_name": "Steam Player",
"server_id": "all-servers",
"timeframe": "weekly",
"window_start": None,
"window_end": None,
"window_kind": "current-week",
"matches_considered": 4,
"kills": 20,
"deaths": 10,
"teamkills": 0,
"player_active_seconds": 1200,
"player_active_minutes": 20.0,
"kpm": 1.0,
"kpm_status": "ready",
"active_time_source": "connection_intervals",
"active_time_coverage": {
"eligible_matches": 4,
"real_source_matches": 4,
"observed_matches": 4,
"total_matches_considered": 4,
"eligible_kills": 20,
"minimum_active_seconds": 60,
"sources": ["connection_intervals"],
},
"platform": "steam",
"steam_id_64": "76561198000000000",
"external_profile_links": {
"steam": "https://steamcommunity.com/profiles/76561198000000000",
"hellor": "https://hellor.pro/player/76561198000000000",
"hll_records": "https://hllrecords.com/profiles/76561198000000000",
"helo": "https://helo-system.de/statistics/players/76561198000000000?series=2024",
},
"weekly_ranking": {"metric": "kills", "ranking_position": 4},
"monthly_ranking": {"metric": "kills", "ranking_position": 7},
"source": {"primary_source": "rcon"},
},
):
payload = build_stats_player_profile_payload(
player_id="76561198000000000",
timeframe="weekly",
)
data = payload["data"]
self.assertEqual(data["kpm"], 1.0)
self.assertEqual(data["kpm_status"], "ready")
self.assertEqual(data["active_time_source"], "connection_intervals")
self.assertEqual(data["steam_id_64"], "76561198000000000")
self.assertEqual(data["platform"], "steam")
self.assertIn("steam", data["external_profile_links"])
def test_build_stats_player_profile_payload_keeps_kpm_null_when_connection_intervals_are_missing(self) -> None:
with patch(
"app.payloads.get_rcon_materialized_player_stats",
return_value={
"player_id": "epic-player-id",
"player_name": "Epic Player",
"server_id": "all-servers",
"timeframe": "monthly",
"window_start": None,
"window_end": None,
"window_kind": "current-month",
"matches_considered": 2,
"kills": 7,
"deaths": 4,
"teamkills": 0,
"player_active_seconds": None,
"player_active_minutes": None,
"kpm": None,
"kpm_status": "missing_connection_intervals",
"active_time_source": "event_span_fallback",
"active_time_coverage": {
"eligible_matches": 0,
"real_source_matches": 0,
"observed_matches": 2,
"total_matches_considered": 2,
"eligible_kills": 0,
"minimum_active_seconds": 60,
"sources": ["event_span_fallback"],
},
"platform": "epic",
"epic_id": "0123456789abcdef0123456789abcdef",
"external_profile_links": {
"hellor": "https://hellor.pro/player/0123456789abcdef0123456789abcdef",
"hll_records": "https://hllrecords.com/profiles/0123456789abcdef0123456789abcdef",
},
"weekly_ranking": {"metric": "kills", "ranking_position": None},
"monthly_ranking": {"metric": "kills", "ranking_position": None},
"source": {"primary_source": "rcon"},
},
):
payload = build_stats_player_profile_payload(
player_id="0123456789abcdef0123456789abcdef",
timeframe="monthly",
)
data = payload["data"]
self.assertIsNone(data["kpm"])
self.assertEqual(data["kpm_status"], "missing_connection_intervals")
self.assertNotIn("steam", data["external_profile_links"])
def test_profile_active_time_payload_marks_ready_only_for_real_connection_intervals(self) -> None:
payload = _build_profile_active_time_payload(
row={
"observed_matches": 3,
"real_source_matches": 3,
"eligible_matches": 3,
"player_active_seconds": 600,
"eligible_kills": 10,
"eligible_sources": "connection_intervals,connection_intervals_carryover",
"observed_sources": "connection_intervals,connection_intervals_carryover",
},
total_matches_considered=3,
min_active_seconds=60,
)
self.assertEqual(payload["kpm"], 1.0)
self.assertEqual(payload["kpm_status"], "ready")
self.assertEqual(payload["active_time_source"], "connection_intervals_mixed")
if __name__ == "__main__":
unittest.main()
+13 -47
View File
@@ -19,7 +19,7 @@ services:
backend:
build:
context: ../../backend
environment: &backend_environment
environment:
HLL_BACKEND_DATABASE_URL: ${HLL_BACKEND_DATABASE_URL:?HLL_BACKEND_DATABASE_URL is required}
HLL_BACKEND_HOST: ${HLL_BACKEND_HOST:-0.0.0.0}
HLL_BACKEND_PORT: ${HLL_BACKEND_PORT:-8000}
@@ -70,7 +70,11 @@ services:
context: ../../backend
command: ["python", "-m", "app.historical_runner", "--hourly"]
environment:
<<: *backend_environment
HLL_BACKEND_DATABASE_URL: ${HLL_BACKEND_DATABASE_URL:?HLL_BACKEND_DATABASE_URL is required}
HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon}
HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon}
HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20}
HLL_BACKEND_RCON_TARGETS: ${HLL_BACKEND_RCON_TARGETS:?HLL_BACKEND_RCON_TARGETS is required}
HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS: ${HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS:-3600}
HLL_HISTORICAL_REFRESH_MAX_RETRIES: ${HLL_HISTORICAL_REFRESH_MAX_RETRIES:-2}
HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS: ${HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS:-15}
@@ -85,57 +89,19 @@ services:
- hll-internal
restart: unless-stopped
rcon-live-adminlog-worker:
profiles:
- advanced
build:
context: ../../backend
command:
- python
- -m
- app.rcon_current_match_worker
- loop
- --interval
- "5"
- --lookback-minutes
- "15"
environment:
<<: *backend_environment
CURRENT_MATCH_ADMINLOG_ENABLED: "true"
CURRENT_MATCH_ADMINLOG_INTERVAL_SECONDS: ${CURRENT_MATCH_ADMINLOG_INTERVAL_SECONDS:-5}
CURRENT_MATCH_ADMINLOG_LOOKBACK_SECONDS: ${CURRENT_MATCH_ADMINLOG_LOOKBACK_SECONDS:-900}
depends_on:
postgres:
condition: service_healthy
backend:
condition: service_started
volumes:
- backend-data:/app/data
networks:
- hll-internal
restart: unless-stopped
rcon-historical-worker:
profiles:
- advanced
build:
context: ../../backend
command:
- python
- -m
- app.rcon_historical_worker
- loop
- --capture-mode
- historical
- --interval
- "900"
- --retries
- "2"
- --retry-delay
- "15"
command: ["python", "-m", "app.rcon_historical_worker", "loop"]
environment:
<<: *backend_environment
HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS:-900}
HLL_BACKEND_DATABASE_URL: ${HLL_BACKEND_DATABASE_URL:?HLL_BACKEND_DATABASE_URL is required}
HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon}
HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon}
HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20}
HLL_BACKEND_RCON_TARGETS: ${HLL_BACKEND_RCON_TARGETS:?HLL_BACKEND_RCON_TARGETS is required}
HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS:-600}
HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES: ${HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES:-2}
HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS:-15}
HLL_BACKEND_RCON_ADMIN_LOG_LOOKBACK_MINUTES: ${HLL_BACKEND_RCON_ADMIN_LOG_LOOKBACK_MINUTES:-10}
+1 -11
View File
@@ -45,7 +45,7 @@ services:
build:
context: ./backend
container_name: hll-vietnam-historical-runner
command: ["python", "-m", "app.historical_runner"]
command: ["python", "-m", "app.historical_runner", "--hourly"]
env_file:
- ./backend/.env.example
environment:
@@ -53,16 +53,6 @@ services:
HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon}
HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon}
HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20}
HLL_PUBLIC_FULL_REFRESH_ENABLED: ${HLL_PUBLIC_FULL_REFRESH_ENABLED:-true}
HLL_PUBLIC_FULL_REFRESH_TIME: ${HLL_PUBLIC_FULL_REFRESH_TIME:-06:00}
HLL_PUBLIC_FULL_REFRESH_TIMEZONE: ${HLL_PUBLIC_FULL_REFRESH_TIMEZONE:-Europe/Madrid}
HLL_PUBLIC_RANKING_REFRESH_INTERVAL_SECONDS: ${HLL_PUBLIC_RANKING_REFRESH_INTERVAL_SECONDS:-900}
HLL_PUBLIC_RANKING_WEEKLY_REFRESH_MINUTE: ${HLL_PUBLIC_RANKING_WEEKLY_REFRESH_MINUTE:-10}
HLL_PUBLIC_RANKING_MONTHLY_REFRESH_TIMES: ${HLL_PUBLIC_RANKING_MONTHLY_REFRESH_TIMES:-07:00,19:00}
HLL_PUBLIC_HISTORICAL_WEEKLY_REFRESH_MINUTE: ${HLL_PUBLIC_HISTORICAL_WEEKLY_REFRESH_MINUTE:-25}
HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_MINUTE: ${HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_MINUTE:-40}
HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_HOUR_INTERVAL: ${HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_HOUR_INTERVAL:-2}
HLL_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS: ${HLL_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS:-60}
HLL_BACKEND_RCON_TARGETS: >-
${HLL_BACKEND_RCON_TARGETS:-[{"name":"Comunidad Hispana #01","slug":"comunidad-hispana-01","external_server_id":"comunidad-hispana-01","host":"152.114.195.174","port":7779,"password":"replace-me-01","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #02","slug":"comunidad-hispana-02","external_server_id":"comunidad-hispana-02","host":"152.114.195.150","port":7879,"password":"replace-me-02","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null}]}
depends_on:
-685
View File
@@ -1,685 +0,0 @@
# Full Application Request Audit
Fecha: 2026-06-10
Task: `TASK-224-full-application-request-audit`
Alcance: auditoria de peticiones publicas frontend/backend/API sin aplicar fixes funcionales.
## Resumen ejecutivo
La auditoria encontro 36 patrones HTTP publicos en `backend/app/routes.py`, 12 ocurrencias `fetch(` en JavaScript publico, 15 literales `/api/...` en frontend y 13 referencias `localhost`/`127.0.0.1` en HTML/JS publico. No se encontro `XMLHttpRequest`.
Se ejecuto la auditoria contra `https://comunidadhll.devzamode.es` con 191 probes automaticos y 4 probes manuales dependientes de `player_id`. Resultado combinado:
| Severidad | Cantidad |
| --- | ---: |
| OK | 77 |
| WARNING | 112 |
| CRITICAL | 6 |
Los fallos criticos actuales no estan en rankings/snapshots ni en el match detail historico probado. Estan en:
- `/api/stats/players/search?q=Medu&server_id=all&limit=10`: timeout a 30s.
- `/api/stats/players/{player_id}?timeframe=weekly|monthly&server_id=all`: timeout a 30s.
- `/api/current-match/kills?server=comunidad-hispana-01&limit=30`: timeout a 30s.
- `/api/current-match/players?server=comunidad-hispana-01`: timeout a 30s.
- `/api/current-match/kills?server=comunidad-hispana-02&limit=30`: 500 en una ejecucion y timeout en una repeticion manual.
`/api/historical/matches/detail` con el match real `comunidad-hispana-01:1781023156:1781028555:purpleheartlanewarfare` respondio `200` en `120.47 ms` y `125580 B`. Sigue teniendo deuda de inicializacion en lectura en la cadena estatica, pero ya no es el endpoint publico mas critico segun la medicion de produccion de esta auditoria.
Siguiente fix prioritario recomendado: aislar o eliminar inicializacion/fallback runtime de `/api/stats/players/search` y `/api/stats/players/{player_id}`. Despues, corregir `/api/current-match/kills` y `/api/current-match/players`. `/api/historical/matches/detail` debe quedar en cola de hardening para hacerlo estrictamente read-only, pero no aparece como el primer incendio operativo en esta medicion.
## Estado post-fix TASK-225
Fecha: 2026-06-10
Alcance aplicado: Fase 1, estabilizacion de lecturas publicas no dependientes de RCON live.
Cambios de codigo aplicados:
- `/api/stats/players/search`: el builder sigue llamando al mismo helper, pero `search_rcon_materialized_players()` ahora es lectura estricta de `player_search_index`. Ya no inicializa `player_search_index` ni cae a busqueda runtime sobre `rcon_match_player_stats` durante un GET publico. Si el read model no existe o esta vacio, devuelve `items: []`, `source.read_model: player-search-index`, `source.status: unavailable`, `fallback_used: false`.
- `/api/stats/players/{player_id}` semanal/mensual: `get_rcon_materialized_player_stats()` usa `player_period_stats` como read model publico. Si falta el read model o el jugador no esta en el periodo, devuelve una respuesta controlada con contadores a cero, `source.read_model: player-period-stats`, `source.status: unavailable`, `fallback_used: false`. El modo `timeframe=all` conserva el camino runtime interno existente.
- `/api/historical/matches/detail`: `get_materialized_rcon_match_detail()` acepta `ensure_storage=False` por defecto y la ruta publica lo usa en modo read-only. PostgreSQL puede abrirse sin `initialize_postgres_rcon_storage()` y SQLite se abre en `mode=ro`. Si falta la tabla/read model, la lectura devuelve `None` y el payload publico RCON responde `found: false`, `fallback_used: false`, sin fallback legacy pesado ni inicializacion.
- `scripts/audit_public_requests.py`: se anadieron `--filter` y `--player-id` para medir subconjuntos afectados sin lanzar toda la matriz.
Validacion local de codigo:
```powershell
python -m compileall backend\app
python -m py_compile scripts\audit_public_requests.py
cd backend
python -m unittest tests.test_rcon_materialization_pipeline
python -m unittest tests.test_current_match_payload tests.test_rcon_admin_log_storage tests.test_historical_snapshot_refresh
```
Resultado:
- `compileall`: OK.
- `py_compile`: OK.
- `tests.test_rcon_materialization_pipeline`: OK, 12 tests. La suite mantiene `ResourceWarning` SQLite ya presentes en tests existentes.
- Tests relacionados de current match, admin log storage y historical snapshot refresh: OK, 13 tests.
Medicion HTTP local:
- `http://127.0.0.1:8000/health` no estaba disponible desde el host.
- Las ejecuciones parciales del script contra `http://127.0.0.1:8000` seleccionaron los probes esperados, pero fallaron por falta de backend local. No miden rendimiento del cambio.
Comandos de medicion recomendados tras redeploy:
```powershell
python scripts\audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --filter stats-player-search --output tmp\task225_prod_player_search_audit.json
python scripts\audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --player-id 76561198092154180 --filter stats-player-profile --output tmp\task225_prod_player_profile_audit.json
python scripts\audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --filter historical-match-detail --output tmp\task225_prod_match_detail_audit.json
```
Estado de severidad esperado tras deploy:
| Endpoint | Severidad TASK-224 | Estado TASK-225 en codigo | Severidad esperada tras deploy |
| --- | --- | --- | --- |
| `/api/stats/players/search` | CRITICAL | Sin initialize ni fallback runtime pesado en GET publico | OK o WARNING si falta read model |
| `/api/stats/players/{player_id}` weekly/monthly | CRITICAL | Sin initialize ni recalculo runtime pesado en GET publico | OK o WARNING si falta read model |
| `/api/historical/matches/detail` | WARNING por deuda estatica | Read-only sin `initialize_*`; sin fallback legacy en modo RCON | OK o WARNING si falta read model |
| `/api/current-match/kills` | CRITICAL | No abordado en Fase 1 | CRITICAL pendiente |
| `/api/current-match/players` | CRITICAL | No abordado en Fase 1 | CRITICAL pendiente |
| `/api/servers` | WARNING | No abordado en Fase 1 | WARNING pendiente |
Siguiente fix prioritario actualizado: Fase 2 de `TASK-225` en task separada o continuacion autorizada, centrada en hardening de `/api/current-match/kills` y `/api/current-match/players` sin cambiar configuracion RCON. La validacion final debe repetirse en el entorno donde RCON este local.
## Estado post-fix TASK-226
Fecha: 2026-06-10
Alcance aplicado: hardening de endpoints publicos RCON/live pendientes, sin cambiar hosts, puertos, `27001`, variables de entorno ni configuracion de servidores.
Cambios de codigo aplicados:
- `/api/current-match/kills`: el payload usa AdminLog en modo lectura publica sin inicializar storage (`ensure_storage=False`). Si falta el read model, hay timeout o falla la lectura, devuelve `status: ok`, `items: []`, `confidence: unavailable`, `fallback_used: true` y `fallback_reason` controlado (`admin-log-read-model-unavailable`, `admin-log-read-timeout` o `admin-log-read-failed`).
- `/api/current-match/players`: aplica el mismo patron de degradacion controlada sobre player stats de AdminLog. Una excepcion de storage ya no se propaga como 500 sin cuerpo.
- `/api/servers`: el builder publico deja de llamar `_try_collect_real_time_snapshot()` y no refresca RCON/A2S en cada GET publico. Sirve snapshots persistidos, marca `freshness`/`is_stale` y expone `refresh_status: "cache-only"`.
Estado de severidad esperado tras deploy:
| Endpoint | Severidad TASK-224 | Estado TASK-226 en codigo | Severidad esperada tras deploy |
| --- | --- | --- | --- |
| `/api/current-match/kills` | CRITICAL | Read-only AdminLog, degradacion JSON controlada | OK o WARNING si falta read model |
| `/api/current-match/players` | CRITICAL | Read-only AdminLog, degradacion JSON controlada | OK o WARNING si falta read model |
| `/api/servers` | WARNING | Snapshot/cache-only; sin refresh RCON live en request publica | OK o WARNING si snapshot stale |
Notas:
- `/api/current-match` conserva deuda separada porque todavia puede intentar muestra RCON directa antes de caer a snapshot.
- La frescura de `/api/servers` depende ahora del proceso/runner que mantenga snapshots calientes fuera del request publico.
- Auditoria parcial contra produccion ejecutada el 2026-06-10 antes de desplegar este codigo: `current-match` todavia mostro los timeouts/500 previos y `/api/servers` midio 4249.71 ms. Repetir tras deploy para confirmar los tiempos post-fix reales.
## Estado post-fix TASK-227
Fecha: 2026-06-10
Alcance aplicado: correccion especifica de `/api/current-match/kills` y `/api/current-match/players` despues de que la auditoria real post-`TASK-226` siguiera mostrando timeouts de 26-30 s en esos endpoints.
Causa confirmada:
- Los payloads ya llamaban `list_current_match_kill_feed(..., ensure_storage=False)` y `list_current_match_player_stats(..., ensure_storage=False)`.
- En la rama SQLite eso abria el storage en modo lectura.
- En la rama PostgreSQL, `backend/app/rcon_admin_log_storage.py` llamaba `connect_postgres_compat()` sin pasar el flag.
- `connect_postgres_compat()` usa `initialize=True` por defecto, por lo que kills/players seguian ejecutando `initialize_postgres_rcon_storage()` en el GET publico.
Cambios de codigo aplicados:
- `/api/current-match/kills`: la lectura PostgreSQL de AdminLog ahora propaga `initialize=ensure_storage`; con `ensure_storage=False` no ejecuta bootstrap/DDL.
- `/api/current-match/players`: aplica el mismo fix para el resumen de jugadores.
- Tests de regresion verifican que ambos caminos PostgreSQL publicos llaman `connect_postgres_compat(initialize=False)`.
Diferencia con `/api/current-match` general:
- `/api/current-match` usa `_query_current_match_rcon_sample()` y fallback a snapshot de servidores; no pasa por el read model AdminLog de kills/players.
- Kills/players son vistas derivadas de AdminLog y el problema estaba en la inicializacion PostgreSQL de esa capa, no en host/puerto RCON.
Estado esperado tras redeploy:
| Endpoint | Estado TASK-227 en codigo | Severidad esperada tras deploy |
| --- | --- | --- |
| `/api/current-match/kills` | PostgreSQL read-only real; sin `initialize_postgres_rcon_storage()` en GET publico | OK o WARNING si falta read model |
| `/api/current-match/players` | PostgreSQL read-only real; sin `initialize_postgres_rcon_storage()` en GET publico | OK o WARNING si falta read model |
Comando de validacion de produccion tras redeploy:
```powershell
python .\scripts\audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --filter current-match --output tmp\task227_current_match_audit_after.json
```
## Estado post-fix TASK-228
Fecha: 2026-06-10
Alcance aplicado: restauracion de `/api/servers` como endpoint near-real-time controlado para la home.
Causa confirmada:
- `TASK-226` hizo que `/api/servers` dejara de llamar `_try_collect_real_time_snapshot()`.
- La ruta quedo en modo `cache-only`, con `refresh_attempted: false` y `refresh_status: cache-only`.
- Cuando no existian snapshots persistidos, respondia rapido pero con `source: no-snapshot-available` e `items: []`, aunque la fuente live pudiera estar disponible en produccion.
Cambios de codigo aplicados:
- `/api/servers` vuelve a usar la politica live/casi-live:
- sirve snapshot fresco si existe.
- intenta refresh live si no hay snapshot o el snapshot esta stale.
- devuelve live RCON/A2S si hay items.
- cae a snapshot stale si live falla y existe ultimo estado conocido.
- devuelve JSON controlado con error/fallback si live falla y no hay snapshot.
- El refresh publico usa timeout corto interno (`2.5s`) propagado a RCON/A2S sin cambiar variables de entorno, hosts, puertos ni configuracion de servidores.
- No se persiste desde el GET publico para evitar inicializaciones/DDL pesadas en lectura.
Estado esperado tras redeploy:
| Endpoint | Estado TASK-228 en codigo | Severidad esperada tras deploy |
| --- | --- | --- |
| `/api/servers` | Near-real-time controlado; live si cache falta/stale, stale fallback si live falla | OK o WARNING si live no disponible |
| `/api/servers/latest` | Sigue leyendo almacenamiento local | OK o WARNING si no hay snapshots persistidos |
| `/api/servers/history` | Sigue leyendo almacenamiento local | OK o WARNING si no hay snapshots persistidos |
Comando de validacion de produccion tras redeploy:
```powershell
$base = "https://comunidadhll.devzamode.es"
Invoke-WebRequest "$base/api/servers" |
Select-Object -ExpandProperty Content
Invoke-WebRequest "$base/api/servers/latest" |
Select-Object -ExpandProperty Content
Invoke-WebRequest "$base/api/servers/history" |
Select-Object -ExpandProperty Content
python .\scripts\audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --filter servers --output tmp\task228_servers_audit_after.json
```
## Estado post-fix TASK-229
Fecha: 2026-06-10
Alcance aplicado: endpoints historicos legacy agregados que seguian agotando timeout tras `TASK-228`.
URLs afectadas:
- `GET /api/historical/server-summary?server=all-servers`
- `GET /api/historical/recent-matches?server=all-servers&limit=20`
Causa confirmada:
- `scripts/audit_public_requests.py` etiqueta esas rutas como `historical-server-summary-all-servers` y `historical-recent-matches-all-servers`.
- `backend/app/routes.py` las mapea a `build_historical_server_summary_payload()` y `build_recent_historical_matches_payload()`.
- Antes del fix, esos builders legacy intentaban read model RCON y, si no habia cobertura suficiente, caian a storage CRCON/PostgreSQL legacy.
- Para `server=all-servers`, el fallback ejecutaba agregaciones globales o inicializacion de display storage en lectura publica. En produccion esa cadena agotaba el timeout de 30 s.
- Los endpoints snapshot equivalentes ya respondian rapido porque solo leen snapshots precomputados.
Cambios aplicados:
- `build_historical_server_summary_payload(server_slug="all-servers")` delega en `build_historical_server_summary_snapshot_payload()`.
- `build_recent_historical_matches_payload(server_slug="all-servers")` delega en `build_recent_historical_matches_snapshot_payload()`.
- Ambos mantienen `context` legacy y `items`, pero declaran `source: historical-precomputed-snapshots` y `legacy_endpoint_policy: snapshot-read-only-fast-path`.
- Si falta snapshot, la respuesta sigue siendo JSON controlado y rapido, sin RCON live, sin fallback runtime pesado y sin `initialize_*` de storage historico desde estos paths agregados.
Estado esperado tras redeploy:
| Endpoint | Estado TASK-229 en codigo | Severidad esperada tras deploy |
| --- | --- | --- |
| `/api/historical/server-summary?server=all-servers` | Wrapper legacy sobre snapshot read-only | OK o WARNING si snapshot missing, sin timeout |
| `/api/historical/recent-matches?server=all-servers&limit=20` | Wrapper legacy sobre snapshot read-only | OK o WARNING si snapshot missing, sin timeout |
| `/api/historical/snapshots/server-summary?server=all-servers` | Snapshot read-only | OK o WARNING si snapshot missing |
| `/api/historical/snapshots/recent-matches?server=all-servers&limit=100` | Snapshot read-only | OK o WARNING si snapshot missing |
Comando de validacion de produccion tras redeploy:
```powershell
python .\scripts\audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --filter servers --output tmp\task229_servers_recheck_after.json
```
## Estado post-fix TASK-230
Fecha: 2026-06-10
Alcance aplicado: ultimo `CRITICAL` de la auditoria global posterior a `TASK-229`.
URL afectada:
- `GET /api/historical/server-summary?server=comunidad-hispana-01`
Evidencia de produccion previa al fix:
- Probe: `historical-server-summary-comunidad-hispana-01`.
- HTTP 200.
- `10120.89 ms`.
- `fallback=False`.
- `historical-server-summary-comunidad-hispana-02` respondia en `139.37 ms`.
- El snapshot equivalente de CH01 respondia en `42.14 ms`.
Causa confirmada:
- `scripts/audit_public_requests.py` etiqueta la ruta como `historical-server-summary-comunidad-hispana-01`.
- `backend/app/routes.py` la mapea a `build_historical_server_summary_payload(server_slug="comunidad-hispana-01")`.
- Tras `TASK-229`, solo `server=all-servers` tenia snapshot fast-path.
- CH01 y CH02 seguian entrando en `get_rcon_historical_read_model().list_server_summaries(...)`.
- El read model RCON construye cada resumen con `_build_server_summary()`, que llama a `list_rcon_historical_recent_activity(server_key=..., limit=1)` para enriquecer actividad reciente.
- Esa lectura intenta materialized RCON/AdminLog (`list_materialized_rcon_matches`) antes del fallback de ventanas. En CH01 ese camino fue lento aunque exitoso (`fallback=False`); CH02 uso el mismo flujo pero con coste bajo.
Cambios aplicados:
- `build_historical_server_summary_payload()` usa snapshot fast-path para cualquier `server_slug` explicito.
- CH01, CH02 y `all-servers` quedan como wrappers legacy sobre `build_historical_server_summary_snapshot_payload()`.
- El contrato conserva `context: historical-server-summary`, `items`, `summary_basis`, `weekly_ranking_window_days` y `legacy_endpoint_policy: snapshot-read-only-fast-path`.
- Si falta snapshot, responde JSON controlado con `items: []`, sin RCON live, sin scoreboard externo, sin `initialize_*` y sin fallback runtime pesado.
Estado esperado tras redeploy:
| Endpoint | Estado TASK-230 en codigo | Severidad esperada tras deploy |
| --- | --- | --- |
| `/api/historical/server-summary?server=comunidad-hispana-01` | Wrapper legacy sobre snapshot read-only | OK o WARNING si snapshot missing, sin timeout |
| `/api/historical/server-summary?server=comunidad-hispana-02` | Wrapper legacy sobre snapshot read-only | OK o WARNING si snapshot missing, sin empeorar |
| `/api/historical/server-summary?server=all-servers` | Wrapper legacy sobre snapshot read-only | OK o WARNING si snapshot missing, sin empeorar |
Comando de validacion de produccion tras redeploy:
```powershell
python .\scripts\audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --output tmp\full_audit_after_task230.json
```
## Estado post-fix TASK-231
Fecha: 2026-06-10
Alcance aplicado: dos `CRITICAL` restantes de recent-matches legacy por servidor tras `TASK-230`.
URLs afectadas:
- `GET /api/historical/recent-matches?server=comunidad-hispana-01&limit=20`
- `GET /api/historical/recent-matches?server=comunidad-hispana-02&limit=20`
Evidencia de produccion previa al fix:
- `historical-recent-matches-comunidad-hispana-01`: timeout `30032.24 ms`.
- `historical-recent-matches-comunidad-hispana-02`: timeout `30027.15 ms`.
- `snapshot-recent-matches-comunidad-hispana-01`: `77.50 ms`, OK.
- `snapshot-recent-matches-comunidad-hispana-02`: `51.11 ms`, OK.
- `historical-recent-matches-all-servers`: `50.66 ms`, OK.
Causa confirmada:
- `scripts/audit_public_requests.py` etiqueta esas rutas como `historical-recent-matches-comunidad-hispana-01` y `historical-recent-matches-comunidad-hispana-02`.
- `backend/app/routes.py` las mapea a `build_recent_historical_matches_payload(limit=20, server_slug=...)`.
- Tras `TASK-229`, solo `server=all-servers` tenia snapshot fast-path.
- CH01 y CH02 seguian entrando en `get_rcon_historical_read_model().list_recent_activity(...)`.
- El read model llama a `list_rcon_historical_recent_activity()`, que intenta materialized RCON/AdminLog (`list_materialized_rcon_matches`) antes del fallback por ventanas.
- Si RCON no cubria o no alcanzaba el `limit`, el builder podia intentar completar con `list_recent_historical_matches()` desde storage legacy CRCON/PostgreSQL display.
Cambios aplicados:
- `build_recent_historical_matches_payload()` usa snapshot fast-path para cualquier `server_slug` explicito.
- CH01, CH02 y `all-servers` quedan como wrappers legacy sobre `build_recent_historical_matches_snapshot_payload()`.
- El contrato conserva `context: historical-recent-matches`, `items`, `limit`, `server_slug`, `historical_data_source`, `coverage_basis` y `legacy_endpoint_policy: snapshot-read-only-fast-path`.
- Si falta snapshot, responde JSON controlado con `items: []`, sin RCON live, sin scoreboard externo, sin `initialize_*` y sin fallback runtime pesado.
Estado esperado tras redeploy:
| Endpoint | Estado TASK-231 en codigo | Severidad esperada tras deploy |
| --- | --- | --- |
| `/api/historical/recent-matches?server=comunidad-hispana-01&limit=20` | Wrapper legacy sobre snapshot read-only | OK o WARNING si snapshot missing, sin timeout |
| `/api/historical/recent-matches?server=comunidad-hispana-02&limit=20` | Wrapper legacy sobre snapshot read-only | OK o WARNING si snapshot missing, sin timeout |
| `/api/historical/recent-matches?server=all-servers&limit=20` | Wrapper legacy sobre snapshot read-only | OK o WARNING si snapshot missing, sin empeorar |
Comando de validacion de produccion tras redeploy:
```powershell
python .\scripts\audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --output tmp\full_audit_after_task231.json
```
## Evidencia ejecutada
Comandos ejecutados:
```powershell
python -m compileall backend\app
python -m py_compile scripts\audit_public_requests.py
cd backend
python -m unittest tests.test_rcon_materialization_pipeline
```
Resultado:
- `compileall`: OK.
- `py_compile`: OK.
- `unittest`: OK, 9 tests en 0.588s. El test existente emitio `ResourceWarning` por conexiones SQLite sin cerrar, sin fallo de test.
Auditoria local:
- `http://127.0.0.1:8000/health` no estaba disponible desde el host, por lo que no se lanzo la matriz completa local.
Auditoria produccion:
```powershell
python scripts\audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --output tmp\public_request_audit.json
```
Resultado automatico:
- Probes lanzados: 191.
- `OK`: 77.
- `WARNING`: 110.
- `CRITICAL`: 4.
- HTTP 200: 187.
- HTTP 500: 1.
- Timeout/error sin status: 3.
- Warnings con `fallback_used=true`: 109.
- Snapshots con `snapshot_status=missing`: 75.
Probes manuales adicionales dependientes de `player_id`:
| Endpoint | Resultado | Tiempo | Severidad |
| --- | ---: | ---: | --- |
| `/api/stats/players/76561198092154180?timeframe=weekly&server_id=all` | timeout | 30094.15 ms | CRITICAL |
| `/api/stats/players/76561198092154180?timeframe=monthly&server_id=all` | timeout | 30046.21 ms | CRITICAL |
| `/api/historical/player-profile?player=76561198092154180` | 200 | 4736.64 ms | WARNING |
| `/api/historical/elo-mmr/player?server=all-servers&player=76561198092154180` | 200 | 46.87 ms | WARNING |
El `player_id` se obtuvo desde `/api/ranking?timeframe=weekly&server_id=all&metric=kills&limit=1`.
## Inventario backend
Rutas HTTP publicas extraidas de `backend/app/routes.py`: 36 patrones.
| ID | Metodo | Path | Handler/payload builder | Parametros | Fuente/modelo esperado |
| --- | --- | --- | --- | --- | --- |
| B001 | GET | `/health` | `build_health_payload` | ninguno | config/runtime status |
| B002 | GET | `/api/community` | `build_community_payload` | ninguno | payload estatico |
| B003 | GET | `/api/trailer` | `build_trailer_payload` | ninguno | payload estatico |
| B004 | GET | `/api/discord` | `build_discord_payload` | ninguno | payload estatico |
| B005 | GET | `/api/servers` | `build_servers_payload` | ninguno | latest snapshots; si stale refresca RCON/A2S |
| B006 | GET | `/api/servers/latest` | `build_server_latest_payload` | ninguno | `list_latest_snapshots` |
| B007 | GET | `/api/servers/history` | `build_server_history_payload` | `limit` | `list_snapshot_history` |
| B008 | GET | `/api/servers/{id}/history` | `build_server_detail_history_payload` | `id`, `limit` | `list_server_history` |
| B009 | GET | `/api/stats/players/search` | `build_stats_player_search_payload` | `q`, `server_id/server`, `limit` | player search read model; fallback runtime RCON materialized |
| B010 | GET | `/api/stats/rankings/annual` | `build_annual_ranking_snapshot_payload` | `year`, `metric=kills`, `server_id/server`, `limit` | annual ranking snapshot |
| B011 | GET | `/api/ranking` | `build_global_ranking_payload` | `timeframe`, `server_id/server`, `metric`, `limit`, `year` for annual | ranking snapshots; annual snapshots |
| B012 | GET | `/api/current-match` | `build_current_match_payload` | `server` | direct RCON sample; fallback `/api/servers` path |
| B013 | GET | `/api/current-match/kills` | `build_current_match_kill_feed_payload` | `server`, `limit`, `since_event_id` | AdminLog storage |
| B014 | GET | `/api/current-match/players` | `build_current_match_player_stats_payload` | `server` | AdminLog storage |
| B015 | GET | `/api/stats/players/{player_id}` | `build_stats_player_profile_payload` | `player_id`, `timeframe`, `server_id/server` | player period stats read model; fallback runtime |
| B016 | GET | `/api/historical/weekly-top-kills` | `build_weekly_top_kills_payload` | `limit`, `server` | legacy historical storage |
| B017 | GET | `/api/historical/leaderboard` | `build_historical_leaderboard_payload` | `limit`, `server`, `metric`, `timeframe` | RCON read model plus public-scoreboard fallback |
| B018 | GET | `/api/historical/weekly-leaderboard` | `build_weekly_leaderboard_payload` | `limit`, `server`, `metric` | legacy weekly storage |
| B019 | GET | `/api/historical/monthly-leaderboard` | `build_monthly_leaderboard_payload` | `limit`, `server`, `metric` | legacy monthly storage |
| B020 | GET | `/api/historical/monthly-mvp` | `build_monthly_mvp_payload` | `limit`, `server` | legacy monthly storage |
| B021 | GET | `/api/historical/monthly-mvp-v2` | `build_monthly_mvp_v2_payload` | `limit`, `server` | legacy monthly storage |
| B022 | GET | `/api/historical/player-events` | `build_player_event_payload` | `limit`, `server`, `view` | legacy player event storage |
| B023 | GET | `/api/historical/snapshots/leaderboard` | `build_leaderboard_snapshot_payload` | `limit`, `server`, `metric`, `timeframe` | displayed historical snapshots |
| B024 | GET | `/api/historical/snapshots/monthly-leaderboard` | `build_monthly_leaderboard_snapshot_payload` | `limit`, `server`, `metric` | displayed historical snapshots |
| B025 | GET | `/api/historical/snapshots/monthly-mvp` | `build_monthly_mvp_snapshot_payload` | `limit`, `server` | displayed historical snapshots |
| B026 | GET | `/api/historical/snapshots/monthly-mvp-v2` | `build_monthly_mvp_v2_snapshot_payload` | `limit`, `server` | displayed historical snapshots |
| B027 | GET | `/api/historical/snapshots/player-events` | `build_player_event_snapshot_payload` | `limit`, `server`, `view` | displayed historical snapshots |
| B028 | GET | `/api/historical/snapshots/weekly-leaderboard` | `build_weekly_leaderboard_snapshot_payload` | `limit`, `server`, `metric` | displayed historical snapshots |
| B029 | GET | `/api/historical/recent-matches` | `build_recent_historical_matches_payload` | `limit`, `server` | RCON read model plus scoreboard merge |
| B030 | GET | `/api/historical/snapshots/recent-matches` | `build_recent_historical_matches_snapshot_payload` | `limit`, `server` | displayed historical snapshots |
| B031 | GET | `/api/historical/matches/detail` | `build_historical_match_detail_payload` | `server`, `match` | RCON materialized detail; fallback scoreboard detail |
| B032 | GET | `/api/historical/server-summary` | `build_historical_server_summary_payload` | `server` | RCON read model plus fallback |
| B033 | GET | `/api/historical/snapshots/server-summary` | `build_historical_server_summary_snapshot_payload` | `server` | displayed historical snapshots |
| B034 | GET | `/api/historical/player-profile` | `build_historical_player_profile_payload` | `player` | legacy historical profile |
| B035 | GET | `/api/historical/elo-mmr/leaderboard` | `build_elo_mmr_leaderboard_payload` | `limit`, `server` | Elo/MMR engine/read storage, currently fallback/paused in public payload |
| B036 | GET | `/api/historical/elo-mmr/player` | `build_elo_mmr_player_payload` | `player`, `server` | Elo/MMR engine/read storage, currently fallback/paused in public payload |
## Matriz completa de peticiones
La tabla usa rangos cuando una ruta se lanzo con varias combinaciones representativas. El JSON probe-a-probe esta en `tmp/public_request_audit.json`.
| ID | Tipo | Archivo origen | Pagina/contexto | Metodo | Endpoint/path | Parametros requeridos | Ejemplo de URL real | Handler/backend function | Fuente de datos | Usa snapshot/read-model/materialized/legacy/RCON/directo | Ejecuta initialize/ensure/bootstrap en lectura publica | Riesgo fallback pesado | Riesgo timeout | Loading/error frontend | Validacion ejecutada | Resultado HTTP | Tiempo medido | Tamano respuesta | Observaciones | Severidad | Recomendacion |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| M001 | backend-api | `backend/app/routes.py` | health | GET | `/health` | ninguno | `/health` | `build_health_payload` | config | status directo | no | no | bajo | n/a | produccion | 200 | 210.37 ms | 264 B | OK | OK | mantener |
| M002 | backend-api | `backend/app/routes.py` | landing metadata | GET | `/api/community` | ninguno | `/api/community` | `build_community_payload` | estatico | directo | no | no | bajo | n/a | produccion | 200 | 22.33 ms | 203 B | OK | OK | mantener |
| M003 | backend-api | `backend/app/routes.py` | trailer | GET | `/api/trailer` | ninguno | `/api/trailer` | `build_trailer_payload` | estatico | directo | no | no | bajo | main usa error controlado | produccion | 200 | 33.08 ms | 139 B | OK | OK | mantener |
| M004 | backend-api | `backend/app/routes.py` | discord | GET | `/api/discord` | ninguno | `/api/discord` | `build_discord_payload` | estatico | directo | no | no | bajo | n/a | produccion | 200 | 47.26 ms | 137 B | OK | OK | mantener |
| M005 | backend-api | `backend/app/routes.py` | server cards | GET | `/api/servers` | ninguno | `/api/servers` | `build_servers_payload` | latest snapshots + live refresh | snapshot/RCON/A2S | no DDL, pero red externa en lectura si stale | si | medio | main tiene fallback visual | produccion | 200 | 4311.38 ms | 1751 B | `source=real-time-rcon-refresh` | WARNING | servir snapshot estricto y mover refresh fuera de request |
| M006 | backend-api | `backend/app/routes.py` | server history | GET | `/api/servers/latest` | ninguno | `/api/servers/latest` | `build_server_latest_payload` | local snapshot storage | snapshot legacy | posible `initialize_storage` por storage local | no | bajo | n/a | produccion | 200 | 42.17 ms | 184 B | OK | OK | mantener |
| M007 | backend-api | `backend/app/routes.py` | server history | GET | `/api/servers/history` | `limit` | `/api/servers/history?limit=20` | `build_server_history_payload` | local snapshot storage | snapshot legacy | posible `initialize_storage` por storage local | no | bajo | n/a | produccion | 200 | 40.59 ms | 167 B | OK | OK | mantener |
| M008 | backend-api | `backend/app/routes.py` | server history detail | GET | `/api/servers/{id}/history` | `id`, `limit` | `/api/servers/comunidad-hispana-01/history?limit=20` | `build_server_detail_history_payload` | local snapshot storage | snapshot legacy | posible `initialize_storage` por storage local | no | bajo | n/a | produccion | 200 | 46.53-78.34 ms | 194 B | OK | OK | mantener |
| M009 | backend-api | `backend/app/routes.py` | stats search | GET | `/api/stats/players/search` | `q`, `server_id/server`, `limit` | `/api/stats/players/search?q=Medu&server_id=all&limit=10` | `build_stats_player_search_payload` | player search index | read-model + runtime materialized fallback | si: `initialize_player_search_index_storage` -> `initialize_rcon_materialized_storage` -> `initialize_postgres_rcon_storage` | si | alto | stats puede quedar esperando hasta timeout navegador | produccion | timeout | 30019.46 ms | 0 B | probe CRITICAL | CRITICAL | primer fix: read-only estricto, sin inicializar ni fallback runtime |
| M010 | backend-api | `backend/app/routes.py` | stats annual ranking | GET | `/api/stats/rankings/annual` | `year`, `metric=kills`, `server_id/server`, `limit` | `/api/stats/rankings/annual?year=2026&metric=kills&server_id=all&limit=10` | `build_annual_ranking_snapshot_payload` | annual ranking snapshot | snapshot | no en lectura normal | no | bajo | stats tiene error controlado | produccion | 200 | 60.07 ms | 5236 B | OK | OK | mantener |
| M011 | backend-api | `backend/app/routes.py` | ranking | GET | `/api/ranking` | `timeframe`, `server_id/server`, `metric`, `limit`, `year` anual | `/api/ranking?timeframe=weekly&server_id=all&metric=kills&limit=20` | `build_global_ranking_payload` | ranking snapshots | snapshot/read-model | no en lectura snapshot; fallback runtime opcional por env | si si env fallback esta activo | bajo | ranking usa AbortController/requestId | produccion | 200 | 31.40-129.56 ms | 755-5668 B | 55 probes OK | OK | mantener fallback runtime desactivado en publico |
| M012 | backend-api | `backend/app/routes.py` | partida actual resumen | GET | `/api/current-match` | `server` | `/api/current-match?server=comunidad-hispana-01` | `build_current_match_payload` | RCON live | directo RCON + fallback `/api/servers` | no DDL | si, por fallback live | medio | partida actual tiene in-flight guard, sin timeout | produccion | 200 | 1242.08-2155.20 ms | 808-820 B | OK pero red externa en request | OK | mover a snapshot/read-model publico |
| M013 | backend-api | `backend/app/routes.py` | partida actual kills | GET | `/api/current-match/kills` | `server`, `limit`, `since_event_id` | `/api/current-match/kills?server=comunidad-hispana-01&limit=30` | `build_current_match_kill_feed_payload` | AdminLog | materialized AdminLog | si: `initialize_rcon_admin_log_storage` -> `initialize_postgres_rcon_storage` | desconocido | alto | polling 1.5s con in-flight guard, sin timeout | produccion | timeout/500 | 6268.78-30024.86 ms | 0-58 B | CH01 timeout, CH02 500 y luego timeout manual | CRITICAL | segundo fix: read-only connection y query/index audit |
| M014 | backend-api | `backend/app/routes.py` | partida actual jugadores | GET | `/api/current-match/players` | `server` | `/api/current-match/players?server=comunidad-hispana-01` | `build_current_match_player_stats_payload` | AdminLog | materialized AdminLog | si: `initialize_rcon_admin_log_storage` -> `initialize_postgres_rcon_storage` | desconocido | alto | polling 3s con in-flight guard, sin timeout | produccion | timeout/200 | 1090.32-30096.62 ms | 0-72280 B | CH01 timeout; CH02 OK | CRITICAL | segundo fix junto con kills |
| M015 | backend-api | `backend/app/routes.py` | stats player detail | GET | `/api/stats/players/{player_id}` | `player_id`, `timeframe`, `server_id/server` | `/api/stats/players/76561198092154180?timeframe=weekly&server_id=all` | `build_stats_player_profile_payload` | player period stats | read-model + runtime fallback | si: `initialize_player_period_stats_storage` -> `initialize_rcon_materialized_storage` -> `initialize_postgres_rcon_storage` | si | alto | stats sin AbortController/timeout | manual produccion | timeout | 30046.21-30094.15 ms | 0 B | weekly/monthly CRITICAL | CRITICAL | primer fix junto con player search |
| M016 | backend-api | `backend/app/routes.py` | legacy historical top kills | GET | `/api/historical/weekly-top-kills` | `limit`, `server` | `/api/historical/weekly-top-kills?server=all-servers&limit=10` | `build_weekly_top_kills_payload` | historical storage | legacy/fallback | si en Postgres display fallback paths | si | bajo | n/a | produccion | 200 | 53.72-75.73 ms | 806 B | `fallback_used=true` | WARNING | convertir a snapshot estricto o marcar legacy |
| M017 | backend-api | `backend/app/routes.py` | legacy historical leaderboard | GET | `/api/historical/leaderboard` | `limit`, `server`, `metric`, `timeframe` | `/api/historical/leaderboard?server=all-servers&timeframe=weekly&metric=kills&limit=10` | `build_historical_leaderboard_payload` | RCON/historical storage | read-model + legacy fallback | si en legacy display paths | si | bajo | n/a | produccion | 200 | 38-132 ms | ~1375-1414 B | `fallback_used=true` en muchos probes | WARNING | documentar deprecacion o hacer snapshot-only |
| M018 | backend-api | `backend/app/routes.py` | legacy weekly leaderboard | GET | `/api/historical/weekly-leaderboard` | `limit`, `server`, `metric` | `/api/historical/weekly-leaderboard?server=comunidad-hispana-01&metric=kills&limit=10` | `build_weekly_leaderboard_payload` | historical storage | legacy | si en Postgres display paths | si | bajo | n/a | produccion | 200 | 55-58 ms | ~1380 B | fallback | WARNING | snapshot-only o mantener como legacy controlado |
| M019 | backend-api | `backend/app/routes.py` | legacy monthly leaderboard | GET | `/api/historical/monthly-leaderboard` | `limit`, `server`, `metric` | `/api/historical/monthly-leaderboard?server=comunidad-hispana-02&metric=kills&limit=10` | `build_monthly_leaderboard_payload` | historical storage | legacy | si en Postgres display paths | si | bajo | n/a | produccion | 200 | 57-116 ms | ~1360 B | fallback | WARNING | snapshot-only o mantener como legacy controlado |
| M020 | backend-api | `backend/app/routes.py` | monthly MVP | GET | `/api/historical/monthly-mvp` | `limit`, `server` | `/api/historical/monthly-mvp?server=all-servers&limit=10` | `build_monthly_mvp_payload` | historical storage | legacy | si en Postgres display paths | si | bajo | n/a | produccion | 200 | 43-58 ms | 1470 B | fallback | WARNING | snapshot-only |
| M021 | backend-api | `backend/app/routes.py` | monthly MVP V2 | GET | `/api/historical/monthly-mvp-v2` | `limit`, `server` | `/api/historical/monthly-mvp-v2?server=all-servers&limit=10` | `build_monthly_mvp_v2_payload` | historical storage | legacy | si en Postgres display paths | si | bajo | n/a | produccion | 200 | 40-72 ms | 1509 B | fallback | WARNING | snapshot-only |
| M022 | backend-api | `backend/app/routes.py` | player events | GET | `/api/historical/player-events` | `limit`, `server`, `view` | `/api/historical/player-events?server=comunidad-hispana-01&view=duels&limit=10` | `build_player_event_payload` | player event storage | legacy | posible initialize legacy storage | si | bajo | n/a | produccion | 200 | 38-67 ms | ~1144-1156 B | fallback | WARNING | snapshot-only |
| M023 | backend-api | `backend/app/routes.py` | snapshot leaderboard | GET | `/api/historical/snapshots/leaderboard` | `limit`, `server`, `metric`, `timeframe` | `/api/historical/snapshots/leaderboard?server=all-servers&timeframe=weekly&metric=kills&limit=10` | `build_leaderboard_snapshot_payload` | displayed snapshots | snapshot | si en Postgres: `get_snapshot` -> `initialize_postgres_display_storage` | no pesado hoy | bajo | historico muestra fallback visual | produccion | 200 | 40-110 ms | ~1596-1646 B | muchos `snapshot_status=missing` | WARNING | quitar DDL/init de `get_snapshot`; completar snapshots |
| M024 | backend-api | `backend/app/routes.py` | snapshot monthly leaderboard | GET | `/api/historical/snapshots/monthly-leaderboard` | `limit`, `server`, `metric` | `/api/historical/snapshots/monthly-leaderboard?server=comunidad-hispana-02&metric=kills&limit=10` | `build_monthly_leaderboard_snapshot_payload` | displayed snapshots | snapshot | si en Postgres display | no pesado hoy | bajo | n/a | produccion | 200 | 41-116 ms | ~1612 B | snapshot missing/fallback | WARNING | completar snapshots y read-only |
| M025 | backend-api | `backend/app/routes.py` | snapshot monthly MVP | GET | `/api/historical/snapshots/monthly-mvp` | `limit`, `server` | `/api/historical/snapshots/monthly-mvp?server=all-servers&limit=10` | `build_monthly_mvp_snapshot_payload` | displayed snapshots | snapshot | si en Postgres display | no pesado hoy | bajo | n/a | produccion | 200 | 42-78 ms | 1506 B | snapshot missing/fallback | WARNING | completar snapshots |
| M026 | backend-api | `backend/app/routes.py` | snapshot monthly MVP V2 | GET | `/api/historical/snapshots/monthly-mvp-v2` | `limit`, `server` | `/api/historical/snapshots/monthly-mvp-v2?server=all-servers&limit=10` | `build_monthly_mvp_v2_snapshot_payload` | displayed snapshots | snapshot | si en Postgres display | no pesado hoy | bajo | n/a | produccion | 200 | 40-41 ms | 1539 B | snapshot missing/fallback | WARNING | completar snapshots |
| M027 | backend-api | `backend/app/routes.py` | snapshot player events | GET | `/api/historical/snapshots/player-events` | `limit`, `server`, `view` | `/api/historical/snapshots/player-events?server=all-servers&view=duels&limit=10` | `build_player_event_snapshot_payload` | displayed snapshots | snapshot | si en Postgres display | no pesado hoy | bajo | n/a | produccion | 200 | 40-57 ms | ~1176-1188 B | snapshot missing/fallback | WARNING | completar snapshots |
| M028 | backend-api | `backend/app/routes.py` | snapshot weekly leaderboard | GET | `/api/historical/snapshots/weekly-leaderboard` | `limit`, `server`, `metric` | `/api/historical/snapshots/weekly-leaderboard?server=comunidad-hispana-01&metric=kills&limit=10` | `build_weekly_leaderboard_snapshot_payload` | displayed snapshots | snapshot | si en Postgres display | no pesado hoy | bajo | n/a | produccion | 200 | 44-70 ms | ~1610 B | snapshot missing/fallback | WARNING | completar snapshots |
| M029 | backend-api | `backend/app/routes.py` | historico recent matches legacy | GET | `/api/historical/recent-matches` | `limit`, `server` | `/api/historical/recent-matches?server=comunidad-hispana-01&limit=20` | `build_recent_historical_matches_payload` | snapshot recent matches | wrapper legacy sobre snapshot para cualquier `server=` explicito | no | no | bajo | n/a | pendiente redeploy TASK-231 | pendiente | pendiente | pendiente | `legacy_endpoint_policy=snapshot-read-only-fast-path` | OK o WARNING sin timeout | mantener como compatibilidad legacy |
| M030 | backend-api | `backend/app/routes.py` | historico recent snapshot | GET | `/api/historical/snapshots/recent-matches` | `limit`, `server` | `/api/historical/snapshots/recent-matches?server=all-servers&limit=20` | `build_recent_historical_matches_snapshot_payload` | displayed snapshots | snapshot | si en Postgres display | no | bajo | historico handles error | produccion | 200 | 48-82 ms | ~24185 B | OK | OK | mantener, quitar init display despues |
| M031 | backend-api | `backend/app/routes.py` | historico match detail | GET | `/api/historical/matches/detail` | `server`, `match` | `/api/historical/matches/detail?server=comunidad-hispana-01&match=comunidad-hispana-01:1781023156:1781028555:purpleheartlanewarfare` | `build_historical_match_detail_payload` | RCON materialized detail; scoreboard fallback | materialized/read-model + fallback legacy | si: `get_materialized_rcon_match_detail` -> `initialize_rcon_materialized_storage` -> `initialize_postgres_rcon_storage`; fallback display init | si | medio | historico-partida muestra error, sin timeout | produccion | 200 | 120.47 ms | 125580 B | hoy OK, deuda de init persiste | OK | hardening posterior read-only estricto |
| M032 | backend-api | `backend/app/routes.py` | historico server summary legacy | GET | `/api/historical/server-summary` | `server` | `/api/historical/server-summary?server=comunidad-hispana-01` | `build_historical_server_summary_payload` | snapshot server summary | wrapper legacy sobre snapshot para cualquier `server=` explicito | no | no | bajo | n/a | pendiente redeploy TASK-230 | pendiente | pendiente | pendiente | `legacy_endpoint_policy=snapshot-read-only-fast-path` | OK o WARNING sin timeout | mantener como compatibilidad legacy |
| M033 | backend-api | `backend/app/routes.py` | historico server summary snapshot | GET | `/api/historical/snapshots/server-summary` | `server` | `/api/historical/snapshots/server-summary?server=all-servers` | `build_historical_server_summary_snapshot_payload` | displayed snapshots | snapshot | si en Postgres display | no | bajo | historico handles missing | produccion | 200 | 50.03-62.60 ms | ~1042 B | `fallback_used=true` | WARNING | completar snapshot/read-only |
| M034 | backend-api | `backend/app/routes.py` | historical player profile | GET | `/api/historical/player-profile` | `player` | `/api/historical/player-profile?player=76561198092154180` | `build_historical_player_profile_payload` | legacy historical profile | legacy | si en Postgres display/historical fallback | si | medio | n/a | manual produccion | 200 | 4736.64 ms | no capturado | fallback true | WARNING | no cargar de inicio; optimizar si se mantiene publico |
| M035 | backend-api | `backend/app/routes.py` | Elo/MMR leaderboard | GET | `/api/historical/elo-mmr/leaderboard` | `limit`, `server` | `/api/historical/elo-mmr/leaderboard?server=all-servers&limit=10` | `build_elo_mmr_leaderboard_payload` | Elo/MMR storage | legacy/paused | posible `initialize_elo_mmr_storage` si engine carga | si | bajo | no usado por frontend actual | produccion | 200 | 58.35 ms | 2257 B | fallback/paused | WARNING | no reactivar; mantener fuera de UI |
| M036 | backend-api | `backend/app/routes.py` | Elo/MMR player | GET | `/api/historical/elo-mmr/player` | `player`, `server` | `/api/historical/elo-mmr/player?server=all-servers&player=76561198092154180` | `build_elo_mmr_player_payload` | Elo/MMR storage | legacy/paused | posible `initialize_elo_mmr_storage` si engine carga | si | bajo | no usado por frontend actual | manual produccion | 200 | 46.87 ms | no capturado | fallback true | WARNING | no reactivar; mantener fuera de UI |
| M037 | frontend-fetch | `frontend/assets/js/main.js` | `index.html` | GET | `/health` | ninguno | `${backendBaseUrl}/health` | backend B001 | config status | directo | no | no | bajo | usa `try/catch`; no bloquea pagina completa | codigo + produccion | 200 | 210.37 ms | 264 B | usado para estado backend | OK | mantener |
| M038 | frontend-fetch | `frontend/assets/js/main.js` | `index.html` | GET | `/api/trailer` | ninguno | `${backendBaseUrl}/api/trailer` | backend B003 | estatico | directo | no | no | bajo | error controlado | codigo + produccion | 200 | 33.08 ms | 139 B | OK | OK | mantener |
| M039 | frontend-fetch | `frontend/assets/js/main.js` | `index.html` | GET | `/api/servers` | ninguno | `${backendBaseUrl}/api/servers` | backend B005 | snapshots/live | RCON refresh posible | no DDL, si red live | si | medio | fallback visual; `Promise.allSettled` | codigo + produccion | 200 | 4311.38 ms | 1751 B | puede ralentizar landing | WARNING | no refrescar live en request publica |
| M040 | frontend-fetch | `frontend/assets/js/historico.js` | `historico.html` | GET | `/api/historical/snapshots/server-summary` | `server` | `/api/historical/snapshots/server-summary?server=comunidad-hispana-01` | backend B033 | snapshot | snapshot | si display init | no | bajo | requestId/cache/error | codigo + produccion | 200 | 50-63 ms | ~1042 B | snapshot missing/fallback | WARNING | completar snapshot y read-only |
| M041 | frontend-fetch | `frontend/assets/js/historico.js` | `historico.html` | GET | `/api/historical/snapshots/recent-matches` | `server`, `limit` | `/api/historical/snapshots/recent-matches?server=comunidad-hispana-01&limit=20` | backend B030 | snapshot | snapshot | si display init | no | bajo | requestId/cache/error | codigo + produccion | 200 | 48-82 ms | ~24 KB | OK | OK | mantener |
| M042 | frontend-fetch | `frontend/assets/js/historico.js` | `historico.html` | GET | `/api/historical/snapshots/leaderboard` | `server`, `timeframe`, `metric`, `limit` | `/api/historical/snapshots/leaderboard?server=comunidad-hispana-01&timeframe=weekly&metric=kills&limit=10` | backend B023 | snapshot | snapshot | si display init | no | bajo | requestId/cache/error | codigo + produccion | 200 | 40-110 ms | ~1.6 KB | snapshot missing/fallback | WARNING | completar snapshots |
| M043 | frontend-fetch | `frontend/assets/js/historico-recent-live.js` | `historico.html` | GET | `/api/historical/snapshots/recent-matches` | `server`, `limit` | `/api/historical/snapshots/recent-matches?server=comunidad-hispana-02&limit=20` | backend B030 | snapshot | snapshot | si display init | no | bajo | `try/catch`; cache no-store | codigo + produccion | 200 | 48-82 ms | ~24 KB | duplicable con historico.js | OK | revisar si ambos scripts piden lo mismo |
| M044 | frontend-fetch | `frontend/assets/js/historico-partida.js` | `historico-partida.html` | GET | `/api/historical/matches/detail` | `server`, `match` | `/api/historical/matches/detail?server=...&match=...` | backend B031 | materialized/fallback | materialized RCON | si | si | medio | error visual; sin timeout/abort | codigo + produccion | 200 | 120.47 ms | 125580 B | fallback localhost corregido previamente | OK | hardening read-only despues |
| M045 | frontend-fetch | `frontend/assets/js/partida-actual.js` | `partida-actual.html` | GET | `/api/current-match` | `server` | `/api/current-match?server=comunidad-hispana-01` | backend B012 | RCON live | directo RCON | no | si | medio | in-flight guard; sin timeout | codigo + produccion | 200 | 1242-2155 ms | ~820 B | polling | OK | mover a snapshot |
| M046 | frontend-fetch | `frontend/assets/js/partida-actual.js` | `partida-actual.html` | GET | `/api/current-match/kills` | `server`, `limit`, `since_event_id` | `/api/current-match/kills?server=comunidad-hispana-01&limit=30` | backend B013 | AdminLog | materialized | si | desconocido | alto | in-flight guard; sin timeout | codigo + produccion | timeout/500 | 6269-30025 ms | 0-58 B | polling frecuente | CRITICAL | corregir backend y agregar timeout frontend |
| M047 | frontend-fetch | `frontend/assets/js/partida-actual.js` | `partida-actual.html` | GET | `/api/current-match/players` | `server` | `/api/current-match/players?server=comunidad-hispana-01` | backend B014 | AdminLog | materialized | si | desconocido | alto | in-flight guard; sin timeout | codigo + produccion | timeout/200 | 1090-30097 ms | 0-72 KB | polling frecuente | CRITICAL | corregir backend y agregar timeout frontend |
| M048 | frontend-fetch | `frontend/assets/js/ranking.js` | `ranking.html` | GET | `/api/ranking` | `timeframe`, `server_id`, `metric`, `limit`, `year` | `/api/ranking?timeframe=weekly&server_id=all&metric=kills&limit=100` | backend B011 | ranking snapshots | snapshot | no en fast path | no | bajo | AbortController + requestId + error | codigo + produccion | 200 | 31-130 ms | variable | OK | OK | mantener |
| M049 | frontend-fetch | `frontend/assets/js/stats.js` | `stats.html` | GET | `/health` | ninguno | `${backendBaseUrl}/health` | backend B001 | config status | directo | no | no | bajo | error controlado | codigo + produccion | 200 | 210 ms | 264 B | OK | OK | mantener |
| M050 | frontend-fetch | `frontend/assets/js/stats.js` | `stats.html` | GET | `/api/stats/players/search` | `q`, opcional `server_id`, `limit` | `/api/stats/players/search?q=Medu&server_id=all&limit=10` | backend B009 | player read model | read-model/fallback | si | si | alto | sin abort/timeout; loading puede durar mucho | codigo + produccion | timeout | 30019 ms | 0 B | CRITICAL | CRITICAL | primer fix |
| M051 | frontend-fetch | `frontend/assets/js/stats.js` | `stats.html` | GET | `/api/stats/rankings/annual` | `year`, `metric`, `server_id`, `limit` | `/api/stats/rankings/annual?year=2026&metric=kills&server_id=all&limit=10` | backend B010 | annual snapshot | snapshot | no | no | bajo | error controlado | codigo + produccion | 200 | 60 ms | 5236 B | OK | OK | mantener |
| M052 | frontend-fetch | `frontend/assets/js/stats.js` | `stats.html` | GET | `/api/stats/players/{player_id}` | `player_id`, `timeframe`, `server_id` | `/api/stats/players/76561198092154180?timeframe=weekly&server_id=all` | backend B015 | player period stats | read-model/fallback | si | si | alto | sin abort/timeout; puede quedarse cargando hasta timeout navegador | codigo + manual produccion | timeout | 30046-30094 ms | 0 B | CRITICAL | CRITICAL | primer fix |
| M053 | static-asset | `frontend/*.html`, `frontend/assets/css`, `frontend/assets/js` | paginas publicas | GET | assets estaticos | path estatico | `/assets/js/ranking.js` | servidor estatico | filesystem/web server | n/a | no | no | bajo | navegador gestiona error de asset | no medida HTTP individual | n/a | n/a | n/a | no se tocaron assets | OK | mantener cache headers fuera de esta task |
| M054 | static-asset | `frontend/assets/img/**` | imagenes publicas | GET | assets imagen | path estatico | `/assets/img/...` | servidor estatico | filesystem/web server | n/a | no | no | bajo | navegador gestiona error de asset | no medida HTTP individual | n/a | n/a | n/a | no se tocaron imagenes, SVGs, weapons ni clans | OK | sin cambios |
| M055 | internal-runner | `backend/app/historical_runner.py` | worker historico | n/a | no publico | args CLI/env | n/a | runner | RCON/public scoreboard/storage | ingestion/fallback | si, por diseno de worker | si | n/a | n/a | lectura estatica | n/a | n/a | n/a | fuera de superficie publica | OK | no mezclar con fixes publicos |
| M056 | internal-runner | `backend/app/historical_ingestion.py` | ingestion historica | n/a | no publico | args CLI/env | n/a | runner | public scoreboard/RCON | ingestion/fallback | si, por diseno de worker | si | n/a | n/a | lectura estatica | n/a | n/a | n/a | fuera de superficie publica | OK | no ejecutar desde request publica |
| M057 | internal-runner | `backend/app/database_maintenance.py` | mantenimiento DB | n/a | no publico | args CLI/env | n/a | runner | DB | maintenance | si, por diseno de worker | n/a | n/a | n/a | lectura estatica | n/a | n/a | n/a | fuera de superficie publica | OK | mantener separado de GET |
| M058 | internal-runner | `scripts/audit_public_requests.py` | auditoria publica | GET | endpoints publicos | `--base-url`, `--timeout` | produccion/local | script stdlib | HTTP publico | n/a | no modifica app | no | configurable | n/a | ejecutado | 191 probes | ver JSON | ver JSON | artefacto de auditoria | OK | usar antes/despues de fixes |
## Endpoints OK
| Endpoint/familia | Evidencia |
| --- | --- |
| `/health`, `/api/community`, `/api/trailer`, `/api/discord` | 200, menos de 211 ms |
| `/api/servers/latest`, `/api/servers/history`, `/api/servers/{id}/history` | 200, 40-78 ms |
| `/api/ranking` semanal/mensual/anual, metricas `kills`, `deaths`, `teamkills`, `matches_considered`, `kd_ratio`, `kills_per_match`, scopes `all`, `comunidad-hispana-01`, `comunidad-hispana-02` | 55 probes OK, 31-130 ms |
| `/api/stats/rankings/annual` | 200, 60.07 ms |
| `/api/current-match` | 200, 1.2-2.2 s; OK funcional, riesgo arquitectonico por RCON directo |
| `/api/historical/recent-matches` | 200, 278-1286 ms |
| `/api/historical/snapshots/recent-matches` | 200, 48-82 ms |
| `/api/historical/matches/detail` con match conocido | 200, 120.47 ms |
| `/api/historical/server-summary` | 200, 95-145 ms |
## Endpoints WARNING
| Endpoint/familia | Motivo |
| --- | --- |
| `/api/servers` | 200 pero 4311.38 ms y `source=real-time-rcon-refresh`: red live durante lectura publica |
| `/api/historical/weekly-top-kills` | `fallback_used=true` |
| `/api/historical/leaderboard`, `/weekly-leaderboard`, `/monthly-leaderboard` | `fallback_used=true` y legacy fallback |
| `/api/historical/monthly-mvp`, `/monthly-mvp-v2` | `fallback_used=true` |
| `/api/historical/player-events` | `fallback_used=true` |
| `/api/historical/snapshots/*` salvo recent-matches | `snapshot_status=missing` frecuente, `fallback_used=true` |
| `/api/historical/player-profile` | 200 pero 4736.64 ms manual y fallback |
| `/api/historical/elo-mmr/*` | endpoints expuestos pero fallback/pausados; no reactivar ni poner en UI |
| frontend `data-backend-base-url`/fallback local | 13 referencias a localhost/127.0.0.1 en HTML/JS publico; `config.js` mitiga en produccion pero el residuo existe |
## Endpoints CRITICAL
| Endpoint | Evidencia | Recomendacion |
| --- | --- | --- |
| `/api/stats/players/search?q=Medu&server_id=all&limit=10` | timeout 30019.46 ms | read-only estricto del player search index; no inicializar storage ni fallback runtime en request publica |
| `/api/stats/players/{player_id}?timeframe=weekly&server_id=all` | timeout 30094.15 ms | igual que search; revisar `player_period_stats` |
| `/api/stats/players/{player_id}?timeframe=monthly&server_id=all` | timeout 30046.21 ms | igual que search; revisar `player_period_stats` |
| `/api/current-match/kills?server=comunidad-hispana-01&limit=30` | timeout 30024.86 ms | quitar init de AdminLog en lectura; revisar indices/query |
| `/api/current-match/players?server=comunidad-hispana-01` | timeout 30096.62 ms | quitar init de AdminLog en lectura; revisar ventana actual e indices |
| `/api/current-match/kills?server=comunidad-hispana-02&limit=30` | 500 en 6268.78 ms; repeticion manual hizo timeout | capturar error backend y estabilizar query |
## Inicializacion en lectura publica
Rutas donde una peticion publica puede acabar ejecutando inicializacion, DDL o bootstrap de storage:
| Ruta publica | Cadena | Tiempo observado/potencial | Estado | Recomendacion |
| --- | --- | --- | --- | --- |
| `/api/historical/matches/detail` | `build_historical_match_detail_payload` -> `get_rcon_historical_match_detail` -> `get_materialized_rcon_match_detail` -> `initialize_rcon_materialized_storage` -> `initialize_postgres_rcon_storage` | historico reciente midio 14s antes; esta auditoria 120 ms para match conocido | funcional pero no limpio | mantener como fix posterior: conexion read-only sin init y fallback estricto |
| `/api/stats/players/search` | `build_stats_player_search_payload` -> `search_rcon_materialized_players` -> `_search_player_search_index` -> `initialize_player_search_index_storage` -> `initialize_rcon_materialized_storage` -> `initialize_postgres_rcon_storage`; fallback `_search_rcon_materialized_players_runtime` | 30s timeout | CRITICAL | primer fix |
| `/api/stats/players/{player_id}` | `build_stats_player_profile_payload` -> `get_rcon_materialized_player_stats` -> `_get_player_period_stats_read_model` -> `initialize_player_period_stats_storage` -> `initialize_rcon_materialized_storage` -> `initialize_postgres_rcon_storage`; fallback runtime | 30s timeout | CRITICAL | primer fix junto a search |
| `/api/current-match/kills` | `build_current_match_kill_feed_payload` -> `list_current_match_kill_feed` -> `initialize_rcon_admin_log_storage` -> `initialize_postgres_rcon_storage` | 30s timeout / 500 | CRITICAL | segundo fix |
| `/api/current-match/players` | `build_current_match_player_stats_payload` -> `list_current_match_player_stats` -> `initialize_rcon_admin_log_storage` -> `initialize_postgres_rcon_storage` | 30s timeout en CH01 | CRITICAL | segundo fix |
| `/api/historical/snapshots/*` | snapshot builder -> `get_historical_snapshot` -> Postgres `get_snapshot` -> `initialize_postgres_display_storage` | rapido hoy, 40-116 ms | WARNING | read-only snapshot connection |
| legacy `/api/historical/*` | payloads -> `historical_storage` -> Postgres display fallback functions | rapido hoy salvo player-profile 4.7s | WARNING | snapshot-only o legacy explicit |
| `/api/ranking` | weekly/monthly fast path uses read-only snapshot connection; `initialize_ranking_snapshot_storage` solo deberia ocurrir en generation/fallback. Runtime fallback existe por env | 31-130 ms | OK con condicion | mantener fallback runtime apagado en publico |
| annual ranking | `get_annual_ranking_snapshot` usa read connection | 31-88 ms | OK | mantener |
## Fallbacks pesados detectados
| Endpoint | Fallback | Activacion | Coste estimado | Lo muestra frontend | Recomendacion |
| --- | --- | --- | --- | --- | --- |
| `/api/stats/players/search` | runtime search en `rcon_match_player_stats` | indice vacio/no disponible/error | alto, timeout 30s | no claramente | eliminar fallback runtime en publico o devolver estado `snapshot_missing` rapido |
| `/api/stats/players/{player_id}` | runtime player stats desde materialized matches | `player_period_stats` vacio/no disponible | alto, timeout 30s | no claramente | eliminar fallback runtime publico |
| `/api/current-match/kills/players` | AdminLog init + queries sobre ventana actual | siempre entra por helper actual | alto en CH01 | UI queda esperando | read-only + indices; respuesta vacia rapida si no hay ventana |
| `/api/servers` | RCON live refresh y A2S fallback si snapshots stale | snapshots ausentes/stale | medio, 4.3s medido | no como fallback tecnico | snapshot estricto en publico; refresh asincrono |
| `/api/historical/recent-matches` | merge con public-scoreboard persisted fallback | RCON insuficiente | medio, 1.3s max | no | frontend ya debe preferir snapshot |
| legacy `/api/historical/*` | public-scoreboard/display fallback | RCON no soporta o snapshot missing | bajo hoy pero acoplado | parcialmente por metadata | deprecar o snapshot-only |
| `/api/historical/matches/detail` | public-scoreboard detail fallback | RCON detail no encontrado | potencial alto si display init se repite | no destacado | fallback estricto y rapido |
## Auditoria frontend
Paginas publicas revisadas:
- `frontend/index.html`
- `frontend/historico.html`
- `frontend/historico-partida.html`
- `frontend/partida-actual.html`
- `frontend/ranking.html`
- `frontend/stats.html`
Scripts publicos revisados:
- `frontend/assets/js/config.js`
- `frontend/assets/js/main.js`
- `frontend/assets/js/historico.js`
- `frontend/assets/js/historico-recent-live.js`
- `frontend/assets/js/historico-partida.js`
- `frontend/assets/js/partida-actual.js`
- `frontend/assets/js/ranking.js`
- `frontend/assets/js/stats.js`
Hallazgos frontend:
| Pagina | Fetchs | Loading/error | Timeout/abort/requestId | Riesgo |
| --- | --- | --- | --- | --- |
| `index.html` | `/health`, `/api/trailer`, `/api/servers` | error controlado; no bloquea todo por `Promise.allSettled` | sin timeout | WARNING por `/api/servers` lento |
| `historico.html` | snapshots server-summary/recent/leaderboard y recent-live | requestId/cache/error visual | requestId; sin timeout generico | WARNING por snapshots missing/fallback |
| `historico-partida.html` | `/api/historical/matches/detail` | error visual si falla | sin timeout/abort | OK hoy, deuda si endpoint vuelve lento |
| `partida-actual.html` | current match, kills, players | in-flight guards; no abort | sin timeout; polling frecuente | CRITICAL por endpoints kills/players |
| `ranking.html` | `/api/ranking` | loading/error correcto | AbortController + requestId | OK |
| `stats.html` | `/health`, player search, annual ranking, player profile | errores visibles pero search/profile pueden esperar demasiado | sin AbortController/requestId robusto para search/profile; sin timeout | CRITICAL por search/profile |
Referencias localhost/127.0.0.1 en frontend publico:
- `frontend/assets/js/config.js`: default dev backend y deteccion localhost.
- `frontend/assets/js/historico-recent-live.js`
- `frontend/assets/js/historico.js`
- `frontend/assets/js/main.js`
- `frontend/assets/js/partida-actual.js`
- `frontend/assets/js/ranking.js`
- `frontend/assets/js/stats.js`
- `frontend/historico.html`
- `frontend/index.html`
- `frontend/partida-actual.html`
- `frontend/ranking.html`
- `frontend/stats.html`
`frontend/historico-partida.html` ya no conserva `data-backend-base-url` local. La mitigacion en `config.js` evita que un host no local use el default dev, pero la deuda sigue existiendo porque hay HTML/JS publico con fallback local.
## Top 10 riesgos actuales
1. `/api/stats/players/search` hace inicializacion/read-model/fallback runtime en lectura publica y vence a 30s.
2. `/api/stats/players/{player_id}` hace inicializacion/read-model/fallback runtime en lectura publica y vence a 30s.
3. `/api/current-match/kills` vence a 30s o devuelve 500 segun servidor.
4. `/api/current-match/players` vence a 30s en `comunidad-hispana-01`.
5. `/api/servers` puede hacer refresh RCON en request publica y tarda 4.3s.
6. `get_snapshot` de snapshots historicos ejecuta `initialize_postgres_display_storage` en lectura.
7. Muchos snapshots historicos estan `missing`, generando `fallback_used=true` en 75+ probes.
8. Hay residuos de fallback `127.0.0.1`/`localhost` en HTML/JS publico.
9. `stats.js`, `partida-actual.js` e `historico-partida.js` no tienen timeout HTTP propio.
10. Endpoints legacy historicos siguen expuestos con fallback dinamico aunque el frontend ya usa snapshots para lo principal.
## Top 10 recomendaciones priorizadas
1. Crear task backend para `/api/stats/players/search` y `/api/stats/players/{player_id}`: read-only estricto, sin `initialize_*` ni fallback runtime en GET publico.
2. Crear task backend para `/api/current-match/kills` y `/api/current-match/players`: quitar init de AdminLog en lectura, revisar indices y devolver payload vacio rapido si no hay ventana actual.
3. Crear task backend para `/api/servers`: no hacer refresh RCON/A2S desde GET publico; responder snapshot y dejar refresh a worker.
4. Crear task frontend para `stats.js`: AbortController/requestId/timeout y estado de error rapido para search/profile.
5. Crear task frontend para `partida-actual.js`: timeout/abort por request y backoff si kills/players fallan.
6. Crear task backend para `historical_snapshot_storage`/`postgres_display_storage.get_snapshot`: conexion read-only sin DDL/init en lectura.
7. Crear task de generacion/validacion snapshots historicos faltantes: reducir `snapshot_status=missing`.
8. Crear task de limpieza de fallbacks locales en frontend publico, manteniendo soporte dev explicito sin contaminar produccion.
9. Crear task backend para hardening de `/api/historical/matches/detail`: read-only estricto y fallback rapido aunque hoy responda OK.
10. Crear task de deprecacion/control de endpoints legacy `/api/historical/*` no usados por frontend o exponerlos solo como legacy con limites claros.
## Fixes propuestos como tasks pequenas
1. `TASK-225-stats-player-read-model-public-fast-path`: `/api/stats/players/search` y `/api/stats/players/{player_id}` sin inicializacion ni fallback runtime en lectura publica.
2. `TASK-226-current-match-adminlog-public-fast-path`: `/api/current-match/kills` y `/api/current-match/players` read-only, indices y errores controlados.
3. `TASK-227-servers-public-snapshot-only`: `/api/servers` no refresca RCON/A2S durante request publica.
4. `TASK-228-frontend-stats-request-timeouts`: timeout/abort/requestId para `stats.js`.
5. `TASK-229-frontend-current-match-request-timeouts`: timeout/backoff para `partida-actual.js`.
6. `TASK-230-historical-snapshot-storage-read-only`: quitar `initialize_postgres_display_storage` del path de lectura de snapshots.
7. `TASK-231-historical-snapshot-coverage-refresh`: completar snapshots faltantes usados por `historico.html`.
8. `TASK-232-frontend-public-backend-url-hardening`: eliminar fallbacks locales de HTML/JS publico de produccion.
9. `TASK-233-historical-match-detail-read-only-hardening`: hardening de `/api/historical/matches/detail`.
10. `TASK-234-legacy-historical-endpoint-policy`: inventario/limites/deprecacion de legacy endpoints publicos.
## Comandos de auditoria
Desde host contra produccion:
```powershell
python scripts\audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --output tmp\public_request_audit.json
```
Desde host contra backend local:
```powershell
python scripts\audit_public_requests.py --base-url http://127.0.0.1:8000 --timeout 30 --output tmp\public_request_audit.json
```
Dentro del contenedor backend desde PowerShell, sin copiar archivos al contenedor:
```powershell
Get-Content scripts\audit_public_requests.py | docker compose exec -T backend python - --base-url http://127.0.0.1:8000 --timeout 30 --output /app/data/public_request_audit.json
```
Equivalente shell:
```bash
cat scripts/audit_public_requests.py | docker compose exec -T backend python - --base-url http://127.0.0.1:8000 --timeout 30 --output /app/data/public_request_audit.json
```
Compilacion del script:
```powershell
python -m py_compile scripts\audit_public_requests.py
```
## Notas de alcance
- No se ejecuto `ai-platform run`.
- No se hizo commit.
- No se hizo push.
- No se aplicaron fixes funcionales.
- No se modifico logica productiva backend ni frontend.
- No se tocaron assets, SVGs, imagenes fisicas, `frontend/assets/img/weapons/`, `frontend/assets/img/clans/` ni `ai/system-metrics.md`.
- No se reactivo Elo/MMR.
- No se agrego ningun servidor nuevo.
@@ -1,259 +0,0 @@
# Historical Match Kills Per Minute Analysis
## Scope
This document defines how real historical KPM works for the match detail served to `historico-partida.html`.
## Why KPM Was Not Safe Before
Historical match detail already exposed:
- per player:
- `kills`
- `deaths`
- `teamkills`
- weapon and matchup counters
- per match:
- `started_at`
- `ended_at`
- `duration_seconds`
What it did not expose was player-level active time. Because of that, dividing by total match duration would have produced a false player KPM.
## Real KPM Rule
Real KPM means:
```text
kills / (player_active_seconds / 60)
```
It does not mean:
```text
kills / match_duration_minutes
```
## Source of Truth
The current implementation uses connection intervals reconstructed from the materialized RCON AdminLog match model.
Reliable interval signals:
- `connected`
- `disconnected`
- `match_start`
- `match_end`
Supporting evidence still stored in the player fact:
- `first_seen_server_time`
- `last_seen_server_time`
The persisted field is:
- `player_active_seconds`
The source label is:
- `active_time_source = "event_log"`
## Persistence
Forward-only persistence now stores on `rcon_match_player_stats`:
- `player_active_seconds INTEGER NULL`
- `active_time_source TEXT`
Legacy rows remain valid. If they were materialized before these columns existed, they keep `player_active_seconds = NULL` until new materialization or new matches populate the field.
## Calculation
Observed active time is now:
```text
sum(connected_interval_seconds clamped to [match_start, match_end])
```
Rules:
- if the player connects during the match:
- open interval at `connected.server_time`
- if the player disconnects during the match:
- close interval at `disconnected.server_time`
- if the player was already connected before `match_start` and there is no later pre-match disconnect:
- open interval at `match_start`
- if the player is still connected at `match_end`:
- close interval at `match_end`
- if the player reconnects multiple times:
- sum all non-overlapping intervals
This is still observed presence, not exact telemetry down to every silent second.
KPM is exposed only when:
- `player_active_seconds` exists
- `player_active_seconds >= HLL_KPM_MIN_ACTIVE_SECONDS`
Default:
- `HLL_KPM_MIN_ACTIVE_SECONDS = 60`
## Payload Contract
Historical match detail player rows may now expose:
- `player_active_seconds`
- `player_active_minutes`
- `kpm`
- `kpm_status`
- `active_time_source`
`active_time_source` values currently used:
- `connection_intervals`
- `connection_intervals_carryover`
- `event_span_fallback`
- `unavailable`
`kpm_status` values:
- `ready`
- `missing_active_time`
- `insufficient_active_time`
- `missing_connection_intervals`
Rules:
- missing active time:
- `kpm = null`
- `kpm_status = "missing_active_time"`
- fallback event span without reliable connection intervals:
- `kpm = null`
- `kpm_status = "missing_connection_intervals"`
- active time below threshold:
- `kpm = null`
- `kpm_status = "insufficient_active_time"`
- valid active time:
- `kpm = round(kills / (player_active_seconds / 60), 2)`
- `kpm_status = "ready"`
- only when `active_time_source` is `connection_intervals` or `connection_intervals_carryover`
## Historical Matches Already Stored
Old matches are not backfilled with fake KPM.
For those rows:
- `player_active_seconds` can remain `null`
- `kpm` stays `null`
- frontend must not render `0.00` as if the value were real
- rows rematerialized without reliable connection intervals can still expose
`event_span_fallback`, but that fallback must not be shown as real KPM
## Frontend Rule
`historico-partida.js` renders KPM only when:
- `kpm_status == "ready"`
If the value is missing or below threshold, the panel stays clean and does not show a fake metric.
## Public Tables
The historical match detail now exposes the same real KPM in two public places:
- expanded player panel in `historico-partida.html`
- main players table in `historico-partida.html`
The main table leaves the KPM cell empty when `kpm_status != "ready"`.
This avoids false `0.00` values for:
- legacy rows without active time
- rows below `HLL_KPM_MIN_ACTIVE_SECONDS`
- rows that only expose `event_span_fallback`
## Personal Player Profile
The public player profile in `stats.html` can now expose real KPM for the selected weekly or monthly window when the lightweight player-period read model already contains enough data for that player and scope.
The profile endpoint does not use a runtime fallback over `rcon_match_player_stats` anymore for weekly/monthly public reads. That fallback was disabled because it could degrade public profile response times when the read model was empty or stale.
When the read model is available, profile KPM uses:
- `SUM(player_active_seconds)`
- only over rows with:
- `active_time_source = connection_intervals`
- `active_time_source = connection_intervals_carryover`
- `player_active_seconds >= HLL_KPM_MIN_ACTIVE_SECONDS`
Weekly and monthly profile KPM use:
```text
sum(eligible_kills) / (sum(eligible_player_active_seconds) / 60)
```
This is intentionally narrower than the window total:
- profile `kills` still show all kills in the selected window
- profile `KPM` only becomes `ready` when the active-time subset is reliable enough
The profile payload exposes:
- `player_active_seconds`
- `player_active_minutes`
- `kpm`
- `kpm_status`
- `active_time_source`
- `active_time_coverage`
If the profile read model is empty or unavailable:
- the endpoint keeps the profile fast
- `kpm` stays `null`
- `kpm_status` stays `missing_active_time`
- external links and profile identity fields remain available
This keeps the page responsive without inventing KPM.
## Kills Per Match Labels
Several public tables were showing `kills_per_match` under the visible label `KPM`.
That is no longer acceptable because:
- `kills_per_match` means kills divided by matches considered
- real KPM means kills divided by active minutes from reliable connection intervals
Public surfaces that now use `Kills/partida` instead of `KPM` for `kills_per_match`:
- historical weekly and monthly leaderboard tables
- annual stats summary table
- annual stats comparison cards
- ranking metric selector and ranking table label
## Aggregated Real KPM
This repository does not yet expose a public weekly, monthly or annual aggregate KPM based only on:
- `active_time_source = connection_intervals`
- `active_time_source = connection_intervals_carryover`
That was intentionally left out of this change because the public leaderboard and ranking snapshots would need an explicit coverage contract before mixing:
- rows with real active time
- older rows without it
- fallback rows blocked from `kpm_status = ready`
Until that contract exists, public aggregated views keep:
- `Kills/partida` for `kills_per_match`
- real KPM only at historical match detail player level
## Limitations
- This is observed active time from AdminLog connection evidence, not exact join/leave telemetry for every silent second.
- Quiet players with kills/chat/team switches but no reliable connection chain can expose `event_span_fallback`; that span is intentionally blocked from KPM.
- Legacy matches remain without KPM unless rematerialized from stored AdminLog evidence.
- We do not discount time spent without squad/unit/role yet because there is no audited historical source for that dimension in this implementation.
-417
View File
@@ -1,417 +0,0 @@
# Performance Public Query Audit
## Resumen Ejecutivo
Esta auditoria revisa las rutas publicas que alimentan `ranking`, `stats`, `historico`, `historico-partida`, `partida-actual` e `index`, con foco en latencia percibida por UI y en el cumplimiento de la regla arquitectonica de leer read models publicos propios en PostgreSQL.
Actualizacion TASK-225, 2026-06-10: la deuda P1 de `stats search` y `stats player profile` fue corregida en codigo para que los GET publicos usen `player_search_index` y `player_period_stats` en modo read-only estricto, sin inicializar storage ni caer a runtime fallback pesado. La medicion HTTP final requiere redeploy. `current-match` sigue pendiente porque depende de RCON live y debe tratarse como hardening/degradacion sin cambiar hosts, puertos ni configuracion RCON.
Actualizacion TASK-226, 2026-06-10: `/api/current-match/kills` y `/api/current-match/players` pasan a usar AdminLog en lectura publica sin inicializar storage y con degradacion JSON controlada si el read model no esta disponible o falla. `/api/servers` pasa a ser snapshot/cache-only en el GET publico y ya no dispara refresh RCON/A2S live durante la lectura. La deuda restante de `current-match` queda en `/api/current-match`, que todavia puede intentar una muestra RCON directa antes de caer a snapshot.
Actualizacion TASK-227, 2026-06-10: la auditoria real post-`TASK-226` mostro que kills/players seguian bloqueando en produccion porque la rama PostgreSQL de AdminLog no propagaba `ensure_storage=False` a `connect_postgres_compat()`. El fix propaga `initialize=ensure_storage`, de modo que `/api/current-match/kills` y `/api/current-match/players` ya no ejecutan `initialize_postgres_rcon_storage()` en el GET publico cuando se sirven como lecturas read-only.
Actualizacion TASK-228, 2026-06-10: `/api/servers` deja de ser cache-only estricto y vuelve a ser near-real-time controlado para la home. Sirve snapshot fresco si existe; si no hay cache o esta stale, intenta RCON/A2S con timeout publico corto y degrada a snapshot stale o JSON controlado si live falla. `/api/servers/latest` e `/api/servers/history` siguen siendo lecturas de almacenamiento local, no sustitutos del estado live.
Actualizacion TASK-229, 2026-06-10: los endpoints legacy agregados `/api/historical/server-summary?server=all-servers` y `/api/historical/recent-matches?server=all-servers&limit=20` dejan de ejecutar read model RCON o fallback runtime CRCON/PostgreSQL en lectura publica. Ahora son wrappers de los snapshots precomputados equivalentes, con `legacy_endpoint_policy=snapshot-read-only-fast-path`; si falta snapshot devuelven JSON controlado rapido en vez de agotar el timeout.
Actualizacion TASK-230, 2026-06-10: el ultimo `CRITICAL` de la auditoria global fue `/api/historical/server-summary?server=comunidad-hispana-01`, con HTTP 200, `fallback=False` y ~10.1 s. La causa estaba en el path legacy por servidor, que seguia entrando en el read model RCON y enriquecia el resumen con actividad materializada reciente. `server-summary` legacy pasa a usar snapshot fast-path para cualquier `server=` explicito (`all-servers`, `comunidad-hispana-01`, `comunidad-hispana-02`), manteniendo contrato compatible con `items` y metadata de snapshot.
Actualizacion TASK-231, 2026-06-10: los dos `CRITICAL` restantes fueron `/api/historical/recent-matches?server=comunidad-hispana-01&limit=20` y `/api/historical/recent-matches?server=comunidad-hispana-02&limit=20`, ambos timeout ~30 s. El fast-path de `TASK-229` solo cubria `all-servers`; los scopes individuales seguian entrando en RCON/materialized recent activity y podian caer al fallback legacy. `recent-matches` legacy pasa a usar snapshot fast-path para cualquier `server=` explicito y respeta el `limit` recibido.
Conclusiones principales:
- El backend de `ranking` ya no muestra el cuello de botella grave del ranking anual. La evidencia mas fuerte es el test `backend/tests/test_annual_ranking_payload.py`, que confirma que la lectura anual en PostgreSQL ya no inicializa storage en request publico.
- El cuello de botella visible actual mas claro esta en frontend: `frontend/assets/js/ranking.js` y `frontend/assets/js/stats.js` bloquean la carga principal detras de `/health`, y `ranking.js` no tiene proteccion contra request race ni limpieza robusta del estado de carga.
- `historico.js` es hoy la referencia mas sana del frontend publico: usa snapshots, cache TTL, deduplicacion de peticiones y `requestId` para ignorar respuestas obsoletas.
- `partida-actual.js` no bloquea por `/health`, pero hace polling agresivo y paralelo a tres endpoints (`/api/current-match`, `/api/current-match/kills`, `/api/current-match/players`) sin `AbortController`, con intervalos de 1.5 s y 3 s que pueden amplificar carga y re-render innecesario. Tras TASK-227, kills/players usan AdminLog PostgreSQL en modo read-only real y degradan desde backend en JSON controlado.
- En backend siguen existiendo fallbacks runtime publicos sobre tablas materializadas grandes para `ranking`, `stats search` y `stats player profile`. Son mejores que consultar RCON directo, pero siguen rompiendo la meta de servir lecturas publicas desde read models dedicados.
- Las queries runtime de leaderboard y player stats usan patrones como `COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT))` y agregaciones sobre `rcon_match_player_stats`, lo que aumenta riesgo de scans y de uso parcial de indices.
- `current-match` sigue consultando RCON directo en request publico cuando hay target confiable. `/api/servers` tambien consulta live de forma controlada cuando falta cache o esta stale porque la home requiere estado actual/casi actual.
## Mapa de Arquitectura de Lectura Publica
Flujo esperado:
1. Procesos internos leen RCON, AdminLog o scoreboard publico cuando aplica.
2. Procesos internos refrescan snapshots y read models en PostgreSQL.
3. Endpoints publicos leen solo read models publicos, sin recalculo runtime ni inicializacion de storage.
4. Frontend pide datos principales sin bloquearlos por checks tecnicos no esenciales.
Flujo observado:
- `ranking` y `stats` mezclan frontend secuencial con backend que aun puede caer a runtime sobre tablas materializadas si falta snapshot o read model.
- `historico` ya prioriza snapshots y fallback controlado.
- `current-match` expone una excepcion relevante: `/api/current-match` consulta RCON directo en la ruta publica cuando encuentra target valido. Kills/players leen AdminLog sin inicializar storage en request publico, incluyendo la rama PostgreSQL corregida en `TASK-227`. `/api/servers` usa refresh live acotado cuando el cache no sirve para mantener la home casi en tiempo real.
## Inventario de Endpoints Publicos
| Ruta | Frontend | Builder backend | Read model esperado | Lectura observada | Fallback/runtime | Riesgo |
|---|---|---|---|---|---|---|
| `/api/ranking` | `frontend/assets/js/ranking.js` | `build_global_ranking_payload()` | `ranking_snapshots`, `ranking_snapshot_items`, `rcon_annual_ranking_snapshots`, `rcon_annual_ranking_snapshot_items` | Annual lee snapshot anual; weekly/monthly leen snapshot y pueden caer a `list_rcon_materialized_leaderboard()` | Si `HLL_BACKEND_RANKING_RUNTIME_FALLBACK_ENABLED=true`, si | P0 |
| `/api/stats/players/search` | `frontend/assets/js/stats.js` | `build_stats_player_search_payload()` | `player_search_index` | Primero `player_search_index`, si falla cae a runtime contra `rcon_match_player_stats` + `rcon_materialized_matches` | Si | P1 |
| `/api/stats/players/{player_id}` | `frontend/assets/js/stats.js` | `build_stats_player_profile_payload()` | `player_period_stats` | Primero `player_period_stats`, si falta cae a runtime y ademas calcula weekly/monthly ranking en caliente | Si | P1 |
| `/api/stats/rankings/annual` | `frontend/assets/js/stats.js` | `build_annual_ranking_snapshot_payload()` | `rcon_annual_ranking_snapshots`, `rcon_annual_ranking_snapshot_items` | Snapshot anual | No recalcula ranking en lectura | P2 |
| `/api/historical/snapshots/leaderboard` | `frontend/assets/js/historico.js` | `build_rcon_materialized_leaderboard_snapshot_payload()` en modo rcon | Snapshot historico publico equivalente | En modo rcon el nombre dice snapshot, pero sirve runtime fast path sobre materialized leaderboard | Si, por definicion del endpoint en modo rcon | P1 |
| `/api/historical/snapshots/recent-matches` | `frontend/assets/js/historico.js` | `build_recent_historical_matches_snapshot_payload()` | Snapshot publico de recent matches | Snapshot precomputado read-only | No | P2 |
| `/api/historical/recent-matches?server=<scope>` | legacy historico audit | `build_recent_historical_matches_payload()` | Snapshot publico de recent matches | Wrapper legacy sobre snapshot precomputado para cualquier `server=` explicito | No | P2 |
| `/api/historical/server-summary?server=<scope>` | legacy historico audit | `build_historical_server_summary_payload()` | Snapshot publico de server summary | Wrapper legacy sobre snapshot precomputado para cualquier `server=` explicito | No | P2 |
| `/api/historical/matches/detail` | `frontend/assets/js/historico-partida.js` | `build_historical_match_detail_payload()` | Read model detalle de partida | Intenta `get_rcon_historical_match_detail()`, luego fallback a storage historico publico | Si | P2 |
| `/api/current-match` | `frontend/assets/js/partida-actual.js` | `build_current_match_payload()` | Read model live propio | Primero intenta `_query_current_match_rcon_sample()` directo; luego fallback a `/api/servers` snapshot | Si, y toca RCON directo | P0 |
| `/api/current-match/kills` | `frontend/assets/js/partida-actual.js` | `build_current_match_kill_feed_payload()` | Read model live propio de kill feed | AdminLog materializado en modo read-only publico; PostgreSQL usa `connect_postgres_compat(initialize=False)` | Degradacion JSON controlada si falla read model | P2 |
| `/api/current-match/players` | `frontend/assets/js/partida-actual.js` | `build_current_match_player_stats_payload()` | Read model live propio de player stats | AdminLog materializado en modo read-only publico; PostgreSQL usa `connect_postgres_compat(initialize=False)` | Degradacion JSON controlada si falla read model | P2 |
| `/api/servers` | `frontend/assets/js/main.js`, fallback de current-match | `build_servers_payload()` | Snapshot live de servidores | Snapshot fresco o refresh live RCON/A2S acotado si falta/stale | Stale snapshot o JSON controlado si live falla | P1 |
| `/health` | `frontend/assets/js/main.js`, `ranking.js`, `stats.js` | `build_health_payload()` | N/A | Check tecnico | No aplica | P1 por bloqueo UI, no por backend |
Notas:
- El endpoint equivalente real al pedido como `/api/current-match/player-stats` es `/api/current-match/players`.
- El equivalente real de `historico` no es una sola ruta `/api/historico`; el frontend consume varias rutas bajo `/api/historical/...`.
## Analisis Frontend
### Ranking
Archivo: `frontend/assets/js/ranking.js`
Hallazgos:
- La carga inicial depende de `refreshBackendHealth()` y solo despues llama `loadRanking()`.
- Si `/health` tarda, falla o llega fuera de orden respecto a cambios del usuario, la UI puede permanecer en `Cargando ranking global...` o en estado offline aunque `/api/ranking` sea rapido.
- No hay `AbortController` ni `currentRequestId`.
- Cada cambio de filtro dispara `loadRanking()` sin proteccion contra respuestas antiguas.
- No hay `finally` dedicado para limpiar loading o reactivar controles.
- `clearRankingSurface()` vacia tabla y meta antes de cada request, amplificando el parpadeo de UI.
Impacto:
- Alto en latencia percibida.
- Alto en riesgo de estado obsoleto.
### Stats
Archivo: `frontend/assets/js/stats.js`
Hallazgos:
- Repite el patron de esperar `/health` antes de cargar el ranking anual.
- `searchPlayers()` hace una sola request, pero cualquier error marca backend offline globalmente.
- `loadPlayerProfile()` usa `Promise.allSettled()` para semanal y mensual, lo cual es correcto, pero sigue dependiendo del flag global `isBackendOnline`.
- No hay cancelacion de busquedas sucesivas ni de perfiles sucesivos.
Impacto:
- Alto en UX.
- Medio en carga backend.
### Historico
Archivo: `frontend/assets/js/historico.js`
Fortalezas observadas:
- Usa `activeServerRequestId` y `activeLeaderboardRequestId`.
- Cachea snapshots con TTL.
- Deduplica peticiones en `pendingRequestCache`.
- Hidrata desde cache y luego refresca.
- No bloquea la carga principal detras de `/health`.
Riesgos residuales:
- Mucho uso de `innerHTML` completo en bloques grandes.
- El endpoint llamado como `snapshots/leaderboard` en modo rcon puede ser runtime fast path, lo que hace que la UI se vea sana aunque backend no este sirviendo un snapshot real.
### Historico Partida
Archivo: `frontend/assets/js/historico-partida.js`
Hallazgos:
- Hace una sola request principal de detalle.
- No hay `/health` previo.
- El costo fuerte parece mas de render DOM que de orchestration.
Riesgo:
- Bajo a medio.
### Partida Actual
Archivo: `frontend/assets/js/partida-actual.js`
Hallazgos:
- Polling en tres loops independientes:
- current match cada 30 s
- kills cada 1.5 s
- players cada 3 s
- Hay guardas `*_RefreshInFlight`, pero no `AbortController`.
- Si el usuario abandona la pagina sin descargar el documento, el polling sigue hasta destruir el contexto.
- `current-match` re-renderiza bloques completos con `innerHTML`.
- `kill feed` y `player stats` reducen re-render con `visibleSignature`, lo cual ayuda.
Riesgo:
- Alto para carga sostenida.
- Alto si la base live comparte recursos con lecturas publicas.
### Landing
Archivo: `frontend/assets/js/main.js`
Hallazgos:
- `fetchHealth()` es paralelo a `hydrateTrailer()` y `refreshServers()`, no bloqueante.
- El polling de servidores es cada 300 s y tiene guardas `serverRefreshInFlight`.
Riesgo:
- Bajo.
## Analisis Backend
### Confirmaciones positivas
- `backend/tests/test_annual_ranking_payload.py` confirma que `get_annual_ranking_snapshot()` no llama `initialize_rcon_materialized_storage()` en lectura PostgreSQL. Ese fix elimina el cuello de botella mas grave ya conocido del ranking anual.
- `routes.py` separa parseo/validacion de parametros y builders por endpoint de forma clara.
- `main.py` ya serializa `date` y `datetime` para no abortar respuestas por JSON.
### Riesgos de backend por area
#### Ranking publico
Archivos: `backend/app/payloads.py`, `backend/app/rcon_historical_leaderboards.py`, `backend/app/rcon_annual_rankings.py`
Hallazgos:
- Weekly/monthly `build_global_ranking_payload()` puede caer a `list_rcon_materialized_leaderboard()` si falta snapshot y el flag runtime fallback sigue activo.
- `list_rcon_materialized_leaderboard()` hace agregacion runtime sobre `rcon_match_player_stats` + `rcon_materialized_matches`.
- Las ventanas usan filtros sobre `COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT))`, lo que puede degradar indices y elevar costo.
Riesgo:
- El endpoint publico sigue siendo rapido hoy en muchos casos, pero no esta totalmente blindado contra crecimiento de datos.
#### Stats search
Archivo: `backend/app/rcon_historical_player_stats.py`
Hallazgos:
- `search_rcon_materialized_players()` usa `player_search_index` primero, bien.
- Si el read model falla o falta, cae a runtime contra tablas grandes.
- La busqueda runtime usa `LOWER(COALESCE(stats.player_name,'')) LIKE LOWER(?)`, agregacion por jugador y luego lookups adicionales de nombres y `servers_seen`.
Riesgo:
- Posible scan costoso y plan poco estable al crecer el historico.
#### Stats player profile
Archivo: `backend/app/rcon_historical_player_stats.py`
Hallazgos:
- `get_rcon_materialized_player_stats()` usa `player_period_stats` primero, bien.
- Si falta, cae a runtime y ademas consulta ranking semanal y mensual del jugador contra tablas materializadas.
- La ruta runtime agrega sobre stats, busca source range y ranking semanal/mensual en la misma lectura.
Riesgo:
- Latencia alta en cold path o con crecimiento de tabla.
#### Historical endpoints
Archivo: `backend/app/payloads.py`
Hallazgos:
- `build_recent_historical_matches_payload()` y `build_historical_match_detail_payload()` permiten fallback a storage publico cuando el read model RCON no cubre el caso.
- El test `test_public_scoreboard_fallback_used_only_without_rcon_activity` confirma que el fallback sigue activo y esperado.
Riesgo:
- Correcto como compatibilidad, pero dificulta garantizar tiempos uniformes y pureza de read model publico.
#### Current match
Archivo: `backend/app/payloads.py`
Hallazgos:
- `build_current_match_payload()` intenta `_query_current_match_rcon_sample()` en request publico.
- Solo si falla usa snapshot de `/api/servers`.
Riesgo:
- Este es el incumplimiento arquitectonico mas claro: request publico consultando RCON directo.
#### Conexiones y cold path
Archivos: `backend/app/postgres_rcon_storage.py`, `backend/app/config.py`
Hallazgos:
- Se usa `psycopg.connect(...)` por contexto, no se observa pool reutilizable.
- No se observan migrations/initializers pesados dentro de rutas publicas, salvo funciones de read model que aun llaman wrappers de initialize en algunas rutas runtime.
Riesgo:
- Medio. La ausencia de pooling puede no ser critica hoy, pero empeora cold starts y bursts cortos.
## Analisis PostgreSQL / Read Models
### Tablas publicas esperadas
- `player_search_index`
- `player_period_stats`
- `ranking_snapshots`
- `ranking_snapshot_items`
- `rcon_annual_ranking_snapshots`
- `rcon_annual_ranking_snapshot_items`
### Tablas grandes o candidatas a crecimiento
- `rcon_materialized_matches`
- `rcon_match_player_stats`
### Indices confirmados por codigo
- `idx_ranking_snapshots_lookup`
- `idx_ranking_snapshot_items_snapshot`
- `idx_ranking_snapshot_items_player`
- `idx_rcon_annual_ranking_snapshots_year`
- `idx_rcon_annual_ranking_snapshots_status`
- `idx_player_search_index_name`
- `idx_player_search_index_last_seen`
- `idx_player_search_index_player`
- `idx_player_period_stats_player_period_server`
- `idx_player_period_stats_server_period`
- `idx_player_period_stats_last_seen`
- `idx_player_period_stats_updated`
- `idx_rcon_materialized_matches_recent`
- indices textuales sobre `COALESCE(CAST(ended_at AS TEXT), CAST(started_at AS TEXT))`
- `idx_rcon_match_player_stats_match`
- `idx_rcon_match_player_stats_player_id_match`
### Riesgos de SQL
- Varios paths runtime filtran por `COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) >= ?`.
- Ese patron tiende a forzar expresiones calculadas en filtro, incluso aunque existan indices funcionales; es un area clara para `EXPLAIN ANALYZE`.
- `LIKE` sobre `LOWER(player_name)` puede degradarse sin estrategia de indice orientada a busqueda parcial.
- Agregaciones publicas sobre `COUNT(DISTINCT stats.match_key)` y `SUM(...)` en tablas grandes deben salir de snapshots o read models, no del path web.
### Queries candidatas a EXPLAIN ANALYZE
- Runtime fallback de `list_rcon_materialized_leaderboard()`
- Runtime fallback de `_search_rcon_materialized_players_runtime()`
- Runtime fallback de `_get_rcon_materialized_player_stats_runtime()`
- Lectura de `get_latest_ranking_snapshot()`
- Lectura de `get_annual_ranking_snapshot()`
- Lectura de `player_search_index` por `server_id + normalized_player_name`
- Lectura de `player_period_stats` por `player_id + period_type + server_id`
### Locks y refresh
- Los runners usan `backend_writer_lock` para procesos internos, lo cual ayuda a writers.
- Aun asi, refreshes completos de snapshots y read models pueden competir por IO con lecturas publicas si comparten la misma base y no se mide el impacto.
- La auditoria no encontro evidencia directa de locks de lectura publica, pero si suficiente motivo para instrumentarlos.
## Riesgos de Fallback / Runtime
Principio deseado:
- Publico: solo read models publicos.
- Interno: materializacion y recalculo.
Desviaciones observadas:
- `/api/ranking` weekly/monthly puede recalcular agregados runtime sobre tablas materializadas.
- `/api/stats/players/search` puede escanear runtime si falta `player_search_index`.
- `/api/stats/players/{player_id}` puede recalcular runtime si falta `player_period_stats`.
- `/api/current-match` consulta RCON directo en request publico.
- `historico` snapshot en modo rcon puede servir runtime fast path en lugar de snapshot real.
## Observabilidad Recomendada
Medicion backend por request:
- `endpoint`
- `status_code`
- `total_duration_ms`
- `db_duration_ms`
- `payload_build_duration_ms`
- `query_count`
- `read_model`
- `fallback_used`
- `fallback_reason`
- `snapshot_status`
- `payload_bytes`
- `timeframe`
- `server_id`
- `metric`
- `limit`
Medicion frontend por vista:
- `page`
- `request_started_at`
- `response_received_at`
- `render_completed_at`
- `perceived_latency_ms`
- `health_blocked_main_data` boolean
- `request_aborted`
- `request_superseded`
- `render_error`
- `stale_response_ignored`
Puntos concretos:
- `performance.mark()` / `performance.measure()` en `ranking.js`, `stats.js`, `partida-actual.js`
- Logging estructurado por endpoint en `main.py` o wrapper de builders
- Incluir `read_model`, `fallback_used`, `snapshot_status` y `payload_bytes` en la respuesta o en logs
- Contador de queries por request en paths publicos costosos
## Matriz de Hallazgos Priorizados
| Pri | Hallazgo | Impacto | Evidencia | Archivo | Propuesta | Riesgo | Validacion |
|---|---|---|---|---|---|---|---|
| P0 | `ranking.js` bloquea la carga principal detras de `/health` y no protege carreras | UI lenta o bloqueada aunque `/api/ranking` responda en <200 ms | Flujo `refreshBackendHealth() -> loadRanking()` y ausencia de `AbortController/currentRequestId` | `frontend/assets/js/ranking.js` | Separar health del dato principal y usar cancelacion o requestId | Bajo | medir `response_received_at -> render_completed_at` |
| P0 | `/api/current-match` consulta RCON directo en request publico | Rompe la regla arquitectonica y puede introducir latencia o fragilidad externa | `_query_current_match_rcon_sample()` se ejecuta antes del fallback a snapshot | `backend/app/payloads.py` | Crear read model live publico y mover la consulta RCON al runner | Medio | endpoint debe seguir respondiendo sin tocar RCON |
| P1 | `/api/ranking` weekly/monthly mantiene fallback runtime sobre tablas materializadas | Riesgo de latencia creciente y scans | `build_global_ranking_payload()` cae a `list_rcon_materialized_leaderboard()` | `backend/app/payloads.py` | Desactivar fallback runtime en publico tras completar snapshot coverage | Medio | requests solo con `read_model=ranking-snapshot` |
| P1 | `stats.js` tambien bloquea por `/health` el ranking anual inicial | Latencia percibida innecesaria | `refreshBackendHealth()` llama `loadAnnualRanking()` despues de `/health` | `frontend/assets/js/stats.js` | Cargar anual directo y tratar `/health` como señal secundaria | Bajo | anual visible sin depender de health |
| P1 | Search de jugadores puede caer a runtime costoso | Picos de latencia en busqueda | `search_rcon_materialized_players()` con fallback runtime | `backend/app/rcon_historical_player_stats.py` | Endurecer cobertura de `player_search_index` y alertar si falta | Bajo | `fallback_used=false` sostenido |
| P1 | Perfil de jugador puede caer a runtime y recomponer rankings semanales/mensuales | Respuestas lentas y carga de DB | `get_rcon_materialized_player_stats()` | `backend/app/rcon_historical_player_stats.py` | Exigir `player_period_stats` actualizado antes de publicar | Medio | `read_model=player-period-stats` |
| P1 | Endpoints snapshot historicos en modo rcon no siempre son snapshots reales | Ambiguedad operativa y mediciones engañosas | `build_rcon_materialized_leaderboard_snapshot_payload()` declara snapshot pero usa runtime materialized fast path | `backend/app/rcon_historical_leaderboards.py` | Renombrar politica o servir snapshot real | Medio | source/generation policy coherentes |
| P2 | Polling de current match demasiado agresivo | Carga sostenida y re-render frecuente | intervalos de 1.5 s / 3 s / 30 s | `frontend/assets/js/partida-actual.js` | Consolidar polling o usar fan-out backend/cache | Medio | bajar requests por minuto |
| P2 | Paths runtime usan `COALESCE(CAST(... AS TEXT))` en filtros temporales | Planes menos eficientes | multiples queries runtime en leaderboard y player stats | `backend/app/rcon_historical_leaderboards.py`, `backend/app/rcon_historical_player_stats.py` | Revisar predicados e indices funcionales con EXPLAIN | Medio | comparar buffers/scan time |
| P3 | No se observa pooling PostgreSQL reutilizable | Mayor costo de cold path y bursts | `psycopg.connect()` por contexto | `backend/app/postgres_rcon_storage.py` | Evaluar pool ligero cuando el resto del path este estabilizado | Medio | medir connect time y throughput |
## Plan de Tasks Recomendado
1. `TASK-215-decouple-public-frontend-data-load-from-health-checks`
Alcance: `ranking.js`, `stats.js`.
Objetivo: no bloquear datos principales por `/health`.
2. `TASK-216-add-request-race-protection-to-public-ranking-and-stats`
Alcance: `ranking.js`, `stats.js`.
Objetivo: `AbortController` o `currentRequestId`, cleanup robusto de loading.
3. `TASK-217-enforce-snapshot-only-public-ranking-read-path`
Alcance: backend publico de `ranking`.
Objetivo: eliminar fallback runtime en `/api/ranking` para weekly/monthly.
4. `TASK-218-enforce-read-model-only-public-player-search-and-profile`
Alcance: `player_search_index`, `player_period_stats`, runners, payloads.
Objetivo: que search y profile no caigan a runtime en request publico.
5. `TASK-219-create-live-public-current-match-read-model`
Alcance: current match.
Objetivo: sacar RCON directo del endpoint publico.
6. `TASK-220-add-public-request-performance-observability`
Alcance: backend + frontend instrumentation minima.
Objetivo: medir request, DB, payload, render y fallback.
7. `TASK-221-run-explain-analyze-for-public-runtime-and-read-model-queries`
Alcance: SQL audit operativa.
Objetivo: validar indices y predicados temporales.
@@ -1,603 +0,0 @@
# Real KPM Player Active Seconds Design
## Resumen ejecutivo
KPM real debe significar `kills / minutos_jugados_reales`. HLL Vietnam no puede calcularlo de forma publica y fiable hoy porque ningun read model materializa tiempo activo real por jugador y partida. Lo que existe actualmente permite dos lecturas parciales:
- Presencia observada por eventos AdminLog dentro de una partida: `rcon_match_player_stats.first_seen_server_time` y `last_seen_server_time`.
- Duracion de partida: `rcon_materialized_matches.started_server_time`, `ended_server_time`, `started_at`, `ended_at`.
La primera puede servir como base `observed` si se etiqueta claramente. La segunda no debe usarse como KPM real por jugador salvo como calidad `estimated`, y no deberia publicarse como KPM competitivo sin decision explicita futura.
Recomendacion: crear `rcon_match_player_presence` como tabla de detalle por jugador y partida, y derivar `player_active_seconds` hacia `rcon_match_player_stats` y read models publicos. Este enfoque conserva trazabilidad, permite backfill por calidad y evita mezclar datos exactos, observados y estimados.
## Definicion de KPM real
Formula:
```text
KPM = kills / (player_active_seconds / 60)
```
Reglas:
- El numerador debe ser kills agregadas del mismo scope que el tiempo.
- El denominador debe ser minutos reales jugados por el jugador, no minutos de partida.
- KPM debe calcularse en generacion de read models/snapshots, nunca en frontend salvo formateo.
- `kills_per_match` debe seguir llamandose `Kills/partida` o `KPP`; nunca KPM.
No es KPM:
- `kills / partidas`
- `kills_per_match`
- `kills / duracion completa de partida` si el jugador no estuvo toda la partida
- `kills / ventana observada` sin marcar calidad
## Estado actual
`TASK-216` confirmo que no existe hoy un campo fiable de tiempo jugado real por jugador materializado para ranking publico.
Campos existentes:
- `rcon_player_profile_snapshots.play_time`: texto de perfil, no normalizado, no por jugador/partida y no apto para ranking publico sin parser y reconciliacion.
- `rcon_match_player_stats.first_seen_server_time`: primer `server_time` observado para el jugador dentro de una partida materializada.
- `rcon_match_player_stats.last_seen_server_time`: ultimo `server_time` observado para el jugador dentro de una partida materializada.
- `rcon_materialized_matches.started_server_time` y `ended_server_time`: limites de partida, no presencia real por jugador.
- `rcon_materialized_matches.started_at` y `ended_at`: timestamps de partida, no presencia real por jugador.
`TASK-217` dejo `kills_per_match` como `Kills/partida` y no implemento KPM real.
## Por que no sirve kills_per_match
`kills_per_match` responde a otra pregunta: rendimiento medio por partida contabilizada.
Ejemplo:
```text
1222 kills / 32 partidas = 38.19 kills/partida
```
Eso no dice si el jugador estuvo 20 minutos, 45 minutos o 90 minutos por partida. Mostrar ese valor como KPM exagera o distorsiona la metrica porque el denominador no es tiempo real jugado.
## Fuentes de datos disponibles
### AdminLog raw
Tabla:
- `rcon_admin_log_events`
Campos relevantes:
- `target_key`
- `external_server_id`
- `event_timestamp`
- `server_time`
- `event_type`
- `parsed_payload_json`
- `raw_message`
Eventos parseados:
- `connected`
- `disconnected`
- `kill`
- `team_switch`
- `chat`
- `message`
- `match_start`
- `match_end`
Calidad:
- `connected` + `disconnected` dentro de limites de match pueden dar calidad `exact` si ambos eventos existen y se pueden emparejar de forma consistente.
- `kill`, `team_switch`, `chat` y `message` dan presencia `observed`, no presencia continua exacta.
- `server_time` es la unidad operativa mas util para duraciones intra-partida.
### rcon_match_player_stats
Tabla:
- `rcon_match_player_stats`
Campos relevantes:
- `target_key`
- `match_key`
- `player_id`
- `player_name`
- `kills`
- `deaths`
- `teamkills`
- `first_seen_server_time`
- `last_seen_server_time`
Calidad:
- Ya agrupa por jugador y partida.
- Sus `first_seen_server_time` y `last_seen_server_time` son presencia observada por eventos, no tiempo activo real garantizado.
- Es buen destino de agregado, pero no conserva suficiente detalle para auditar intervalos de conexion/desconexion.
### rcon_materialized_matches
Tabla:
- `rcon_materialized_matches`
Campos relevantes:
- `target_key`
- `external_server_id`
- `match_key`
- `started_server_time`
- `ended_server_time`
- `started_at`
- `ended_at`
- `source_basis`
Calidad:
- Define limites de partida.
- No debe usarse como tiempo por jugador sin presencia individual.
- Puede acotar intervalos de presencia para evitar duraciones negativas o fuera de partida.
### rcon_player_profile_snapshots
Tabla:
- `rcon_player_profile_snapshots`
Campos relevantes:
- `player_id`
- `source_server_time`
- `first_seen`
- `sessions`
- `matches_played`
- `play_time`
Calidad:
- `play_time` es texto de perfil y puede ser acumulado global del servidor.
- No esta normalizado por partida.
- No puede distribuirse con precision por weekly/monthly/annual.
- Puede servir como comparacion diagnostica, no como fuente primaria para KPM publico.
### player_period_stats y snapshots
Tablas:
- `player_period_stats`
- `ranking_snapshot_items`
- `rcon_annual_ranking_snapshot_items`
Estado:
- Tienen kills, deaths, teamkills, matches, K/D y Kills/partida.
- No tienen `player_active_seconds`, `playtime_quality` ni `kills_per_minute`.
## Evaluacion de calidad de datos
| Fuente | Calidad | Uso recomendado |
|---|---|---|
| `connected` + `disconnected` emparejados dentro de match | `exact` | Calcular intervalos reales acotados por partida |
| `connected` sin `disconnected` pero con match end | `observed` o `estimated` segun politica | Acotar hasta `ended_server_time`, no publicar como exacto |
| `disconnected` sin `connected` pero con match start | `observed` | Acotar desde primer evento observado o match start solo si se etiqueta |
| `kill`, `team_switch`, `chat`, `message` | `observed` | Inferir ventana `first_seen`/`last_seen` |
| Duracion completa de partida | `estimated` | Solo diagnostico o fallback no publico |
| Sin eventos suficientes | `unknown` | No calcular KPM |
| `rcon_player_profile_snapshots.play_time` | `unknown` para KPM por periodo | No usar como fuente primaria |
Valores recomendados para `playtime_quality`:
- `exact`: intervalos conectados/desconectados suficientemente cerrados y acotados por partida.
- `observed`: ventana inferida por eventos del jugador dentro de la partida.
- `estimated`: duracion completa o parcial inferida sin evidencia individual suficiente.
- `unknown`: no hay base util.
Regla publica recomendada:
- Publicar KPM solo con `exact` u `observed`.
- No publicar KPM para `estimated` o `unknown` salvo decision explicita futura y etiqueta visible.
## Modelo de datos propuesto
### Opcion A: anadir player_active_seconds a rcon_match_player_stats
Columnas nuevas:
- `player_active_seconds INTEGER`
- `playtime_quality TEXT NOT NULL DEFAULT 'unknown'`
- `playtime_source TEXT`
- `playtime_first_server_time BIGINT`
- `playtime_last_server_time BIGINT`
- `playtime_interval_count INTEGER NOT NULL DEFAULT 0`
Clave existente:
- `UNIQUE(target_key, match_key, player_id)`
Indices recomendados:
- `idx_rcon_match_player_stats_playtime_quality` sobre `(playtime_quality)`
- `idx_rcon_match_player_stats_player_playtime` sobre `(player_id, target_key, match_key, player_active_seconds)`
Como se rellena:
- Durante `materialize_rcon_admin_log`, despues de derivar stats por jugador.
- Se calcula a partir de eventos en la ventana del match.
- Si solo hay `first_seen_server_time` y `last_seen_server_time`, usar `observed`.
Ventajas:
- Menor cambio para read models existentes.
- Agregaciones weekly/monthly/annual simples.
Riesgos:
- Pierde detalle de intervalos exactos.
- Mezcla metrica agregada con evidencia.
- Es mas dificil auditar reconexiones multiples.
### Opcion B: crear rcon_match_player_presence
Tabla nueva propuesta:
```sql
CREATE TABLE rcon_match_player_presence (
id BIGSERIAL PRIMARY KEY,
target_key TEXT NOT NULL,
external_server_id TEXT,
match_key TEXT NOT NULL,
player_id TEXT NOT NULL,
player_name TEXT NOT NULL,
first_seen_server_time BIGINT,
last_seen_server_time BIGINT,
active_seconds INTEGER NOT NULL DEFAULT 0,
playtime_quality TEXT NOT NULL DEFAULT 'unknown',
interval_count INTEGER NOT NULL DEFAULT 0,
evidence_event_count INTEGER NOT NULL DEFAULT 0,
evidence_event_types TEXT NOT NULL DEFAULT '[]',
source_basis TEXT NOT NULL DEFAULT 'rcon-admin-log',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(target_key, match_key, player_id)
);
```
Indices recomendados:
- `(target_key, match_key)`
- `(player_id, target_key, match_key)`
- `(playtime_quality, active_seconds)`
- `(external_server_id, match_key)`
Como se rellena:
- Leer `rcon_admin_log_events` acotados por `rcon_materialized_matches.started_server_time` y `ended_server_time`.
- Construir intervalos por jugador:
- abrir intervalo en `connected`
- cerrar intervalo en `disconnected`
- acotar cada intervalo por match start/end
- sumar intervalos no solapados
- Si no hay intervalos completos, usar ventana de eventos observados:
- `first_seen_server_time = MIN(server_time)` de eventos del jugador
- `last_seen_server_time = MAX(server_time)` de eventos del jugador
- `active_seconds = max(0, last_seen - first_seen)`
- `playtime_quality = observed`
- Si solo existe una observacion puntual, `active_seconds = 0` o minimo configurable, calidad `observed-low-confidence`.
Para mantener enum simple, guardar `observed` y exponer `evidence_event_count`.
- No usar duracion completa de partida salvo como `estimated`.
Ventajas:
- Auditable.
- Permite recalculo/backfill independiente.
- No contamina stats base hasta tener calidad suficiente.
Riesgos:
- Requiere nueva tabla, backfill y runner.
- La exactitud depende de cobertura real de eventos `connected`/`disconnected`.
### Opcion C: ambas
Recomendacion final:
- Crear `rcon_match_player_presence` como fuente de verdad de presencia por jugador/partida.
- Denormalizar hacia `rcon_match_player_stats.player_active_seconds` y `playtime_quality` para agregaciones rapidas.
Por que:
- La tabla de detalle permite auditoria y recalculo.
- El campo denormalizado evita joins costosos en generacion de snapshots.
- La ruta publica sigue leyendo snapshots/read models, no calculando KPM en request.
## Estrategia de calculo
### Intervalos exactos
Entrada:
- Eventos `connected` y `disconnected` con `player_id`, `server_time`.
- Limites de match `started_server_time`, `ended_server_time`.
Algoritmo:
1. Ordenar eventos por `server_time`, `id`.
2. Por jugador, abrir intervalo en `connected`.
3. Cerrar intervalo en `disconnected`.
4. Acotar inicio/fin al rango de la partida.
5. Fusionar intervalos solapados.
6. Sumar segundos.
7. Marcar `playtime_quality = exact` solo si la evidencia permite explicar inicio y fin.
### Ventana observada
Entrada:
- Eventos `kill`, `team_switch`, `chat`, `message`, `connected`, `disconnected`.
Algoritmo:
1. Tomar min/max `server_time` por jugador dentro del match.
2. Calcular `active_seconds = max(0, last_seen - first_seen)`.
3. Guardar `evidence_event_count` y tipos.
4. Marcar `playtime_quality = observed`.
### Estimacion por duracion de partida
Entrada:
- `started_server_time`, `ended_server_time`.
Politica:
- No usar para KPM publico por defecto.
- Si se guarda, marcar `playtime_quality = estimated`.
- Requiere flag futuro para mostrarse.
## Estrategia de backfill
Fase 1:
- Crear tabla `rcon_match_player_presence`.
- Backfill desde `rcon_admin_log_events` y `rcon_materialized_matches`.
- No cambiar API publica.
Fase 2:
- Denormalizar `player_active_seconds` y `playtime_quality` en `rcon_match_player_stats`.
- Backfill stats desde presence.
Fase 3:
- Extender `player_period_stats` con:
- `player_active_seconds`
- `playtime_quality`
- `kills_per_minute`
- Regenerar weekly/monthly/yearly period stats.
Fase 4:
- Extender `ranking_snapshot_items` y `rcon_annual_ranking_snapshot_items` con:
- `player_active_seconds`
- `playtime_quality`
- `kills_per_minute`
- Generar snapshots KPM solo para filas con calidad permitida.
Reglas de backfill:
- Si hay intervalos exactos, usar `exact`.
- Si solo hay `first_seen`/`last_seen` por eventos, usar `observed`.
- Si solo hay duracion completa de partida, usar `estimated` y excluir de KPM publico.
- Si no hay evidencia, usar `unknown` y excluir de KPM.
- No mezclar `exact` y `observed` sin conservar calidad agregada.
Calidad agregada por periodo:
- `exact` si todos los segundos agregados vienen de exact.
- `observed` si hay mezcla exact + observed o solo observed.
- `estimated` si incluye estimated.
- `unknown` si no hay segundos validos.
## Cambios necesarios en read models
### rcon_match_player_stats
Campos recomendados:
- `player_active_seconds INTEGER`
- `playtime_quality TEXT`
- `kills_per_minute REAL`
Nota: `kills_per_minute` puede no guardarse en stats base si se prefiere derivarlo en read models. Si se guarda, debe recalcularse cada vez que cambien kills o segundos.
### player_period_stats
Campos recomendados:
- `player_active_seconds INTEGER NOT NULL DEFAULT 0`
- `playtime_quality TEXT NOT NULL DEFAULT 'unknown'`
- `kills_per_minute REAL`
Agregacion:
```text
SUM(kills) / (SUM(player_active_seconds) / 60)
```
Filtro publico:
- Incluir KPM solo si `SUM(player_active_seconds) > 0` y calidad agregada no es `estimated` ni `unknown`.
### ranking_snapshot_items
Campos recomendados:
- `player_active_seconds`
- `playtime_quality`
- `kills_per_minute`
Nueva metrica snapshot:
- `kills_per_minute`
Orden:
- `metric_value DESC`
- `player_active_seconds DESC`
- `kills DESC`
- `matches_considered DESC`
- `player_name ASC`
### rcon_annual_ranking_snapshot_items
Campos recomendados:
- `player_active_seconds`
- `playtime_quality`
- `kills_per_minute`
Regla:
- Annual KPM debe tener snapshot propio `metric = kills_per_minute`.
- No representar KPM anual con `kills_per_match`.
### Perfil de jugador
Exponer:
- `player_active_seconds`
- `player_active_minutes`
- `kills_per_minute`
- `playtime_quality`
Evitar:
- Calcular KPM runtime en request publico.
- Mezclar periodo semanal/mensual/anual sin metadata.
## Cambios necesarios en snapshots weekly/monthly/annual
Weekly/monthly:
- Extender generacion en `rcon_historical_leaderboards`.
- Agregar metrica `kills_per_minute` solo si existe `player_active_seconds`.
- Persistir `kills_per_minute` en `ranking_snapshot_items`.
Annual:
- Extender `rcon_annual_rankings` cuando exista presence/read model.
- Agregar `kills_per_minute` a metricas soportadas solo despues del backfill.
- Persistir snapshot independiente por anio, servidor y metrica.
General:
- Public read path debe seguir leyendo solo snapshots.
- Missing snapshot debe devolver `snapshot_status=missing`, sin fallback runtime.
## Cambios necesarios en API y frontend
API:
- Incluir `kills_per_minute` solo cuando venga de read model/snapshot.
- Incluir `playtime_quality`.
- Mantener `kills_per_match` como `Kills/partida`.
- Rechazar o marcar missing si se pide KPM sin snapshot real.
Frontend:
- Mostrar columna `KPM` solo si backend expone `kills_per_minute`.
- Mostrar tooltip o metadata de calidad si se decide publicar `observed`.
- No calcular KPM en JS.
- No mostrar KPM para `estimated` o `unknown`.
## Impacto en refresh runner
El runner actual refresca:
- RCON capture
- materializaciones
- player search index
- player period stats
- ranking snapshots
Orden futuro recomendado:
1. RCON capture/AdminLog ingestion.
2. Materializar matches y player stats.
3. Materializar `rcon_match_player_presence`.
4. Denormalizar `player_active_seconds` a `rcon_match_player_stats`.
5. Refrescar `player_search_index`.
6. Refrescar `player_period_stats`.
7. Refrescar ranking snapshots weekly/monthly.
8. Refrescar annual snapshots cuando aplique.
## Comandos futuros de produccion
Nombres propuestos; no existen todavia:
```bash
docker compose exec backend python -m app.rcon_player_presence migrate
docker compose exec backend python -m app.rcon_player_presence backfill --year 2026 --server-key all-servers
docker compose exec backend python -m app.rcon_player_presence backfill --year 2026 --server-key comunidad-hispana-01
docker compose exec backend python -m app.rcon_player_presence backfill --year 2026 --server-key comunidad-hispana-02
docker compose exec backend python -m app.rcon_historical_player_stats refresh-player-period-stats
docker compose exec backend python -m app.rcon_historical_leaderboards refresh-ranking-snapshots --limit 30
docker compose exec backend python -m app.rcon_annual_rankings generate --year 2026 --server-key all-servers --metric kills_per_minute --limit 30 --replace-existing
docker compose exec backend python -m app.rcon_annual_rankings generate --year 2026 --server-key comunidad-hispana-01 --metric kills_per_minute --limit 30 --replace-existing
docker compose exec backend python -m app.rcon_annual_rankings generate --year 2026 --server-key comunidad-hispana-02 --metric kills_per_minute --limit 30 --replace-existing
```
## Plan de implementacion por tasks
1. `TASK-219-create-rcon-match-player-presence-schema`
Crear schema SQLite/Postgres para `rcon_match_player_presence`, sin cambiar API publica.
2. `TASK-220-materialize-player-presence-from-adminlog`
Implementar materializacion exact/observed desde `rcon_admin_log_events` y `rcon_materialized_matches`.
3. `TASK-221-backfill-player-active-seconds`
Backfill historico por servidor/anio y reporte de cobertura por calidad.
4. `TASK-222-denormalize-player-active-seconds-into-match-stats`
Agregar `player_active_seconds` y `playtime_quality` a `rcon_match_player_stats`.
5. `TASK-223-extend-player-period-stats-with-kpm`
Extender `player_period_stats` con segundos activos y KPM real.
6. `TASK-224-add-kpm-ranking-snapshots`
Agregar `kills_per_minute` a weekly/monthly snapshots y annual snapshots independientes.
7. `TASK-225-expose-real-kpm-in-api-payloads`
Exponer `kills_per_minute` y `playtime_quality` desde snapshots/read models, sin runtime publico.
8. `TASK-226-show-real-kpm-in-ranking-ui`
Mostrar KPM solo cuando backend entregue `kills_per_minute`; mantener Kills/partida separado.
9. `TASK-227-validate-kpm-quality-and-performance`
Medir cobertura, latencia, `fallback_used=false` y comparar contra muestras manuales.
## Riesgos principales
- Cobertura incompleta de `connected`/`disconnected`.
- `server_time` puede reiniciarse o tener discontinuidades entre partidas; siempre debe estar acotado por `match_key`.
- Jugadores con eventos escasos pueden tener `observed` subestimado.
- Usar duracion completa de partida como tiempo de jugador inflaria o distorsionaria KPM.
- Mezclar calidades sin metadata haria la metrica poco confiable.
- Backfill masivo puede competir con lecturas publicas si se ejecuta fuera de ventana controlada.
- El frontend podria volver a confundir `Kills/partida` con KPM si no se separan nombres y payloads.
## Criterios de aceptacion futuros
- Existe `player_active_seconds` por jugador/partida con `playtime_quality`.
- Cada fila puede explicar su fuente: exact, observed, estimated o unknown.
- KPM se calcula como `kills / (player_active_seconds / 60)`.
- No se calcula KPM en frontend.
- `kills_per_match` sigue separado como `Kills/partida`.
- Weekly/monthly/annual KPM tienen snapshots propios.
- Request publico de ranking no consulta `rcon_match_player_stats` para calcular KPM.
- `fallback_used=false` en rutas publicas normales.
- Si falta snapshot KPM, el endpoint devuelve missing sin fallback runtime.
- Tests cubren exact, observed, estimated/unknown excluded, division por cero y orden de ranking.
-94
View File
@@ -1,94 +0,0 @@
# Teamkills Ranking Audit
## Scope
This document explains why annual public `teamkills` ranking currently returns no usable rows in the audited dataset.
## What Was Checked
Sources reviewed:
- `rcon_admin_log_events`
- `rcon_match_player_stats`
- `rcon_annual_ranking_snapshots`
- `rcon_annual_ranking_snapshot_items`
- `rcon_player_profile_snapshots`
Code reviewed:
- `backend/app/rcon_admin_log_parser.py`
- `backend/app/rcon_admin_log_storage.py`
- `backend/app/rcon_admin_log_materialization.py`
- `backend/app/rcon_annual_rankings.py`
- `backend/app/routes.py`
- `backend/tests/test_rcon_materialization_pipeline.py`
## What Works Already
The parser and materialization pipeline already support teamkills when the source event is explicit enough.
The key condition is:
```text
killer_team == victim_team
```
When that is true, the materializer increments:
- `teamkills` on the killer
- `deaths_by_teamkill` on the victim
This behavior is covered by regression tests.
## What The Local Dataset Shows
Read-only checks on `backend/data/hll_vietnam_dev.sqlite3` show:
- many `kill` events exist
- zero `rcon_match_player_stats` rows with `teamkills > 0`
- zero annual ranking snapshot items with `teamkills > 0`
- zero parsed `kill` events where `killer_team == victim_team`
That means the annual ranking source facts never receive positive teamkill values.
## What The Profile Snapshots Show
`rcon_player_profile_snapshots` does contain positive:
- `teamkills_done`
- `teamkills_received`
So the broader data ecosystem knows teamkills somewhere, but those values are:
- cumulative
- profile-oriented
- not scoped to exact closed match windows
Using them directly for annual ranking would be semantically wrong.
## Public Endpoint Check
The current public endpoint returns empty annual teamkill ranking items for:
- `all`
- `comunidad-hispana-01`
- `comunidad-hispana-02`
So the problem is not a frontend-only rendering issue.
## Conclusion
The current annual teamkills ranking depends on:
- `rcon_match_player_stats.teamkills`
In the audited dataset, that field remains zero because the match-level kill stream does not contain same-team kill events that the materializer can classify as teamkills.
## Safe Next Step
Do not fake teamkills from unrelated counters.
The next implementation task should first decide whether:
1. the event stream is incomplete and needs parser/source correction, or
2. teamkills need a dedicated ranking source different from match-level kill events.
-236
View File
@@ -1,236 +0,0 @@
# Weapon Icon Mapping Audit
## Summary
The frontend weapon icon source of truth is now `frontend/assets/js/current-match-weapon-icons.js`, backed only by local SVG files from `frontend/assets/img/weapons/black/`.
The audit focused on avoiding broken image loads, replacing legacy typo filenames, and reusing the same black SVG resolver in the current match kill feed and historical match detail weapon lists.
## Asset Path
- Runtime path: `./assets/img/weapons/black/`
- Filesystem path: `frontend/assets/img/weapons/black/`
- Fallback behavior: if the shared resolver is not available, views render a text fallback instead of loading an old asset path.
- Unknown fallback: `UNKNOWN -> precision_strike_black.svg`
## Local SVG Files Included
The current local set contains 123 SVG files:
- `60l_supply_black.svg`
- `60l_transport_black.svg`
- `at_mine_gs_mk_v_black.svg`
- `ba10_black.svg`
- `bazooka_black.svg`
- `bedford_oyd_supply_black.svg`
- `bedford_oyd_transport_black.svg`
- `bishop_sp_25pdr_black.svg`
- `bombing_run_black.svg`
- `boys_anti_tank_rifle_black.svg`
- `bren_gun_black.svg`
- `browning_m1919_black.svg`
- `canadian_sten_mk_ii_black.svg`
- `churchill_mk_iii_avre_black.svg`
- `churchill_mk_iii_black.svg`
- `churchill_mk_vii_black.svg`
- `colt_1911_black.svg`
- `cromwell_black.svg`
- `crusader_mk_iii_black.svg`
- `enfield_no2_mk_i_black.svg`
- `feldspaten_black.svg`
- `fg42_black.svg`
- `fg42_x4_black.svg`
- `firefly_black.svg`
- `flammenwerfer41_black.svg`
- `flare_gun_black.svg`
- `fn_inglis_no2_mk_i_black.svg`
- `gewehr_black.svg`
- `gmc_cckw_353_supply_black.svg`
- `gmc_cckw_363_supply_black.svg`
- `gmc_cckw_363_transport_black.svg`
- `half_track_black.svg`
- `is_1_black.svg`
- `jeep_black.svg`
- `jeep_willys_black.svg`
- `kar98k_black.svg`
- `kar98k_x8_black.svg`
- `kubelwagen_black.svg`
- `kv2_black.svg`
- `lanchester_black.svg`
- `lee_enfield_jungle_carbine_black.svg`
- `lee_enfield_n4_black.svg`
- `lee_enfield_pattern_1914_black.svg`
- `lee_enfield_pattern_1914_sniper_black.svg`
- `lewis_gun_black.svg`
- `luger_p08_black.svg`
- `m1903_springfield_black.svg`
- `m1903_springfield_sniper_black.svg`
- `m1918a2_bar_black.svg`
- `m1_57mm_cannon_black.svg`
- `m1_carbine_black.svg`
- `m1_garand_black.svg`
- `m1a1_at_mine_black.svg`
- `m24_stielhandgranate_black.svg`
- `m2_ap_mine_black.svg`
- `m2_flamethrower_black.svg`
- `m3_grease_gun_black.svg`
- `m3_half_track_black.svg`
- `m3_knife_black.svg`
- `m43_stielhandgranate_black.svg`
- `m4a3_105mm_black.svg`
- `m8_greyhound_black.svg`
- `m97_black.svg`
- `mg34_black.svg`
- `mg42_black.svg`
- `mills_bomb_black.svg`
- `mk2_grenade_black.svg`
- `mosin_nagant_1891_black.svg`
- `mosin_nagant_9130_black.svg`
- `mosin_nagant_m38_black.svg`
- `mp40_black.svg`
- `mpl50_spade_black.svg`
- `nagant_m1895_black.svg`
- `no82_grenade_black.svg`
- `opel_blitz_supply_black.svg`
- `opel_blitz_transport_black.svg`
- `pak_40_75mm_black.svg`
- `panzer_iii_ausf_n_black.svg`
- `panzerschreck_black.svg`
- `piat_black.svg`
- `pomz_ap_mine_black.svg`
- `ppsh41_black.svg`
- `ppsh_41w_drum_black.svg`
- `precision_strike_black.svg`
- `ptrs41_black.svg`
- `qf_6_pounder_black.svg`
- `rg42_grenade_black.svg`
- `rifle_no4_mk_i_black.svg`
- `rifle_no4_mk_i_sniper_black.svg`
- `rifle_no5_mk_i_black.svg`
- `s_mine_black.svg`
- `satchel_charge_black.svg`
- `scoped_mosin_nagant_9130_black.svg`
- `scoped_svt40_black.svg`
- `sdkfz_121_luchs_black.svg`
- `sdkfz_161_panzer_iv_black.svg`
- `sdkfz_171_panther_black.svg`
- `sdkfz_181_tiger_1_black.svg`
- `sdkfz_234_puma_black.svg`
- `sdkfz_251_half_track_black.svg`
- `sherman_m4a3_75w_black.svg`
- `sherman_m4a3e2_76_black.svg`
- `sherman_m4a3e2_black.svg`
- `smle_no1_mk_iii_black.svg`
- `sten_gun_black.svg`
- `sten_gun_mk_ii_black.svg`
- `stg44_black.svg`
- `strafing_run_black.svg`
- `stuart_m5a1_black.svg`
- `sturmpanzer_iv_black.svg`
- `svt40_black.svg`
- `t34_76_black.svg`
- `t70_black.svg`
- `tellermine_43_black.svg`
- `tetrarch_black.svg`
- `thompson_black.svg`
- `tm35_at_mine_black.svg`
- `tokarev_tt33_black.svg`
- `walther_p38_black.svg`
- `webley_revolver_black.svg`
- `zis2_57mm_cannon_black.svg`
- `zis5_supply_black.svg`
- `zis5_transport_black.svg`
## Inventory Findings
- Tracked SVGs modified in `black/`: 29 existing SVGs.
- Tracked SVGs deleted in `black/`: `browing_m1919_black.svg`, `dp27_black.svg`, `flammenwefer41_black.svg`, `m1_carabine_black.svg`, `mosing_nagant_1891_black.svg`, `mosing_nagant_9130_black.svg`, `mosing_nagant_m38_black.svg`, `panzerchreck_black.svg`, `sten_mk_v_black.svg`.
- New/untracked SVGs in `black/`: 94 files, including corrected names and expanded vehicle, mine, commander, British, Canadian, Soviet and US assets.
- Duplicate by hash: `lee_enfield_jungle_carbine_black.svg` and `rifle_no5_mk_i_black.svg`.
- Suspicious legacy typo names are intentionally not used by runtime JS: `browing`, `mosing`, `panzerchreck`, `flammenwefer`, `m1_carabine`.
## Mapping Corrections
- `browing_m1919_black.svg` is replaced by `browning_m1919_black.svg`.
- `m1_carabine_black.svg` is replaced by `m1_carbine_black.svg`.
- `panzerchreck_black.svg` is replaced by `panzerschreck_black.svg`.
- `flammenwefer41_black.svg` is replaced by `flammenwerfer41_black.svg`.
- `mosing_nagant_*_black.svg` files are replaced by `mosin_nagant_*_black.svg`.
- `sten_mk_v_black.svg` is replaced by the available `sten_gun_black.svg` shared fallback.
- `dp27_black.svg` no longer exists; `DP-27` currently maps to `lewis_gun_black.svg` as the closest local top-feed LMG silhouette.
- The old `weapons/white/` fallback in `partida-actual.js` was removed from the operational path.
- `historico-partida.html` now loads the shared weapon resolver before `historico-partida.js`.
- `historico-partida.js` now renders local black icons for historical `top_weapons` and `death_by` lists when names resolve.
## RCON Names Covered
The runtime covers the 220 explicit RCON/AdminLog names in `CURRENT_MATCH_RCON_WEAPON_ICON_ENTRIES`, including:
- Infantry examples: `GEWEHR 43`, `KARABINER 98K`, `KARABINER 98K x8`, `FG42`, `FG42 x4`, `M1 GARAND`, `M1 CARBINE`, `BROWNING M1919`, `STG44`, `MP40`, `MG34`, `MG42`, `BAZOOKA`, `PANZERSCHRECK`, `PIAT`, `PTRS-41`, `M1918A2 BAR`, `M3 GREASE GUN`, `M97 TRENCH GUN`, `M1A1 THOMPSON`, `M1928A1 THOMPSON`.
- Soviet examples: `MOSIN NAGANT 1891`, `MOSIN NAGANT 91/30`, `MOSIN NAGANT M38`, `SCOPED MOSIN NAGANT 91/30`, `SCOPED SVT40`, `SVT40`, `PPSH 41`, `PPSH 41 W/DRUM`, `TOKAREV TT33`, `NAGANT M1895`.
- British/Canadian examples: `Bren Gun`, `Boys Anti-tank Rifle`, `Sten Gun`, `Sten Gun Mk.II`, `Sten Gun Mk.V`, `Canadian Sten Mk.II`, `Lanchester`, `SMLE No.1 Mk III`, `Rifle No.4 Mk I`, `Rifle No.4 Mk I Sniper`, `Rifle No.5 Mk I`, `PIAT`, `Mills Bomb`, `No.82 Grenade`.
- Vehicles and mounted weapons: `Sd.Kfz.121 Luchs`, `Sd.Kfz.161 Panzer IV`, `Sd.Kfz.171 Panther`, `Sd.Kfz.181 Tiger 1`, `Sd.Kfz.234 Puma`, `Sd.Kfz 251 Half-track`, `Sherman M4A3(75)W`, `Sherman M4A3E2`, `Sherman M4A3E2(76)`, `Stuart M5A1`, `M3 Stuart Honey`, `M8 Greyhound`, `T34/76`, `T70`, `IS-1`, `KV-2`, `BA-10`, `Churchill Mk.III`, `Churchill Mk.VII`, `Cromwell`, `Firefly`, `Tetrarch`.
- Commander, explosives and mines: `BOMBING RUN`, `STRAFING RUN`, `PRECISION STRIKE`, `SATCHEL`, `SATCHEL CHARGE`, `M1A1 AT MINE`, `M2 AP MINE`, `TELLERMINE 43`, `S-MINE`, `POMZ AP MINE`, `TM-35 AT MINE`, `A.T. Mine G.S. Mk V`.
- Logistics vehicles: `GMC CCKW 353 (Supply)`, `GMC CCKW 363 (Supply)`, `GMC CCKW 363 (Transport)`, `Opel Blitz (Supply)`, `Opel Blitz (Transport)`, `ZIS-5 (Supply)`, `ZIS-5 (Transport)`, `Bedford OYD (Supply)`, `Bedford OYD (Transport)`, `Jeep`, `Jeep Willys`, `Kubelwagen`, `60L (Supply)`, `60L (Transport)`.
- Unknown: `UNKNOWN`.
The full exact list remains in `frontend/assets/js/current-match-weapon-icons.js` and is validated by `scripts/validate-weapon-icon-mapping.js`.
## Pending Names Without Dedicated Icon
These are covered by controlled fallbacks but should get dedicated SVGs later if visual accuracy matters:
- `UNKNOWN`
- `MOLOTOV`
- `No.77`
- `Daimler`
- `GAZ-67`
- `DP-27`
- `FairbairnSykes`
- `122MM HOWITZER [M1938 (M-30)]`
- `150MM HOWITZER [sFH 18]`
- `155MM HOWITZER [M114]`
- Generic towed artillery entries that currently reuse closest field-gun or vehicle silhouettes.
## Alias Decisions
- Important aliases remain explicit for common normalized names such as `g43`, `kar98k`, `kar98k x8`, `mp 40`, `m1 garand`, `m1 carbine`, `m1919`, `fg42 scoped`, `fg42 sniper`, `ppsh drum`, `scoped svt40`, `sniper mosin`, `m1a1 thompson`, `m1928 thompson`.
- Legacy typo filenames are not used as asset references.
- Runtime normalization is case-insensitive and strips accents, punctuation, hyphens, brackets and repeated separators into a stable lookup key.
- Vehicle-mounted weapons map to the specific platform icon when a platform SVG exists.
- Shared fallbacks are documented instead of guessed through broad substring matching.
## Production/API Sampling
Commands executed against production:
```powershell
Invoke-RestMethod "https://comunidadhll.devzamode.es/api/current-match/kills?server=comunidad-hispana-01&limit=100"
Invoke-RestMethod "https://comunidadhll.devzamode.es/api/current-match/kills?server=comunidad-hispana-02&limit=100"
Invoke-RestMethod "https://comunidadhll.devzamode.es/api/current-match/players?server=comunidad-hispana-01"
Invoke-RestMethod "https://comunidadhll.devzamode.es/api/current-match/players?server=comunidad-hispana-02"
Invoke-RestMethod "https://comunidadhll.devzamode.es/api/historical/matches/detail?server=comunidad-hispana-01&match=1781023156:1781028555:purpleheartlanewarfare"
```
The endpoints responded, but at the time of this audit they did not expose weapon names in the sampled payloads. The implemented coverage therefore relies on the repository RCON weapon universe from the previous mapping work plus current frontend usage.
## Validation Commands
```powershell
node --check frontend/assets/js/current-match-weapon-icons.js
node --check frontend/assets/js/partida-actual.js
node --check frontend/assets/js/historico-partida.js
node scripts/validate-weapon-icon-mapping.js
git status --short --untracked-files=all
git diff --name-only
```
## Scope Confirmation
- No backend file was modified for weapon icons.
- No RCON host, port, server config, `27001`, Elo/MMR or Comunidad Hispana #03 setting was changed.
- No clan asset was modified by this task.
- No asset outside `frontend/assets/img/weapons/black/` was modified for weapon icons.
- `frontend/assets/img/weapons/black - copia/` and `frontend/assets/img/weapons/black.zip` were not included.
- `tmp/` was not included.
- `ai/system-metrics.md` was not touched by this task; it remains a pre-existing local modification.
-222
View File
@@ -1,222 +0,0 @@
# Runbook: annual ranking snapshot top 20 (Stats)
Objetivo: operar el ranking anual top 20 de `Stats` de forma reproducible, usando snapshots precomputados para no recalcular anualmente por cada request.
## 1) Proposito del snapshot anual top 20
El snapshot anual proporciona la tabla de posiciones de jugadores para el bloque de ranking anual de `Stats` con impacto bajo en latencia y costo de consulta.
Objetivos del diseno:
- entregar `top 20` de la temporada por metrica (V1: `kills`);
- consumir un resultado estable desde API publica sin recalcular toda la historia anual en cada request;
- permitir validacion operativa clara de si el ranking esta disponible o no;
- soportar regeneracion controlada por proceso de mantenimiento.
## 2) Fuente de datos
La fuente oficial para este ranking es materializada RCON:
- `rcon_materialized_matches`
- `rcon_match_player_stats`
Filtro principal:
- `matches.source_basis = admin-log-match-ended`
Reglas clave de calculo:
- ventanas por ano (`YYYY-01-01T00:00:00Z` a `YYYY+1-01-01T00:00:00Z`);
- acumulado de `kills`, `deaths`, `teamkills` por `player_id`;
- orden por `metric_value` desc, `matches_considered` desc, `player_name` asc;
- solo posiciones con actividad valida y `player_name` no vacio.
## 3) Endpoint consumidor
Desde Stats se consume:
- `GET /api/stats/rankings/annual?year=<year>&server_id=<server-or-all>&metric=kills&limit=20`
En V1:
- `metric` soporta solo `kills`;
- `server_id` acepta `all` o identificador de servidor.
## 4) Como generar snapshot anual
La generacion se hace fuera de la API (job/comando) para precomputar:
1. Definir ano objetivo `year`.
2. Definir alcance `server_id` (`all` para global o servidor especifico).
3. Ejecutar generador de snapshot anual con la configuracion necesaria.
4. Generador:
- elimina snapshot previo del mismo `(year, server_id, metric)` si existe;
- recalcula top-k desde datos materializados RCON;
- guarda cabecera + items del ranking en tablas snapshots;
- persiste metadatos (`generated_at`, ventana y conteo fuente).
### Comando recomendado
```bash
python -m app.rcon_annual_rankings generate --year 2026 --server-key all-servers --metric kills --limit 30 --replace-existing
```
Notas:
- Cuando `HLL_BACKEND_DATABASE_URL` esta configurado, el comando usa PostgreSQL por defecto.
- SQLite queda solo como override explicito mediante `--sqlite-path <path>`.
- El comando puede fallar si la capa de datos local no esta inicializada.
- En entornos manuales, validar entorno y storage objetivo antes de ejecutar.
- Si no hay datos, se puede generar un snapshot vacio (`ready` con `items=[]`).
### Override local SQLite
```bash
python -m app.rcon_annual_rankings generate --year 2026 --server-key all-servers --metric kills --limit 30 --replace-existing --sqlite-path backend/data/hll_vietnam_dev.sqlite3
```
### Comando Docker recomendado
```bash
docker compose exec backend python -m app.rcon_annual_rankings generate --year 2026 --server-key all-servers --metric kills --limit 30 --replace-existing
```
## 5) Como regenerarlo
Regenerar cuando:
- cambie la cobertura de datos anual;
- se detecte snapshot incompleto;
- se requiera refrescar fecha `generated_at`.
Procedimiento:
1. Ejecutar nuevamente el generador con el mismo `year` y `server_id`.
2. Aceptar reemplazo seguro del snapshot existente.
3. Validar que el snapshot nuevo refleje el recuento de partidas fuente actualizado.
Recomendacion:
- programar recalculo en ventanas de mantenimiento (idealmente cierre anual o rutina periodica definida por operaciones).
## 6) Como validar que existe un snapshot
Verificacion local:
1. Consultar API de ranking anual del ano objetivo.
2. Revisar `snapshot_status` en respuesta:
- `ready`: existe snapshot;
- `missing`: no existe snapshot.
3. Verificar `generated_at`, `window_start`, `window_end`, `source_matches_count`.
4. Verificar metadatos de limite:
- `requested_limit`: limite pedido por el cliente;
- `snapshot_limit`: limite persistido en snapshot;
- `effective_limit`: limite realmente servido;
- `item_count`: filas actualmente disponibles.
Se considera snapshot existente si API responde `snapshot_status="ready"`.
## 7) Como validar desde API
Usar llamadas directas a backend para validar estado y contenido:
- `GET /api/stats/rankings/annual?year=2026&server_id=all&metric=kills&limit=20`
- `GET /api/stats/rankings/annual?year=1999&server_id=all&metric=kills&limit=20`
- `GET /api/stats/rankings/annual?year=2026&server_id=all&metric=deaths&limit=20`
Checklist:
- HTTP 200 esperado para parametros validos de V1.
- `status` debe ser `"ok"` con estructura de data consistente.
- Para snapshots `ready`, `effective_limit` puede ser menor que `requested_limit` cuando el snapshot fue generado con un limite menor o contiene menos filas.
- En V1 con `metric` no soportada, esperar error de request (400) sin recomputar ranking.
## 8) Como validar desde frontend Stats
Desde `frontend/stats.html`:
1. Abrir la pestana `Stats`.
2. Ejecutar consulta anual usando `year`.
3. Confirmar:
- estado de mensaje de carga/success/empty/error;
- render de filas cuando haya items;
- texto explicito cuando el snapshot esta `missing`;
- texto explicito cuando el snapshot existe pero no tiene items.
4. Confirmar que no rompe bloque semanal/mensual ni busqueda cuando el anual no esta disponible.
5. Si endpoint retorna metricas invalidas, validar estado de warning en UI y que no cambia el resto del flujo.
## 9) Casos esperados
### 9.1 `snapshot_status=ready` con items
Respuesta tipica:
- `status: "ok"`
- `data.snapshot_status: "ready"`
- `data.items` con ranking ordenado.
Debe mostrarse top 20 con campos minimos:
- `ranking_position`
- `player_name`
- `metric_value`
- `matches_considered`
- `kills`
- `deaths`
- `teamkills`
- `kd_ratio`
### 9.2 `snapshot_status=ready` sin items
Respuesta tipica:
- `snapshot_status: "ready"`
- `items: []`
- `generated_at` puede estar presente
Debe renderizar estado "snapshot ready vacio" y no tratarlo como error de sistema.
### 9.3 `snapshot_status=missing`
Respuesta tipica:
- `snapshot_status: "missing"`
- `items: []`
Debe mostrar estado informativo claro de que el ranking no fue generado aun.
### 9.4 Metrica no soportada
Respuesta tipica:
- error request (400) con mensaje de metrica invalida/no soportada.
La UI/backend no debe intentar recalcular ni degradar a comportamiento inesperado.
## 10) Advertencias operativas
- No reactivar Elo/MMR ni logica dependiente en este bloque.
- No reintroducir `Comunidad Hispana #03` como alcance normal.
- No usar scoreboard publico como fuente primaria si RCON materializado esta disponible.
- No recalcular ranking anual completo en cada request publico; siempre leer snapshot.
- No tocar `frontend/assets/js/partida-actual.js` ni `frontend/assets/img/clans/bxb.png` en este runbook.
## Checklist de operacion
- [ ] Definir ano/servidor/limite.
- [ ] Ejecutar generacion o regeneracion.
- [ ] Confirmar respuesta de API por ano objetivo.
- [ ] Confirmar estado en UI de Stats.
- [ ] Registrar fecha/hora de generacion y responsable.
- [ ] Si faltan datos esperados, revisar pipeline RCON materializado.
## Validacion de la task
- `docs/annual-ranking-snapshot-runbook.md` actualizado.
- Cambios esperados unicamente de documentacion.
- No se aplican tests automaticos en este runbook; la validacion real se ejecuta en la task de backend correspondiente.
## Proximos pasos recomendados
- Si el bloque muestra estados esperados y refrescos, conectar este runbook con operacion de mantenimiento programada.
- Mantener la misma politica de fuentes en futuros reportes o automatizaciones de jobs.
-210
View File
@@ -1,210 +0,0 @@
# Annual Ranking Snapshot Schema Plan
Este documento define el diseño de persistencia para snapshots anuales top 20.
## 1. Objetivo del snapshot anual
- Evitar recalcular ranking anual completo por request público.
- Guardar ranking precomputado por año/servidor/métrica para consumo O(1) en API.
- Mantener continuidad con el pipeline materializado RCON ya existente (`rcon_materialized_matches` y `rcon_match_player_stats`).
## 2. Propuesta de tablas
Se plantea un modelo de dos tablas:
- `rcon_annual_ranking_snapshots`
- `rcon_annual_ranking_snapshot_items`
### `rcon_annual_ranking_snapshots`
Campos esperados:
- `id`
- `year`
- `server_key`
- `metric`
- `limit_size`
- `source_basis`
- `window_start`
- `window_end`
- `generated_at`
- `status`
- `source_matches_count`
### `rcon_annual_ranking_snapshot_items`
Campos esperados:
- `id`
- `snapshot_id`
- `ranking_position`
- `player_id`
- `player_name`
- `metric_value`
- `matches_considered`
- `kills`
- `deaths`
- `teamkills`
- `kd_ratio`
## 3. Propuesta de DDL (convencional SQLite/Postgres)
### SQLite
```sql
CREATE TABLE IF NOT EXISTS rcon_annual_ranking_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
year INTEGER NOT NULL,
server_key TEXT NOT NULL,
metric TEXT NOT NULL,
limit_size INTEGER NOT NULL DEFAULT 20,
source_basis TEXT NOT NULL DEFAULT 'rcon-admin-log',
window_start TEXT,
window_end TEXT,
generated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
status TEXT NOT NULL DEFAULT 'ready',
source_matches_count INTEGER NOT NULL DEFAULT 0,
source_range_start TEXT,
source_range_end TEXT,
source_payload_hash TEXT,
generation_policy TEXT NOT NULL DEFAULT 'manual',
notes TEXT,
UNIQUE (year, server_key, metric),
CHECK (limit_size > 0),
CHECK (metric IN ('kills', 'deaths', 'matches_over_100_kills', 'support'))
);
CREATE INDEX IF NOT EXISTS idx_rcon_annual_snapshots_year
ON rcon_annual_ranking_snapshots (year, server_key, metric);
CREATE INDEX IF NOT EXISTS idx_rcon_annual_snapshots_status
ON rcon_annual_ranking_snapshots (status);
CREATE TABLE IF NOT EXISTS rcon_annual_ranking_snapshot_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_id INTEGER NOT NULL REFERENCES rcon_annual_ranking_snapshots(id) ON DELETE CASCADE,
ranking_position INTEGER NOT NULL,
player_id TEXT NOT NULL,
player_name TEXT NOT NULL,
metric_value INTEGER NOT NULL DEFAULT 0,
matches_considered INTEGER NOT NULL DEFAULT 0,
kills INTEGER NOT NULL DEFAULT 0,
deaths INTEGER NOT NULL DEFAULT 0,
teamkills INTEGER NOT NULL DEFAULT 0,
kd_ratio REAL NOT NULL DEFAULT 0.0,
generated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(snapshot_id, ranking_position),
UNIQUE(snapshot_id, player_id)
);
CREATE INDEX IF NOT EXISTS idx_rcon_annual_snapshot_items_snapshot
ON rcon_annual_ranking_snapshot_items (snapshot_id, ranking_position);
CREATE INDEX IF NOT EXISTS idx_rcon_annual_snapshot_items_player
ON rcon_annual_ranking_snapshot_items (snapshot_id, player_id);
```
### PostgreSQL (equivalente)
- `INTEGER` -> `INTEGER`
- `TEXT` -> `TEXT`
- `REAL` -> `DOUBLE PRECISION`
- `AUTOINCREMENT` -> `BIGSERIAL PRIMARY KEY` con secuencia estándar.
- `CURRENT_TIMESTAMP` con zona horaria (`TIMESTAMPTZ`) en la capa de compatibilidad.
## 4. Reglas de unicidad
1. Snapshot único por combinación:
- `UNIQUE(year, server_key, metric)`.
2. Posición única por snapshot:
- `UNIQUE(snapshot_id, ranking_position)`.
3. Jugador único por snapshot:
- `UNIQUE(snapshot_id, player_id)`.
### Comportamiento esperado
- Si cambia el ranking de un año ya existente, el proceso de generación debe reemplazarlo de forma idempotente.
- El reemplazo puede implementarse con:
- borrado por `snapshot_id` + inserción nueva, o
- `UPSERT`/`ON CONFLICT` sobre `year + server_key + metric`.
## 5. Índices recomendados
#### `rcon_annual_ranking_snapshots`
- `(year, server_key, metric)` para lookup directo por API.
- `(server_key, status)` para monitorización y estado de caducidad.
- `(status, generated_at)` para refrescos y auditoría.
#### `rcon_annual_ranking_snapshot_items`
- `(snapshot_id, ranking_position)` para orden fijo del ranking.
- `(snapshot_id, player_id)` para comprobaciones de idempotencia.
## 6. Compatibilidad SQLite/Postgres
La implementación futura debe seguir el patrón actual:
- Consultas materializadas con SQL ANSI/SQLite friendly.
- Aislar la conexión con la función de compatibilidad existente:
- `use_postgres_rcon_storage` + `connect_postgres_compat()`.
- Mantener placeholders por parámetro (`?` en SQL base, transformado por `PostgresCompatConnection`).
Esto evita duplicar lógica entre SQLite y Postgres y reduce riesgo al soportar ambas rutas.
### Ajuste de esquema en migración real (futuro)
- Incluir las tablas en `backend/app/postgres_rcon_storage.py` dentro de `RCON_SCHEMA_SQL` cuando corresponda.
- Añadir a `RCON_TABLES` en `backend/app/sqlite_to_postgres_migration.py` solo en fase de migración real.
## 7. Política de generación
### Inicial (ahora)
- Generación **manual**:
- comando interno o mantenimiento administrativo para producir snapshot anual.
- Semántica de reemplazo:
- `replace_existing` seguro por:
- `UNIQUE(year, server_key, metric)` + transacción de borrado e inserción,
- o `ON CONFLICT` en `INSERT`.
- Registrar:
- `generated_at`, `status`, `source_matches_count`, `source_basis`.
### Evolución
- Generación programada futura (cron/job) con ventana anual cerrada.
- Mantener snapshots por año histórico para inspección.
## 8. API futura
Ruta definida para la siguiente implementación:
```http
GET /api/stats/rankings/annual?year=<year>&server_id=<server-or-all>&metric=kills
```
### Parámetros
- `year` (obligatorio): año calendario (ej. `2026`).
- `server_id`:
- `all-servers` (default) o slug de servidor.
- Debe mapearse al mismo `server_key` usado por RCON materialized.
- `metric` (obligatorio en la fase inicial): `kills`.
### Contrato recomendado
- `status: ok | pending | stale | not_found`.
- `data`:
- `year`, `server_key`, `metric`, `generated_at`, `status`, `source_range_start`, `source_range_end`.
- `items`: lista de items del ranking con los campos de la tabla de items.
- Si no hay snapshot generado:
- respuesta `pending` (o `not_found`) con colección vacía.
- sin recalcular on-demand (la carga debe dispararse por proceso dedicado).
## 9. Notas de alcance y migración
- Esta tarea es de diseño documental únicamente, no incluye migraciones ni cambios de código.
- No introduce Elo/MMR.
- No se añade lógica de frontend.
- No se modifica `frontend/assets/js/partida-actual.js`.
- No se toca `frontend/assets/img/clans/bxb.png`.
- No se reintroduce Comunidad Hispana #03.
-364
View File
@@ -1,364 +0,0 @@
# Current Match AdminLog Freshness
## Production Evidence
The current-match public feed was stale because the persisted AdminLog read model was coupled to slow historical materialization.
Observed production/runtime evidence:
- `hll-vietnam-rcon-historical-worker-1` was running `python -m app.rcon_historical_worker loop`
- configured capture interval was `HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS=2`
- configured retries were `HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES=2` and `HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS=1`
- configured AdminLog lookback was `HLL_BACKEND_RCON_ADMIN_LOG_LOOKBACK_MINUTES=10`
- real `rcon_historical_capture_runs` durations were around `15-30` minutes instead of `2` seconds
- recent examples included:
- `40789`: `10:17:54 -> 10:33:45`
- `40790`: `10:33:44 -> 10:49:38`
- `40791`: `10:33:47 -> 11:05:20`
- `40792`: `11:05:19 -> 11:21:15` failed with PostgreSQL deadlock
- `40793`: `11:05:22` still running at inspection time
- each heavy cycle was also doing large historical work:
- `player_stats_seen` around `838k`
- `player_stats_materialized` around `838k`
- `materialized_matches_updated` around `392-394`
This matters because:
- `/api/current-match/kills` reads `rcon_admin_log_events`
- `/api/current-match/players` reads `rcon_admin_log_events`
- those endpoints do not query RCON/CRCON directly per public request
- if the worker cycle lasts longer than the AdminLog lookback window, new live events can be delayed or missed from the current-match API perspective
The feed freshness problem was therefore primarily a backend ingestion architecture issue, not a frontend rendering issue.
## Architecture Before
Before this change, one worker path mixed three concerns:
1. lightweight live sample capture
2. AdminLog ingestion
3. heavy historical materialization of matches, player stats, rankings and search inputs
That design had two bad properties:
- current-match freshness depended on heavy historical work finishing
- the heavy path could deadlock or overlap while still starving `rcon_admin_log_events` refreshes
## Architecture After
### Live AdminLog ingestion path
- dedicated worker module: `python -m app.rcon_current_match_worker`
- scope: trusted current-match servers only
- `comunidad-hispana-01`
- `comunidad-hispana-02`
- cadence: explicit fast loop, intended for `5s`
- overlap safety: relies on idempotent AdminLog inserts and table uniqueness instead of the long historical writer lock
- output target: `rcon_admin_log_events`
- explicitly does not:
- run `materialize_rcon_admin_log`
- rebuild `rcon_match_player_stats`
- refresh annual rankings
- rebuild player search
- process hundreds of thousands of historical stat rows
Operational behavior:
- worker startup initializes AdminLog storage once
- each loop iteration fetches recent AdminLog only
- each target is handled independently
- per-target results include:
- `target_key`
- `entries_seen`
- `events_inserted`
- `duplicate_events`
- `duration_ms`
- target failures are isolated and do not stop healthy targets
### Historical materialization path
- historical worker remains `python -m app.rcon_historical_worker loop`
- it now runs on an explicit safe interval in Portainer instead of inheriting a too-fast runtime cadence
- heavy materialization stays in the historical worker
- the single-running-historical guard now uses a runtime lock instead of a schema-level unique index
- PostgreSQL uses an advisory lock only around the heavy historical worker path
- if a historical run is already active, the next historical attempt returns a skipped result with reason `already-running`
- stale or duplicated old `mode='historical'` run rows no longer crash live worker startup and no longer block PostgreSQL schema bootstrap
### Schema and initialization behavior
The hot live path was reduced in two places:
- current-match API reads already use `ensure_storage=False`, so public `GET /api/current-match/kills` and `GET /api/current-match/players` do not request schema initialization on each read
- the live AdminLog worker now initializes only the PostgreSQL AdminLog tables at startup or once-mode execution, then persists with `ensure_storage=False`
- the full PostgreSQL bootstrap now removes the obsolete `idx_rcon_historical_single_running_historical` index instead of attempting to recreate it
This removes obvious repeated PostgreSQL DDL/init from the live ingestion loop.
TASK-271 production hotfix note:
- the original split exposed a startup crash when production already contained duplicate historical capture rows
- the crash came from schema-level creation of `idx_rcon_historical_single_running_historical`
- historical single-running protection now uses a runtime PostgreSQL advisory lock on the heavy worker path
- the live AdminLog worker remains independent from historical materialization and historical capture-run state
## Deployment Decision
The chosen deployment split in `deploy/portainer/docker-compose.nas.yml` is:
1. add a dedicated live worker service:
```yaml
rcon-live-adminlog-worker:
command:
- python
- -m
- app.rcon_current_match_worker
- loop
- --interval
- "5"
- --lookback-minutes
- "15"
```
2. keep the historical worker for heavy materialization, but force a safe cadence:
```yaml
rcon-historical-worker:
command:
- python
- -m
- app.rcon_historical_worker
- loop
- --capture-mode
- historical
- --interval
- "900"
```
Rationale:
- the repository already uses the historical worker for AdminLog-backed materialization
- `historical-runner` is not a drop-in replacement for `materialize_rcon_admin_log`
- the immediate production risk was heavy materialization running effectively every few seconds
- slowing the heavy worker to `900s` while moving live ingestion to a dedicated `5s` worker cleanly separates freshness from historical backfill/materialization cost
Non-negotiable result:
- no worker should be materializing `~838k` player-stat rows every `2` seconds
- live current-match freshness no longer depends on heavy historical materialization completing first
## Current-Match API Read Path
Confirmed after this change:
- `/api/current-match/kills` reads from `rcon_admin_log_events`
- `/api/current-match/players` reads from `rcon_admin_log_events`
- those routes do not trigger `materialize_rcon_admin_log`
- those routes do not query RCON directly per public request
- those routes avoid repeated storage initialization through `ensure_storage=False`
## Validation
Code validation completed locally:
- `python -m compileall backend/app`
- `cd backend; python -m unittest tests.test_current_match_payload`
- `cd backend; python -m unittest tests.test_rcon_historical_worker`
- `cd backend; python -m unittest tests.test_rcon_current_match_worker`
Coverage added/updated for:
- live worker one-shot persistence reuse
- live worker loop `max-runs`
- live worker one-time storage initialization with `ensure_storage=False` on hot writes
- live worker per-target failure isolation
- historical worker skip when a heavy run is already active
- compose split validation for live worker presence and historical safe interval
- current-match read paths remaining read-only and not triggering materialization
## Post-Deploy Validation Commands
### 1. Verify services
```powershell
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Command}}\t{{.Status}}"
```
### 2. Verify live worker command
```powershell
docker inspect -f '{{.Path}} {{json .Args}}' hll-vietnam-rcon-live-adminlog-worker-1
```
Adapt the container name to the actual stack service name if needed.
### 3. Verify historical worker is no longer 2-second heavy materialization
```powershell
docker inspect -f '{{.Path}} {{json .Args}}' hll-vietnam-rcon-historical-worker-1
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' hll-vietnam-rcon-historical-worker-1 | sort | grep -Ei 'CAPTURE|CURRENT|RCON|ADMIN|MATERIAL|INTERVAL|RETRY|LOOKBACK|LOCK'
```
Expected:
- no heavy materialization every `2` seconds
- historical interval should be safe if materialization remains in this service
### 4. Live worker logs
```powershell
docker logs --tail=200 hll-vietnam-rcon-live-adminlog-worker-1
```
Expected:
- short cycles
- per-target AdminLog counts
- no traceback or `UniqueViolation`
- no attempt to create `idx_rcon_historical_single_running_historical`
- no `player_stats_seen 838k`
- no `materialized_matches_updated` output every few seconds
- no repeated deadlocks
### 4a. Verify removed runtime index creation
```powershell
docker exec hll-vietnam-backend-1 sh -lc "grep -R \"idx_rcon_historical_single_running_historical\" -n /app/app || true"
```
Expected:
- no runtime schema code creates the removed unique index
- documentation or tests may still mention it as a removed regression string
### 5. Historical worker/materializer logs
```powershell
docker logs --since 30m hll-vietnam-rcon-historical-worker-1 2>&1 | grep -Ei "deadlock|materialized|player_stats_seen|capture-cycle|error|exception|traceback"
```
Expected:
- no repeated deadlocks
- no overlapping heavy cycles
- no heavy cycle every `2` seconds
### 6. DB capture run sanity
```powershell
docker exec -i hll-vietnam-postgres-1 psql -U hll_vietnam -d hll_vietnam -P pager=off -c "
SELECT
id,
mode,
status,
started_at,
completed_at,
targets_seen,
samples_inserted,
duplicate_samples,
failed_targets,
LEFT(COALESCE(notes,''), 160) AS notes
FROM rcon_historical_capture_runs
ORDER BY id DESC
LIMIT 20;
"
```
### 7. AdminLog freshness
```powershell
docker exec -i hll-vietnam-postgres-1 psql -U hll_vietnam -d hll_vietnam -P pager=off -c "
SELECT
target_key,
event_type,
COUNT(*) AS n,
MAX(server_time) AS max_server_time,
MAX(event_timestamp) AS max_event_timestamp,
MAX(created_at) AS max_created_at
FROM rcon_admin_log_events
WHERE target_key IN ('comunidad-hispana-01', 'comunidad-hispana-02')
AND created_at >= now() - interval '15 minutes'
GROUP BY target_key, event_type
ORDER BY target_key, n DESC;
"
```
### 8. API validation from Windows PowerShell
```powershell
cd "D:\Proyectos\HLL Vietnam"
$servers = @("comunidad-hispana-01", "comunidad-hispana-02")
foreach ($server in $servers) {
$ts = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
$kills = Invoke-RestMethod "https://comunidadhll.devzamode.es/api/current-match/kills?server=$server&limit=20&_ts=$ts" -Headers @{
"Cache-Control" = "no-cache"
"Pragma" = "no-cache"
}
$players = Invoke-RestMethod "https://comunidadhll.devzamode.es/api/current-match/players?server=$server&_ts=$ts" -Headers @{
"Cache-Control" = "no-cache"
"Pragma" = "no-cache"
}
""
"===== $server ====="
[PSCustomObject]@{
server = $server
kills_scope = $kills.data.scope
kills_confidence = $kills.data.confidence
kills_count = @($kills.data.items).Count
kills_stale_filtered = $kills.data.stale_events_filtered
players_scope = $players.data.scope
players_confidence = $players.data.confidence
players_count = @($players.data.items).Count
players_updated_at = $players.data.updated_at
}
"Latest kills:"
$kills.data.items | Select-Object -First 10 event_id,server_time,event_timestamp,killer_name,killer_team,victim_name,victim_team,weapon
"Top players:"
$players.data.items | Sort-Object kills -Descending | Select-Object -First 10 player_name,team,kills,deaths,teamkills,deaths_by_teamkill,most_used_weapon
}
```
Expected acceptance after redeploy:
- new CRCON kills should reach `rcon_admin_log_events` within roughly `5-30` seconds under normal conditions
- `/api/current-match/kills` should reflect them shortly after
- `/api/current-match/players` should also advance shortly after
- no worker should materialize `838k` player stats every few seconds
- repeated PostgreSQL deadlocks should stop
## Known Remaining Issue
`TEAM KILL` AdminLog parsing was left as follow-up work in this task.
Known behavior:
- parser currently recognizes `KILL:` but not the separate `TEAM KILL` label in some lines
- those lines can still land as `event_type="unknown"`
- that means some teamkills may remain absent from current-match feed/stats until a follow-up parser fix lands
This was intentionally not expanded into the central scope of the ingestion/materialization split.
## Risks And Rollback
Primary residual risks:
- if production has multiple unexpected worker replicas, logs should be reviewed after redeploy to confirm the split behaves as intended
- live freshness still depends on RCON AdminLog availability per target
Rollback path:
1. stop the dedicated `rcon-live-adminlog-worker`
2. restore the previous historical worker command/env in Portainer if required
3. redeploy the stack
Rollback should only be used if the new live worker introduces unexpected operational issues, because the previous combined design is known to starve current-match freshness under heavy materialization load.
-346
View File
@@ -1,346 +0,0 @@
# Global Ranking Page Plan
## Objective
Define the V1 functional contract for a dedicated `Ranking` page that exposes public top lists across HLL Vietnam community servers without merging this flow into `Stats`.
## Why Ranking Is Separate From Stats
`Stats` and `Ranking` solve different user jobs:
- `Stats` is player-centric: search one player, open one profile, inspect personal performance.
- `Ranking` is leaderboard-centric: open a public table, switch filters, compare top players.
They can safely share:
- the same sober tactical visual system already used by `stats.html` and `historico.html`
- the same backend health/offline messaging pattern
- the same table/card rendering conventions for loading, empty and error states
They must remain separate in navigation and contracts:
- `Stats` keeps player search and personal summary endpoints.
- `Ranking` gets its own page and dedicated ranking endpoint.
- `Ranking` may link to `Stats` for player lookup, but it does not embed the player search workflow as a primary interaction.
## Current Foundation
- Weekly and monthly historical leaderboard reads already exist in `backend/app/rcon_historical_leaderboards.py`.
- Annual ranking reads already exist through snapshot storage in `backend/app/rcon_annual_rankings.py`.
- Historical ranking remains RCON-first.
- Public scoreboard remains fallback or enrichment only and must not become the primary read path while RCON coverage exists.
## V1 Scope
The first Ranking page supports:
- public top-player lists
- timeframe filter: `weekly`, `monthly`, `annual`
- server scope filter: `all`, `comunidad-hispana-01`, `comunidad-hispana-02`
- metric filter: V1 committed to `kills`
- limit filter: default `20`, with smaller values allowed
- filter changes without manual page reload where feasible
- clear differentiation between `ready`, `missing`, `empty`, `offline` and request-error states
## V1.1 Metric Expansion Contract
V1.1 expands `Ranking global` only within the existing RCON-first read paths and annual snapshot boundary.
Supported metrics:
- `kills`
- `deaths`
- `teamkills`
- `matches_considered`
- `kd_ratio`
- `kills_per_match`
Calculation rules:
- `kills`: `SUM(kills)`
- `deaths`: `SUM(deaths)`
- `teamkills`: `SUM(teamkills)`
- `matches_considered`: `COUNT(DISTINCT match_key)` across closed matches in the selected window
- `kd_ratio`: `SUM(kills) / SUM(deaths)`
- `kills_per_match`: `SUM(kills) / COUNT(DISTINCT match_key)`
Safe division rules:
- if `deaths = 0`, `kd_ratio` returns `kills` as a finite display-safe value rather than dividing by zero
- if `matches_considered = 0`, `kills_per_match` returns `0`
- backend should return rounded display-safe decimal values for ratio metrics
Ordering and tie-break rules:
- `kills`: order by `kills` desc, `matches_considered` desc, `player_name` asc
- `deaths`: order by `deaths` desc, `matches_considered` desc, `player_name` asc
- `teamkills`: order by `teamkills` desc, `matches_considered` desc, `player_name` asc
- `matches_considered`: order by `matches_considered` desc, `kills` desc, `player_name` asc
- `kd_ratio`: order by `kd_ratio` desc, `kills` desc, `matches_considered` desc, `player_name` asc
- `kills_per_match`: order by `kills_per_match` desc, `kills` desc, `matches_considered` desc, `player_name` asc
Timeframe support:
- `weekly`: supports all V1.1 metrics through the existing materialized RCON runtime read model
- `monthly`: supports all V1.1 metrics through the existing materialized RCON runtime read model
- `annual`: remains snapshot-safe first; `kills` is required support and extra annual metrics are allowed only when a precomputed annual snapshot already exists for that metric
Annual safety boundary:
- annual requests must remain snapshot-backed
- annual requests must not trigger full-year recomputation in the public request path
- if an annual metric does not have a safe snapshot-backed read path, backend must return controlled `400` instead of degrading to runtime aggregation
- until extra annual snapshots are explicitly implemented, annual support is effectively `kills`-only
## V1 Non-goals
- Elo/MMR
- authentication
- private or expanded player profiles
- advanced charts
- weapon or map breakdowns
- large schema changes
- annual recalculation on public request
- reintroducing Comunidad Hispana #03
## User Flow
1. User opens `Ranking`.
2. Page checks backend availability.
3. User chooses timeframe.
4. User chooses server scope.
5. User chooses metric.
6. User chooses limit if needed.
7. Frontend requests ranking data and updates the list in place.
8. User can switch filters again without manual reload.
9. If the user wants one-player analysis, the page offers a small link to `Stats`.
## Data Source Policy
### Weekly and monthly
- Source: materialized RCON/AdminLog leaderboard read model.
- Reader: existing weekly/monthly selection logic in `select_leaderboard_window(...)`.
- Ordering: metric desc, `matches_considered` desc, `player_name` asc.
- V1 metric commitment: `kills`.
- V1.1 metric support: `kills`, `deaths`, `teamkills`, `matches_considered`, `kd_ratio`, `kills_per_match`.
### Annual
- Source: persisted annual ranking snapshots.
- Reader: existing annual snapshot loader.
- No annual recomputation during public requests.
- Missing annual data must surface as a controlled `snapshot_status="missing"` state.
- V1 required annual metric: `kills`.
- Extra annual metrics are out of scope until their snapshot generation and read path are explicitly implemented.
## API Contract Direction
### Dedicated Ranking endpoint
```http
GET /api/ranking?timeframe=weekly|monthly|annual&server_id=<server-or-all>&metric=<metric>&limit=20&year=<year-when-annual>
```
Purpose:
- give Ranking its own public contract without changing Stats endpoints or mixing player-profile concerns into ranking reads
Query rules:
- `timeframe` is optional and defaults to `weekly`
- `server_id` is optional and defaults to `all`
- `metric` is optional and defaults to `kills`
- `limit` is optional and defaults to `20`
- `year` is required only when `timeframe=annual`
Validation rules:
- allowed `timeframe`: `weekly`, `monthly`, `annual`
- V1.1 allowed `metric` for weekly/monthly: `kills`, `deaths`, `teamkills`, `matches_considered`, `kd_ratio`, `kills_per_match`
- allowed `annual` metric by default: `kills`
- annual extra metrics require an explicit snapshot-backed implementation; otherwise return `400`
- allowed `limit`: `1..100`
- `year` must be a positive integer when annual is requested
- annual requests ignore weekly/monthly runtime window policy and read snapshots only
- unsupported metric, unsupported timeframe or unsupported annual metric must return a controlled request-validation error and must not silently downgrade to `kills`
## Response Contract
```json
{
"status": "ok",
"data": {
"page_kind": "global-ranking",
"timeframe": "monthly",
"server_id": "all",
"metric": "kills_per_match",
"limit": 20,
"requested_limit": 20,
"window_start": "2026-06-01T00:00:00Z",
"window_end": "2026-06-08T18:00:00Z",
"window_kind": "current-month",
"window_label": "Mes actual",
"source": {
"primary_source": "rcon",
"read_model": "rcon-materialized-admin-log-leaderboard",
"generated_at": "2026-06-08T18:00:00Z",
"freshness": "runtime"
},
"snapshot_status": "ready",
"items": [
{
"ranking_position": 1,
"player_id": "76561198000000000",
"player_name": "Rambo",
"metric_value": 30.07,
"matches_considered": 14,
"kills": 421,
"deaths": 280,
"teamkills": 3,
"kd_ratio": 1.5,
"kills_per_match": 30.07
}
]
}
}
```
## Field Rules
Common top-level fields:
- `timeframe`: `weekly`, `monthly`, `annual`
- `server_id`: `all` or one supported active server id
- `metric`: requested supported metric
- `limit`: effective served limit
- `requested_limit`: requested client limit
- `snapshot_status`: always present for frontend state handling
- `items`: always an array
Required item fields:
- `ranking_position`
- `player_id`
- `player_name`
- `metric_value`
- `matches_considered`
- `kills`
- `deaths`
- `teamkills`
- `kd_ratio`
- `kills_per_match`
Conditional item rules:
- `metric_value` may be integer or rounded decimal depending on the requested metric
- `kills_per_match` should be present when that metric is supported in the response contract
- `matches_considered` remains required because it is both a displayed field and a common tie-break input
Weekly/monthly-only metadata:
- `window_start`
- `window_end`
- `window_kind`
- `window_label`
- `selection_reason`
Annual-only metadata:
- `year`
- `generated_at`
- `snapshot_limit`
- `effective_limit`
- `item_count`
## Timeframe-specific expectations
### Weekly
- Use the same weekly fallback rule already defined by the leaderboard read model.
- If current-week sample is insufficient, backend may serve previous-week data.
- Frontend must not reinterpret the window; it should display returned window metadata.
### Monthly
- Use the same current-month vs previous-month rule already defined by the leaderboard read model.
- Frontend must display the returned monthly window metadata exactly as served.
### Annual
- Read only from annual snapshots.
- `snapshot_status="ready"` with `items=[]` is a valid empty-ready state.
- `snapshot_status="missing"` is not a backend crash and must be rendered distinctly.
- Annual must not pretend support for metrics that do not have precomputed snapshots.
- If client requests an unsupported annual metric, backend should return `400` rather than an empty-ready payload.
## UI State Contract
The Ranking page must support these states explicitly:
- `loading`: request in flight
- `backend offline`: `/health` unavailable or ranking request unreachable
- `no data`: successful response with `items=[]` in weekly/monthly
- `annual snapshot missing`: annual response with `snapshot_status="missing"`
- `unsupported metric`: client requested unsupported metric or backend returns 400 metric validation error
- `controlled error`: valid HTTP response with request validation error or unexpected backend failure
Expected rendering behavior:
- loading keeps previous table hidden or visually muted
- backend offline shows a clear retry-safe message
- no data keeps filters visible and explains that there are no rows for the active scope
- annual snapshot missing explains that the annual snapshot has not been generated yet
- unsupported metric does not silently downgrade to another metric
- unsupported annual metric explains that the metric is not available for annual snapshots yet
- controlled error does not break page navigation or filter controls
## Backend Behavior Requirements
- Preserve existing Stats endpoint compatibility.
- Do not expose public-scoreboard as the normal primary source for Ranking.
- Do not expand annual generation logic inside request handling.
- Keep server scope limited to active supported scopes: `all`, `comunidad-hispana-01`, `comunidad-hispana-02`.
- Do not surface Comunidad Hispana #03 in defaults, options or backend read scope.
- Do not add large new tables or a second ranking architecture for V1.1 metric expansion.
## Payload Adjustment Notes
- The existing `metric_value` field remains the primary sortable/display value for the active metric.
- Weekly and monthly responses should keep the existing totals payload and may add `kills_per_match` when the backend serves that metric.
- Annual responses should keep the same payload shape and only expand metrics when the snapshot schema already stores the required values safely.
- Client code should continue to rely on explicit `metric` and `snapshot_status` fields rather than inferring support from item contents alone.
## V1.1 Non-goals
- Elo/MMR
- public scoreboard as primary ranking source
- annual runtime full-year recomputation
- large new tables
- advanced charts
- authentication
- Comunidad Hispana #03
## Frontend Integration Guidance
Recommended page split:
- `stats.html`: personal lookup and personal ranking context
- `ranking.html`: public top lists
Recommended safe reuse:
- backend availability chip pattern from `stats.js`
- existing tactical panel and card styling from `styles.css`
- simple select/button driven filtering similar to `historico.html`
Recommended minimal cross-links:
- `Ranking` page links to `Stats` with copy like "Buscar jugador"
- `Stats` page may link back to `Ranking` with copy like "Ver ranking global"
## Follow-up Task Suggestions
- Implement `GET /api/ranking` by adapting existing weekly/monthly leaderboard reads and annual snapshot reads.
- Build `frontend/ranking.html` and `frontend/assets/js/ranking.js` against this contract.
- Add a small regression script for Ranking endpoint and frontend state validation.
-188
View File
@@ -1,188 +0,0 @@
# Player Period Stats Read Model
## Objective
Define the PostgreSQL read model that backs personal player stats by period so `/api/stats/players/{player_id}` does not aggregate large RCON historical tables on every public request when a regenerated read model is available.
## Table
Primary table:
- `player_period_stats`
Operational scope:
- PostgreSQL is the default operational store
- SQLite remains available only as explicit local compatibility for validation and isolated maintenance runs
Read-model nature:
- this table is regenerable
- it is not the canonical source of truth
- canonical historical data remains in `rcon_materialized_matches` and `rcon_match_player_stats`
## Stored Fields
Each row stores one player projection for one public scope and one active period window:
- `period_type`
- `window_kind`
- `period_start`
- `period_end`
- `server_id`
- `player_id`
- `player_name`
- `matches_considered`
- `kills`
- `deaths`
- `teamkills`
- `ranking_position`
- `kd_ratio`
- `kills_per_match`
- `first_seen_at`
- `last_seen_at`
- `updated_at`
Current period types:
- `weekly`
- `monthly`
- `yearly`
Current scope rows:
- `all-servers`
- `comunidad-hispana-01`
- `comunidad-hispana-02`
Why `server_id` exists:
- the public profile endpoint already supports `server_id`
- keeping one row per scope preserves the current frontend contract without recalculating large runtime aggregates for server-filtered profiles
Why `window_kind` exists:
- the profile payload already exposes weekly and monthly ranking context
- persisting `current-week`, `previous-week`, `current-month` or `previous-month` keeps the public contract stable without recomputing that selection on each request
## Period Windows
Window selection follows the same policy already used by ranking/stats reads where applicable:
- `weekly`
- uses `select_leaderboard_window(... timeframe="weekly")`
- current week is used only when the closed-match threshold is sufficient
- otherwise the read model stores the previous week window
- `monthly`
- uses `select_leaderboard_window(... timeframe="monthly")`
- from day 1 to day 7 it stores the previous month
- from day 8 onward it stores the current month
- `yearly`
- stores the current UTC year from January 1 until refresh time
- it is prepared internally even if the current public profile contract still requests weekly/monthly only
## Refresh Command
Manual command:
```bash
python -m app.rcon_historical_player_stats refresh-player-period-stats
```
SQLite-only local override:
```bash
python -m app.rcon_historical_player_stats refresh-player-period-stats --sqlite-path backend/data/hll_vietnam_dev.sqlite3
```
Refresh policy:
- rebuild from materialized RCON/AdminLog tables
- replace rows scope by scope and period by period
- keep the latest player name seen inside the selected period window
- persist ranking position by kills inside each generated period window
Automatic runner refresh:
- `backend/app/historical_runner.py` refreshes `player_period_stats` automatically
- it inherits the periodic cadence of the historical runner via `HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS`
- the runner executes this step after `player_search_index` and before `ranking_snapshots`
- the runner still attempts this step even if the legacy historical snapshot block fails earlier in the same cycle
- the overall runner result may end as `partial` when that legacy block fails but operational PostgreSQL refreshes continue
- the public profile path keeps runtime fallback preserved if the read model is empty, incomplete or unavailable
Emergency manual command:
```bash
python -m app.rcon_historical_player_stats refresh-player-period-stats
```
## Public Read Path
Priority for `/api/stats/players/{player_id}`:
1. use `player_period_stats` when the requested scope has generated rows for the required periods
2. serve the requested weekly/monthly totals and ranking context directly from the read model
3. fall back to runtime aggregation only when:
- the read model table is unavailable
- the requested scope has no generated rows yet
- the player has no generated row for one required period
- a controlled read error occurs
Returned compatibility:
- the payload still returns `player_id`
- the payload still returns `player_name`
- the payload still returns `matches_considered`
- the payload still returns `kills`
- the payload still returns `deaths`
- the payload still returns `teamkills`
- the payload still returns `kd_ratio`
- the payload still returns `kills_per_match`
- the payload still returns weekly/monthly ranking blocks
Source metadata:
- read-model path reports `source.read_model = "player-period-stats"`
- read-model path reports `source.fallback_used = false`
- runtime fallback keeps the same public contract and reports `source.fallback_used = true`
## PostgreSQL Notes
No extra PostgreSQL extensions are required.
Indexes kept for the public profile flow:
- `(player_id, period_type, server_id)`
- `(server_id, period_type)`
- `last_seen_at`
- `updated_at`
## Production Validation
Recommended checks after refresh:
- confirm the historical runner output reports either `status=ok` or `status=partial`
- confirm `historical_snapshot_result`, `player_search_index_result`, `player_period_stats_result` and `ranking_snapshot_result` are present in the cycle payload
- if the cycle is `partial`, inspect `historical_snapshot_result.error_type`, `historical_snapshot_result.error` and the runner logs for the legacy failure
- confirm `player_period_stats.updated_at` advanced even when a legacy snapshot error was reported
- confirm the historical runner output reports `player_period_stats_result`
- if an emergency rebuild is needed, run `python -m app.rcon_historical_player_stats refresh-player-period-stats`
- confirm the command reports rows for:
- `all-servers`
- `comunidad-hispana-01`
- `comunidad-hispana-02`
- `weekly`
- `monthly`
- `yearly`
- call `/api/stats/players/<known-player>?timeframe=weekly`
- verify response metadata reports `read_model=player-period-stats`
- verify response metadata reports `fallback_used=false` when the read model is populated
- verify fallback metadata appears only when the read model is empty, incomplete or unavailable
## Current Limitations
- the public route still exposes weekly/monthly only; yearly is prepared internally for future use
- the runner refreshes all supported public scopes and periods on each cycle even when a manual runner execution is limited with `--server`
- runtime fallback remains necessary as a safety net even with periodic automation in place
- canonical historical truth remains in materialized RCON tables, not in the read model
-147
View File
@@ -1,147 +0,0 @@
# Player Search Read Model
## Objective
Define the first dedicated read model for player search so `/api/stats/players/search` does not aggregate large RCON historical tables on every public request.
## Table
Primary table:
- `player_search_index`
Operational scope:
- PostgreSQL is the default operational store
- SQLite remains available only as explicit local compatibility for validation and isolated maintenance runs
Read-model nature:
- this table is regenerable
- it is not the canonical source of truth
- canonical historical data remains in `rcon_materialized_matches` and `rcon_match_player_stats`
## Stored Fields
Each row stores one player projection for one public search scope:
- `server_id`
- `player_id`
- `player_name`
- `normalized_player_name`
- `first_seen_at`
- `last_seen_at`
- `servers_seen`
- `matches_current_year`
- `kills_current_year`
- `deaths_current_year`
- `teamkills_current_year`
- `updated_at`
Current scope rows:
- `all-servers`
- `comunidad-hispana-01`
- `comunidad-hispana-02`
Why `server_id` exists:
- the public search endpoint already supports `server_id`
- keeping one row per scope preserves the existing frontend contract without recalculating large runtime aggregates for server-filtered searches
## Refresh Command
Manual command:
```bash
python -m app.rcon_historical_player_stats refresh-player-search-index
```
SQLite-only local override:
```bash
python -m app.rcon_historical_player_stats refresh-player-search-index --sqlite-path backend/data/hll_vietnam_dev.sqlite3
```
Refresh policy:
- rebuild from materialized RCON/AdminLog tables
- replace rows scope by scope
- aggregate only the current UTC year
- keep the latest current-year player name
- store accent-insensitive normalized names in Python
Automatic runner refresh:
- `backend/app/historical_runner.py` refreshes `player_search_index` automatically
- it inherits the periodic cadence of the historical runner via `HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS`
- the runner executes this step after the existing RCON ingestion/materialization cycle
- the runner still attempts this step even if the legacy historical snapshot block fails earlier in the same cycle
- the overall runner result may end as `partial` when that legacy block fails but operational PostgreSQL refreshes continue
- the runner keeps runtime fallback preserved for the public endpoint
Emergency manual command:
```bash
python -m app.rcon_historical_player_stats refresh-player-search-index
```
## Public Read Path
Priority for `/api/stats/players/search`:
1. use `player_search_index` when the requested scope has rows
2. return read-model results directly, including empty query results when the index is populated but the query does not match
3. fall back to runtime aggregation only when:
- the read model table is unavailable
- the requested scope has no rows yet
- a controlled read error occurs
Returned compatibility:
- the payload still returns `player_id`
- the payload still returns `player_name`
- the payload still returns `matches_considered`
- the payload still returns `last_seen_at`
- the payload still returns `servers_seen`
`matches_considered` remains compatible by mapping from `matches_current_year`.
## PostgreSQL Notes
No extra PostgreSQL extensions are required.
Specifically:
- no `pg_trgm`
- no custom text-search extension
Search tolerance is implemented with:
- normalized lowercase names
- accent stripping in Python
- indexed scope + normalized-name reads
- runtime fallback preserved as a safety net
## Current Limitations
- this read model is focused on player search only, not personal profile totals
- counts are current-year only by design
- historical players with no activity in the current UTC year are not intentionally prioritized in this first model
- the runner refreshes all supported public scopes on each cycle even when a manual runner execution is limited with `--server`
- profile and personal stats use their own dedicated read model and still preserve runtime fallback when needed
## Production Validation
Recommended checks after refresh:
- confirm the historical runner output reports either `status=ok` or `status=partial`
- confirm `historical_snapshot_result`, `player_search_index_result`, `player_period_stats_result` and `ranking_snapshot_result` are present in the cycle payload
- if the cycle is `partial`, inspect `historical_snapshot_result.error_type`, `historical_snapshot_result.error` and the runner logs for the legacy failure
- confirm `player_search_index.updated_at` advanced even when a legacy snapshot error was reported
- confirm the historical runner output reports `player_search_index_result`
- if an emergency rebuild is needed, run `python -m app.rcon_historical_player_stats refresh-player-search-index`
- confirm the command reports rows for `all-servers`, `comunidad-hispana-01` and `comunidad-hispana-02`
- call `/api/stats/players/search?q=<known-player>&limit=5`
- verify response metadata reports `read_model=player-search-index`
- verify fallback metadata only appears when the read model is empty or unavailable
-181
View File
@@ -1,181 +0,0 @@
# Public Snapshot Refresh Schedule
## Goal
Public pages must read persisted snapshots and read models only. No public GET endpoint should regenerate ranking or historical snapshots at request time.
## Current Scheduler Owner
The internal `historical-runner` owns public refresh scheduling. Host cron is not required.
## Implemented Cadence
Local timezone for scheduled jobs: `Europe/Madrid`.
Heavy daily window:
- full public refresh:
- `06:00`
- rebuilds full historical snapshots, weekly/monthly ranking snapshots, annual ranking snapshots, player search index and player period stats
- runs under `public-full-refresh`
Ranking page:
- annual ranking:
- rebuilt inside the daily full refresh at `06:00`
- generated sequentially per scope and metric
- monthly ranking:
- `07:00` and `19:00`
- generated sequentially per scope and metric
- runs under `public-ranking-monthly-refresh`
- weekly ranking:
- every hour at minute `10`
- generated sequentially per scope and metric
- runs under `public-ranking-weekly-refresh`
Historical page:
- weekly historical leaderboard subset:
- every hour at minute `25`
- scopes:
- `all-servers`
- `comunidad-hispana-01`
- `comunidad-hispana-02`
- metrics:
- `kills`
- `deaths`
- `matches_over_100_kills`
- `support`
- runs under `public-historical-weekly-refresh`
- monthly historical UI subset:
- every `2` hours at minute `40`
- same scopes as weekly historical
- leaderboard metrics:
- `kills`
- `deaths`
- `matches_over_100_kills`
- `support`
- also refreshes:
- `monthly-mvp`
- `monthly-mvp-v2`
- runs under `public-historical-monthly-refresh`
Recent matches:
- interval polling fallback remains enabled through `HLL_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS`
- default: `60` seconds
- immediate refresh still happens when the RCON capture loop materializes a finished match
## Why Historical Weekly And Monthly Were Missing
The previous short cadence only refreshed:
- `ranking_snapshots` for `/api/ranking`
- `recent-matches` snapshots
It did not refresh the broader historical snapshot subset consumed by `historico.html`. That left weekly and monthly historical leaderboard payloads in `snapshot_status=missing` until the next daily full refresh.
## Locking And Overlap Policy
The scheduler uses in-process locks and controlled skips. It does not enqueue backlog jobs.
Heavy jobs:
- `public-full-refresh`
- `public-ranking-annual-refresh`
- `public-ranking-monthly-refresh`
Policy:
- heavy jobs do not overlap with other heavy jobs
- hourly and bi-hourly jobs skip when a conflicting heavy job is in progress
- if a heavy job already ran in the same scheduler tick, lower-priority jobs skip and retry on the next tick
- weekly and monthly historical subset jobs also avoid overlapping with each other
This keeps the implementation simple and avoids CPU spikes without reintroducing request-time fallback work.
## Manual Job Hardening
Manual public jobs executed with `python -m app.historical_runner --public-job ...` now follow the same storage and error-handling rules as scheduled runs:
- CLI JSON output serializes `datetime` and `date` values safely
- ranking snapshot rows are deduplicated by `player_id` before inserting `ranking_snapshot_items`
- PostgreSQL snapshot storage initialization runs once at the start of the heavy job, then substeps reuse non-DDL connections
- missing `player_event_raw_ledger` no longer aborts `historical-monthly`; the monthly MVP V2 slice degrades to an empty payload with `event_coverage.ready = false`
- materialized storage initialization gating stays in the leaderboard wrapper layer; `initialize_rcon_materialized_storage()` is called without extra keywords only when initialization is actually required
This keeps manual validation commands useful without hiding partial failures inside the job payload.
## Last Update Exposure
Historical and ranking payloads continue to expose persisted generation metadata from the snapshot records:
- `snapshot_status`
- `generated_at`
- `source_range_start`
- `source_range_end`
- `is_stale`
`historico.js` already renders `generated_at` as the visible "Actualizado" label, so no frontend contract change was required for this task.
## Environment Variables
Existing variables kept:
- `HLL_PUBLIC_FULL_REFRESH_ENABLED`
- `HLL_PUBLIC_FULL_REFRESH_TIME`
- `HLL_PUBLIC_FULL_REFRESH_TIMEZONE`
- `HLL_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS`
Retained for compatibility:
- `HLL_PUBLIC_RANKING_REFRESH_INTERVAL_SECONDS`
- no longer drives the hourly/slot-based ranking scheduler
- still participates in runner tick resolution and legacy compatibility paths
New scheduler variables:
- `HLL_PUBLIC_RANKING_WEEKLY_REFRESH_MINUTE`
- default `10`
- `HLL_PUBLIC_RANKING_MONTHLY_REFRESH_TIMES`
- default `07:00,19:00`
- `HLL_PUBLIC_HISTORICAL_WEEKLY_REFRESH_MINUTE`
- default `25`
- `HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_MINUTE`
- default `40`
- `HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_HOUR_INTERVAL`
- default `2`
## Manual Validation Commands
One-off public jobs through the runner:
```powershell
docker compose exec historical-runner python -m app.historical_runner --public-job ranking-weekly
docker compose exec historical-runner python -m app.historical_runner --public-job ranking-monthly
docker compose exec historical-runner python -m app.historical_runner --public-job historical-weekly
docker compose exec historical-runner python -m app.historical_runner --public-job historical-monthly
docker compose exec historical-runner python -m app.historical_runner --public-job public-full
```
Direct ranking matrix commands:
```powershell
docker compose exec historical-runner python -m app.rcon_historical_leaderboards refresh-ranking-snapshots --timeframe weekly --limit 30
docker compose exec historical-runner python -m app.rcon_historical_leaderboards refresh-ranking-snapshots --timeframe monthly --limit 30
```
Operational checks:
```powershell
docker logs --tail=300 hll-vietnam-historical-runner-1
docker exec hll-vietnam-historical-runner-1 sh -lc 'env | sort | grep HLL_PUBLIC'
python .\scripts\audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --filter servers --output tmp\task240_servers_after.json
python .\scripts\audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --output tmp\task240_full_audit_after.json
```
UI checks:
- verify `historico.html` weekly rankings render without `snapshot_status=missing`
- verify `historico.html` monthly rankings render without `snapshot_status=missing`
- verify the visible "Actualizado" label changes after the corresponding runner job
-362
View File
@@ -1,362 +0,0 @@
# Ranking Snapshot Read Model Plan
## Objective
Define a snapshot-backed read model for weekly and monthly public ranking so `GET /api/ranking` does not depend on runtime aggregation over materialized RCON match stats on every request.
The design follows the same philosophy as the current annual ranking snapshot path:
- generate outside the public request path
- serve stable snapshot rows through a small read model
- expose clear metadata for `ready`, `missing` and controlled fallback states
## Scope
Impacted public endpoint:
- `GET /api/ranking?timeframe=weekly|monthly|annual&server_id=<scope>&metric=<metric>&limit=<n>&year=<year-when-annual>`
Primary target of this plan:
- weekly ranking snapshots
- monthly ranking snapshots
Compatibility target:
- the model must also represent annual snapshots so the repository can converge on one ranking snapshot vocabulary over time
- `TASK-191` should keep annual requests on the existing annual snapshot path until migration is explicitly implemented
## Why This Is Needed
`TASK-188` showed:
- weekly/monthly ranking still performs repeated window counting and grouped aggregation at request time
- stats/ranking runtime reads depend on `rcon_materialized_matches` and `rcon_match_player_stats`
- the empty June 2026 window hides the structural cost, but the query plans still show scans and temp B-trees
`TASK-189` reduced some scan risk with indexes, but it did not remove the core public-request recomputation pattern.
## Proposed Tables
### `ranking_snapshots`
Purpose:
- one snapshot header per `(timeframe, server_id, metric, window_start, window_end)`
Proposed fields:
- `id`
- `timeframe`
- allowed values: `weekly`, `monthly`, `annual`
- `server_id`
- `all-servers`, `comunidad-hispana-01`, `comunidad-hispana-02`
- `metric`
- V1.1 weekly/monthly: `kills`, `deaths`, `teamkills`, `matches_considered`, `kd_ratio`, `kills_per_match`
- annual: keep `kills` as the required supported metric until annual expansion is explicitly implemented
- `window_start`
- `window_end`
- `generated_at`
- `source`
- expected current value: `rcon-materialized-admin-log`
- `snapshot_status`
- expected values: `ready`, `building`, `failed`
- `item_count`
- `limit_size`
- `source_matches_count`
- `freshness`
- example values: `fresh`, `stale`
- `error_message`
- nullable, operational only
Key rules:
- unique key on `(timeframe, server_id, metric, window_start, window_end)`
- keep only one `ready` snapshot per exact window and metric scope
- `item_count` reflects stored rows, not the request limit
- `limit_size` records how many positions were generated, for example top 20
### `ranking_snapshot_items`
Purpose:
- ordered player rows for one ranking snapshot
Proposed fields:
- `id`
- `snapshot_id`
- `ranking_position`
- `player_id`
- `player_name`
- `metric_value`
- `matches_considered`
- `kills`
- `deaths`
- `teamkills`
- `kd_ratio`
- `kills_per_match`
Key rules:
- unique key on `(snapshot_id, ranking_position)`
- unique key on `(snapshot_id, player_id)`
- all item rows must carry the common display totals even when the requested metric is different
## Snapshot Window Rules
### Weekly
Window logic:
- preserve the current weekly selection policy already exposed by `select_leaderboard_window(...)`
- snapshot window is whichever week the public runtime policy would have served:
- `current-week` when the current week has sufficient closed matches
- otherwise `previous-week`
Stored metadata:
- `timeframe=weekly`
- `window_start`
- `window_end`
- `snapshot_status`
- `generated_at`
- `source='rcon-materialized-admin-log'`
### Monthly
Window logic:
- preserve the current monthly selection policy already exposed by `select_leaderboard_window(...)`
- snapshot window is:
- `previous-month` until day 7 inclusive
- `current-month` after day 7
Stored metadata:
- `timeframe=monthly`
- `window_start`
- `window_end`
- `snapshot_status`
- `generated_at`
- `source='rcon-materialized-admin-log'`
### Annual
Window logic:
- `year-01-01T00:00:00Z` to `(year+1)-01-01T00:00:00Z`
Transition note:
- the new table design supports annual snapshots
- `TASK-191` should keep annual requests on the existing `rcon_annual_ranking_snapshots` path until a dedicated migration task consolidates storage
## Refresh Policy
Current windows:
- weekly current: refresh every `5` to `15` minutes
- monthly current: refresh every `15` to `30` minutes
- annual current: manual or daily
Closed windows:
- previous week: treated as closed and stable
- previous month: treated as closed and stable
- annual closed windows: treated as stable after generation
Operational rules:
- generation happens outside the public request path
- one refresh job should upsert the header row and replace the corresponding item set atomically
- if refresh fails, keep the last `ready` snapshot until a new `ready` snapshot is produced
## Public Fallback Policy
Public read priority:
1. serve snapshot when a matching `ready` snapshot exists
2. if no matching snapshot exists:
- return controlled `snapshot_status='missing'`
- or use runtime fallback only when configuration explicitly enables it
3. never recalculate by default on every public request
Required policy fields in API response:
- `source`
- `snapshot_status`
- `generated_at`
- `freshness`
- `fallback_used`
- `window_start`
- `window_end`
Expected meanings:
- `source='ranking-snapshot'` when serving stored weekly/monthly snapshot rows
- `source='rcon-materialized-runtime-fallback'` only when configuration allows fallback and snapshot is missing
- `snapshot_status='ready'` when snapshot rows were served
- `snapshot_status='missing'` when no snapshot exists for the requested window and runtime fallback is disabled
## Expected API Metadata
Weekly/monthly responses should expose:
- `page_kind`
- `timeframe`
- `server_id`
- `metric`
- `limit`
- `requested_limit`
- `window_start`
- `window_end`
- `window_kind`
- `window_label`
- `snapshot_status`
- `generated_at`
- `freshness`
- `fallback_used`
- `source`
- `items`
Snapshot item contract:
- `ranking_position`
- `player_id`
- `player_name`
- `metric_value`
- `matches_considered`
- `kills`
- `deaths`
- `teamkills`
- `kd_ratio`
- `kills_per_match`
## Generation Source
Authoritative source for weekly/monthly/annual ranking snapshots:
- `rcon_materialized_matches`
- `rcon_match_player_stats`
Source filter:
- `matches.source_basis = 'admin-log-match-ended'`
Generation logic:
- reuse the same metric formulas and tie-break ordering already documented for `Ranking`
- do not use public scoreboard as the primary ranking source
- do not reintroduce Comunidad Hispana #03
## Transition Notes For TASK-191
Implementation order:
1. add snapshot lookup helpers for weekly/monthly
2. make `/api/ranking` weekly/monthly try snapshot lookup first
3. keep annual on the current annual snapshot loader
4. add controlled runtime fallback behind configuration
5. return explicit metadata for `ready`, `missing` and fallback cases
Expected runtime behavior in `TASK-191`:
- weekly/monthly:
- snapshot first
- runtime fallback only when snapshot is missing and fallback is enabled
- controlled missing when snapshot is missing and fallback is disabled
- annual:
- keep current snapshot behavior unchanged
Operational note after `TASK-191`:
- snapshot tables are initialized automatically on first ranking access
- snapshot rows are not generated automatically yet
- runtime fallback remains enabled by default for transition through `HLL_BACKEND_RANKING_RUNTIME_FALLBACK_ENABLED=true`
- operators can force controlled missing behavior by setting `HLL_BACKEND_RANKING_RUNTIME_FALLBACK_ENABLED=false`
## Manual Generation Workflow
Manual generator entrypoint:
```bash
python -m app.rcon_historical_leaderboards generate-ranking-snapshot --timeframe weekly --server-key all --metric kills --limit 20
```
Bulk refresh entrypoint:
```bash
python -m app.rcon_historical_leaderboards refresh-ranking-snapshots --limit 30
```
Operational default:
- when `HLL_BACKEND_DATABASE_URL` is configured, the CLI uses PostgreSQL by default
- SQLite is no longer the default operational target for snapshot generation
- local SQLite generation remains available only through an explicit override such as:
```bash
python -m app.rcon_historical_leaderboards generate-ranking-snapshot --timeframe weekly --server-key all --metric kills --limit 20 --sqlite-path backend/data/hll_vietnam_dev.sqlite3
```
Docker form:
```bash
docker compose exec backend python -m app.rcon_historical_leaderboards generate-ranking-snapshot --timeframe weekly --server-key all --metric kills --limit 20
```
Bulk Docker form:
```bash
docker compose exec backend python -m app.rcon_historical_leaderboards refresh-ranking-snapshots --limit 30
```
Operational expectation:
- the recommended Docker command should generate weekly/monthly snapshots in PostgreSQL, matching the `/api/ranking` production read path
Supported manual parameters:
- `timeframe`: `weekly`, `monthly`
- `server-key`: `all`, `all-servers`, `comunidad-hispana-01`, `comunidad-hispana-02`
- `metric`: `kills`, `deaths`, `teamkills`, `matches_considered`, `kd_ratio`, `kills_per_match`
- `limit`: positive integer, normally `20`
Current implementation note:
- `generate-ranking-snapshot` remains unitary per command invocation for explicit manual control
- `refresh-ranking-snapshots --limit 30` generates the full weekly/monthly public matrix in one run
- the periodic historical runner in `backend/app/historical_runner.py` invokes that bulk refresh as part of the normal backend refresh cycle
- current cycle order is:
- existing RCON ingestion/materialization cycle
- legacy `historical_snapshot_result`
- `player_search_index`
- `player_period_stats`
- `ranking_snapshots`
- per-combination failures should be reported without aborting the entire matrix refresh
- a legacy historical snapshot failure can leave the full cycle in `partial`, but the runner should still attempt the operational PostgreSQL refreshes and report `ranking_snapshot_result`
## Recommended Combinations
Minimum production matrix for parity with the public Ranking filters:
- weekly + `all-servers` + all six supported metrics
- weekly + `comunidad-hispana-01` + all six supported metrics
- weekly + `comunidad-hispana-02` + all six supported metrics
- monthly + `all-servers` + all six supported metrics
- monthly + `comunidad-hispana-01` + all six supported metrics
- monthly + `comunidad-hispana-02` + all six supported metrics
That matrix requires `36` snapshot generations for each refresh cycle.
Operational default limit:
- generate each combination with `limit=30`
- that stored `limit_size=30` covers the current UI request limits `10`, `20` and `30`
## Suggested Frequency
Suggested operator cadence:
- weekly current window: regenerate every `5` to `15` minutes while the active week is changing
- monthly current window: regenerate every `15` to `30` minutes while the active month is changing
- previous week and previous month windows: regenerate once after closure or after any historical backfill that changes source coverage
Operational guidance:
- regenerate after materialized RCON/AdminLog data grows
- regenerate after manual backfill
- regenerate after metric SQL changes that affect ranking totals or ordering
- when using the periodic backend runner, keep ranking refresh attached to the same recurring cycle rather than a separate scheduler unless operational load proves otherwise
- the runner inherits its cadence from `HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS`
- if the runner reports `partial`, review `historical_snapshot_result` and backend logs before assuming `ranking_snapshots` was skipped
- confirm `ranking_snapshots.generated_at` or the latest `updated_at`/window timestamp advanced in PostgreSQL even when a legacy snapshot error was logged
- fallback runtime remains preserved for `/api/ranking` if a requested snapshot is missing or unavailable
## Ready Vs Fallback
Expected API result after successful generation for an exact requested combination:
- `snapshot_status=ready`
- `fallback_used=false`
- `source.read_model=ranking-snapshot`
Expected API result when the requested combination has not been generated yet and fallback remains enabled:
- `snapshot_status=missing`
- `fallback_used=true`
- `freshness=runtime`
Expected API result when the requested combination has not been generated and fallback is disabled:
- `snapshot_status=missing`
- `fallback_used=false`
- `items=[]`
## Out Of Scope
- backend implementation of the snapshot generator
- migrations
- frontend changes
- annual storage migration from the legacy annual snapshot tables
- Elo/MMR
- public scoreboard as ranking primary source
-262
View File
@@ -1,262 +0,0 @@
# Ranking And Stats Performance Audit
## Scope
Audit date:
- 2026-06-09
Target endpoints:
- `/api/ranking?timeframe=weekly&server_id=all&metric=kills&limit=20`
- `/api/ranking?timeframe=weekly&server_id=all&metric=kd_ratio&limit=20`
- `/api/ranking?timeframe=monthly&server_id=all&metric=kills_per_match&limit=20`
- `/api/ranking?timeframe=annual&year=2026&server_id=all&metric=kills&limit=20`
- `/api/stats/players/search?q=Chi&limit=10`
- `/api/stats/players/3530e19cf8a9dd6a9fada4599592fbf8?timeframe=weekly`
## Environment And Method
- Backend HTTP server was not running on `http://127.0.0.1:8000` during the audit.
- Request timing was measured in-process through `app.routes.resolve_get_payload(...)`.
- SQL timing was measured by instrumenting the SQLite read connections used by:
- `backend/app/rcon_historical_leaderboards.py`
- `backend/app/rcon_historical_player_stats.py`
- `backend/app/rcon_annual_rankings.py`
- `EXPLAIN QUERY PLAN` was obtained successfully from SQLite.
- Storage inspected:
- `backend/data/hll_vietnam_dev.sqlite3`
Important coverage note:
- The latest materialized match rows for `source_basis="admin-log-match-ended"` end on `2026-05-20T23:21:45.816Z`.
- The weekly/monthly public requests on `2026-06-09` therefore read windows with `0` qualifying matches.
- Measured latency is real for the current dataset, but it understates future production cost because the active public windows are empty.
## Measured Baseline
| Endpoint | Avg request ms | Avg SQL ms | SQL queries | Result |
| --- | ---: | ---: | ---: | --- |
| `/api/ranking` weekly `kills` | 4.045 | 0.882 | 6 | `snapshot_status=ready`, `items=0` |
| `/api/ranking` weekly `kd_ratio` | 3.801 | 0.837 | 6 | `snapshot_status=ready`, `items=0` |
| `/api/ranking` monthly `kills_per_match` | 3.612 | 0.816 | 6 | `snapshot_status=ready`, `items=0` |
| `/api/ranking` annual `kills` | 3.326 | 0.629 | 3 | `snapshot_status=ready`, `items=2` |
| `/api/stats/players/search?q=Chi&limit=10` | 6.731 | 3.593 | 3 | `items=10` |
| `/api/stats/players/{player_id}` weekly | 8.924 | 4.494 | 16 | weekly profile payload |
Slowest endpoint:
- `/api/stats/players/{player_id}` weekly
- Main reason: 16 SQL statements per request, including repeated window-count queries and two ranking subqueries.
## Relevant Table Size And Coverage
Row counts:
- `rcon_materialized_matches`: `58`
- `rcon_match_player_stats`: `3,824`
- `rcon_annual_ranking_snapshots`: `2`
- `rcon_annual_ranking_snapshot_items`: `2`
Rows actually used by runtime ranking/stats:
- `rcon_materialized_matches` with `source_basis="admin-log-match-ended"`: `22`
Observed covered range for runtime ranking/stats:
- first materialized match end/start: `2026-05-19T11:16:10.281Z`
- latest materialized match end/start: `2026-05-20T23:21:45.816Z`
Current public window coverage:
- weekly window `2026-06-01T00:00:00Z` to `2026-06-08T00:00:00Z`: `0` matches
- monthly window `2026-06-01T00:00:00Z` to `2026-06-09T23:59:59Z`: `0` matches
Annual snapshot presence:
- `2026 / all-servers / kills / ready / limit_size=2 / source_matches_count=22`
## Existing Indexes
`rcon_materialized_matches`
- unique `(target_key, match_key)`
- non-unique `(target_key, ended_at, ended_server_time)`
`rcon_match_player_stats`
- unique `(target_key, match_key, player_id)`
- non-unique `(target_key, match_key)`
`rcon_annual_ranking_snapshots`
- unique `(year, server_key, metric)`
- non-unique `(year, server_key, metric)`
- non-unique `(status)`
`rcon_annual_ranking_snapshot_items`
- unique `(snapshot_id, ranking_position)`
- unique `(snapshot_id, player_id)`
- non-unique `(snapshot_id, ranking_position)`
- non-unique `(snapshot_id, player_id)`
## Current Query Shapes
Weekly/monthly ranking runtime path:
- 4x `COUNT(*)` over `rcon_materialized_matches` to choose weekly/monthly windows
- 1x aggregate join from `rcon_match_player_stats` to `rcon_materialized_matches`
- 1x source-range query over `rcon_materialized_matches`
Annual ranking path:
- 1x snapshot lookup in `rcon_annual_ranking_snapshots`
- 1x item count in `rcon_annual_ranking_snapshot_items`
- 1x ordered item read in `rcon_annual_ranking_snapshot_items`
Stats player search path:
- 1x grouped search query joining `rcon_match_player_stats` to `rcon_materialized_matches`
- 1x latest-name lookup for returned `player_id` set
- 1x `servers_seen` lookup for returned `player_id` set
Stats player detail path:
- 12x `COUNT(*)` window-selection queries over `rcon_materialized_matches`
- 1x player aggregate query
- 1x source-range query
- 2x ranking-position subqueries using grouped leaderboard logic
## Execution Plan Findings
SQLite `EXPLAIN QUERY PLAN` showed:
Ranking count queries:
- `SCAN matches`
Weekly/monthly leaderboard aggregate queries:
- `SCAN matches`
- `SEARCH stats USING INDEX idx_rcon_match_player_stats_match (target_key=? AND match_key=?)`
- `USE TEMP B-TREE FOR GROUP BY`
- `USE TEMP B-TREE FOR count(DISTINCT)`
- `USE TEMP B-TREE FOR ORDER BY`
Stats search primary query:
- `SCAN matches`
- `SEARCH stats USING INDEX idx_rcon_match_player_stats_match (target_key=? AND match_key=?)`
- `USE TEMP B-TREE FOR GROUP BY`
- `USE TEMP B-TREE FOR count(DISTINCT)`
- `USE TEMP B-TREE FOR ORDER BY`
Stats player detail aggregate query:
- `SCAN stats`
- `SEARCH matches USING INDEX sqlite_autoindex_rcon_materialized_matches_1 (target_key=? AND match_key=?)`
- `USE TEMP B-TREE FOR count(DISTINCT)`
Stats player detail ranking subquery:
- `SCAN stats USING INDEX sqlite_autoindex_rcon_match_player_stats_1`
- `SEARCH matches USING INDEX sqlite_autoindex_rcon_materialized_matches_1 (target_key=? AND match_key=?)`
- `USE TEMP B-TREE FOR GROUP BY`
- `USE TEMP B-TREE FOR count(DISTINCT)`
- `USE TEMP B-TREE FOR ORDER BY`
Annual snapshot lookup:
- `SEARCH rcon_annual_ranking_snapshots USING INDEX sqlite_autoindex_rcon_annual_ranking_snapshots_1 (year=? AND server_key=? AND metric=?)`
## Root Cause Assessment
Current measured latency is low because the runtime weekly/monthly windows are empty on `2026-06-09`, not because the runtime read path is already efficient at scale.
The most likely root causes for future slowdown are:
- no index that matches `source_basis + time window` on `rcon_materialized_matches`
- repeated public-request window counting against `rcon_materialized_matches`
- grouped leaderboard/search queries that spill to temp B-trees for `GROUP BY`, `COUNT(DISTINCT)` and `ORDER BY`
- no direct `player_id` read index for the stats player-detail aggregate path
The main non-performance operational issue exposed by the audit:
- runtime weekly/monthly ranking currently has stale coverage for the current public window because the latest materialized match data ends on `2026-05-20`, while the requests were evaluated on `2026-06-09`
## Recommendation For TASK-189
Priority indexes justified by the current plans:
- add a time-window read index on `rcon_materialized_matches` keyed by `source_basis` plus the runtime time field used by public reads
- add a scoped time-window index on `rcon_materialized_matches` that also includes `target_key`
- add a direct `player_id` index on `rcon_match_player_stats`
Concrete candidate coverage to evaluate in implementation:
- `rcon_materialized_matches(source_basis, ended_at)`
- `rcon_materialized_matches(source_basis, started_at)`
- `rcon_materialized_matches(target_key, source_basis, ended_at)`
- `rcon_materialized_matches(target_key, source_basis, started_at)`
- `rcon_match_player_stats(player_id)`
Notes:
- `(target_key, match_key)` is already covered and should be preserved.
- annual snapshot item lookup is already covered by `(snapshot_id, ranking_position)` and `(snapshot_id, player_id)`.
- a simple `player_name` B-tree is not likely to materially help the current search query because the query uses `LOWER(...) LIKE '%term%'` with a leading wildcard. If search becomes a real bottleneck later, a normalized-prefix strategy or FTS is more promising than a plain index.
## TASK-189 Applied Indexes
Applied in storage initialization for both SQLite and PostgreSQL:
- `idx_rcon_materialized_matches_source_window_text`
- `idx_rcon_materialized_matches_target_source_window_text`
- `idx_rcon_materialized_matches_external_source_window_text`
- `idx_rcon_match_player_stats_player_id_match`
Not added:
- plain `player_name` index
Reason:
- the current search query uses `LOWER(player_name) LIKE '%term%'`, so a simple B-tree on `player_name` is not expected to materially help the current search shape
## TASK-189 Post-Index Check
Observed plan change:
- weekly count query now uses `SEARCH matches USING COVERING INDEX idx_rcon_materialized_matches_source_window_text (source_basis=? AND <expr>>? AND <expr><?)`
- stats player-detail aggregate now uses:
- `SEARCH matches USING INDEX idx_rcon_materialized_matches_source_window_text (...)`
- `SEARCH stats USING INDEX sqlite_autoindex_rcon_match_player_stats_1 (target_key=? AND match_key=? AND player_id=?)`
Before/after timing probe:
- `/api/stats/players/{player_id}` weekly
- before: request `8.924 ms`, SQL `4.494 ms`
- after: request `4.300 ms`, SQL `1.043 ms`
- `/api/ranking` weekly `kills`
- before: request `4.045 ms`, SQL `0.882 ms`
- after: request `4.724 ms`, SQL `0.881 ms`
- interpretation: no meaningful change in this dataset because the current public weekly window on `2026-06-09` is empty and the table is still small
Residual gap after indexes:
- weekly/monthly public ranking still performs repeated runtime counting and grouped aggregation per request
- snapshots remain the correct next step for predictable public ranking latency
## Recommendation For TASK-190
Weekly/monthly public ranking should move to snapshot-backed reads for the same reason annual ranking is already cheap:
- runtime ranking currently performs repeated window counting and grouped aggregation on every public request
- the empty-window result on `2026-06-09` hides that structural cost
- snapshot-backed weekly/monthly reads would make public ranking latency predictable and remove repeated runtime recomputation from the request path
Recommended transition policy:
- serve weekly/monthly ranking from snapshots when `snapshot_status=ready`
- keep runtime fallback controlled and optional during migration
- do not recalculate heavy weekly/monthly aggregates by default on every public request
## Commands Executed
Repository and task context:
- `Get-Content AGENTS.md`
- `Get-Content ai/architecture-index.md`
- `Get-Content ai/repo-context.md`
- `Get-Content ai/tasks/pending/TASK-188-audit-ranking-and-stats-query-performance.md`
- `Get-Content ai/orchestrator/backend-senior.md`
- `Get-Content ai/orchestrator/database-architect.md`
Relevant code inspection:
- `Get-Content backend/app/routes.py`
- `Get-Content backend/app/payloads.py`
- `Get-Content backend/app/rcon_historical_leaderboards.py`
- `Get-Content backend/app/rcon_historical_player_stats.py`
- `Get-Content backend/app/rcon_annual_rankings.py`
- `Get-Content backend/app/config.py`
- `Get-Content backend/app/postgres_rcon_storage.py`
- `Get-Content backend/app/sqlite_utils.py`
- `Get-Content docs/global-ranking-page-plan.md`
- `Get-Content scripts/run-stats-validation.ps1`
Environment inspection:
- `Get-ChildItem backend/data -Force`
- `try { (Invoke-WebRequest -UseBasicParsing -TimeoutSec 3 http://127.0.0.1:8000/health).Content } catch { $_.Exception.Message }`
Measurement and database inspection:
- inline Python against `backend/data/hll_vietnam_dev.sqlite3` for endpoint timing, SQL timing, row counts, `PRAGMA index_list`, `PRAGMA index_info` and `EXPLAIN QUERY PLAN`
## Limitations
- No live HTTP timing was captured because the backend process was not running locally during the audit.
- The current runtime weekly/monthly windows were empty on `2026-06-09`, so the measured latency is a lower bound, not a stressed production baseline.
- The database is large on disk because the repository stores many other domains, but the materialized ranking/stats tables used by these endpoints are currently small.
-123
View File
@@ -1,123 +0,0 @@
# Plan de integracion de Stats en frontend
## 1. Pagina prevista
- Pantalla objetivo: `frontend/stats.html` (nueva).
- Objetivo V1: pagina ligera, statically-safe, con componentes para:
- buscador
- resultados
- panel de stats personales
- resumen semanal
- resumen mensual
- bloque futuro de ranking anual top 20 (placeholder)
## 2. Navegacion
El cambio no debe romper Inicio, Historico ni Partida actual.
- Mantener `frontend/index.html` con su estructura actual.
- Cambios de navegacion propuestos:
- Añadir un link `Stats` en el bloque de acciones del hero:
- `href="./stats.html"`
- texto: `Stats`
- estilo reutilizando `.secondary-button` o variante especifica en CSS si se requiere.
- En `frontend/historico.html`:
- añadir boton de regreso a Inicio si no existe.
- opcionalmente incluir acceso rapido a `stats.html` en el header.
- En `partida-actual.html` (futuro, si usa una plantilla):
- no modificar por ahora.
- Regla de compatibilidad:
- No alterar rutas de `historico.html` ni de `partida-actual.html`.
- Mantener rutas existentes (`./historico.html`, `./index.html`) intactas.
## 3. Componentes V1
- Buscador de jugador
- Input para texto libre.
- Boton de ejecucion.
- Debounce/submit manual para evitar ruido.
- Query query param `q`.
- Lista de resultados
- Mostrar por fila:
- `player_name`
- `player_id` (texto de soporte)
- `matches_considered`
- `last_seen_at`
- `servers_seen` (solo si se integra en payload final).
- Cada fila habilita seleccion de jugador.
- Panel de estadisticas personales
- Estado base despues de seleccionar jugador:
- nombre y player_id
- server/scope visible.
- Datos por `GET /api/stats/players/{player_id}?timeframe=...&server_id=...`.
- Resumen semanal
- Render a partir de `data.weekly_ranking` y `data` para la misma ventana.
- Resumen mensual
- Render a partir de `data.monthly_ranking` y datos de ventana.
- Bloque anual futuro
- Reservado en layout con estado:
- `Proximo: ranking anual top 20`.
- Sin llamada ni dependencia now.
## 4. Endpoints usados
- `GET /api/stats/players/search?q=<query>`
- `q` obligatorio.
- parametros recomendados V1:
- `server_id` opcional (`all-servers` como default).
- `limit` opcional.
- `GET /api/stats/players/{player_id}`
- para semana: `timeframe=weekly`.
- para mes: `timeframe=monthly`.
- para cambios de scope: `server_id=all-servers|comunidad-hispana-01|comunidad-hispana-02`.
## 5. Estados de UI
- Loading
- Skeleton o placeholder en buscador y lista de resultados.
- Mensaje breve en paneles de resumen con estado neutro.
- Error
- Mensaje tactico y silencioso en consola.
- Mantener contenido previo visible.
- Sin resultados
- estado vacio: `No se encontraron jugadores para esta busqueda`.
- Jugador sin stats
- si `matches_considered == 0`:
- mostrar `Sin actividad registrada en el periodo`.
- Backend no disponible
- banner no intrusivo de estado.
- no bloquear el resto de la pagina.
## 6. Identidad visual
- Mantener estilo militar / Vietnam / tactico / sobrio:
- variables CSS existentes en `frontend/assets/css/styles.css`.
- paleta actual.
- tarjetas y borders similares a `panel`, `clan-card`, `server-card`.
- No introducir frameworks.
- Preferir reutilizacion:
- `hero`, `panel`, `status-chip`, `secondary-button` y utilidades de layout existentes.
- Agregar estilos nuevos solo en `styles.css` si la combinacion base no cubre el layout.
## 7. Archivos esperados para implementacion futura
- `frontend/stats.html`
- `frontend/assets/js/stats.js`
- `frontend/assets/css/styles.css` (solo si necesario para grid, estados, card o tabla simple)
- `frontend/index.html` y/o `frontend/historico.html` para navegacion a Stats, sin tocar la logica de secciones actuales.
## Alcance de validacion
Esta task es un plan y no requiere pruebas automaticas.
- Verificar por dif:
- solo `ai/tasks/pending/TASK-164-design-stats-frontend-integration-plan.md`
- `docs/stats-frontend-integration-plan.md`
- Revisar que el plan respete las restricciones:
- sin cambios backend
- sin implementacion
- sin ranking anual
- sin elo/mmr
- sin comunidad hispana #03
- Preparar siguiente task: implementacion de `frontend/stats.html` y `frontend/assets/js/stats.js` usando endpoints V1.
-311
View File
@@ -1,311 +0,0 @@
# Stats Section Functional Plan
## Objective
Define the V1 functional contract for a future `Stats` section where a player can search their profile, review personal historical performance across HLL Vietnam community servers, and understand their weekly and monthly ranking position without implementing backend or frontend yet.
## Current Foundation
- HLL Vietnam historical read paths are RCON-first.
- `backend/app/rcon_historical_leaderboards.py` already defines weekly and monthly leaderboard windows over materialized RCON/AdminLog match stats.
- Current reliable materialized counters for public player-facing V1 are kills, deaths, teamkills and matches considered.
- Public scoreboard data remains fallback or enrichment only; it must not become the normal primary source for this section while RCON coverage exists.
## V1 Scope
The first `Stats` contract covers:
- player search by name or player id
- player detail view for one selected player
- personal totals for a selected timeframe and server scope
- weekly ranking position by kills
- monthly ranking position by kills
- data freshness and source metadata
- annual top 20 preparation through a dedicated snapshot model
V1 should support two server scopes:
- one specific server id
- `all` aggregated across active supported servers
## User Flow
1. User opens the future `Stats` section.
2. User searches by partial player name or exact player id.
3. Backend returns compact search matches ordered by best textual match and recent activity.
4. User selects one player result.
5. Frontend requests the player detail payload for a chosen server scope and timeframe.
6. UI shows core totals, calculated ratios, weekly rank position and monthly rank position.
7. UI may later request annual ranking snapshot data for the selected year without recalculating it on each public request.
## API Contract Direction
### 1. Player search
```http
GET /api/stats/players/search?q=<query>&server_id=<server-or-all>&limit=10
```
Purpose:
- resolve a user-entered name fragment or player id into selectable player records backed by materialized historical data
Query rules:
- `q` is required
- `server_id` is optional and defaults to `all`
- `limit` is optional and defaults to `10`, with a hard cap such as `20`
Response shape:
```json
{
"status": "ok",
"data": {
"query": "rambo",
"server_id": "all",
"items": [
{
"player_id": "76561198000000000",
"player_name": "Rambo",
"matches_considered": 42,
"last_seen_at": "2026-06-06T21:40:00Z",
"servers_seen": ["comunidad-hispana-01", "comunidad-hispana-02"]
}
]
}
}
```
Search result notes:
- `player_id` is the canonical selection key for later requests
- `player_name` is the latest display name known from materialized stats
- `matches_considered` is the aggregated closed-match count in the selected scope
- `servers_seen` is optional and may be omitted when `server_id` is not `all`
### 2. Personal player stats
```http
GET /api/stats/players/{player_id}?server_id=<server-or-all>&timeframe=weekly|monthly
```
Purpose:
- return the selected player summary for the chosen scope and timeframe plus ranking position context
Response shape:
```json
{
"status": "ok",
"data": {
"player_id": "76561198000000000",
"player_name": "Rambo",
"server_id": "all",
"timeframe": "monthly",
"window_start": "2026-06-01T00:00:00Z",
"window_end": "2026-06-07T18:00:00Z",
"matches_considered": 12,
"kills": 356,
"deaths": 241,
"teamkills": 4,
"kd_ratio": 1.48,
"kills_per_match": 29.67,
"deaths_per_match": 20.08,
"weekly_ranking": {
"metric": "kills",
"ranking_position": 8,
"window_kind": "current-week",
"window_start": "2026-06-02T00:00:00Z",
"window_end": "2026-06-07T18:00:00Z"
},
"monthly_ranking": {
"metric": "kills",
"ranking_position": 5,
"window_kind": "current-month",
"window_start": "2026-06-01T00:00:00Z",
"window_end": "2026-06-07T18:00:00Z"
},
"source": {
"primary_source": "rcon",
"read_model": "rcon-materialized-admin-log-player-stats",
"generated_at": "2026-06-07T18:02:00Z",
"source_range_start": "2026-06-01T00:14:00Z",
"source_range_end": "2026-06-07T17:51:00Z",
"freshness": "runtime"
}
}
}
```
Field rules:
- `timeframe=weekly` returns the player totals within the same weekly window policy used by leaderboard reads
- `timeframe=monthly` returns the player totals within the same monthly window policy used by leaderboard reads
- `timeframe=all` is not implemented in the current contract and remains out of scope until a dedicated implementation is added.
- `kd_ratio`, `kills_per_match` and `deaths_per_match` should be rounded for display-safe payloads
### 3. Annual ranking snapshot
```http
GET /api/stats/rankings/annual?year=<year>&server_id=<server-or-all>&metric=kills
```
Purpose:
- return a precomputed annual top 20 leaderboard snapshot without recalculating the full year on each public request
Response shape:
```json
{
"status": "ok",
"data": {
"year": 2026,
"server_id": "all",
"metric": "kills",
"generated_at": "2027-01-01T01:30:00Z",
"snapshot_status": "ready",
"items": [
{
"ranking_position": 1,
"player_id": "76561198000000000",
"player_name": "Rambo",
"metric_value": 4210,
"matches_considered": 148,
"kills": 4210,
"deaths": 2950,
"teamkills": 18,
"kd_ratio": 1.43
}
]
}
}
```
V1 annual endpoint notes:
- only `metric=kills` needs to be committed in the first annual design pass
- annual read path should be snapshot-backed, not runtime full-range aggregation
- if a requested year is not generated yet, respond with a controlled empty or pending state, not a slow public recalculation
## Ranking Calculation Rules
### Weekly position
- Use the same weekly window selected by `select_leaderboard_window(... timeframe="weekly")`
- Respect the existing sufficient-sample policy:
- use current week when the closed-match threshold is met
- fall back to previous week when the current week sample is insufficient
- Rank by total kills descending
- Break ties by:
- `matches_considered` descending
- `player_name` ascending
- Player position should be derived from the same aggregate query family as the public leaderboard to avoid contradictory ordering
### Monthly position
- Use the same monthly window selected by `select_leaderboard_window(... timeframe="monthly")`
- Respect the current rule already present in the read model:
- from day 1 to day 7, use previous month
- from day 8 onward, use current month
- Rank by total kills descending with the same tie-break rules used in weekly ranking
### Personal totals
- Use the same RCON materialized match/player stats source as leaderboard calculations
- Only closed matches within the selected scope and selected timeframe window should count
- Do not add support, offense, defense or weapon breakdown fields until they are proven reliable in the RCON materialized model
## Annual Snapshot Persistence Direction
Future implementation should add a dedicated persistence model instead of recomputing annual top 20 on every request.
### Table: `rcon_annual_ranking_snapshots`
Expected columns:
- `id`
- `server_id`
- `year`
- `metric`
- `generated_at`
- `source_range_start`
- `source_range_end`
- `snapshot_status`
- `item_count`
- `generation_policy`
- `notes` nullable
Expected uniqueness:
- unique on `(server_id, year, metric)`
Expected behavior:
- one authoritative snapshot row per year, scope and metric
- regenerated only by an explicit batch job, scheduled maintenance command or year-close workflow
### Table: `rcon_annual_ranking_snapshot_items`
Expected columns:
- `id`
- `snapshot_id`
- `ranking_position`
- `player_id`
- `player_name`
- `metric_value`
- `matches_considered`
- `kills`
- `deaths`
- `teamkills`
- `kd_ratio`
Expected uniqueness:
- unique on `(snapshot_id, ranking_position)`
- unique on `(snapshot_id, player_id)`
Expected behavior:
- store only the top 20 rows for the selected annual snapshot
- preserve display-ready values used by the frontend
- keep enough totals to show a concise annual card without joining additional tables at read time
### Generation policy
- Run after year close or through an explicit maintenance workflow
- Read from materialized RCON historical data, not live RCON calls
- Default scope should support both `all` and individual active servers
- Prefer idempotent replace or upsert semantics for a given `(server_id, year, metric)`
- Store `generated_at` and source range metadata for traceability
## V1 Non-goals
- Elo or MMR reactivation
- Comunidad Hispana #03 reintroduction
- support, combat, offense or defense ranking when not already reliable in materialized RCON stats
- weapon-level breakdowns
- charts, heatmaps or advanced visualizations
- authenticated or private player profiles
- backend endpoint implementation
- database migrations
- frontend page, components or scripts
## Future Extensions After V1
- map-by-map player breakdowns
- server comparison cards per player
- annual ranking filters beyond kills
- profile history charts when a stable event or snapshot series exists
- external links to trusted scoreboard detail when correlation is already validated
## Recommended Follow-up Tasks
- add player search endpoint over materialized RCON player stats
- add personal player stats endpoint with weekly and monthly ranking context
- design and implement annual ranking snapshot schema and generation command
- extend frontend data consumption plan with the `Stats` section integration sequence
- implement the `Stats` frontend section with static-safe fallback behavior
@@ -1,339 +0,0 @@
# Análisis: inferencia de destrucción de vehículos en partida actual por correlación de puntuación de combate
## 1) Resumen ejecutivo
No hay evidencia en la arquitectura actual de un `combat_score` (ni `offense_score`, `defense_score`, `support_score` ni `total_score`) consultable **en vivo por jugador** para partidas en tiempo real.
Se puede inferir destrucción de vehículo solo con baja confianza a partir del AdminLog (`kill` + arma anti-tanque) y sin score delta en vivo.
### Recomendación principal
Clasificación de viabilidad: **no viable sin nueva fuente** (para confirmación de destrucción de vehículo en partida actual).
- Viable parcialmente: generar **candidatos débiles** con evidencia de arma anti-tanque y metadatos de kill.
- No viable: confirmación robusta (`confirmed_adminlog_vehicle_event` o `score_delta_match`) bajo fuentes actuales.
---
## 2) Fuentes revisadas
- `backend/app/rcon_client.py`
- `backend/app/providers/rcon_provider.py`
- `backend/app/payloads.py`
- `backend/app/rcon_admin_log_parser.py`
- `backend/app/rcon_admin_log_storage.py`
- `backend/app/rcon_historical_worker.py`
- `backend/app/config.py`
- `backend/app/scoreboard_origins.py`
- `backend/app/rcon_scoreboard_correlation.py`
- `backend/app/providers/public_scoreboard_provider.py`
- `backend/app/rcon_historical_storage.py`
- `backend/app/postgres_rcon_storage.py`
- `backend/tests/test_current_match_payload.py`
- `backend/tests/test_rcon_admin_log_parser.py`
- `frontend/assets/js/partida-actual.js` (solo consumo actual, sin cambios)
---
## 3) Qué datos tenemos hoy
### RCON en vivo
- `query_live_server_sample` consulta `GetServerInformation` y expone estado general del servidor (mapa, modo, tiempos, jugadores conectados/máximos, scores por equipo, etc.).
- No hay consulta de roster detallada ni endpoint en este flujo que devuelva métricas por jugador.
### AdminLog
- El parser ya reconoce `kill`, `message`, `connected`, `disconnected`, `team_switch`, `match_*`, `chat`, `kick`, `ban`.
- `kill` incluye `killer_*`, `victim_*`, `weapon` y timestamp aproximado.
- No existe evento explícito de tipo “vehicle_destroyed” ni campo dedicado de score en el evento.
### Almacenamiento histórico/persistencia
- Eventos y mensajes se guardan en `rcon_admin_log_events`.
- Hay metadata de jugador en eventos (incluyendo identificadores de jugador cuando parseable), y payloads sin schema de puntuación por componente.
- No hay snapshot de `combat_score` por jugador en `live` ni en tablas de snapshot del flujo actual.
### Scoreboard público
- `public_scoreboard_provider.py` consume endpoints tipo `get_public_info`, `get_scoreboard_maps`, `get_map_scoreboard`.
- No hay integración que entregue componente de score por jugador en vivo dentro del código actual.
---
## 4) Qué datos faltan para inferencia robusta de destrucción de vehículo
- Score por jugador en vivo al instante (combat/offense/defense/support/total).
- Snapshot continuo por jugador (`target_key`, `player_id`) con timestamps alineables al servidor.
- Evento de destrucción de vehículo explícito en AdminLog.
- Normalización confiable de daño/munición en AdminLog que distinga:
- arma anti-tanque vs no anti-tanque,
- kill a infantería vs kill por daño de vehicular.
- Fuente de verdad para tabla de puntos de recompensa por vehículo/arma (si no hay oficial).
---
## 5) Respuesta a preguntas clave
1. ¿Alguna fuente actual devuelve score por jugador en vivo?
- `combat_score`, `offense_score`, `defense_score`, `support_score`, `total_score`: **no hay fuentes vivas por jugador identificadas en el flujo actual**.
- `kills/deaths` por jugador en vivo: **no desde RCON live payload actual**, solo derivables de AdminLog offline/cron histórico.
2. ¿`query_live_server_sample` o client actual devuelven roster nominal o solo conteos?
- Solo conteos/síntesis del servidor; **no roster nominal**.
3. ¿Scoreboard público expone API/página con stats por jugador en vivo?
- No se encontró en el código actual una fuente dedicada a estadísticas en vivo por jugador.
4. ¿AdminLog incluye `player_id` en kills/messages/connected/team_switch?
- Sí en eventos parseables (kill, connected, disconnected, team_switch), aunque `player_id` puede faltar o ser inconsistente por mensajes no normalizados.
5. ¿Se puede correlacionar de forma fiable un kill con snapshots de score?
- Hoy por hoy: **no**, porque no hay snapshots de score por jugador en vivo.
6. ¿Granularidad temporal necesaria?
- Para correlación por delta, se necesitan al menos snapshots cada `5s` (o menos idealmente) y tolerancia temporal acotada.
7. ¿Tablas temporales necesarias?
- Sí, si se adopta inferencia:
- `rcon_current_player_score_snapshots`
- `rcon_vehicle_destruction_candidates`
8. ¿Campos mínimos a guardar?
- Ver modelado propuesto en sección 8.
9. ¿Armas candidatas anti-tanque?
- `BAZOOKA`, `PIAT`, `PANZERSCHRECK`, `AT MINE`, `ANTI-TANK MINE`, `SATCHEL`, `AT GUN`, `CANNON` y variantes normalizadas.
10. ¿Cómo evitar falsos positivos?
- Usar reglas de confianza multi-factor y penalizar eventos con múltiples kills rápidas, sin score_delta, o cambios de identidad.
11. ¿Confianza de inferencia?
- Depende de la evidencia:
- `confirmed_adminlog_vehicle_event` (si aparece en futuro)
- `score_delta_match`
- `anti_tank_weapon_score_delta`
- `anti_tank_weapon_only`
12. ¿Distinguir bazooka matando infantería vs destruyendo vehículo?
- No de forma confiable con datos actuales.
13. ¿Distinguir mina antitanque vs antipersonal en AdminLog actual?
- No con seguridad actual; se puede mejorar por normalización de texto de arma, pero queda incierto.
14. ¿Hay datos suficientes para delta de puntuación antes/después?
- No. Falta captura continua de score por jugador en vivo.
15. ¿Cambios necesarios si la fuente existe?
- Backend: nuevo collector de snapshots por jugador + normalizador de armas/eventos.
- Worker: ventana de correlación, persistencia candidatos y limpieza.
- Sin cambios frontend si el objetivo inicial es solo análisis de viabilidad.
---
## 6) Diseño propuesto si `combat_score` **sí existiera** (ideal)
### Esquema temporal 1: snapshots de score
- Al recibir cada sample live:
- Capturar `combat_score`, `offense_score`, `defense_score`, `support_score`, `total_score` por jugador.
- Persistir con `captured_at` y `server_time`.
- Mantener ventana corta (13h) para memoria en caliente.
### Correlación
- Input: `kill` con arma anti-tanque.
- Buscar snapshot anterior en ventana `[-10s, 0s]` y posterior `[0s, +10s]`.
- Calcular delta y clasificar solo si salto cumple condiciones y hay contexto consistente.
### Decisión operativa sugerida
- Si la delta coincide con patrón fuerte y sin ruido: marca candidata de alta confianza.
- Si solo arma anti-tanque sin delta: candidata baja confianza.
- Si hay conflictos (multieventos) bajar confiabilidad o descartar.
---
## 7) Diseño alternativo si `combat_score` **no existe** (estado actual)
- Mantener estado actual sin feature productiva.
- Documentar y visualizar internamente solo candidatos débiles:
- Evento kill con arma anti-tanque.
- Co-ocurrencia de cambio de equipo/mapa/rounds en un rango temporal.
- No mostrar como “destrucción de vehículo” sin evidencia adicional.
- Guardar trazabilidad mínima para revisión manual futura.
---
## 8) Modelo de datos temporal propuesto
### Tabla: `rcon_current_player_score_snapshots`
- `id`
- `target_key`
- `player_id`
- `player_name`
- `team`
- `combat_score`
- `offense_score`
- `defense_score`
- `support_score`
- `total_score`
- `captured_at`
- `server_time`
- `source`
- `created_at`
### Tabla: `rcon_vehicle_destruction_candidates`
- `id`
- `target_key`
- `source_event_id`
- `event_timestamp`
- `server_time`
- `player_id`
- `player_name`
- `weapon`
- `score_before`
- `score_after`
- `combat_score_delta`
- `total_score_delta`
- `matched_score_bucket`
- `confidence`
- `evidence_json`
- `created_at`
### Retención recomendada
- Snapshots live: `13 horas`.
- Agregados agregados: no guardar indefinidamente; compactar por ventanas/ciclos.
---
## 9) Algoritmo de correlación before/after
1) Detectar evento `kill`.
2) Normalizar y clasificar arma (`AT`, ignorable, desconocida).
3) Obtener `player_id` del killer.
4) Buscar snapshot previo `[event_ts - 10s, event_ts]` (preferible el más cercano a `event_ts`).
5) Buscar snapshot posterior `[event_ts, event_ts + 10s]`.
6) Si existen ambos:
- `delta = score_after - score_before` por componente y total.
7) Clasificar:
- `confirmed_adminlog_vehicle_event`: si en el futuro aparece evento explícito.
- `score_delta_match`: si delta positivo con patrón fuerte y único.
- `anti_tank_weapon_score_delta`: AT + delta positivo compatible.
- `anti_tank_weapon_only`: solo evento AT sin delta.
8) Ajustar confianza por ruido:
- múltiples eventos en ventana,
- falta de `player_id`,
- desalineación temporal alta,
- cambios de nombre/teams.
---
## 10) Ventanas temporales recomendadas
- Ventana principal: `[-10s, +10s]` respecto al kill.
- Ventana de captura de snapshots: `5s` entre muestras.
- Ventana de desempate:
- Si hay múltiples eventos del mismo jugador en ±10s, reducir confianza.
- Si hay 2+ snapshots en ventana, tomar snapshot más cercano al evento y registrar distancia temporal.
---
## 11) Armas anti-tanque candidatas
- `BAZOOKA`, `PIAT`, `PANZERSCHRECK`, `AT MINE`, `ANTI-TANK MINE`, `SATCHEL`, `AT GUN`, `CANNON`.
- `SATCHEL` puede aparecer como variantes de texto; normalizar case/espacios/dashes.
- Mantener lista configurable (diccionario de normalización) y tabla de excepciones.
---
## 12) Niveles de confianza y señales
- `high`: score delta consistente + evento AT + pocos eventos paralelos + match limpio de ventana temporal.
- `medium`: weapon AT + score delta parcial o sin un campo consistente.
- `low`: solo AT weapon sin delta.
- `invalid`: sin `player_id` ni evidencia de correlación temporal.
---
## 13) Riesgos y falsos positivos
- Misclasificación de kills de infantería con armas AT.
- Eventos AT en spam (misfires, intentos, daños no letales).
- Mismatches de identidad (`player_id` ausente o cambiante).
- Latencia entre `event_timestamp` y `server_time`.
- Diferencias entre entorno de servidor y extracción live.
- Suposición de valor fijo de puntuación por destrucción.
---
## 14) Consultas SQL de validación en producción/JTA
### a) Revisión de términos vehiculares en AdminLog
```sql
select event_type, count(*)
from rcon_admin_log_events
where raw_message ilike '%vehicle%'
or raw_message ilike '%destroy%'
or raw_message ilike '%destroyed%'
or raw_message ilike '%tank%'
or raw_message ilike '%truck%'
or raw_message ilike '%halftrack%'
group by event_type
order by count(*) desc;
```
### b) Ejemplos de eventos relacionados con vehículo
```sql
select id, target_key, event_type, event_timestamp, raw_message
from rcon_admin_log_events
where raw_message ilike '%vehicle%'
or raw_message ilike '%destroy%'
or raw_message ilike '%destroyed%'
or raw_message ilike '%tank%'
or raw_message ilike '%truck%'
or raw_message ilike '%halftrack%'
order by id desc
limit 50;
```
### c) Armas reales en kills (extract de payload JSON)
PostgreSQL con `jsonb`:
```sql
select parsed_payload_json->>'weapon' as weapon, count(*)
from rcon_admin_log_events
where event_type = 'kill'
group by parsed_payload_json->>'weapon'
order by count(*) desc;
```
Fallback no-`jsonb` (si `json`/texto):
```sql
select parsed_payload_json::text::json ->> 'weapon' as weapon, count(*)
from rcon_admin_log_events
where event_type = 'kill'
group by parsed_payload_json::text::json ->> 'weapon'
order by count(*) desc;
```
### c1) Top players con kills por weapon (validación adicional)
```sql
select parsed_payload_json->>'killer_name' as player, parsed_payload_json->>'weapon' as weapon, count(*) as kills
from rcon_admin_log_events
where event_type = 'kill'
group by 1,2
order by kills desc
limit 100;
```
---
## 15) Recomendación final
Con el estado actual del repositorio, la inferencia de destrucción de vehículo **no es confirmable en vivo**.
Conclusión:
- `combat_score` en vivo por jugador: **no disponible** hoy.
- Por tanto, el score-delta **no puede implementarse hoy como verificación fuerte**.
- Se recomienda:
1. Mantener esta tarea como análisis/documento.
2. Validar si CRCON u otro canal puede proveer snapshots por jugador (`GetPlayers`/`GetPlayer`/endpoint oficial con score componentes).
3. En caso afirmativo, implementar primero snapshots y luego pipeline de correlación con ventanas y niveles de confianza.
4. Si no aparece fuente, continuar con señal heurística de baja confianza (AT kill only) sin presentación como destrucción confirmada.
### Nivel de viabilidad
- **No viable sin nueva fuente de score live** para detección fiable.
- **Viable parcialmente** para candidatos por heurística de arma + tiempo.
-559
View File
@@ -1,559 +0,0 @@
# Weapon Icon Black Mapping Analysis
## Resumen ejecutivo
Se cruzó el universo RCON pegado por el usuario con los **220 nombres únicos** consolidados desde `US_WEAPONS`, `SOVIET_WEAPONS`, `BRITISH_WEAPONS`, `CA_WEAPONS`, `AXIS_WEAPONS`, `ALL_WEAPONS` y `NO_SIDE_WEAPONS` (incluyendo `UNKNOWN`) frente a los **123 SVG** presentes en `frontend/assets/img/weapons/black/`.
El resultado deja asignadas todas las armas RCON y hace aparecer todos los iconos `black` en la tabla inversa. Cuando no existe silueta exacta, la asignación se documenta como compartida o fallback, con `confidence=low` en los casos dudosos.
## Conteo
- Armas únicas RCON: **220**
- Iconos `black` encontrados: **123**
- Asignaciones directas (`exact`): **73**
- Asignaciones compartidas (`shared`): **92**
- Asignaciones fallback: **55**
- Asignaciones dudosas (`confidence=low`): **38**
- Iconos usados en la tabla inversa: **123**
- Iconos compartidos por más de un arma: **46**
## Fuentes leídas
- `AGENTS.md`
- `ai/repo-context.md`
- `ai/architecture-index.md`
- `ai/orchestrator/analyst.md`
- `frontend/assets/js/partida-actual.js`
- `frontend/assets/img/weapons/black/`
- `ai/tasks/done/TASK-159-current-match-feed-rollback-and-weapon-icons.md`
- `ai/tasks/done/TASK-135-fix-rcon-match-detail-faction-assets.md`
## Listado completo de SVG en `frontend/assets/img/weapons/black/`
- `60l_supply_black.svg`
- `60l_transport_black.svg`
- `at_mine_gs_mk_v_black.svg`
- `ba10_black.svg`
- `bazooka_black.svg`
- `bedford_oyd_supply_black.svg`
- `bedford_oyd_transport_black.svg`
- `bishop_sp_25pdr_black.svg`
- `bombing_run_black.svg`
- `boys_anti_tank_rifle_black.svg`
- `bren_gun_black.svg`
- `browning_m1919_black.svg`
- `canadian_sten_mk_ii_black.svg`
- `churchill_mk_iii_avre_black.svg`
- `churchill_mk_iii_black.svg`
- `churchill_mk_vii_black.svg`
- `colt_1911_black.svg`
- `cromwell_black.svg`
- `crusader_mk_iii_black.svg`
- `enfield_no2_mk_i_black.svg`
- `feldspaten_black.svg`
- `fg42_black.svg`
- `fg42_x4_black.svg`
- `firefly_black.svg`
- `flammenwerfer41_black.svg`
- `flare_gun_black.svg`
- `fn_inglis_no2_mk_i_black.svg`
- `gewehr_black.svg`
- `gmc_cckw_353_supply_black.svg`
- `gmc_cckw_363_supply_black.svg`
- `gmc_cckw_363_transport_black.svg`
- `half_track_black.svg`
- `is_1_black.svg`
- `jeep_black.svg`
- `jeep_willys_black.svg`
- `kar98k_black.svg`
- `kar98k_x8_black.svg`
- `kubelwagen_black.svg`
- `kv2_black.svg`
- `lanchester_black.svg`
- `lee_enfield_jungle_carbine_black.svg`
- `lee_enfield_n4_black.svg`
- `lee_enfield_pattern_1914_black.svg`
- `lee_enfield_pattern_1914_sniper_black.svg`
- `lewis_gun_black.svg`
- `luger_p08_black.svg`
- `m1903_springfield_black.svg`
- `m1903_springfield_sniper_black.svg`
- `m1918a2_bar_black.svg`
- `m1_57mm_cannon_black.svg`
- `m1_carbine_black.svg`
- `m1_garand_black.svg`
- `m1a1_at_mine_black.svg`
- `m24_stielhandgranate_black.svg`
- `m2_ap_mine_black.svg`
- `m2_flamethrower_black.svg`
- `m3_grease_gun_black.svg`
- `m3_half_track_black.svg`
- `m3_knife_black.svg`
- `m43_stielhandgranate_black.svg`
- `m4a3_105mm_black.svg`
- `m8_greyhound_black.svg`
- `m97_black.svg`
- `mg34_black.svg`
- `mg42_black.svg`
- `mills_bomb_black.svg`
- `mk2_grenade_black.svg`
- `mosin_nagant_1891_black.svg`
- `mosin_nagant_9130_black.svg`
- `mosin_nagant_m38_black.svg`
- `mp40_black.svg`
- `mpl50_spade_black.svg`
- `nagant_m1895_black.svg`
- `no82_grenade_black.svg`
- `opel_blitz_supply_black.svg`
- `opel_blitz_transport_black.svg`
- `pak_40_75mm_black.svg`
- `panzer_iii_ausf_n_black.svg`
- `panzerschreck_black.svg`
- `piat_black.svg`
- `pomz_ap_mine_black.svg`
- `ppsh41_black.svg`
- `ppsh_41w_drum_black.svg`
- `precision_strike_black.svg`
- `ptrs41_black.svg`
- `qf_6_pounder_black.svg`
- `rg42_grenade_black.svg`
- `rifle_no4_mk_i_black.svg`
- `rifle_no4_mk_i_sniper_black.svg`
- `rifle_no5_mk_i_black.svg`
- `s_mine_black.svg`
- `satchel_charge_black.svg`
- `scoped_mosin_nagant_9130_black.svg`
- `scoped_svt40_black.svg`
- `sdkfz_121_luchs_black.svg`
- `sdkfz_161_panzer_iv_black.svg`
- `sdkfz_171_panther_black.svg`
- `sdkfz_181_tiger_1_black.svg`
- `sdkfz_234_puma_black.svg`
- `sdkfz_251_half_track_black.svg`
- `sherman_m4a3_75w_black.svg`
- `sherman_m4a3e2_76_black.svg`
- `sherman_m4a3e2_black.svg`
- `smle_no1_mk_iii_black.svg`
- `sten_gun_black.svg`
- `sten_gun_mk_ii_black.svg`
- `stg44_black.svg`
- `strafing_run_black.svg`
- `stuart_m5a1_black.svg`
- `sturmpanzer_iv_black.svg`
- `svt40_black.svg`
- `t34_76_black.svg`
- `t70_black.svg`
- `tellermine_43_black.svg`
- `tetrarch_black.svg`
- `thompson_black.svg`
- `tm35_at_mine_black.svg`
- `tokarev_tt33_black.svg`
- `walther_p38_black.svg`
- `webley_revolver_black.svg`
- `zis2_57mm_cannon_black.svg`
- `zis5_supply_black.svg`
- `zis5_transport_black.svg`
## Observaciones sobre el mapping actual de `partida-actual.js`
- El mapping operativo de `frontend/assets/js/partida-actual.js` cubre solo un subconjunto de armas de infantería y algunas aliases de coaxiales/vehículos, no el universo completo RCON.
- Ese mapping trabaja contra la carpeta `white/`, pero sirve para inferir normalizaciones útiles (`GEWEHR 43`, `KARABINER 98K`, `Panzerschreck`, `M1 Garand`, `Mosin`, `Scoped SVT40`, etc.).
- Antes de implementar el mapping `black`, conviene resolverlo en JS con aliases explícitos y sin renombrar SVGs, porque ya hay arrastre de nombres históricos/legacy.
## Tabla principal
| Arma RCON | Facción/origen | WeaponType | Icono asignado | Tipo | Confianza | Notas |
| --- | --- | --- | --- | --- | --- | --- |
| 105MM HOWITZER [M4A3 (105mm)] | US | SPA | m4a3_105mm_black.svg | fallback | low | The British BESA references on the M4A3(105) are likely data anomalies, but the closest reviewable fallback remains the M4A3(105) platform icon. |
| 122MM HOWITZER [M1938 (M-30)] | SOVIET | Artillery | zis2_57mm_cannon_black.svg | fallback | low | The folder has no Soviet towed howitzer icon; ZiS-2 is the closest Soviet field-gun silhouette. |
| 150MM HOWITZER [sFH 18] | AXIS | Artillery | pak_40_75mm_black.svg | fallback | low | The folder has no Axis towed howitzer icon; Pak 40 is the closest Axis field-gun silhouette. |
| 152MM M-10T [KV-2] | SOVIET | SPA | kv2_black.svg | shared | high | Mounted KV-2 weapons reuse the platform silhouette. |
| 155MM HOWITZER [M114] | US | Artillery | m1_57mm_cannon_black.svg | fallback | low | The folder has no US towed howitzer icon; M1 57mm is the closest US field-gun silhouette. |
| 19-K 45MM [BA-10] | SOVIET | Armor | ba10_black.svg | shared | high | Vehicle-mounted BA-10 weapons reuse the platform silhouette. |
| 20MM KWK 30 [Sd.Kfz.121 Luchs] | AXIS | Armor | sdkfz_121_luchs_black.svg | shared | high | Mounted Luchs weapons reuse the platform silhouette. |
| 230MM PETARD [Churchill Mk III A.V.R.E.] | BRITISH, CA | SPA | churchill_mk_iii_avre_black.svg | shared | high | Mounted AVRE weapons reuse the platform silhouette. |
| 37MM CANNON [M3 Stuart Honey] | BRITISH | Armor | stuart_m5a1_black.svg | shared | medium | British M3 Stuart Honey falls back to the visually equivalent Stuart M5A1 silhouette. |
| 37MM CANNON [Stuart M5A1] | CA, US | Armor | stuart_m5a1_black.svg | shared | medium | British M3 Stuart Honey falls back to the visually equivalent Stuart M5A1 silhouette. |
| 45MM M1937 [T70] | SOVIET | Armor | t70_black.svg | shared | high | Mounted T70 weapons reuse the platform silhouette. |
| 50mm KwK 39/1 [Sd.Kfz.234 Puma] | AXIS | Armor | sdkfz_234_puma_black.svg | shared | high | Mounted Puma weapons reuse the platform silhouette. |
| 57MM CANNON [M1 57mm] | US | Armor | m1_57mm_cannon_black.svg | fallback | low | The folder has no US towed howitzer icon; M1 57mm is the closest US field-gun silhouette. |
| 57MM CANNON [ZiS-2] | SOVIET | PAK | zis2_57mm_cannon_black.svg | fallback | low | The folder has no Soviet towed howitzer icon; ZiS-2 is the closest Soviet field-gun silhouette. |
| 60L (Supply) | CA | Armor | 60l_supply_black.svg | exact | high | - |
| 60L (Transport) | CA | Armor | 60l_transport_black.svg | exact | high | - |
| 7.5CM KwK 37 [Panzer III Ausf.N] | AXIS | SPA | panzer_iii_ausf_n_black.svg | shared | high | Mounted Panzer III N weapons reuse the platform silhouette. |
| 7.5CM KwK 37 [Sd.Kfz.161 Panzer IV] | AXIS | SPA | sdkfz_161_panzer_iv_black.svg | shared | high | Mounted Panzer IV weapons reuse the platform silhouette. |
| 75MM CANNON [PAK 40] | AXIS | PAK | pak_40_75mm_black.svg | fallback | low | The folder has no Axis towed howitzer icon; Pak 40 is the closest Axis field-gun silhouette. |
| 75MM CANNON [Sd.Kfz.161 Panzer IV] | AXIS | Armor | sdkfz_161_panzer_iv_black.svg | shared | high | Mounted Panzer IV weapons reuse the platform silhouette. |
| 75MM CANNON [Sd.Kfz.171 Panther] | AXIS | Armor | sdkfz_171_panther_black.svg | shared | high | Mounted Panther weapons reuse the platform silhouette. |
| 75MM CANNON [Sherman M4A3(75)W] | CA, US | Armor | sherman_m4a3_75w_black.svg | shared | high | Mounted Sherman M4A3(75)W weapons reuse the platform silhouette. |
| 75MM M3 GUN [Sherman M4A3E2] | US | Armor | sherman_m4a3e2_black.svg | shared | high | Mounted Jumbo 75mm weapons reuse the platform silhouette. |
| 76MM M1 GUN [Sherman M4A3E2(76)] | US | Armor | sherman_m4a3e2_76_black.svg | shared | high | Mounted Jumbo 76mm weapons reuse the platform silhouette. |
| 76MM ZiS-5 [T34/76] | SOVIET | Armor | t34_76_black.svg | shared | high | Mounted T34/76 weapons reuse the platform silhouette. |
| 88 KWK 36 L/56 [Sd.Kfz.181 Tiger 1] | AXIS | Armor | sdkfz_181_tiger_1_black.svg | shared | high | Mounted Tiger I weapons reuse the platform silhouette. |
| A.P. Shrapnel Mine Mk II | BRITISH, CA | Mine | s_mine_black.svg | fallback | medium | The British AP shrapnel mine falls back to the closest anti-personnel mine silhouette present in the folder. |
| A.T. Mine G.S. Mk V | BRITISH, CA | Mine | at_mine_gs_mk_v_black.svg | exact | high | - |
| BA-10 | SOVIET | Armor | ba10_black.svg | shared | high | Vehicle-mounted BA-10 weapons reuse the platform silhouette. |
| BAZOOKA | SOVIET, US | Bazooka | bazooka_black.svg | exact | high | Silhouette reused by US and Soviet bazooka entries because the RCON label is side-agnostic. |
| Bedford OYD (Supply) | BRITISH | Armor | bedford_oyd_supply_black.svg | exact | high | - |
| Bedford OYD (Transport) | BRITISH | Armor | bedford_oyd_transport_black.svg | exact | high | - |
| Bishop SP 25pdr | BRITISH | Armor | bishop_sp_25pdr_black.svg | fallback | medium | The only 25-pounder-related asset is the Bishop SP platform, so the towed QF 25-pounder falls back to the same ordnance family silhouette. |
| BOMBING RUN | NO_SIDE | Commander | bombing_run_black.svg | exact | high | - |
| Boys Anti-tank Rifle | BRITISH | Infantry | boys_anti_tank_rifle_black.svg | exact | high | - |
| Bren Gun | BRITISH, CA | MachineGun | bren_gun_black.svg | exact | high | - |
| BROWNING M1919 | US | MachineGun | browning_m1919_black.svg | exact | high | - |
| Canadian Sten Mk.II | CA | Infantry | canadian_sten_mk_ii_black.svg | exact | high | - |
| Churchill Mk III A.V.R.E. | BRITISH, CA | Armor | churchill_mk_iii_avre_black.svg | shared | high | Mounted AVRE weapons reuse the platform silhouette. |
| Churchill Mk.III | BRITISH | Armor | churchill_mk_iii_black.svg | shared | high | Mounted Churchill Mk.III weapons reuse the platform silhouette. |
| COAXIAL BESA [Cromwell] | BRITISH | Armor | cromwell_black.svg | fallback | low | The generic BESA 7.92mm entry has no platform suffix, so it falls back to the Cromwell icon as a representative British armored BESA platform. |
| COAXIAL BESA [Crusader Mk.III] | BRITISH | Armor | crusader_mk_iii_black.svg | fallback | medium | There is no standalone Crusader RCON platform entry in the pasted file, so the icon is anchored through its mounted weapon strings. |
| COAXIAL BESA [Daimler] | BRITISH, CA | Armor | m8_greyhound_black.svg | fallback | low | No dedicated Daimler icon exists; the closest armored-car silhouette is M8 Greyhound. Exact M8 entries share the same icon. |
| COAXIAL BESA [Tetrarch] | BRITISH | Armor | tetrarch_black.svg | shared | high | Mounted Tetrarch weapons reuse the platform silhouette. |
| COAXIAL BESA 7.92mm | BRITISH | Armor | cromwell_black.svg | fallback | low | The generic BESA 7.92mm entry has no platform suffix, so it falls back to the Cromwell icon as a representative British armored BESA platform. |
| COAXIAL BESA 7.92mm [Churchill Mk III A.V.R.E.] | BRITISH, CA | Armor | churchill_mk_iii_avre_black.svg | shared | high | Mounted AVRE weapons reuse the platform silhouette. |
| COAXIAL BESA 7.92mm [Churchill Mk.III] | BRITISH | Armor | churchill_mk_iii_black.svg | shared | high | Mounted Churchill Mk.III weapons reuse the platform silhouette. |
| COAXIAL BESA 7.92mm [Churchill Mk.VII] | BRITISH | Armor | churchill_mk_vii_black.svg | fallback | medium | No standalone Churchill Mk.VII RCON platform entry is present, so the icon is anchored through its mounted weapon strings. |
| COAXIAL BESA 7.92mm [M4A3 (105mm)] | BRITISH | Armor | m4a3_105mm_black.svg | fallback | low | The British BESA references on the M4A3(105) are likely data anomalies, but the closest reviewable fallback remains the M4A3(105) platform icon. |
| COAXIAL DT [BA-10] | SOVIET | Armor | ba10_black.svg | shared | high | Vehicle-mounted BA-10 weapons reuse the platform silhouette. |
| COAXIAL DT [IS-1] | SOVIET | Armor | is_1_black.svg | shared | high | Mounted IS-1 weapons reuse the platform silhouette. |
| COAXIAL DT [T34/76] | SOVIET | Armor | t34_76_black.svg | shared | high | Mounted T34/76 weapons reuse the platform silhouette. |
| COAXIAL DT [T70] | SOVIET | Armor | t70_black.svg | shared | high | Mounted T70 weapons reuse the platform silhouette. |
| COAXIAL M1919 [Firefly] | BRITISH, CA | Armor | firefly_black.svg | shared | high | Mounted Firefly weapons reuse the platform silhouette. |
| COAXIAL M1919 [M3 Stuart Honey] | BRITISH | Armor | stuart_m5a1_black.svg | shared | medium | British M3 Stuart Honey falls back to the visually equivalent Stuart M5A1 silhouette. |
| COAXIAL M1919 [M4A3 (105mm)] | US | Armor | m4a3_105mm_black.svg | fallback | low | The British BESA references on the M4A3(105) are likely data anomalies, but the closest reviewable fallback remains the M4A3(105) platform icon. |
| COAXIAL M1919 [M8 Greyhound] | US | Armor | m8_greyhound_black.svg | fallback | low | No dedicated Daimler icon exists; the closest armored-car silhouette is M8 Greyhound. Exact M8 entries share the same icon. |
| COAXIAL M1919 [Sherman M4A3(75)W] | CA, US | Armor | sherman_m4a3_75w_black.svg | shared | high | Mounted Sherman M4A3(75)W weapons reuse the platform silhouette. |
| COAXIAL M1919 [Sherman M4A3E2(76)] | US | Armor | sherman_m4a3e2_76_black.svg | shared | high | Mounted Jumbo 76mm weapons reuse the platform silhouette. |
| COAXIAL M1919 [Sherman M4A3E2] | US | Armor | sherman_m4a3e2_black.svg | shared | high | Mounted Jumbo 75mm weapons reuse the platform silhouette. |
| COAXIAL M1919 [Stuart M5A1] | CA, US | Armor | stuart_m5a1_black.svg | shared | medium | British M3 Stuart Honey falls back to the visually equivalent Stuart M5A1 silhouette. |
| COAXIAL MG34 | AXIS | Armor | mg34_black.svg | shared | high | The unsuffixed coaxial MG34 string reuses the MG34 silhouette because no generic vehicle-MG icon exists. |
| COAXIAL MG34 [Panzer III Ausf.N] | AXIS | Armor | panzer_iii_ausf_n_black.svg | shared | high | Mounted Panzer III N weapons reuse the platform silhouette. |
| COAXIAL MG34 [Sd.Kfz.121 Luchs] | AXIS | Armor | sdkfz_121_luchs_black.svg | shared | high | Mounted Luchs weapons reuse the platform silhouette. |
| COAXIAL MG34 [Sd.Kfz.161 Panzer IV] | AXIS | Armor | sdkfz_161_panzer_iv_black.svg | shared | high | Mounted Panzer IV weapons reuse the platform silhouette. |
| COAXIAL MG34 [Sd.Kfz.171 Panther] | AXIS | Armor | sdkfz_171_panther_black.svg | shared | high | Mounted Panther weapons reuse the platform silhouette. |
| COAXIAL MG34 [Sd.Kfz.181 Tiger 1] | AXIS | Armor | sdkfz_181_tiger_1_black.svg | shared | high | Mounted Tiger I weapons reuse the platform silhouette. |
| COAXIAL MG34 [Sd.Kfz.234 Puma] | AXIS | Armor | sdkfz_234_puma_black.svg | shared | high | Mounted Puma weapons reuse the platform silhouette. |
| COLT M1911 | US | Infantry | colt_1911_black.svg | exact | high | - |
| Cromwell | BRITISH | Armor | cromwell_black.svg | fallback | low | The generic BESA 7.92mm entry has no platform suffix, so it falls back to the Cromwell icon as a representative British armored BESA platform. |
| D-5T 85MM [IS-1] | SOVIET | Armor | is_1_black.svg | shared | high | Mounted IS-1 weapons reuse the platform silhouette. |
| Daimler | BRITISH, CA | Armor | m8_greyhound_black.svg | fallback | low | No dedicated Daimler icon exists; the closest armored-car silhouette is M8 Greyhound. Exact M8 entries share the same icon. |
| DP-27 | SOVIET | MachineGun | lewis_gun_black.svg | fallback | medium | El set `black` actual no incluye `dp27_black.svg`; `lewis_gun_black.svg` es la silueta de LMG con cargador superior más cercana. |
| Enfield No.2 Mk I | CA | Infantry | enfield_no2_mk_i_black.svg | exact | high | - |
| FairbairnSykes | BRITISH, CA | Infantry | m3_knife_black.svg | fallback | low | No Fairbairn-Sykes dagger silhouette exists in `black`; M3 knife is the closest melee fallback. |
| FELDSPATEN | AXIS | Infantry | feldspaten_black.svg | exact | high | - |
| FG42 | AXIS | Infantry | fg42_black.svg | exact | high | - |
| FG42 x4 | AXIS | Sniper | fg42_x4_black.svg | exact | high | - |
| Firefly | BRITISH, CA | Armor | firefly_black.svg | shared | high | Mounted Firefly weapons reuse the platform silhouette. |
| FLAMETHROWER | BRITISH, CA | Infantry | m2_flamethrower_black.svg | fallback | low | British/Canadian flamethrower entries have no faction-specific icon; the US flamethrower silhouette is the closest shared fallback. |
| FLAMMENWERFER 41 | AXIS | Infantry | flammenwerfer41_black.svg | exact | high | - |
| FLARE GUN | AXIS, SOVIET, US | Infantry | flare_gun_black.svg | shared | high | British flare pistol is mapped to the same flare silhouette used by the generic FLARE GUN entry. |
| FN-Inglis No 2 MK I | CA | Infantry | fn_inglis_no2_mk_i_black.svg | exact | high | - |
| GAZ-67 | SOVIET | Armor | jeep_black.svg | fallback | medium | GAZ-67 and the Canadian Jeep use the generic jeep silhouette because there is no dedicated GAZ-67 asset. |
| GEWEHR 43 | AXIS | Infantry | gewehr_black.svg | shared | medium | The filename is generic (`gewehr`) but existing frontend aliases already treat it as Gewehr 43. |
| GMC CCKW 353 (Supply) | US | Armor | gmc_cckw_353_supply_black.svg | exact | high | - |
| GMC CCKW 363 (Supply) | US | Armor | gmc_cckw_363_supply_black.svg | exact | high | - |
| GMC CCKW 363 (Transport) | US | Armor | gmc_cckw_363_transport_black.svg | exact | high | - |
| Half-track | CA | Armor | half_track_black.svg | fallback | medium | The Canadian half-track is only available as a generic half-track silhouette, which also serves as fallback for its mounted Browning. |
| HULL BESA [Cromwell] | BRITISH | Armor | cromwell_black.svg | fallback | low | The generic BESA 7.92mm entry has no platform suffix, so it falls back to the Cromwell icon as a representative British armored BESA platform. |
| HULL BESA 7.92mm [Churchill Mk.III] | BRITISH | Armor | churchill_mk_iii_black.svg | shared | high | Mounted Churchill Mk.III weapons reuse the platform silhouette. |
| HULL BESA 7.92mm [Churchill Mk.VII] | BRITISH | Armor | churchill_mk_vii_black.svg | fallback | medium | No standalone Churchill Mk.VII RCON platform entry is present, so the icon is anchored through its mounted weapon strings. |
| HULL BESA 7.92mm [M4A3 (105mm)] | BRITISH | Armor | m4a3_105mm_black.svg | fallback | low | The British BESA references on the M4A3(105) are likely data anomalies, but the closest reviewable fallback remains the M4A3(105) platform icon. |
| HULL DT [IS-1] | SOVIET | Armor | is_1_black.svg | shared | high | Mounted IS-1 weapons reuse the platform silhouette. |
| HULL DT [KV-2] | SOVIET | Armor | kv2_black.svg | shared | high | Mounted KV-2 weapons reuse the platform silhouette. |
| HULL DT [T34/76] | SOVIET | Armor | t34_76_black.svg | shared | high | Mounted T34/76 weapons reuse the platform silhouette. |
| HULL M1919 [M4A3 (105mm)] | US | Armor | m4a3_105mm_black.svg | fallback | low | The British BESA references on the M4A3(105) are likely data anomalies, but the closest reviewable fallback remains the M4A3(105) platform icon. |
| HULL M1919 [Sherman M4A3(75)W] | CA, US | Armor | sherman_m4a3_75w_black.svg | shared | high | Mounted Sherman M4A3(75)W weapons reuse the platform silhouette. |
| HULL M1919 [Sherman M4A3E2(76)] | US | Armor | sherman_m4a3e2_76_black.svg | shared | high | Mounted Jumbo 76mm weapons reuse the platform silhouette. |
| HULL M1919 [Sherman M4A3E2] | US | Armor | sherman_m4a3e2_black.svg | shared | high | Mounted Jumbo 75mm weapons reuse the platform silhouette. |
| HULL M1919 [Stuart M5A1] | CA, US | Armor | stuart_m5a1_black.svg | shared | medium | British M3 Stuart Honey falls back to the visually equivalent Stuart M5A1 silhouette. |
| HULL MG34 [Sd.Kfz.161 Panzer IV] | AXIS | Armor | sdkfz_161_panzer_iv_black.svg | shared | high | Mounted Panzer IV weapons reuse the platform silhouette. |
| HULL MG34 [Sd.Kfz.171 Panther] | AXIS | Armor | sdkfz_171_panther_black.svg | shared | high | Mounted Panther weapons reuse the platform silhouette. |
| HULL MG34 [Sd.Kfz.181 Tiger 1] | AXIS | Armor | sdkfz_181_tiger_1_black.svg | shared | high | Mounted Tiger I weapons reuse the platform silhouette. |
| IS-1 | SOVIET | Armor | is_1_black.svg | shared | high | Mounted IS-1 weapons reuse the platform silhouette. |
| Jeep | CA | Armor | jeep_black.svg | fallback | medium | GAZ-67 and the Canadian Jeep use the generic jeep silhouette because there is no dedicated GAZ-67 asset. |
| Jeep Willys | BRITISH, US | Armor | jeep_willys_black.svg | exact | high | - |
| KARABINER 98K | AXIS | Infantry | kar98k_black.svg | exact | high | - |
| KARABINER 98K x8 | AXIS | Sniper | kar98k_x8_black.svg | exact | high | - |
| Kubelwagen | AXIS | Armor | kubelwagen_black.svg | exact | high | - |
| KV-2 | SOVIET | Armor | kv2_black.svg | shared | high | Mounted KV-2 weapons reuse the platform silhouette. |
| Lanchester | BRITISH, CA | Infantry | lanchester_black.svg | exact | high | - |
| Lee-Enfield Pattern 1914 | BRITISH | Infantry | lee_enfield_pattern_1914_black.svg | exact | high | - |
| Lee-Enfield Pattern 1914 Sniper | BRITISH | Sniper | lee_enfield_pattern_1914_sniper_black.svg | exact | high | - |
| LeeEnfield Jungle Carbine | BRITISH | Infantry | lee_enfield_jungle_carbine_black.svg | exact | high | Hash comparison shows this SVG is identical to `rifle_no5_mk_i_black.svg`; kept separate because the RCON name still exists. |
| LeeEnfield No.4 Mk I | BRITISH | Infantry | lee_enfield_n4_black.svg | exact | high | Legacy RCON name kept separate from `Rifle No.4 Mk I`. |
| Lewis Gun | BRITISH | MachineGun | lewis_gun_black.svg | fallback | medium | El set `black` actual no incluye `dp27_black.svg`; `lewis_gun_black.svg` es la silueta de LMG con cargador superior más cercana. |
| LUGER P08 | AXIS | Infantry | luger_p08_black.svg | exact | high | - |
| M1 CARBINE | US | Infantry | m1_carbine_black.svg | exact | high | - |
| M1 GARAND | US | Infantry | m1_garand_black.svg | exact | high | - |
| M1903 SPRINGFIELD | US | Sniper | m1903_springfield_sniper_black.svg | exact | high | - |
| M1918A2 BAR | US | Infantry | m1918a2_bar_black.svg | exact | high | - |
| M1919 SPRINGFIELD | US | Sniper | m1903_springfield_black.svg | fallback | medium | Current frontend aliases already use the non-sniper Springfield silhouette for a scoped Springfield label; kept as a legacy/shared mapping candidate. |
| M1928A1 THOMPSON | BRITISH | Infantry | thompson_black.svg | shared | high | Both Thompson variants reuse the same silhouette. |
| M1A1 AT MINE | US | Mine | m1a1_at_mine_black.svg | exact | high | - |
| M1A1 THOMPSON | US | Infantry | thompson_black.svg | shared | high | Both Thompson variants reuse the same silhouette. |
| M2 AP MINE | US | Mine | m2_ap_mine_black.svg | exact | high | - |
| M2 Browning [Half-track] | CA | Armor | half_track_black.svg | fallback | medium | The Canadian half-track is only available as a generic half-track silhouette, which also serves as fallback for its mounted Browning. |
| M2 Browning [M3 Half-track] | SOVIET, US | Armor | m3_half_track_black.svg | shared | medium | No dedicated M2 Browning silhouette exists; the mounted weapon falls back to the M3 Half-track platform icon. |
| M2 FLAMETHROWER | US | Infantry | m2_flamethrower_black.svg | fallback | low | British/Canadian flamethrower entries have no faction-specific icon; the US flamethrower silhouette is the closest shared fallback. |
| M24 STIELHANDGRANATE | AXIS | Grenade | m24_stielhandgranate_black.svg | exact | high | - |
| M3 GREASE GUN | US | Infantry | m3_grease_gun_black.svg | exact | high | - |
| M3 Half-track | BRITISH, SOVIET, US | Armor | m3_half_track_black.svg | shared | medium | No dedicated M2 Browning silhouette exists; the mounted weapon falls back to the M3 Half-track platform icon. |
| M3 KNIFE | US | Infantry | m3_knife_black.svg | fallback | low | No Fairbairn-Sykes dagger silhouette exists in `black`; M3 knife is the closest melee fallback. |
| M3 Stuart Honey | BRITISH | Armor | stuart_m5a1_black.svg | shared | medium | British M3 Stuart Honey falls back to the visually equivalent Stuart M5A1 silhouette. |
| M43 STIELHANDGRANATE | AXIS | Grenade | m43_stielhandgranate_black.svg | exact | high | - |
| M4A3 (105mm) | US | Armor | m4a3_105mm_black.svg | fallback | low | The British BESA references on the M4A3(105) are likely data anomalies, but the closest reviewable fallback remains the M4A3(105) platform icon. |
| M6 37mm [M8 Greyhound] | US | Armor | m8_greyhound_black.svg | fallback | low | No dedicated Daimler icon exists; the closest armored-car silhouette is M8 Greyhound. Exact M8 entries share the same icon. |
| M8 Greyhound | US | Armor | m8_greyhound_black.svg | fallback | low | No dedicated Daimler icon exists; the closest armored-car silhouette is M8 Greyhound. Exact M8 entries share the same icon. |
| M97 TRENCH GUN | US | Infantry | m97_black.svg | shared | high | The icon filename is shortened to `m97`, but it clearly corresponds to the trench gun. |
| MG 42 [Sd.Kfz 251 Half-track] | AXIS | Armor | sdkfz_251_half_track_black.svg | shared | high | Mounted Sd.Kfz 251 weapon reuses the platform silhouette. |
| MG34 | AXIS | MachineGun | mg34_black.svg | shared | high | The unsuffixed coaxial MG34 string reuses the MG34 silhouette because no generic vehicle-MG icon exists. |
| MG42 | AXIS | MachineGun | mg42_black.svg | exact | high | - |
| Mills Bomb | BRITISH, CA | Grenade | mills_bomb_black.svg | exact | high | - |
| MK2 GRENADE | US | Grenade | mk2_grenade_black.svg | exact | high | - |
| MOLOTOV | SOVIET | Grenade | rg42_grenade_black.svg | fallback | low | No dedicated Molotov bottle icon exists in `black`; RG-42 is the closest Soviet throwable fallback. |
| MOSIN NAGANT 1891 | SOVIET | Infantry | mosin_nagant_1891_black.svg | exact | high | - |
| MOSIN NAGANT 91/30 | SOVIET | Infantry | mosin_nagant_9130_black.svg | exact | high | - |
| MOSIN NAGANT M38 | SOVIET | Infantry | mosin_nagant_m38_black.svg | exact | high | - |
| MP40 | AXIS | Infantry | mp40_black.svg | exact | high | - |
| MPL-50 SPADE | SOVIET | Infantry | mpl50_spade_black.svg | exact | high | - |
| NAGANT M1895 | SOVIET | Infantry | nagant_m1895_black.svg | exact | high | - |
| No.2 Mk 5 Flare Pistol | BRITISH, CA | Infantry | flare_gun_black.svg | shared | high | British flare pistol is mapped to the same flare silhouette used by the generic FLARE GUN entry. |
| No.77 | CA | Grenade | no82_grenade_black.svg | fallback | low | No dedicated No.77 grenade icon exists; No.82 is the nearest Commonwealth grenade fallback. |
| No.82 Grenade | BRITISH, CA | Grenade | no82_grenade_black.svg | fallback | low | No dedicated No.77 grenade icon exists; No.82 is the nearest Commonwealth grenade fallback. |
| Opel Blitz (Supply) | AXIS | Armor | opel_blitz_supply_black.svg | exact | high | - |
| Opel Blitz (Transport) | AXIS | Armor | opel_blitz_transport_black.svg | exact | high | - |
| OQF 57MM [Churchill Mk.III] | BRITISH | Armor | churchill_mk_iii_black.svg | shared | high | Mounted Churchill Mk.III weapons reuse the platform silhouette. |
| OQF 57MM [Crusader Mk.III] | BRITISH | Armor | crusader_mk_iii_black.svg | fallback | medium | There is no standalone Crusader RCON platform entry in the pasted file, so the icon is anchored through its mounted weapon strings. |
| OQF 57MM [Sturmpanzer IV] | AXIS | SPA | sturmpanzer_iv_black.svg | fallback | low | The `OQF 57MM [Sturmpanzer IV]` label looks inconsistent but still maps best to the Sturmpanzer IV platform silhouette. |
| OQF 6 - POUNDER Mk.V [Churchill Mk.III] | BRITISH | Armor | churchill_mk_iii_black.svg | shared | high | Mounted Churchill Mk.III weapons reuse the platform silhouette. |
| OQF 75MM [Churchill Mk.VII] | BRITISH | Armor | churchill_mk_vii_black.svg | fallback | medium | No standalone Churchill Mk.VII RCON platform entry is present, so the icon is anchored through its mounted weapon strings. |
| OQF 75MM [Cromwell] | BRITISH | Armor | cromwell_black.svg | fallback | low | The generic BESA 7.92mm entry has no platform suffix, so it falls back to the Cromwell icon as a representative British armored BESA platform. |
| Ordnance QF 6-pounder | CA | PAK | qf_6_pounder_black.svg | shared | high | Canadian and British labels refer to the same 6-pounder gun silhouette. |
| Panzer III Ausf.N | AXIS | Armor | panzer_iii_ausf_n_black.svg | shared | high | Mounted Panzer III N weapons reuse the platform silhouette. |
| PANZERSCHRECK | AXIS | Bazooka | panzerschreck_black.svg | exact | high | - |
| PETARD 230MM [M4A3 (105mm)] | US | SPA | m4a3_105mm_black.svg | fallback | low | The British BESA references on the M4A3(105) are likely data anomalies, but the closest reviewable fallback remains the M4A3(105) platform icon. |
| PIAT | BRITISH, CA | Infantry | piat_black.svg | exact | high | - |
| POMZ AP MINE | SOVIET | Mine | pomz_ap_mine_black.svg | exact | high | - |
| PPSH 41 | SOVIET | Infantry | ppsh41_black.svg | exact | high | - |
| PPSH 41 W/DRUM | SOVIET | Infantry | ppsh_41w_drum_black.svg | exact | high | - |
| PRECISION STRIKE | NO_SIDE | Commander | precision_strike_black.svg | fallback | low | No dedicated unknown/fallback silhouette exists in `black`; `UNKNOWN` is parked on the most generic commander-strike icon only to keep the review matrix complete. |
| PTRS-41 | SOVIET | Infantry | ptrs41_black.svg | exact | high | - |
| QF 17-POUNDER [Firefly] | BRITISH, CA | Armor | firefly_black.svg | shared | high | Mounted Firefly weapons reuse the platform silhouette. |
| QF 2-POUNDER [Daimler] | BRITISH, CA | Armor | m8_greyhound_black.svg | fallback | low | No dedicated Daimler icon exists; the closest armored-car silhouette is M8 Greyhound. Exact M8 entries share the same icon. |
| QF 2-POUNDER [Tetrarch] | BRITISH | Armor | tetrarch_black.svg | shared | high | Mounted Tetrarch weapons reuse the platform silhouette. |
| QF 25 POUNDER [Bishop SP 25pdr] | BRITISH | SPA | bishop_sp_25pdr_black.svg | fallback | medium | The only 25-pounder-related asset is the Bishop SP platform, so the towed QF 25-pounder falls back to the same ordnance family silhouette. |
| QF 25-POUNDER [QF 25-Pounder] | BRITISH, CA | Artillery | bishop_sp_25pdr_black.svg | fallback | medium | The only 25-pounder-related asset is the Bishop SP platform, so the towed QF 25-pounder falls back to the same ordnance family silhouette. |
| QF 6-POUNDER [QF 6-Pounder] | BRITISH, CA | PAK | qf_6_pounder_black.svg | shared | high | Canadian and British labels refer to the same 6-pounder gun silhouette. |
| QF 75MM [Cromwell] | BRITISH | Armor | cromwell_black.svg | fallback | low | The generic BESA 7.92mm entry has no platform suffix, so it falls back to the Cromwell icon as a representative British armored BESA platform. |
| RG-42 GRENADE | SOVIET | Grenade | rg42_grenade_black.svg | fallback | low | No dedicated Molotov bottle icon exists in `black`; RG-42 is the closest Soviet throwable fallback. |
| Rifle No.4 Mk I | BRITISH, CA | Infantry | rifle_no4_mk_i_black.svg | exact | high | - |
| Rifle No.4 Mk I Sniper | BRITISH, CA | Sniper | rifle_no4_mk_i_sniper_black.svg | exact | high | - |
| Rifle No.5 Mk I | BRITISH | Infantry | rifle_no5_mk_i_black.svg | exact | high | Hash comparison shows this SVG is identical to `lee_enfield_jungle_carbine_black.svg`. |
| S-MINE | AXIS | Mine | s_mine_black.svg | fallback | medium | The British AP shrapnel mine falls back to the closest anti-personnel mine silhouette present in the folder. |
| Satchel | BRITISH, CA | Satchel | satchel_charge_black.svg | shared | high | Three RCON labels collapse into the same satchel silhouette. |
| SATCHEL | AXIS, US | Satchel | satchel_charge_black.svg | shared | high | Three RCON labels collapse into the same satchel silhouette. |
| SATCHEL CHARGE | SOVIET | Satchel | satchel_charge_black.svg | shared | high | Three RCON labels collapse into the same satchel silhouette. |
| SCOPED MOSIN NAGANT 91/30 | SOVIET | Sniper | scoped_mosin_nagant_9130_black.svg | exact | high | - |
| SCOPED SVT40 | SOVIET | Sniper | scoped_svt40_black.svg | exact | high | - |
| Sd.Kfz 251 Half-track | AXIS | Armor | sdkfz_251_half_track_black.svg | shared | high | Mounted Sd.Kfz 251 weapon reuses the platform silhouette. |
| Sd.Kfz.121 Luchs | AXIS | Armor | sdkfz_121_luchs_black.svg | shared | high | Mounted Luchs weapons reuse the platform silhouette. |
| Sd.Kfz.161 Panzer IV | AXIS | Armor | sdkfz_161_panzer_iv_black.svg | shared | high | Mounted Panzer IV weapons reuse the platform silhouette. |
| Sd.Kfz.171 Panther | AXIS | Armor | sdkfz_171_panther_black.svg | shared | high | Mounted Panther weapons reuse the platform silhouette. |
| Sd.Kfz.181 Tiger 1 | AXIS | Armor | sdkfz_181_tiger_1_black.svg | shared | high | Mounted Tiger I weapons reuse the platform silhouette. |
| Sd.Kfz.234 Puma | AXIS | Armor | sdkfz_234_puma_black.svg | shared | high | Mounted Puma weapons reuse the platform silhouette. |
| Sherman M4A3(75)W | CA, US | Armor | sherman_m4a3_75w_black.svg | shared | high | Mounted Sherman M4A3(75)W weapons reuse the platform silhouette. |
| Sherman M4A3E2 | US | Armor | sherman_m4a3e2_black.svg | shared | high | Mounted Jumbo 75mm weapons reuse the platform silhouette. |
| Sherman M4A3E2(76) | US | Armor | sherman_m4a3e2_76_black.svg | shared | high | Mounted Jumbo 76mm weapons reuse the platform silhouette. |
| SMLE No.1 Mk III | BRITISH, CA | Infantry | smle_no1_mk_iii_black.svg | exact | high | - |
| Sten Gun | BRITISH | Infantry | sten_gun_black.svg | shared | medium | The folder has a generic Sten silhouette and a separate Mk.II file; Mk.V is mapped to the generic Sten. |
| Sten Gun Mk.II | BRITISH | Infantry | sten_gun_mk_ii_black.svg | exact | high | - |
| Sten Gun Mk.V | BRITISH | Infantry | sten_gun_black.svg | shared | medium | The folder has a generic Sten silhouette and a separate Mk.II file; Mk.V is mapped to the generic Sten. |
| STG44 | AXIS | Infantry | stg44_black.svg | exact | high | - |
| STRAFING RUN | NO_SIDE | Commander | strafing_run_black.svg | exact | high | - |
| Stuart M5A1 | CA, US | Armor | stuart_m5a1_black.svg | shared | medium | British M3 Stuart Honey falls back to the visually equivalent Stuart M5A1 silhouette. |
| StuH 43 L/12 [Sturmpanzer IV] | AXIS | SPA | sturmpanzer_iv_black.svg | fallback | low | The `OQF 57MM [Sturmpanzer IV]` label looks inconsistent but still maps best to the Sturmpanzer IV platform silhouette. |
| Sturmpanzer IV | AXIS | Armor | sturmpanzer_iv_black.svg | fallback | low | The `OQF 57MM [Sturmpanzer IV]` label looks inconsistent but still maps best to the Sturmpanzer IV platform silhouette. |
| SVT40 | SOVIET | Infantry | svt40_black.svg | exact | high | - |
| T34/76 | SOVIET | Armor | t34_76_black.svg | shared | high | Mounted T34/76 weapons reuse the platform silhouette. |
| T70 | SOVIET | Armor | t70_black.svg | shared | high | Mounted T70 weapons reuse the platform silhouette. |
| TELLERMINE 43 | AXIS | Mine | tellermine_43_black.svg | exact | high | - |
| Tetrarch | BRITISH | Armor | tetrarch_black.svg | shared | high | Mounted Tetrarch weapons reuse the platform silhouette. |
| TM-35 AT MINE | SOVIET | Mine | tm35_at_mine_black.svg | exact | high | - |
| TOKAREV TT33 | SOVIET | Infantry | tokarev_tt33_black.svg | exact | high | - |
| UNKNOWN | NO_SIDE | Unknown | precision_strike_black.svg | fallback | low | No dedicated unknown/fallback silhouette exists in `black`; `UNKNOWN` is parked on the most generic commander-strike icon only to keep the review matrix complete. |
| WALTHER P38 | AXIS | Infantry | walther_p38_black.svg | exact | high | - |
| Webley MK VI | BRITISH | Infantry | webley_revolver_black.svg | shared | high | The icon filename uses the common weapon family name instead of the exact RCON label. |
| ZIS-5 (Supply) | SOVIET | Armor | zis5_supply_black.svg | exact | high | - |
| ZIS-5 (Transport) | SOVIET | Armor | zis5_transport_black.svg | exact | high | - |
## Tabla inversa
| Icono black | Armas RCON asignadas | Notas |
| --- | --- | --- |
| 60l_supply_black.svg | 60L (Supply) | Asignación única. |
| 60l_transport_black.svg | 60L (Transport) | Asignación única. |
| at_mine_gs_mk_v_black.svg | A.T. Mine G.S. Mk V | Asignación única. |
| ba10_black.svg | 19-K 45MM [BA-10], BA-10, COAXIAL DT [BA-10] | Icono compartido por varias armas o variantes. |
| bazooka_black.svg | BAZOOKA | Asignación única. |
| bedford_oyd_supply_black.svg | Bedford OYD (Supply) | Asignación única. |
| bedford_oyd_transport_black.svg | Bedford OYD (Transport) | Asignación única. |
| bishop_sp_25pdr_black.svg | Bishop SP 25pdr, QF 25 POUNDER [Bishop SP 25pdr], QF 25-POUNDER [QF 25-Pounder] | Icono compartido por varias armas o variantes. |
| bombing_run_black.svg | BOMBING RUN | Asignación única. |
| boys_anti_tank_rifle_black.svg | Boys Anti-tank Rifle | Asignación única. |
| bren_gun_black.svg | Bren Gun | Asignación única. |
| browning_m1919_black.svg | BROWNING M1919 | Asignación única. |
| canadian_sten_mk_ii_black.svg | Canadian Sten Mk.II | Asignación única. |
| churchill_mk_iii_avre_black.svg | 230MM PETARD [Churchill Mk III A.V.R.E.], Churchill Mk III A.V.R.E., COAXIAL BESA 7.92mm [Churchill Mk III A.V.R.E.] | Icono compartido por varias armas o variantes. |
| churchill_mk_iii_black.svg | Churchill Mk.III, COAXIAL BESA 7.92mm [Churchill Mk.III], HULL BESA 7.92mm [Churchill Mk.III], OQF 57MM [Churchill Mk.III], OQF 6 - POUNDER Mk.V [Churchill Mk.III] | Icono compartido por varias armas o variantes. |
| churchill_mk_vii_black.svg | COAXIAL BESA 7.92mm [Churchill Mk.VII], HULL BESA 7.92mm [Churchill Mk.VII], OQF 75MM [Churchill Mk.VII] | Icono compartido por varias armas o variantes. |
| colt_1911_black.svg | COLT M1911 | Asignación única. |
| cromwell_black.svg | COAXIAL BESA [Cromwell], COAXIAL BESA 7.92mm, Cromwell, HULL BESA [Cromwell], OQF 75MM [Cromwell], QF 75MM [Cromwell] | Icono compartido por varias armas o variantes. |
| crusader_mk_iii_black.svg | COAXIAL BESA [Crusader Mk.III], OQF 57MM [Crusader Mk.III] | Icono compartido por varias armas o variantes. |
| enfield_no2_mk_i_black.svg | Enfield No.2 Mk I | Asignación única. |
| feldspaten_black.svg | FELDSPATEN | Asignación única. |
| fg42_black.svg | FG42 | Asignación única. |
| fg42_x4_black.svg | FG42 x4 | Asignación única. |
| firefly_black.svg | COAXIAL M1919 [Firefly], Firefly, QF 17-POUNDER [Firefly] | Icono compartido por varias armas o variantes. |
| flammenwerfer41_black.svg | FLAMMENWERFER 41 | Asignación única. |
| flare_gun_black.svg | FLARE GUN, No.2 Mk 5 Flare Pistol | Icono compartido por varias armas o variantes. |
| fn_inglis_no2_mk_i_black.svg | FN-Inglis No 2 MK I | Asignación única. |
| gewehr_black.svg | GEWEHR 43 | Asignación única. |
| gmc_cckw_353_supply_black.svg | GMC CCKW 353 (Supply) | Asignación única. |
| gmc_cckw_363_supply_black.svg | GMC CCKW 363 (Supply) | Asignación única. |
| gmc_cckw_363_transport_black.svg | GMC CCKW 363 (Transport) | Asignación única. |
| half_track_black.svg | Half-track, M2 Browning [Half-track] | Icono compartido por varias armas o variantes. |
| is_1_black.svg | COAXIAL DT [IS-1], D-5T 85MM [IS-1], HULL DT [IS-1], IS-1 | Icono compartido por varias armas o variantes. |
| jeep_black.svg | GAZ-67, Jeep | Icono compartido por varias armas o variantes. |
| jeep_willys_black.svg | Jeep Willys | Asignación única. |
| kar98k_black.svg | KARABINER 98K | Asignación única. |
| kar98k_x8_black.svg | KARABINER 98K x8 | Asignación única. |
| kubelwagen_black.svg | Kubelwagen | Asignación única. |
| kv2_black.svg | 152MM M-10T [KV-2], HULL DT [KV-2], KV-2 | Icono compartido por varias armas o variantes. |
| lanchester_black.svg | Lanchester | Asignación única. |
| lee_enfield_jungle_carbine_black.svg | LeeEnfield Jungle Carbine | Asignación única. |
| lee_enfield_n4_black.svg | LeeEnfield No.4 Mk I | Asignación única. |
| lee_enfield_pattern_1914_black.svg | Lee-Enfield Pattern 1914 | Asignación única. |
| lee_enfield_pattern_1914_sniper_black.svg | Lee-Enfield Pattern 1914 Sniper | Asignación única. |
| lewis_gun_black.svg | DP-27, Lewis Gun | Icono compartido por varias armas o variantes. |
| luger_p08_black.svg | LUGER P08 | Asignación única. |
| m1903_springfield_black.svg | M1919 SPRINGFIELD | Asignación única. |
| m1903_springfield_sniper_black.svg | M1903 SPRINGFIELD | Asignación única. |
| m1918a2_bar_black.svg | M1918A2 BAR | Asignación única. |
| m1_57mm_cannon_black.svg | 155MM HOWITZER [M114], 57MM CANNON [M1 57mm] | Icono compartido por varias armas o variantes. |
| m1_carbine_black.svg | M1 CARBINE | Asignación única. |
| m1_garand_black.svg | M1 GARAND | Asignación única. |
| m1a1_at_mine_black.svg | M1A1 AT MINE | Asignación única. |
| m24_stielhandgranate_black.svg | M24 STIELHANDGRANATE | Asignación única. |
| m2_ap_mine_black.svg | M2 AP MINE | Asignación única. |
| m2_flamethrower_black.svg | FLAMETHROWER, M2 FLAMETHROWER | Icono compartido por varias armas o variantes. |
| m3_grease_gun_black.svg | M3 GREASE GUN | Asignación única. |
| m3_half_track_black.svg | M2 Browning [M3 Half-track], M3 Half-track | Icono compartido por varias armas o variantes. |
| m3_knife_black.svg | FairbairnSykes, M3 KNIFE | Icono compartido por varias armas o variantes. |
| m43_stielhandgranate_black.svg | M43 STIELHANDGRANATE | Asignación única. |
| m4a3_105mm_black.svg | 105MM HOWITZER [M4A3 (105mm)], COAXIAL BESA 7.92mm [M4A3 (105mm)], COAXIAL M1919 [M4A3 (105mm)], HULL BESA 7.92mm [M4A3 (105mm)], HULL M1919 [M4A3 (105mm)], M4A3 (105mm), PETARD 230MM [M4A3 (105mm)] | Icono compartido por varias armas o variantes. |
| m8_greyhound_black.svg | COAXIAL BESA [Daimler], COAXIAL M1919 [M8 Greyhound], Daimler, M6 37mm [M8 Greyhound], M8 Greyhound, QF 2-POUNDER [Daimler] | Icono compartido por varias armas o variantes. |
| m97_black.svg | M97 TRENCH GUN | Asignación única. |
| mg34_black.svg | COAXIAL MG34, MG34 | Icono compartido por varias armas o variantes. |
| mg42_black.svg | MG42 | Asignación única. |
| mills_bomb_black.svg | Mills Bomb | Asignación única. |
| mk2_grenade_black.svg | MK2 GRENADE | Asignación única. |
| mosin_nagant_1891_black.svg | MOSIN NAGANT 1891 | Asignación única. |
| mosin_nagant_9130_black.svg | MOSIN NAGANT 91/30 | Asignación única. |
| mosin_nagant_m38_black.svg | MOSIN NAGANT M38 | Asignación única. |
| mp40_black.svg | MP40 | Asignación única. |
| mpl50_spade_black.svg | MPL-50 SPADE | Asignación única. |
| nagant_m1895_black.svg | NAGANT M1895 | Asignación única. |
| no82_grenade_black.svg | No.77, No.82 Grenade | Icono compartido por varias armas o variantes. |
| opel_blitz_supply_black.svg | Opel Blitz (Supply) | Asignación única. |
| opel_blitz_transport_black.svg | Opel Blitz (Transport) | Asignación única. |
| pak_40_75mm_black.svg | 150MM HOWITZER [sFH 18], 75MM CANNON [PAK 40] | Icono compartido por varias armas o variantes. |
| panzer_iii_ausf_n_black.svg | 7.5CM KwK 37 [Panzer III Ausf.N], COAXIAL MG34 [Panzer III Ausf.N], Panzer III Ausf.N | Icono compartido por varias armas o variantes. |
| panzerschreck_black.svg | PANZERSCHRECK | Asignación única. |
| piat_black.svg | PIAT | Asignación única. |
| pomz_ap_mine_black.svg | POMZ AP MINE | Asignación única. |
| ppsh41_black.svg | PPSH 41 | Asignación única. |
| ppsh_41w_drum_black.svg | PPSH 41 W/DRUM | Asignación única. |
| precision_strike_black.svg | PRECISION STRIKE, UNKNOWN | Icono compartido por varias armas o variantes. |
| ptrs41_black.svg | PTRS-41 | Asignación única. |
| qf_6_pounder_black.svg | Ordnance QF 6-pounder, QF 6-POUNDER [QF 6-Pounder] | Icono compartido por varias armas o variantes. |
| rg42_grenade_black.svg | MOLOTOV, RG-42 GRENADE | Icono compartido por varias armas o variantes. |
| rifle_no4_mk_i_black.svg | Rifle No.4 Mk I | Asignación única. |
| rifle_no4_mk_i_sniper_black.svg | Rifle No.4 Mk I Sniper | Asignación única. |
| rifle_no5_mk_i_black.svg | Rifle No.5 Mk I | Asignación única. |
| s_mine_black.svg | A.P. Shrapnel Mine Mk II, S-MINE | Icono compartido por varias armas o variantes. |
| satchel_charge_black.svg | Satchel, SATCHEL, SATCHEL CHARGE | Icono compartido por varias armas o variantes. |
| scoped_mosin_nagant_9130_black.svg | SCOPED MOSIN NAGANT 91/30 | Asignación única. |
| scoped_svt40_black.svg | SCOPED SVT40 | Asignación única. |
| sdkfz_121_luchs_black.svg | 20MM KWK 30 [Sd.Kfz.121 Luchs], COAXIAL MG34 [Sd.Kfz.121 Luchs], Sd.Kfz.121 Luchs | Icono compartido por varias armas o variantes. |
| sdkfz_161_panzer_iv_black.svg | 7.5CM KwK 37 [Sd.Kfz.161 Panzer IV], 75MM CANNON [Sd.Kfz.161 Panzer IV], COAXIAL MG34 [Sd.Kfz.161 Panzer IV], HULL MG34 [Sd.Kfz.161 Panzer IV], Sd.Kfz.161 Panzer IV | Icono compartido por varias armas o variantes. |
| sdkfz_171_panther_black.svg | 75MM CANNON [Sd.Kfz.171 Panther], COAXIAL MG34 [Sd.Kfz.171 Panther], HULL MG34 [Sd.Kfz.171 Panther], Sd.Kfz.171 Panther | Icono compartido por varias armas o variantes. |
| sdkfz_181_tiger_1_black.svg | 88 KWK 36 L/56 [Sd.Kfz.181 Tiger 1], COAXIAL MG34 [Sd.Kfz.181 Tiger 1], HULL MG34 [Sd.Kfz.181 Tiger 1], Sd.Kfz.181 Tiger 1 | Icono compartido por varias armas o variantes. |
| sdkfz_234_puma_black.svg | 50mm KwK 39/1 [Sd.Kfz.234 Puma], COAXIAL MG34 [Sd.Kfz.234 Puma], Sd.Kfz.234 Puma | Icono compartido por varias armas o variantes. |
| sdkfz_251_half_track_black.svg | MG 42 [Sd.Kfz 251 Half-track], Sd.Kfz 251 Half-track | Icono compartido por varias armas o variantes. |
| sherman_m4a3_75w_black.svg | 75MM CANNON [Sherman M4A3(75)W], COAXIAL M1919 [Sherman M4A3(75)W], HULL M1919 [Sherman M4A3(75)W], Sherman M4A3(75)W | Icono compartido por varias armas o variantes. |
| sherman_m4a3e2_76_black.svg | 76MM M1 GUN [Sherman M4A3E2(76)], COAXIAL M1919 [Sherman M4A3E2(76)], HULL M1919 [Sherman M4A3E2(76)], Sherman M4A3E2(76) | Icono compartido por varias armas o variantes. |
| sherman_m4a3e2_black.svg | 75MM M3 GUN [Sherman M4A3E2], COAXIAL M1919 [Sherman M4A3E2], HULL M1919 [Sherman M4A3E2], Sherman M4A3E2 | Icono compartido por varias armas o variantes. |
| smle_no1_mk_iii_black.svg | SMLE No.1 Mk III | Asignación única. |
| sten_gun_black.svg | Sten Gun, Sten Gun Mk.V | Icono compartido por varias armas o variantes. |
| sten_gun_mk_ii_black.svg | Sten Gun Mk.II | Asignación única. |
| stg44_black.svg | STG44 | Asignación única. |
| strafing_run_black.svg | STRAFING RUN | Asignación única. |
| stuart_m5a1_black.svg | 37MM CANNON [M3 Stuart Honey], 37MM CANNON [Stuart M5A1], COAXIAL M1919 [M3 Stuart Honey], COAXIAL M1919 [Stuart M5A1], HULL M1919 [Stuart M5A1], M3 Stuart Honey, Stuart M5A1 | Icono compartido por varias armas o variantes. |
| sturmpanzer_iv_black.svg | OQF 57MM [Sturmpanzer IV], StuH 43 L/12 [Sturmpanzer IV], Sturmpanzer IV | Icono compartido por varias armas o variantes. |
| svt40_black.svg | SVT40 | Asignación única. |
| t34_76_black.svg | 76MM ZiS-5 [T34/76], COAXIAL DT [T34/76], HULL DT [T34/76], T34/76 | Icono compartido por varias armas o variantes. |
| t70_black.svg | 45MM M1937 [T70], COAXIAL DT [T70], T70 | Icono compartido por varias armas o variantes. |
| tellermine_43_black.svg | TELLERMINE 43 | Asignación única. |
| tetrarch_black.svg | COAXIAL BESA [Tetrarch], QF 2-POUNDER [Tetrarch], Tetrarch | Icono compartido por varias armas o variantes. |
| thompson_black.svg | M1928A1 THOMPSON, M1A1 THOMPSON | Icono compartido por varias armas o variantes. |
| tm35_at_mine_black.svg | TM-35 AT MINE | Asignación única. |
| tokarev_tt33_black.svg | TOKAREV TT33 | Asignación única. |
| walther_p38_black.svg | WALTHER P38 | Asignación única. |
| webley_revolver_black.svg | Webley MK VI | Asignación única. |
| zis2_57mm_cannon_black.svg | 122MM HOWITZER [M1938 (M-30)], 57MM CANNON [ZiS-2] | Icono compartido por varias armas o variantes. |
| zis5_supply_black.svg | ZIS-5 (Supply) | Asignación única. |
| zis5_transport_black.svg | ZIS-5 (Transport) | Asignación única. |
## Iconos con nombre sospechoso o silueta dudosa
- `partida-actual.js` sigue referenciando nombres legacy/erróneos en blanco como `browing_m1919`, `flammenwefer41`, `m1_carabine`, `mosing_nagant_*`, `panzerchreck` y `sten_mk_v`.
- `gewehr_black.svg` es demasiado genérico para un mapeo exacto a `GEWEHR 43`.
- `m1903_springfield_black.svg` y `m1903_springfield_sniper_black.svg` sugieren dos usos para una familia que en RCON llega como sniper.
- `lee_enfield_jungle_carbine_black.svg` y `rifle_no5_mk_i_black.svg` son SVG idénticos por hash.
- `precision_strike_black.svg` se reutiliza como placeholder de `UNKNOWN` solo para cerrar la matriz documental; no es una decisión de implementación final.
## Recomendación de siguiente paso
- El mapping puede implementarse directamente en JS mediante una tabla de aliases y resolución a `black/`, sin renombrar archivos.
- No conviene renombrar SVGs ahora: hay nombres legacy ya consumidos por el frontend (`browing`, `mosing`, `panzerchreck`, etc.) y es más seguro encapsular la normalización en código.
- Faltan siluetas específicas para varios casos (`Daimler`, `GAZ-67`, `Molotov`, `No.77`, howitzers remolcados y un icono genérico `UNKNOWN`), pero el documento ya propone fallbacks revisables.
- Antes de aplicar el mapping operativo, conviene revisar manualmente los casos `confidence=low`, especialmente `UNKNOWN`, `MOLOTOV`, `FLAMETHROWER`, `Daimler`, `QF 25-POUNDER [QF 25-Pounder]`, `150MM HOWITZER [sFH 18]` y las referencias BESA sobre `M4A3 (105mm)`.
## Validación documental
- Todas las armas únicas del universo RCON consolidado (220) tienen icono asignado.
- Todos los SVG de `frontend/assets/img/weapons/black/` (123) aparecen en la tabla inversa.
- No se modificó ningún SVG.
- No se tocó backend.
- No se hizo push.
## Implementación aplicada
- Runtime aplicado en `frontend/assets/js/current-match-weapon-icons.js`.
- Consumo del runtime activado desde `frontend/partida-actual.html` y `frontend/assets/js/partida-actual.js`.
- Estrategia usada:
- 220 entradas RCON exactas `arma -> svg black`
- aliases explícitos para nombres legacy o coloquiales ya soportados por el frontend
- fallback `UNKNOWN -> precision_strike_black.svg`
- fallback documentado para `MOLOTOV`, `No.77`, `Daimler`, `GAZ-67`, howitzers remolcados y `FairbairnSykes`
- Cobertura implementada: 220 armas únicas RCON más aliases legacy.
- Casos que siguen requiriendo icono nuevo futuro:
- `UNKNOWN`
- `MOLOTOV`
- `No.77`
- `Daimler`
- `GAZ-67`
- `122MM HOWITZER [M1938 (M-30)]`
- `155MM HOWITZER [M114]`
- `150MM HOWITZER [sFH 18]`
- opcionalmente `FairbairnSykes`
- opcionalmente `DP-27`
+15 -15
View File
@@ -1,10 +1,10 @@
@media (min-width: 1121px) {
.hero--public-page:not(.historical-hero) .hero__content {
.hero:not(.historical-hero) .hero__content {
padding-block: 54px 60px;
padding-inline: clamp(64px, 5.5vw, 104px);
}
.hero--public-page:not(.historical-hero) .hero__brand {
.hero:not(.historical-hero) .hero__brand {
width: 100%;
max-width: 1420px;
margin: 0 auto;
@@ -14,7 +14,7 @@
align-items: center;
}
.hero--public-page:not(.historical-hero) .logo-frame {
.hero:not(.historical-hero) .logo-frame {
grid-column: 1;
justify-self: start;
width: min(470px, 100%);
@@ -22,11 +22,11 @@
padding: 30px 34px;
}
.hero--public-page:not(.historical-hero) .logo-frame__image {
.hero:not(.historical-hero) .logo-frame__image {
max-height: 340px;
}
.hero--public-page:not(.historical-hero) .hero__copy {
.hero:not(.historical-hero) .hero__copy {
grid-column: 3;
justify-self: end;
width: 100%;
@@ -37,41 +37,41 @@
transform: none;
}
.hero--public-page:not(.historical-hero) .hero__title {
.hero:not(.historical-hero) .hero__title {
max-width: none;
font-size: clamp(4.2rem, 5.2vw, 6.35rem);
line-height: 1.02;
}
.hero--public-page:not(.historical-hero) .hero__title-accent {
.hero:not(.historical-hero) .hero__title-accent {
display: inline;
}
.hero--public-page:not(.historical-hero) .hero__text {
.hero:not(.historical-hero) .hero__text {
max-width: 68ch;
}
}
@media (min-width: 1121px) and (max-width: 1380px) {
.hero--public-page:not(.historical-hero) .hero__content {
.hero:not(.historical-hero) .hero__content {
padding-inline: clamp(44px, 4vw, 64px);
}
.hero--public-page:not(.historical-hero) .hero__brand {
.hero:not(.historical-hero) .hero__brand {
grid-template-columns: minmax(320px, 390px) minmax(28px, 1fr) minmax(560px, 720px);
column-gap: clamp(20px, 2.5vw, 40px);
}
.hero--public-page:not(.historical-hero) .logo-frame {
.hero:not(.historical-hero) .logo-frame {
width: min(390px, 100%);
min-height: 316px;
}
.hero--public-page:not(.historical-hero) .logo-frame__image {
.hero:not(.historical-hero) .logo-frame__image {
max-height: 298px;
}
.hero--public-page:not(.historical-hero) .hero__copy {
.hero:not(.historical-hero) .hero__copy {
grid-column: 3;
justify-self: end;
width: 100%;
@@ -79,13 +79,13 @@
transform: none;
}
.hero--public-page:not(.historical-hero) .hero__title {
.hero:not(.historical-hero) .hero__title {
font-size: clamp(3.7rem, 5.35vw, 5.15rem);
}
}
@media (max-width: 1120px) {
.hero--public-page:not(.historical-hero) .hero__copy {
.hero:not(.historical-hero) .hero__copy {
grid-column: auto;
transform: none;
}
@@ -267,7 +267,6 @@
display: inline-flex;
min-height: 32px;
align-items: center;
gap: 8px;
padding: 0 11px;
border: 1px solid rgba(210, 182, 118, 0.34);
border-radius: 999px;
@@ -279,16 +278,6 @@
text-transform: uppercase;
}
.historical-player-profile-link__brand {
width: auto;
height: auto;
max-width: 20px;
max-height: 20px;
object-fit: contain;
flex: 0 0 auto;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.28));
}
.historical-player-profile-links a:hover,
.historical-player-profile-links a:focus-visible {
border-color: rgba(210, 182, 118, 0.62);
+46 -106
View File
@@ -7,8 +7,8 @@
}
.historical-hero__content {
padding-top: 58px;
padding-bottom: 64px;
padding-top: 44px;
padding-bottom: 46px;
}
.historical-hero__topline {
@@ -21,7 +21,7 @@
.historical-hero__copy {
display: grid;
gap: 18px;
gap: 24px;
min-width: 0;
}
@@ -34,7 +34,7 @@
.historical-hero__layout--registry {
grid-template-columns: minmax(220px, 330px) minmax(0, 1fr);
column-gap: 36px;
column-gap: clamp(72px, 7vw, 140px);
}
.historical-hero__layout > * {
@@ -42,8 +42,8 @@
}
.historical-logo-frame {
width: min(340px, 100%);
min-height: 248px;
width: min(300px, 100%);
min-height: 220px;
}
.historical-hero__title {
@@ -65,11 +65,6 @@
/* Registry/historico hero: logo left, real flexible spacer, copy anchored right. */
@media (min-width: 1121px) {
.hero--public-page.historical-hero .historical-hero__content {
padding-block: 54px 60px;
padding-inline: clamp(64px, 5.5vw, 104px);
}
.historical-hero__layout--registry {
width: 100%;
max-width: 1420px;
@@ -118,10 +113,6 @@
}
@media (min-width: 1121px) and (max-width: 1380px) {
.hero--public-page.historical-hero .historical-hero__content {
padding-inline: clamp(44px, 4vw, 64px);
}
.historical-hero__layout--registry {
grid-template-columns: minmax(320px, 390px) minmax(28px, 1fr) minmax(560px, 720px);
column-gap: clamp(20px, 2.5vw, 40px);
@@ -638,18 +629,6 @@
display: none;
}
.historical-weapon-stat-list {
display: grid;
gap: 8px;
}
.historical-weapon-stat-list li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
}
.historical-table-shell {
overflow-x: auto;
}
@@ -701,7 +680,7 @@
}
.historical-table--players {
min-width: 1000px;
min-width: 920px;
}
.historical-table__position {
@@ -771,11 +750,11 @@
.current-match-killfeed-screen {
position: relative;
box-sizing: border-box;
width: 100%;
min-height: 180px;
padding: 10px 10px 12px;
overflow: visible;
max-height: 520px;
padding: 10px;
overflow: hidden;
border: 1px solid rgba(159, 168, 141, 0.2);
border-radius: 6px;
background:
@@ -805,13 +784,12 @@
.current-match-killfeed {
position: relative;
z-index: 2;
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 8px;
max-height: none;
overflow: visible;
max-height: 500px;
overflow: hidden;
}
.current-match-killfeed__column {
@@ -823,12 +801,12 @@
.current-match-killfeed__row {
display: grid;
grid-template-columns: minmax(0, 1fr) 128px minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr) minmax(86px, 112px) minmax(0, 1fr);
align-items: center;
gap: 8px;
min-height: 74px;
padding: 8px 10px;
overflow: visible;
min-height: 54px;
padding: 6px 9px;
overflow: hidden;
border: 1px solid rgba(159, 168, 141, 0.14);
border-left: 2px solid rgba(159, 168, 141, 0.24);
border-radius: 3px;
@@ -864,18 +842,13 @@
display: inline-flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
max-width: 100%;
overflow: hidden;
flex-wrap: nowrap;
flex-wrap: wrap;
}
.current-match-killfeed__player-name {
display: block;
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -908,54 +881,36 @@
.current-match-killfeed__weapon {
display: grid;
grid-template-rows: auto auto;
grid-template-rows: 25px auto;
align-items: center;
justify-content: center;
gap: 4px;
width: 128px;
min-width: 128px;
min-height: 58px;
padding: 3px 5px;
overflow: hidden;
gap: 1px;
min-width: 92px;
min-height: 38px;
padding: 2px 5px;
border: 1px solid rgba(159, 168, 141, 0.18);
border-radius: 3px;
color: var(--text);
background: rgba(8, 10, 11, 0.74);
}
.current-match-killfeed__weapon-icon-frame {
display: grid;
place-items: center;
box-sizing: border-box;
width: 96px;
height: 36px;
overflow: hidden;
padding: 3px 7px;
justify-self: center;
border: 1px solid rgba(81, 76, 62, 0.22);
border-radius: 6px;
background: linear-gradient(180deg, rgba(247, 242, 229, 0.96), rgba(226, 218, 198, 0.94));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.4),
0 1px 3px rgba(0, 0, 0, 0.18);
}
.current-match-killfeed__weapon-icon {
display: block;
width: auto;
height: auto;
max-width: 82px;
max-height: 26px;
width: min(100%, 104px);
height: 24px;
object-fit: contain;
object-position: center;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.72));
}
.current-match-killfeed__weapon-fallback {
display: grid;
width: 100%;
height: 100%;
width: 24px;
height: 24px;
justify-self: center;
place-items: center;
color: rgba(49, 43, 31, 0.86);
border: 1px solid rgba(210, 182, 118, 0.3);
border-radius: 3px;
color: var(--accent-warm);
font-size: 0.82rem;
font-weight: 800;
}
@@ -966,9 +921,7 @@
.current-match-killfeed__weapon em {
display: block;
width: 100%;
max-width: 116px;
min-width: 0;
max-width: 112px;
overflow: hidden;
color: var(--muted);
font-size: 0.58rem;
@@ -996,11 +949,9 @@
}
.current-match-killfeed__team-badge {
flex: 0 0 auto;
min-width: auto;
padding-inline: 8px;
font-size: 0.62rem;
white-space: nowrap;
}
.current-match-player-intro {
@@ -1040,12 +991,6 @@
}
}
@media (max-width: 1280px) {
.current-match-killfeed {
grid-template-columns: 1fr;
}
}
.historical-comparison-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
@@ -1284,41 +1229,36 @@
min-width: 540px;
}
.current-match-killfeed {
grid-template-columns: 1fr;
max-height: 500px;
}
.current-match-killfeed__row {
grid-template-columns: minmax(0, 1fr) 128px minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr) minmax(86px, 116px) minmax(0, 1fr);
}
}
@media (max-width: 480px) {
.current-match-killfeed__row {
grid-template-columns: minmax(0, 1fr) 104px minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr) 82px minmax(0, 1fr);
gap: 5px;
padding-inline: 6px;
}
.current-match-killfeed__weapon {
width: 104px;
min-width: 104px;
min-width: 82px;
padding-inline: 3px;
}
.current-match-killfeed__weapon-icon-frame {
width: 86px;
height: 32px;
padding-inline: 5px;
}
.current-match-killfeed__weapon-icon {
max-width: 76px;
max-height: 23px;
}
.current-match-killfeed__weapon em {
max-width: 94px;
}
.current-match-killfeed__player-name {
display: -webkit-box;
overflow: hidden;
font-size: 0.78rem;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-overflow: ellipsis;
white-space: normal;
}
.current-match-killfeed__player-identity {
+10 -835
View File
@@ -1,4 +1,4 @@
:root {
:root {
--bg: #0f120d;
--bg-deep: #090b08;
--bg-elevated: rgba(27, 33, 24, 0.92);
@@ -85,60 +85,6 @@ a {
pointer-events: none;
}
.public-nav {
width: min(var(--page-shell-width), calc(100vw - (var(--page-shell-gutter) * 2)));
margin: 0 auto 16px;
padding: 10px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
border: 1px solid var(--border);
border-radius: 22px;
background:
linear-gradient(180deg, rgba(24, 30, 22, 0.9), rgba(10, 13, 9, 0.96)),
radial-gradient(circle at top center, rgba(183, 201, 125, 0.08), transparent 40%);
box-shadow: var(--shadow-soft);
}
.public-nav__link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 50px;
padding: 0 18px;
border: 1px solid rgba(159, 168, 141, 0.22);
border-radius: 999px;
background: linear-gradient(180deg, rgba(18, 23, 16, 0.88), rgba(10, 12, 9, 0.96));
color: var(--text-soft);
font-size: 0.82rem;
font-weight: 800;
letter-spacing: 0.1em;
line-height: 1.2;
text-align: center;
text-transform: uppercase;
transition:
transform 160ms ease,
border-color 160ms ease,
background 160ms ease,
color 160ms ease,
box-shadow 160ms ease;
}
.public-nav__link:hover,
.public-nav__link:focus-visible {
transform: translateY(-1px);
border-color: rgba(210, 182, 118, 0.48);
background: linear-gradient(180deg, rgba(43, 50, 36, 0.94), rgba(16, 20, 14, 0.98));
color: var(--text);
}
.public-nav__link.is-active {
border-color: rgba(183, 201, 125, 0.42);
background: linear-gradient(180deg, rgba(183, 201, 125, 0.22), rgba(89, 101, 58, 0.28));
color: var(--text);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.hero {
position: relative;
overflow: hidden;
@@ -477,14 +423,6 @@ h2 {
box-shadow: 0 18px 36px rgba(110, 127, 72, 0.45);
}
.discord-button:disabled {
cursor: not-allowed;
opacity: 0.58;
filter: saturate(0.7);
box-shadow: none;
transform: none;
}
.content {
width: 100%;
margin-top: -28px;
@@ -615,83 +553,6 @@ h2 {
border-radius: 10px;
}
.release-countdown {
margin-top: 18px;
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
border: 1px solid rgba(210, 182, 118, 0.24);
border-radius: 18px;
background:
linear-gradient(180deg, rgba(31, 35, 24, 0.92), rgba(12, 15, 11, 0.98)),
repeating-linear-gradient(
-32deg,
rgba(183, 201, 125, 0.035) 0 1px,
transparent 1px 18px
);
}
.release-countdown__copy {
align-self: flex-start;
min-width: 0;
max-width: 100%;
}
.release-countdown__copy .eyebrow--section {
margin-bottom: 0;
border-color: rgba(210, 182, 118, 0.42);
background:
linear-gradient(180deg, rgba(82, 68, 33, 0.28), rgba(41, 49, 24, 0.62));
color: #e0c487;
font-weight: 800;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
0 10px 22px rgba(0, 0, 0, 0.18);
}
.release-countdown__units {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.release-countdown__units article {
min-width: 0;
padding: 12px 10px;
border: 1px solid rgba(159, 168, 141, 0.18);
border-radius: 12px;
background: rgba(8, 10, 7, 0.58);
text-align: center;
}
.release-countdown__units strong,
.release-countdown__units span {
display: block;
}
.release-countdown__units strong {
color: var(--accent-warm);
font-size: clamp(1.55rem, 3.1vw, 2.4rem);
line-height: 1;
}
.release-countdown__units span {
margin-top: 6px;
color: var(--muted);
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.release-countdown__status {
grid-column: 1 / -1;
margin: -4px 0 0;
color: var(--text-soft);
font-size: 0.86rem;
}
.servers-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
@@ -968,14 +829,14 @@ h2 {
.clan-card__brand {
display: grid;
grid-template-columns: minmax(156px, 176px) minmax(0, 1fr);
grid-template-columns: minmax(92px, 112px) minmax(0, 1fr);
align-items: center;
gap: 20px;
gap: 18px;
}
.clan-card__logo {
min-height: 160px;
padding: 10px;
min-height: 92px;
padding: 12px;
display: grid;
place-items: center;
border: 1px dashed rgba(183, 201, 125, 0.3);
@@ -983,34 +844,22 @@ h2 {
background: linear-gradient(180deg, rgba(19, 24, 16, 0.82), rgba(10, 13, 9, 0.66));
}
.clan-card__logo--standard {
min-height: 164px;
}
.clan-card__logo--wide {
padding-inline: 8px;
}
.clan-card__logo--shield {
padding-inline: 16px;
padding-inline: 18px;
}
.clan-card__logo img {
width: 100%;
height: 100%;
width: auto;
height: auto;
max-width: 100%;
max-height: 144px;
max-height: 84px;
object-fit: contain;
}
.clan-card__logo--wide img {
max-height: 148px;
}
.clan-card__logo--shield img {
max-height: 152px;
}
.clan-card__logo-placeholder {
width: 100%;
min-height: 84px;
@@ -1067,627 +916,6 @@ h2 {
min-width: 180px;
}
.stats__hero-actions {
margin-top: 8px;
}
.stats-search-form {
margin: 0 0 14px;
display: grid;
gap: 10px;
}
.stats-search-form__label {
margin: 0;
color: var(--text-soft);
font-size: 0.86rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.stats-search-form__fields {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: center;
}
.stats-search-input {
min-height: 52px;
padding: 0 16px;
border: 1px solid rgba(210, 182, 118, 0.3);
border-radius: 12px;
background: linear-gradient(180deg, rgba(19, 24, 16, 0.96), rgba(10, 13, 9, 0.98));
color: var(--text);
font-size: 1rem;
outline: none;
}
.stats-search-input:focus-visible {
border-color: rgba(210, 182, 118, 0.6);
box-shadow: 0 0 0 2px rgba(210, 182, 118, 0.2);
}
.stats-state {
margin: 0 0 14px;
padding: 0.75rem 1rem;
border-radius: 12px;
border: 1px solid rgba(159, 168, 141, 0.24);
background: rgba(16, 21, 15, 0.72);
color: var(--text-soft);
}
.stats-state--neutral {
border-color: rgba(159, 168, 141, 0.28);
}
.stats-state--loading {
border-color: rgba(183, 201, 125, 0.45);
color: var(--accent-strong);
}
.stats-state--error {
border-color: rgba(210, 182, 118, 0.45);
color: var(--accent-warm);
}
.stats-state--warning {
border-color: rgba(210, 182, 118, 0.35);
color: #e2d7a9;
}
.stats-state--ready {
border-color: rgba(183, 201, 125, 0.32);
color: var(--accent-strong);
}
.stats-result-list {
display: grid;
gap: 10px;
}
.stats-result-item__button {
width: 100%;
text-align: left;
border: 1px solid rgba(159, 168, 141, 0.26);
border-radius: 14px;
padding: 14px 16px;
background: linear-gradient(180deg, rgba(28, 34, 25, 0.9), rgba(12, 15, 11, 0.95));
color: var(--text);
cursor: pointer;
transition:
border-color 160ms ease,
transform 160ms ease,
box-shadow 160ms ease;
}
.stats-result-item__button:hover,
.stats-result-item__button:focus-visible {
border-color: rgba(210, 182, 118, 0.5);
transform: translateY(-1px);
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22);
}
.stats-result-item__main {
display: grid;
gap: 2px;
}
.stats-result-item__name {
margin: 0;
font-weight: 700;
}
.stats-result-item__meta {
margin: 4px 0 0;
color: var(--muted);
font-size: 0.82rem;
}
.stats-result-item__metrics {
margin: 10px 0 0;
color: var(--text-soft);
font-size: 0.85rem;
}
.stats-annual-table-wrap {
display: grid;
gap: 16px;
}
.stats-annual-season {
display: inline-flex;
align-items: baseline;
gap: 10px;
margin: 18px 0 14px;
padding: 0.8rem 1rem;
border: 1px solid rgba(183, 201, 125, 0.24);
border-radius: 14px;
background: linear-gradient(180deg, rgba(30, 36, 27, 0.78), rgba(12, 15, 11, 0.9));
}
.stats-annual-season__label {
color: var(--muted);
font-size: 0.74rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.stats-annual-season__value {
color: var(--accent-strong);
font-size: 1.12rem;
letter-spacing: 0.08em;
}
.stats-annual-meta {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
margin: 0;
}
.stats-annual-card {
border: 1px solid rgba(159, 168, 141, 0.2);
border-radius: 18px;
padding: 18px;
background:
linear-gradient(180deg, rgba(28, 34, 25, 0.88), rgba(12, 15, 11, 0.96));
}
.stats-annual-meta-item {
margin: 0;
padding: 14px 16px;
border: 1px solid rgba(159, 168, 141, 0.18);
border-radius: 16px;
background: rgba(15, 18, 13, 0.62);
}
.stats-annual-meta-item p,
.stats-annual-meta-item strong {
margin: 0;
}
.stats-annual-meta-item p {
margin-bottom: 8px;
color: var(--muted);
font-size: 0.74rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.stats-annual-table-shell {
overflow-x: auto;
border: 1px solid rgba(159, 168, 141, 0.18);
border-radius: 18px;
background: linear-gradient(180deg, rgba(19, 24, 16, 0.88), rgba(10, 13, 9, 0.96));
}
.stats-annual-table {
width: 100%;
min-width: 860px;
border-collapse: collapse;
}
.stats-annual-table th,
.stats-annual-table td {
padding: 14px 16px;
text-align: left;
border-bottom: 1px solid rgba(159, 168, 141, 0.12);
}
.stats-annual-table thead th {
color: var(--accent-strong);
font-size: 0.76rem;
letter-spacing: 0.08em;
text-transform: uppercase;
background: rgba(14, 18, 12, 0.96);
}
.stats-annual-table tbody tr:hover {
background: rgba(183, 201, 125, 0.05);
}
.stats-annual-table tbody tr:last-child td {
border-bottom: 0;
}
.stats-annual-rank {
color: var(--accent-warm);
font-weight: 700;
}
.stats-annual-player {
display: grid;
gap: 4px;
}
.stats-annual-player strong {
font-size: 1rem;
line-height: 1.35;
}
.stats-annual-metric {
color: var(--accent-warm);
font-weight: 700;
}
.stats-annual-sub {
color: var(--muted);
}
.stats-summary-grid {
display: grid;
gap: 12px;
margin-bottom: 16px;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
}
.stats-comparison-grid {
display: grid;
gap: 12px;
margin: 16px 0;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
}
.stats-comparison-card {
position: relative;
overflow: hidden;
min-height: 100%;
border: 1px solid rgba(183, 201, 125, 0.18);
border-radius: 18px;
padding: 16px 18px;
background:
linear-gradient(180deg, rgba(30, 36, 27, 0.94), rgba(11, 14, 10, 0.98));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
var(--shadow-soft);
}
.stats-comparison-card::before {
content: "";
position: absolute;
inset: 0 0 auto;
height: 1px;
background: linear-gradient(90deg, rgba(210, 182, 118, 0.5), transparent 70%);
pointer-events: none;
}
.stats-comparison-card__eyebrow {
margin: 0 0 8px;
color: var(--accent-warm);
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.stats-comparison-card__title {
margin: 0 0 8px;
font-size: 1rem;
line-height: 1.4;
}
.stats-comparison-card__badge {
display: inline-flex;
align-items: center;
min-height: 34px;
margin-bottom: 12px;
padding: 0.4rem 0.8rem;
border: 1px solid rgba(159, 168, 141, 0.22);
border-radius: 999px;
background: rgba(15, 18, 13, 0.84);
color: var(--text-soft);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.stats-comparison-card__badge--ok {
border-color: rgba(183, 201, 125, 0.34);
color: var(--accent-strong);
}
.stats-comparison-card__badge--warning {
border-color: rgba(210, 182, 118, 0.32);
color: #e2d7a9;
}
.stats-comparison-card__badge--error {
border-color: rgba(210, 182, 118, 0.45);
color: var(--accent-warm);
}
.stats-comparison-card__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.stats-comparison-card__metric {
padding: 10px 12px;
border: 1px solid rgba(159, 168, 141, 0.14);
border-radius: 12px;
background: linear-gradient(180deg, rgba(15, 18, 13, 0.5), rgba(8, 10, 7, 0.3));
}
.stats-comparison-card__metric-label {
display: block;
margin-bottom: 6px;
color: var(--muted);
font-size: 0.74rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.stats-comparison-card__metric-value {
display: block;
color: var(--text);
font-size: 1.02rem;
font-weight: 700;
}
.stats-comparison-card__detail,
.stats-comparison-card__note {
margin: 0;
color: var(--text-soft);
line-height: 1.6;
}
.stats-comparison-card__detail + .stats-comparison-card__detail,
.stats-comparison-card__detail + .stats-comparison-card__note {
margin-top: 8px;
}
.stats-summary-card {
margin: 0;
border: 1px solid rgba(159, 168, 141, 0.2);
border-radius: 16px;
padding: 14px 16px;
background:
linear-gradient(180deg, rgba(28, 34, 25, 0.88), rgba(12, 15, 11, 0.96));
}
.stats-profile-links {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
.stats-profile-links-bar {
margin: 0 0 18px;
}
.stats-profile-links-strip {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
}
.stats-profile-links-strip__label {
color: var(--muted);
font-size: 0.82rem;
font-weight: 800;
text-transform: uppercase;
}
.stats-profile-links-bar .stats-profile-links {
margin-top: 0;
}
.stats-profile-links a {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 0.55rem 0.9rem;
border: 1px solid rgba(159, 168, 141, 0.18);
border-radius: 999px;
background: rgba(15, 18, 13, 0.84);
color: var(--text);
text-decoration: none;
font-weight: 700;
}
.stats-profile-links a:hover {
border-color: rgba(210, 182, 118, 0.34);
color: var(--accent-warm);
}
.stats-profile-links__brand {
width: 18px;
height: 18px;
object-fit: contain;
flex: 0 0 auto;
}
.stats-summary-card--placeholder {
border-color: rgba(210, 182, 118, 0.26);
color: var(--accent-warm);
}
.stats-summary-title {
margin: 0 0 10px;
color: var(--accent-strong);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.stats-summary-card p {
margin: 0 0 8px;
}
.stats-summary-card p:last-child {
margin-bottom: 0;
}
.stats-ranking-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
}
.stats-hero .status-chip {
width: fit-content;
}
.ranking-form {
display: grid;
gap: 16px;
margin-top: 20px;
}
.ranking-secondary-action {
margin: 0 0 18px;
}
.ranking-form__grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
}
.ranking-form__actions {
margin-top: 0;
}
.ranking-form__note {
margin: 0;
padding: 0.8rem 1rem;
border: 1px solid rgba(159, 168, 141, 0.18);
border-radius: 14px;
background: rgba(15, 18, 13, 0.62);
color: var(--text-soft);
line-height: 1.65;
}
.ranking-form__note--warning {
border-color: rgba(210, 182, 118, 0.3);
color: #e2d7a9;
}
.ranking-select {
width: 100%;
min-height: 52px;
margin-top: 8px;
padding: 0 16px;
border: 1px solid rgba(210, 182, 118, 0.3);
border-radius: 12px;
background: linear-gradient(180deg, rgba(19, 24, 16, 0.96), rgba(10, 13, 9, 0.98));
color: var(--text);
font-size: 1rem;
outline: none;
}
.ranking-select:focus-visible {
border-color: rgba(210, 182, 118, 0.6);
box-shadow: 0 0 0 2px rgba(210, 182, 118, 0.2);
}
.ranking-meta {
display: grid;
gap: 12px;
margin-bottom: 16px;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
}
.ranking-results-title {
margin: 0 0 16px;
font-size: 1.08rem;
}
.ranking-meta-card {
margin: 0;
padding: 14px 16px;
border: 1px solid rgba(159, 168, 141, 0.2);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(28, 34, 25, 0.88), rgba(12, 15, 11, 0.96));
}
.ranking-meta-card p,
.ranking-meta-card strong {
margin: 0;
}
.ranking-meta-card p {
margin-bottom: 8px;
color: var(--muted);
font-size: 0.74rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ranking-meta-card--active {
border-color: rgba(183, 201, 125, 0.28);
background:
linear-gradient(180deg, rgba(36, 44, 31, 0.92), rgba(14, 18, 12, 0.98));
}
.ranking-table-shell {
overflow-x: auto;
}
.ranking-table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
border: 1px solid rgba(159, 168, 141, 0.18);
border-radius: 18px;
overflow: hidden;
background: linear-gradient(180deg, rgba(19, 24, 16, 0.88), rgba(10, 13, 9, 0.96));
}
.ranking-table th,
.ranking-table td {
padding: 14px 16px;
text-align: left;
border-bottom: 1px solid rgba(159, 168, 141, 0.12);
}
.ranking-table thead th {
color: var(--accent-strong);
font-size: 0.76rem;
letter-spacing: 0.08em;
text-transform: uppercase;
background: rgba(14, 18, 12, 0.96);
}
.ranking-table tbody tr:hover {
background: rgba(183, 201, 125, 0.05);
}
.ranking-table__metric {
color: var(--accent-warm);
font-weight: 700;
}
.ranking-table th[hidden],
.ranking-table td[hidden] {
display: none;
}
.ranking-player {
display: grid;
gap: 0;
}
.ranking-empty {
padding: 18px 20px;
border: 1px dashed rgba(210, 182, 118, 0.24);
border-radius: 18px;
background: linear-gradient(180deg, rgba(31, 28, 22, 0.82), rgba(14, 12, 9, 0.92));
color: var(--text-soft);
line-height: 1.7;
}
.ranking-form__actions .discord-button[disabled] {
opacity: 0.72;
cursor: progress;
}
.server-action-link--disabled {
border-color: rgba(159, 168, 141, 0.18);
background: linear-gradient(180deg, rgba(42, 46, 39, 0.5), rgba(19, 22, 17, 0.7));
@@ -1718,10 +946,6 @@ h2 {
}
@media (max-width: 760px) {
.public-nav {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.servers-grid,
.servers-grid--section,
.clans-grid {
@@ -1751,53 +975,6 @@ h2 {
font-size: 0.98rem;
}
.public-nav {
padding: 8px;
gap: 8px;
}
.public-nav__link {
min-height: 46px;
padding-inline: 12px;
font-size: 0.72rem;
letter-spacing: 0.06em;
}
.stats-search-form__fields {
grid-template-columns: 1fr;
}
.release-countdown {
grid-template-columns: 1fr;
}
.release-countdown__units {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.stats-comparison-card__grid {
grid-template-columns: 1fr;
}
.stats-search-form__fields .discord-button {
width: 100%;
}
.ranking-form__actions {
width: 100%;
justify-content: center;
}
.ranking-table th,
.ranking-table td {
padding: 12px 10px;
}
.stats-annual-table th,
.stats-annual-table td {
padding: 12px 10px;
}
.logo-frame {
min-height: 180px;
padding: 16px 18px;
@@ -1821,8 +998,7 @@ h2 {
width: 100%;
}
.secondary-button,
.public-nav__link {
.secondary-button {
width: 100%;
}
@@ -1888,4 +1064,3 @@ h2 {
grid-template-columns: 1fr;
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 803 KiB

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Some files were not shown because too many files have changed in this diff Show More