404 lines
14 KiB
JavaScript
404 lines
14 KiB
JavaScript
/**
|
||
* 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<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`;
|
||
}
|