/**
* 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;
}
async _prepareContext() {
_registerHandlebarsHelpers();
return {
...this._formData,
activeActor: buildActiveActorContext(),
activeTab: this._activeTab,
};
}
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);
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('');
$results.empty();
try {
const worlds = await searchWorlds(query);
if (!worlds.length) {
$results.append('
Aucun monde trouvé');
} else {
worlds.slice(0, 10).forEach((w) => {
const $li = $(`${w.name} ${w.uwp} ${w.sector}`);
$li.on('click', async () => {
$results.empty();
$input.val(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);
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);
}
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('Erreur de connexion à Traveller Map');
}
$btn.prop('disabled', false).html(' 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(``);
}
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(' | '));
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);
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(' | '));
this._tradeGoods = result;
const goodsDiv = html.find('.trade-goods-result');
const listDiv = html.find('.trade-goods-list');
if (result.goods.length === 0) {
listDiv.html('Aucune marchandise disponible sur ce monde.
');
} else {
const rows = result.goods.map((g) => `
`).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 += ``;
}
return new Handlebars.SafeString(``);
});
}