Files
mgt2-compendium-amiral-denisov/scripts/CommerceDialog.js

397 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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>`);
});
}