diff --git a/ai/tasks/done/TASK-249-wire-map-images-and-steam-brand-icon.md b/ai/tasks/done/TASK-249-wire-map-images-and-steam-brand-icon.md new file mode 100644 index 0000000..5fb7e5f --- /dev/null +++ b/ai/tasks/done/TASK-249-wire-map-images-and-steam-brand-icon.md @@ -0,0 +1,120 @@ +# TASK-249 - Wire map images and Steam brand icon + +## Summary + +This task connects the new local map images to current match and historical match detail using a shared frontend resolver, and wires the new Steam brand icon into the external profile buttons. + +No map or brand asset files were modified. + +## Files Read First + +- `frontend/partida-actual.html` +- `frontend/assets/js/partida-actual.js` +- `frontend/historico-partida.html` +- `frontend/assets/js/historico-partida.js` +- `frontend/assets/css/historico.css` +- `frontend/assets/img/maps/` +- `frontend/assets/img/brands/` + +## Problem + +The frontend was still using narrow map-name heuristics and did not know how to resolve the new map image naming pattern consistently across: + +- current match +- historical match detail + +The Steam external profile button also had no brand icon wired even though the asset already existed locally. + +## Decision + +Create one shared client-side resolver instead of duplicating ad hoc logic in each page. + +Rules: + +- normalize map ids, layer ids and pretty names aggressively +- resolve to local assets using the `mapid-environment.webp` pattern when possible +- keep a safe fallback image path +- do not modify physical assets + +## Asset Coverage Detected + +Detected local coverage in `frontend/assets/img/maps/`: + +- `carentan`: day, dusk, night, rain +- `driel`: dawn, day, night +- `elalamein`: day, dusk +- `elsenbornridge`: dawn, day, dusk, night +- `foy`: day, night +- `hill400`: day, dusk, night +- `hurtgenforest`: day, night +- `junobeach`: dawn, day, night +- `kharkov`: day, night +- `kursk`: day, night +- `mortain`: day, dusk, night, overcast +- `omahabeach`: day, dusk +- `purpleheartlane`: dawn, day, night, rain +- `remagen`: day, night +- `smolensk`: day, dusk, night +- `stalingrad`: day, dusk, night, overcast +- `stmariedumont`: day, night, rain +- `stmereeglise`: dawn, day, night +- `tobruk`: dawn, day, dusk +- `utahbeach`: day, night +- fallback assets: `unknown-day.webp`, `unknown.webp` + +## Implementation + +### Shared map resolver + +Added `frontend/assets/js/map-image-resolver.js` with: + +- canonical map aliases +- environment aliases +- compact normalization of layer ids and display names +- deterministic fallback selection + +Examples resolved correctly: + +- `junobeachwarfare` -> `./assets/img/maps/junobeach-day.webp` +- `Juno Beach Warfare` -> `./assets/img/maps/junobeach-day.webp` +- `utahbeach` -> `./assets/img/maps/utahbeach-day.webp` +- `Driel` -> `./assets/img/maps/driel-day.webp` +- `st marie du mont warfare` -> `./assets/img/maps/stmariedumont-day.webp` + +### Current match + +- `partida-actual.html` now loads the shared resolver before the page script. +- `partida-actual.js` now resolves map assets from layer id, map id and pretty names instead of a short hardcoded map list. + +### Historical match detail + +- `historico-partida.html` now loads the shared resolver. +- `historico-partida.js` now resolves map images from `match_id`, `map.id`, `map.name` and `map.pretty_name`. +- It keeps the existing fallback behavior if no match is found. + +### Steam brand icon + +- Wired the Steam profile button to `./assets/img/brands/steam.png`. +- Added `onerror="this.remove();"` to brand icons so a missing file does not leave a broken image. + +## Validation + +Executed: + +- `node --check frontend/assets/js/map-image-resolver.js` +- `node --check frontend/assets/js/partida-actual.js` +- `node --check frontend/assets/js/historico-partida.js` + +Static/runtime validation performed: + +- confirmed `historico-partida.html` does not load `current-match-weapon-icons.js` +- confirmed `partida-actual.html` still loads `current-match-weapon-icons.js` +- confirmed resolver maps `junobeachwarfare` to `junobeach-day.webp` +- confirmed resolver maps `utahbeach`, `driel` and `st marie du mont warfare` to existing local assets +- confirmed Steam button now points to the brand icon asset when present + +## Notes + +- No backend changes were required for this task. +- No asset files were moved, renamed or modified. +- No weapon or clan asset path was touched. diff --git a/frontend/assets/js/historico-partida.js b/frontend/assets/js/historico-partida.js index 99d9cc9..c1ee488 100644 --- a/frontend/assets/js/historico-partida.js +++ b/frontend/assets/js/historico-partida.js @@ -54,7 +54,7 @@ const EXTERNAL_PROFILE_BRANDS = Object.freeze({ }), steam: Object.freeze({ label: "Steam", - logoSrc: "", + logoSrc: "./assets/img/brands/steam.png", }), }); @@ -601,7 +601,7 @@ function renderExternalProfilesSection(player) { ${ brand.logoSrc - ? `` + ? `` : "" } ${escapeHtml(brand.label)} @@ -807,33 +807,21 @@ function resolveMatchFactions(item, mapName) { } function resolveMapImagePath(item, mapName) { - const normalizedMap = normalizeLookupText( - `${item.map?.name || ""} ${item.map?.pretty_name || ""} ${mapName || ""}`, - ).replaceAll(" ", ""); - const mapAssetByKey = { - carentan: "carentan-day.webp", - driel: "driel-day.webp", - elalamein: "elalamein-day.webp", - elsenbornridge: "elsenbornridge-day.webp", - foy: "foy-day.webp", - hill400: "hill400-day.webp", - hurtgenforest: "hurtgenforest-day.webp", - kharkov: "kharkov-day.webp", - kursk: "kursk-day.webp", - mortain: "mortain-day.webp", - omahabeach: "omahabeach-day.webp", - purpleheartlane: "purpleheartlane-rain.webp", - smolensk: "smolensk-day.webp", - stmariedumont: "stmariedumont-day.webp", - stmereeglise: "stmereeglise-day.webp", - tobrukdawn: "tobruk-dawn.webp", - tobruk: "tobruk-day.webp", - utahbeach: "utahbeach-day.webp", - }; - const matchedKey = Object.keys(mapAssetByKey).find((key) => - normalizedMap.includes(key), + const resolver = globalThis.HLL_VIETNAM_MAP_IMAGES?.resolveMapImageAsset; + if (typeof resolver !== "function") { + return ""; + } + return ( + resolver({ + candidates: [ + item.match_id, + item.map?.id, + item.map?.name, + item.map?.pretty_name, + mapName, + ], + })?.src || "" ); - return matchedKey ? `./assets/img/maps/${mapAssetByKey[matchedKey]}` : ""; } function normalizeLookupText(value) { diff --git a/frontend/assets/js/map-image-resolver.js b/frontend/assets/js/map-image-resolver.js new file mode 100644 index 0000000..a561d74 --- /dev/null +++ b/frontend/assets/js/map-image-resolver.js @@ -0,0 +1,182 @@ +(() => { + const MAP_IMAGE_VARIANTS = Object.freeze({ + carentan: Object.freeze(["day", "dusk", "night", "rain"]), + driel: Object.freeze(["dawn", "day", "night"]), + elalamein: Object.freeze(["day", "dusk"]), + elsenbornridge: Object.freeze(["dawn", "day", "dusk", "night"]), + foy: Object.freeze(["day", "night"]), + hill400: Object.freeze(["day", "dusk", "night"]), + hurtgenforest: Object.freeze(["day", "night"]), + junobeach: Object.freeze(["dawn", "day", "night"]), + kharkov: Object.freeze(["day", "night"]), + kursk: Object.freeze(["day", "night"]), + mortain: Object.freeze(["day", "dusk", "night", "overcast"]), + omahabeach: Object.freeze(["day", "dusk"]), + purpleheartlane: Object.freeze(["dawn", "day", "night", "rain"]), + remagen: Object.freeze(["day", "night"]), + smolensk: Object.freeze(["day", "dusk", "night"]), + stalingrad: Object.freeze(["day", "dusk", "night", "overcast"]), + stmariedumont: Object.freeze(["day", "night", "rain"]), + stmereeglise: Object.freeze(["dawn", "day", "night"]), + tobruk: Object.freeze(["dawn", "day", "dusk"]), + utahbeach: Object.freeze(["day", "night"]), + unknown: Object.freeze(["day"]), + }); + + const MAP_ALIASES = Object.freeze({ + carentan: Object.freeze(["carentan"]), + driel: Object.freeze(["driel"]), + elalamein: Object.freeze(["elalamein", "el alamein"]), + elsenbornridge: Object.freeze(["elsenbornridge", "elsenborn ridge"]), + foy: Object.freeze(["foy"]), + hill400: Object.freeze(["hill400", "hill 400"]), + hurtgenforest: Object.freeze(["hurtgenforest", "hurtgen forest", "hurtgen"]), + junobeach: Object.freeze(["junobeach", "juno beach"]), + kharkov: Object.freeze(["kharkov"]), + kursk: Object.freeze(["kursk"]), + mortain: Object.freeze(["mortain"]), + omahabeach: Object.freeze(["omahabeach", "omaha beach"]), + purpleheartlane: Object.freeze(["purpleheartlane", "purple heart lane"]), + remagen: Object.freeze(["remagen"]), + smolensk: Object.freeze(["smolensk"]), + stalingrad: Object.freeze(["stalingrad"]), + stmariedumont: Object.freeze([ + "stmariedumont", + "st marie du mont", + "saint marie du mont", + "sainte marie du mont", + ]), + stmereeglise: Object.freeze([ + "stmereeglise", + "st mere eglise", + "saint mere eglise", + "sainte mere eglise", + ]), + tobruk: Object.freeze(["tobruk"]), + utahbeach: Object.freeze(["utahbeach", "utah beach"]), + }); + + const ENVIRONMENT_ALIASES = Object.freeze({ + day: Object.freeze(["day"]), + night: Object.freeze(["night"]), + dawn: Object.freeze(["dawn"]), + dusk: Object.freeze(["dusk"]), + overcast: Object.freeze(["overcast"]), + rain: Object.freeze(["rain"]), + }); + + const DEFAULT_ENVIRONMENT_PRIORITY = Object.freeze([ + "day", + "dawn", + "dusk", + "overcast", + "rain", + "night", + ]); + + const ORDERED_MAP_MATCHERS = Object.freeze( + Object.entries(MAP_ALIASES) + .flatMap(([mapId, aliases]) => + aliases.map((alias) => ({ + mapId, + compactAlias: compactLookup(alias), + })), + ) + .sort((left, right) => right.compactAlias.length - left.compactAlias.length), + ); + + function normalizeLookup(value) { + return String(value || "") + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); + } + + function compactLookup(value) { + return normalizeLookup(value).replace(/\s+/g, ""); + } + + function resolveMapId(candidates) { + const normalizedCandidates = candidates + .map((candidate) => compactLookup(candidate)) + .filter(Boolean); + for (const candidate of normalizedCandidates) { + for (const matcher of ORDERED_MAP_MATCHERS) { + if (candidate.includes(matcher.compactAlias)) { + return matcher.mapId; + } + } + } + return null; + } + + function resolveEnvironment(candidates) { + const combined = candidates + .map((candidate) => normalizeLookup(candidate)) + .filter(Boolean) + .join(" "); + if (!combined) { + return null; + } + for (const [environment, aliases] of Object.entries(ENVIRONMENT_ALIASES)) { + for (const alias of aliases) { + if (combined.includes(alias)) { + return environment; + } + } + } + return null; + } + + function resolveVariant(mapId, requestedEnvironment) { + const variants = MAP_IMAGE_VARIANTS[mapId]; + if (!Array.isArray(variants) || variants.length === 0) { + return null; + } + if (requestedEnvironment && variants.includes(requestedEnvironment)) { + return requestedEnvironment; + } + for (const candidate of DEFAULT_ENVIRONMENT_PRIORITY) { + if (variants.includes(candidate)) { + return candidate; + } + } + return variants[0] || null; + } + + function buildUnknownResult() { + const environment = resolveVariant("unknown", "day") || "day"; + return { + mapId: "unknown", + environment, + src: `./assets/img/maps/unknown-${environment}.webp`, + matched: false, + }; + } + + function resolveMapImageAsset(options = {}) { + const candidates = Array.isArray(options.candidates) ? options.candidates : []; + const mapId = resolveMapId(candidates); + if (!mapId) { + return buildUnknownResult(); + } + const requestedEnvironment = resolveEnvironment(candidates); + const environment = resolveVariant(mapId, requestedEnvironment); + if (!environment) { + return buildUnknownResult(); + } + return { + mapId, + environment, + src: `./assets/img/maps/${mapId}-${environment}.webp`, + matched: true, + }; + } + + globalThis.HLL_VIETNAM_MAP_IMAGES = Object.freeze({ + normalizeLookup, + resolveMapImageAsset, + }); +})(); diff --git a/frontend/assets/js/partida-actual.js b/frontend/assets/js/partida-actual.js index 91f35bd..a10ccdc 100644 --- a/frontend/assets/js/partida-actual.js +++ b/frontend/assets/js/partida-actual.js @@ -600,33 +600,21 @@ function renderMapHero(data, mapName, nodes) { } function resolveMapImagePath(data, mapName) { - const normalizedMap = normalizeLookupText( - `${data.map_id || ""} ${data.map || ""} ${data.map_pretty_name || ""} ${mapName || ""}`, - ).replaceAll(" ", ""); - const mapAssetByKey = { - carentan: "carentan-day.webp", - driel: "driel-day.webp", - elalamein: "elalamein-day.webp", - elsenbornridge: "elsenbornridge-day.webp", - foy: "foy-day.webp", - hill400: "hill400-day.webp", - hurtgenforest: "hurtgenforest-day.webp", - kharkov: "kharkov-day.webp", - kursk: "kursk-day.webp", - mortain: "mortain-day.webp", - omahabeach: "omahabeach-day.webp", - purpleheartlane: "purpleheartlane-rain.webp", - smolensk: "smolensk-day.webp", - stmariedumont: "stmariedumont-day.webp", - stmereeglise: "stmereeglise-day.webp", - tobrukdawn: "tobruk-dawn.webp", - tobruk: "tobruk-day.webp", - utahbeach: "utahbeach-day.webp", - }; - const matchedKey = Object.keys(mapAssetByKey).find((key) => - normalizedMap.includes(key), + const resolver = globalThis.HLL_VIETNAM_MAP_IMAGES?.resolveMapImageAsset; + if (typeof resolver !== "function") { + return ""; + } + return ( + resolver({ + candidates: [ + data.layer_id, + data.map_id, + data.map, + data.map_pretty_name, + mapName, + ], + })?.src || "" ); - return matchedKey ? `./assets/img/maps/${mapAssetByKey[matchedKey]}` : ""; } function resolveTrustedScoreboardUrl(data) { diff --git a/frontend/historico-partida.html b/frontend/historico-partida.html index c792edf..88e67e0 100644 --- a/frontend/historico-partida.html +++ b/frontend/historico-partida.html @@ -173,6 +173,7 @@ + diff --git a/frontend/partida-actual.html b/frontend/partida-actual.html index ea32214..d6241cf 100644 --- a/frontend/partida-actual.html +++ b/frontend/partida-actual.html @@ -142,6 +142,7 @@ +