Ajout des commandes de creation de rencontre/NJ

This commit is contained in:
2026-04-17 16:34:16 +02:00
commit 90911c2e60
232 changed files with 53843 additions and 0 deletions

403
scripts/tradeHelper.js Normal file
View File

@@ -0,0 +1,403 @@
/**
* 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`;
}