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, '
'); 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, }; }