Ready for release
Release Creation / build (release) Successful in 43s

This commit is contained in:
2026-06-12 20:53:44 +02:00
parent efe37b8a96
commit a53c7ace53
98 changed files with 1171 additions and 289 deletions
+89 -3
View File
@@ -1,5 +1,6 @@
import { formatCredits } from './tradeHelper.js';
import { createNpcActor, generateClientMission, generateEncounter, generateQuickNpc } from './npcHelper.js';
import { createNpcActor, generateClientMission, generateEncounter, generateQuickNpc, formatSigned } from './npcHelper.js';
import { generateAllyEnemy } from './allyEnemyGenerator.js';
import { NPC_RELATIONS } from './data/npcTables.js';
import { generateAndCreateTravellerNpc } from './travellerNpcGenerator.js';
import { generateRandomName } from './data/travellerNpcGenerator.js';
@@ -68,6 +69,13 @@ export class NpcDialog extends HandlebarsApplicationMixin(ApplicationV2) {
actorName: '',
openCreatedActor: DEFAULT_OPTIONS.openCreatedActor,
},
ae: {
relation: options.relation ?? 'contact',
includeSpecial: true,
createActor: false,
actorName: '',
openCreatedActor: true,
},
};
}
@@ -138,6 +146,12 @@ export class NpcDialog extends HandlebarsApplicationMixin(ApplicationV2) {
await this._handleTravellerNpc();
});
html.find('[data-action="generate-ally-enemy"]').on('click', async (event) => {
event.preventDefault();
this._readForm(html);
await this._handleAllyEnemy();
});
html.find('[data-action="randomize-name"]').on('click', (event) => {
event.preventDefault();
this._randomizeTravellerName(html);
@@ -205,6 +219,13 @@ export class NpcDialog extends HandlebarsApplicationMixin(ApplicationV2) {
this._formData.encounter.context = html.find('[name="encounter.context"]').val();
this._formData.encounter.includeFollowUp = html.find('[name="encounter.includeFollowUp"]').is(':checked');
// Données pour l'onglet Alliés/Ennemis
this._formData.ae.relation = html.find('[name="ae.relation"]').val();
this._formData.ae.includeSpecial = html.find('[name="ae.includeSpecial"]').is(':checked');
this._formData.ae.createActor = html.find('[name="ae.createActor"]').is(':checked');
this._formData.ae.actorName = html.find('[name="ae.actorName"]').val();
this._formData.ae.openCreatedActor = html.find('[name="ae.openCreatedActor"]').is(':checked');
// Données pour l'onglet PNJ Détaillé (Traveller)
this._formData.traveller.citizenCategory = html.find('[name="traveller.citizenCategory"]').val();
this._formData.traveller.experience = html.find('[name="traveller.experience"]').val();
@@ -281,6 +302,60 @@ export class NpcDialog extends HandlebarsApplicationMixin(ApplicationV2) {
}
}
async _handleAllyEnemy() {
const button = $(this.element).find('[data-action="generate-ally-enemy"]');
const originalLabel = button.html();
try {
button.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Génération...');
const result = await generateAllyEnemy(this._formData.ae.relation, {
includeSpecial: this._formData.ae.includeSpecial,
});
if (result.success) {
if (this._formData.ae.createActor) {
const ae = this._formData.ae;
const actorName = ae.actorName?.trim() || `PNJ — ${result.relation.label}`;
const baseActorSystem = game.system?.id === 'mgt2e'
? await (await import('./travellerNpcGenerator.js')).getMgt2eBaseActorSystem()
: null;
const actorData = {
name: actorName,
type: 'npc',
img: 'systems/mgt2e/icons/cargo/passenger-middle.svg',
system: {
settings: foundry.utils.mergeObject(foundry.utils.deepClone(baseActorSystem?.settings ?? {}), {
hideUntrained: true, lockCharacteristics: true,
}),
sophont: foundry.utils.mergeObject(foundry.utils.deepClone(baseActorSystem?.sophont ?? {}), {
age: 18, homeworld: '', profession: result.relation.label,
}),
characteristics: foundry.utils.deepClone(baseActorSystem?.characteristics ?? {}),
hits: foundry.utils.deepClone(baseActorSystem?.hits ?? {}),
skills: foundry.utils.deepClone(baseActorSystem?.skills ?? {}),
},
flags: {
[MODULE_ID]: { generatedAllyEnemy: { relation: result.relation.key } },
},
};
const actor = await Actor.create(actorData, { renderSheet: false });
result.createdActor = { id: actor.id, name: actor.name };
if (ae.openCreatedActor) actor.sheet?.render(true);
ui.notifications.info(`Fiche PNJ créée : ${actor.name}`);
}
await this._postToChatResult(result);
} else {
ui.notifications.error('Erreur lors de la génération de la relation');
}
} catch (error) {
console.error(`${MODULE_ID} | Erreur AE:`, error);
ui.notifications.error(`Erreur: ${error.message}`);
} finally {
button.prop('disabled', false).html(originalLabel);
}
}
_randomizeTravellerName(html) {
const name = generateRandomName(this._formData.traveller.gender);
html.find('[name="traveller.firstName"]').val(name.firstName);
@@ -295,13 +370,15 @@ export class NpcDialog extends HandlebarsApplicationMixin(ApplicationV2) {
async _postToChatResult(data) {
registerHandlebarsHelpers();
// Déterminer quel template utiliser en fonction du type de données
let template = `modules/${MODULE_ID}/templates/npc-result.hbs`;
let resultType = 'npc-result';
if (data.type === 'traveller-npc' || data?.flags?.[MODULE_ID]?.type === 'traveller-npc-result') {
template = `modules/${MODULE_ID}/templates/traveller-npc-result.hbs`;
resultType = 'traveller-npc-result';
} else if (data.type === 'ally-enemy') {
template = `modules/${MODULE_ID}/templates/ally-enemy-result.hbs`;
resultType = 'ally-enemy-result';
}
const html = await foundry.applications.handlebars.renderTemplate(template, data);
@@ -369,4 +446,13 @@ function registerHandlebarsHelpers() {
if (!obj || !key) return '';
return obj[key] !== undefined ? obj[key] : '';
});
const RELATION_LABELS = Object.entries(NPC_RELATIONS).reduce((acc, [key, val]) => {
acc[key] = val.label;
return acc;
}, {});
Handlebars.registerHelper('lookupRelationKey', (key) => RELATION_LABELS[key] || key);
Handlebars.registerHelper('formatSigned', (value) => formatSigned(value));
}
+277
View File
@@ -0,0 +1,277 @@
import { NPC_RELATIONS } from './data/npcTables.js';
import {
RELATION_FORMULAS,
AFFINITY_INIMITY_MAP,
POWER_INFLUENCE_MAP,
AFFINITY_LABELS,
INIMITY_LABELS,
POWER_LABELS,
INFLUENCE_LABELS,
SPECIAL_CHARACTERISTICS_TABLE,
} from './data/allyEnemyTables.js';
export function mapRollToValue(roll, mapping) {
return mapping[roll] ?? 0;
}
export function getLabel(value, labels) {
return labels.find(l => l.value === Math.abs(value)) ?? labels[0];
}
export function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
async function rollFormula(formula) {
const roll = await new Roll(formula).evaluate();
return { formula, total: roll.total };
}
function getD66Entry(entries, total) {
return entries.find(e => e.d66 === total) ?? null;
}
async function rollD66(entries) {
const tens = await rollFormula('1d6');
const ones = await rollFormula('1d6');
const total = (tens.total * 10) + ones.total;
return {
total,
tens: tens.total,
ones: ones.total,
entry: getD66Entry(entries, total),
};
}
async function rollAffinityInimity(relationKey) {
const formulas = RELATION_FORMULAS[relationKey];
let affinityRoll = null;
let inimityRoll = null;
if (formulas.affinity !== '0') {
affinityRoll = await rollFormula(formulas.affinity);
}
if (formulas.inimity !== '0') {
inimityRoll = await rollFormula(formulas.inimity);
}
return {
affinityValue: affinityRoll ? mapRollToValue(affinityRoll.total, AFFINITY_INIMITY_MAP) : 0,
inimityValue: inimityRoll ? mapRollToValue(inimityRoll.total, AFFINITY_INIMITY_MAP) : 0,
affinityRoll,
inimityRoll,
formulas,
};
}
async function resolveSpecialCharacteristics(currentRelationKey, depth = 0) {
if (depth > 5) return [];
const d66Result = await rollD66(SPECIAL_CHARACTERISTICS_TABLE);
if (!d66Result.entry) return [];
const entry = d66Result.entry;
const result = {
d66: d66Result.total,
text: entry.text,
effects: entry.effects,
appliedDeltas: { affinity: 0, inimity: 0, power: 0, influence: 0 },
rerollNote: null,
swapNote: null,
narrativeText: entry.effects.action === 'narrativeOnly' ? entry.text : null,
newRelationKey: null,
subCharacteristics: [],
};
if (entry.effects.affinityMod) result.appliedDeltas.affinity = entry.effects.affinityMod;
if (entry.effects.inimityMod) result.appliedDeltas.inimity = entry.effects.inimityMod;
if (entry.effects.powerMod) result.appliedDeltas.power = entry.effects.powerMod;
if (entry.effects.influenceMod) result.appliedDeltas.influence = entry.effects.influenceMod;
if (entry.effects.action === 'extraRolls') {
const count = entry.effects.actionValue || 1;
for (let i = 0; i < count; i++) {
const extra = await resolveSpecialCharacteristics(currentRelationKey, depth + 1);
result.subCharacteristics.push(...extra);
}
}
return result;
}
export async function generateAllyEnemy(relationKey = 'contact', options = {}) {
const relation = NPC_RELATIONS[relationKey];
let currentRelationKey = relationKey;
const initial = await rollAffinityInimity(relationKey);
let affinityValue = initial.affinityValue;
let inimityValue = initial.inimityValue;
let affinityRoll = initial.affinityRoll;
let inimityRoll = initial.inimityRoll;
let currentFormulas = initial.formulas;
const powerRoll = await rollFormula('2d6');
const influenceRoll = await rollFormula('2d6');
let powerValue = mapRollToValue(powerRoll.total, POWER_INFLUENCE_MAP);
let influenceValue = mapRollToValue(influenceRoll.total, POWER_INFLUENCE_MAP);
let specialRoll = null;
let specialCharacteristics = [];
if (options.includeSpecial !== false) {
specialRoll = await rollFormula('2d6');
if (specialRoll.total >= 8) {
let queue = await resolveSpecialCharacteristics(currentRelationKey);
while (queue.length > 0) {
const sc = queue.shift();
if (sc.effects.action === 'extraRolls') {
queue.push(...sc.subCharacteristics);
}
if (sc.effects.action === 'moderateRelation') {
if (currentRelationKey === 'enemy') {
currentRelationKey = 'rival';
const rerolled = await rollAffinityInimity(currentRelationKey);
affinityValue = rerolled.affinityValue;
inimityValue = rerolled.inimityValue;
affinityRoll = rerolled.affinityRoll;
inimityRoll = rerolled.inimityRoll;
currentFormulas = rerolled.formulas;
sc.newRelationKey = currentRelationKey;
} else if (currentRelationKey === 'ally') {
currentRelationKey = 'contact';
const rerolled = await rollAffinityInimity(currentRelationKey);
affinityValue = rerolled.affinityValue;
inimityValue = rerolled.inimityValue;
affinityRoll = rerolled.affinityRoll;
inimityRoll = rerolled.inimityRoll;
currentFormulas = rerolled.formulas;
sc.newRelationKey = currentRelationKey;
}
}
if (sc.effects.action === 'intensifyRelation') {
if (currentRelationKey === 'rival') {
currentRelationKey = 'enemy';
const rerolled = await rollAffinityInimity(currentRelationKey);
affinityValue = rerolled.affinityValue;
inimityValue = rerolled.inimityValue;
affinityRoll = rerolled.affinityRoll;
inimityRoll = rerolled.inimityRoll;
currentFormulas = rerolled.formulas;
sc.newRelationKey = currentRelationKey;
} else if (currentRelationKey === 'contact') {
currentRelationKey = 'ally';
const rerolled = await rollAffinityInimity(currentRelationKey);
affinityValue = rerolled.affinityValue;
inimityValue = rerolled.inimityValue;
affinityRoll = rerolled.affinityRoll;
inimityRoll = rerolled.inimityRoll;
currentFormulas = rerolled.formulas;
sc.newRelationKey = currentRelationKey;
}
}
if (sc.effects.action === 'reRollAffinity') {
const reroll = await rollFormula('2d6');
const rerolledValue = mapRollToValue(reroll.total, AFFINITY_INIMITY_MAP);
if (rerolledValue > affinityValue) {
sc.rerollNote = `Affinité relancée : ${reroll.total}${rerolledValue} (était ${affinityValue})`;
sc.appliedDeltas.affinity = rerolledValue - affinityValue;
}
}
if (sc.effects.action === 'reRollInimity') {
const reroll = await rollFormula('2d6');
const rerolledValue = mapRollToValue(reroll.total, AFFINITY_INIMITY_MAP);
if (rerolledValue > inimityValue) {
sc.rerollNote = `Inimitié relancée : ${reroll.total}${rerolledValue} (était ${inimityValue})`;
sc.appliedDeltas.inimity = rerolledValue - inimityValue;
}
}
if (sc.effects.action === 'swapAffinityInimity') {
sc.swapNote = 'Affinité et Inimitié échangées';
const tmpAff = affinityValue;
const tmpInim = inimityValue;
sc.appliedDeltas.affinity = tmpInim - affinityValue;
sc.appliedDeltas.inimity = tmpAff - inimityValue;
}
if (sc.effects.action === 'setPowerToZero') {
sc.appliedDeltas.power = -powerValue;
}
if (sc.effects.action === 'createEnemy') {
sc.narrativeText = 'Un nouvel Ennemi commun au Voyageur et à cet individu est créé.';
}
if (sc.effects.action === 'createContactOrRival') {
const net = affinityValue - inimityValue;
sc.narrativeText = net > 0
? 'Un nouveau Contact est créé (Affinité supérieure à l\'Inimitié).'
: 'Un nouveau Rival est créé (Inimitié supérieure à l\'Affinité).';
}
let newAffinity = affinityValue + (sc.appliedDeltas.affinity || 0);
let newInimity = inimityValue + (sc.appliedDeltas.inimity || 0);
let newPower = powerValue + (sc.appliedDeltas.power || 0);
let newInfluence = influenceValue + (sc.appliedDeltas.influence || 0);
affinityValue = clamp(newAffinity, 0, 6);
inimityValue = clamp(newInimity, 0, 6);
powerValue = clamp(newPower, 0, 6);
influenceValue = clamp(newInfluence, 0, 6);
specialCharacteristics.push(sc);
}
}
}
const finalRelation = currentRelationKey !== relationKey
? NPC_RELATIONS[currentRelationKey]
: relation;
const netScore = affinityValue - inimityValue;
return {
success: true,
type: 'ally-enemy',
relation: { key: currentRelationKey, label: finalRelation.label, summary: finalRelation.summary },
originalRelationKey: relationKey,
relationChanged: currentRelationKey !== relationKey,
affinity: {
formula: currentFormulas.affinity,
roll: affinityRoll?.total ?? 0,
value: affinityValue,
label: getLabel(affinityValue, AFFINITY_LABELS).label,
description: getLabel(affinityValue, AFFINITY_LABELS).description,
},
inimity: {
formula: currentFormulas.inimity,
roll: inimityRoll?.total ?? 0,
value: inimityValue,
label: getLabel(inimityValue, INIMITY_LABELS).label,
description: getLabel(inimityValue, INIMITY_LABELS).description,
},
netScore,
power: {
value: powerValue,
label: getLabel(powerValue, POWER_LABELS).label,
description: getLabel(powerValue, POWER_LABELS).description,
},
influence: {
value: influenceValue,
label: getLabel(influenceValue, INFLUENCE_LABELS).label,
description: getLabel(influenceValue, INFLUENCE_LABELS).description,
},
specialRoll: specialRoll ? { roll: specialRoll.total, triggered: specialRoll.total >= 8 } : null,
specialCharacteristics,
};
}
+95
View File
@@ -0,0 +1,95 @@
export const RELATION_FORMULAS = {
ally: { affinity: '2d6', inimity: '0' },
contact: { affinity: '1d6+1', inimity: '1d6-1' },
rival: { affinity: '1d6-1', inimity: '1d6+1' },
enemy: { affinity: '0', inimity: '2d6' },
};
export const AFFINITY_INIMITY_MAP = {
2: 0, 3: 1, 4: 1, 5: 2, 6: 2,
7: 3, 8: 3, 9: 4, 10: 4, 11: 5, 12: 6,
};
export const POWER_INFLUENCE_MAP = {
2: 0, 3: 0, 4: 0, 5: 0,
6: 1, 7: 1, 8: 2, 9: 3, 10: 4, 11: 5, 12: 6,
};
export const AFFINITY_LABELS = [
{ value: 0, label: 'Aucune', description: 'Aucune affinité envers le Voyageur. Peut être un ennemi ou quelqu\'un d\'indifférent selon son Inimitié.' },
{ value: 1, label: 'Vaguement bienveillant', description: 'Bienveillance comparable à celle d\'un inconnu ordinaire. Petits gestes d\'entraide par courtoisie.' },
{ value: 2, label: 'Bienveillant', description: 'Aidera probablement le Voyageur si simple et sans danger, même sans récompense.' },
{ value: 3, label: 'Très bienveillant', description: 'N\'hésitera pas à prendre des risques modérés ou à offrir son aide de son propre chef.' },
{ value: 4, label: 'Ami loyal', description: 'Fera presque tout son possible pour aider, mais peut être retenu par d\'autres loyautés.' },
{ value: 5, label: 'Amour', description: 'Passera très probablement les intérêts du Voyageur avant les siens ou ceux d\'autrui.' },
{ value: 6, label: 'Fanatique', description: 'Fera tout ce que le Voyageur exige, quels que soient les risques.' },
];
export const INIMITY_LABELS = [
{ value: 0, label: 'Aucune', description: 'Aucune inimitié envers le Voyageur.' },
{ value: 1, label: 'Méfiant', description: 'Vaguement mal disposé mais ne fera pas d\'efforts particuliers pour faire obstacle.' },
{ value: 2, label: 'Malveillant', description: 'Peut commettre des actes de malveillance mineurs par pure mesquinerie.' },
{ value: 3, label: 'Très malveillant', description: 'Se donnera du mal pour faire obstacle au Voyageur par simple rancune.' },
{ value: 4, label: 'Haine', description: 'Fera presque tout pour avoir le dessus sur le Voyageur.' },
{ value: 5, label: 'Haine farouche', description: 'Complotera activement ou prendra de grands risques pour nuire au Voyageur.' },
{ value: 6, label: 'Haine aveugle', description: 'Peut s\'engager dans des actions autodestructrices pour nuire au Voyageur.' },
];
export const POWER_LABELS = [
{ value: 0, label: 'Négligeable', description: 'Ne dispose pratiquement d\'aucune ressource mobilisable en dehors de ses possessions personnelles.' },
{ value: 1, label: 'Faible', description: 'Quelques amis ou contacts. Équivalent d\'un groupe de Voyageurs typique.' },
{ value: 2, label: 'Utile', description: 'Possède un atout majeur : petit vaisseau, unité de mercenaires, équipe d\'avocats.' },
{ value: 3, label: 'Modérément puissant', description: 'Ressources très importantes : unité de mercenaires ou entreprise de taille moyenne.' },
{ value: 4, label: 'Puissant', description: 'Atouts majeurs : compagnie de transport marchand ou grand groupe commercial.' },
{ value: 5, label: 'Très puissant', description: 'Pouvoir colossal : haute sphère gouvernementale ou PDG d\'une grande compagnie.' },
{ value: 6, label: 'Acteur majeur', description: 'Pèse sur la politique interstellaire : amiral ou haut dignitaire.' },
];
export const INFLUENCE_LABELS = [
{ value: 0, label: 'Aucune influence', description: 'N\'a pratiquement aucune influence sur qui que ce soit.' },
{ value: 1, label: 'Faible influence', description: 'Peut faire jouer quelques faveurs auprès de fonctionnaires mineurs.' },
{ value: 2, label: 'Influence modérée', description: 'A un ou plusieurs notables locaux « dans la poche ».' },
{ value: 3, label: 'Influent', description: 'Exerce une influence sur des gens de pouvoir (fonctionnaires, négociants).' },
{ value: 4, label: 'Très influent', description: 'Influence interplanétaire, personnalités gouvernementales ou figures de la pègre.' },
{ value: 5, label: 'Extrêmement influent', description: 'Influence interstellaire, pression sur les législateurs.' },
{ value: 6, label: 'Incontournable', description: 'A l\'oreille de personnes extrêmement puissantes (noble dirigeant le sous-secteur).' },
];
export const SPECIAL_CHARACTERISTICS_TABLE = [
{ d66: 11, text: 'Cet individu a des raisons de pardonner au Voyageur ou de l\'apprécier plus que d\'ordinaire.', effects: { affinityMod: 1 } },
{ d66: 12, text: 'Les relations entre le Voyageur et cet individu se sont particulièrement détériorées.', effects: { inimityMod: 1, affinityMod: -1 } },
{ d66: 13, text: 'Un événement a altéré la relation entre le Voyageur et cet associé.', effects: { affinityMod: 1, inimityMod: -1 } },
{ d66: 14, text: 'Un incident augmente l\'Inimitié entre le Voyageur et cet individu.', effects: { inimityMod: 1 } },
{ d66: 15, text: 'La relation devient plus modérée. Un Ennemi devient un Rival et un Allié devient un Contact. Relancez l\'Affinité et l\'Inimitié.', effects: { action: 'moderateRelation' } },
{ d66: 16, text: 'La relation s\'intensifie. Un Rival devient un Ennemi et un Contact devient un Allié. Relancez l\'Affinité et l\'Inimitié.', effects: { action: 'intensifyRelation' } },
{ d66: 21, text: 'Cet individu gagne en pouvoir.', effects: { powerMod: 1 } },
{ d66: 22, text: 'Cet individu perd une partie de sa base de pouvoir.', effects: { powerMod: -1 } },
{ d66: 23, text: 'Cet individu gagne en influence.', effects: { influenceMod: 1 } },
{ d66: 24, text: 'L\'influence de cet individu diminue.', effects: { influenceMod: -1 } },
{ d66: 25, text: 'Cet individu gagne à la fois en pouvoir et en influence.', effects: { powerMod: 1, influenceMod: 1 } },
{ d66: 26, text: 'Cet individu perd à la fois en pouvoir et en influence.', effects: { powerMod: -1, influenceMod: -1 } },
{ d66: 31, text: 'Cet individu appartient à un groupe culturel ou religieux inhabituel.', effects: { action: 'narrativeOnly' } },
{ d66: 32, text: 'Cet individu appartient à une xéno-espèce rare.', effects: { action: 'narrativeOnly' } },
{ d66: 33, text: 'Cet individu est particulièrement atypique (intelligence artificielle ou entité profondément xéno).', effects: { action: 'narrativeOnly' } },
{ d66: 34, text: 'Cet individu représente en réalité une organisation (mouvement politique, entreprise).', effects: { action: 'narrativeOnly' } },
{ d66: 35, text: 'Cet individu est membre d\'une organisation dont la vision est généralement opposée à celle du Voyageur.', effects: { action: 'narrativeOnly' } },
{ d66: 36, text: 'Cet individu est une figure douteuse (criminel, pirate ou noble déchu). Le Voyageur sera jugé par association.', effects: { action: 'narrativeOnly' } },
{ d66: 41, text: 'Le Voyageur et cet individu se sont violemment brouillés. Relancez l\'Inimitié sur 2D et utilisez le nouveau résultat s\'il est supérieur.', effects: { action: 'reRollInimity' } },
{ d66: 42, text: 'Le Voyageur et cet individu se sont réconciliés. Relancez l\'Affinité sur 2D et appliquez le nouveau résultat s\'il est supérieur.', effects: { action: 'reRollAffinity' } },
{ d66: 43, text: 'Cet individu traverse une période difficile.', effects: { powerMod: -1 } },
{ d66: 44, text: 'Cet individu a été ruiné par un malheur causé par le Voyageur.', effects: { action: 'setPowerToZero', inimityMod: 1 } },
{ d66: 45, text: 'Cet individu a gagné en influence grâce à l\'aide du Voyageur.', effects: { influenceMod: 1, affinityMod: 1 } },
{ d66: 46, text: 'Cet individu a gagné du pouvoir aux dépens d\'un tiers qui blâme désormais le Voyageur.', effects: { powerMod: 1, action: 'createEnemy' } },
{ d66: 51, text: 'Cet individu a disparu dans des circonstances suspectes.', effects: { action: 'narrativeOnly' } },
{ d66: 52, text: 'Cet individu est injoignable, occupé à quelque chose d\'intéressant mais sans caractère suspect.', effects: { action: 'narrativeOnly' } },
{ d66: 53, text: 'Cet individu est en grave difficulté et aurait bien besoin de l\'aide du Voyageur.', effects: { action: 'narrativeOnly' } },
{ d66: 54, text: 'Cet individu a récemment bénéficié d\'une chance insolente.', effects: { action: 'narrativeOnly' } },
{ d66: 55, text: 'Cet individu est incarcéré ou piégé quelque part.', effects: { action: 'narrativeOnly' } },
{ d66: 56, text: 'Cet individu est retrouvé ou déclaré mort. Ce n\'est peut-être pas toute la vérité…', effects: { action: 'narrativeOnly' } },
{ d66: 61, text: 'Cet individu s\'est récemment marié ou a vécu un événement bouleversant sa vie.', effects: { action: 'narrativeOnly' } },
{ d66: 62, text: 'Cet individu a été renié par sa famille, a divorcé ou a vécu un événement tragique.', effects: { action: 'narrativeOnly' } },
{ d66: 63, text: 'Les relations de cet individu commencent à affecter le Voyageur. Créez un nouveau Contact si son Affinité est supérieure à son Inimitié, ou un Rival si l\'Inimitié est supérieure.', effects: { action: 'createContactOrRival' } },
{ d66: 64, text: 'La relation entre le Voyageur et cet associé est complètement redéfinie. Alliés↔Ennemis, Rivaux↔Contacts. Échangez les valeurs d\'Affinité et d\'Inimitié.', effects: { action: 'swapAffinityInimity' } },
{ d66: 65, text: 'Tirez deux autres caractéristiques spéciales.', effects: { action: 'extraRolls', actionValue: 2 } },
{ d66: 66, text: 'Tirez trois autres caractéristiques spéciales.', effects: { action: 'extraRolls', actionValue: 3 } },
];
+1
View File
@@ -37,6 +37,7 @@ Hooks.once('init', () => {
`modules/${MODULE_ID}/templates/npc-result.hbs`,
`modules/${MODULE_ID}/templates/traveller-npc-dialog.hbs`,
`modules/${MODULE_ID}/templates/traveller-npc-result.hbs`,
`modules/${MODULE_ID}/templates/ally-enemy-result.hbs`,
]);
}
+161
View File
@@ -0,0 +1,161 @@
import { strict as assert } from 'assert';
import {
RELATION_FORMULAS,
AFFINITY_INIMITY_MAP,
POWER_INFLUENCE_MAP,
AFFINITY_LABELS,
INIMITY_LABELS,
POWER_LABELS,
INFLUENCE_LABELS,
SPECIAL_CHARACTERISTICS_TABLE,
} from '../data/allyEnemyTables.js';
import { mapRollToValue, getLabel, clamp } from '../allyEnemyGenerator.js';
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
passed++;
console.log(` PASS ${name}`);
} catch (e) {
failed++;
console.error(` FAIL ${name}\n ${e.message}`);
}
}
function assertEqual(actual, expected, msg) {
assert.strictEqual(actual, expected, msg || `expected ${expected}, got ${actual}`);
}
// ──────────────────────────────────────
console.log('\nmapRollToValue');
// ──────────────────────────────────────
test('maps 2→0', () => assertEqual(mapRollToValue(2, AFFINITY_INIMITY_MAP), 0));
test('maps 3→1', () => assertEqual(mapRollToValue(3, AFFINITY_INIMITY_MAP), 1));
test('maps 5→2', () => assertEqual(mapRollToValue(5, AFFINITY_INIMITY_MAP), 2));
test('maps 7→3', () => assertEqual(mapRollToValue(7, AFFINITY_INIMITY_MAP), 3));
test('maps 9→4', () => assertEqual(mapRollToValue(9, AFFINITY_INIMITY_MAP), 4));
test('maps 11→5', () => assertEqual(mapRollToValue(11, AFFINITY_INIMITY_MAP), 5));
test('maps 12→6', () => assertEqual(mapRollToValue(12, AFFINITY_INIMITY_MAP), 6));
test('unknown roll → 0', () => assertEqual(mapRollToValue(13, AFFINITY_INIMITY_MAP), 0));
test('power 2-5→0', () => { assertEqual(mapRollToValue(2, POWER_INFLUENCE_MAP), 0); assertEqual(mapRollToValue(5, POWER_INFLUENCE_MAP), 0); });
test('power 6-7→1', () => { assertEqual(mapRollToValue(6, POWER_INFLUENCE_MAP), 1); assertEqual(mapRollToValue(7, POWER_INFLUENCE_MAP), 1); });
test('power 8→2', () => assertEqual(mapRollToValue(8, POWER_INFLUENCE_MAP), 2));
test('power 9→3', () => assertEqual(mapRollToValue(9, POWER_INFLUENCE_MAP), 3));
test('power 10→4', () => assertEqual(mapRollToValue(10, POWER_INFLUENCE_MAP), 4));
test('power 11→5', () => assertEqual(mapRollToValue(11, POWER_INFLUENCE_MAP), 5));
test('power 12→6', () => assertEqual(mapRollToValue(12, POWER_INFLUENCE_MAP), 6));
// ──────────────────────────────────────
console.log('\ngetLabel');
// ──────────────────────────────────────
test('finds matching affinity label', () => {
assertEqual(getLabel(3, AFFINITY_LABELS).label, 'Très bienveillant');
});
test('returns first for out-of-range', () => {
assertEqual(getLabel(99, AFFINITY_LABELS).label, 'Aucune');
});
test('finds inimity label', () => {
assertEqual(getLabel(4, INIMITY_LABELS).label, 'Haine');
});
test('finds power label', () => {
assertEqual(getLabel(5, POWER_LABELS).label, 'Très puissant');
});
test('finds influence label', () => {
assertEqual(getLabel(2, INFLUENCE_LABELS).label, 'Influence modérée');
});
// ──────────────────────────────────────
console.log('\nclamp');
// ──────────────────────────────────────
test('within range', () => assertEqual(clamp(3, 0, 6), 3));
test('below min', () => assertEqual(clamp(-1, 0, 6), 0));
test('above max', () => assertEqual(clamp(7, 0, 6), 6));
test('edge min', () => assertEqual(clamp(0, 0, 6), 0));
test('edge max', () => assertEqual(clamp(6, 0, 6), 6));
// ──────────────────────────────────────
console.log('\nRELATION_FORMULAS');
// ──────────────────────────────────────
test('ally: 2d6 affinity, 0 inimity', () => {
assertEqual(RELATION_FORMULAS.ally.affinity, '2d6');
assertEqual(RELATION_FORMULAS.ally.inimity, '0');
});
test('contact: 1d6+1 affinity, 1d6-1 inimity', () => {
assertEqual(RELATION_FORMULAS.contact.affinity, '1d6+1');
assertEqual(RELATION_FORMULAS.contact.inimity, '1d6-1');
});
test('rival: 1d6-1 affinity, 1d6+1 inimity', () => {
assertEqual(RELATION_FORMULAS.rival.affinity, '1d6-1');
assertEqual(RELATION_FORMULAS.rival.inimity, '1d6+1');
});
test('enemy: 0 affinity, 2d6 inimity', () => {
assertEqual(RELATION_FORMULAS.enemy.affinity, '0');
assertEqual(RELATION_FORMULAS.enemy.inimity, '2d6');
});
// ──────────────────────────────────────
console.log('\nLABELS — array lengths');
// ──────────────────────────────────────
test('AFFINITY_LABELS has 7 entries', () => assertEqual(AFFINITY_LABELS.length, 7));
test('INIMITY_LABELS has 7 entries', () => assertEqual(INIMITY_LABELS.length, 7));
test('POWER_LABELS has 7 entries', () => assertEqual(POWER_LABELS.length, 7));
test('INFLUENCE_LABELS has 7 entries', () => assertEqual(INFLUENCE_LABELS.length, 7));
// ──────────────────────────────────────
console.log('\nSPECIAL_CHARACTERISTICS_TABLE');
// ──────────────────────────────────────
test('has 36 D66 entries', () => assertEqual(SPECIAL_CHARACTERISTICS_TABLE.length, 36));
test('all entries have valid D66 range', () => {
for (const e of SPECIAL_CHARACTERISTICS_TABLE) {
if (e.d66 < 11 || e.d66 > 66) throw new Error(`entry d66=${e.d66} out of range`);
if (!e.text) throw new Error(`entry d66=${e.d66} missing text`);
if (!e.effects) throw new Error(`entry d66=${e.d66} missing effects`);
}
});
test('D66 65 is extraRolls 2', () => {
const e = SPECIAL_CHARACTERISTICS_TABLE.find(x => x.d66 === 65);
assertEqual(e.effects.action, 'extraRolls');
assertEqual(e.effects.actionValue, 2);
});
test('D66 66 is extraRolls 3', () => {
const e = SPECIAL_CHARACTERISTICS_TABLE.find(x => x.d66 === 66);
assertEqual(e.effects.action, 'extraRolls');
assertEqual(e.effects.actionValue, 3);
});
test('D66 11 has affinityMod 1', () => {
const e = SPECIAL_CHARACTERISTICS_TABLE.find(x => x.d66 === 11);
assertEqual(e.effects.affinityMod, 1);
});
test('D66 44 has setPowerToZero + inimityMod 1', () => {
const e = SPECIAL_CHARACTERISTICS_TABLE.find(x => x.d66 === 44);
assertEqual(e.effects.action, 'setPowerToZero');
assertEqual(e.effects.inimityMod, 1);
});
// ──────────────────────────────────────
console.log('\n');
// ──────────────────────────────────────
if (failed > 0) {
console.error(`\n ${failed} of ${passed + failed} tests FAILED\n`);
process.exit(1);
} else {
console.log(` All ${passed} tests passed\n`);
}