358 lines
13 KiB
JavaScript
358 lines
13 KiB
JavaScript
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';
|
|
import { localizeSkill, setSkillLevel } from './mgt2eSkills.js';
|
|
|
|
const MODULE_ID = 'mgt2-compendium-amiral-denisov';
|
|
|
|
const CORE_CHARACTERISTICS = ['strength', 'dexterity', 'endurance', 'intellect', 'education', 'social'];
|
|
const MGT2E_CHARACTERISTICS = {
|
|
strength: 'STR',
|
|
dexterity: 'DEX',
|
|
endurance: 'END',
|
|
intellect: 'INT',
|
|
education: 'EDU',
|
|
social: 'SOC',
|
|
};
|
|
const DEFAULT_PRIORITIES = {
|
|
'Non-combattant': ['intellect', 'education', 'social', 'dexterity', 'endurance', 'strength'],
|
|
'Combattant': ['dexterity', 'endurance', 'strength', 'education', 'intellect', 'social'],
|
|
};
|
|
let mgt2eBaseActorSystemPromise = null;
|
|
|
|
const ROLE_HINTS = [
|
|
{ match: /médecin/i, skills: ['medic', 'science'], priorities: ['education', 'intellect', 'dexterity'] },
|
|
{ match: /scientifique|chercheur/i, skills: ['science', 'electronics'], priorities: ['intellect', 'education', 'dexterity'] },
|
|
{ match: /diplomate|ambassadeur|attaché culturel/i, skills: ['diplomat', 'language', 'persuade'], priorities: ['social', 'education', 'intellect'] },
|
|
{ match: /marchand|franc-marchand|courtier/i, skills: ['broker', 'admin', 'persuade'], priorities: ['social', 'education', 'intellect'] },
|
|
{ match: /mercenaire|seigneur de guerre/i, skills: ['guncombat', 'melee', 'recon'], priorities: ['dexterity', 'endurance', 'strength'] },
|
|
{ match: /officier de marine|amiral|capitaine/i, skills: ['leadership', 'seafarer', 'tactics'], priorities: ['education', 'social', 'intellect'] },
|
|
{ match: /explorateur|éclaireur/i, skills: ['recon', 'survival', 'navigation'], priorities: ['education', 'dexterity', 'endurance'] },
|
|
{ match: /interprète|xéno/i, skills: ['language', 'diplomat', 'science'], priorities: ['education', 'intellect', 'social'] },
|
|
{ match: /cadre de corpo|agent corpo|administrateur|gouverneur|homme d'état|noble/i, skills: ['admin', 'leadership', 'diplomat'], priorities: ['social', 'education', 'intellect'] },
|
|
{ match: /journaliste|enquêteur|inspecteur|agent impérial/i, skills: ['investigate', 'persuade', 'guncombat'], priorities: ['intellect', 'education', 'dexterity'] },
|
|
{ match: /conspirateur|criminel|contrebandier/i, skills: ['deception', 'streetwise', 'guncombat'], priorities: ['social', 'dexterity', 'intellect'] },
|
|
{ match: /chef religieux|cultiste/i, skills: ['persuade', 'leadership', 'language'], priorities: ['social', 'education', 'intellect'] },
|
|
{ match: /joueur|playboy/i, skills: ['gambler', 'carouse', 'persuade'], priorities: ['social', 'intellect', 'education'] },
|
|
{ match: /intelligence artificielle/i, skills: ['electronics', '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 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;
|
|
}
|
|
|
|
function buildMgt2eCharacteristics(existingCharacteristics = {}, values) {
|
|
const characteristics = foundry.utils.deepClone(existingCharacteristics);
|
|
|
|
for (const [legacyKey, targetKey] of Object.entries(MGT2E_CHARACTERISTICS)) {
|
|
const value = values[legacyKey] ?? 7;
|
|
characteristics[targetKey] = foundry.utils.mergeObject(characteristics[targetKey] ?? {}, {
|
|
value,
|
|
current: value,
|
|
dm: calculateDm(value),
|
|
show: true,
|
|
default: false,
|
|
});
|
|
}
|
|
|
|
return characteristics;
|
|
}
|
|
|
|
function buildMgt2eSkills(existingSkills = {}, result) {
|
|
const skills = foundry.utils.deepClone(existingSkills);
|
|
const { hint } = buildCharacteristicValues(result);
|
|
const skillLevels = mergeSkillLevels(result.experience.profile.skills, hint.skills, result.experience.profile.skillLevel);
|
|
|
|
for (const [skillFqn, level] of skillLevels.entries()) {
|
|
setSkillLevel(skills, skillFqn, level);
|
|
}
|
|
|
|
return skills;
|
|
}
|
|
|
|
function buildActorDescription(result, actorName, ucp) {
|
|
const { hint } = buildCharacteristicValues(result);
|
|
const notableSkills = mergeSkillLevels(result.experience.profile.skills, hint.skills, result.experience.profile.skillLevel);
|
|
return [
|
|
`${actorName} — ${result.role.entry.text}`,
|
|
`Relation : ${result.relation.label}`,
|
|
`Particularité : ${result.quirk.entry.text}`,
|
|
`Expérience : ${result.experience.profile.label}`,
|
|
`UPP : ${ucp}`,
|
|
`Compétences clés : ${Array.from(notableSkills.keys()).map((skill) => localizeSkill(skill)).join(', ')}`,
|
|
].join('\n');
|
|
}
|
|
|
|
async function getMgt2eBaseActorSystem() {
|
|
if (!mgt2eBaseActorSystemPromise) {
|
|
mgt2eBaseActorSystemPromise = (async () => {
|
|
const pack = game.packs.get('mgt2e.base-actors');
|
|
if (!pack) return null;
|
|
|
|
const index = Array.from(await pack.getIndex({ fields: ['name', 'type'] }));
|
|
const entry = index.find((document) => document.name === 'DEFAULT TRAVELLER')
|
|
?? index.find((document) => document.type === 'traveller')
|
|
?? index[0];
|
|
if (!entry?._id) return null;
|
|
|
|
const document = await pack.getDocument(entry._id);
|
|
return document?.toObject()?.system ?? null;
|
|
})();
|
|
}
|
|
|
|
const system = await mgt2eBaseActorSystemPromise;
|
|
return system ? foundry.utils.deepClone(system) : null;
|
|
}
|
|
|
|
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 requestedName = options.name?.trim();
|
|
const { values, ucp } = buildCharacteristicValues(result);
|
|
const baseActorSystem = game.system?.id === 'mgt2e' ? await getMgt2eBaseActorSystem() : null;
|
|
const npcData = {
|
|
name: requestedName || `PNJ — ${result.role.entry.text}`,
|
|
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.role.entry.text,
|
|
}),
|
|
characteristics: foundry.utils.deepClone(baseActorSystem?.characteristics ?? {}),
|
|
hits: foundry.utils.deepClone(baseActorSystem?.hits ?? {}),
|
|
skills: foundry.utils.deepClone(baseActorSystem?.skills ?? {}),
|
|
description: '',
|
|
},
|
|
flags: {
|
|
[MODULE_ID]: {
|
|
generatedNpc: {
|
|
relation: result.relation.key,
|
|
role: result.role.entry.text,
|
|
quirk: result.quirk.entry.text,
|
|
experience: result.experience.profile.label,
|
|
upp: ucp,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
npcData.name = requestedName || npcData.name || `PNJ — ${result.role.entry.text}`;
|
|
npcData.system.sophont = foundry.utils.mergeObject(npcData.system.sophont ?? {}, {
|
|
profession: result.role.entry.text,
|
|
});
|
|
npcData.system.characteristics = buildMgt2eCharacteristics(npcData.system.characteristics, values);
|
|
npcData.system.skills = buildMgt2eSkills(npcData.system.skills, result);
|
|
npcData.system.description = buildActorDescription(result, npcData.name, ucp).replace(/\n/g, '<br>');
|
|
|
|
const actor = await Actor.create(npcData, { renderSheet: false });
|
|
|
|
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,
|
|
};
|
|
}
|