Ajout des commandes de creation de rencontre/NJ
This commit is contained in:
403
scripts/tradeHelper.js
Normal file
403
scripts/tradeHelper.js
Normal 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 (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`;
|
||||
}
|
||||
Reference in New Issue
Block a user