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

404 lines
14 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 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 (16)
* @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<object>}
*/
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<object>}
*/
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<object>}
*/
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<object>}
*/
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`;
}