Ajout des commandes de creation de rencontre/NJ
This commit is contained in:
396
scripts/CommerceDialog.js
Normal file
396
scripts/CommerceDialog.js
Normal file
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* MGT2 Commerce – CommerceDialog
|
||||
*
|
||||
* Boîte de dialogue principale (FormApplication FoundryVTT).
|
||||
* Trois onglets : Passagers / Cargaison / Commerce spéculatif.
|
||||
* Les résultats sont postés dans le chat.
|
||||
*/
|
||||
|
||||
import { calculatePassengers, calculateCargo, findAvailableGoods, calculatePrice, formatCredits } from './tradeHelper.js';
|
||||
import { searchWorlds, fetchWorldDetail, fetchWorldCoordinates, calcParsecs } from './travellerMapApi.js';
|
||||
|
||||
const MODULE_ID = 'mgt2-compendium-amiral-denisov';
|
||||
|
||||
export class CommerceDialog extends FormApplication {
|
||||
|
||||
constructor(options = {}) {
|
||||
super({}, options);
|
||||
// Valeurs par défaut du formulaire
|
||||
this._formData = {
|
||||
pax: {
|
||||
uwpDep: '', uwpDest: '', zoneDep: 'normal', zoneDest: 'normal',
|
||||
parsecs: 1, skillEffect: 0, stewardLevel: 0,
|
||||
},
|
||||
cargo: {
|
||||
uwpDep: '', uwpDest: '', zoneDep: 'normal', zoneDest: 'normal',
|
||||
parsecs: 1, skillEffect: 0, navyRank: 0, scoutRank: 0, socMod: 0, armed: false,
|
||||
},
|
||||
trade: {
|
||||
uwp: '', zone: 'normal', brokerSkill: 0, previousAttempts: 0, blackMarket: false,
|
||||
},
|
||||
};
|
||||
this._tradeGoods = null; // résultats de findAvailableGoods, conservés pour le calcul des prix
|
||||
}
|
||||
|
||||
static get defaultOptions() {
|
||||
return mergeObject(super.defaultOptions, {
|
||||
id: 'mgt2-commerce',
|
||||
title: 'Commerce – MGT2',
|
||||
template: `modules/${MODULE_ID}/templates/commerce-dialog.hbs`,
|
||||
width: 780,
|
||||
height: 'auto',
|
||||
resizable: true,
|
||||
tabs: [{
|
||||
navSelector: '.tabs',
|
||||
contentSelector: '.tab-content',
|
||||
initial: 'passengers',
|
||||
}],
|
||||
classes: ['mgt2-commerce-dialog'],
|
||||
});
|
||||
}
|
||||
|
||||
getData() {
|
||||
_registerHandlebarsHelpers();
|
||||
return foundry.utils.mergeObject(super.getData(), this._formData);
|
||||
}
|
||||
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
|
||||
// Bouton "Calculer les passagers"
|
||||
html.find('[data-action="calculate-passengers"]').on('click', async (ev) => {
|
||||
ev.preventDefault();
|
||||
this._readForm(html);
|
||||
await this._handlePassengers();
|
||||
});
|
||||
|
||||
// Bouton "Calculer la cargaison"
|
||||
html.find('[data-action="calculate-cargo"]').on('click', async (ev) => {
|
||||
ev.preventDefault();
|
||||
this._readForm(html);
|
||||
await this._handleCargo();
|
||||
});
|
||||
|
||||
// Bouton "Trouver un fournisseur & marchandises"
|
||||
html.find('[data-action="find-goods"]').on('click', async (ev) => {
|
||||
ev.preventDefault();
|
||||
this._readForm(html);
|
||||
await this._handleFindGoods(html);
|
||||
});
|
||||
|
||||
// Bouton "Calculer les prix d'achat"
|
||||
html.on('click', '[data-action="calculate-buy-prices"]', async (ev) => {
|
||||
ev.preventDefault();
|
||||
await this._handleBuyPrices();
|
||||
});
|
||||
|
||||
// ─── Recherche de monde (Traveller Map API) ───────────────────────────────
|
||||
html.find('.world-search-widget').each((_, widget) => {
|
||||
const $widget = $(widget);
|
||||
const $input = $widget.find('.world-search-input');
|
||||
const $btn = $widget.find('.btn-world-search');
|
||||
const $results = $widget.find('.world-search-results');
|
||||
const uwpTarget = $widget.data('uwp-target');
|
||||
const zoneTarget = $widget.data('zone-target');
|
||||
const parsecsTarget = $widget.data('parsecs-target') || null;
|
||||
const role = $widget.data('role') || null;
|
||||
|
||||
const doSearch = async () => {
|
||||
const query = $input.val().trim();
|
||||
if (query.length < 2) return;
|
||||
$btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i>');
|
||||
$results.empty();
|
||||
try {
|
||||
const worlds = await searchWorlds(query);
|
||||
if (!worlds.length) {
|
||||
$results.append('<li class="no-result">Aucun monde trouvé</li>');
|
||||
} else {
|
||||
worlds.slice(0, 10).forEach(w => {
|
||||
const $li = $(`<li data-sector="${w.sector}" data-hex="${w.hex}"><span class="world-name">${w.name}</span> <span class="world-uwp">${w.uwp}</span> <span class="world-sector">${w.sector}</span></li>`);
|
||||
$li.on('click', async () => {
|
||||
$results.empty();
|
||||
$input.val(w.name);
|
||||
// Récupère les détails (UWP précis + zone) et les coordonnées en parallèle
|
||||
const [detail, coords] = await Promise.all([
|
||||
fetchWorldDetail(w.sector, w.hex).catch(() => null),
|
||||
fetchWorldCoordinates(w.sector, w.hex).catch(() => null),
|
||||
]);
|
||||
|
||||
const resolvedUwp = detail ? detail.uwp : w.uwp;
|
||||
const resolvedZone = detail ? detail.zone : 'normal';
|
||||
|
||||
html.find(`[name="${uwpTarget}"]`).val(resolvedUwp);
|
||||
html.find(`[name="${zoneTarget}"]`).val(resolvedZone);
|
||||
|
||||
// Stocker les coordonnées dans le widget courant
|
||||
$widget.data('coords', coords);
|
||||
|
||||
// Synchronisation inter-onglets : départ Passagers → départ Cargaison + monde Commerce
|
||||
if (uwpTarget === 'pax.uwpDep') {
|
||||
const $cargoDep = html.find('.world-search-widget[data-uwp-target="cargo.uwpDep"]');
|
||||
$cargoDep.find('.world-search-input').val(w.name);
|
||||
html.find('[name="cargo.uwpDep"]').val(resolvedUwp);
|
||||
html.find('[name="cargo.zoneDep"]').val(resolvedZone);
|
||||
$cargoDep.data('coords', coords);
|
||||
|
||||
const $tradeWorld = html.find('.world-search-widget[data-uwp-target="trade.uwp"]');
|
||||
$tradeWorld.find('.world-search-input').val(w.name);
|
||||
html.find('[name="trade.uwp"]').val(resolvedUwp);
|
||||
html.find('[name="trade.zone"]').val(resolvedZone);
|
||||
}
|
||||
|
||||
// Calculer les parsecs si le widget partenaire a aussi ses coordonnées
|
||||
if (parsecsTarget) {
|
||||
const partnerRole = role === 'dep' ? 'dest' : 'dep';
|
||||
const $partner = html.find(
|
||||
`.world-search-widget[data-parsecs-target="${parsecsTarget}"][data-role="${partnerRole}"]`
|
||||
);
|
||||
const partnerCoords = $partner.data('coords');
|
||||
if (coords && partnerCoords) {
|
||||
const dist = calcParsecs(coords, partnerCoords);
|
||||
html.find(`[name="${parsecsTarget}"]`).val(dist);
|
||||
}
|
||||
}
|
||||
});
|
||||
$results.append($li);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
$results.append('<li class="no-result">Erreur de connexion à Traveller Map</li>');
|
||||
}
|
||||
$btn.prop('disabled', false).html('<i class="fas fa-globe"></i> Chercher');
|
||||
};
|
||||
|
||||
$btn.on('click', doSearch);
|
||||
$input.on('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); doSearch(); } });
|
||||
});
|
||||
|
||||
// Fermer les listes de résultats en cliquant ailleurs
|
||||
html.on('click', (ev) => {
|
||||
if (!$(ev.target).closest('.world-search-widget').length) {
|
||||
html.find('.world-search-results').empty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Lecture du formulaire ─────────────────────────────────────────────────
|
||||
|
||||
_readForm(html) {
|
||||
// Passagers
|
||||
this._formData.pax.uwpDep = html.find('[name="pax.uwpDep"]').val();
|
||||
this._formData.pax.uwpDest = html.find('[name="pax.uwpDest"]').val();
|
||||
this._formData.pax.zoneDep = html.find('[name="pax.zoneDep"]').val();
|
||||
this._formData.pax.zoneDest = html.find('[name="pax.zoneDest"]').val();
|
||||
this._formData.pax.parsecs = parseInt(html.find('[name="pax.parsecs"]').val()) || 1;
|
||||
this._formData.pax.skillEffect = parseInt(html.find('[name="pax.skillEffect"]').val()) || 0;
|
||||
this._formData.pax.stewardLevel = parseInt(html.find('[name="pax.stewardLevel"]').val()) || 0;
|
||||
|
||||
// Cargaison
|
||||
this._formData.cargo.uwpDep = html.find('[name="cargo.uwpDep"]').val();
|
||||
this._formData.cargo.uwpDest = html.find('[name="cargo.uwpDest"]').val();
|
||||
this._formData.cargo.zoneDep = html.find('[name="cargo.zoneDep"]').val();
|
||||
this._formData.cargo.zoneDest = html.find('[name="cargo.zoneDest"]').val();
|
||||
this._formData.cargo.parsecs = parseInt(html.find('[name="cargo.parsecs"]').val()) || 1;
|
||||
this._formData.cargo.skillEffect = parseInt(html.find('[name="cargo.skillEffect"]').val()) || 0;
|
||||
this._formData.cargo.navyRank = parseInt(html.find('[name="cargo.navyRank"]').val()) || 0;
|
||||
this._formData.cargo.scoutRank = parseInt(html.find('[name="cargo.scoutRank"]').val()) || 0;
|
||||
this._formData.cargo.socMod = parseInt(html.find('[name="cargo.socMod"]').val()) || 0;
|
||||
this._formData.cargo.armed = html.find('[name="cargo.armed"]').is(':checked');
|
||||
|
||||
// Commerce spéculatif
|
||||
this._formData.trade.uwp = html.find('[name="trade.uwp"]').val();
|
||||
this._formData.trade.zone = html.find('[name="trade.zone"]').val();
|
||||
this._formData.trade.brokerSkill = parseInt(html.find('[name="trade.brokerSkill"]').val()) || 0;
|
||||
this._formData.trade.previousAttempts = parseInt(html.find('[name="trade.previousAttempts"]').val()) || 0;
|
||||
this._formData.trade.blackMarket = html.find('[name="trade.blackMarket"]').is(':checked');
|
||||
}
|
||||
|
||||
// ─── Passagers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async _handlePassengers() {
|
||||
const p = this._formData.pax;
|
||||
if (!p.uwpDep || !p.uwpDest) {
|
||||
return ui.notifications.warn('Veuillez saisir les UWP de départ et de destination.');
|
||||
}
|
||||
|
||||
const result = await calculatePassengers({
|
||||
uwpDep: p.uwpDep,
|
||||
uwpDest: p.uwpDest,
|
||||
zoneDep: p.zoneDep,
|
||||
zoneDest: p.zoneDest,
|
||||
parsecs: p.parsecs,
|
||||
skillEffect: p.skillEffect,
|
||||
stewardLevel: p.stewardLevel,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return ui.notifications.error(result.errors.join(' | '));
|
||||
}
|
||||
|
||||
await this._postToChatResult(result);
|
||||
}
|
||||
|
||||
// ─── Cargaison ─────────────────────────────────────────────────────────────
|
||||
|
||||
async _handleCargo() {
|
||||
const c = this._formData.cargo;
|
||||
if (!c.uwpDep || !c.uwpDest) {
|
||||
return ui.notifications.warn('Veuillez saisir les UWP de départ et de destination.');
|
||||
}
|
||||
|
||||
const result = await calculateCargo({
|
||||
uwpDep: c.uwpDep,
|
||||
uwpDest: c.uwpDest,
|
||||
zoneDep: c.zoneDep,
|
||||
zoneDest: c.zoneDest,
|
||||
parsecs: c.parsecs,
|
||||
skillEffect: c.skillEffect,
|
||||
navyRank: c.navyRank,
|
||||
scoutRank: c.scoutRank,
|
||||
socMod: c.socMod,
|
||||
armed: c.armed,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return ui.notifications.error(result.errors.join(' | '));
|
||||
}
|
||||
|
||||
// Calcule le sous-total cargaison pour le template
|
||||
result.cargoRevenue = result.lots.reduce((s, l) => s + l.revenue, 0);
|
||||
|
||||
await this._postToChatResult(result);
|
||||
}
|
||||
|
||||
// ─── Commerce spéculatif – trouver les marchandises ────────────────────────
|
||||
|
||||
async _handleFindGoods(html) {
|
||||
const t = this._formData.trade;
|
||||
if (!t.uwp) {
|
||||
return ui.notifications.warn('Veuillez saisir le code UWP du monde.');
|
||||
}
|
||||
|
||||
const result = await findAvailableGoods({
|
||||
uwp: t.uwp,
|
||||
zone: t.zone,
|
||||
blackMarket: t.blackMarket,
|
||||
brokerSkill: t.brokerSkill,
|
||||
previousAttempts: t.previousAttempts,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return ui.notifications.error(result.errors.join(' | '));
|
||||
}
|
||||
|
||||
this._tradeGoods = result;
|
||||
|
||||
// Affiche un aperçu inline dans l'onglet
|
||||
const goodsDiv = html.find('.trade-goods-result');
|
||||
const listDiv = html.find('.trade-goods-list');
|
||||
|
||||
if (result.goods.length === 0) {
|
||||
listDiv.html('<p><em>Aucune marchandise disponible sur ce monde.</em></p>');
|
||||
} else {
|
||||
const rows = result.goods.map(g => `
|
||||
<div class="trade-good-item">
|
||||
<label>
|
||||
<input type="checkbox" class="good-select" data-d66="${g.d66}" checked>
|
||||
<strong>[${g.d66}] ${g.name}</strong>
|
||||
— ${g.tons} t — ${formatCredits(g.basePrice)}/t
|
||||
(achat MD ${g.buyMod >= 0 ? '+' : ''}${g.buyMod},
|
||||
vente MD ${g.sellMod >= 0 ? '+' : ''}${g.sellMod})
|
||||
</label>
|
||||
</div>
|
||||
`).join('');
|
||||
listDiv.html(rows);
|
||||
}
|
||||
|
||||
goodsDiv.show();
|
||||
await this._postToChatResult(result);
|
||||
}
|
||||
|
||||
// ─── Commerce spéculatif – calculer les prix d'achat ───────────────────────
|
||||
|
||||
async _handleBuyPrices() {
|
||||
if (!this._tradeGoods || !this._tradeGoods.goods.length) {
|
||||
return ui.notifications.warn('Veuillez d\'abord trouver les marchandises disponibles.');
|
||||
}
|
||||
|
||||
const brokerSkill = this._formData.trade.brokerSkill;
|
||||
const prices = [];
|
||||
|
||||
for (const g of this._tradeGoods.goods) {
|
||||
const priceResult = await calculatePrice({
|
||||
basePrice: g.basePrice,
|
||||
buyMod: g.buyMod,
|
||||
sellMod: g.sellMod,
|
||||
brokerSkill,
|
||||
mode: 'buy',
|
||||
});
|
||||
prices.push({
|
||||
name: g.name,
|
||||
tons: g.tons,
|
||||
basePrice: g.basePrice,
|
||||
diceResult: priceResult.diceResult,
|
||||
modifier: priceResult.modifier,
|
||||
total: priceResult.total,
|
||||
percent: priceResult.percent,
|
||||
actualPrice: priceResult.actualPrice,
|
||||
totalCost: priceResult.actualPrice * g.tons,
|
||||
});
|
||||
}
|
||||
|
||||
const grandTotal = prices.reduce((s, p) => s + p.totalCost, 0);
|
||||
|
||||
await this._postToChatResult({ type: 'buy-prices', prices, grandTotal });
|
||||
}
|
||||
|
||||
// ─── Post en chat ───────────────────────────────────────────────────────────
|
||||
|
||||
async _postToChatResult(data) {
|
||||
// Ajoute les helpers Handlebars nécessaires (si pas déjà enregistrés)
|
||||
_registerHandlebarsHelpers();
|
||||
|
||||
const html = await renderTemplate(
|
||||
`modules/${MODULE_ID}/templates/commerce-result.hbs`,
|
||||
data,
|
||||
);
|
||||
|
||||
await ChatMessage.create({
|
||||
content: html,
|
||||
speaker: ChatMessage.getSpeaker(),
|
||||
flags: { [MODULE_ID]: { type: 'commerce-result' } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Nécessaire pour FormApplication : ne soumet rien (tout est piloté par boutons). */
|
||||
async _updateObject(_event, _formData) {}
|
||||
}
|
||||
|
||||
// ─── Helpers Handlebars ───────────────────────────────────────────────────────
|
||||
|
||||
let _helpersRegistered = false;
|
||||
|
||||
function _registerHandlebarsHelpers() {
|
||||
if (_helpersRegistered) return;
|
||||
_helpersRegistered = true;
|
||||
|
||||
Handlebars.registerHelper('formatCredits', (amount) => formatCredits(amount));
|
||||
Handlebars.registerHelper('join', (arr, sep) => (Array.isArray(arr) ? arr.join(sep) : ''));
|
||||
Handlebars.registerHelper('gt', (a, b) => a > b);
|
||||
Handlebars.registerHelper('gte', (a, b) => a >= b);
|
||||
Handlebars.registerHelper('eq', (a, b) => a === b);
|
||||
|
||||
/** Classe CSS selon le signe du modificateur. */
|
||||
Handlebars.registerHelper('modClass', (n) => Number(n) > 0 ? 'mod-pos' : Number(n) < 0 ? 'mod-neg' : 'mod-zero');
|
||||
|
||||
/** Génère un <select> numérique de min à max, avec +N pour les positifs. */
|
||||
Handlebars.registerHelper('modSelect', function(id, name, value, min, max) {
|
||||
let opts = '';
|
||||
for (let i = min; i <= max; i++) {
|
||||
const label = i > 0 ? `+${i}` : `${i}`;
|
||||
const sel = value === i ? ' selected' : '';
|
||||
opts += `<option value="${i}"${sel}>${label}</option>`;
|
||||
}
|
||||
return new Handlebars.SafeString(`<select id="${id}" name="${name}">${opts}</select>`);
|
||||
});
|
||||
}
|
||||
136
scripts/NpcDialog.js
Normal file
136
scripts/NpcDialog.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import { formatCredits } from './tradeHelper.js';
|
||||
import { createNpcActor, generateClientMission, generateEncounter, generateQuickNpc } from './npcHelper.js';
|
||||
import { NPC_RELATIONS } from './data/npcTables.js';
|
||||
|
||||
const MODULE_ID = 'mgt2-compendium-amiral-denisov';
|
||||
|
||||
export class NpcDialog extends FormApplication {
|
||||
constructor(options = {}) {
|
||||
super({}, options);
|
||||
this._formData = {
|
||||
npc: {
|
||||
relation: options.relation ?? 'contact',
|
||||
experienceBias: 'random',
|
||||
createActor: false,
|
||||
actorName: '',
|
||||
openCreatedActor: true,
|
||||
},
|
||||
encounter: {
|
||||
context: options.context ?? 'starport',
|
||||
includeFollowUp: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: 'mgt2-npc',
|
||||
title: 'PNJ & Rencontres – MGT2',
|
||||
template: `modules/${MODULE_ID}/templates/npc-dialog.hbs`,
|
||||
width: 720,
|
||||
height: 'auto',
|
||||
resizable: true,
|
||||
tabs: [{
|
||||
navSelector: '.tabs',
|
||||
contentSelector: '.tab-content',
|
||||
initial: 'npc',
|
||||
}],
|
||||
classes: ['mgt2-npc-dialog'],
|
||||
});
|
||||
}
|
||||
|
||||
getData() {
|
||||
registerHandlebarsHelpers();
|
||||
return foundry.utils.mergeObject(super.getData(), {
|
||||
...this._formData,
|
||||
relations: Object.entries(NPC_RELATIONS).map(([key, value]) => ({ key, label: value.label })),
|
||||
});
|
||||
}
|
||||
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
|
||||
html.find('[data-action="generate-npc"]').on('click', async (event) => {
|
||||
event.preventDefault();
|
||||
this._readForm(html);
|
||||
await this._handleNpc();
|
||||
});
|
||||
|
||||
html.find('[data-action="generate-encounter"]').on('click', async (event) => {
|
||||
event.preventDefault();
|
||||
this._readForm(html);
|
||||
await this._handleEncounter();
|
||||
});
|
||||
|
||||
html.find('[data-action="generate-mission"]').on('click', async (event) => {
|
||||
event.preventDefault();
|
||||
await this._handleMission();
|
||||
});
|
||||
|
||||
if (this.options.initialTab) {
|
||||
this._tabs?.[0]?.activate(this.options.initialTab);
|
||||
}
|
||||
}
|
||||
|
||||
_readForm(html) {
|
||||
this._formData.npc.relation = html.find('[name="npc.relation"]').val();
|
||||
this._formData.npc.experienceBias = html.find('[name="npc.experienceBias"]').val();
|
||||
this._formData.npc.createActor = html.find('[name="npc.createActor"]').is(':checked');
|
||||
this._formData.npc.actorName = html.find('[name="npc.actorName"]').val();
|
||||
this._formData.npc.openCreatedActor = html.find('[name="npc.openCreatedActor"]').is(':checked');
|
||||
this._formData.encounter.context = html.find('[name="encounter.context"]').val();
|
||||
this._formData.encounter.includeFollowUp = html.find('[name="encounter.includeFollowUp"]').is(':checked');
|
||||
}
|
||||
|
||||
async _handleNpc() {
|
||||
const result = await generateQuickNpc(this._formData.npc);
|
||||
if (this._formData.npc.createActor) {
|
||||
const actor = await createNpcActor(result, {
|
||||
name: this._formData.npc.actorName,
|
||||
openSheet: this._formData.npc.openCreatedActor,
|
||||
});
|
||||
result.createdActor = {
|
||||
id: actor.id,
|
||||
name: actor.name,
|
||||
};
|
||||
ui.notifications.info(`Fiche PNJ créée : ${actor.name}`);
|
||||
}
|
||||
await this._postToChatResult(result);
|
||||
}
|
||||
|
||||
async _handleEncounter() {
|
||||
const result = await generateEncounter(this._formData.encounter);
|
||||
await this._postToChatResult(result);
|
||||
}
|
||||
|
||||
async _handleMission() {
|
||||
const result = await generateClientMission();
|
||||
await this._postToChatResult(result);
|
||||
}
|
||||
|
||||
async _postToChatResult(data) {
|
||||
registerHandlebarsHelpers();
|
||||
const renderHbs = foundry.applications?.handlebars?.renderTemplate ?? renderTemplate;
|
||||
const html = await renderHbs(`modules/${MODULE_ID}/templates/npc-result.hbs`, data);
|
||||
|
||||
await ChatMessage.create({
|
||||
content: html,
|
||||
speaker: ChatMessage.getSpeaker(),
|
||||
flags: { [MODULE_ID]: { type: 'npc-result' } },
|
||||
});
|
||||
}
|
||||
|
||||
async _updateObject(_event, _formData) {}
|
||||
}
|
||||
|
||||
let helpersRegistered = false;
|
||||
|
||||
function registerHandlebarsHelpers() {
|
||||
if (helpersRegistered) return;
|
||||
helpersRegistered = true;
|
||||
|
||||
Handlebars.registerHelper('eq', (a, b) => a === b);
|
||||
Handlebars.registerHelper('join', (arr, sep) => (Array.isArray(arr) ? arr.join(sep) : ''));
|
||||
Handlebars.registerHelper('formatCredits', (amount) => formatCredits(amount));
|
||||
Handlebars.registerHelper('contains', (text, search) => String(text ?? '').includes(search));
|
||||
}
|
||||
36
scripts/commerce.js
Normal file
36
scripts/commerce.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* MGT2 Commerce – Point d'entrée du module
|
||||
*
|
||||
* Chargé par FoundryVTT via "esmodules" dans module.json.
|
||||
* Enregistre la commande /commerce dans le chat.
|
||||
*/
|
||||
|
||||
import { CommerceDialog } from './CommerceDialog.js';
|
||||
|
||||
const MODULE_ID = 'mgt2-compendium-amiral-denisov';
|
||||
|
||||
Hooks.once('init', () => {
|
||||
console.log(`${MODULE_ID} | Commerce module initialisé`);
|
||||
|
||||
// Pré-charge les templates Handlebars
|
||||
loadTemplates([
|
||||
`modules/${MODULE_ID}/templates/commerce-dialog.hbs`,
|
||||
`modules/${MODULE_ID}/templates/commerce-result.hbs`,
|
||||
]);
|
||||
});
|
||||
|
||||
Hooks.once('ready', () => {
|
||||
console.log(`${MODULE_ID} | Commerce module prêt – tapez /commerce dans le chat`);
|
||||
});
|
||||
|
||||
/**
|
||||
* Intercepte les messages de chat commençant par /commerce.
|
||||
* Retourne false pour empêcher l'envoi du message brut.
|
||||
*/
|
||||
Hooks.on('chatMessage', (_chatLog, message, _chatData) => {
|
||||
const trimmed = message.trim().toLowerCase();
|
||||
if (trimmed === '/commerce' || trimmed.startsWith('/commerce ')) {
|
||||
new CommerceDialog().render(true);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
475
scripts/data/npcTables.js
Normal file
475
scripts/data/npcTables.js
Normal file
@@ -0,0 +1,475 @@
|
||||
export const NPC_RELATIONS = {
|
||||
ally: {
|
||||
label: 'Allié',
|
||||
summary: 'Prêt à prendre des risques pour aider les Voyageurs.',
|
||||
},
|
||||
contact: {
|
||||
label: 'Contact',
|
||||
summary: 'Aide limitée, souvent utile pour des informations ou des introductions.',
|
||||
},
|
||||
rival: {
|
||||
label: 'Rival',
|
||||
summary: 'Adversaire récurrent, préfère gêner ou humilier les Voyageurs.',
|
||||
},
|
||||
enemy: {
|
||||
label: 'Ennemi',
|
||||
summary: 'Opposant durable prêt à aller plus loin que le Rival.',
|
||||
},
|
||||
};
|
||||
|
||||
export const EXPERIENCE_PROFILES = [
|
||||
{
|
||||
key: 'noncombatant-blue',
|
||||
category: 'Non-combattant',
|
||||
tier: 'Bleu',
|
||||
label: 'Non-combattant bleu',
|
||||
skillLevel: 0,
|
||||
characteristicBonuses: [],
|
||||
skills: ['Conduire/aéronef'],
|
||||
},
|
||||
{
|
||||
key: 'combatant-blue',
|
||||
category: 'Combattant',
|
||||
tier: 'Bleu',
|
||||
label: 'Combattant bleu',
|
||||
skillLevel: 0,
|
||||
characteristicBonuses: [],
|
||||
skills: ['Conduire/aéronef', 'Combat Arme', 'Mêlée'],
|
||||
},
|
||||
{
|
||||
key: 'noncombatant-average',
|
||||
category: 'Non-combattant',
|
||||
tier: 'Moyen',
|
||||
label: 'Non-combattant moyen',
|
||||
skillLevel: 1,
|
||||
characteristicBonuses: ['+1'],
|
||||
skills: ['Conduire/aéronef', 'Profession'],
|
||||
},
|
||||
{
|
||||
key: 'combatant-average',
|
||||
category: 'Combattant',
|
||||
tier: 'Moyen',
|
||||
label: 'Combattant moyen',
|
||||
skillLevel: 1,
|
||||
characteristicBonuses: ['+1'],
|
||||
skills: ['Conduire/aéronef', 'Combat Arme', 'Mêlée', 'Reconnaissance'],
|
||||
},
|
||||
{
|
||||
key: 'noncombatant-experienced',
|
||||
category: 'Non-combattant',
|
||||
tier: 'Expérimenté',
|
||||
label: 'Non-combattant expérimenté',
|
||||
skillLevel: 2,
|
||||
characteristicBonuses: ['+1', '+2'],
|
||||
skills: ['Administration', 'Conduire/aéronef', 'Profession'],
|
||||
},
|
||||
{
|
||||
key: 'combatant-experienced',
|
||||
category: 'Combattant',
|
||||
tier: 'Expérimenté',
|
||||
label: 'Combattant expérimenté',
|
||||
skillLevel: 2,
|
||||
characteristicBonuses: ['+1', '+2'],
|
||||
skills: ['Conduire/aéronef', 'Combat Arme', 'Armes lourdes', 'Mêlée', 'Reconnaissance'],
|
||||
},
|
||||
{
|
||||
key: 'noncombatant-elite',
|
||||
category: 'Non-combattant',
|
||||
tier: 'Élite',
|
||||
label: 'Non-combattant élite',
|
||||
skillLevel: 3,
|
||||
characteristicBonuses: ['+1', '+2', '+3'],
|
||||
skills: ['Administration', 'Conduire/aéronef', 'Enquêter', 'Profession'],
|
||||
},
|
||||
{
|
||||
key: 'combatant-elite',
|
||||
category: 'Combattant',
|
||||
tier: 'Élite',
|
||||
label: 'Combattant élite',
|
||||
skillLevel: 3,
|
||||
characteristicBonuses: ['+1', '+2', '+3'],
|
||||
skills: ['Conduire/aéronef', 'Combat Arme', 'Armes lourdes', 'Mêlée', 'Reconnaissance', 'Tactique'],
|
||||
},
|
||||
];
|
||||
|
||||
export const ALLIES_ENEMIES_TABLE = [
|
||||
{ d66: 11, text: 'Officier de marine' },
|
||||
{ d66: 12, text: 'Diplomate impérial' },
|
||||
{ d66: 13, text: 'Marchand véreux' },
|
||||
{ d66: 14, text: 'Médecin' },
|
||||
{ d66: 15, text: 'Scientifique excentrique' },
|
||||
{ d66: 16, text: 'Mercenaire' },
|
||||
{ d66: 21, text: 'Interprète célèbre' },
|
||||
{ d66: 22, text: 'Xéno' },
|
||||
{ d66: 23, text: 'Franc-Marchand' },
|
||||
{ d66: 24, text: 'Explorateur' },
|
||||
{ d66: 25, text: 'Capitaine de vaisseau' },
|
||||
{ d66: 26, text: 'Cadre de corpo' },
|
||||
{ d66: 31, text: 'Chercheur' },
|
||||
{ d66: 32, text: 'Attaché culturel' },
|
||||
{ d66: 33, text: 'Chef religieux' },
|
||||
{ d66: 34, text: 'Conspirateur' },
|
||||
{ d66: 35, text: 'Noble riche' },
|
||||
{ d66: 36, text: 'Intelligence artificielle' },
|
||||
{ d66: 41, text: 'Noble oisif' },
|
||||
{ d66: 42, text: 'Gouverneur planétaire' },
|
||||
{ d66: 43, text: 'Joueur invétéré' },
|
||||
{ d66: 44, text: 'Journaliste en croisade' },
|
||||
{ d66: 45, text: "Cultiste de l'apocalypse" },
|
||||
{ d66: 46, text: 'Agent corpo' },
|
||||
{ d66: 51, text: 'Criminel mafieux' },
|
||||
{ d66: 52, text: 'Gouverneur militaire' },
|
||||
{ d66: 53, text: "Quartier-maître de l'armée" },
|
||||
{ d66: 54, text: 'Enquêteur privé' },
|
||||
{ d66: 55, text: 'Amiral à la retraite' },
|
||||
{ d66: 56, text: 'Ambassadeur xéno' },
|
||||
{ d66: 61, text: 'Contrebandier' },
|
||||
{ d66: 62, text: "Administrateur de l'astroport" },
|
||||
{ d66: 63, text: "Inspecteur d'armement" },
|
||||
{ d66: 64, text: "Homme d'État âgé" },
|
||||
{ d66: 65, text: 'Seigneur de guerre planétaire' },
|
||||
{ d66: 66, text: 'Agent impérial' },
|
||||
];
|
||||
|
||||
export const CHARACTER_QUIRKS_TABLE = [
|
||||
{ d66: 11, text: 'Loyal' },
|
||||
{ d66: 12, text: 'Distrait par des soucis' },
|
||||
{ d66: 13, text: 'Dettes envers des criminels' },
|
||||
{ d66: 14, text: 'Fait de très mauvaises blagues' },
|
||||
{ d66: 15, text: 'Trahira les personnages' },
|
||||
{ d66: 16, text: 'Agressif' },
|
||||
{ d66: 21, text: 'A des alliés secrets' },
|
||||
{ d66: 22, text: "Utilisateur secret d'anagathiques" },
|
||||
{ d66: 23, text: 'À la recherche de quelque chose' },
|
||||
{ d66: 24, text: 'Serviable' },
|
||||
{ d66: 25, text: 'Subit des pertes de mémoire' },
|
||||
{ d66: 26, text: 'Veut engager les Voyageurs' },
|
||||
{ d66: 31, text: 'A des contacts utiles' },
|
||||
{ d66: 32, text: 'Artistique' },
|
||||
{ d66: 33, text: 'Facile à tromper' },
|
||||
{ d66: 34, text: 'Possède une laideur inhabituelle' },
|
||||
{ d66: 35, text: 'Inquiet de la situation présente' },
|
||||
{ d66: 36, text: 'Montre des images de ses enfants' },
|
||||
{ d66: 41, text: 'Répand des rumeurs' },
|
||||
{ d66: 42, text: 'Très provincial' },
|
||||
{ d66: 43, text: 'Ivrogne ou toxicomane' },
|
||||
{ d66: 44, text: 'Informateur du gouvernement' },
|
||||
{ d66: 45, text: 'Prend un Voyageur pour un autre' },
|
||||
{ d66: 46, text: 'Possède une technologie exceptionnellement avancée' },
|
||||
{ d66: 51, text: 'Possède une beauté exceptionnelle' },
|
||||
{ d66: 52, text: 'Espionne les Voyageurs' },
|
||||
{ d66: 53, text: 'Membre de la SAV' },
|
||||
{ d66: 54, text: 'Secrètement hostile aux Voyageurs' },
|
||||
{ d66: 55, text: "Veut emprunter de l'argent" },
|
||||
{ d66: 56, text: 'Est convaincu que les Voyageurs sont dangereux' },
|
||||
{ d66: 61, text: 'Impliqué dans des intrigues politiques' },
|
||||
{ d66: 62, text: 'A un dangereux secret' },
|
||||
{ d66: 63, text: 'Veut quitter la planète dans les meilleurs délais' },
|
||||
{ d66: 64, text: 'Attiré par un des Voyageurs' },
|
||||
{ d66: 65, text: "Hors-monde (originaire d'un autre monde)" },
|
||||
{ d66: 66, text: 'Doué de télépathie ou autre particularité exceptionnelle' },
|
||||
];
|
||||
|
||||
export const RANDOM_CLIENT_TABLE = [
|
||||
{ d66: 11, text: 'Assassin' },
|
||||
{ d66: 12, text: 'Contrebandier' },
|
||||
{ d66: 13, text: 'Terroriste' },
|
||||
{ d66: 14, text: 'Escroc' },
|
||||
{ d66: 15, text: 'Voleur' },
|
||||
{ d66: 16, text: 'Révolutionnaire' },
|
||||
{ d66: 21, text: 'Notaire' },
|
||||
{ d66: 22, text: 'Administrateur' },
|
||||
{ d66: 23, text: 'Maire' },
|
||||
{ d66: 24, text: 'Noble mineur' },
|
||||
{ d66: 25, text: 'Médecin' },
|
||||
{ d66: 26, text: 'Chef de tribu' },
|
||||
{ d66: 31, text: 'Diplomate' },
|
||||
{ d66: 32, text: 'Courrier' },
|
||||
{ d66: 33, text: 'Espion' },
|
||||
{ d66: 34, text: 'Ambassadeur' },
|
||||
{ d66: 35, text: 'Noble' },
|
||||
{ d66: 36, text: 'Officier de police' },
|
||||
{ d66: 41, text: 'Marchand' },
|
||||
{ d66: 42, text: 'Franc-Marchand' },
|
||||
{ d66: 43, text: 'Courtier' },
|
||||
{ d66: 44, text: 'Cadre de corpo' },
|
||||
{ d66: 45, text: 'Agent de corpo' },
|
||||
{ d66: 46, text: 'Financier' },
|
||||
{ d66: 51, text: 'Ceinturien' },
|
||||
{ d66: 52, text: 'Chercheur' },
|
||||
{ d66: 53, text: 'Officier de Marine' },
|
||||
{ d66: 54, text: 'Pilote' },
|
||||
{ d66: 55, text: "Administrateur d'astroport" },
|
||||
{ d66: 56, text: 'Éclaireur' },
|
||||
{ d66: 61, text: 'Xéno' },
|
||||
{ d66: 62, text: 'Playboy' },
|
||||
{ d66: 63, text: 'Passager clandestin' },
|
||||
{ d66: 64, text: 'Membre de la famille' },
|
||||
{ d66: 65, text: "Agent d'une puissance étrangère" },
|
||||
{ d66: 66, text: 'Agent impérial' },
|
||||
];
|
||||
|
||||
export const RANDOM_MISSION_TABLE = [
|
||||
{ d66: 11, text: 'Assassiner une cible' },
|
||||
{ d66: 12, text: 'Piéger une cible' },
|
||||
{ d66: 13, text: 'Détruire une cible' },
|
||||
{ d66: 14, text: 'Voler une cible' },
|
||||
{ d66: 15, text: 'Aide pour un cambriolage' },
|
||||
{ d66: 16, text: 'Arrêter un cambriolage' },
|
||||
{ d66: 21, text: 'Récupérer des données ou un objet dans un lieu sécurisé' },
|
||||
{ d66: 22, text: 'Discréditer une cible' },
|
||||
{ d66: 23, text: 'Retrouver une cargaison disparue' },
|
||||
{ d66: 24, text: 'Retrouver une personne perdue' },
|
||||
{ d66: 25, text: 'Tromper une cible' },
|
||||
{ d66: 26, text: 'Saboter une cible' },
|
||||
{ d66: 31, text: 'Convoyer des marchandises' },
|
||||
{ d66: 32, text: 'Convoyer une personne' },
|
||||
{ d66: 33, text: 'Convoyer des données' },
|
||||
{ d66: 34, text: 'Transporter secrètement des marchandises' },
|
||||
{ d66: 35, text: 'Transporter rapidement des marchandises' },
|
||||
{ d66: 36, text: 'Transporter des marchandises dangereuses' },
|
||||
{ d66: 41, text: 'Enquêter sur un délit' },
|
||||
{ d66: 42, text: 'Enquêter sur un vol' },
|
||||
{ d66: 43, text: 'Enquêter sur un meurtre' },
|
||||
{ d66: 44, text: 'Enquêter sur un mystère' },
|
||||
{ d66: 45, text: 'Enquêter sur une cible' },
|
||||
{ d66: 46, text: 'Enquêter sur un événement' },
|
||||
{ d66: 51, text: 'Participer à une expédition' },
|
||||
{ d66: 52, text: 'Enquête sur une planète' },
|
||||
{ d66: 53, text: 'Explorer un nouveau système' },
|
||||
{ d66: 54, text: 'Explorer une ruine' },
|
||||
{ d66: 55, text: 'Récupérer un vaisseau' },
|
||||
{ d66: 56, text: 'Capturer une créature' },
|
||||
{ d66: 61, text: 'Détourner un vaisseau' },
|
||||
{ d66: 62, text: 'Divertir un noble' },
|
||||
{ d66: 63, text: 'Protéger une cible' },
|
||||
{ d66: 64, text: 'Sauver une cible' },
|
||||
{ d66: 65, text: 'Aider une cible' },
|
||||
{ d66: 66, text: "Il s'agit d'un piège – le Client a l'intention de trahir le Voyageur" },
|
||||
];
|
||||
|
||||
export const RANDOM_TARGET_TABLE = [
|
||||
{ d66: 11, text: 'Marchandises communes' },
|
||||
{ d66: 12, text: 'Marchandises communes' },
|
||||
{ d66: 13, text: 'Marchandises (table page 240)', special: 'trade-goods' },
|
||||
{ d66: 14, text: 'Marchandises (table page 240)', special: 'trade-goods' },
|
||||
{ d66: 15, text: 'Marchandises illicites', special: 'illegal-goods' },
|
||||
{ d66: 16, text: 'Marchandises illicites', special: 'illegal-goods' },
|
||||
{ d66: 21, text: 'Données informatiques' },
|
||||
{ d66: 22, text: 'Artefact xéno' },
|
||||
{ d66: 23, text: 'Effets personnels' },
|
||||
{ d66: 24, text: "Œuvre d'art" },
|
||||
{ d66: 25, text: 'Artefact historique' },
|
||||
{ d66: 26, text: 'Arme' },
|
||||
{ d66: 31, text: 'Astroport' },
|
||||
{ d66: 32, text: 'Base astéroïde' },
|
||||
{ d66: 33, text: 'Ville' },
|
||||
{ d66: 34, text: 'Station de recherche' },
|
||||
{ d66: 35, text: 'Bar ou boîte de nuit' },
|
||||
{ d66: 36, text: 'Installation médicale' },
|
||||
{ d66: 41, text: 'Client aléatoire', special: 'client' },
|
||||
{ d66: 42, text: 'Client aléatoire', special: 'client' },
|
||||
{ d66: 43, text: 'Client aléatoire', special: 'client' },
|
||||
{ d66: 44, text: 'Allié ou ennemi', special: 'ally-enemy' },
|
||||
{ d66: 45, text: 'Allié ou ennemi', special: 'ally-enemy' },
|
||||
{ d66: 46, text: 'Allié ou ennemi', special: 'ally-enemy' },
|
||||
{ d66: 51, text: 'Gouvernement local' },
|
||||
{ d66: 52, text: 'Gouvernement planétaire' },
|
||||
{ d66: 53, text: 'Corpo' },
|
||||
{ d66: 54, text: 'Service de renseignement impérial' },
|
||||
{ d66: 55, text: 'Criminel mafieux' },
|
||||
{ d66: 56, text: 'Gang criminel' },
|
||||
{ d66: 61, text: 'Franc-Marchand' },
|
||||
{ d66: 62, text: 'Yacht' },
|
||||
{ d66: 63, text: 'Transporteur de cargaison' },
|
||||
{ d66: 64, text: 'Cotre de police' },
|
||||
{ d66: 65, text: 'Station spatiale' },
|
||||
{ d66: 66, text: 'Vaisseau de guerre' },
|
||||
];
|
||||
|
||||
export const RANDOM_OPPOSITION_TABLE = [
|
||||
{ d66: 11, text: 'Animaux' },
|
||||
{ d66: 12, text: 'Gros animaux' },
|
||||
{ d66: 13, text: 'Bandits et voleurs' },
|
||||
{ d66: 14, text: 'Paysans craintifs' },
|
||||
{ d66: 15, text: 'Autorités locales' },
|
||||
{ d66: 16, text: 'Seigneur local' },
|
||||
{ d66: 21, text: 'Criminels – voyous ou corsaires' },
|
||||
{ d66: 22, text: 'Criminels – voleurs ou saboteurs' },
|
||||
{ d66: 23, text: 'Police – forces de sécurité ordinaires' },
|
||||
{ d66: 24, text: 'Police – inspecteurs et détectives' },
|
||||
{ d66: 25, text: 'Corpo – agents' },
|
||||
{ d66: 26, text: 'Corpo – juridique' },
|
||||
{ d66: 31, text: "Sécurité de l'astroport" },
|
||||
{ d66: 32, text: 'Marines impériaux' },
|
||||
{ d66: 33, text: 'Corpo interstellaire' },
|
||||
{ d66: 34, text: 'Xéno – citoyen privé ou corpo' },
|
||||
{ d66: 35, text: 'Xéno – gouvernement' },
|
||||
{ d66: 36, text: 'Voyageurs spatiaux ou vaisseau rival' },
|
||||
{ d66: 41, text: "La cible est dans l'espace profond" },
|
||||
{ d66: 42, text: 'La cible est en orbite' },
|
||||
{ d66: 43, text: 'Conditions météorologiques défavorables' },
|
||||
{ d66: 44, text: 'Organismes dangereux ou radiations' },
|
||||
{ d66: 45, text: 'La cible se trouve dans une région dangereuse' },
|
||||
{ d66: 46, text: 'La cible se trouve dans une zone restreinte' },
|
||||
{ d66: 51, text: 'La cible est sous observation électronique' },
|
||||
{ d66: 52, text: 'Robots ou navires de garde hostiles' },
|
||||
{ d66: 53, text: 'Identification biométrique requise' },
|
||||
{ d66: 54, text: 'Défaillance mécanique ou piratage informatique' },
|
||||
{ d66: 55, text: 'Les Voyageurs sont sous surveillance' },
|
||||
{ d66: 56, text: 'Manque de carburant ou de munitions' },
|
||||
{ d66: 61, text: 'Enquête de police' },
|
||||
{ d66: 62, text: 'Obstacles juridiques' },
|
||||
{ d66: 63, text: 'Noblesse' },
|
||||
{ d66: 64, text: 'Fonctionnaires du gouvernement' },
|
||||
{ d66: 65, text: 'La cible est protégée par un tiers' },
|
||||
{ d66: 66, text: 'Otages' },
|
||||
];
|
||||
|
||||
export const STARPORT_ENCOUNTERS_TABLE = [
|
||||
{ d66: 11, text: "Robot d'entretien au travail" },
|
||||
{ d66: 12, text: "Arrivée ou départ d'un vaisseau marchand" },
|
||||
{ d66: 13, text: "Le capitaine s'insurge contre les prix du carburant" },
|
||||
{ d66: 14, text: "Une nouvelle sur l'activité de pirates s’affiche sur un écran de l’astroport et attire la foule" },
|
||||
{ d66: 15, text: "Un employé qui s'ennuie rend la vie difficile aux Voyageurs" },
|
||||
{ d66: 16, text: 'Un marchand local avec une cargaison à transporter cherche un vaisseau' },
|
||||
{ d66: 21, text: "Un dissident tente de demander l'asile aux autorités planétaires" },
|
||||
{ d66: 22, text: 'Des marchands Hors-monde discutent avec des négociants locaux' },
|
||||
{ d66: 23, text: "Un technicien réparant le système informatique d'astroport" },
|
||||
{ d66: 24, text: 'Un journaliste demande des nouvelles du Hors-monde' },
|
||||
{ d66: 25, text: 'Spectacle culturel insolite' },
|
||||
{ d66: 26, text: 'Un Client se dispute avec un autre groupe de Voyageurs' },
|
||||
{ d66: 31, text: "Arrivée ou départ d'un vaisseau militaire" },
|
||||
{ d66: 32, text: "Manifestation à l'extérieur de l'astroport" },
|
||||
{ d66: 33, text: 'Des prisonniers évadés implorent un passage vers le Hors-monde' },
|
||||
{ d66: 34, text: "Bazar improvisé d'objets bizarres" },
|
||||
{ d66: 35, text: 'Patrouille de sécurité' },
|
||||
{ d66: 36, text: 'Xéno inhabituel' },
|
||||
{ d66: 41, text: 'Des marchands proposent des pièces détachées et des fournitures à prix réduits.' },
|
||||
{ d66: 42, text: 'Un chantier de réparation prend feu' },
|
||||
{ d66: 43, text: "Arrivée ou départ d'un vaisseau spatial de type paquebot" },
|
||||
{ d66: 44, text: "Un robot serviteur propose de guider les Voyageurs dans l'astroport." },
|
||||
{ d66: 45, text: "Des marchands d'un système lointain vendant d'étranges curiosités" },
|
||||
{ d66: 46, text: "Un vieux Ceinturien infirme fait la manche en se plaignant que des drones ont pris son travail." },
|
||||
{ d66: 51, text: 'Le Client offre un emploi aux Voyageurs', followUp: 'client-mission' },
|
||||
{ d66: 52, text: "Passager à la recherche d'un vaisseau" },
|
||||
{ d66: 53, text: 'Des pèlerins religieux tentent de convertir les Voyageurs' },
|
||||
{ d66: 54, text: "Arrivée ou départ d'un transporteur de marchandises" },
|
||||
{ d66: 55, text: "Arrivée ou départ d’un vaisseau Éclaireur" },
|
||||
{ d66: 56, text: 'Des marchandises illégales ou dangereuses sont saisies' },
|
||||
{ d66: 61, text: 'Un pickpocket tente de voler les Voyageurs' },
|
||||
{ d66: 62, text: "Une bande d'ivrognes cherche la bagarre" },
|
||||
{ d66: 63, text: 'Des fonctionnaires enquêtent sur les Voyageurs' },
|
||||
{ d66: 64, text: 'Inspection de sécurité aléatoire des Voyageurs et de leurs bagages' },
|
||||
{ d66: 65, text: "L'astroport est temporairement fermé pour des raisons de sécurité" },
|
||||
{ d66: 66, text: "Accostage d'urgence d'un vaisseau endommagé" },
|
||||
];
|
||||
|
||||
export const RURAL_ENCOUNTERS_TABLE = [
|
||||
{ d66: 11, text: 'Animal sauvage' },
|
||||
{ d66: 12, text: 'Robots agricoles' },
|
||||
{ d66: 13, text: 'Un drone pulvérisateur survole la région' },
|
||||
{ d66: 14, text: "Réparation d'un robot agricole endommagé" },
|
||||
{ d66: 15, text: 'Petite communauté isolée' },
|
||||
{ d66: 16, text: 'Groupe de chasseurs nobles' },
|
||||
{ d66: 21, text: 'Animal sauvage' },
|
||||
{ d66: 22, text: "Terrain d'atterrissage local" },
|
||||
{ d66: 23, text: 'Enfant perdu' },
|
||||
{ d66: 24, text: 'Caravane marchande itinérante' },
|
||||
{ d66: 25, text: 'Convoi de marchandises' },
|
||||
{ d66: 26, text: 'Poursuite policière' },
|
||||
{ d66: 31, text: 'Animal sauvage' },
|
||||
{ d66: 32, text: 'Zone blanche de télécommunications' },
|
||||
{ d66: 33, text: 'Patrouille de sécurité' },
|
||||
{ d66: 34, text: 'Installation militaire' },
|
||||
{ d66: 35, text: 'Bar ou relais' },
|
||||
{ d66: 36, text: 'Vaisseau spatial échoué' },
|
||||
{ d66: 41, text: 'Animal sauvage' },
|
||||
{ d66: 42, text: 'Petite communauté – lieu de vie tranquille' },
|
||||
{ d66: 43, text: 'Petite communauté – sur une route commerciale' },
|
||||
{ d66: 44, text: 'Petite communauté – festival en cours' },
|
||||
{ d66: 45, text: 'Petite communauté en danger' },
|
||||
{ d66: 46, text: "Une petite communauté qui n'est pas ce qu'elle semble être" },
|
||||
{ d66: 51, text: 'Animal sauvage' },
|
||||
{ d66: 52, text: 'Conditions météorologiques inhabituelles' },
|
||||
{ d66: 53, text: 'Terrain difficile' },
|
||||
{ d66: 54, text: 'Créature inhabituelle' },
|
||||
{ d66: 55, text: 'Ferme isolée – accueillante' },
|
||||
{ d66: 56, text: 'Ferme isolée – hostile' },
|
||||
{ d66: 61, text: 'Animal sauvage' },
|
||||
{ d66: 62, text: 'Villa privée' },
|
||||
{ d66: 63, text: 'Monastère ou refuge' },
|
||||
{ d66: 64, text: 'Ferme expérimentale' },
|
||||
{ d66: 65, text: 'Structure en ruine' },
|
||||
{ d66: 66, text: 'Centre de recherche' },
|
||||
];
|
||||
|
||||
export const URBAN_ENCOUNTERS_TABLE = [
|
||||
{ d66: 11, text: 'Émeute de rue en cours' },
|
||||
{ d66: 12, text: 'Les Voyageurs passent devant un charmant restaurant' },
|
||||
{ d66: 13, text: 'Marchand de produits illégaux' },
|
||||
{ d66: 14, text: 'Dispute en public' },
|
||||
{ d66: 15, text: 'Changement soudain de temps' },
|
||||
{ d66: 16, text: "L’aide des Voyageurs est sollicitée" },
|
||||
{ d66: 21, text: 'Les Voyageurs passent devant un bar ou un pub' },
|
||||
{ d66: 22, text: 'Les Voyageurs passent devant un théâtre ou un autre lieu de divertissement' },
|
||||
{ d66: 23, text: 'Boutique de curiosités' },
|
||||
{ d66: 24, text: 'Un marchand sur un étal de marché en plein air tente de vendre quelque chose aux Voyageurs' },
|
||||
{ d66: 25, text: "Incendie, rupture de dôme ou autre situation d'urgence en cours" },
|
||||
{ d66: 26, text: 'Tentative de vol sur les Voyageurs' },
|
||||
{ d66: 31, text: 'Accident de véhicule impliquant les Voyageurs' },
|
||||
{ d66: 32, text: 'Un vaisseau spatial survole les Voyageurs à basse altitude' },
|
||||
{ d66: 33, text: 'Xéno-espèce ou autre Hors-monde' },
|
||||
{ d66: 34, text: 'Un personnage aléatoire bouscule un Voyageur', followUp: 'npc-contact' },
|
||||
{ d66: 35, text: 'Pickpocket' },
|
||||
{ d66: 36, text: 'Équipe média ou journaliste' },
|
||||
{ d66: 41, text: 'Patrouille de sécurité' },
|
||||
{ d66: 42, text: 'Bâtiment ancien ou archives' },
|
||||
{ d66: 43, text: 'Festival' },
|
||||
{ d66: 44, text: "Quelqu'un suit les personnages" },
|
||||
{ d66: 45, text: 'Groupe ou événement culturel inhabituel' },
|
||||
{ d66: 46, text: 'Fonctionnaire planétaire' },
|
||||
{ d66: 51, text: "Les Voyageurs repèrent quelqu'un qu'ils reconnaissent" },
|
||||
{ d66: 52, text: 'Manifestation publique' },
|
||||
{ d66: 53, text: 'Les Voyageurs croisent un robot ou autre serviteur' },
|
||||
{ d66: 54, text: 'Client potentiel', followUp: 'client-mission' },
|
||||
{ d66: 55, text: "Crime tel qu'un vol ou une attaque en cours" },
|
||||
{ d66: 56, text: "Un prêcheur de rue s'en prend aux Voyageurs" },
|
||||
{ d66: 61, text: "Diffusion d'informations sur les écrans publics" },
|
||||
{ d66: 62, text: 'Couvre-feu soudain ou autre restriction de mouvement' },
|
||||
{ d66: 63, text: 'Rue inhabituellement vide ou calme' },
|
||||
{ d66: 64, text: 'Annonce publique' },
|
||||
{ d66: 65, text: 'Événement sportif' },
|
||||
{ d66: 66, text: 'Dignitaire impérial' },
|
||||
];
|
||||
|
||||
export const ENCOUNTER_CONTEXTS = {
|
||||
starport: {
|
||||
label: 'Astroport',
|
||||
tableKey: 'starport-encounters',
|
||||
entries: STARPORT_ENCOUNTERS_TABLE,
|
||||
},
|
||||
rural: {
|
||||
label: 'Rural',
|
||||
tableKey: 'rural-encounters',
|
||||
entries: RURAL_ENCOUNTERS_TABLE,
|
||||
},
|
||||
urban: {
|
||||
label: 'Urbain',
|
||||
tableKey: 'urban-encounters',
|
||||
entries: URBAN_ENCOUNTERS_TABLE,
|
||||
},
|
||||
};
|
||||
|
||||
export const NPC_ROLLTABLE_DEFINITIONS = [
|
||||
{ key: 'allies-enemies', name: 'PNJ — Alliés et ennemis', formula: '1d36', entries: ALLIES_ENEMIES_TABLE },
|
||||
{ key: 'character-quirks', name: 'PNJ — Particularités', formula: '1d36', entries: CHARACTER_QUIRKS_TABLE },
|
||||
{ key: 'experience-profiles', name: 'PNJ — Expérience', formula: '1d8', entries: EXPERIENCE_PROFILES.map((entry, index) => ({ roll: index + 1, text: entry.label })) },
|
||||
{ key: 'random-clients', name: 'PNJ — Clients aléatoires', formula: '1d36', entries: RANDOM_CLIENT_TABLE },
|
||||
{ key: 'random-missions', name: 'PNJ — Missions aléatoires', formula: '1d36', entries: RANDOM_MISSION_TABLE },
|
||||
{ key: 'random-targets', name: 'PNJ — Cibles aléatoires', formula: '1d36', entries: RANDOM_TARGET_TABLE },
|
||||
{ key: 'random-opposition', name: 'PNJ — Oppositions aléatoires', formula: '1d36', entries: RANDOM_OPPOSITION_TABLE },
|
||||
{ key: 'starport-encounters', name: 'PNJ — Rencontres astroport', formula: '1d36', entries: STARPORT_ENCOUNTERS_TABLE },
|
||||
{ key: 'rural-encounters', name: 'PNJ — Rencontres rurales', formula: '1d36', entries: RURAL_ENCOUNTERS_TABLE },
|
||||
{ key: 'urban-encounters', name: 'PNJ — Rencontres urbaines', formula: '1d36', entries: URBAN_ENCOUNTERS_TABLE },
|
||||
];
|
||||
561
scripts/data/tradeTables.js
Normal file
561
scripts/data/tradeTables.js
Normal file
@@ -0,0 +1,561 @@
|
||||
/**
|
||||
* MGT2 Commerce – Trade Tables
|
||||
* Source : Mongoose Traveller 2e, pages 235–241 (traduction française)
|
||||
*
|
||||
* Clés des codes commerciaux (abréviation standard MGT2) :
|
||||
* Ag Agricole As Astéroïdes Ba Stérile
|
||||
* De Désert Fl Océans fluides Ga Jardin
|
||||
* Hi Pop. élevée Ht Haute tech IC Calotte glaciaire
|
||||
* In Industriel Lo Pop. basse Lt Basse tech
|
||||
* Na Non-Agricole Ni Non-Industriel Po Pauvre
|
||||
* Ri Riche Wa Monde aquatique Va Vide
|
||||
*
|
||||
* Les zones sont notées : 'ZA' (Zone Ambre) et 'ZR' (Zone Rouge).
|
||||
*/
|
||||
|
||||
/** Tarifs de passage et de fret, par distance en parsecs. */
|
||||
export const PASSAGE_COSTS = [
|
||||
{ parsecs: 1, sup: 9000, inter: 6500, eco: 2000, inf: 700, freight: 1000 },
|
||||
{ parsecs: 2, sup: 14000, inter: 10000, eco: 3000, inf: 1300, freight: 1600 },
|
||||
{ parsecs: 3, sup: 21000, inter: 14000, eco: 5000, inf: 2200, freight: 2600 },
|
||||
{ parsecs: 4, sup: 34000, inter: 23000, eco: 8000, inf: 3900, freight: 4400 },
|
||||
{ parsecs: 5, sup: 60000, inter: 40000, eco: 14000, inf: 7200, freight: 8500 },
|
||||
{ parsecs: 6, sup: 210000, inter: 130000, eco: 55000, inf: 27000, freight: 32000 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Table Trafic de passagers.
|
||||
* Clé = résultat 2D + modificateurs.
|
||||
* Valeur = formule de dés pour le nombre de passagers (string Roll-compatible).
|
||||
*/
|
||||
export const PASSENGER_TRAFFIC = {
|
||||
1: '0',
|
||||
2: '1d6',
|
||||
3: '1d6',
|
||||
4: '2d6',
|
||||
5: '2d6',
|
||||
6: '2d6',
|
||||
7: '3d6',
|
||||
8: '3d6',
|
||||
9: '3d6',
|
||||
10: '3d6',
|
||||
11: '4d6',
|
||||
12: '4d6',
|
||||
13: '4d6',
|
||||
14: '5d6',
|
||||
15: '5d6',
|
||||
16: '6d6',
|
||||
17: '7d6',
|
||||
18: '8d6',
|
||||
19: '9d6',
|
||||
20: '10d6',
|
||||
};
|
||||
|
||||
/**
|
||||
* Table Trafic de cargaison.
|
||||
* Clé = résultat 2D + modificateurs.
|
||||
* Valeur = formule de dés pour le nombre de lots.
|
||||
*/
|
||||
export const CARGO_TRAFFIC = {
|
||||
1: '0',
|
||||
2: '1d6',
|
||||
3: '1d6',
|
||||
4: '2d6',
|
||||
5: '2d6',
|
||||
6: '3d6',
|
||||
7: '3d6',
|
||||
8: '3d6',
|
||||
9: '4d6',
|
||||
10: '4d6',
|
||||
11: '4d6',
|
||||
12: '5d6',
|
||||
13: '5d6',
|
||||
14: '5d6',
|
||||
15: '6d6',
|
||||
16: '6d6',
|
||||
17: '7d6',
|
||||
18: '8d6',
|
||||
19: '9d6',
|
||||
20: '10d6',
|
||||
};
|
||||
|
||||
/**
|
||||
* Table Prix modifiés.
|
||||
* Clé = résultat du jet (3D + modificateurs).
|
||||
* Valeur = { buy: % du prix de base, sell: % du prix de base }.
|
||||
*/
|
||||
export const MODIFIED_PRICES = {
|
||||
'-3': { buy: 300, sell: 10 },
|
||||
'-2': { buy: 250, sell: 20 },
|
||||
'-1': { buy: 200, sell: 30 },
|
||||
'0': { buy: 175, sell: 40 },
|
||||
'1': { buy: 150, sell: 45 },
|
||||
'2': { buy: 135, sell: 50 },
|
||||
'3': { buy: 125, sell: 55 },
|
||||
'4': { buy: 120, sell: 60 },
|
||||
'5': { buy: 115, sell: 65 },
|
||||
'6': { buy: 110, sell: 70 },
|
||||
'7': { buy: 105, sell: 75 },
|
||||
'8': { buy: 100, sell: 80 },
|
||||
'9': { buy: 95, sell: 85 },
|
||||
'10': { buy: 90, sell: 90 },
|
||||
'11': { buy: 85, sell: 100 },
|
||||
'12': { buy: 80, sell: 105 },
|
||||
'13': { buy: 75, sell: 110 },
|
||||
'14': { buy: 70, sell: 115 },
|
||||
'15': { buy: 65, sell: 120 },
|
||||
'16': { buy: 60, sell: 125 },
|
||||
'17': { buy: 55, sell: 130 },
|
||||
'18': { buy: 50, sell: 140 },
|
||||
'19': { buy: 45, sell: 150 },
|
||||
'20': { buy: 40, sell: 160 },
|
||||
'21': { buy: 35, sell: 175 },
|
||||
'22': { buy: 30, sell: 200 },
|
||||
'23': { buy: 25, sell: 250 },
|
||||
'24': { buy: 20, sell: 300 },
|
||||
'25': { buy: 15, sell: 400 },
|
||||
};
|
||||
|
||||
/** Retourne les pourcentages d'achat/vente pour un résultat de jet donné. */
|
||||
export function getModifiedPrice(result) {
|
||||
const clamped = Math.max(-3, Math.min(25, result));
|
||||
return MODIFIED_PRICES[String(clamped)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Table des Marchandises (D66).
|
||||
* D66 11–36 : extraites des pages 235–241 du livre de règles.
|
||||
* D66 41–66 : à compléter dès réception des pages manquantes.
|
||||
*
|
||||
* Structure de chaque entrée :
|
||||
* d66 {number} Résultat D66 (11, 12, ..., 66)
|
||||
* name {string} Nom de la marchandise
|
||||
* availability {string[]} Codes commerciaux requis, ou ['all'] si disponible partout
|
||||
* tonsDice {string} Formule Roll pour les tonnes disponibles
|
||||
* basePrice {number} Prix de base en Crédits par tonne
|
||||
* illegal {boolean} Vrai si illégale universellement
|
||||
* buyMod {Object} Code commercial → modificateur d'achat
|
||||
* sellMod {Object} Code commercial → modificateur de vente
|
||||
*/
|
||||
export const GOODS_TABLE = [
|
||||
// ── D66 11–16 : Marchandises communes ────────────────────────────────────────
|
||||
{
|
||||
d66: 11,
|
||||
name: 'Électroniques communes',
|
||||
availability: ['all'],
|
||||
tonsDice: '2d6*10',
|
||||
basePrice: 20000,
|
||||
illegal: false,
|
||||
buyMod: { In: 2, Ht: 3, Ri: 1 },
|
||||
sellMod: { Ni: 2, Lt: 1, Po: 1 },
|
||||
examples: 'Électronique simple, y compris les ordinateurs basiques jusqu\'au NT10',
|
||||
},
|
||||
{
|
||||
d66: 12,
|
||||
name: 'Produits industriels communs',
|
||||
availability: ['all'],
|
||||
tonsDice: '2d6*10',
|
||||
basePrice: 10000,
|
||||
illegal: false,
|
||||
buyMod: { Na: 2, In: 5 },
|
||||
sellMod: { Ni: 3, Ag: 2 },
|
||||
examples: 'Composants de machines et pièces détachées pour machines courantes',
|
||||
},
|
||||
{
|
||||
d66: 13,
|
||||
name: 'Biens manufacturés communs',
|
||||
availability: ['all'],
|
||||
tonsDice: '2d6*10',
|
||||
basePrice: 20000,
|
||||
illegal: false,
|
||||
buyMod: { Na: 2, In: 5 },
|
||||
sellMod: { Ni: 3, Hi: 2 },
|
||||
examples: 'Appareils ménagers, vêtements, et autres produits manufacturés',
|
||||
},
|
||||
{
|
||||
d66: 14,
|
||||
name: 'Matières premières communes',
|
||||
availability: ['all'],
|
||||
tonsDice: '2d6*20',
|
||||
basePrice: 5000,
|
||||
illegal: false,
|
||||
buyMod: { Ag: 3, Ga: 2 },
|
||||
sellMod: { In: 2, Po: 2 },
|
||||
examples: 'Métaux, plastiques, produits chimiques et autres matériaux de base',
|
||||
},
|
||||
{
|
||||
d66: 15,
|
||||
name: 'Consommables communs',
|
||||
availability: ['all'],
|
||||
tonsDice: '2d6*20',
|
||||
basePrice: 500,
|
||||
illegal: false,
|
||||
buyMod: { Ag: 3, Wa: 2, Ga: 1, As: -4 },
|
||||
sellMod: { As: 1, Fl: 1, IC: 1, Hi: 1 },
|
||||
examples: 'Nourriture, boissons et autres produits agricoles',
|
||||
},
|
||||
{
|
||||
d66: 16,
|
||||
name: 'Minéraux communs',
|
||||
availability: ['all'],
|
||||
tonsDice: '2d6*20',
|
||||
basePrice: 1000,
|
||||
illegal: false,
|
||||
buyMod: { As: 4 },
|
||||
sellMod: { In: 3, Ni: 1 },
|
||||
examples: 'Métaux communs porteurs de minerai',
|
||||
},
|
||||
// ── D66 21–26 : Marchandises commerciales avancées ───────────────────────────
|
||||
{
|
||||
d66: 21,
|
||||
name: 'Électronique Avancée',
|
||||
availability: ['In', 'Ht'],
|
||||
tonsDice: '1d6*5',
|
||||
basePrice: 100000,
|
||||
illegal: false,
|
||||
buyMod: { In: 2, Ht: 3 },
|
||||
sellMod: { Ni: 1, Ri: 2, As: 3 },
|
||||
examples: 'Capteurs avancés, ordinateurs et autres produits électroniques jusqu\'au NT15',
|
||||
},
|
||||
{
|
||||
d66: 22,
|
||||
name: 'Pièces de Machines Avancées',
|
||||
availability: ['In', 'Ht'],
|
||||
tonsDice: '1d6*5',
|
||||
basePrice: 75000,
|
||||
illegal: false,
|
||||
buyMod: { In: 2, Ht: 1 },
|
||||
sellMod: { As: 2, Ni: 1 },
|
||||
examples: 'Composants de machines et pièces détachées, y compris les composants gravitiques',
|
||||
},
|
||||
{
|
||||
d66: 23,
|
||||
name: 'Biens Manufacturés Avancés',
|
||||
availability: ['In', 'Ht'],
|
||||
tonsDice: '1d6*5',
|
||||
basePrice: 100000,
|
||||
illegal: false,
|
||||
buyMod: { In: 1 },
|
||||
sellMod: { Hi: 1, Ri: 2 },
|
||||
examples: 'Appareils et vêtements intégrant des technologies avancées',
|
||||
},
|
||||
{
|
||||
d66: 24,
|
||||
name: 'Armes Avancées',
|
||||
availability: ['In', 'Ht'],
|
||||
tonsDice: '1d6*5',
|
||||
basePrice: 150000,
|
||||
illegal: false,
|
||||
buyMod: { Ht: 2 },
|
||||
sellMod: { Po: 1, ZA: 2, ZR: 4 },
|
||||
examples: 'Armes à feu, explosifs, munitions, artillerie et autres armements militaires de pointe',
|
||||
},
|
||||
{
|
||||
d66: 25,
|
||||
name: 'Véhicules Avancés',
|
||||
availability: ['In', 'Ht'],
|
||||
tonsDice: '1d6*5',
|
||||
basePrice: 180000,
|
||||
illegal: false,
|
||||
buyMod: { Ht: 2 },
|
||||
sellMod: { As: 2, Ri: 2 },
|
||||
examples: 'Véhicules spatiaux, tank grav, aéro/barges et autres véhicules jusqu\'au NT15',
|
||||
},
|
||||
{
|
||||
d66: 26,
|
||||
name: 'Biochimiques',
|
||||
availability: ['Ag', 'Wa'],
|
||||
tonsDice: '1d6*5',
|
||||
basePrice: 50000,
|
||||
illegal: false,
|
||||
buyMod: { Ag: 1, Wa: 2 },
|
||||
sellMod: { In: 2 },
|
||||
examples: 'Cultures bio-organiques, produits chimiques bio-carburants',
|
||||
},
|
||||
// ── D66 31–36 : Marchandises spéciales ──────────────────────────────────────
|
||||
{
|
||||
d66: 31,
|
||||
name: 'Cristaux & Gemmes',
|
||||
availability: ['As', 'De', 'IC'],
|
||||
tonsDice: '1d6*5',
|
||||
basePrice: 20000,
|
||||
illegal: false,
|
||||
buyMod: { As: 2, De: 1, IC: 1 },
|
||||
sellMod: { In: 3, Ri: 2 },
|
||||
examples: 'Diamants, gemmes synthétiques ou naturelles',
|
||||
},
|
||||
{
|
||||
d66: 32,
|
||||
name: 'Cybernétiques',
|
||||
availability: ['Ht'],
|
||||
tonsDice: '1d6',
|
||||
basePrice: 250000,
|
||||
illegal: false,
|
||||
buyMod: { Ht: 1 },
|
||||
sellMod: { As: 1, IC: 1, Ri: 2 },
|
||||
examples: 'Composants cybernétiques, prothèses',
|
||||
},
|
||||
{
|
||||
d66: 33,
|
||||
name: 'Animaux Vivants',
|
||||
availability: ['Ag', 'Ga'],
|
||||
tonsDice: '1d6*10',
|
||||
basePrice: 10000,
|
||||
illegal: false,
|
||||
buyMod: { Ag: 2 },
|
||||
sellMod: { Lo: 3 },
|
||||
examples: 'Animaux de trait, de ferme, ou animaux exotiques',
|
||||
},
|
||||
{
|
||||
d66: 34,
|
||||
name: 'Consommables de Luxe',
|
||||
availability: ['Ag', 'Ga', 'Wa'],
|
||||
tonsDice: '1d6*10',
|
||||
basePrice: 20000,
|
||||
illegal: false,
|
||||
buyMod: { Ag: 2, Wa: 1 },
|
||||
sellMod: { Ri: 2, Hi: 2 },
|
||||
examples: 'Nourriture rare, liqueurs fines',
|
||||
},
|
||||
{
|
||||
d66: 35,
|
||||
name: 'Biens de luxes',
|
||||
availability: ['Hi'],
|
||||
tonsDice: '1d6',
|
||||
basePrice: 200000,
|
||||
illegal: false,
|
||||
buyMod: { Hi: 1 },
|
||||
sellMod: { Ri: 4 },
|
||||
examples: 'Biens manufacturés rares ou de très haute qualité',
|
||||
},
|
||||
{
|
||||
d66: 36,
|
||||
name: 'Fournitures Médicales',
|
||||
availability: ['Ht', 'Hi'],
|
||||
tonsDice: '1d6*5',
|
||||
basePrice: 50000,
|
||||
illegal: false,
|
||||
buyMod: { Ht: 2 },
|
||||
sellMod: { In: 2, Po: 1, Ri: 1 },
|
||||
examples: 'Équipements de diagnostic, médicaments de base, technologies de clonage',
|
||||
},
|
||||
// ── D66 41–46 : Marchandises spécialisées ────────────────────────────────────
|
||||
{
|
||||
d66: 41,
|
||||
name: 'Pétrochimiques',
|
||||
availability: ['De', 'Fl', 'IC', 'Wa'],
|
||||
tonsDice: '1d6*10',
|
||||
basePrice: 10000,
|
||||
illegal: false,
|
||||
buyMod: { De: 2 },
|
||||
sellMod: { In: 2, Ag: 1, Lt: 2 },
|
||||
examples: 'Essences, carburants liquides',
|
||||
},
|
||||
{
|
||||
d66: 42,
|
||||
name: 'Produits Pharmaceutiques',
|
||||
availability: ['As', 'De', 'Hi', 'Wa'],
|
||||
tonsDice: '1d6',
|
||||
basePrice: 100000,
|
||||
illegal: false,
|
||||
buyMod: { As: 2, Hi: 1 },
|
||||
sellMod: { Ri: 2, Lt: 1 },
|
||||
examples: 'Médicaments, fournitures médicales, anagathiques, médistases ou tachymèdes',
|
||||
},
|
||||
{
|
||||
d66: 43,
|
||||
name: 'Polymères',
|
||||
availability: ['In'],
|
||||
tonsDice: '1d6*10',
|
||||
basePrice: 7000,
|
||||
illegal: false,
|
||||
buyMod: { In: 1 },
|
||||
sellMod: { Ri: 2, Ni: 1 },
|
||||
examples: 'Plastiques et autres matériaux synthétiques',
|
||||
},
|
||||
{
|
||||
d66: 44,
|
||||
name: 'Métaux Précieux',
|
||||
availability: ['As', 'De', 'IC', 'Fl'],
|
||||
tonsDice: '1d6',
|
||||
basePrice: 50000,
|
||||
illegal: false,
|
||||
buyMod: { As: 3, De: 1, IC: 2 },
|
||||
sellMod: { Ri: 3, In: 2, Ht: 1 },
|
||||
examples: 'Or, argent, platine, éléments rares',
|
||||
},
|
||||
{
|
||||
d66: 45,
|
||||
name: 'Matériaux radioactifs',
|
||||
availability: ['As', 'De', 'Lo'],
|
||||
tonsDice: '1d6',
|
||||
basePrice: 1000000,
|
||||
illegal: false,
|
||||
buyMod: { As: 2, Lo: 2 },
|
||||
sellMod: { In: 3, Ht: 1, Ni: -2, Ag: -3 },
|
||||
examples: 'Uranium, plutonium, unobtanium, éléments rares',
|
||||
},
|
||||
{
|
||||
d66: 46,
|
||||
name: 'Robots',
|
||||
availability: ['In'],
|
||||
tonsDice: '1d6*5',
|
||||
basePrice: 400000,
|
||||
illegal: false,
|
||||
buyMod: { In: 1 },
|
||||
sellMod: { Ag: 2, Ht: 1 },
|
||||
examples: 'Robots industriels et personnels, drones',
|
||||
},
|
||||
// ── D66 51–56 : Marchandises naturelles et véhicules ─────────────────────────
|
||||
{
|
||||
d66: 51,
|
||||
name: 'Épices',
|
||||
availability: ['Ga', 'De', 'Wa'],
|
||||
tonsDice: '1d6*10',
|
||||
basePrice: 6000,
|
||||
illegal: false,
|
||||
buyMod: { De: 2 },
|
||||
sellMod: { Hi: 2, Ri: 3, Po: 3 },
|
||||
examples: 'Conservateurs, additifs alimentaires de luxe, drogues naturelles',
|
||||
},
|
||||
{
|
||||
d66: 52,
|
||||
name: 'Textiles',
|
||||
availability: ['Ag', 'Ni'],
|
||||
tonsDice: '1d6*20',
|
||||
basePrice: 3000,
|
||||
illegal: false,
|
||||
buyMod: { Ag: 7 },
|
||||
sellMod: { Hi: 3, Na: 2 },
|
||||
examples: 'Vêtements et tissus',
|
||||
},
|
||||
{
|
||||
d66: 53,
|
||||
name: 'Minéraux Rares',
|
||||
availability: ['As', 'IC'],
|
||||
tonsDice: '1d6*20',
|
||||
basePrice: 5000,
|
||||
illegal: false,
|
||||
buyMod: { As: 4 },
|
||||
sellMod: { In: 3, Ni: 1 },
|
||||
examples: 'Minerai contenant des métaux précieux ou de valeur',
|
||||
},
|
||||
{
|
||||
d66: 54,
|
||||
name: 'Matières Premières Rares',
|
||||
availability: ['Ag', 'De', 'Wa'],
|
||||
tonsDice: '1d6*10',
|
||||
basePrice: 20000,
|
||||
illegal: false,
|
||||
buyMod: { Ag: 2, Wa: 1 },
|
||||
sellMod: { In: 2, Ht: 1 },
|
||||
examples: 'Métaux précieux comme le titane, éléments rares',
|
||||
},
|
||||
{
|
||||
d66: 55,
|
||||
name: 'Bois',
|
||||
availability: ['Ag', 'Ga'],
|
||||
tonsDice: '1d6*20',
|
||||
basePrice: 1000,
|
||||
illegal: false,
|
||||
buyMod: { Ag: 6 },
|
||||
sellMod: { Ri: 2, In: 1 },
|
||||
examples: 'Bois rares ou précieux et extraits végétaux',
|
||||
},
|
||||
{
|
||||
d66: 56,
|
||||
name: 'Véhicules',
|
||||
availability: ['In', 'Ht'],
|
||||
tonsDice: '1d6*10',
|
||||
basePrice: 15000,
|
||||
illegal: false,
|
||||
buyMod: { In: 2, Ht: 1 },
|
||||
sellMod: { Ni: 2, Hi: 1 },
|
||||
examples: 'Véhicules à roues, chenilles ou gravitationnels jusqu\'au NT10',
|
||||
},
|
||||
// ── D66 61–66 : Marchandises illégales ───────────────────────────────────────
|
||||
{
|
||||
d66: 61,
|
||||
name: 'Biochimiques (Illégal)',
|
||||
availability: ['Ag', 'Wa'],
|
||||
tonsDice: '1d6*5',
|
||||
basePrice: 50000,
|
||||
illegal: true,
|
||||
buyMod: { Wa: 2 },
|
||||
sellMod: { In: 6 },
|
||||
examples: 'Produits chimiques dangereux, extraits d\'espèces menacées',
|
||||
},
|
||||
{
|
||||
d66: 62,
|
||||
name: 'Cybernétiques (Illégal)',
|
||||
availability: ['Ht'],
|
||||
tonsDice: '1d6',
|
||||
basePrice: 250000,
|
||||
illegal: true,
|
||||
buyMod: { Ht: 1 },
|
||||
sellMod: { As: 4, IC: 4, Ri: 8, ZA: 6, ZR: 6 },
|
||||
examples: 'Cybernétique de combat, améliorations illégales',
|
||||
},
|
||||
{
|
||||
d66: 63,
|
||||
name: 'Drogues (Illégal)',
|
||||
availability: ['As', 'De', 'Hi', 'Wa'],
|
||||
tonsDice: '1d6',
|
||||
basePrice: 100000,
|
||||
illegal: true,
|
||||
buyMod: { As: 1, De: 1, Ga: 1, Wa: 1 },
|
||||
sellMod: { Ri: 6, Hi: 6 },
|
||||
examples: 'Drogues addictives, drogues de combat',
|
||||
},
|
||||
{
|
||||
d66: 64,
|
||||
name: 'Biens de luxe (Illégal)',
|
||||
availability: ['Ag', 'Ga', 'Wa'],
|
||||
tonsDice: '1d6',
|
||||
basePrice: 50000,
|
||||
illegal: true,
|
||||
buyMod: { Ag: 2, Wa: 1 },
|
||||
sellMod: { Ri: 6, Hi: 4 },
|
||||
examples: 'Produits de luxes décadents ou addictifs',
|
||||
},
|
||||
{
|
||||
d66: 65,
|
||||
name: 'Armes (Illégal)',
|
||||
availability: ['In', 'Ht'],
|
||||
tonsDice: '1d6*5',
|
||||
basePrice: 150000,
|
||||
illegal: true,
|
||||
buyMod: { Ht: 2 },
|
||||
sellMod: { Po: 6, ZA: 8, ZR: 10 },
|
||||
examples: 'Armes de destruction massive, armements navals',
|
||||
},
|
||||
{
|
||||
d66: 66,
|
||||
name: 'Exotiques',
|
||||
availability: ['all'],
|
||||
tonsDice: '1d6',
|
||||
basePrice: 0, // prix libre, à déterminer par l'Arbitre
|
||||
illegal: false,
|
||||
buyMod: {},
|
||||
sellMod: {},
|
||||
examples: 'Reliques xénos, prototypes technologiques, plantes ou animaux uniques, trésors inestimables. Prix et disponibilité à la discrétion de l\'Arbitre.',
|
||||
},
|
||||
];
|
||||
|
||||
/** Codes commerciaux reconnus par les tables de marchandises. */
|
||||
export const TRADE_CODES = ['Ag','As','Ba','De','Fl','Ga','Hi','Ht','IC','In','Lo','Lt','Na','Ni','Po','Ri','Wa','Va'];
|
||||
|
||||
/** Catégories de passage (labels français). */
|
||||
export const PASSAGE_CATEGORIES = {
|
||||
sup: 'Supérieur',
|
||||
inter: 'Intermédiaire',
|
||||
eco: 'Éco',
|
||||
inf: 'Inférieur',
|
||||
};
|
||||
|
||||
/** Tailles de lots de cargaison et leurs formules. */
|
||||
export const CARGO_LOT_SIZES = {
|
||||
major: { label: 'Lot majeur', formula: '1d6*10' },
|
||||
minor: { label: 'Lot mineur', formula: '1d6*5' },
|
||||
access: { label: 'Lot accessoire', formula: '1d6' },
|
||||
};
|
||||
41
scripts/npc.js
Normal file
41
scripts/npc.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NpcDialog } from './NpcDialog.js';
|
||||
import { syncNpcRollTables } from './npcRollTableSync.js';
|
||||
|
||||
const MODULE_ID = 'mgt2-compendium-amiral-denisov';
|
||||
|
||||
function openNpcDialog(initialTab, options = {}) {
|
||||
new NpcDialog({ initialTab, ...options }).render(true);
|
||||
}
|
||||
|
||||
Hooks.once('init', () => {
|
||||
console.log(`${MODULE_ID} | Outils PNJ initialisés`);
|
||||
|
||||
loadTemplates([
|
||||
`modules/${MODULE_ID}/templates/npc-dialog.hbs`,
|
||||
`modules/${MODULE_ID}/templates/npc-result.hbs`,
|
||||
]);
|
||||
});
|
||||
|
||||
Hooks.once('ready', async () => {
|
||||
await syncNpcRollTables();
|
||||
console.log(`${MODULE_ID} | Outils PNJ prêts – tapez /pnj, /rencontre ou /mission dans le chat`);
|
||||
});
|
||||
|
||||
Hooks.on('chatMessage', (_chatLog, message, _chatData) => {
|
||||
const trimmed = message.trim().toLowerCase();
|
||||
|
||||
if (trimmed === '/pnj' || trimmed.startsWith('/pnj ')) {
|
||||
openNpcDialog('npc');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (trimmed === '/rencontre' || trimmed.startsWith('/rencontre ')) {
|
||||
openNpcDialog('encounter');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (trimmed === '/mission' || trimmed.startsWith('/mission ')) {
|
||||
openNpcDialog('mission');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
345
scripts/npcHelper.js
Normal file
345
scripts/npcHelper.js
Normal file
@@ -0,0 +1,345 @@
|
||||
import { GOODS_TABLE } from './data/tradeTables.js';
|
||||
import {
|
||||
NPC_RELATIONS,
|
||||
EXPERIENCE_PROFILES,
|
||||
ALLIES_ENEMIES_TABLE,
|
||||
CHARACTER_QUIRKS_TABLE,
|
||||
RANDOM_CLIENT_TABLE,
|
||||
RANDOM_MISSION_TABLE,
|
||||
RANDOM_TARGET_TABLE,
|
||||
RANDOM_OPPOSITION_TABLE,
|
||||
ENCOUNTER_CONTEXTS,
|
||||
} from './data/npcTables.js';
|
||||
|
||||
const MODULE_ID = 'mgt2-compendium-amiral-denisov';
|
||||
const SKILL_PACK_ID = `${MODULE_ID}.competences`;
|
||||
|
||||
const CORE_CHARACTERISTICS = ['strength', 'dexterity', 'endurance', 'intellect', 'education', 'social'];
|
||||
const DEFAULT_PRIORITIES = {
|
||||
'Non-combattant': ['intellect', 'education', 'social', 'dexterity', 'endurance', 'strength'],
|
||||
'Combattant': ['dexterity', 'endurance', 'strength', 'education', 'intellect', 'social'],
|
||||
};
|
||||
|
||||
const ROLE_HINTS = [
|
||||
{ match: /médecin/i, skills: ['Médecine', 'Science'], priorities: ['education', 'intellect', 'dexterity'] },
|
||||
{ match: /scientifique|chercheur/i, skills: ['Science', 'Électronique'], priorities: ['intellect', 'education', 'dexterity'] },
|
||||
{ match: /diplomate|ambassadeur|attaché culturel/i, skills: ['Diplomatie', 'Langage', 'Persuader'], priorities: ['social', 'education', 'intellect'] },
|
||||
{ match: /marchand|franc-marchand|courtier/i, skills: ['Courtier', 'Administration', 'Persuader'], priorities: ['social', 'education', 'intellect'] },
|
||||
{ match: /mercenaire|seigneur de guerre/i, skills: ['Combat Arme', 'Mêlée', 'Reconnaissance'], priorities: ['dexterity', 'endurance', 'strength'] },
|
||||
{ match: /officier de marine|amiral|capitaine/i, skills: ['Leadership', 'Marin', 'Tactique'], priorities: ['education', 'social', 'intellect'] },
|
||||
{ match: /explorateur|éclaireur/i, skills: ['Reconnaissance', 'Survie', 'Navigation'], priorities: ['education', 'dexterity', 'endurance'] },
|
||||
{ match: /interprète|xéno/i, skills: ['Langage', 'Diplomatie', 'Science'], priorities: ['education', 'intellect', 'social'] },
|
||||
{ match: /cadre de corpo|agent corpo|administrateur|gouverneur|homme d'état|noble/i, skills: ['Administration', 'Leadership', 'Diplomatie'], priorities: ['social', 'education', 'intellect'] },
|
||||
{ match: /journaliste|enquêteur|inspecteur|agent impérial/i, skills: ['Enquêter', 'Persuader', 'Combat Arme'], priorities: ['intellect', 'education', 'dexterity'] },
|
||||
{ match: /conspirateur|criminel|contrebandier/i, skills: ['Duperie', 'Sens de la rue', 'Combat Arme'], priorities: ['social', 'dexterity', 'intellect'] },
|
||||
{ match: /chef religieux|cultiste/i, skills: ['Persuader', 'Leadership', 'Langage'], priorities: ['social', 'education', 'intellect'] },
|
||||
{ match: /joueur|playboy/i, skills: ['Flambeur', 'Mondanités', 'Persuader'], priorities: ['social', 'intellect', 'education'] },
|
||||
{ match: /intelligence artificielle/i, skills: ['Électronique', 'Science', 'Profession'], priorities: ['intellect', 'education', 'social'] },
|
||||
];
|
||||
|
||||
function getD66Entry(entries, total) {
|
||||
return entries.find((entry) => entry.d66 === total) ?? null;
|
||||
}
|
||||
|
||||
async function rollFormula(formula) {
|
||||
const roll = await new Roll(formula).evaluate();
|
||||
return { formula, total: roll.total };
|
||||
}
|
||||
|
||||
async function rollD66(entries) {
|
||||
const tens = await rollFormula('1d6');
|
||||
const ones = await rollFormula('1d6');
|
||||
const total = (tens.total * 10) + ones.total;
|
||||
return {
|
||||
formula: 'D66',
|
||||
tens: tens.total,
|
||||
ones: ones.total,
|
||||
total,
|
||||
entry: getD66Entry(entries, total),
|
||||
};
|
||||
}
|
||||
|
||||
async function rollFlat(entries) {
|
||||
const roll = await rollFormula(`1d${entries.length}`);
|
||||
return {
|
||||
formula: `1d${entries.length}`,
|
||||
total: roll.total,
|
||||
entry: entries[roll.total - 1] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function pickGoodsPool(type) {
|
||||
if (type === 'illegal-goods') return GOODS_TABLE.filter((good) => good.illegal);
|
||||
if (type === 'trade-goods') return GOODS_TABLE.filter((good) => !good.illegal);
|
||||
return [];
|
||||
}
|
||||
|
||||
async function resolveSpecialTarget(entry) {
|
||||
if (!entry?.special) return null;
|
||||
|
||||
switch (entry.special) {
|
||||
case 'client': {
|
||||
const nested = await rollD66(RANDOM_CLIENT_TABLE);
|
||||
return {
|
||||
label: 'Client tiré',
|
||||
roll: nested,
|
||||
text: nested.entry?.text ?? '',
|
||||
};
|
||||
}
|
||||
case 'ally-enemy': {
|
||||
const nested = await rollD66(ALLIES_ENEMIES_TABLE);
|
||||
return {
|
||||
label: 'PNJ tiré',
|
||||
roll: nested,
|
||||
text: nested.entry?.text ?? '',
|
||||
};
|
||||
}
|
||||
case 'trade-goods':
|
||||
case 'illegal-goods': {
|
||||
const goods = pickGoodsPool(entry.special);
|
||||
if (!goods.length) return null;
|
||||
const nested = await rollFlat(goods);
|
||||
return {
|
||||
label: entry.special === 'illegal-goods' ? 'Marchandise illicite' : 'Marchandise tirée',
|
||||
roll: nested,
|
||||
text: nested.entry?.name ?? '',
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getExperiencePool(mode) {
|
||||
if (mode === 'combatant') return EXPERIENCE_PROFILES.filter((entry) => entry.category === 'Combattant');
|
||||
if (mode === 'noncombatant') return EXPERIENCE_PROFILES.filter((entry) => entry.category === 'Non-combattant');
|
||||
return EXPERIENCE_PROFILES;
|
||||
}
|
||||
|
||||
async function generateExperience(mode = 'random') {
|
||||
const pool = getExperiencePool(mode);
|
||||
const roll = await rollFlat(pool);
|
||||
return {
|
||||
roll,
|
||||
profile: roll.entry,
|
||||
};
|
||||
}
|
||||
|
||||
function findRoleHint(roleName, category) {
|
||||
const hint = ROLE_HINTS.find((entry) => entry.match.test(roleName));
|
||||
return hint ?? {
|
||||
skills: ['Profession'],
|
||||
priorities: DEFAULT_PRIORITIES[category] ?? DEFAULT_PRIORITIES['Non-combattant'],
|
||||
};
|
||||
}
|
||||
|
||||
function toHex(value) {
|
||||
return Math.max(0, Math.min(15, value)).toString(16).toUpperCase();
|
||||
}
|
||||
|
||||
function calculateDm(value) {
|
||||
return Math.floor((value - 6) / 3);
|
||||
}
|
||||
|
||||
function buildCharacteristicValues(result) {
|
||||
const profile = result.experience.profile;
|
||||
const hint = findRoleHint(result.role.entry.text, profile.category);
|
||||
const values = Object.fromEntries(CORE_CHARACTERISTICS.map((key) => [key, 7]));
|
||||
|
||||
profile.characteristicBonuses.forEach((bonus, index) => {
|
||||
const key = hint.priorities[index] ?? hint.priorities.at(-1) ?? CORE_CHARACTERISTICS[0];
|
||||
values[key] += Number.parseInt(String(bonus).replace('+', ''), 10);
|
||||
});
|
||||
|
||||
return {
|
||||
values,
|
||||
hint,
|
||||
ucp: CORE_CHARACTERISTICS.map((key) => toHex(values[key])).join(''),
|
||||
};
|
||||
}
|
||||
|
||||
function buildCharacteristicsData(values) {
|
||||
const allKeys = {
|
||||
strength: { showMax: true },
|
||||
dexterity: { showMax: true },
|
||||
endurance: { showMax: true },
|
||||
intellect: { showMax: false },
|
||||
education: { showMax: false },
|
||||
social: { showMax: false },
|
||||
morale: { showMax: false, value: 0 },
|
||||
luck: { showMax: false, value: 0 },
|
||||
sanity: { showMax: false, value: 0 },
|
||||
charm: { showMax: false, value: 0 },
|
||||
psionic: { showMax: false, value: 0 },
|
||||
other: { showMax: false, value: 0 },
|
||||
};
|
||||
|
||||
return Object.fromEntries(Object.entries(allKeys).map(([key, config]) => {
|
||||
const value = values[key] ?? config.value ?? 0;
|
||||
return [key, {
|
||||
value,
|
||||
max: value,
|
||||
dm: calculateDm(value),
|
||||
show: true,
|
||||
showMax: config.showMax,
|
||||
}];
|
||||
}));
|
||||
}
|
||||
|
||||
async function getSkillPackIndex() {
|
||||
const pack = game.packs.get(SKILL_PACK_ID);
|
||||
if (!pack) throw new Error(`Pack de compétences introuvable : ${SKILL_PACK_ID}`);
|
||||
const index = await pack.getIndex();
|
||||
return { pack, index };
|
||||
}
|
||||
|
||||
function mergeSkillLevels(profileSkills, roleSkills, baseLevel) {
|
||||
const levels = new Map();
|
||||
|
||||
for (const skill of profileSkills) levels.set(skill, baseLevel);
|
||||
for (const skill of roleSkills) levels.set(skill, Math.max(levels.get(skill) ?? 0, Math.max(1, baseLevel)));
|
||||
|
||||
return levels;
|
||||
}
|
||||
|
||||
async function buildSkillItems(result) {
|
||||
const { pack, index } = await getSkillPackIndex();
|
||||
const { hint } = buildCharacteristicValues(result);
|
||||
const skillLevels = mergeSkillLevels(result.experience.profile.skills, hint.skills, result.experience.profile.skillLevel);
|
||||
const items = [];
|
||||
|
||||
for (const [skillName, level] of skillLevels.entries()) {
|
||||
const entry = index.contents.find((item) => item.name === skillName);
|
||||
if (!entry) continue;
|
||||
const document = await pack.getDocument(entry._id);
|
||||
const data = document.toObject();
|
||||
delete data._id;
|
||||
delete data.folder;
|
||||
data.system.level = level;
|
||||
items.push(data);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildActorDescription(result, actorName, ucp) {
|
||||
return [
|
||||
`${actorName} — ${result.role.entry.text}`,
|
||||
`Relation : ${result.relation.label}`,
|
||||
`Particularité : ${result.quirk.entry.text}`,
|
||||
`Expérience : ${result.experience.profile.label}`,
|
||||
`UCP : ${ucp}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function formatSigned(value) {
|
||||
return value >= 0 ? `+${value}` : `${value}`;
|
||||
}
|
||||
|
||||
export async function generateQuickNpc(params = {}) {
|
||||
const relationKey = params.relation && NPC_RELATIONS[params.relation] ? params.relation : 'contact';
|
||||
const relation = { key: relationKey, ...NPC_RELATIONS[relationKey] };
|
||||
const role = await rollD66(ALLIES_ENEMIES_TABLE);
|
||||
const quirk = await rollD66(CHARACTER_QUIRKS_TABLE);
|
||||
const experience = await generateExperience(params.experienceBias ?? 'random');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
type: 'npc',
|
||||
relation,
|
||||
role,
|
||||
quirk,
|
||||
experience,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createNpcActor(result, options = {}) {
|
||||
const actorName = options.name?.trim() || `PNJ — ${result.role.entry.text}`;
|
||||
const { values, ucp } = buildCharacteristicValues(result);
|
||||
const items = await buildSkillItems(result);
|
||||
const actor = await Actor.create({
|
||||
name: actorName,
|
||||
type: 'character',
|
||||
img: 'icons/svg/mystery-man.svg',
|
||||
system: {
|
||||
life: {
|
||||
value: values.endurance,
|
||||
max: values.endurance,
|
||||
},
|
||||
personal: {
|
||||
title: result.role.entry.text,
|
||||
species: '',
|
||||
speciesText: {},
|
||||
age: '',
|
||||
ucp,
|
||||
traits: [
|
||||
{ name: result.relation.label },
|
||||
{ name: result.quirk.entry.text },
|
||||
],
|
||||
},
|
||||
characteristics: buildCharacteristicsData(values),
|
||||
biography: buildActorDescription(result, actorName, ucp),
|
||||
notes: buildActorDescription(result, actorName, ucp),
|
||||
},
|
||||
flags: {
|
||||
[MODULE_ID]: {
|
||||
generatedNpc: {
|
||||
relation: result.relation.key,
|
||||
role: result.role.entry.text,
|
||||
quirk: result.quirk.entry.text,
|
||||
experience: result.experience.profile.label,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, { renderSheet: false });
|
||||
|
||||
if (items.length) await actor.createEmbeddedDocuments('Item', items);
|
||||
if (options.openSheet !== false) actor.sheet?.render(true);
|
||||
return actor;
|
||||
}
|
||||
|
||||
export async function generateClientMission() {
|
||||
const client = await rollD66(RANDOM_CLIENT_TABLE);
|
||||
const mission = await rollD66(RANDOM_MISSION_TABLE);
|
||||
const target = await rollD66(RANDOM_TARGET_TABLE);
|
||||
const opposition = await rollD66(RANDOM_OPPOSITION_TABLE);
|
||||
const targetResolution = await resolveSpecialTarget(target.entry);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
type: 'client-mission',
|
||||
client,
|
||||
mission,
|
||||
target,
|
||||
targetResolution,
|
||||
opposition,
|
||||
rewardGuidance: "Le PDF ne fournit pas de table de rémunération détaillée : négociez une récompense légèrement supérieure à ce que les Voyageurs gagneraient via le commerce.",
|
||||
};
|
||||
}
|
||||
|
||||
function getEncounterContext(context) {
|
||||
return ENCOUNTER_CONTEXTS[context] ?? ENCOUNTER_CONTEXTS.starport;
|
||||
}
|
||||
|
||||
async function resolveEncounterFollowUp(followUp) {
|
||||
if (followUp === 'client-mission') return generateClientMission();
|
||||
if (followUp === 'npc-contact') return generateQuickNpc({ relation: 'contact', experienceBias: 'random' });
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function generateEncounter(params = {}) {
|
||||
const context = getEncounterContext(params.context);
|
||||
const encounter = await rollD66(context.entries);
|
||||
const followUp = params.includeFollowUp === false ? null : await resolveEncounterFollowUp(encounter.entry?.followUp);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
type: 'encounter',
|
||||
context: {
|
||||
key: params.context ?? 'starport',
|
||||
label: context.label,
|
||||
},
|
||||
encounter,
|
||||
followUp,
|
||||
};
|
||||
}
|
||||
147
scripts/npcRollTableSync.js
Normal file
147
scripts/npcRollTableSync.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import { NPC_ROLLTABLE_DEFINITIONS } from './data/npcTables.js';
|
||||
|
||||
const MODULE_ID = 'mgt2-compendium-amiral-denisov';
|
||||
const PACK_ID = `${MODULE_ID}.tables-pnj`;
|
||||
const WORLD_FOLDER_NAME = 'MGT2 — Tables PNJ';
|
||||
const ROLLTABLES_VERSION = 1;
|
||||
|
||||
function entryText(entry) {
|
||||
if (typeof entry.d66 === 'number') return `[${entry.d66}] ${entry.text}`;
|
||||
if (typeof entry.roll === 'number') return `[${entry.roll}] ${entry.text}`;
|
||||
return entry.text;
|
||||
}
|
||||
|
||||
function buildTableResults(entries) {
|
||||
return entries.map((entry, index) => ({
|
||||
type: CONST.TABLE_RESULT_TYPES.TEXT,
|
||||
text: entryText(entry),
|
||||
weight: 1,
|
||||
range: [index + 1, index + 1],
|
||||
drawn: false,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildTableData(definition, extra = {}) {
|
||||
return {
|
||||
name: definition.name,
|
||||
description: 'Table synchronisée automatiquement depuis les règles PNJ du module.',
|
||||
formula: definition.formula,
|
||||
replacement: true,
|
||||
displayRoll: true,
|
||||
results: buildTableResults(definition.entries),
|
||||
flags: {
|
||||
[MODULE_ID]: {
|
||||
tableKey: definition.key,
|
||||
syncVersion: ROLLTABLES_VERSION,
|
||||
},
|
||||
},
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeResults(results) {
|
||||
return results.map((result) => ({
|
||||
type: result.type,
|
||||
text: result.name ?? result.description ?? result.text ?? '',
|
||||
weight: result.weight,
|
||||
range: Array.from(result.range),
|
||||
}));
|
||||
}
|
||||
|
||||
function needsSync(existing, wanted) {
|
||||
if (!existing) return true;
|
||||
if (existing.name !== wanted.name) return true;
|
||||
if (existing.formula !== wanted.formula) return true;
|
||||
if (existing.replacement !== wanted.replacement) return true;
|
||||
const currentVersion = existing.getFlag(MODULE_ID, 'syncVersion');
|
||||
if (currentVersion !== ROLLTABLES_VERSION) return true;
|
||||
return JSON.stringify(normalizeResults(existing.results.contents)) !== JSON.stringify(normalizeResults(wanted.results));
|
||||
}
|
||||
|
||||
function getExistingKey(document) {
|
||||
return document.getFlag(MODULE_ID, 'tableKey') ?? document.name;
|
||||
}
|
||||
|
||||
async function syncCompendiumPack() {
|
||||
const pack = game.packs.get(PACK_ID);
|
||||
if (!pack) throw new Error(`Pack introuvable : ${PACK_ID}`);
|
||||
|
||||
await pack.getIndex();
|
||||
const existingDocs = await pack.getDocuments();
|
||||
const existingByKey = new Map(existingDocs.map((doc) => [getExistingKey(doc), doc]));
|
||||
const toCreate = [];
|
||||
const toUpdate = [];
|
||||
const wasLocked = pack.locked;
|
||||
|
||||
try {
|
||||
if (wasLocked && typeof pack.configure === 'function') await pack.configure({ locked: false });
|
||||
|
||||
for (const definition of NPC_ROLLTABLE_DEFINITIONS) {
|
||||
const wanted = buildTableData(definition);
|
||||
const existing = existingByKey.get(definition.key);
|
||||
if (!existing) {
|
||||
toCreate.push(wanted);
|
||||
continue;
|
||||
}
|
||||
if (needsSync(existing, wanted)) {
|
||||
toUpdate.push({
|
||||
_id: existing.id,
|
||||
...wanted,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (toCreate.length) await RollTable.createDocuments(toCreate, { pack: pack.collection });
|
||||
if (toUpdate.length) await RollTable.updateDocuments(toUpdate, { pack: pack.collection });
|
||||
} finally {
|
||||
if (wasLocked && typeof pack.configure === 'function') await pack.configure({ locked: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function getWorldFolder() {
|
||||
let folder = game.folders.find((entry) => entry.type === 'RollTable' && entry.name === WORLD_FOLDER_NAME);
|
||||
if (folder) return folder;
|
||||
return Folder.create({
|
||||
name: WORLD_FOLDER_NAME,
|
||||
type: 'RollTable',
|
||||
color: '#c9a227',
|
||||
});
|
||||
}
|
||||
|
||||
async function syncWorldFallback() {
|
||||
const folder = await getWorldFolder();
|
||||
const existingDocs = game.tables.filter((doc) => doc.folder?.id === folder.id);
|
||||
const existingByKey = new Map(existingDocs.map((doc) => [getExistingKey(doc), doc]));
|
||||
const toCreate = [];
|
||||
const toUpdate = [];
|
||||
|
||||
for (const definition of NPC_ROLLTABLE_DEFINITIONS) {
|
||||
const wanted = buildTableData(definition, { folder: folder.id });
|
||||
const existing = existingByKey.get(definition.key);
|
||||
if (!existing) {
|
||||
toCreate.push(wanted);
|
||||
continue;
|
||||
}
|
||||
if (needsSync(existing, wanted)) {
|
||||
toUpdate.push({
|
||||
_id: existing.id,
|
||||
...wanted,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (toCreate.length) await RollTable.createDocuments(toCreate);
|
||||
if (toUpdate.length) await RollTable.updateDocuments(toUpdate);
|
||||
}
|
||||
|
||||
export async function syncNpcRollTables() {
|
||||
if (!game.user?.isGM) return;
|
||||
|
||||
try {
|
||||
await syncCompendiumPack();
|
||||
} catch (error) {
|
||||
console.error(`${MODULE_ID} | Échec de synchronisation du pack RollTable`, error);
|
||||
await syncWorldFallback();
|
||||
ui.notifications.warn('Tables PNJ synchronisées dans le monde courant (fallback) — le pack de compendium n’a pas pu être mis à jour.');
|
||||
}
|
||||
}
|
||||
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`;
|
||||
}
|
||||
131
scripts/travellerMapApi.js
Normal file
131
scripts/travellerMapApi.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* MGT2 Commerce – Client API Traveller Map
|
||||
*
|
||||
* Documentation : https://travellermap.com/doc/api
|
||||
*
|
||||
* Deux fonctions exportées :
|
||||
* searchWorlds(query) → [{name, sector, hex, uwp, zone}]
|
||||
* fetchWorldDetail(sector, hex) → {uwp, zone} (zone: 'normal'|'amber'|'red')
|
||||
*/
|
||||
|
||||
const BASE_URL = 'https://travellermap.com';
|
||||
|
||||
/** Convertit la zone Traveller Map ('A', 'R', '') vers notre convention. */
|
||||
function normalizeZone(z) {
|
||||
if (z === 'A') return 'amber';
|
||||
if (z === 'R') return 'red';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche des mondes par nom via l'API Traveller Map.
|
||||
*
|
||||
* @param {string} query Nom partiel ou complet (ex. "Regina")
|
||||
* @returns {Promise<Array<{name:string, sector:string, hex:string, uwp:string}>>}
|
||||
* Tableau de résultats (uniquement les mondes, pas secteurs/subsecteurs).
|
||||
* Vide si erreur réseau.
|
||||
*/
|
||||
export async function searchWorlds(query) {
|
||||
if (!query || query.trim().length < 2) return [];
|
||||
|
||||
const url = `${BASE_URL}/api/search?q=${encodeURIComponent(query.trim())}`;
|
||||
|
||||
let data;
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
data = await resp.json();
|
||||
} catch (err) {
|
||||
console.warn('MGT2 Commerce | Traveller Map search error:', err);
|
||||
return [];
|
||||
}
|
||||
|
||||
const items = data?.Results?.Items ?? [];
|
||||
return items
|
||||
.filter(item => item.World) // garder uniquement les mondes
|
||||
.map(item => {
|
||||
const w = item.World;
|
||||
// Formatter le code hex sur 4 chiffres (HexX→XX, HexY→YY)
|
||||
const hex = String(w.HexX).padStart(2, '0') + String(w.HexY).padStart(2, '0');
|
||||
return {
|
||||
name: w.Name,
|
||||
sector: w.Sector,
|
||||
hex,
|
||||
uwp: w.Uwp ?? '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les coordonnées absolues d'un monde en parsecs.
|
||||
* Utilise l'endpoint /api/coordinates.
|
||||
*
|
||||
* @param {string} sector Nom du secteur (ex. "Spinward Marches")
|
||||
* @param {string} hex Code hex 4 chiffres (ex. "1910")
|
||||
* @returns {Promise<{x:number, y:number}|null>}
|
||||
*/
|
||||
export async function fetchWorldCoordinates(sector, hex) {
|
||||
const url = `${BASE_URL}/api/coordinates?sector=${encodeURIComponent(sector)}&hex=${encodeURIComponent(hex)}`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
if (data?.x == null || data?.y == null) return null;
|
||||
return { x: data.x, y: data.y };
|
||||
} catch (err) {
|
||||
console.warn('MGT2 Commerce | Traveller Map coordinates error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule la distance en parsecs entre deux mondes à partir de leurs
|
||||
* coordonnées absolues (retournées par fetchWorldCoordinates).
|
||||
* Utilise la formule de distance en coordonnées cubiques pour grille hexagonale.
|
||||
*
|
||||
* Dans le système Traveller Map : les colonnes impaires (hx impair) sont
|
||||
* décalées vers le bas. En coordonnées API, hx impair ↔ x pair.
|
||||
* Conversion offset→cube : q=x, r=y−⌈x/2⌉, s=−q−r.
|
||||
* Distance = max(|Δq|, |Δr|, |Δs|).
|
||||
*
|
||||
* @param {{x:number,y:number}} c1
|
||||
* @param {{x:number,y:number}} c2
|
||||
* @returns {number} Distance en parsecs (entier, minimum 1)
|
||||
*/
|
||||
export function calcParsecs(c1, c2) {
|
||||
const q1 = c1.x, r1 = c1.y - Math.ceil(c1.x / 2);
|
||||
const q2 = c2.x, r2 = c2.y - Math.ceil(c2.x / 2);
|
||||
const dq = q2 - q1, dr = r2 - r1, ds = -dq - dr;
|
||||
return Math.max(1, Math.max(Math.abs(dq), Math.abs(dr), Math.abs(ds)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les détails d'un monde pour obtenir la zone de voyage.
|
||||
* Utilise l'endpoint /data/{sector}/{hex} (jump=0).
|
||||
*
|
||||
* @param {string} sector Nom du secteur (ex. "Spinward Marches")
|
||||
* @param {string} hex Code hex 4 chiffres (ex. "1910")
|
||||
* @returns {Promise<{uwp:string, zone:string}>}
|
||||
*/
|
||||
export async function fetchWorldDetail(sector, hex) {
|
||||
const url = `${BASE_URL}/data/${encodeURIComponent(sector)}/${hex}`;
|
||||
|
||||
let data;
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
data = await resp.json();
|
||||
} catch (err) {
|
||||
console.warn('MGT2 Commerce | Traveller Map detail error:', err);
|
||||
return null;
|
||||
}
|
||||
|
||||
const world = data?.Worlds?.[0];
|
||||
if (!world) return null;
|
||||
|
||||
return {
|
||||
uwp: world.UWP ?? '',
|
||||
zone: normalizeZone(world.Zone ?? ''),
|
||||
};
|
||||
}
|
||||
251
scripts/uwpParser.js
Normal file
251
scripts/uwpParser.js
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* MGT2 Commerce – Parseur UWP (Universal World Profile)
|
||||
*
|
||||
* Format standard : SAAHPGL-T
|
||||
* Exemples : A788899-C B200400-A
|
||||
*
|
||||
* S = Astroport (A B C D E X)
|
||||
* A = Taille (0–A hex, 0–10)
|
||||
* A = Atmosphère (0–F hex, 0–15)
|
||||
* H = Hydrographie (0–A hex, 0–10)
|
||||
* P = Population (0–C hex, 0–12)
|
||||
* G = Gouvernement (0–F hex, 0–15)
|
||||
* L = Niveau de loi(0–J hex, 0–19)
|
||||
* T = Niveau tech (0–F hex, 0–15) — après le tiret
|
||||
*/
|
||||
|
||||
import { TRADE_CODES } from './data/tradeTables.js';
|
||||
|
||||
/**
|
||||
* Convertit un caractère de code UWP en valeur numérique.
|
||||
* Utilise la base 16 pour les chiffres 0–9 et lettres A–F.
|
||||
* Pour le Niveau de loi : G=16, H=17, J=18 (pas de I).
|
||||
*/
|
||||
function uwpCharToInt(char) {
|
||||
if (!char) return 0;
|
||||
const c = char.toUpperCase();
|
||||
if (c === 'G') return 16;
|
||||
if (c === 'H') return 17;
|
||||
if (c === 'J') return 18;
|
||||
const n = parseInt(c, 16);
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse une chaîne UWP et retourne un objet avec tous les attributs.
|
||||
* @param {string} uwp Ex. "A788899-C"
|
||||
* @returns {{
|
||||
* raw: string,
|
||||
* valid: boolean,
|
||||
* error: string|null,
|
||||
* starport: string,
|
||||
* size: number,
|
||||
* atmosphere: number,
|
||||
* hydrographics: number,
|
||||
* population: number,
|
||||
* government: number,
|
||||
* lawLevel: number,
|
||||
* techLevel: number,
|
||||
* tradeCodes: string[],
|
||||
* zone: string,
|
||||
* }}
|
||||
*/
|
||||
export function parseUWP(uwp, zone = 'normal') {
|
||||
const result = {
|
||||
raw: uwp,
|
||||
valid: false,
|
||||
error: null,
|
||||
starport: '',
|
||||
size: 0,
|
||||
atmosphere: 0,
|
||||
hydrographics: 0,
|
||||
population: 0,
|
||||
government: 0,
|
||||
lawLevel: 0,
|
||||
techLevel: 0,
|
||||
tradeCodes: [],
|
||||
zone,
|
||||
};
|
||||
|
||||
if (!uwp || typeof uwp !== 'string') {
|
||||
result.error = 'UWP manquant';
|
||||
return result;
|
||||
}
|
||||
|
||||
// Normalise : retire espaces, met en majuscules
|
||||
const s = uwp.trim().toUpperCase();
|
||||
|
||||
// Accepte SAAHPGL-T ou SAAHPGL (sans tech level)
|
||||
const match = s.match(/^([ABCDEX])([0-9A-F])([0-9A-F])([0-9A])([0-9A-C])([0-9A-F])([0-9A-J])(?:-([0-9A-F]))?$/i);
|
||||
if (!match) {
|
||||
result.error = `Format UWP invalide : "${uwp}". Attendu : SAAHPGL-T (ex. A788899-C)`;
|
||||
return result;
|
||||
}
|
||||
|
||||
result.starport = match[1];
|
||||
result.size = uwpCharToInt(match[2]);
|
||||
result.atmosphere = uwpCharToInt(match[3]);
|
||||
result.hydrographics = uwpCharToInt(match[4]);
|
||||
result.population = uwpCharToInt(match[5]);
|
||||
result.government = uwpCharToInt(match[6]);
|
||||
result.lawLevel = uwpCharToInt(match[7]);
|
||||
result.techLevel = match[8] ? uwpCharToInt(match[8]) : 0;
|
||||
result.valid = true;
|
||||
|
||||
result.tradeCodes = deriveTradeCodes(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dérive les codes commerciaux d'un monde à partir de ses attributs UWP.
|
||||
* @param {object} w Objet retourné par parseUWP
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function deriveTradeCodes(w) {
|
||||
const codes = [];
|
||||
|
||||
// Ag – Agricole : Atm 4–9, Hyd 4–8, Pop 5–7
|
||||
if (w.atmosphere >= 4 && w.atmosphere <= 9 &&
|
||||
w.hydrographics >= 4 && w.hydrographics <= 8 &&
|
||||
w.population >= 5 && w.population <= 7) {
|
||||
codes.push('Ag');
|
||||
}
|
||||
|
||||
// As – Astéroïdes : Taille 0, Atm 0, Hyd 0
|
||||
if (w.size === 0 && w.atmosphere === 0 && w.hydrographics === 0) {
|
||||
codes.push('As');
|
||||
}
|
||||
|
||||
// Ba – Stérile : Pop 0, Gov 0, Loi 0
|
||||
if (w.population === 0 && w.government === 0 && w.lawLevel === 0) {
|
||||
codes.push('Ba');
|
||||
}
|
||||
|
||||
// De – Désert : Atm 2+, Hyd 0
|
||||
if (w.atmosphere >= 2 && w.hydrographics === 0) {
|
||||
codes.push('De');
|
||||
}
|
||||
|
||||
// Fl – Océans fluides : Atm A (10)+, Hyd 1+
|
||||
if (w.atmosphere >= 10 && w.hydrographics >= 1) {
|
||||
codes.push('Fl');
|
||||
}
|
||||
|
||||
// Ga – Jardin : Taille 6–8, Atm 5 ou 6 ou 8, Hyd 5–7
|
||||
if (w.size >= 6 && w.size <= 8 &&
|
||||
[5, 6, 8].includes(w.atmosphere) &&
|
||||
w.hydrographics >= 5 && w.hydrographics <= 7) {
|
||||
codes.push('Ga');
|
||||
}
|
||||
|
||||
// Hi – Pop. élevée : Pop A (10)+
|
||||
if (w.population >= 10) {
|
||||
codes.push('Hi');
|
||||
}
|
||||
|
||||
// Ht – Haute tech : TL C (12)+
|
||||
if (w.techLevel >= 12) {
|
||||
codes.push('Ht');
|
||||
}
|
||||
|
||||
// IC – Calotte glaciaire : Atm 0–1, Hyd 1+
|
||||
if (w.atmosphere <= 1 && w.hydrographics >= 1) {
|
||||
codes.push('IC');
|
||||
}
|
||||
|
||||
// In – Industriel : Atm ∈ {0,1,2,4,7,9}, Pop 9+
|
||||
if ([0, 1, 2, 4, 7, 9].includes(w.atmosphere) && w.population >= 9) {
|
||||
codes.push('In');
|
||||
}
|
||||
|
||||
// Lo – Pop. basse : Pop 1–3
|
||||
if (w.population >= 1 && w.population <= 3) {
|
||||
codes.push('Lo');
|
||||
}
|
||||
|
||||
// Lt – Basse tech : TL ≤ 5
|
||||
if (w.techLevel <= 5) {
|
||||
codes.push('Lt');
|
||||
}
|
||||
|
||||
// Na – Non-Agricole : Atm 0–3, Hyd 0–3, Pop 6+
|
||||
if (w.atmosphere <= 3 && w.hydrographics <= 3 && w.population >= 6) {
|
||||
codes.push('Na');
|
||||
}
|
||||
|
||||
// Ni – Non-Industriel : Pop 4–6
|
||||
if (w.population >= 4 && w.population <= 6) {
|
||||
codes.push('Ni');
|
||||
}
|
||||
|
||||
// Po – Pauvre : Atm 2–5, Hyd 0–3
|
||||
if (w.atmosphere >= 2 && w.atmosphere <= 5 && w.hydrographics <= 3) {
|
||||
codes.push('Po');
|
||||
}
|
||||
|
||||
// Ri – Riche : Gov ∈ {4,5,6,10}, Pop 6–8, Atm ∈ {6,8}
|
||||
if ([4, 5, 6, 10].includes(w.government) &&
|
||||
w.population >= 6 && w.population <= 8 &&
|
||||
[6, 8].includes(w.atmosphere)) {
|
||||
codes.push('Ri');
|
||||
}
|
||||
|
||||
// Wa – Monde aquatique : Hyd A (10)
|
||||
if (w.hydrographics === 10) {
|
||||
codes.push('Wa');
|
||||
}
|
||||
|
||||
// Va – Vide : Atm 0
|
||||
if (w.atmosphere === 0) {
|
||||
codes.push('Va');
|
||||
}
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le modificateur d'astroport pour les tables de trafic.
|
||||
* @param {string} starport 'A'|'B'|'C'|'D'|'E'|'X'
|
||||
* @param {string} table 'passenger'|'cargo'|'supplier'
|
||||
* @returns {number}
|
||||
*/
|
||||
export function starportModifier(starport, table = 'passenger') {
|
||||
if (table === 'supplier') {
|
||||
return { A: 6, B: 4, C: 2, D: 0, E: 0, X: 0 }[starport] ?? 0;
|
||||
}
|
||||
// passenger & cargo
|
||||
return { A: 2, B: 1, C: 0, D: 0, E: -1, X: -3 }[starport] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le modificateur de population pour les tables de trafic.
|
||||
* @param {number} pop Valeur numérique de la population
|
||||
* @param {string} table 'passenger'|'cargo'
|
||||
* @returns {number}
|
||||
*/
|
||||
export function populationModifier(pop, table = 'passenger') {
|
||||
if (table === 'cargo') {
|
||||
if (pop <= 1) return -4;
|
||||
if (pop >= 6 && pop <= 7) return 2;
|
||||
if (pop >= 8) return 4;
|
||||
return 0;
|
||||
}
|
||||
// passenger
|
||||
if (pop <= 1) return -4;
|
||||
if (pop >= 6 && pop <= 7) return 1;
|
||||
if (pop >= 8) return 3;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le modificateur de zone (Ambre/Rouge/Normal) pour les tables de trafic.
|
||||
* @param {string} zone 'normal'|'amber'|'red'
|
||||
* @param {string} table 'passenger'|'cargo'
|
||||
* @returns {number}
|
||||
*/
|
||||
export function zoneModifier(zone, table = 'passenger') {
|
||||
if (table === 'cargo') {
|
||||
return { normal: 0, amber: -2, red: -6 }[zone] ?? 0;
|
||||
}
|
||||
return { normal: 0, amber: 1, red: -4 }[zone] ?? 0;
|
||||
}
|
||||
Reference in New Issue
Block a user