// Asset URL resolution against data/assets.json (docs/ASSETS.md). Pure: every function takes the // manifest object (or null) and returns a URL string or null — callers draw their own fallback // (glyph, CSS shape) when null. Only URLs present in the manifest are ever returned, so the client // never requests files the asset pipeline did not produce (no 404 noise in the console). const str = (v) => (typeof v === 'string' && v ? v : null); const obj = (v) => (v && typeof v === 'object' ? v : null); /** @param {any} m manifest @param {string} key 'group/key' */ export function uiUrl(m, key) { return str(obj(obj(m)?.ui)?.[key]); } /** * Operator avatar for a chess record (golden → E2 art when present). * @param {any} m manifest * @param {any} chess chess.json record (or { assets: { avatar } }) */ export function chessAvatarUrl(m, chess) { const chars = obj(obj(m)?.chars); const id = str(chess?.assets?.avatar) || str(chess?.charId); if (!chars || !id) return null; if (id.endsWith('_2') && !chars[id]) { const base = chars[id.slice(0, -2)]; return str(base?.avatarE2) || str(base?.avatar); } return str(chars[id]?.avatar) || (chess?.charId ? str(chars[chess.charId]?.avatar) : null); } /** * Half-body portrait (180×360) for a chess record (golden → E2 portrait when present). * @param {any} m * @param {any} chess */ export function chessPortraitUrl(m, chess) { const chars = obj(obj(m)?.chars); const id = str(chess?.assets?.portrait); if (!chars) return null; if (id) { if (id.endsWith('_2')) { const base = chars[id.slice(0, -2)]; if (base) return str(base.portraitE2) || str(base.portrait); } if (id.endsWith('_1')) { const base = chars[id.slice(0, -2)]; if (base) return str(base.portrait); } } const byChar = chess?.charId ? chars[chess.charId] : null; if (!byChar) return null; return (chess?.isGolden ? str(byChar.portraitE2) : null) || str(byChar.portrait); } /** Skill icon of a chess record (manifest `skills[iconId]`, else the empty skill sprite). */ export function skillIconUrl(m, chess) { const skills = obj(obj(m)?.skills); const id = str(chess?.assets?.skillIcon) || str(chess?.skill?.iconId) || str(chess?.skill?.skillId); if (skills && id && skills[id]) return str(skills[id]); const byId = obj(obj(m)?.skillsById); const alt = byId && chess?.skill?.skillId ? byId[chess.skill.skillId] : null; if (skills && alt && skills[alt]) return str(skills[alt]); return uiUrl(m, 'skillIcon/empty'); } /** * Skill icon of one skill record (DESIGN §16 `skills[]`: iconId / skillId), else the empty skill sprite — or null with * `{ empty: false }` (the asset manifest only carries the default skills' icons: callers then draw a lettered S1–S3 * tile instead of a blank square). */ export function skillRecordIconUrl(m, skill, { empty = true } = {}) { const skills = obj(obj(m)?.skills); for (const id of [str(skill?.iconId), str(skill?.skillId)]) if (skills && id && skills[id]) return str(skills[id]); const byId = obj(obj(m)?.skillsById); const alt = byId && skill?.skillId ? byId[skill.skillId] : null; if (skills && alt && skills[alt]) return str(skills[alt]); return empty ? uiUrl(m, 'skillIcon/empty') : null; } const PROF_KEY = { PIONEER: 'pioneer', WARRIOR: 'warrior', TANK: 'tank', SNIPER: 'sniper', CASTER: 'caster', MEDIC: 'medic', SUPPORT: 'support', SPECIAL: 'special', }; /** Profession icon (small white glyph). */ export function profIconUrl(m, profession) { const k = PROF_KEY[String(profession || '').toUpperCase()]; return k ? str(obj(obj(obj(m)?.prof)?.icon)?.[k]) : null; } /** Sub-profession icon from a chess record (`assets.subProfIcon` = 'sub__icon'). */ export function subProfIconUrl(m, chess) { const sub = obj(obj(obj(m)?.prof)?.sub); if (!sub) return null; const raw = str(chess?.assets?.subProfIcon); const id = raw ? raw.replace(/^sub_/, '').replace(/_icon$/, '') : str(chess?.subProfessionId); return id ? str(sub[id]) : null; } /** Bond glyph (white; tint in CSS). */ export function bondIconUrl(m, bondId) { return bondId ? str(obj(obj(m)?.bonds)?.[bondId]) : null; } /** Band (strategy) icon. */ export function bandIconUrl(m, bandId) { return bandId ? str(obj(obj(m)?.bands)?.[bandId]) : null; } /** Item icon for an items.json record (by trapId / iconId) or a raw trap id. */ export function itemIconUrl(m, item) { const items = obj(obj(m)?.items); if (!items) return null; if (typeof item === 'string') return str(items[item]); return str(items[item?.iconId]) || str(items[item?.trapId]); } /** Enemy icon (manifest keeps fallbacks resolved). */ export function enemyIconUrl(m, enemyKey) { const e = enemyKey ? obj(obj(obj(m)?.enemies)?.[enemyKey]) : null; if (e?.icon) return str(e.icon); // `_2` / `_3` variants fall back to their base enemy const base = typeof enemyKey === 'string' ? enemyKey.replace(/_\d+$/, '') : null; return base && base !== enemyKey ? str(obj(obj(obj(m)?.enemies)?.[base])?.icon) : null; } /** Token avatar, falling back to its owner operator's avatar. */ export function tokenAvatarUrl(m, tokenId) { const tokens = obj(obj(m)?.tokens); const t = tokenId ? obj(tokens?.[tokenId]) : null; if (t?.avatar) return str(t.avatar); if (t?.owner) return str(obj(obj(obj(m)?.chars)?.[t.owner])?.avatar); return null; } /** Faction (特训敌人 type) icon: `enemyTypeIcon/`. */ export function factionIconUrl(m, iconId) { return iconId ? uiUrl(m, `enemyTypeIcon/${iconId}`) : null; } /** Title (评语) icon: `titleIcon/`. */ export function titleIconUrl(m, picId) { return picId ? uiUrl(m, `titleIcon/${picId}`) : null; } /** Greek type letters of some modules (ISW-α, …) → the Latin letter of the client's icon file names (isw-a). */ const GREEK = { 'α': 'a', 'β': 'b', 'γ': 'g', 'δ': 'd', 'Δ': 'd' }; /** Lower-cased key → path index of a local `groups.module` object (built once per manifest object). */ const moduleIconIndex = new WeakMap(); /** * Official module (uniequip) TYPE icon from the local-client art (DESIGN §13 / §16): `data/local-assets.json` * `groups.module[]`, matched case-insensitively against the module's typeName ('MAR-X' → key 'mar-x', 'PRI-X' → * key 'PRI-X' — the client's file names are mixed case). null when the manifest, the group or the entry is missing * (callers draw the lettered tile / type text instead). * @param {any} local the local-art manifest (`data.get('local')`) or null * @param {string} typeName e.g. 'MAR-X' */ export function moduleTypeIconUrl(local, typeName) { const g = obj(obj(obj(local)?.groups)?.module); const t = str(typeName)?.trim(); if (!g || !t) return null; const exact = str(obj(g[t])?.path); if (exact) return exact; let idx = moduleIconIndex.get(g); if (!idx) { idx = new Map(); for (const [k, v] of Object.entries(g)) { const p = str(obj(v)?.path); if (p && !idx.has(k.toLowerCase())) idx.set(k.toLowerCase(), p); } moduleIconIndex.set(g, idx); } const key = t.replace(/[αβγδΔ]/g, (c) => GREEK[c]).toLowerCase(); return idx.get(key) || null; } /** * Icon for an m.private.effects entry: { iconKind: 'band'|'choice'|'team'|'item'|'garrison', iconId }. * @param {any} m * @param {{ iconKind?: string, iconId?: string }} eff */ export function effectIconUrl(m, eff) { const kind = eff?.iconKind; const id = str(eff?.iconId); if (kind === 'band') return bandIconUrl(m, id) || (id && id.startsWith('icon_') ? bandIconUrl(m, `band_${id.slice(5)}`) : null); if (kind === 'item') return itemIconUrl(m, id); if (kind === 'garrison') return uiUrl(m, `garrisonTypeIcon/${id || 's_icon_bond'}`) || uiUrl(m, 'garrisonTypeIcon/s_icon_bond'); if (kind === 'team') return uiUrl(m, `buffIcon/${id && id.startsWith('icon_') ? id : 'icon_team_buff'}`); if (kind === 'choice') return uiUrl(m, `buffIcon/${id && id.startsWith('icon_') ? id : 'icon_player_buff'}`); return id ? uiUrl(m, `buffIcon/${id}`) : null; }