397 lines
16 KiB
JavaScript
397 lines
16 KiB
JavaScript
/**
|
||
* MGT2 Commerce – CommerceDialog
|
||
*
|
||
* Boîte de dialogue principale (FormApplication FoundryVTT).
|
||
* Trois onglets : Passagers / Cargaison / Commerce spéculatif.
|
||
* Les résultats sont postés dans le chat.
|
||
*/
|
||
|
||
import { calculatePassengers, calculateCargo, findAvailableGoods, calculatePrice, formatCredits } from './tradeHelper.js';
|
||
import { searchWorlds, fetchWorldDetail, fetchWorldCoordinates, calcParsecs } from './travellerMapApi.js';
|
||
|
||
const MODULE_ID = 'mgt2-compendium-amiral-denisov';
|
||
|
||
export class CommerceDialog extends FormApplication {
|
||
|
||
constructor(options = {}) {
|
||
super({}, options);
|
||
// Valeurs par défaut du formulaire
|
||
this._formData = {
|
||
pax: {
|
||
uwpDep: '', uwpDest: '', zoneDep: 'normal', zoneDest: 'normal',
|
||
parsecs: 1, skillEffect: 0, stewardLevel: 0,
|
||
},
|
||
cargo: {
|
||
uwpDep: '', uwpDest: '', zoneDep: 'normal', zoneDest: 'normal',
|
||
parsecs: 1, skillEffect: 0, navyRank: 0, scoutRank: 0, socMod: 0, armed: false,
|
||
},
|
||
trade: {
|
||
uwp: '', zone: 'normal', brokerSkill: 0, previousAttempts: 0, blackMarket: false,
|
||
},
|
||
};
|
||
this._tradeGoods = null; // résultats de findAvailableGoods, conservés pour le calcul des prix
|
||
}
|
||
|
||
static get defaultOptions() {
|
||
return mergeObject(super.defaultOptions, {
|
||
id: 'mgt2-commerce',
|
||
title: 'Commerce – MGT2',
|
||
template: `modules/${MODULE_ID}/templates/commerce-dialog.hbs`,
|
||
width: 780,
|
||
height: 'auto',
|
||
resizable: true,
|
||
tabs: [{
|
||
navSelector: '.tabs',
|
||
contentSelector: '.tab-content',
|
||
initial: 'passengers',
|
||
}],
|
||
classes: ['mgt2-commerce-dialog'],
|
||
});
|
||
}
|
||
|
||
getData() {
|
||
_registerHandlebarsHelpers();
|
||
return foundry.utils.mergeObject(super.getData(), this._formData);
|
||
}
|
||
|
||
activateListeners(html) {
|
||
super.activateListeners(html);
|
||
|
||
// Bouton "Calculer les passagers"
|
||
html.find('[data-action="calculate-passengers"]').on('click', async (ev) => {
|
||
ev.preventDefault();
|
||
this._readForm(html);
|
||
await this._handlePassengers();
|
||
});
|
||
|
||
// Bouton "Calculer la cargaison"
|
||
html.find('[data-action="calculate-cargo"]').on('click', async (ev) => {
|
||
ev.preventDefault();
|
||
this._readForm(html);
|
||
await this._handleCargo();
|
||
});
|
||
|
||
// Bouton "Trouver un fournisseur & marchandises"
|
||
html.find('[data-action="find-goods"]').on('click', async (ev) => {
|
||
ev.preventDefault();
|
||
this._readForm(html);
|
||
await this._handleFindGoods(html);
|
||
});
|
||
|
||
// Bouton "Calculer les prix d'achat"
|
||
html.on('click', '[data-action="calculate-buy-prices"]', async (ev) => {
|
||
ev.preventDefault();
|
||
await this._handleBuyPrices();
|
||
});
|
||
|
||
// ─── Recherche de monde (Traveller Map API) ───────────────────────────────
|
||
html.find('.world-search-widget').each((_, widget) => {
|
||
const $widget = $(widget);
|
||
const $input = $widget.find('.world-search-input');
|
||
const $btn = $widget.find('.btn-world-search');
|
||
const $results = $widget.find('.world-search-results');
|
||
const uwpTarget = $widget.data('uwp-target');
|
||
const zoneTarget = $widget.data('zone-target');
|
||
const parsecsTarget = $widget.data('parsecs-target') || null;
|
||
const role = $widget.data('role') || null;
|
||
|
||
const doSearch = async () => {
|
||
const query = $input.val().trim();
|
||
if (query.length < 2) return;
|
||
$btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i>');
|
||
$results.empty();
|
||
try {
|
||
const worlds = await searchWorlds(query);
|
||
if (!worlds.length) {
|
||
$results.append('<li class="no-result">Aucun monde trouvé</li>');
|
||
} else {
|
||
worlds.slice(0, 10).forEach(w => {
|
||
const $li = $(`<li data-sector="${w.sector}" data-hex="${w.hex}"><span class="world-name">${w.name}</span> <span class="world-uwp">${w.uwp}</span> <span class="world-sector">${w.sector}</span></li>`);
|
||
$li.on('click', async () => {
|
||
$results.empty();
|
||
$input.val(w.name);
|
||
// Récupère les détails (UWP précis + zone) et les coordonnées en parallèle
|
||
const [detail, coords] = await Promise.all([
|
||
fetchWorldDetail(w.sector, w.hex).catch(() => null),
|
||
fetchWorldCoordinates(w.sector, w.hex).catch(() => null),
|
||
]);
|
||
|
||
const resolvedUwp = detail ? detail.uwp : w.uwp;
|
||
const resolvedZone = detail ? detail.zone : 'normal';
|
||
|
||
html.find(`[name="${uwpTarget}"]`).val(resolvedUwp);
|
||
html.find(`[name="${zoneTarget}"]`).val(resolvedZone);
|
||
|
||
// Stocker les coordonnées dans le widget courant
|
||
$widget.data('coords', coords);
|
||
|
||
// Synchronisation inter-onglets : départ Passagers → départ Cargaison + monde Commerce
|
||
if (uwpTarget === 'pax.uwpDep') {
|
||
const $cargoDep = html.find('.world-search-widget[data-uwp-target="cargo.uwpDep"]');
|
||
$cargoDep.find('.world-search-input').val(w.name);
|
||
html.find('[name="cargo.uwpDep"]').val(resolvedUwp);
|
||
html.find('[name="cargo.zoneDep"]').val(resolvedZone);
|
||
$cargoDep.data('coords', coords);
|
||
|
||
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);
|
||
}
|
||
|
||
// Calculer les parsecs si le widget partenaire a aussi ses coordonnées
|
||
if (parsecsTarget) {
|
||
const partnerRole = role === 'dep' ? 'dest' : 'dep';
|
||
const $partner = html.find(
|
||
`.world-search-widget[data-parsecs-target="${parsecsTarget}"][data-role="${partnerRole}"]`
|
||
);
|
||
const partnerCoords = $partner.data('coords');
|
||
if (coords && partnerCoords) {
|
||
const dist = calcParsecs(coords, partnerCoords);
|
||
html.find(`[name="${parsecsTarget}"]`).val(dist);
|
||
}
|
||
}
|
||
});
|
||
$results.append($li);
|
||
});
|
||
}
|
||
} catch (err) {
|
||
$results.append('<li class="no-result">Erreur de connexion à Traveller Map</li>');
|
||
}
|
||
$btn.prop('disabled', false).html('<i class="fas fa-globe"></i> Chercher');
|
||
};
|
||
|
||
$btn.on('click', doSearch);
|
||
$input.on('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); doSearch(); } });
|
||
});
|
||
|
||
// Fermer les listes de résultats en cliquant ailleurs
|
||
html.on('click', (ev) => {
|
||
if (!$(ev.target).closest('.world-search-widget').length) {
|
||
html.find('.world-search-results').empty();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ─── Lecture du formulaire ─────────────────────────────────────────────────
|
||
|
||
_readForm(html) {
|
||
// Passagers
|
||
this._formData.pax.uwpDep = html.find('[name="pax.uwpDep"]').val();
|
||
this._formData.pax.uwpDest = html.find('[name="pax.uwpDest"]').val();
|
||
this._formData.pax.zoneDep = html.find('[name="pax.zoneDep"]').val();
|
||
this._formData.pax.zoneDest = html.find('[name="pax.zoneDest"]').val();
|
||
this._formData.pax.parsecs = parseInt(html.find('[name="pax.parsecs"]').val()) || 1;
|
||
this._formData.pax.skillEffect = parseInt(html.find('[name="pax.skillEffect"]').val()) || 0;
|
||
this._formData.pax.stewardLevel = parseInt(html.find('[name="pax.stewardLevel"]').val()) || 0;
|
||
|
||
// Cargaison
|
||
this._formData.cargo.uwpDep = html.find('[name="cargo.uwpDep"]').val();
|
||
this._formData.cargo.uwpDest = html.find('[name="cargo.uwpDest"]').val();
|
||
this._formData.cargo.zoneDep = html.find('[name="cargo.zoneDep"]').val();
|
||
this._formData.cargo.zoneDest = html.find('[name="cargo.zoneDest"]').val();
|
||
this._formData.cargo.parsecs = parseInt(html.find('[name="cargo.parsecs"]').val()) || 1;
|
||
this._formData.cargo.skillEffect = parseInt(html.find('[name="cargo.skillEffect"]').val()) || 0;
|
||
this._formData.cargo.navyRank = parseInt(html.find('[name="cargo.navyRank"]').val()) || 0;
|
||
this._formData.cargo.scoutRank = parseInt(html.find('[name="cargo.scoutRank"]').val()) || 0;
|
||
this._formData.cargo.socMod = parseInt(html.find('[name="cargo.socMod"]').val()) || 0;
|
||
this._formData.cargo.armed = html.find('[name="cargo.armed"]').is(':checked');
|
||
|
||
// Commerce spéculatif
|
||
this._formData.trade.uwp = html.find('[name="trade.uwp"]').val();
|
||
this._formData.trade.zone = html.find('[name="trade.zone"]').val();
|
||
this._formData.trade.brokerSkill = parseInt(html.find('[name="trade.brokerSkill"]').val()) || 0;
|
||
this._formData.trade.previousAttempts = parseInt(html.find('[name="trade.previousAttempts"]').val()) || 0;
|
||
this._formData.trade.blackMarket = html.find('[name="trade.blackMarket"]').is(':checked');
|
||
}
|
||
|
||
// ─── Passagers ─────────────────────────────────────────────────────────────
|
||
|
||
async _handlePassengers() {
|
||
const p = this._formData.pax;
|
||
if (!p.uwpDep || !p.uwpDest) {
|
||
return ui.notifications.warn('Veuillez saisir les UWP de départ et de destination.');
|
||
}
|
||
|
||
const result = await calculatePassengers({
|
||
uwpDep: p.uwpDep,
|
||
uwpDest: p.uwpDest,
|
||
zoneDep: p.zoneDep,
|
||
zoneDest: p.zoneDest,
|
||
parsecs: p.parsecs,
|
||
skillEffect: p.skillEffect,
|
||
stewardLevel: p.stewardLevel,
|
||
});
|
||
|
||
if (!result.success) {
|
||
return ui.notifications.error(result.errors.join(' | '));
|
||
}
|
||
|
||
await this._postToChatResult(result);
|
||
}
|
||
|
||
// ─── Cargaison ─────────────────────────────────────────────────────────────
|
||
|
||
async _handleCargo() {
|
||
const c = this._formData.cargo;
|
||
if (!c.uwpDep || !c.uwpDest) {
|
||
return ui.notifications.warn('Veuillez saisir les UWP de départ et de destination.');
|
||
}
|
||
|
||
const result = await calculateCargo({
|
||
uwpDep: c.uwpDep,
|
||
uwpDest: c.uwpDest,
|
||
zoneDep: c.zoneDep,
|
||
zoneDest: c.zoneDest,
|
||
parsecs: c.parsecs,
|
||
skillEffect: c.skillEffect,
|
||
navyRank: c.navyRank,
|
||
scoutRank: c.scoutRank,
|
||
socMod: c.socMod,
|
||
armed: c.armed,
|
||
});
|
||
|
||
if (!result.success) {
|
||
return ui.notifications.error(result.errors.join(' | '));
|
||
}
|
||
|
||
// Calcule le sous-total cargaison pour le template
|
||
result.cargoRevenue = result.lots.reduce((s, l) => s + l.revenue, 0);
|
||
|
||
await this._postToChatResult(result);
|
||
}
|
||
|
||
// ─── Commerce spéculatif – trouver les marchandises ────────────────────────
|
||
|
||
async _handleFindGoods(html) {
|
||
const t = this._formData.trade;
|
||
if (!t.uwp) {
|
||
return ui.notifications.warn('Veuillez saisir le code UWP du monde.');
|
||
}
|
||
|
||
const result = await findAvailableGoods({
|
||
uwp: t.uwp,
|
||
zone: t.zone,
|
||
blackMarket: t.blackMarket,
|
||
brokerSkill: t.brokerSkill,
|
||
previousAttempts: t.previousAttempts,
|
||
});
|
||
|
||
if (!result.success) {
|
||
return ui.notifications.error(result.errors.join(' | '));
|
||
}
|
||
|
||
this._tradeGoods = result;
|
||
|
||
// Affiche un aperçu inline dans l'onglet
|
||
const goodsDiv = html.find('.trade-goods-result');
|
||
const listDiv = html.find('.trade-goods-list');
|
||
|
||
if (result.goods.length === 0) {
|
||
listDiv.html('<p><em>Aucune marchandise disponible sur ce monde.</em></p>');
|
||
} else {
|
||
const rows = result.goods.map(g => `
|
||
<div class="trade-good-item">
|
||
<label>
|
||
<input type="checkbox" class="good-select" data-d66="${g.d66}" checked>
|
||
<strong>[${g.d66}] ${g.name}</strong>
|
||
— ${g.tons} t — ${formatCredits(g.basePrice)}/t
|
||
(achat MD ${g.buyMod >= 0 ? '+' : ''}${g.buyMod},
|
||
vente MD ${g.sellMod >= 0 ? '+' : ''}${g.sellMod})
|
||
</label>
|
||
</div>
|
||
`).join('');
|
||
listDiv.html(rows);
|
||
}
|
||
|
||
goodsDiv.show();
|
||
await this._postToChatResult(result);
|
||
}
|
||
|
||
// ─── Commerce spéculatif – calculer les prix d'achat ───────────────────────
|
||
|
||
async _handleBuyPrices() {
|
||
if (!this._tradeGoods || !this._tradeGoods.goods.length) {
|
||
return ui.notifications.warn('Veuillez d\'abord trouver les marchandises disponibles.');
|
||
}
|
||
|
||
const brokerSkill = this._formData.trade.brokerSkill;
|
||
const prices = [];
|
||
|
||
for (const g of this._tradeGoods.goods) {
|
||
const priceResult = await calculatePrice({
|
||
basePrice: g.basePrice,
|
||
buyMod: g.buyMod,
|
||
sellMod: g.sellMod,
|
||
brokerSkill,
|
||
mode: 'buy',
|
||
});
|
||
prices.push({
|
||
name: g.name,
|
||
tons: g.tons,
|
||
basePrice: g.basePrice,
|
||
diceResult: priceResult.diceResult,
|
||
modifier: priceResult.modifier,
|
||
total: priceResult.total,
|
||
percent: priceResult.percent,
|
||
actualPrice: priceResult.actualPrice,
|
||
totalCost: priceResult.actualPrice * g.tons,
|
||
});
|
||
}
|
||
|
||
const grandTotal = prices.reduce((s, p) => s + p.totalCost, 0);
|
||
|
||
await this._postToChatResult({ type: 'buy-prices', prices, grandTotal });
|
||
}
|
||
|
||
// ─── Post en chat ───────────────────────────────────────────────────────────
|
||
|
||
async _postToChatResult(data) {
|
||
// Ajoute les helpers Handlebars nécessaires (si pas déjà enregistrés)
|
||
_registerHandlebarsHelpers();
|
||
|
||
const html = await renderTemplate(
|
||
`modules/${MODULE_ID}/templates/commerce-result.hbs`,
|
||
data,
|
||
);
|
||
|
||
await ChatMessage.create({
|
||
content: html,
|
||
speaker: ChatMessage.getSpeaker(),
|
||
flags: { [MODULE_ID]: { type: 'commerce-result' } },
|
||
});
|
||
}
|
||
|
||
/** Nécessaire pour FormApplication : ne soumet rien (tout est piloté par boutons). */
|
||
async _updateObject(_event, _formData) {}
|
||
}
|
||
|
||
// ─── Helpers Handlebars ───────────────────────────────────────────────────────
|
||
|
||
let _helpersRegistered = false;
|
||
|
||
function _registerHandlebarsHelpers() {
|
||
if (_helpersRegistered) return;
|
||
_helpersRegistered = true;
|
||
|
||
Handlebars.registerHelper('formatCredits', (amount) => formatCredits(amount));
|
||
Handlebars.registerHelper('join', (arr, sep) => (Array.isArray(arr) ? arr.join(sep) : ''));
|
||
Handlebars.registerHelper('gt', (a, b) => a > b);
|
||
Handlebars.registerHelper('gte', (a, b) => a >= b);
|
||
Handlebars.registerHelper('eq', (a, b) => a === b);
|
||
|
||
/** Classe CSS selon le signe du modificateur. */
|
||
Handlebars.registerHelper('modClass', (n) => Number(n) > 0 ? 'mod-pos' : Number(n) < 0 ? 'mod-neg' : 'mod-zero');
|
||
|
||
/** Génère un <select> numérique de min à max, avec +N pour les positifs. */
|
||
Handlebars.registerHelper('modSelect', function(id, name, value, min, max) {
|
||
let opts = '';
|
||
for (let i = min; i <= max; i++) {
|
||
const label = i > 0 ? `+${i}` : `${i}`;
|
||
const sel = value === i ? ' selected' : '';
|
||
opts += `<option value="${i}"${sel}>${label}</option>`;
|
||
}
|
||
return new Handlebars.SafeString(`<select id="${id}" name="${name}">${opts}</select>`);
|
||
});
|
||
}
|