MAp management and helpers

This commit is contained in:
2026-06-02 00:16:08 +02:00
parent 49423f40f5
commit efe37b8a96
22 changed files with 1163 additions and 71 deletions
+36 -1
View File
@@ -51,15 +51,36 @@ export class CommerceDialog extends HandlebarsApplicationMixin(ApplicationV2) {
},
};
this._tradeGoods = null;
this._worldNames = {};
if (options.defaultWorld) {
const w = options.defaultWorld;
this._defaultWorld = w;
this._worldNames['pax.uwpDep'] = w.name || '';
this._worldNames['cargo.uwpDep'] = w.name || '';
this._worldNames['trade.uwp'] = w.name || '';
if (w.uwp) this._formData.pax.uwpDep = w.uwp;
if (w.zone) this._formData.pax.zoneDep = w.zone;
if (w.uwp) this._formData.cargo.uwpDep = w.uwp;
if (w.zone) this._formData.cargo.zoneDep = w.zone;
if (w.uwp) this._formData.trade.uwp = w.uwp;
if (w.zone) this._formData.trade.zone = w.zone;
this._activeTab = 'trade';
}
}
async _prepareContext() {
_registerHandlebarsHelpers();
return {
const ctx = {
...this._formData,
activeActor: buildActiveActorContext(),
activeTab: this._activeTab,
};
if (this._defaultWorld) {
ctx.defaultWorldName = this._defaultWorld.name;
ctx.defaultWorldLoc = `${this._defaultWorld.sector || ''} ${this._defaultWorld.hex || ''}`.trim();
}
return ctx;
}
async _onRender(context, options) {
@@ -128,6 +149,12 @@ export class CommerceDialog extends HandlebarsApplicationMixin(ApplicationV2) {
this._bindWorldSearch(html);
// Pré-remplir les champs recherche avec le nom du monde
if (this._defaultWorld?.name) {
html.find('.world-search-widget[data-role="dep"] .world-search-input, .world-block-full .world-search-input')
.val(this._defaultWorld.name);
}
html.on('click', (ev) => {
if (!$(ev.target).closest('.world-search-widget').length) {
html.find('.world-search-results').empty();
@@ -204,6 +231,7 @@ export class CommerceDialog extends HandlebarsApplicationMixin(ApplicationV2) {
$li.on('click', async () => {
$results.empty();
$input.val(w.name);
if (uwpTarget) this._worldNames[uwpTarget] = w.name;
const [detail, coords] = await Promise.all([
fetchWorldDetail(w.sector, w.hex).catch(() => null),
fetchWorldCoordinates(w.sector, w.hex).catch(() => null),
@@ -222,11 +250,13 @@ export class CommerceDialog extends HandlebarsApplicationMixin(ApplicationV2) {
html.find('[name="cargo.uwpDep"]').val(resolvedUwp);
html.find('[name="cargo.zoneDep"]').val(resolvedZone);
$cargoDep.data('coords', coords);
this._worldNames['cargo.uwpDep'] = w.name;
const $tradeWorld = html.find('.world-search-widget[data-uwp-target="trade.uwp"]');
$tradeWorld.find('.world-search-input').val(w.name);
html.find('[name="trade.uwp"]').val(resolvedUwp);
html.find('[name="trade.zone"]').val(resolvedZone);
this._worldNames['trade.uwp'] = w.name;
}
if (parsecsTarget) {
@@ -370,6 +400,8 @@ export class CommerceDialog extends HandlebarsApplicationMixin(ApplicationV2) {
});
if (!result.success) return ui.notifications.error(result.errors.join(' | '));
result.dep = { ...result.dep, name: this._worldNames['pax.uwpDep'] || '' };
result.dest = { ...result.dest, name: this._worldNames['pax.uwpDest'] || '' };
await this._postToChatResult(result);
}
@@ -394,6 +426,8 @@ export class CommerceDialog extends HandlebarsApplicationMixin(ApplicationV2) {
if (!result.success) return ui.notifications.error(result.errors.join(' | '));
result.cargoRevenue = result.lots.reduce((s, l) => s + l.revenue, 0);
result.dep = { ...result.dep, name: this._worldNames['cargo.uwpDep'] || '' };
result.dest = { ...result.dest, name: this._worldNames['cargo.uwpDest'] || '' };
await this._postToChatResult(result);
}
@@ -413,6 +447,7 @@ export class CommerceDialog extends HandlebarsApplicationMixin(ApplicationV2) {
if (!result.success) return ui.notifications.error(result.errors.join(' | '));
result.world = { ...result.world, name: this._worldNames['trade.uwp'] || '' };
this._tradeGoods = result;
const goodsDiv = html.find('.trade-goods-result');
const listDiv = html.find('.trade-goods-list');
+129 -52
View File
@@ -5,6 +5,9 @@
* 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';
@@ -21,9 +24,12 @@ export class SectorMapApp extends ApplicationV2 {
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}`;
@@ -31,10 +37,15 @@ export class SectorMapApp extends ApplicationV2 {
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`;
}
return `${base}/go/${encodeURIComponent(this._sector)}?style=mongoose`;
let url = `${base}/go/${encodeURIComponent(this._sector)}?style=mongoose`;
if (this._mapHex) {
url += `&hex=${this._mapHex}&scale=512`;
}
return url;
}
/* ───── Rendu ───── */
@@ -49,10 +60,15 @@ export class SectorMapApp extends ApplicationV2 {
_renderHTML() {
return `<div class="mgt2-sector-map-outer">
<div class="mgt2-sector-map-toolbar">
<span class="mgt2-sector-map-label">${this._sector}${this._subsector ? ` — sous-secteur ${this._subsector}` : ''}</span>
<div class="mgt2-sector-map-search">
<input type="text" class="mgt2-sector-map-input" placeholder="Rechercher un monde…" autocomplete="off">
<ul class="mgt2-sector-map-results"></ul>
</div>
<span class="mgt2-sector-map-label">${this._sector ? `${this._sector}${this._subsector ? ` — ss.${this._subsector}` : ''}` : 'Toute la carte'}</span>
<span class="mgt2-sector-map-hint">Cliquez sur un hex pour voir les détails du monde</span>
<button type="button" class="mgt2-sector-map-share" title="Partager une image fixe dans le chat"><i class="fas fa-image"></i> Partager</button>
<button type="button" class="mgt2-sector-map-sync" title="Ouvrir la carte interactive chez tous les joueurs"><i class="fas fa-users"></i> Synchroniser</button>
<button type="button" class="mgt2-sector-map-travel" title="Planifier un voyage"><i class="fas fa-route"></i> Voyage</button>
</div>
<iframe
src="${this._mapUrl}"
@@ -71,6 +87,24 @@ export class SectorMapApp extends ApplicationV2 {
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 ───── */
@@ -131,7 +165,7 @@ export class SectorMapApp extends ApplicationV2 {
/* ───── 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 _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','','',''];
@@ -164,7 +198,7 @@ export class SectorMapApp extends ApplicationV2 {
return `<span class="uwp-dig" title="${desc}">${val}</span>`;
}
_uwpBreakdown(uwp) {
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]);
@@ -237,22 +271,12 @@ export class SectorMapApp extends ApplicationV2 {
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',
};
@@ -263,9 +287,7 @@ export class SectorMapApp extends ApplicationV2 {
'5':'Majeure', '6':'Capitale',
};
/* ───── Lignes dépliables (details/summary) ───── */
_foldRow(label, value, detail, titleAttr) {
static _foldRow(label, value, detail, titleAttr) {
const valAttr = titleAttr ? ` title="${titleAttr}"` : '';
return `<tr><td colspan="2">
<details>
@@ -275,21 +297,21 @@ export class SectorMapApp extends ApplicationV2 {
</td></tr>`;
}
_decodeImportance(ix) {
static _decodeImportance(ix) {
if (!ix) return '';
const m = String(ix).match(/\{?\s*(-?\d+)\s*\}?/);
if (!m) return this._foldRow('Importance', ix, '');
if (!m) return SectorMapApp._foldRow('Importance', ix, '');
const val = m[1];
const desc = SectorMapApp._IMPORTANCE[val] || '—';
const detail = `<div class="fold-desc">Valeur dimportance économique et stratégique du monde.<br>${val} = ${desc}</div>`;
return this._foldRow('Importance', `{ ${val} } ${desc}`, detail);
return SectorMapApp._foldRow('Importance', `{ ${val} } ${desc}`, detail);
}
_decodeEconomics(ex) {
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 this._foldRow('Économie', ex, '');
if (!m) return SectorMapApp._foldRow('Économie', ex, '');
const res = m[1], lab = m[2], inf = m[3], eff = m[4];
const detail = `<table class="fold-subtable">
<tr><td>Ressources</td><td>${res}</td></tr>
@@ -297,13 +319,13 @@ export class SectorMapApp extends ApplicationV2 {
<tr><td>Infrastructure</td><td>${inf}</td></tr>
<tr><td>Efficacité</td><td>${eff}</td></tr>
</table>`;
return this._foldRow('Économie', `( ${res} ${lab} ${inf} ${eff} )`, detail);
return SectorMapApp._foldRow('Économie', `( ${res} ${lab} ${inf} ${eff} )`, detail);
}
_decodeCulture(cx) {
static _decodeCulture(cx) {
if (!cx) return '';
const s = String(cx).replace(/[\[\]\s]/g, '');
if (s.length < 4) return this._foldRow('Culture', cx, '');
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 = `<table class="fold-subtable">
<tr><td>Hétérogénéité</td><td>${h}</td></tr>
@@ -311,10 +333,10 @@ export class SectorMapApp extends ApplicationV2 {
<tr><td>Progressisme</td><td>${p}</td></tr>
<tr><td>Agressivité</td><td>${a}</td></tr>
</table>`;
return this._foldRow('Culture', `[ ${h} ${t} ${p} ${a} ]`, detail);
return SectorMapApp._foldRow('Culture', `[ ${h} ${t} ${p} ${a} ]`, detail);
}
_decodePopulation(uwp, pbg) {
static _decodePopulation(uwp, pbg) {
if (!uwp || uwp.length < 5) return '';
const popUwp = SectorMapApp._hexVal(uwp[4]);
if (popUwp < 0) return '';
@@ -332,22 +354,22 @@ export class SectorMapApp extends ApplicationV2 {
const gas = pbg ? parseInt(pbg[2], 10) : null;
const detail = `<div class="fold-desc">Population = multiplicateur (PBG: <b>${popPbg}</b>) × 10<sup>chiffre UWP (${uwp[4]})</sup><br>
Ceintures dastéroïdes : <b>${belts ?? '?'}</b> &nbsp;|&nbsp; Géantes gazeuses : <b>${gas ?? '?'}</b></div>`;
return this._foldRow('Population', `${fmtMult} × ${fmtBase} = ${fmtTotal}`, detail);
return SectorMapApp._foldRow('Population', `${fmtMult} × ${fmtBase} = ${fmtTotal}`, detail);
}
_decodeNobility(nob) {
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 this._foldRow('Noblesse', nob, '');
if (!titles.length) return SectorMapApp._foldRow('Noblesse', nob, '');
const detail = `<div class="fold-desc">Titres de noblesse impériale présents sur ce monde.</div>`;
return this._foldRow('Noblesse', titles.join(', '), detail);
return SectorMapApp._foldRow('Noblesse', titles.join(', '), detail);
}
_postWorldCard(w) {
static _buildWorldCardHTML(w) {
const sector = w.Sector || '';
const hex = w.Hex || '';
const name = w.Name || '—';
@@ -363,26 +385,22 @@ export class SectorMapApp extends ApplicationV2 {
: zone === 'A' ? 'Ambre'
: 'Verte';
// Construction des lignes pliables
const lines = [];
// UWP
const uwpDetail = this._uwpBreakdown(uwp);
lines.push(this._foldRow('UWP', `<span class="mono">${uwp}</span>`, uwpDetail));
const uwpDetail = SectorMapApp._uwpBreakdown(uwp);
lines.push(SectorMapApp._foldRow('UWP', `<span class="mono">${uwp}</span>`, uwpDetail));
// Champs étendus
const ixRow = this._decodeImportance(w.Ix);
const ixRow = SectorMapApp._decodeImportance(w.Ix);
if (ixRow) lines.push(ixRow);
const exRow = this._decodeEconomics(w.Ex);
const exRow = SectorMapApp._decodeEconomics(w.Ex);
if (exRow) lines.push(exRow);
const cxRow = this._decodeCulture(w.Cx);
const cxRow = SectorMapApp._decodeCulture(w.Cx);
if (cxRow) lines.push(cxRow);
const popRow = this._decodePopulation(uwp, pbg);
const popRow = SectorMapApp._decodePopulation(uwp, pbg);
if (popRow) lines.push(popRow);
const nobRow = this._decodeNobility(w.Nobility);
const nobRow = SectorMapApp._decodeNobility(w.Nobility);
if (nobRow) lines.push(nobRow);
// Bases
if (bases) {
const bCodes = bases.split(/[\s,;]+/);
const bList = bCodes.map(c => {
@@ -390,10 +408,9 @@ export class SectorMapApp extends ApplicationV2 {
return `<tr><td class="mono">${c}</td><td>${desc || '—'}</td></tr>`;
}).join('');
const bDetail = `<table class="fold-subtable"><tbody>${bList}</tbody></table>`;
lines.push(this._foldRow('Bases', bases, bDetail));
lines.push(SectorMapApp._foldRow('Bases', bases, bDetail));
}
// Remarques
if (remarks) {
const rCodes = remarks.split(/[\s,;]+/);
const rList = rCodes.map(c => {
@@ -401,17 +418,15 @@ export class SectorMapApp extends ApplicationV2 {
return `<tr><td class="mono">${c}</td><td>${desc || '—'}</td></tr>`;
}).join('');
const rDetail = `<table class="fold-subtable"><tbody>${rList}</tbody></table>`;
lines.push(this._foldRow('Remarques', remarks, rDetail));
lines.push(SectorMapApp._foldRow('Remarques', remarks, rDetail));
}
// Allégeance
if (allegiance) {
const allegFull = w.AllegianceName || '';
const aDetail = `<div class="fold-desc">${allegFull || allegiance}</div>`;
lines.push(this._foldRow('Allégeance', allegFull ? `${allegiance} (${allegFull})` : allegiance, aDetail));
lines.push(SectorMapApp._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();
@@ -424,34 +439,89 @@ export class SectorMapApp extends ApplicationV2 {
sList.push(`<tr><td class="mono">${m[1]}${m[2]} ${m[3]}</td><td>${type} · ${cls}</td></tr>`);
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 ? `<table class="fold-subtable"><tbody>${sList.join('')}</tbody></table>` : `<div class="fold-desc">${stellar}</div>`;
lines.push(this._foldRow('Étoile', `<span class="mono">${stellar}</span>`, sDetail));
lines.push(SectorMapApp._foldRow('Étoile', `<span class="mono">${stellar}</span>`, sDetail));
}
const html = `<section class="mgt2-world-card">
return `<section class="mgt2-world-card">
<div class="mgt2-world-card-header">
<span class="mgt2-world-name">${name}</span>
<span class="mgt2-world-hex">${sector} ${hex}</span>
<span class="mgt2-world-zone zone-${zone.toLowerCase() || 'g'}">${zoneLabel}</span>
</div>
<table class="mgt2-world-card-body"><tbody>${lines.join('')}</tbody></table>
<div class="mgt2-world-card-actions">
<a class="mgt2-world-commerce" data-sector="${this._escapeAttr(sector)}" data-hex="${hex}" data-uwp="${uwp}" data-zone="${zone || 'normal'}" data-name="${this._escapeAttr(name)}">
<i class="fas fa-balance-scale"></i> Commerce
</a>
</div>
</section>`;
}
static _escapeAttr(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
_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 = '<li class="no-result">Aucun monde trouvé</li>';
return;
}
results.innerHTML = worlds.slice(0, 12).map(w =>
`<li data-sector="${w.sector}" data-hex="${w.hex}" data-name="${w.name}">
<span class="world-name">${w.name}</span>
<span class="world-uwp">${w.uwp}</span>
<span class="world-sector">${w.sector}</span>
</li>`
).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}`
@@ -484,6 +554,13 @@ export class SectorMapApp extends ApplicationV2 {
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() {
+72 -10
View File
@@ -6,6 +6,8 @@
*/
import { SectorMapApp } from './SectorMapApp.js';
import { postWorldCardToChat } from './worldCard.js';
import { CommerceDialog } from './CommerceDialog.js';
const MODULE_ID = 'mgt2-compendium-amiral-denisov';
const ChatLogV2 = foundry.applications?.sidebar?.tabs?.ChatLog;
@@ -23,15 +25,11 @@ async function handleSectorCommand(sector, subsector) {
if (_pendingHandle) return;
_pendingHandle = true;
try {
if (!sector?.trim()) {
ui.notifications.warn('Usage : /sector <nom du secteur> (ex: /sector "Spinward Marches")');
return;
}
if (!game.user?.isGM) {
ui.notifications.error('Seul le MJ peut utiliser cette commande');
return;
}
await openMap(sector.trim());
await openMap(sector?.trim());
} catch (err) {
console.error(`${MODULE_ID} | Erreur /sector :`, err);
ui.notifications.error(`Erreur : ${err.message}`);
@@ -40,6 +38,14 @@ async function handleSectorCommand(sector, subsector) {
}
}
async function handleSystemCommand(sector, hex) {
if (!sector || !hex) {
ui.notifications.warn('Usage : /system <secteur> <hex> (ex: /system "Spinward Marches" 1910)');
return;
}
await postWorldCardToChat(sector, hex);
}
async function handleSubsectorCommand(raw) {
if (_pendingHandle) return;
_pendingHandle = true;
@@ -75,11 +81,8 @@ if (ChatLogV2?.CHAT_COMMANDS) {
rgx: /^\/sector(?:\s+(.*))?$/i,
fn: function() {
const raw = arguments[1]?.[1]?.trim?.();
if (raw) {
handleSectorCommand(raw);
return false;
}
return true;
handleSectorCommand(raw || undefined);
return false;
},
};
console.log(`${MODULE_ID} | Commande /sector enregistrée`);
@@ -96,6 +99,20 @@ if (ChatLogV2?.CHAT_COMMANDS) {
},
};
console.log(`${MODULE_ID} | Commande /subsector enregistrée`);
ChatLogV2.CHAT_COMMANDS['system'] = {
rgx: /^\/system\s+(.+?)\s+(\d{4})\s*$/i,
fn: function() {
const sector = arguments[1]?.[1]?.trim?.();
const hex = arguments[1]?.[2]?.trim?.();
if (sector && hex) {
handleSystemCommand(sector, hex);
return false;
}
return true;
},
};
console.log(`${MODULE_ID} | Commande /system enregistrée`);
}
/* ───── Hooks de secours (v13 / fallback v14) ───── */
@@ -114,6 +131,12 @@ Hooks.on('preCreateChatMessage', (message, data, options) => {
handleSubsectorCommand(m[1]?.trim());
return false;
}
m = c?.match(/^\/system\s+(.+?)\s+(\d{4})\s*$/i);
if (m) {
handleSystemCommand(m[1]?.trim(), m[2]?.trim());
return false;
}
});
/* ───── Socket (synchronisation MJ → joueurs) ───── */
@@ -124,6 +147,39 @@ Hooks.once('ready', () => {
if (game.user?.isGM) return;
openMap(data.sector, data.subsector);
});
// Clics sur les liens de monde dans les journals de trajet
document.addEventListener('click', (event) => {
const link = event.target.closest('.mgt2-world-link');
if (!link) return;
event.preventDefault();
const sector = link.dataset.sector;
const hex = link.dataset.hex;
if (sector && hex) {
handleSystemCommand(sector, hex);
}
});
// Clics sur le bouton Commerce dans les cartes de monde
document.addEventListener('click', (event) => {
const btn = event.target.closest('.mgt2-world-commerce');
if (!btn) return;
event.preventDefault();
const uwp = btn.dataset.uwp;
const zone = btn.dataset.zone;
const name = btn.dataset.name;
const sector = btn.dataset.sector;
const hex = btn.dataset.hex;
if (uwp) {
const existing = Object.values(ui.windows).find(w => w.id === 'mgt2-commerce');
if (existing) { existing.bringToTop(); return; }
const dialog = new CommerceDialog({
defaultWorld: { uwp, zone, name, sector, hex },
initialTab: 'trade',
});
dialog.render({ force: true });
}
});
});
Hooks.on('chatMessage', (...args) => {
@@ -143,4 +199,10 @@ Hooks.on('chatMessage', (...args) => {
handleSubsectorCommand(m[1]?.trim());
return false;
}
m = msg?.trim()?.match(/^\/system\s+(.+?)\s+(\d{4})\s*$/i);
if (m) {
handleSystemCommand(m[1]?.trim(), m[2]?.trim());
return false;
}
});
+274
View File
@@ -0,0 +1,274 @@
import { searchWorlds, calcParsecs } from './travellerMapApi.js';
const MODULE_ID = 'mgt2-compendium-amiral-denisov';
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
function cleanSectorName(sector) {
return sector?.replace(/\s*\([^)]*\)\s*$/, '').trim() || sector;
}
function worldCoord(sx, sy, hx, hy) {
return { x: (sx - 0) * 32 + (hx - 1), y: (sy - 0) * 40 + (hy - 40) };
}
export class TravelDialog extends HandlebarsApplicationMixin(ApplicationV2) {
static DEFAULT_OPTIONS = {
id: 'mgt2-travel-dialog',
classes: ['mgt2-travel-dialog'],
position: { width: 600, height: 500 },
window: { icon: 'fas fa-route', title: 'Planificateur de voyage', resizable: true, controls: [] },
};
static PARTS = {
main: {
template: `modules/${MODULE_ID}/templates/travel-dialog.hbs`,
},
};
constructor() {
super();
this._fromWorld = null;
this._toWorld = null;
this._lastWorlds = null;
this._lastSegments = null;
}
_onRender(context, options) {
this._wireSearch('from');
this._wireSearch('to');
this.element?.querySelector('[data-action="calculate"]')?.addEventListener('click', () => {
this._calculateRoute();
});
this.element?.querySelector('[data-action="create-journal"]')?.addEventListener('click', () => {
this._createJournal();
});
}
_wireSearch(prefix) {
const input = this.element.querySelector(`[name="travel-${prefix}"]`);
const results = this.element.querySelector(`.travel-${prefix}-results`);
if (!input || !results) return;
let timeout = null;
input.addEventListener('input', () => {
clearTimeout(timeout);
const q = input.value.trim();
if (q.length < 2) { results.innerHTML = ''; return; }
timeout = setTimeout(async () => {
const worlds = await searchWorlds(q);
if (!worlds?.length) {
results.innerHTML = '<li class="no-result">Aucun résultat</li>';
return;
}
results.innerHTML = worlds.slice(0, 10).map(w =>
`<li data-sector="${w.sector}" data-hex="${w.hex}" data-name="${w.name}">
<span class="world-name">${w.name}</span>
<span class="world-sector">${w.sector}</span>
<span class="world-hex">${w.hex}</span>
</li>`
).join('');
}, 300);
});
results.addEventListener('click', (e) => {
const li = e.target.closest('[data-sector]');
if (!li) return;
const data = { sector: li.dataset.sector, hex: li.dataset.hex, name: li.dataset.name };
if (prefix === 'from') this._fromWorld = data;
else this._toWorld = data;
input.value = `${data.name} (${data.sector} ${data.hex})`;
results.innerHTML = '';
});
input.addEventListener('blur', () => {
setTimeout(() => { results.innerHTML = ''; }, 200);
});
}
async _calculateRoute() {
if (!this._fromWorld || !this._toWorld) {
ui.notifications.warn('Veuillez sélectionner un monde de départ et un monde d\'arrivée');
return;
}
const jumpEl = this.element.querySelector('[name="travel-jump"]');
const jump = parseInt(jumpEl?.value, 10) || 2;
const resultsEl = this.element.querySelector('.travel-results');
if (!resultsEl) return;
resultsEl.innerHTML = '<div class="travel-loading"><i class="fas fa-spinner fa-spin"></i> Calcul de l\'itinéraire…</div>';
const startSector = cleanSectorName(this._fromWorld.sector);
const endSector = cleanSectorName(this._toWorld.sector);
const startLoc = `${startSector} ${this._fromWorld.hex}`;
const endLoc = `${endSector} ${this._toWorld.hex}`;
try {
const resp = await fetch(
`https://travellermap.com/api/route?start=${encodeURIComponent(startLoc)}&end=${encodeURIComponent(endLoc)}&jump=${jump}`
);
if (!resp.ok) {
if (resp.status === 404) {
const text = await resp.text().catch(() => 'Aucun itinéraire trouvé');
resultsEl.innerHTML = `<div class="travel-error">${this._escapeHtml(text)}</div>`;
} else {
resultsEl.innerHTML = `<div class="travel-error">Erreur API (${resp.status})</div>`;
}
return;
}
const data = await resp.json();
if (!Array.isArray(data) || data.length < 2) {
resultsEl.innerHTML = '<div class="travel-error">Aucun itinéraire trouvé</div>';
return;
}
this._displayRoute(data, resultsEl);
} catch (err) {
console.error('TravelDialog | Erreur:', err);
resultsEl.innerHTML = `<div class="travel-error">Erreur : ${this._escapeHtml(err.message)}</div>`;
}
}
_displayRoute(worlds, resultsEl) {
this._lastWorlds = worlds;
const segments = [];
let totalParsecs = 0;
for (let i = 0; i < worlds.length - 1; i++) {
const a = worlds[i];
const b = worlds[i + 1];
const fromC = worldCoord(a.SectorX, a.SectorY, a.HexX, a.HexY);
const toC = worldCoord(b.SectorX, b.SectorY, b.HexX, b.HexY);
const dist = calcParsecs(fromC, toC);
totalParsecs += dist;
segments.push({ from: a, to: b, dist });
}
this._lastSegments = segments;
let html = `<div class="travel-route-summary">
<i class="fas fa-route"></i>
<strong>${segments.length}</strong> saut${segments.length > 1 ? 's' : ''}
· <strong>${totalParsecs}</strong> parsecs
</div>`;
html += `<div class="travel-route-duration">
<i class="fas fa-clock"></i> Durée estimée : <strong>${segments.length}</strong> semaine${segments.length > 1 ? 's' : ''}
</div>`;
html += '<ol class="travel-jump-list">';
segments.forEach((seg) => {
const f = seg.from;
const t = seg.to;
html += `<li>
<div class="jump-segment">
<div class="jump-world jump-from">
<span class="jump-world-name">${f.Name || '?'}</span>
<span class="jump-world-detail">${f.Sector} ${f.Hex || ''}</span>
</div>
<div class="jump-arrow"><i class="fas fa-long-arrow-alt-right"></i></div>
<div class="jump-world jump-to">
<span class="jump-world-name">${t.Name || '?'}</span>
<span class="jump-world-detail">${t.Sector} ${t.Hex || ''}</span>
</div>
<div class="jump-distance">Saut-${seg.dist}</div>
</div>
</li>`;
});
html += '</ol>';
resultsEl.innerHTML = html;
const journalBtn = this.element?.querySelector('.travel-journal-actions');
if (journalBtn) journalBtn.style.display = 'block';
}
async _createJournal() {
const worlds = this._lastWorlds;
const segments = this._lastSegments;
if (!worlds?.length || !segments?.length) {
ui.notifications.warn('Calculez d\'abord un itinéraire');
return;
}
const from = worlds[0];
const to = worlds[worlds.length - 1];
const totalParsecs = segments.reduce((s, seg) => s + seg.dist, 0);
const totalJumps = segments.length;
const jumpRating = this.element?.querySelector('[name="travel-jump"]')?.value || '?';
const lines = [];
lines.push(`<h2>Journal de voyage</h2>`);
lines.push(`<p><strong>Départ :</strong> ${this._worldLink(from)}</p>`);
lines.push(`<p><strong>Destination :</strong> ${this._worldLink(to)}</p>`);
lines.push(`<p><strong>Moteur :</strong> J-${jumpRating}</p>`);
lines.push(`<hr>`);
lines.push(`<h3>Itinéraire (${totalJumps} saut${totalJumps > 1 ? 's' : ''}, ${totalParsecs} pc)</h3>`);
lines.push(`<table><thead><tr><th>#</th><th>Départ</th><th>→</th><th>Arrivée</th><th>Distance</th></tr></thead><tbody>`);
segments.forEach((seg, i) => {
const f = seg.from;
const t = seg.to;
lines.push(`<tr>
<td>${i + 1}</td>
<td>${this._worldLink(f)}</td>
<td>→</td>
<td>${this._worldLink(t)}</td>
<td>${seg.dist} pc</td>
</tr>`);
});
lines.push(`</tbody></table>`);
lines.push(`<hr>`);
lines.push(`<h3>Mondes visités</h3>`);
lines.push(`<ul>`);
const seen = new Set();
for (const w of worlds) {
const key = `${w.Sector}|${w.Hex}`;
if (seen.has(key)) continue;
seen.add(key);
lines.push(`<li>${this._worldLink(w)}${w.UWP ? ` — UWP: ${w.UWP}` : ''}</li>`);
}
lines.push(`</ul>`);
const content = lines.join('\n');
try {
const journal = await JournalEntry.create({
name: `Voyage : ${from.Name || '?'}${to.Name || '?'}`,
pages: [{
name: 'Itinéraire',
type: 'text',
text: { content, format: 2 },
}],
});
if (journal) {
ui.notifications.info(`Journal créé : ${journal.name}`);
journal.sheet?.render(true);
}
} catch (err) {
console.error('TravelDialog | Erreur création journal:', err);
ui.notifications.error('Erreur lors de la création du journal');
}
}
_worldLink(w) {
const name = w.Name || '?';
const sector = w.Sector || '';
const hex = w.Hex || '';
return `<a class="mgt2-world-link" data-sector="${this._escapeAttr(sector)}" data-hex="${this._escapeAttr(hex)}">${name}</a> <em>(${sector} ${hex})</em>`;
}
_escapeAttr(str) {
if (!str) return '';
return str.replace(/"/g, '&quot;').replace(/&/g, '&amp;');
}
_escapeHtml(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Fonctions réutilisables pour afficher une carte de monde dans le chat.
*/
import { SectorMapApp } from './SectorMapApp.js';
const BASE_URL = 'https://travellermap.com';
/**
* Récupère les données d'un monde via l'API et poste une carte détaillée dans le chat.
* @param {string} sector Nom du secteur
* @param {string} hex Code hex sur 4 chiffres
* @param {object} [options] { whisper: bool }
*/
export async function postWorldCardToChat(sector, hex, options = {}) {
if (!sector || !hex) {
ui.notifications.error('Secteur et hex requis');
return;
}
const url = `${BASE_URL}/data/${encodeURIComponent(sector)}/${encodeURIComponent(hex)}`;
let resp;
try {
resp = await fetch(url);
} catch (err) {
console.error('worldCard | fetch error:', err);
ui.notifications.error('Erreur réseau');
return;
}
if (!resp.ok) {
ui.notifications.error(`Monde introuvable : ${sector} ${hex}`);
return;
}
const data = await resp.json();
const world = data.Worlds?.[0];
if (!world) {
ui.notifications.error(`Aucune donnée pour ${sector} ${hex}`);
return;
}
const html = SectorMapApp._buildWorldCardHTML(world);
const msgData = { content: html };
if (options.whisper !== false) msgData.whisper = [game.user.id];
await ChatMessage.create(msgData);
}