/** * 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(``); }); }