Files
mgt2-compendium-amiral-denisov/scripts/CommerceDialog.js
T
2026-06-02 00:16:08 +02:00

541 lines
20 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 (ApplicationV2 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';
import { buildActiveActorContext, COMMERCE_SKILLS, getActiveTravellerActor, rollActorSkillEffect, getActorSkillSummary } from './mgt2eSkills.js';
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
const MODULE_ID = 'mgt2-compendium-amiral-denisov';
export class CommerceDialog extends HandlebarsApplicationMixin(ApplicationV2) {
static DEFAULT_OPTIONS = {
id: 'mgt2-commerce',
classes: ['mgt2-commerce-dialog'],
position: {
width: 780,
height: 'auto',
},
window: {
title: 'Commerce MgT2e',
resizable: true,
},
};
static PARTS = {
main: {
template: `modules/${MODULE_ID}/templates/commerce-dialog.hbs`,
root: true,
},
};
constructor(options = {}) {
super(options);
this._activeTab = options.initialTab ?? 'passengers';
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;
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();
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) {
await super._onRender(context, options);
const html = this._getForm();
if (!html?.length) return;
html.addClass('mgt2-commerce-form');
this._applyThemeStyles(html);
html.find('[data-action="calculate-passengers"]').on('click', async (ev) => {
ev.preventDefault();
this._readForm(html);
await this._handlePassengers();
});
html.find('[data-action="calculate-cargo"]').on('click', async (ev) => {
ev.preventDefault();
this._readForm(html);
await this._handleCargo();
});
html.find('[data-action="find-goods"]').on('click', async (ev) => {
ev.preventDefault();
this._readForm(html);
await this._handleFindGoods(html);
});
html.on('click', '[data-action="calculate-buy-prices"]', async (ev) => {
ev.preventDefault();
this._readForm(html);
await this._handleBuyPrices();
});
html.find('[data-action="load-pax-actor"]').on('click', async (ev) => {
ev.preventDefault();
this._applyPassengerActorData(html);
});
html.find('[data-action="roll-pax-effect"]').on('click', async (ev) => {
ev.preventDefault();
await this._rollActorEffectIntoField(html, 'pax.skillEffect', COMMERCE_SKILLS.passengerEffect);
});
html.find('[data-action="load-cargo-actor"]').on('click', async (ev) => {
ev.preventDefault();
this._applyCargoActorData(html);
});
html.find('[data-action="roll-cargo-effect"]').on('click', async (ev) => {
ev.preventDefault();
await this._rollActorEffectIntoField(html, 'cargo.skillEffect', COMMERCE_SKILLS.cargoEffect);
});
html.find('[data-action="load-trade-actor"]').on('click', async (ev) => {
ev.preventDefault();
this._applyTradeActorData(html);
});
html.find('.tabs .item').on('click', (ev) => {
ev.preventDefault();
this._readForm(html);
this._activateTab($(ev.currentTarget).data('tab'));
});
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();
}
});
}
_getForm() {
return $(this.element).find('.window-content');
}
_activateTab(tabId) {
const html = this._getForm();
if (!html?.length) return;
this._activeTab = tabId;
html.find('.tabs .item').removeClass('active');
html.find(`.tabs .item[data-tab="${tabId}"]`).addClass('active');
html.find('.tab-content .tab').removeClass('active');
html.find(`.tab-content .tab[data-tab="${tabId}"]`).addClass('active');
this._applyThemeStyles(html);
}
_applyThemeStyles(html) {
html.find('.tabs .item').css({
color: '#d8c79a',
'text-shadow': 'none',
'background-color': '',
'border-bottom-color': 'transparent'
});
html.find('.tabs .item.active').css({
color: '#d9b24c',
'text-shadow': 'none',
'background-color': 'rgba(201, 162, 39, 0.18)',
'border-bottom-color': '#c9a227'
});
html.find('h3').css({
color: '#5f4300',
'border-bottom-color': '#b78f26',
'text-shadow': 'none'
});
html.find('legend').css({
color: '#7a5c00',
'text-shadow': 'none'
});
}
_bindWorldSearch(html) {
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);
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),
]);
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);
$widget.data('coords', coords);
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);
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) {
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);
}
}
this._readForm(html);
});
$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();
}
});
});
}
_readForm(html) {
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(), 10) || 1;
this._formData.pax.skillEffect = parseInt(html.find('[name="pax.skillEffect"]').val(), 10) || 0;
this._formData.pax.stewardLevel = parseInt(html.find('[name="pax.stewardLevel"]').val(), 10) || 0;
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(), 10) || 1;
this._formData.cargo.skillEffect = parseInt(html.find('[name="cargo.skillEffect"]').val(), 10) || 0;
this._formData.cargo.navyRank = parseInt(html.find('[name="cargo.navyRank"]').val(), 10) || 0;
this._formData.cargo.scoutRank = parseInt(html.find('[name="cargo.scoutRank"]').val(), 10) || 0;
this._formData.cargo.socMod = parseInt(html.find('[name="cargo.socMod"]').val(), 10) || 0;
this._formData.cargo.armed = html.find('[name="cargo.armed"]').is(':checked');
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(), 10) || 0;
this._formData.trade.previousAttempts = parseInt(html.find('[name="trade.previousAttempts"]').val(), 10) || 0;
this._formData.trade.blackMarket = html.find('[name="trade.blackMarket"]').is(':checked');
}
_getActiveActorOrWarn() {
const { actor } = getActiveTravellerActor();
if (!actor) {
ui.notifications.warn('Aucun token sélectionné ni personnage assigné pour lire les compétences mgt2e.');
return null;
}
return actor;
}
_applyPassengerActorData(html) {
const actor = this._getActiveActorOrWarn();
if (!actor) return;
const steward = getActorSkillSummary(actor, COMMERCE_SKILLS.steward);
this._setNumericSelectValue(html, 'pax.stewardLevel', steward.value);
this._readForm(html);
ui.notifications.info(`${actor.name} : Intendant ${steward.value} chargé dans le calcul passagers.`);
}
_applyCargoActorData(html) {
const actor = this._getActiveActorOrWarn();
if (!actor) return;
const socMod = Number(actor.system.characteristics?.SOC?.dm ?? 0);
this._setNumericSelectValue(html, 'cargo.socMod', socMod);
this._readForm(html);
ui.notifications.info(`${actor.name} : DM SOC ${socMod >= 0 ? '+' : ''}${socMod} chargé dans le calcul cargaison.`);
}
_applyTradeActorData(html) {
const actor = this._getActiveActorOrWarn();
if (!actor) return;
const broker = getActorSkillSummary(actor, COMMERCE_SKILLS.tradeBroker);
this._setNumericSelectValue(html, 'trade.brokerSkill', broker.value);
this._readForm(html);
ui.notifications.info(`${actor.name} : Courtier ${broker.value} chargé pour le commerce spéculatif.`);
}
async _rollActorEffectIntoField(html, fieldName, skillList) {
const actor = this._getActiveActorOrWarn();
if (!actor) return;
const rollResult = await rollActorSkillEffect(actor, skillList);
if (!rollResult) {
ui.notifications.warn(`${actor.name} ne possède aucune des compétences attendues pour ce calcul.`);
return;
}
this._setNumericSelectValue(html, fieldName, rollResult.effect);
this._readForm(html);
ui.notifications.info(`${actor.name} : ${rollResult.label} → 2D6 (${rollResult.diceTotal}) + ${rollResult.totalModifier >= 0 ? '+' : ''}${rollResult.totalModifier} = ${rollResult.total}, effet ${rollResult.effect >= 0 ? '+' : ''}${rollResult.effect}.`);
}
_setNumericSelectValue(html, fieldName, value) {
const select = html.find(`[name="${fieldName}"]`);
if (!select.length) return;
const normalized = Number(value ?? 0);
if (!select.find(`option[value="${normalized}"]`).length) {
const label = normalized > 0 ? `+${normalized}` : `${normalized}`;
select.append(`<option value="${normalized}">${label}</option>`);
}
select.val(`${normalized}`);
}
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(' | '));
result.dep = { ...result.dep, name: this._worldNames['pax.uwpDep'] || '' };
result.dest = { ...result.dest, name: this._worldNames['pax.uwpDest'] || '' };
await this._postToChatResult(result);
}
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(' | '));
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);
}
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(' | '));
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');
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);
}
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 });
}
async _postToChatResult(data) {
_registerHandlebarsHelpers();
const html = await foundry.applications.handlebars.renderTemplate(`modules/${MODULE_ID}/templates/commerce-result.hbs`, data);
await ChatMessage.create({
content: html,
speaker: ChatMessage.getSpeaker(),
flags: { [MODULE_ID]: { type: 'commerce-result' } },
});
}
}
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);
Handlebars.registerHelper('modClass', (n) => (Number(n) > 0 ? 'mod-pos' : Number(n) < 0 ? 'mod-neg' : 'mod-zero'));
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>`);
});
}