/**
* 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.
*/
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;
}
get title() {
return this._subsector
? `Sous-secteur ${this._subsector} — ${this._sector}`
: `Secteur ${this._sector}`;
}
get _mapUrl() {
const base = 'https://travellermap.com';
if (this._subsector) {
return `${base}/?sector=${encodeURIComponent(this._sector)}&subsector=${encodeURIComponent(this._subsector)}&style=mongoose&hideui=1`;
}
return `${base}/go/${encodeURIComponent(this._sector)}?style=mongoose`;
}
/* ───── 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._subsector ? ` — sous-secteur ${this._subsector}` : ''}
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();
});
}
/* ───── É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']; // A=10, F=15=Gaz géant (géré à part)
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}`;
}
_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]} | Taille | Gaz 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 ``;
}
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',
};
_tooltipHelp(map, codes) {
if (!codes) return '';
return codes.split(/[\s,;]+/).map(c => {
const desc = map[c.toUpperCase()];
return `${c}${desc ? ' — ' + desc : ''}`;
}).join(' | ');
}
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',
};
/* ───── Lignes dépliables (details/summary) ───── */
_foldRow(label, value, detail, titleAttr) {
const valAttr = titleAttr ? ` title="${titleAttr}"` : '';
return `
${label}${value}
${detail}
|
`;
}
_decodeImportance(ix) {
if (!ix) return '';
const m = String(ix).match(/\{?\s*(-?\d+)\s*\}?/);
if (!m) return this._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 this._foldRow('Importance', `{ ${val} } ${desc}`, detail);
}
_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 this._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 this._foldRow('Économie', `( ${res} ${lab} ${inf} ${eff} )`, detail);
}
_decodeCulture(cx) {
if (!cx) return '';
const s = String(cx).replace(/[\[\]\s]/g, '');
if (s.length < 4) return this._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 this._foldRow('Culture', `[ ${h} ${t} ${p} ${a} ]`, detail);
}
_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 this._foldRow('Population', `${fmtMult} × ${fmtBase} = ${fmtTotal}`, detail);
}
_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 this._foldRow('Noblesse', nob, '');
const detail = `Titres de noblesse impériale présents sur ce monde.
`;
return this._foldRow('Noblesse', titles.join(', '), detail);
}
_postWorldCard(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';
// Construction des lignes pliables
const lines = [];
// UWP
const uwpDetail = this._uwpBreakdown(uwp);
lines.push(this._foldRow('UWP', `${uwp}`, uwpDetail));
// Champs étendus
const ixRow = this._decodeImportance(w.Ix);
if (ixRow) lines.push(ixRow);
const exRow = this._decodeEconomics(w.Ex);
if (exRow) lines.push(exRow);
const cxRow = this._decodeCulture(w.Cx);
if (cxRow) lines.push(cxRow);
const popRow = this._decodePopulation(uwp, pbg);
if (popRow) lines.push(popRow);
const nobRow = this._decodeNobility(w.Nobility);
if (nobRow) lines.push(nobRow);
// Bases
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 = ``;
lines.push(this._foldRow('Bases', bases, bDetail));
}
// Remarques
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 = ``;
lines.push(this._foldRow('Remarques', remarks, rDetail));
}
// Allégeance
if (allegiance) {
const allegFull = w.AllegianceName || '';
const aDetail = `${allegFull || allegiance}
`;
lines.push(this._foldRow('Allégeance', allegFull ? `${allegiance} (${allegFull})` : allegiance, aDetail));
}
// Étoile (les notations comme "G3 V" contiennent un espace entre sous-classe et luminosité)
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 {
// Saute les tokens non reconnus (BD, compagnon, etc.)
const next = remaining.indexOf(' ');
if (next < 0) break;
remaining = remaining.slice(next + 1).trimStart();
}
}
const sDetail = sList.length ? `` : `${stellar}
`;
lines.push(this._foldRow('Étoile', `${stellar}`, sDetail));
}
const html = ``;
ChatMessage.create({
content: html,
whisper: [game.user.id],
});
}
/* ───── Partage ───── */
_shareMap() {
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 = ``;
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`);
}
/* ───── Nettoyage ───── */
close() {
if (this._handler) {
window.removeEventListener('message', this._handler);
this._handler = null;
}
return super.close();
}
}