diff --git a/BOT/render_replay.py b/BOT/render_replay.py index 31c11bd..77ed8b8 100644 --- a/BOT/render_replay.py +++ b/BOT/render_replay.py @@ -907,6 +907,46 @@ def _capture_sort_key(name: str) -> tuple[int, str]: return (int(digits) if digits else 999, name) +def _capture_area_number(name: str) -> int | None: + digits = "".join(ch for ch in name if ch.isdigit()) + return int(digits) if digits else None + + +def _mission_briefing_zone_numbers(mission_def: dict | None) -> set[int]: + """Extract cap numbers referenced by the mission briefing map icons.""" + if not isinstance(mission_def, dict): + return set() + briefing = mission_def.get("mission_settings", {}).get("briefing") + if not isinstance(briefing, dict): + return set() + + out: set[int] = set() + + def walk(node: Any) -> None: + if isinstance(node, dict): + target = node.get("target") + icon_type = str(node.get("icontype", "")).lower() + if isinstance(target, str): + target_l = target.lower() + if "zone" in target_l and ( + "basezone" in icon_type + or "capture" in icon_type + or "zone" in icon_type + or "briefing_zone" in target_l + ): + digits = "".join(ch for ch in target if ch.isdigit()) + if digits: + out.add(int(digits)) + for value in node.values(): + walk(value) + elif isinstance(node, list): + for item in node: + walk(item) + + walk(briefing) + return out + + def _capture_radius_from_tm(tm: list) -> float: try: a = np.array(tm[0], dtype=np.float64) @@ -953,6 +993,15 @@ def resolve_capture_areas(mission_def: dict | None, mission_def_path: Path | Non if not capture_names: return [] + briefing_zone_numbers = _mission_briefing_zone_numbers(mission_def) + if briefing_zone_numbers: + filtered = [ + name for name in capture_names + if _capture_area_number(name) in briefing_zone_numbers + ] + if filtered: + capture_names = filtered + # Keep one variant per area base (prefer arcade, then realistic, hardcore). suffix_prio = ["_arcade", "_realistic", "_hardcore", "_simulator", ""] groups: dict[str, dict[str, str]] = {} @@ -1032,6 +1081,35 @@ def resolve_capture_areas(mission_def: dict | None, mission_def_path: Path | Non return out +def _capture_areas_from_zone_geometry(zone_geometry: dict | None) -> list[dict]: + """Build capture area geometry from replay zone centers when mission .blk lacks areas.""" + if not isinstance(zone_geometry, dict): + return [] + out: list[dict] = [] + for letter in sorted(zone_geometry.keys()): + zdata = zone_geometry.get(letter) + if not isinstance(zdata, dict): + continue + center = zdata.get("center") + if not isinstance(center, list) or len(center) < 3: + continue + try: + cx = float(center[0]) + cz = float(center[2]) + radius = float(zdata.get("radius", 0.0) or 0.0) + except Exception: + continue + out.append({ + "name": f"replay_zone_{letter}", + "type": "Sphere", + "x": cx, + "z": cz, + "radius": radius, + "tm": None, + }) + return out + + def _world_to_map_px(x: float, z: float, x0: float, z0: float, xr: float, zr: float, canvas: int) -> tuple[int, int]: px = int(round((x - x0) / xr * canvas)) py = int(round((z0 + zr - z) / zr * canvas)) @@ -1471,7 +1549,7 @@ def load_map_image(level_path: str, level_def: dict | None, def load_level_coords(level_path: str, session_id: int = 0) -> dict | None: - """Load tankMapCoord0/1 from local .blkx files (including datamine clone).""" + """Load local level .blkx data, including full-map-only air levels.""" del session_id # kept in signature for call-site compatibility stem = Path(level_path).stem candidates = [ @@ -1485,10 +1563,13 @@ def load_level_coords(level_path: str, session_id: int = 0) -> dict | None: data = _load_json_file(blkx) if not isinstance(data, dict): continue - if "tankMapCoord0" in data and "tankMapCoord1" in data: + if ( + ("tankMapCoord0" in data and "tankMapCoord1" in data) + or ("mapCoord0" in data and "mapCoord1" in data) + ): print(f" LevelDef : {blkx}") return data - print(f" LevelDef missing tank coords: {blkx.name}") + print(f" LevelDef missing map coords: {blkx.name}") return None @@ -1514,6 +1595,22 @@ def select_tank_coords(level_def: dict, use_alt_map_coord: bool raise ValueError("LevelDef missing usable tank map coordinates") +def select_map_coords(level_def: dict) -> tuple[list[float], list[float], str]: + """Pick full tactical-map coordinate bounds from a level definition.""" + mc0 = level_def.get("mapCoord0") + mc1 = level_def.get("mapCoord1") + if isinstance(mc0, list) and mc0 and isinstance(mc0[0], list): + mc0 = mc0[0] + if isinstance(mc1, list) and mc1 and isinstance(mc1[0], list): + mc1 = mc1[0] + if ( + isinstance(mc0, list) and len(mc0) >= 2 + and isinstance(mc1, list) and len(mc1) >= 2 + ): + return mc0, mc1, "mapCoord" + raise ValueError("LevelDef missing usable full map coordinates") + + # ── Coordinate transform ─────────────────────────────────────────────────────── @@ -2752,6 +2849,8 @@ def render_gob( tc0, tc1 = _clamp_bounds_to_base(tc0, tc1, base_tc0, base_tc1) print(f"ok ({coord_src}) X=[{tc0[0]}, {tc1[0]}] Z=[{tc0[1]}, {tc1[1]}]") capture_areas = resolve_capture_areas(mission_def, mission_def_path, battle_type) + if not capture_areas: + capture_areas = _capture_areas_from_zone_geometry(d.get("ZoneGeometry")) if capture_areas: print("Capture : " + ", ".join( f"{c['name']}({c['type']})" for c in capture_areas @@ -3129,6 +3228,26 @@ def _position_at_time(path: list[dict[str, float]], time_ms: float) -> dict[str, return prev +def _zone_geometry_from_raw_zones(zones_src: Any) -> dict[str, dict]: + if not isinstance(zones_src, dict): + return {} + out: dict[str, dict] = {} + for letter, zdata in zones_src.items(): + if not isinstance(letter, str) or not isinstance(zdata, dict): + continue + center = zdata.get("center") + if not isinstance(center, list) or len(center) < 3: + continue + try: + out[letter] = { + "center": [float(center[0]), float(center[1]), float(center[2])], + "radius": float(zdata.get("radius", 0.0) or 0.0), + } + except Exception: + continue + return out + + def _convert_ws_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, Any]: players_src = replay.get("players") or {} if not isinstance(players_src, dict): @@ -3266,6 +3385,7 @@ def _convert_ws_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, Any]: for letter, zdata in zones_src.items() if isinstance(zdata, dict) } if isinstance(zones_src, dict) else {} + zone_geometry_out = _zone_geometry_from_raw_zones(zones_src) tickets_out = tickets_src if isinstance(tickets_src, dict) else {} team_slot_names = {} if winner_team: @@ -3286,6 +3406,7 @@ def _convert_ws_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, Any]: "Kills": kills_out, "DamageReports": damages_out, "Zones": zones_out, + "ZoneGeometry": zone_geometry_out, "Tickets": tickets_out, "WinnerSlot": winner_team, "TeamSlotNames": team_slot_names, @@ -3414,6 +3535,7 @@ def _convert_local_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, An # Zones (letter -> cap timeline, positive == slot 1), tickets (slot -> timeline), # the winning slot, and slot -> name all stay in slot space for the readers. zones_out = replay.get("zones") if isinstance(replay.get("zones"), dict) else {} + zone_geometry_out = _zone_geometry_from_raw_zones(replay.get("zones")) tickets_out = replay.get("tickets") if isinstance(replay.get("tickets"), dict) else {} team_slot_names = replay.get("team_slot_names") if isinstance(replay.get("team_slot_names"), dict) else {} @@ -3430,6 +3552,7 @@ def _convert_local_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, An "Kills": kills_out, "DamageReports": damages_out, "Zones": zones_out, + "ZoneGeometry": zone_geometry_out, "Tickets": tickets_out, "WinnerSlot": _to_int(replay.get("winner_team_slot"), 0), "TeamSlotNames": team_slot_names, @@ -3496,7 +3619,7 @@ def _vehicle_icon_key(model_name: str) -> str: def _vehicle_mini_icon(model_name: str) -> str | None: """Return the mini icon filename for aircraft (e.g. 'spitfire_ix_ico'), or None.""" internal = model_name.split("/")[-1] - mini_path = Path(__file__).resolve().parent / "ICONS" / "MINIS" / f"{internal}_ico.png" + mini_path = MINIS_DIR / f"{internal}_ico.png" if mini_path.exists(): return f"mini:{internal}_ico" return None @@ -3575,41 +3698,46 @@ def export_replay_json(replay_path: Path) -> dict: and e.get("ModelName", "").startswith("tankModels/") and e.get("Path")] capture_areas = resolve_capture_areas(mission_def, mission_def_path, battle_type) + if not capture_areas: + capture_areas = _capture_areas_from_zone_geometry(d.get("ZoneGeometry")) if level_data: - base_c0, base_c1, _ = select_tank_coords(level_data, use_alt_map_coord) - tank_map_coords = {"x0": base_c0[0], "z0": base_c0[1], "x1": base_c1[0], "z1": base_c1[1]} - c0, c1, _ = resolve_world_bounds( - level_data, - use_alt_map_coord, - mission_def, - mission_def_path, - battle_type, - ) - c0, c1, _ = _fit_world_bounds_to_ground_activity( - c0, c1, "json", - mission_def, mission_def_path, battle_type, - ground_entities_for_bounds, - ) - c0, c1 = _expand_bounds_by_pixels(c0, c1, canvas=CANVAS_MIN, pad_px=MAP_PAD_PX) - c0, c1 = _clamp_bounds_to_base(c0, c1, base_c0, base_c1) - level_coords = {"x0": c0[0], "z0": c0[1], "x1": c1[0], "z1": c1[1]} + try: + base_c0, base_c1, _ = select_tank_coords(level_data, use_alt_map_coord) + tank_map_coords = {"x0": base_c0[0], "z0": base_c0[1], "x1": base_c1[0], "z1": base_c1[1]} + c0, c1, _ = resolve_world_bounds( + level_data, + use_alt_map_coord, + mission_def, + mission_def_path, + battle_type, + ) + c0, c1, _ = _fit_world_bounds_to_ground_activity( + c0, c1, "json", + mission_def, mission_def_path, battle_type, + ground_entities_for_bounds, + ) + c0, c1 = _expand_bounds_by_pixels(c0, c1, canvas=CANVAS_MIN, pad_px=MAP_PAD_PX) + c0, c1 = _clamp_bounds_to_base(c0, c1, base_c0, base_c1) + level_coords = {"x0": c0[0], "z0": c0[1], "x1": c1[0], "z1": c1[1]} + except ValueError: + mc0, mc1, _ = select_map_coords(level_data) + level_coords = {"x0": mc0[0], "z0": mc0[1], "x1": mc1[0], "z1": mc1[1]} else: level_coords = {"x0": 0, "z0": 0, "x1": 4096, "z1": 4096} map_coords = None full_map_level = None - if level_data and "mapCoord0" in level_data and "mapCoord1" in level_data: - mc0 = level_data["mapCoord0"] - mc1 = level_data["mapCoord1"] - if isinstance(mc0[0], list): - mc0 = mc0[0] - if isinstance(mc1[0], list): - mc1 = mc1[0] + if level_data: + try: + mc0, mc1, _ = select_map_coords(level_data) + except ValueError: + mc0 = mc1 = None + if level_data and mc0 is not None and mc1 is not None: map_coords = {"x0": mc0[0], "z0": mc0[1], "x1": mc1[0], "z1": mc1[1]} stem = Path(level_path).stem if level_path else "" custom = level_data.get("customLevelMap", "") if custom: - full_map_level = custom.rstrip("*") + full_map_level = _clean_map_key(custom) else: full_map_level = stem + "_map" if stem else None diff --git a/web/server.js b/web/server.js index b3405fb..7fd8fb4 100644 --- a/web/server.js +++ b/web/server.js @@ -2034,9 +2034,19 @@ app.get('/api/match/minimap/:level', (req, res) => { if (!level || !/^[a-zA-Z0-9_]+$/.test(level)) { return res.status(400).json({ error: 'Invalid level name' }); } - const suffix = req.query.type === 'full' ? '.png' : '_tankmap.png'; - const minimapPath = path.join(__dirname, '..', '..', 'SHARED', 'MAPS', 'MINIMAPS', level + suffix); - if (!fs.existsSync(minimapPath)) { + const minimapsDir = path.join(__dirname, '..', '..', 'SHARED', 'MAPS', 'MINIMAPS'); + const names = req.query.type === 'full' + ? [level + '.png', level + '_map.png'] + : [level + '_tankmap.png', level + '.png', level + '_map.png']; + let minimapPath = null; + for (const name of [...new Set(names)]) { + const candidate = path.join(minimapsDir, name); + if (fs.existsSync(candidate)) { + minimapPath = candidate; + break; + } + } + if (!minimapPath) { return res.status(404).json({ error: 'Minimap not found' }); } res.sendFile(minimapPath, {