/** * MGT2 – SectorMapApp * * Application interactive affichant une carte Traveller Map dans un IFRAME. * Les clics sur la carte affichent les détails du monde dans le chat. */ import { searchWorlds } from './travellerMapApi.js'; import { TravelDialog } from './travelDialog.js'; const { ApplicationV2 } = foundry.applications.api; const MODULE_ID = 'mgt2-compendium-amiral-denisov'; export class SectorMapApp extends ApplicationV2 { static DEFAULT_OPTIONS = { id: 'mgt2-sector-map', classes: ['mgt2-sector-map'], position: { width: 960, height: 720 }, window: { icon: 'fas fa-map', resizable: true, controls: [] }, }; constructor(sector, subsector) { super(); this._sector = sector; this._subsector = subsector; this._handler = null; this._mapHex = null; this._searchTimeout = null; } get title() { if (!this._sector) return 'Carte stellaire — Traveller Map'; return this._subsector ? `Sous-secteur ${this._subsector} — ${this._sector}` : `Secteur ${this._sector}`; } get _mapUrl() { const base = 'https://travellermap.com'; if (!this._sector) return `${base}/?style=mongoose&hideui=1`; if (this._subsector) { return `${base}/?sector=${encodeURIComponent(this._sector)}&subsector=${encodeURIComponent(this._subsector)}&style=mongoose&hideui=1`; } let url = `${base}/go/${encodeURIComponent(this._sector)}?style=mongoose`; if (this._mapHex) { url += `&hex=${this._mapHex}&scale=512`; } return url; } /* ───── Rendu ───── */ _replaceHTML(result, config) { const content = this.element?.querySelector('.window-content'); if (!content) return; const html = typeof result === 'string' ? result : this._lastHTML; content.innerHTML = typeof html === 'string' ? html : ''; } _renderHTML() { return `
${this._sector ? `${this._sector}${this._subsector ? ` — ss.${this._subsector}` : ''}` : 'Toute la carte'} Cliquez sur un hex pour voir les détails du monde
`; } async _onRender(context, options) { this._listen(); this.element?.querySelector('.mgt2-sector-map-share')?.addEventListener('click', () => { this._shareMap(); }); this.element?.querySelector('.mgt2-sector-map-sync')?.addEventListener('click', () => { this._syncAll(); }); this.element?.querySelector('.mgt2-sector-map-travel')?.addEventListener('click', () => { this._openTravelDialog(); }); const input = this.element?.querySelector('.mgt2-sector-map-input'); const results = this.element?.querySelector('.mgt2-sector-map-results'); if (input && results) { input.addEventListener('input', () => { if (this._searchTimeout) clearTimeout(this._searchTimeout); this._searchTimeout = setTimeout(() => this._doSearch(input, results), 300); }); input.addEventListener('blur', () => { setTimeout(() => { results.innerHTML = ''; }, 200); }); input.addEventListener('focus', () => { if (input.value.trim().length >= 2) this._doSearch(input, results); }); } } /* ───── Écoute des clics IFRAME ───── */ _listen() { if (this._handler) return; this._handler = (event) => { // Accept messages from travellermap or any origin (for testing) const d = event.data || {}; const wx = d.x ?? d.location?.x; const wy = d.y ?? d.location?.y; if (wx == null || wy == null) return; const x = Number(wx); const y = Number(wy); if (isNaN(x) || isNaN(y)) return; console.log('SectorMapApp | click at', x, y, 'from', event.origin); this._onMapClick({x, y}).catch(err => { console.error('SectorMapApp | click handler failed:', err); }); }; window.addEventListener('message', this._handler); } async _onMapClick(loc) { const wx = loc?.x; const wy = loc?.y; if (wx == null || wy == null) return; const coordResp = await fetch( `https://travellermap.com/api/coordinates?x=${wx}&y=${wy}` ); if (!coordResp.ok) { console.error('SectorMapApp | /api/coordinates failed', coordResp.status); return; } const coord = await coordResp.json(); const { sx, sy, hx, hy } = coord; if (sx == null || hx == null || hy == null) { console.error('SectorMapApp | no sx/hx/hy in', coord); return; } const metaResp = await fetch( `https://travellermap.com/api/metadata?sx=${sx}&sy=${sy}` ); if (!metaResp.ok) { console.error('SectorMapApp | /api/metadata failed', metaResp.status); return; } const meta = await metaResp.json(); const sectorName = meta.Names?.[0]?.Text; if (!sectorName) { console.error('SectorMapApp | no Names[0].Text in metadata', meta); return; } const hex = String(hx).padStart(2, '0') + String(hy).padStart(2, '0'); const resp = await fetch( `https://travellermap.com/data/${encodeURIComponent(sectorName)}/${hex}` ); if (!resp.ok) { console.error('SectorMapApp | /data failed', resp.status, sectorName, hex); return; } const data = await resp.json(); const world = data.Worlds?.[0]; if (!world) { console.error('SectorMapApp | no Worlds in data', data); return; } this._postWorldCard(world); } /* ───── Carte de chat ───── */ static _STARPORT = { A:'Excellent', B:'Bon', C:'Routinier', D:'Médiocre', E:'Frontière', X:'Aucun' }; static _SIZE = ['Aucun (Astéroïde)','1 600 km','3 200 km','4 800 km','6 400 km','8 000 km','9 600 km','11 200 km','12 800 km','14 400 km','16 000 km']; static _ATMO = [ 'Aucune (vide)','Trace','Très ténue (polluée)','Très ténue','Ténue (polluée)','Ténue','Standard','Standard (polluée)','Dense','Dense (polluée)', 'Exotique','Corrosive','Insidieuse','','','']; static _HYDRO = [ '0–5% (désert)','6–15%','16–25%','26–35%','36–45%','46–55%','56–65%','66–75%','76–85%','86–95%','96–100%' ]; static _POP = ['','Dizaines','Centaines','Milliers','Dizaines de milliers','Centaines de milliers','Millions','Dizaines de millions','Centaines de millions','Milliards','Dizaines de milliards','','','','','','']; static _GOV = [ 'Aucun','Compagnie / Corporation','Démocratie participative','Oligarchie auto-perpétuée', 'Démocratie représentative','Technocratie féodale','Gouvernement captif / Colonie', 'Balkanisation','Bureaucratie de service civil','Bureaucratie impersonnelle', 'Dictature charismatique','Dictature non-charismatique','Oligarchie charismatique', 'Dictature religieuse','Oligarchie religieuse','Gouvernement tribal']; static _LAW = [ 'Aucune', 'Armes de poing, explosifs, poison','Armes à énergie portatives','Mitrailleuses, armes auto', 'Armes d\'assaut légères, PM','Armes de poing individuelles','Toutes les armes à feu sauf neutralisateur', 'Fusils, neutralisateur','Armes blanches, neutralisateur','Armes hors du domicile','Armes interdites', 'Contrôle rigide','Aucune arme','Contrôle militariste sévère']; static _TL = [ 'Âge de pierre','Âge du bronze/fer','Médiéval','Grandes découvertes','Révolution industrielle', 'Production mécanisée','Ère nucléaire','Pré-stellaire (ère de l\'information)','Propulsion à saut (1re gen)', 'Propulsion à saut-2','Propulsion à saut-3','Propulsion à saut-4','Propulsion à saut-5', 'Propulsion à saut-6','Transporteur','Moyenne stellaire']; static _hexVal(ch) { const n = parseInt(ch, 36); if (isNaN(n)) return -1; return n; } static _uwpDigit(desc, val) { return `${val}`; } static _uwpBreakdown(uwp) { if (!uwp || uwp.length < 2) return ''; const d = SectorMapApp._hexVal; const s = d(uwp[0]), sz = d(uwp[1]), a = d(uwp[2]), h = d(uwp[3]); const p = d(uwp[4]), g = d(uwp[5]), l = d(uwp[6]); const t = uwp.length > 8 ? d(uwp[8]) : -1; const lines = []; const starport = SectorMapApp._STARPORT[uwp[0]]; lines.push(`${uwp[0]}Starport${starport ?? '—'}`); if (uwp[1] === 'F' || uwp[1] === 'f') { lines.push(`${uwp[1]}TailleGaz géant`); } else if (sz >= 0 && sz <= 10) { const km = SectorMapApp._SIZE[sz]; const grav = sz === 0 ? '0g' : (sz < 10 ? `0.${sz}g` : '1.0g+'); lines.push(`${uwp[1]}Taille${km} (${grav})`); } if (a >= 0 && a <= 15) { const atmo = SectorMapApp._ATMO[a] ?? '—'; lines.push(`${uwp[2]}Atmosphère${atmo}`); } if (h >= 0 && h <= 10) { lines.push(`${uwp[3]}Hydrosphère${SectorMapApp._HYDRO[h]}`); } if (p >= 0 && p <= 15) { lines.push(`${uwp[4]}Population${SectorMapApp._POP[p] ?? '—'}`); } if (g >= 0 && g <= 15) { lines.push(`${uwp[5]}Gouvernement${SectorMapApp._GOV[g] ?? '—'}`); } if (l >= 0 && l <= 15) { lines.push(`${uwp[6]}Niveau légal${SectorMapApp._LAW[l] ?? '—'}`); } if (t >= 0 && t <= 15) { lines.push(`${uwp[8]}Technologie${SectorMapApp._TL[t] ?? '—'}`); } return `${lines.join('')}
`; } static _REMARKS_HELP = { AB:'Anneau (ceinture)', AG:'Agricole', AN:'Site ancien', AS:'Astéroïde', BA:'Bande astéroïdale', CP:'Sous-secteur capitale', CS:'Colonie', CX:'Chasseur (Croiseur)', CY:'Colonie', DA:'Déchu', DE:'Désertique', DI:'Interdit (Diebar)', FL:'Fluides Lo', FO:'Interdit (Forbidden)', FR:'Gelé (Frozen)', GA:'Jardin (Garden)', HE:'Helios', HI:'Haute population', HT:'Haute technologie', IC:'Mondes gelés (Ice)', IN:'Industrialisé', LI:'Faible population', LO:'Faible population (Low)', LT:'Basse technologie (Low Tech)', MI:'Militaire', MR:'Mine (ressources)', NA:'Non-agricole', NI:'Non-industrialisé', OC:'Océanique', OX:'Oxydant', PA:'Pré-agricole (Pre-Agricultural)', PH:'Phosphore', PO:'Pauvre (Poor)', PR:'Pré-industriel (Pre-Industrial)', PX:'Prisonnier (exil)', PZ:'Puzzle (énigmatique)', RE:'Religieux (Religious)', RI:'Riche (Rich)', SA:'Bande d\'astéroïdes (Satellite)', SC:'Sainte (colonie)', SL:'Esclavage (Slave)', SO:'Soleil (Sol)', SP:'Désert (Despoiled)', SR:'Réserve (Reserve)', ST:'Base stellaire', SU:'Secteur capitale', TR:'Traces (Trace)', TU:'Tucannides', TZ:'Mondes Tz', UN:'Inhabité (Uninhabited)', VA:'Vide (Vacuum)', WA:'Monde aquatique (Water)', WT:'Monde d\'eau (Watery)', }; static _STAR_TYPES = { O:'Bleu (hypergéante)', B:'Bleu-blanc', A:'Blanc', F:'Blanc-jaune', G:'Jaune (naine)', K:'Orange (naine)', M:'Rouge (naine)', L:'Brune', T:'Brune', Y:'Brune' }; static _STAR_CLASS = { 'I':'Supergéante', 'II':'Géante brillante', 'III':'Géante', 'IV':'Sous-géante', 'V':'Naine (séquence principale)', 'VI':'Sous-naine', 'VII':'Naine blanche' }; static _BASES_HELP = { N:'Base navale', S:'Base scout', W:'Relais', D:'Dépôt naval', T:'Base TAS', C:'Consulat', P:'Base pirate', R:'Base de réparation', K:'Base navale (K.)', X:'Relais Xboat', }; static _NOBILITY = { B:'Chevalier (Baronet)', C:'Baron', D:'Marquis', E:'Comte', F:'Duc', G:'Archiduc', H:'Empereur', }; static _IMPORTANCE = { '-5':'Très mineur', '-4':'Mineur', '-3':'Mineur', '-2':'Très secondaire', '-1':'Secondaire', '0':'Ordinaire', '1':'Important', '2':'Important', '3':'Très important', '4':'Majeure', '5':'Majeure', '6':'Capitale', }; static _foldRow(label, value, detail, titleAttr) { const valAttr = titleAttr ? ` title="${titleAttr}"` : ''; return `
${label}${value}
${detail}
`; } static _decodeImportance(ix) { if (!ix) return ''; const m = String(ix).match(/\{?\s*(-?\d+)\s*\}?/); if (!m) return SectorMapApp._foldRow('Importance', ix, ''); const val = m[1]; const desc = SectorMapApp._IMPORTANCE[val] || '—'; const detail = `
Valeur d’importance économique et stratégique du monde.
${val} = ${desc}
`; return SectorMapApp._foldRow('Importance', `{ ${val} } ${desc}`, detail); } static _decodeEconomics(ex) { if (!ex) return ''; let s = String(ex).replace(/[()\s]/g, ''); const m = s.match(/^([\dA-F])([\dA-F])([\dA-F])([+-]\d+)$/i); if (!m) return SectorMapApp._foldRow('Économie', ex, ''); const res = m[1], lab = m[2], inf = m[3], eff = m[4]; const detail = `
Ressources${res}
Main-d’œuvre${lab}
Infrastructure${inf}
Efficacité${eff}
`; return SectorMapApp._foldRow('Économie', `( ${res} ${lab} ${inf} ${eff} )`, detail); } static _decodeCulture(cx) { if (!cx) return ''; const s = String(cx).replace(/[\[\]\s]/g, ''); if (s.length < 4) return SectorMapApp._foldRow('Culture', cx, ''); const h = s[0].toUpperCase(), t = s[1].toUpperCase(), p = s[2].toUpperCase(), a = s[3].toUpperCase(); const detail = `
Hétérogénéité${h}
Traditionalisme${t}
Progressisme${p}
Agressivité${a}
`; return SectorMapApp._foldRow('Culture', `[ ${h} ${t} ${p} ${a} ]`, detail); } static _decodePopulation(uwp, pbg) { if (!uwp || uwp.length < 5) return ''; const popUwp = SectorMapApp._hexVal(uwp[4]); if (popUwp < 0) return ''; const popPbg = pbg ? parseInt(pbg[0], 10) : null; const multiplier = popPbg != null && !isNaN(popPbg) ? popPbg : 1; const base = Math.pow(10, popUwp); const total = multiplier * base; const fmtBase = `10${popUwp}`; const fmtMult = multiplier; const fmtTotal = total >= 1e9 ? `${(total / 1e9).toFixed(1)} milliards` : total >= 1e6 ? `${(total / 1e6).toFixed(1)} millions` : total >= 1e3 ? `${(total / 1e3).toFixed(0)} 000` : String(total); const belts = pbg ? parseInt(pbg[1], 10) : null; const gas = pbg ? parseInt(pbg[2], 10) : null; const detail = `
Population = multiplicateur (PBG: ${popPbg}) × 10chiffre UWP (${uwp[4]})
Ceintures d’astéroïdes : ${belts ?? '?'}  |  Géantes gazeuses : ${gas ?? '?'}
`; return SectorMapApp._foldRow('Population', `${fmtMult} × ${fmtBase} = ${fmtTotal}`, detail); } static _decodeNobility(nob) { if (!nob) return ''; const titles = []; for (const ch of nob) { const desc = SectorMapApp._NOBILITY[ch.toUpperCase()]; if (desc) titles.push(`${ch} (${desc})`); } if (!titles.length) return SectorMapApp._foldRow('Noblesse', nob, ''); const detail = `
Titres de noblesse impériale présents sur ce monde.
`; return SectorMapApp._foldRow('Noblesse', titles.join(', '), detail); } static _buildWorldCardHTML(w) { const sector = w.Sector || ''; const hex = w.Hex || ''; const name = w.Name || '—'; const uwp = w.UWP || '???????-?'; const bases = w.Bases || ''; const remarks = w.Remarks || ''; const allegiance = w.Allegiance || ''; const stellar = w.Stellar || ''; const zone = w.Zone || ''; const pbg = w.PBG || ''; const zoneLabel = zone === 'R' ? 'Rouge' : zone === 'A' ? 'Ambre' : 'Verte'; const lines = []; const uwpDetail = SectorMapApp._uwpBreakdown(uwp); lines.push(SectorMapApp._foldRow('UWP', `${uwp}`, uwpDetail)); const ixRow = SectorMapApp._decodeImportance(w.Ix); if (ixRow) lines.push(ixRow); const exRow = SectorMapApp._decodeEconomics(w.Ex); if (exRow) lines.push(exRow); const cxRow = SectorMapApp._decodeCulture(w.Cx); if (cxRow) lines.push(cxRow); const popRow = SectorMapApp._decodePopulation(uwp, pbg); if (popRow) lines.push(popRow); const nobRow = SectorMapApp._decodeNobility(w.Nobility); if (nobRow) lines.push(nobRow); if (bases) { const bCodes = bases.split(/[\s,;]+/); const bList = bCodes.map(c => { const desc = SectorMapApp._BASES_HELP[c.toUpperCase()]; return `${c}${desc || '—'}`; }).join(''); const bDetail = `${bList}
`; lines.push(SectorMapApp._foldRow('Bases', bases, bDetail)); } if (remarks) { const rCodes = remarks.split(/[\s,;]+/); const rList = rCodes.map(c => { const desc = SectorMapApp._REMARKS_HELP[c.toUpperCase()]; return `${c}${desc || '—'}`; }).join(''); const rDetail = `${rList}
`; lines.push(SectorMapApp._foldRow('Remarques', remarks, rDetail)); } if (allegiance) { const allegFull = w.AllegianceName || ''; const aDetail = `
${allegFull || allegiance}
`; lines.push(SectorMapApp._foldRow('Allégeance', allegFull ? `${allegiance} (${allegFull})` : allegiance, aDetail)); } if (stellar) { const sList = []; let remaining = stellar.trim(); const reStar = /^([OBAFGKMLTY])(\d)\s*(VII|VI|V|IV|III|II|I)\s*/i; while (remaining) { const m = remaining.match(reStar); if (m) { const type = SectorMapApp._STAR_TYPES[m[1].toUpperCase()] || m[1]; const cls = SectorMapApp._STAR_CLASS[m[3]] || m[3]; sList.push(`${m[1]}${m[2]} ${m[3]}${type} · ${cls}`); remaining = remaining.slice(m[0].length).trimStart(); } else { const next = remaining.indexOf(' '); if (next < 0) break; remaining = remaining.slice(next + 1).trimStart(); } } const sDetail = sList.length ? `${sList.join('')}
` : `
${stellar}
`; lines.push(SectorMapApp._foldRow('Étoile', `${stellar}`, sDetail)); } return `
${name} ${sector} ${hex} ${zoneLabel}
${lines.join('')}
Commerce
`; } static _escapeAttr(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, '''); } _postWorldCard(w) { const html = SectorMapApp._buildWorldCardHTML(w); ChatMessage.create({ content: html, whisper: [game.user.id], }); } /* ───── Recherche de monde ───── */ async _doSearch(input, results) { const query = input.value.trim(); if (query.length < 2) { results.innerHTML = ''; return; } const worlds = await searchWorlds(query); if (!worlds.length) { results.innerHTML = '
  • Aucun monde trouvé
  • '; return; } results.innerHTML = worlds.slice(0, 12).map(w => `
  • ${w.name} ${w.uwp} ${w.sector}
  • ` ).join(''); results.querySelectorAll('li[data-sector]').forEach(li => { li.addEventListener('click', () => this._selectWorld(li, input, results)); }); } async _selectWorld(li, input, results) { const sector = li.dataset.sector; const hex = li.dataset.hex; const name = li.dataset.name; results.innerHTML = ''; input.value = name; this._sector = sector; this._subsector = null; this._mapHex = hex; const iframe = this.element?.querySelector('.mgt2-sector-map-frame'); if (iframe) iframe.src = this._mapUrl; ui.notifications.info(`Carte centrée sur ${name} (${sector} ${hex})`); } /* ───── Partage ───── */ _shareMap() { if (!this._sector) { ui.notifications.warn('Aucun secteur sélectionné — utilisez la recherche pour centrer sur un secteur'); return; } const posterUrl = `https://travellermap.com/api/poster?sector=${encodeURIComponent(this._sector)}${this._subsector ? `&subsector=${encodeURIComponent(this._subsector)}` : ''}&style=mongoose&scale=128&dpr=2`; const label = this._subsector ? `Sous-secteur ${this._subsector} — ${this._sector}` : `Secteur ${this._sector}`; const html = `
    ${label}
    ${label}
    `; ChatMessage.create({ content: html, rollMode: 'public' }); ui.notifications.info(`Carte partagée avec les joueurs`); } _syncAll() { game.socket.emit(`module.${MODULE_ID}`, { type: 'sectorMapSync', sector: this._sector, subsector: this._subsector, }); ui.notifications.info(`Carte synchronisée chez tous les joueurs`); } _openTravelDialog() { const existing = Object.values(ui.windows).find(w => w.id === 'mgt2-travel-dialog'); if (existing) { existing.bringToTop(); return; } const dialog = new TravelDialog(); dialog.render({ force: true }); } /* ───── Nettoyage ───── */ close() { if (this._handler) { window.removeEventListener('message', this._handler); this._handler = null; } return super.close(); } }