Compare commits
10 Commits
c1acafe65a
...
c0214eaaae
| Author | SHA1 | Date | |
|---|---|---|---|
| c0214eaaae | |||
| 61236a8267 | |||
| 3af950b464 | |||
| 659785f8f3 | |||
| 0f8f22df29 | |||
| 010e356dc8 | |||
| a58a4e5c42 | |||
| 0b3c6f478e | |||
| 820073f95c | |||
| 18faa665ec |
+44
-44
@@ -1,65 +1,65 @@
|
||||
[
|
||||
{
|
||||
"max_br": 14.3,
|
||||
"start": 1777618800,
|
||||
"end": 1778223600,
|
||||
"start_discord": "<t:1777618800:D>",
|
||||
"end_discord": "<t:1778223600:D>"
|
||||
"max_br": 14.7,
|
||||
"start": 1782889200,
|
||||
"end": 1783494000,
|
||||
"start_discord": "<t:1782889200:D>",
|
||||
"end_discord": "<t:1783494000:D>"
|
||||
},
|
||||
{
|
||||
"max_br": 12.0,
|
||||
"start": 1778223600,
|
||||
"end": 1778828400,
|
||||
"start_discord": "<t:1778223600:D>",
|
||||
"end_discord": "<t:1778828400:D>"
|
||||
"start": 1783494000,
|
||||
"end": 1784098800,
|
||||
"start_discord": "<t:1783494000:D>",
|
||||
"end_discord": "<t:1784098800:D>"
|
||||
},
|
||||
{
|
||||
"max_br": 11.0,
|
||||
"start": 1778828400,
|
||||
"end": 1779433200,
|
||||
"start_discord": "<t:1778828400:D>",
|
||||
"end_discord": "<t:1779433200:D>"
|
||||
"max_br": 10.7,
|
||||
"start": 1784098800,
|
||||
"end": 1784703600,
|
||||
"start_discord": "<t:1784098800:D>",
|
||||
"end_discord": "<t:1784703600:D>"
|
||||
},
|
||||
{
|
||||
"max_br": 10.0,
|
||||
"start": 1779433200,
|
||||
"end": 1780038000,
|
||||
"start_discord": "<t:1779433200:D>",
|
||||
"end_discord": "<t:1780038000:D>"
|
||||
"max_br": 9.7,
|
||||
"start": 1784703600,
|
||||
"end": 1785308400,
|
||||
"start_discord": "<t:1784703600:D>",
|
||||
"end_discord": "<t:1785308400:D>"
|
||||
},
|
||||
{
|
||||
"max_br": 9.0,
|
||||
"start": 1780038000,
|
||||
"end": 1780642800,
|
||||
"start_discord": "<t:1780038000:D>",
|
||||
"end_discord": "<t:1780642800:D>"
|
||||
"max_br": 8.7,
|
||||
"start": 1785308400,
|
||||
"end": 1785913200,
|
||||
"start_discord": "<t:1785308400:D>",
|
||||
"end_discord": "<t:1785913200:D>"
|
||||
},
|
||||
{
|
||||
"max_br": 8.0,
|
||||
"start": 1780642800,
|
||||
"end": 1781247600,
|
||||
"start_discord": "<t:1780642800:D>",
|
||||
"end_discord": "<t:1781247600:D>"
|
||||
"max_br": 7.3,
|
||||
"start": 1785913200,
|
||||
"end": 1786518000,
|
||||
"start_discord": "<t:1785913200:D>",
|
||||
"end_discord": "<t:1786518000:D>"
|
||||
},
|
||||
{
|
||||
"max_br": 7.0,
|
||||
"start": 1781247600,
|
||||
"end": 1781852400,
|
||||
"start_discord": "<t:1781247600:D>",
|
||||
"end_discord": "<t:1781852400:D>"
|
||||
"max_br": 6.3,
|
||||
"start": 1786518000,
|
||||
"end": 1787122800,
|
||||
"start_discord": "<t:1786518000:D>",
|
||||
"end_discord": "<t:1787122800:D>"
|
||||
},
|
||||
{
|
||||
"max_br": 6.0,
|
||||
"start": 1781852400,
|
||||
"end": 1782457200,
|
||||
"start_discord": "<t:1781852400:D>",
|
||||
"end_discord": "<t:1782457200:D>"
|
||||
"max_br": 5.7,
|
||||
"start": 1787122800,
|
||||
"end": 1787727600,
|
||||
"start_discord": "<t:1787122800:D>",
|
||||
"end_discord": "<t:1787727600:D>"
|
||||
},
|
||||
{
|
||||
"max_br": 5.0,
|
||||
"start": 1782457200,
|
||||
"end": 1782889200,
|
||||
"start_discord": "<t:1782457200:D>",
|
||||
"end_discord": "<t:1782889200:D>"
|
||||
"max_br": 4.7,
|
||||
"start": 1787727600,
|
||||
"end": 1788246000,
|
||||
"start_discord": "<t:1787727600:D>",
|
||||
"end_discord": "<t:1788246000:D>"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
1 week мах BR 14.7 (01.07 — 07.07)
|
||||
2 week мах BR 12.0 (08.07 — 14.07)
|
||||
3 week мах BR 10.7 (15.07 — 21.07)
|
||||
4 week мах BR 9.7 (22.07 — 28.07)
|
||||
5 week мах BR 8.7 (29.07 — 04.08)
|
||||
6 week мах BR 7.3 (05.08 — 11.08)
|
||||
7 week мах BR 6.3 (12.08 — 18.08)
|
||||
8 week мах BR 5.7 (19.08 — 25.08)
|
||||
Until the end of season, мах BR 4.7 (26.08 — 31.08)
|
||||
+48
-18
@@ -3574,21 +3574,41 @@ async def card(
|
||||
)
|
||||
|
||||
try:
|
||||
_t_lookup = time_module.monotonic()
|
||||
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
await db.create_function("ulower", 1, str.lower)
|
||||
async with db.execute(
|
||||
"""
|
||||
SELECT UID, MIN(nick) AS nick
|
||||
FROM player_games_hist
|
||||
WHERE ulower(nick) LIKE ulower(?)
|
||||
GROUP BY UID
|
||||
ORDER BY nick
|
||||
LIMIT 25
|
||||
""",
|
||||
(f"%{player}%",),
|
||||
) as cursor:
|
||||
results = list(await cursor.fetchall())
|
||||
|
||||
async def _lookup(where: str, pattern: str) -> list:
|
||||
async with db.execute(
|
||||
f"""
|
||||
SELECT UID, MIN(nick) AS nick
|
||||
FROM player_games_hist
|
||||
WHERE {where}
|
||||
GROUP BY UID
|
||||
ORDER BY nick
|
||||
LIMIT 25
|
||||
""",
|
||||
(pattern,),
|
||||
) as cursor:
|
||||
return list(await cursor.fetchall())
|
||||
|
||||
# Fast path: a prefix match uses the `nick COLLATE NOCASE` index
|
||||
# (~ms), covering the common case of typing the start of a name.
|
||||
# This avoids a full scan of the multi-million-row games table.
|
||||
results = await _lookup("nick LIKE ? COLLATE NOCASE", f"{player}%")
|
||||
lookup_path = "prefix"
|
||||
|
||||
# Fallback: only if the prefix found nothing do we pay for a full
|
||||
# substring scan. ulower() gives full Unicode case-folding (e.g.
|
||||
# Cyrillic) that the ASCII-only NOCASE collation can't.
|
||||
if not results:
|
||||
await db.create_function("ulower", 1, str.lower)
|
||||
results = await _lookup("ulower(nick) LIKE ulower(?)", f"%{player}%")
|
||||
lookup_path = "substring"
|
||||
logging.info(
|
||||
"(CARD) nick lookup query=%r path=%s matches=%d ms=%d",
|
||||
player, lookup_path, len(results), (time_module.monotonic() - _t_lookup) * 1000,
|
||||
)
|
||||
except Exception as e:
|
||||
error_str = str(e)[:1800]
|
||||
return await interaction.followup.send(
|
||||
@@ -5024,19 +5044,29 @@ _LOWER_IS_BETTER = {"deaths", "losses"}
|
||||
async def _resolve_player_uids_batch(db, usernames: List[str], lang: str = "en"):
|
||||
"""Resolve multiple usernames in one pass. Returns (uid_list, error_msg) or (None, error_msg)."""
|
||||
uids: List[str] = []
|
||||
for name in usernames:
|
||||
|
||||
async def _resolve_one(where: str, pattern: str) -> list:
|
||||
async with db.execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT UID, nick FROM (
|
||||
SELECT UID, nick, ROW_NUMBER() OVER (PARTITION BY UID ORDER BY endtime_unix DESC) as rn
|
||||
FROM player_games_hist
|
||||
WHERE ulower(nick) LIKE ulower(?)
|
||||
WHERE {where}
|
||||
) WHERE rn = 1
|
||||
ORDER BY nick LIMIT 25
|
||||
""",
|
||||
(f"%{name}%",),
|
||||
(pattern,),
|
||||
) as cursor:
|
||||
results = list(await cursor.fetchall())
|
||||
return list(await cursor.fetchall())
|
||||
|
||||
for name in usernames:
|
||||
# Fast path: prefix match uses the nick COLLATE NOCASE index (~ms).
|
||||
# Only fall back to a full substring scan (ulower for Unicode
|
||||
# case-folding) when the prefix finds nothing.
|
||||
results = await _resolve_one("nick LIKE ? COLLATE NOCASE", f"{name}%")
|
||||
if not results:
|
||||
await db.create_function("ulower", 1, str.lower)
|
||||
results = await _resolve_one("ulower(nick) LIKE ulower(?)", f"%{name}%")
|
||||
if not results:
|
||||
return None, t(lang, "compare.no_players_found", name=name)
|
||||
if len(results) > 1:
|
||||
|
||||
+14
-2
@@ -655,8 +655,20 @@ async def obtain_clan_new_points(
|
||||
while isinstance(data, dict) and "member_ratings" not in data and len(data) == 1:
|
||||
data = next(iter(data.values()))
|
||||
|
||||
total_score = data["astat"]["dr_era5_hist"]
|
||||
raw_ratings = data["member_ratings"]
|
||||
# A real clan record always carries a `members` roster. Deleted
|
||||
# clans come back as JSON (handled above), and truncated payloads
|
||||
# raise inside bin_blk_to_json, so a parsed BLK without `members`
|
||||
# is genuinely unavailable.
|
||||
if not isinstance(data, dict) or "members" not in data:
|
||||
raise KeyError("members")
|
||||
|
||||
# After a season reset the game API can omit the aggregate `astat`
|
||||
# block and/or the per-member `member_ratings` block for inactive
|
||||
# squadrons even though the clan and its roster still exist. Default
|
||||
# both to zero so the squadron renders at 0 points instead of being
|
||||
# reported as deleted.
|
||||
total_score = (data.get("astat") or {}).get("dr_era5_hist", 0)
|
||||
raw_ratings = data.get("member_ratings") or {}
|
||||
|
||||
except ClanInfoError as e:
|
||||
try:
|
||||
|
||||
@@ -55,6 +55,12 @@ def normalize_ws_message(data: Any) -> Optional[List[Dict[str, Any]]]:
|
||||
if isinstance(data, dict) and 'completed' in data:
|
||||
return data['completed']
|
||||
|
||||
if isinstance(data, dict) and isinstance(data.get('data'), dict):
|
||||
return [data['data']]
|
||||
|
||||
if isinstance(data, dict) and isinstance(data.get('data'), list):
|
||||
return data['data']
|
||||
|
||||
logger.warning(f"Unknown WS message format: {type(data)}")
|
||||
return None
|
||||
|
||||
|
||||
+42
-1
@@ -369,6 +369,43 @@ def gather_squadron_rating(conn_sq: sqlite3.Connection, clan_id: int,
|
||||
first=first, change=final - first)
|
||||
|
||||
|
||||
def gather_squadron_place(conn_sq: sqlite3.Connection, clan_id: int,
|
||||
season_start: int, season_end: int
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
"""Finishing leaderboard place for the season.
|
||||
|
||||
We don't store a per-season position, but ``total_score`` in
|
||||
squadrons_points IS the squadron rating that the in-game leaderboard ranks
|
||||
by. So rank clans by their final score and read off this clan's rank.
|
||||
|
||||
Only clans with at least one snapshot *within the season window* are ranked
|
||||
— a clan whose last data predates the season never appeared on that season's
|
||||
ladder (dead/inactive clans drop off in-game), so counting them would inflate
|
||||
the denominator. Each ranked clan uses its last in-season snapshot.
|
||||
|
||||
Returns (place, total_ranked) or None if this clan wasn't active in-season.
|
||||
Works for in-progress seasons too (season_end in the future → last snapshot
|
||||
so far = current standing).
|
||||
"""
|
||||
cur = conn_sq.execute(
|
||||
"SELECT p.clan_id, p.total_score "
|
||||
"FROM squadrons_points p "
|
||||
"JOIN (SELECT clan_id, MAX(unix_time) AS mt FROM squadrons_points "
|
||||
" WHERE unix_time BETWEEN ? AND ? GROUP BY clan_id) m "
|
||||
" ON m.clan_id = p.clan_id AND m.mt = p.unix_time",
|
||||
(season_start, season_end),
|
||||
)
|
||||
board = [(cid, sc) for cid, sc in cur.fetchall() if sc is not None]
|
||||
if not board:
|
||||
return None
|
||||
board.sort(key=lambda r: r[1], reverse=True)
|
||||
total = len(board)
|
||||
for i, (cid, _sc) in enumerate(board):
|
||||
if cid == clan_id:
|
||||
return i + 1, total
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchRow:
|
||||
session_id: str
|
||||
@@ -1070,6 +1107,7 @@ def render_squadron_card(out_path: Path, ident: SquadronIdent, season: str,
|
||||
rolling_battles: List[Tuple[int, int]],
|
||||
season_start: int, season_end: int,
|
||||
week_boundaries: List[int],
|
||||
place: Optional[Tuple[int, int]] = None,
|
||||
theme: str = "light",
|
||||
lang: str = DEFAULT_LANG) -> None:
|
||||
pal = theme_palette(theme)
|
||||
@@ -1219,7 +1257,7 @@ def render_squadron_card(out_path: Path, ident: SquadronIdent, season: str,
|
||||
# Short rows render in 2 columns; wide rows span full width.
|
||||
short_rows: List[Tuple[str, str]] = [
|
||||
(t("imgStatPeakRating"), f"{_fmt_int(rating.peak)} ({_fmt_date(rating.peak_ts)})"),
|
||||
(t("imgStatRatingChange"), f"{'+' if (rating.change or 0) >= 0 else ''}{_fmt_int(rating.change)}"),
|
||||
(t("imgStatPlaceFinished"), f"#{_fmt_int(place[0])} / {_fmt_int(place[1])}" if place else "—"),
|
||||
(t("imgStatTotalKills"), f"{_fmt_int(players.total_kills)} ({_fmt_int(players.ground_kills)} {t('imgGroundShort')} / {_fmt_int(players.air_kills)} {t('imgAirShort')})"),
|
||||
(t("imgStatTotalDeaths"), _fmt_int(players.deaths)),
|
||||
(t("imgStatAssistsCaptures"), f"{_fmt_int(players.assists)} / {_fmt_int(players.captures)}"),
|
||||
@@ -1677,6 +1715,8 @@ def run_squadron(args: Args) -> int:
|
||||
logging.info(f"resolved {ident.short_name} / {ident.long_name}")
|
||||
rating = gather_squadron_rating(conn_sq, args.clan_id, args.season_start, args.season_end)
|
||||
logging.info(f"rating: series={len(rating.series)} final={rating.final} peak={rating.peak} change={rating.change}")
|
||||
place = gather_squadron_place(conn_sq, args.clan_id, args.season_start, args.season_end)
|
||||
logging.info(f"place: {place}")
|
||||
|
||||
with _open_ro(SQ_BATTLES_DB) as conn_b:
|
||||
stream = gather_squadron_match_stream(conn_b, ident.short_name, args.season_start, args.season_end)
|
||||
@@ -1707,6 +1747,7 @@ def run_squadron(args: Args) -> int:
|
||||
rolling_wr, rolling_kd, rolling_battles,
|
||||
args.season_start, args.season_end,
|
||||
args.week_boundaries,
|
||||
place=place,
|
||||
theme=args.theme,
|
||||
lang=args.lang,
|
||||
)
|
||||
|
||||
+39
-14
@@ -3185,6 +3185,10 @@ def _unit_to_model_name(unit_name: str) -> str:
|
||||
internal = (unit_name or "").strip()
|
||||
if not internal:
|
||||
return "tankModels/unknown"
|
||||
if internal.startswith(("tankModels/", "airModels/")):
|
||||
return internal
|
||||
if internal.startswith("aircrafts/"):
|
||||
return f"airModels/{internal.split('/', 1)[1]}"
|
||||
tags = _get_unit_tags(internal) or []
|
||||
tag_set = set(tags)
|
||||
if "type_strike_ucav" in tag_set or "ucav" in internal.lower():
|
||||
@@ -3245,6 +3249,15 @@ def _position_at_time(path: list[dict[str, float]], time_ms: float) -> dict[str,
|
||||
return prev
|
||||
|
||||
|
||||
def _event_position_to_dict(pos: Any) -> dict[str, float] | None:
|
||||
if not isinstance(pos, list) or len(pos) < 3:
|
||||
return None
|
||||
try:
|
||||
return {"X": float(pos[0]), "Y": float(pos[1]), "Z": float(pos[2])}
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _zone_geometry_from_raw_zones(zones_src: Any) -> dict[str, dict]:
|
||||
if not isinstance(zones_src, dict):
|
||||
return {}
|
||||
@@ -3307,7 +3320,7 @@ def _convert_ws_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(ent, dict):
|
||||
continue
|
||||
uid = _to_int(ent.get("uid"), 0)
|
||||
unit = str(ent.get("unit") or "")
|
||||
unit = str(ent.get("model_path") or ent.get("unit") or "")
|
||||
path_raw = ent.get("path") or []
|
||||
if not isinstance(path_raw, list):
|
||||
continue
|
||||
@@ -3349,16 +3362,22 @@ def _convert_ws_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, Any]:
|
||||
kill_time = float(kill.get("time") or 0.0)
|
||||
victim_path = entity_paths_by_uid.get(victim_id, [])
|
||||
killer_path = entity_paths_by_uid.get(killer_id, [])
|
||||
victim_pos = _position_at_time(victim_path, kill_time)
|
||||
killer_pos = _position_at_time(killer_path, kill_time)
|
||||
victim_pos = (
|
||||
_position_at_time(victim_path, kill_time)
|
||||
or _event_position_to_dict(kill.get("offended_pos"))
|
||||
)
|
||||
killer_pos = (
|
||||
_position_at_time(killer_path, kill_time)
|
||||
or _event_position_to_dict(kill.get("offender_pos"))
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"Time": kill_time,
|
||||
"VictimID": victim_id,
|
||||
"KillerID": killer_id,
|
||||
"VictimEntityIndex": uid_to_entity_index.get(victim_id, 0),
|
||||
"Weapon": str(kill.get("used_weapon") or kill.get("weapon") or ""),
|
||||
"VictimModel": _unit_to_model_name(str(kill.get("offended_unit") or "")),
|
||||
"KillerModel": _unit_to_model_name(str(kill.get("offender_unit") or "")),
|
||||
"VictimModel": _unit_to_model_name(str(kill.get("offended_unit_model_path") or kill.get("offended_unit") or "")),
|
||||
"KillerModel": _unit_to_model_name(str(kill.get("offender_unit_model_path") or kill.get("offender_unit") or "")),
|
||||
"crashed": bool(kill.get("crashed", False)),
|
||||
}
|
||||
if victim_pos:
|
||||
@@ -3383,8 +3402,8 @@ def _convert_ws_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, Any]:
|
||||
"Time": float(dmg.get("time") or 0.0),
|
||||
"OffenderID": _to_int(dmg.get("offender_uid"), 0),
|
||||
"OffendedID": _to_int(dmg.get("offended_uid"), 0),
|
||||
"OffenderModel": _unit_to_model_name(str(dmg.get("offender_unit") or "")),
|
||||
"OffendedModel": _unit_to_model_name(str(dmg.get("offended_unit") or "")),
|
||||
"OffenderModel": _unit_to_model_name(str(dmg.get("offender_unit_model_path") or dmg.get("offender_unit") or "")),
|
||||
"OffendedModel": _unit_to_model_name(str(dmg.get("offended_unit_model_path") or dmg.get("offended_unit") or "")),
|
||||
"Afire": bool(dmg.get("afire", False)),
|
||||
})
|
||||
|
||||
@@ -3468,7 +3487,7 @@ def _convert_local_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, An
|
||||
if not isinstance(ent, dict):
|
||||
continue
|
||||
uid = _to_int(ent.get("uid"), 0)
|
||||
unit = str(ent.get("unit") or "")
|
||||
unit = str(ent.get("model_path") or ent.get("unit") or "")
|
||||
path_raw = ent.get("path") or []
|
||||
if not isinstance(path_raw, list):
|
||||
continue
|
||||
@@ -3510,16 +3529,22 @@ def _convert_local_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, An
|
||||
kill_time = float(kill.get("time") or 0.0)
|
||||
victim_path = entity_paths_by_uid.get(victim_id, [])
|
||||
killer_path = entity_paths_by_uid.get(killer_id, [])
|
||||
victim_pos = _position_at_time(victim_path, kill_time)
|
||||
killer_pos = _position_at_time(killer_path, kill_time)
|
||||
victim_pos = (
|
||||
_position_at_time(victim_path, kill_time)
|
||||
or _event_position_to_dict(kill.get("offended_pos"))
|
||||
)
|
||||
killer_pos = (
|
||||
_position_at_time(killer_path, kill_time)
|
||||
or _event_position_to_dict(kill.get("offender_pos"))
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"Time": kill_time,
|
||||
"VictimID": victim_id,
|
||||
"KillerID": killer_id,
|
||||
"VictimEntityIndex": uid_to_entity_index.get(victim_id, 0),
|
||||
"Weapon": str(kill.get("used_weapon") or kill.get("weapon") or ""),
|
||||
"VictimModel": _unit_to_model_name(str(kill.get("offended_unit") or "")),
|
||||
"KillerModel": _unit_to_model_name(str(kill.get("offender_unit") or "")),
|
||||
"VictimModel": _unit_to_model_name(str(kill.get("offended_unit_model_path") or kill.get("offended_unit") or "")),
|
||||
"KillerModel": _unit_to_model_name(str(kill.get("offender_unit_model_path") or kill.get("offender_unit") or "")),
|
||||
"crashed": bool(kill.get("crashed", False)),
|
||||
}
|
||||
if victim_pos:
|
||||
@@ -3544,8 +3569,8 @@ def _convert_local_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, An
|
||||
"Time": float(dmg.get("time") or 0.0),
|
||||
"OffenderID": _to_int(dmg.get("offender_uid"), 0),
|
||||
"OffendedID": _to_int(dmg.get("offended_uid"), 0),
|
||||
"OffenderModel": _unit_to_model_name(str(dmg.get("offender_unit") or "")),
|
||||
"OffendedModel": _unit_to_model_name(str(dmg.get("offended_unit") or "")),
|
||||
"OffenderModel": _unit_to_model_name(str(dmg.get("offender_unit_model_path") or dmg.get("offender_unit") or "")),
|
||||
"OffendedModel": _unit_to_model_name(str(dmg.get("offended_unit_model_path") or dmg.get("offended_unit") or "")),
|
||||
"Afire": bool(dmg.get("afire", False)),
|
||||
})
|
||||
|
||||
|
||||
+26
-2
@@ -41,6 +41,7 @@ from data_parser import (
|
||||
apply_vehicle_name_filters,
|
||||
normalize_name,
|
||||
)
|
||||
from spectra_replay_normalize import normalize_spectra_replay
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -597,6 +598,15 @@ def gate_entitle(required_tier: str):
|
||||
|
||||
async def permission_fail(interaction: discord.Interaction, error):
|
||||
"""Handle permission-related errors with appropriate embeds."""
|
||||
# discord.py dispatches a command error to BOTH the command's local
|
||||
# `@cmd.error` handler and the global `@tree.error` handler (see
|
||||
# app_commands/tree.py). Since both route here, guard against sending the
|
||||
# error embed twice for the same interaction — the second send would land
|
||||
# as a separate (and, after a public defer, differently-scoped) message.
|
||||
if interaction.extras.get("_permission_fail_handled"):
|
||||
return
|
||||
interaction.extras["_permission_fail_handled"] = True
|
||||
|
||||
lang = await guild_lang(interaction.guild_id) if interaction.guild_id else "en"
|
||||
if isinstance(error, BlacklistCheckFailure):
|
||||
reason = getattr(error, "reason", None)
|
||||
@@ -1251,7 +1261,7 @@ def transform_to_local_format(api_data: Dict[str, Any]) -> Optional[Dict[str, An
|
||||
logging.error("Invalid API data structure")
|
||||
return None
|
||||
|
||||
replay = api_data["completed"][0]
|
||||
replay = normalize_spectra_replay(api_data["completed"][0])
|
||||
|
||||
winner_winged = str(replay.get("winner") or "")
|
||||
loser_winged = str(replay.get("loser") or "")
|
||||
@@ -2151,14 +2161,19 @@ async def _get_recap(
|
||||
try:
|
||||
stat = cache_path.stat()
|
||||
if season_range["status"] == "completed":
|
||||
serve_from_cache = True
|
||||
# A completed season's card is only final if it was rendered AFTER
|
||||
# the season ended. Files rendered mid-season are stale snapshots
|
||||
# frozen at whatever point they were last viewed — re-render those.
|
||||
serve_from_cache = stat.st_mtime > season_range["end"]
|
||||
elif (time.time() - stat.st_mtime) < RECAP_TTL_SECONDS:
|
||||
serve_from_cache = True
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
ident = clan_id if mode == "squadron" else uid
|
||||
if not serve_from_cache:
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
t0 = time.monotonic()
|
||||
await _spawn_recap_render(
|
||||
mode,
|
||||
clan_id=clan_id,
|
||||
@@ -2171,6 +2186,15 @@ async def _get_recap(
|
||||
theme=theme,
|
||||
lang=lang,
|
||||
)
|
||||
logging.info(
|
||||
"(RECAP) %s id=%s season=%s theme=%s lang=%s result=fresh ms=%d",
|
||||
mode, ident, season, theme, lang, (time.monotonic() - t0) * 1000,
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
"(RECAP) %s id=%s season=%s theme=%s lang=%s result=cache",
|
||||
mode, ident, season, theme, lang,
|
||||
)
|
||||
|
||||
if not cache_path.exists():
|
||||
raise RecapError("recap file missing after render")
|
||||
|
||||
+27
-4
@@ -9,18 +9,33 @@ const DEPLOY_PATH = __dirname;
|
||||
// Both bots share one venv at BOTS/SHARED/.venv (built from SHARED/requirements.txt).
|
||||
const PY_INTERPRETER = `${DEPLOY_PATH}/../SHARED/.venv/bin/python`;
|
||||
|
||||
// Shared crash-loop governor. Without this, `autorestart` relaunches a process
|
||||
// that dies on startup forever (every restart_delay). Several apps here share
|
||||
// SHARED/.env + SHARED/.venv + the STORAGE volume, so one bad shared config can
|
||||
// make them crash-loop at once and peg all 8 cores until the box is unreachable
|
||||
// (and `pm2 resurrect` then reproduces it on every boot). With this, PM2 gives
|
||||
// up after max_restarts attempts that each fail to stay up min_uptime ms,
|
||||
// marking the app `errored` instead of hammering the CPU. exp_backoff grows the
|
||||
// delay between attempts (supersedes restart_delay during a crash loop).
|
||||
const RESTART_POLICY = {
|
||||
max_restarts: 10,
|
||||
min_uptime: 10000,
|
||||
exp_backoff_restart_delay: 200,
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
// Discord Bot
|
||||
{
|
||||
name: 'srebot',
|
||||
...RESTART_POLICY,
|
||||
script: 'start_bot.py',
|
||||
interpreter: PY_INTERPRETER,
|
||||
cwd: DEPLOY_PATH,
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: '16000M',
|
||||
max_memory_restart: '12000M',
|
||||
log_file: './logs/bot_combined.log',
|
||||
out_file: './logs/bot_out.log',
|
||||
error_file: './logs/bot_error.log',
|
||||
@@ -33,9 +48,13 @@ module.exports = {
|
||||
// API Server (reads SREBOT_API_PORT from .env)
|
||||
{
|
||||
name: 'srebot-api',
|
||||
script: 'server.js',
|
||||
interpreter: 'node',
|
||||
node_args: '--max-old-space-size=6144',
|
||||
...RESTART_POLICY,
|
||||
// Shell wrapper exports UV_THREADPOOL_SIZE at the OS level.
|
||||
// PM2's env: and env_file options don't propagate to the child
|
||||
// process's OS environ block (required by libuv for threadpool init).
|
||||
script: 'start_server.sh',
|
||||
interpreter: 'none',
|
||||
node_args: [],
|
||||
cwd: DEPLOY_PATH,
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
@@ -54,6 +73,7 @@ module.exports = {
|
||||
// Reads TSS_API_HOST/PORT from .env (default 127.0.0.1:6100).
|
||||
{
|
||||
name: 'tssbot-api',
|
||||
...RESTART_POLICY,
|
||||
script: PY_INTERPRETER,
|
||||
args: '-m web.main',
|
||||
interpreter: 'none',
|
||||
@@ -79,6 +99,7 @@ module.exports = {
|
||||
// Reads SREBOT_EXTERNAL_HOST/PORT/UPSTREAM_URL + STORAGE_VOL_PATH from .env.
|
||||
{
|
||||
name: 'relay-gateway',
|
||||
...RESTART_POLICY,
|
||||
script: PY_INTERPRETER,
|
||||
args: '-m relay_gateway.gateway',
|
||||
interpreter: 'none',
|
||||
@@ -100,6 +121,7 @@ module.exports = {
|
||||
// Reads SREBOT_WEBHOOK_PORT from .env.
|
||||
{
|
||||
name: 'srebot-webhook',
|
||||
...RESTART_POLICY,
|
||||
script: 'github_webhook_updater.py',
|
||||
interpreter: PY_INTERPRETER,
|
||||
cwd: DEPLOY_PATH,
|
||||
@@ -119,6 +141,7 @@ module.exports = {
|
||||
// Website (reads SREBOT_WEB_PORT from .env)
|
||||
{
|
||||
name: 'srebot-web',
|
||||
...RESTART_POLICY,
|
||||
script: 'server.js',
|
||||
cwd: `${DEPLOY_PATH}/web`,
|
||||
instances: 3,
|
||||
|
||||
@@ -127,6 +127,24 @@ def pull_and_restart(changed_files: list[str], before: str = '', after: str = ''
|
||||
processes_to_restart.append('srebot-api')
|
||||
logger.info("API server.js changed, will restart srebot-api")
|
||||
|
||||
# Relay gateway: restart if SHARED/relay_gateway/ files changed
|
||||
relay_gateway_changed = any(
|
||||
f.startswith('SHARED/relay_gateway/')
|
||||
for f in changed_files
|
||||
)
|
||||
if relay_gateway_changed:
|
||||
processes_to_restart.append('relay-gateway')
|
||||
logger.info("Relay gateway files changed, will restart relay-gateway")
|
||||
|
||||
# TSSBOT API: restart if TSSBOT/web/ files changed
|
||||
tssbot_api_changed = any(
|
||||
f.startswith('TSSBOT/web/')
|
||||
for f in changed_files
|
||||
)
|
||||
if tssbot_api_changed:
|
||||
processes_to_restart.append('tssbot-api')
|
||||
logger.info("TSSBOT API files changed, will restart tssbot-api")
|
||||
|
||||
# Web frontend: restart if SREBOT/web/ files changed
|
||||
web_changed = any(f.startswith('SREBOT/web/') for f in changed_files)
|
||||
if web_changed:
|
||||
|
||||
@@ -6,6 +6,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const zlib = require('zlib');
|
||||
const seasonsUtil = require('./web/utils/seasons');
|
||||
const http = require('http');
|
||||
|
||||
/** Parse a JSON column that may be gzip-compressed (Buffer) or plain text (string). */
|
||||
function parseJsonColumn(data) {
|
||||
@@ -88,13 +89,7 @@ function requireAdminBearer(req, res, next) {
|
||||
|
||||
app.use('/api', requireApiBearer);
|
||||
|
||||
// Rewrite /api/sre/* → /api/* so the gateway can forward channel-prefixed paths.
|
||||
app.use((req, res, next) => {
|
||||
if (req.url.startsWith('/api/sre')) {
|
||||
req.url = '/api' + req.url.slice(9);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
|
||||
// Readiness gate: heavy aggregation endpoints sit behind this so cold-start
|
||||
// requests don't pile up on the read connection while the DB is still opening
|
||||
@@ -192,6 +187,91 @@ function dedup(key, worker) {
|
||||
return promise;
|
||||
}
|
||||
|
||||
// ─── Heavy-aggregate cache (leaderboards) ───────────────────────────────
|
||||
// Leaderboard payloads cost 13-53 s to build (full season-window scans on the
|
||||
// shared heavyDb connection). Give them their own cache -- isolated from the
|
||||
// 100-entry responseCache churn -- with stale-while-revalidate serving + a
|
||||
// background warmer so requests never block on a cold/stale leaderboard.
|
||||
const aggregateCache = new Map();
|
||||
const aggregateInFlight = new Map();
|
||||
const LIVE_AGG_TTL = 5 * 60 * 1000;
|
||||
const COMPLETED_AGG_TTL = 24 * 60 * 60 * 1000;
|
||||
|
||||
function aggregateCacheTtl(dateFilters) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (dateFilters && dateFilters.endTimestamp && dateFilters.endTimestamp < now - 24 * 3600) {
|
||||
return COMPLETED_AGG_TTL;
|
||||
}
|
||||
return LIVE_AGG_TTL;
|
||||
}
|
||||
|
||||
function aggregateKey(prefix, dateFilters) {
|
||||
return `${prefix}_${dateFilters.startTimestamp ?? 'all'}_${dateFilters.endTimestamp ?? 'all'}`;
|
||||
}
|
||||
|
||||
function refreshAggregate(key, compute) {
|
||||
const existing = aggregateInFlight.get(key);
|
||||
if (existing) return existing;
|
||||
const promise = Promise.resolve().then(compute)
|
||||
.then(data => { aggregateCache.set(key, { data, timestamp: Date.now() }); return data; })
|
||||
.finally(() => aggregateInFlight.delete(key));
|
||||
aggregateInFlight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
function serveAggregateCached(key, dateFilters, compute, res, shape, opts = {}) {
|
||||
const entry = aggregateCache.get(key);
|
||||
if (entry) {
|
||||
const fresh = Date.now() - entry.timestamp < aggregateCacheTtl(dateFilters);
|
||||
res.json(shape(entry.data));
|
||||
if (!fresh) refreshAggregate(key, compute).catch(err =>
|
||||
log.warn('Background aggregate refresh failed', { key, error: err && err.message }));
|
||||
return;
|
||||
}
|
||||
if (opts.allowCompute === false) {
|
||||
return res.status(400).json({
|
||||
error: opts.filterError || 'A date filter (start_date/end_date/season/week) is required.',
|
||||
errorCode: 'FILTER_REQUIRED',
|
||||
});
|
||||
}
|
||||
refreshAggregate(key, compute)
|
||||
.then(data => { if (!res.headersSent) res.json(shape(data)); })
|
||||
.catch(err => {
|
||||
if (res.headersSent) return;
|
||||
if (err && err.status && err.body) return res.status(err.status).json(err.body);
|
||||
log.error('Aggregate computation failed', err, { key });
|
||||
res.status(500).json({ error: 'Database error', errorCode: opts.errorCode || 'DB_LEADERBOARD_FAILED' });
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Leaderboard warmer (Fix 3) ─────────────────────────────────────────
|
||||
function warmOneAggregate(routePath, startTs, endTs) {
|
||||
const path = `${routePath}?start_date=${startTs}&end_date=${endTs}&limit=1`;
|
||||
const req = http.get({ host: '127.0.0.1', port: PORT, path }, r => { r.resume(); });
|
||||
req.on('error', err => log.warn('Leaderboard warm request failed', { path, error: err.message }));
|
||||
req.setTimeout(120000, () => req.destroy());
|
||||
}
|
||||
|
||||
function warmLeaderboards() {
|
||||
let seasons;
|
||||
try { seasons = seasonsUtil.getSeasons(); } catch (e) {
|
||||
return log.warn('Warmer: getSeasons failed', { error: e.message });
|
||||
}
|
||||
const entries = Object.entries(seasons)
|
||||
.map(([name, r]) => ({ name, start: r.start, end: r.end, status: r.status }))
|
||||
.sort((a, b) => a.start - b.start);
|
||||
const current = entries.filter(s => s.status === 'in_progress');
|
||||
const completed = entries.filter(s => s.status === 'completed');
|
||||
const lastCompleted = completed.length ? [completed[completed.length - 1]] : [];
|
||||
const targets = [...current, ...lastCompleted];
|
||||
for (const s of targets) {
|
||||
warmOneAggregate('/api/leaderboard/players', s.start, s.end);
|
||||
warmOneAggregate('/api/leaderboard/vehicles', s.start, s.end);
|
||||
warmOneAggregate('/api/leaderboard/squadrons', s.start, s.end);
|
||||
}
|
||||
log.info('Leaderboard warmer tick', { windows: targets.map(t => t.name) });
|
||||
}
|
||||
|
||||
// Best-effort sync lookup of a squadron name → clan_id using the warm cache.
|
||||
// Returns null if the cache isn't populated yet or the input doesn't resolve.
|
||||
// Callers fall back to text matching when this returns null.
|
||||
@@ -3490,22 +3570,9 @@ app.get('/api/leaderboard/players', (req, res) => {
|
||||
return { ...full, players: full.players.slice(0, limit), limit, returned: Math.min(limit, full.players.length) };
|
||||
};
|
||||
|
||||
const cacheKey = `leaderboard_players_${start_date || 'all'}_${end_date || 'all'}`;
|
||||
const cached = getCachedResponse(cacheKey);
|
||||
if (cached) {
|
||||
log.info('Returning cached leaderboard response');
|
||||
return res.json(applyLimit(cached));
|
||||
}
|
||||
const cacheKey = aggregateKey('leaderboard_players', dateFilters);
|
||||
|
||||
if (!dateFilters.hasFilter) {
|
||||
return res.status(400).json({
|
||||
error: 'A date filter (start_date/end_date/season/week) is required for uncached all-time player leaderboard queries.',
|
||||
errorCode: 'FILTER_REQUIRED'
|
||||
});
|
||||
}
|
||||
|
||||
// Dedup: if this exact query is already running, wait for it
|
||||
dedup(cacheKey, () => new Promise((resolve, reject) => {
|
||||
const compute = () => new Promise((resolve, reject) => {
|
||||
// Step 1: Pure aggregation — no nick lookups, single scan
|
||||
const statsQuery = `
|
||||
SELECT
|
||||
@@ -3537,8 +3604,7 @@ app.get('/api/leaderboard/players', (req, res) => {
|
||||
heavyDb.all(statsQuery, queryParams, (err, statsRows) => {
|
||||
if (err) {
|
||||
log.error('Database error in player leaderboard aggregation', err);
|
||||
reject(err);
|
||||
return res.status(500).json({ error: 'Database error', errorCode: 'DB_PLAYER_LEADERBOARD_FAILED' });
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
log.info('Player leaderboard aggregation done', { rows: statsRows.length, ms: Date.now() - queryStart });
|
||||
@@ -3595,9 +3661,7 @@ app.get('/api/leaderboard/players', (req, res) => {
|
||||
totalMs: Date.now() - queryStart
|
||||
});
|
||||
|
||||
setCachedResponse(cacheKey, response);
|
||||
resolve(response);
|
||||
res.json(applyLimit(response));
|
||||
};
|
||||
|
||||
// Fallback for players not in any squadron — uses (UID, endtime_unix) index
|
||||
@@ -3620,10 +3684,12 @@ app.get('/api/leaderboard/players', (req, res) => {
|
||||
});
|
||||
});
|
||||
});
|
||||
})).catch(err => {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Database error', errorCode: 'DB_PLAYER_LEADERBOARD_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
serveAggregateCached(cacheKey, dateFilters, compute, res, applyLimit, {
|
||||
allowCompute: dateFilters.hasFilter,
|
||||
filterError: 'A date filter (start_date/end_date/season/week) is required for uncached all-time player leaderboard queries.',
|
||||
errorCode: 'DB_PLAYER_LEADERBOARD_FAILED',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3647,21 +3713,9 @@ app.get('/api/leaderboard/vehicles', (req, res) => {
|
||||
return { ...full, vehicles: full.vehicles.slice(0, limit), limit, returned: Math.min(limit, full.vehicles.length) };
|
||||
};
|
||||
|
||||
const cacheKey = `leaderboard_vehicles_${vehicle || 'all'}_${start_date || 'all'}_${end_date || 'all'}`;
|
||||
const cached = getCachedResponse(cacheKey);
|
||||
if (cached) {
|
||||
log.info('Returning cached vehicle leaderboard');
|
||||
return res.json(applyLimit(cached));
|
||||
}
|
||||
const cacheKey = `leaderboard_vehicles_${vehicle || 'all'}_${dateFilters.startTimestamp ?? 'all'}_${dateFilters.endTimestamp ?? 'all'}`;
|
||||
|
||||
if (!vehicle && !dateFilters.hasFilter) {
|
||||
return res.status(400).json({
|
||||
error: 'A date filter (start_date/end_date/season/week) or vehicle filter is required for uncached all-time vehicle leaderboard queries.',
|
||||
errorCode: 'FILTER_REQUIRED'
|
||||
});
|
||||
}
|
||||
|
||||
dedup(cacheKey, () => new Promise((resolve, reject) => {
|
||||
const compute = () => new Promise((resolve, reject) => {
|
||||
let statsQuery;
|
||||
let queryParams;
|
||||
|
||||
@@ -3703,8 +3757,7 @@ app.get('/api/leaderboard/vehicles', (req, res) => {
|
||||
heavyDb.all(statsQuery, queryParams, (err, vehicleRows) => {
|
||||
if (err) {
|
||||
log.error('Database error in vehicle leaderboard', err);
|
||||
reject(err);
|
||||
return res.status(500).json({ error: 'Database error', errorCode: 'DB_VEHICLE_LEADERBOARD_FAILED' });
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
// Nick/squadron lookup from squadron_members cache (instant)
|
||||
@@ -3751,9 +3804,7 @@ app.get('/api/leaderboard/vehicles', (req, res) => {
|
||||
};
|
||||
|
||||
log.info('Vehicle leaderboard complete', { vehiclesReturned: vehicleRows.length, totalMs: Date.now() - queryStart });
|
||||
setCachedResponse(cacheKey, response);
|
||||
resolve(response);
|
||||
res.json(applyLimit(response));
|
||||
};
|
||||
|
||||
if (uncoveredUids.length > 0) {
|
||||
@@ -3774,10 +3825,12 @@ app.get('/api/leaderboard/vehicles', (req, res) => {
|
||||
});
|
||||
});
|
||||
});
|
||||
})).catch(err => {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Database error', errorCode: 'DB_VEHICLE_LEADERBOARD_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
serveAggregateCached(cacheKey, dateFilters, compute, res, applyLimit, {
|
||||
allowCompute: Boolean(vehicle) || dateFilters.hasFilter,
|
||||
filterError: 'A date filter (start_date/end_date/season/week) or vehicle filter is required for uncached all-time vehicle leaderboard queries.',
|
||||
errorCode: 'DB_VEHICLE_LEADERBOARD_FAILED',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3802,14 +3855,9 @@ app.get('/api/leaderboard/squadrons', (req, res) => {
|
||||
};
|
||||
};
|
||||
|
||||
const cacheKey = `leaderboard_squadrons_${start_date || 'all'}_${end_date || 'all'}`;
|
||||
const cached = getCachedResponse(cacheKey);
|
||||
if (cached) {
|
||||
log.info('Returning cached squadron leaderboard');
|
||||
return res.json(applyLimit(cached));
|
||||
}
|
||||
const cacheKey = aggregateKey('leaderboard_squadrons', dateFilters);
|
||||
|
||||
dedup(cacheKey, () => new Promise((resolve, reject) => {
|
||||
const compute = () => new Promise((resolve, reject) => {
|
||||
const schemaQuery = `PRAGMA table_info(player_games_hist)`;
|
||||
|
||||
db.all(schemaQuery, (err, schema) => {
|
||||
@@ -3830,7 +3878,6 @@ app.get('/api/leaderboard/squadrons', (req, res) => {
|
||||
migration_needed: true,
|
||||
message: 'Squadron data not available - squadron_name column missing from database'
|
||||
};
|
||||
setCachedResponse(cacheKey, emptyResponse);
|
||||
return resolve(emptyResponse);
|
||||
}
|
||||
|
||||
@@ -4063,22 +4110,17 @@ app.get('/api/leaderboard/squadrons', (req, res) => {
|
||||
squadronsWithPoints: squadronsArray.filter(s => s.points.has_points_data).length
|
||||
});
|
||||
|
||||
setCachedResponse(cacheKey, response);
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
})).then(response => {
|
||||
if (!res.headersSent) res.json(applyLimit(response));
|
||||
}).catch(err => {
|
||||
if (res.headersSent) return;
|
||||
if (err && err.status && err.body) {
|
||||
return res.status(err.status).json(err.body);
|
||||
}
|
||||
log.error('Unhandled error in squadron leaderboard', err);
|
||||
res.status(500).json({ error: 'Database error occurred', errorCode: 'DB_SQUADRON_LEADERBOARD_FAILED' });
|
||||
});
|
||||
|
||||
serveAggregateCached(cacheKey, dateFilters, compute, res, applyLimit, {
|
||||
allowCompute: true,
|
||||
errorCode: 'DB_SQUADRON_LEADERBOARD_FAILED',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6039,6 +6081,11 @@ setTimeout(() => {
|
||||
}, 600000); // Every 10 minutes
|
||||
}, 150000); // Start after 2.5 min offset
|
||||
|
||||
// Warm hot leaderboard windows shortly after boot, then every 4 min.
|
||||
// The first tick waits for the DB-ready grace so heavy scans don't fight startup.
|
||||
setTimeout(warmLeaderboards, 20000);
|
||||
setInterval(warmLeaderboards, 4 * 60 * 1000);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`SREBOT Player API server running on port ${PORT}`);
|
||||
console.log(`Health check: http://localhost:${PORT}/health`);
|
||||
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Wrapper so libuv reads UV_THREADPOOL_SIZE from the OS environ block.
|
||||
# PM2's env:/env_file don't propagate to child processes on this system.
|
||||
export UV_THREADPOOL_SIZE=24
|
||||
exec node --max-old-space-size=6144 server.js
|
||||
+13
-1
@@ -32,4 +32,16 @@ week 5 (29.05 — 04.06) <t:1780038000:R> max BR 9.0
|
||||
week 6 (05.06 — 11.06) <t:1780642800:R> max BR 8.0
|
||||
week 7 (12.06 — 18.06) <t:1781247600:R> max BR 7.0
|
||||
week 8 (19.06 — 25.06) <t:1781852400:R> max BR 6.0
|
||||
until eos (26.06 — 30.06) <t:1782457200:R> max BR 5.0
|
||||
until eos (26.06 — 30.06) <t:1782457200:R> max BR 5.0
|
||||
|
||||
2026-IV
|
||||
|
||||
week 1 (01.07 — 07.07) <t:1782889200:R> max BR 14.7
|
||||
week 2 (08.07 — 14.07) <t:1783494000:R> max BR 12.0
|
||||
week 3 (15.07 — 21.07) <t:1784098800:R> max BR 10.7
|
||||
week 4 (22.07 — 28.07) <t:1784703600:R> max BR 9.7
|
||||
week 5 (29.07 — 04.08) <t:1785308400:R> max BR 8.7
|
||||
week 6 (05.08 — 11.08) <t:1785913200:R> max BR 7.3
|
||||
week 7 (12.08 — 18.08) <t:1786518000:R> max BR 6.3
|
||||
week 8 (19.08 — 25.08) <t:1787122800:R> max BR 5.7
|
||||
until eos (26.08 — 31.08) <t:1787727600:R> max BR 4.7
|
||||
|
||||
@@ -702,6 +702,7 @@
|
||||
"imgAxisWinRate": "Win Rate (%)",
|
||||
"imgStatPeakRating": "Peak rating",
|
||||
"imgStatRatingChange": "Rating change",
|
||||
"imgStatPlaceFinished": "Place finished",
|
||||
"imgStatTotalKills": "Total kills",
|
||||
"imgStatTotalDeaths": "Total deaths",
|
||||
"imgStatAssistsCaptures": "Assists / captures",
|
||||
|
||||
+25
-20
@@ -509,31 +509,30 @@ async function getCachedData(type) {
|
||||
return cache.data;
|
||||
}
|
||||
|
||||
// Initialize cache on startup — staggered to avoid overloading SQLite
|
||||
// Initialize only lightweight caches on the primary worker.
|
||||
// Player and vehicle leaderboards are too expensive to refresh in the background:
|
||||
// recent production runs took 140s+ and kept the API CPU saturated after callers timed out.
|
||||
async function initializeCache() {
|
||||
log.info('[CACHE] Initializing leaderboard cache (staggered)...');
|
||||
// Stats is lightest, do it first
|
||||
if (!IS_PRIMARY_WORKER) return;
|
||||
|
||||
log.info('[CACHE] Initializing lightweight leaderboard cache (staggered)...');
|
||||
await updateCache('stats');
|
||||
log.info('[CACHE] Stats cache ready');
|
||||
// Squadrons next
|
||||
await updateCache('squadrons');
|
||||
log.info('[CACHE] Squadrons cache ready');
|
||||
// Players is heaviest
|
||||
await updateCache('players');
|
||||
log.info('[CACHE] Players cache ready');
|
||||
// Vehicles last
|
||||
await updateCache('vehicles');
|
||||
log.info('[CACHE] Vehicles cache ready — all caches populated!');
|
||||
}
|
||||
|
||||
// Auto-refresh caches staggered to avoid hammering the API all at once
|
||||
const cacheTypes = ['players', 'vehicles', 'stats', 'squadrons'];
|
||||
cacheTypes.forEach((type, i) => {
|
||||
setInterval(async () => {
|
||||
log.debug(`[CACHE] Auto-refreshing ${type} cache...`);
|
||||
await updateCache(type).catch(log.error);
|
||||
}, CACHE_DURATION + i * 15000); // stagger each by 15s
|
||||
});
|
||||
// Auto-refresh only lightweight caches from the primary worker. Heavy caches are
|
||||
// refreshed lazily by request so background work cannot pin SQLite indefinitely.
|
||||
if (IS_PRIMARY_WORKER) {
|
||||
const cacheTypes = ['stats', 'squadrons'];
|
||||
cacheTypes.forEach((type, i) => {
|
||||
setInterval(async () => {
|
||||
log.debug(`[CACHE] Auto-refreshing ${type} cache...`);
|
||||
await updateCache(type).catch(log.error);
|
||||
}, CACHE_DURATION + i * 15000);
|
||||
});
|
||||
}
|
||||
|
||||
// Log search cache statistics every 10 minutes
|
||||
setInterval(() => {
|
||||
@@ -1045,7 +1044,10 @@ app.get('/squadron/:clan_id/recap/:season.png', async (req, res) => {
|
||||
try {
|
||||
const stat = await fsp.stat(cachePath);
|
||||
if (range.status === 'completed') {
|
||||
serveFromCache = true;
|
||||
// Only trust a completed-season cache if it was rendered after the
|
||||
// season ended; mid-season files are stale snapshots (range.end is
|
||||
// epoch seconds, mtimeMs is ms).
|
||||
serveFromCache = stat.mtimeMs > range.end * 1000;
|
||||
} else if (Date.now() - stat.mtimeMs < RECAP_TTL_MS) {
|
||||
serveFromCache = true;
|
||||
}
|
||||
@@ -1113,7 +1115,10 @@ app.get('/players/:uid/recap/:season.png', async (req, res) => {
|
||||
try {
|
||||
const stat = await fsp.stat(cachePath);
|
||||
if (range.status === 'completed') {
|
||||
serveFromCache = true;
|
||||
// Only trust a completed-season cache if it was rendered after the
|
||||
// season ended; mid-season files are stale snapshots (range.end is
|
||||
// epoch seconds, mtimeMs is ms).
|
||||
serveFromCache = stat.mtimeMs > range.end * 1000;
|
||||
} else if (Date.now() - stat.mtimeMs < RECAP_TTL_MS) {
|
||||
serveFromCache = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user