/** * MGT2 Commerce – Logique métier * * Toutes les fonctions retournent des objets détaillés pour alimenter * la carte de chat et le dialogue. Les jets de dés utilisent l'API Roll * de FoundryVTT (asynchrone). */ import { PASSAGE_COSTS, PASSENGER_TRAFFIC, CARGO_TRAFFIC, GOODS_TABLE, CARGO_LOT_SIZES, getModifiedPrice, } from './data/tradeTables.js'; import { parseUWP, starportModifier, populationModifier, zoneModifier } from './uwpParser.js'; /** * Évalue une formule de dés et retourne { formula, total, rolls }. * Affiche le jet dans le chat FoundryVTT si whisper=false. * @param {string} formula * @returns {Promise<{formula: string, total: number}>} */ async function rollDice(formula) { if (formula === '0') return { formula: '0', total: 0 }; const roll = await new Roll(formula).evaluate(); return { formula, total: roll.total }; } // ─── Passagers ──────────────────────────────────────────────────────────────── /** * Calcule le trafic de passagers entre deux mondes. * * @param {object} params * @param {string} params.uwpDep UWP du monde de départ * @param {string} params.uwpDest UWP du monde de destination * @param {string} params.zoneDep Zone du départ ('normal'|'amber'|'red') * @param {string} params.zoneDest Zone de destination * @param {number} params.parsecs Distance en parsecs (1–6) * @param {number} params.skillEffect Effet du test de compétence (Mondanités/Courtier/Sens de rue) * @param {number} params.stewardLevel Niveau de compétence Intendant (chef) * @returns {Promise} */ export async function calculatePassengers(params) { const { uwpDep, uwpDest, zoneDep = 'normal', zoneDest = 'normal', parsecs = 1, skillEffect = 0, stewardLevel = 0, } = params; const dep = parseUWP(uwpDep, zoneDep); const dest = parseUWP(uwpDest, zoneDest); const errors = []; if (!dep.valid) errors.push(`Départ : ${dep.error}`); if (!dest.valid) errors.push(`Destination : ${dest.error}`); if (errors.length) return { success: false, errors }; // Modificateurs communs aux deux mondes function worldMods(w) { return populationModifier(w.population, 'passenger') + starportModifier(w.starport, 'passenger') + zoneModifier(w.zone, 'passenger'); } // Modificateur de distance (au-delà du 1er parsec) const distanceMod = -(Math.max(1, parsecs) - 1); const categories = ['sup', 'inter', 'eco', 'inf']; const categoryMods = { sup: -4, inter: 0, eco: 0, inf: 1 }; const results = []; for (const cat of categories) { const baseMods = worldMods(dep) + worldMods(dest) + skillEffect + stewardLevel + categoryMods[cat] + distanceMod; const diceRoll = await rollDice('2d6'); const total = Math.max(1, diceRoll.total + baseMods); const numKey = String(Math.min(20, Math.max(1, total))); const numFormula = PASSENGER_TRAFFIC[numKey] ?? '0'; const numRoll = await rollDice(numFormula); const costEntry = PASSAGE_COSTS[Math.min(parsecs, 6) - 1]; const pricePerPax = costEntry ? costEntry[cat] : 0; results.push({ category: cat, label: { sup: 'Supérieur', inter: 'Intermédiaire', eco: 'Éco', inf: 'Inférieur' }[cat], modifiers: baseMods, diceResult: diceRoll.total, trafficTotal: total, countFormula: numFormula, count: numRoll.total, pricePerPax, revenue: numRoll.total * pricePerPax, }); } const totalRevenue = results.reduce((s, r) => s + r.revenue, 0); return { success: true, type: 'passengers', dep: { uwp: dep.raw, starport: dep.starport, population: dep.population, tradeCodes: dep.tradeCodes }, dest: { uwp: dest.raw, starport: dest.starport, population: dest.population, tradeCodes: dest.tradeCodes }, parsecs, categories: results, totalRevenue, }; } // ─── Cargaison ──────────────────────────────────────────────────────────────── /** * Calcule le trafic de cargaison et le courrier disponibles. * * @param {object} params * @param {string} params.uwpDep * @param {string} params.uwpDest * @param {string} params.zoneDep * @param {string} params.zoneDest * @param {number} params.parsecs * @param {number} params.skillEffect Effet du test Courtier/Sens de rue * @param {number} params.navyRank Rang le plus élevé dans la Marine (pour courrier) * @param {number} params.scoutRank Rang le plus élevé dans les Éclaireurs (pour courrier) * @param {number} params.socMod MD SOC le plus élevé des Voyageurs (pour courrier) * @param {boolean} params.armed Le vaisseau est-il armé ? * @returns {Promise} */ export async function calculateCargo(params) { const { uwpDep, uwpDest, zoneDep = 'normal', zoneDest = 'normal', parsecs = 1, skillEffect = 0, navyRank = 0, scoutRank = 0, socMod = 0, armed = false, } = params; const dep = parseUWP(uwpDep, zoneDep); const dest = parseUWP(uwpDest, zoneDest); const errors = []; if (!dep.valid) errors.push(`Départ : ${dep.error}`); if (!dest.valid) errors.push(`Destination : ${dest.error}`); if (errors.length) return { success: false, errors }; const distanceMod = -(Math.max(1, parsecs) - 1); function worldCargoBonuses(w) { return populationModifier(w.population, 'cargo') + starportModifier(w.starport, 'cargo') + zoneModifier(w.zone, 'cargo') + (w.techLevel >= 9 ? 2 : 0) + (w.techLevel <= 6 ? -1 : 0); } const baseMod = worldCargoBonuses(dep) + worldCargoBonuses(dest) + skillEffect + distanceMod; const lotTypes = [ { key: 'access', label: 'Lots accessoires', sizeMod: 2 }, { key: 'minor', label: 'Lots mineurs', sizeMod: 0 }, { key: 'major', label: 'Lots majeurs', sizeMod: -4 }, ]; const lots = []; let trafficMd = 0; // On garde le MD de trafic pour le calcul du courrier (avg) for (const lt of lotTypes) { const dice = await rollDice('2d6'); const total = Math.max(1, dice.total + baseMod + lt.sizeMod); trafficMd = baseMod; // conserve le MD de base pour le courrier const numKey = String(Math.min(20, Math.max(1, total))); const numFormula = CARGO_TRAFFIC[numKey] ?? '0'; const numRoll = await rollDice(numFormula); const tonsDice = CARGO_LOT_SIZES[lt.key].formula; const tonsEntry = await rollDice(tonsDice); const costEntry = PASSAGE_COSTS[Math.min(parsecs, 6) - 1]; const ratePerTon = costEntry ? costEntry.freight : 0; lots.push({ key: lt.key, label: lt.label, modifiers: baseMod + lt.sizeMod, diceResult: dice.total, trafficTotal: total, countFormula: numFormula, count: numRoll.total, tonsPerLot: tonsEntry.total, ratePerTon, revenue: numRoll.total * tonsEntry.total * ratePerTon, }); } // ── Courrier ────────────────────────────────────────────────────────────── // MD courrier = MD trafic cargaison converti + autres modificateurs const mailMd = _cargoMdToMailMd(trafficMd) + (armed ? 2 : 0) + (dep.techLevel <= 5 ? -4 : 0) + Math.max(navyRank, scoutRank) + socMod; const mailDice = await rollDice('2d6'); const mailTotal = mailDice.total + mailMd; let mail = null; if (mailTotal >= 12) { const mailCount = await rollDice('1d6'); mail = { available: true, count: mailCount.total, revenuePerContainer: 25000, revenue: mailCount.total * 25000, }; } else { mail = { available: false, count: 0, revenue: 0 }; } const totalRevenue = lots.reduce((s, l) => s + l.revenue, 0) + mail.revenue; return { success: true, type: 'cargo', dep: { uwp: dep.raw, starport: dep.starport, techLevel: dep.techLevel }, dest: { uwp: dest.raw, starport: dest.starport, techLevel: dest.techLevel }, parsecs, lots, mail, totalRevenue, }; } /** Convertit le MD de trafic de cargaison en MD courrier (table page 237). */ function _cargoMdToMailMd(md) { if (md <= -10) return -2; if (md <= -5) return -1; if (md <= 4) return 0; if (md <= 9) return 1; return 2; } // ─── Commerce spéculatif ───────────────────────────────────────────────────── /** * Détermine les marchandises disponibles chez un fournisseur sur un monde donné. * Ne roule pas encore les prix (étape séparée). * * @param {object} params * @param {string} params.uwp * @param {string} params.zone * @param {boolean} params.blackMarket Recherche sur le marché noir ? * @param {number} params.brokerSkill Niveau de compétence Courtier du PJ * @param {number} params.previousAttempts Nombre de tentatives précédentes ce mois (MD –1 chacune) * @returns {Promise} */ export async function findAvailableGoods(params) { const { uwp, zone = 'normal', blackMarket = false, brokerSkill = 0, previousAttempts = 0, } = params; const world = parseUWP(uwp, zone); if (!world.valid) return { success: false, errors: [world.error] }; const tradeCodes = world.tradeCodes; // Jet de recherche de fournisseur : 2D + Courtier – tentatives précédentes // (MGT2 p. 240 : chaque tentative précédente ce mois = MD –1) const supplierMod = brokerSkill - previousAttempts; const supplierRoll = await rollDice('2d6'); const supplierTotal = supplierRoll.total + supplierMod; // Marchandises dont la disponibilité correspond au monde const eligible = GOODS_TABLE.filter(g => { if (blackMarket) { // Marché noir : uniquement les marchandises illégales (D66 6x), en respectant la disponibilité return g.illegal && (g.availability.includes('all') || g.availability.some(code => tradeCodes.includes(code))); } if (g.illegal) return false; // exclut les marchandises illégales du marché normal if (g.availability.includes('all')) return true; return g.availability.some(code => tradeCodes.includes(code)); }); // Quantité aléatoire déterminée pour chaque marchandise disponible const goods = []; for (const g of eligible) { const { formula: tonsFormula, modifier: tonsMod } = adjustTonsDice(g.tonsDice, world.population); const tonsRoll = await rollDice(tonsFormula); const tons = Math.max(0, tonsRoll.total + tonsMod); const buyMod = highestApplicableMod(g.buyMod, tradeCodes, world.zone); const sellMod = highestApplicableMod(g.sellMod, tradeCodes, world.zone); goods.push({ d66: g.d66, name: g.name, basePrice: g.basePrice, illegal: g.illegal, tons, buyMod, sellMod, }); } return { success: true, type: 'trade-goods', world: { uwp: world.raw, tradeCodes, population: world.population }, supplierRoll: { dice: supplierRoll.total, mod: supplierMod, total: supplierTotal }, goods, }; } /** * Calcule le prix d'achat ou de vente pour une marchandise donnée. * * @param {object} params * @param {number} params.basePrice * @param {number} params.buyMod MD d'achat applicable (+) * @param {number} params.sellMod MD de vente applicable (−) * @param {number} params.brokerSkill Compétence Courtier du Voyageur * @param {string} params.mode 'buy'|'sell' * @returns {Promise} */ export async function calculatePrice(params) { const { basePrice, buyMod = 0, sellMod = 0, brokerSkill = 0, mode = 'buy' } = params; // Prix d'achat : 3D + Courtier Voyageur + MD achat – MD vente – Courtier fournisseur (2) // Prix de vente : 3D + Courtier Voyageur + MD vente – MD achat – Courtier acheteur (2) const supplierBroker = 2; // on suppose toujours 2 (règle de base) let modifier; if (mode === 'buy') { modifier = brokerSkill + buyMod - sellMod - supplierBroker; } else { modifier = brokerSkill + sellMod - buyMod - supplierBroker; } const dice = await rollDice('3d6'); const total = dice.total + modifier; const priceEntry = getModifiedPrice(total); const percent = mode === 'buy' ? priceEntry.buy : priceEntry.sell; const actualPrice = Math.round(basePrice * percent / 100); return { success: true, mode, diceResult: dice.total, modifier, total, percent, basePrice, actualPrice, }; } // ─── Utilitaires ───────────────────────────────────────────────────────────── /** * Retourne le plus grand modificateur applicable parmi les codes commerciaux du monde. * @param {object} modMap { codeCommercial: modificateur } * @param {string[]} codes Codes commerciaux du monde * @param {string} zone 'normal'|'amber'|'red' * @returns {number} */ export function highestApplicableMod(modMap, codes, zone = 'normal') { let max = 0; for (const [code, val] of Object.entries(modMap)) { if (code === 'ZA' && zone === 'amber' && val > max) max = val; else if (code === 'ZR' && zone === 'red' && val > max) max = val; else if (codes.includes(code) && val > max) max = val; } return max; } /** * Ajuste la formule de tonnes en fonction de la population du monde. * Pop ≤ 3 : MD –3 ; Pop ≥ 9 : MD +3 (minimum 1 tonne). * Retourne un objet { formula, modifier } — l'appelant applique le modificateur * APRÈS le jet et clame à Math.max(0, ...) pour éviter d'utiliser max() dans la * formule de dés (non supporté par l'API Roll de FoundryVTT). */ function adjustTonsDice(formula, population) { if (formula === '0') return { formula: '0', modifier: 0 }; if (population <= 3) return { formula, modifier: -3 }; if (population >= 9) return { formula, modifier: +3 }; return { formula, modifier: 0 }; } /** * Formate un montant en Crédits avec séparateur de milliers. * @param {number} amount * @returns {string} */ export function formatCredits(amount) { return `${amount.toLocaleString('fr-FR')} Cr`; }