349 lines
14 KiB
JavaScript
349 lines
14 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const { playerHelpers, pool } = require('../mariadb');
|
|
|
|
const DATA_DIR = path.join(__dirname, '..', '..', 'data', 'players');
|
|
|
|
const SKILLS = [
|
|
'Acrobatics (Ag)', 'Awareness (Per)', 'Charm (Fel)', 'Climb (S)', 'Command (Fel)',
|
|
'Common Lore (Int)', 'Deathwatch', 'Imperium', 'War', 'Dodge (Ag)',
|
|
'Forbidden Lore (Int)', 'Xenos', 'Intimidate (S)', 'Literacy (Int)', 'Medicae (Int)',
|
|
'Navigation (Int)', 'Pilot (Ag)', 'Scholastic Lore (Int)', 'Codex Astartes',
|
|
'Scrutiny (Per)', 'Search (Per)', 'Silent Move (Ag)', 'Speak Language (Int)',
|
|
'High Gothic', 'Low Gothic', 'Survival (Int)', 'Tactics (Int)', 'Tracking (Int)',
|
|
'Tech-Use (Int)'
|
|
];
|
|
|
|
const STAT_MAP = {
|
|
weapon_skill: ['WS', 'ws'],
|
|
ballistic_skill: ['BS', 'bs'],
|
|
strength: ['S', 's'],
|
|
toughness: ['T', 't'],
|
|
agility: ['Ag', 'ag'],
|
|
intelligence: ['Int', 'int'],
|
|
perception: ['Per', 'per'],
|
|
willpower: ['Wp', 'wp'],
|
|
fellowship: ['Fel', 'fel']
|
|
};
|
|
|
|
const DEFAULT_SPACE_MARINE_TALENTS = [
|
|
'Ambidextrous',
|
|
'Astartes Weapon Training',
|
|
'Bulging Biceps',
|
|
'Deathwatch Training',
|
|
'Heightened Senses (Hearing, Sight)',
|
|
'Killing Strike',
|
|
'Nerves of Steel',
|
|
'Quick Draw',
|
|
'Resistance (Psychic Powers)',
|
|
'True Grit',
|
|
'Unarmed Master',
|
|
'Unnatural Strength (x2)',
|
|
'Unnatural Toughness (x2)'
|
|
];
|
|
|
|
function readJson(file) {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function asArray(value) {
|
|
return Array.isArray(value) ? value : value ? [value] : [];
|
|
}
|
|
|
|
function text(value) {
|
|
if (Array.isArray(value)) return value.map(text).filter(Boolean).join('\n');
|
|
if (value && typeof value === 'object') return value.name || value.summary || JSON.stringify(value);
|
|
return value == null ? '' : String(value);
|
|
}
|
|
|
|
function cleanSpeciality(specialty) {
|
|
return String(specialty || '')
|
|
.replace(/^Deathwatch\s+/i, '')
|
|
.replace(/\s+Marine$/i, '')
|
|
.trim()
|
|
.toLowerCase();
|
|
}
|
|
|
|
function identityFrom(raw, existingTab) {
|
|
const character = raw?.character || raw?.converted_character || raw?.source_character || {};
|
|
return {
|
|
charName: character.name || character.preferred_short_name || existingTab.charName || existingTab.characterName || 'Unnamed Battle-Brother',
|
|
chapter: character.chapter || existingTab.chapter || 'Unknown Chapter',
|
|
speciality: cleanSpeciality(character.specialty || character.original_specialty || existingTab.speciality || 'battle-brother'),
|
|
rank: character.rank ? `Rank ${character.rank} — Battle-Brother` : (existingTab.rank || 'Rank 1 — Battle-Brother'),
|
|
demeanour: character.chapter_demeanour || existingTab.demeanour || '',
|
|
personalDemeanour: character.personal_demeanour || existingTab.personalDemeanour || '',
|
|
pastEvent: character.past_event || existingTab.pastEvent || '',
|
|
powerArmour: character.power_armour_history || existingTab.powerArmour || '',
|
|
description: character.description || existingTab.description || ''
|
|
};
|
|
}
|
|
|
|
function characteristicsFrom(raw, existingTab) {
|
|
const out = {};
|
|
const source = raw?.characteristics || {};
|
|
for (const [sourceKey, [upper, lower]] of Object.entries(STAT_MAP)) {
|
|
const value = source[sourceKey] ?? existingTab.characteristics?.[upper] ?? existingTab.characteristics?.[lower] ?? 40;
|
|
out[upper] = Number(value) || 40;
|
|
out[lower] = Number(value) || 40;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function markSkill(skills, name) {
|
|
if (!skills[name]) return;
|
|
skills[name].trained = true;
|
|
}
|
|
|
|
function skillsFrom(raw, existingTab) {
|
|
const skills = Object.fromEntries(SKILLS.map(name => [name, { trained: false, plus10: false, plus20: false }]));
|
|
|
|
if (existingTab.skills && typeof existingTab.skills === 'object' && !Array.isArray(existingTab.skills)) {
|
|
for (const [name, value] of Object.entries(existingTab.skills)) {
|
|
if (skills[name]) skills[name] = { ...skills[name], ...value };
|
|
}
|
|
} else if (Array.isArray(existingTab.skills)) {
|
|
for (const name of existingTab.skills) markSkill(skills, String(name));
|
|
}
|
|
|
|
for (const entry of asArray(raw?.skills)) {
|
|
const name = String(entry?.name || entry || '');
|
|
if (/Awareness/i.test(name)) markSkill(skills, 'Awareness (Per)');
|
|
if (/Charm/i.test(name)) markSkill(skills, 'Charm (Fel)');
|
|
if (/Command/i.test(name)) markSkill(skills, 'Command (Fel)');
|
|
if (/Common Lore/i.test(name)) {
|
|
markSkill(skills, 'Common Lore (Int)');
|
|
for (const spec of asArray(entry.specialisations)) {
|
|
if (skills[spec]) markSkill(skills, spec);
|
|
}
|
|
}
|
|
if (/Dodge/i.test(name)) markSkill(skills, 'Dodge (Ag)');
|
|
if (/Forbidden Lore/i.test(name)) {
|
|
markSkill(skills, 'Forbidden Lore (Int)');
|
|
for (const spec of asArray(entry.specialisations)) {
|
|
if (skills[spec]) markSkill(skills, spec);
|
|
}
|
|
}
|
|
if (/Intimidate/i.test(name)) markSkill(skills, 'Intimidate (S)');
|
|
if (/Literacy/i.test(name)) markSkill(skills, 'Literacy (Int)');
|
|
if (/Medicae/i.test(name)) markSkill(skills, 'Medicae (Int)');
|
|
if (/Navigation/i.test(name)) markSkill(skills, 'Navigation (Int)');
|
|
if (/Pilot/i.test(name)) markSkill(skills, 'Pilot (Ag)');
|
|
if (/Scholastic Lore/i.test(name)) {
|
|
markSkill(skills, 'Scholastic Lore (Int)');
|
|
for (const spec of asArray(entry.specialisations)) {
|
|
if (/Codex/i.test(spec)) markSkill(skills, 'Codex Astartes');
|
|
}
|
|
}
|
|
if (/Scrutiny/i.test(name)) markSkill(skills, 'Scrutiny (Per)');
|
|
if (/Search/i.test(name)) markSkill(skills, 'Search (Per)');
|
|
if (/Silent Move/i.test(name)) markSkill(skills, 'Silent Move (Ag)');
|
|
if (/Speak Language/i.test(name)) {
|
|
markSkill(skills, 'Speak Language (Int)');
|
|
for (const spec of asArray(entry.specialisations)) {
|
|
if (/High Gothic/i.test(spec)) markSkill(skills, 'High Gothic');
|
|
if (/Low Gothic/i.test(spec)) markSkill(skills, 'Low Gothic');
|
|
}
|
|
}
|
|
if (/Survival/i.test(name)) markSkill(skills, 'Survival (Int)');
|
|
if (/Tactics/i.test(name)) markSkill(skills, 'Tactics (Int)');
|
|
if (/Tracking/i.test(name)) markSkill(skills, 'Tracking (Int)');
|
|
if (/Tech-Use/i.test(name)) markSkill(skills, 'Tech-Use (Int)');
|
|
}
|
|
|
|
if (raw?.character?.specialty === 'Deathwatch Librarian' || raw?.converted_character?.specialty === 'Deathwatch Librarian') {
|
|
markSkill(skills, 'Awareness (Per)');
|
|
markSkill(skills, 'Forbidden Lore (Int)');
|
|
}
|
|
|
|
return skills;
|
|
}
|
|
|
|
function weaponFromEntry(entry) {
|
|
if (!entry || typeof entry !== 'object') return null;
|
|
if (entry.variants) return entry.variants.map(weaponFromEntry).filter(Boolean);
|
|
const special = asArray(entry.special_rules).join(', ') || entry.special || '';
|
|
return {
|
|
name: entry.name || '',
|
|
class: entry.class || entry.type || '',
|
|
damage: entry.damage || '',
|
|
type: entry.damage_type || entry.type || '',
|
|
pen: entry.penetration ?? entry.pen ?? entry.ap ?? '',
|
|
range: entry.range || '',
|
|
rof: entry.rof || '',
|
|
clip: entry.clip || '',
|
|
rld: entry.reload || entry.rld || '',
|
|
special
|
|
};
|
|
}
|
|
|
|
function weaponsFrom(raw, existingTab) {
|
|
const fromRaw = asArray(raw?.weapons).flatMap(w => asArray(weaponFromEntry(w))).filter(w => w.name);
|
|
const existing = asArray(existingTab.weapons).map(w => ({
|
|
name: w.name || '',
|
|
class: w.class || w.type || '',
|
|
damage: w.damage || '',
|
|
type: w.type || w.damage_type || '',
|
|
pen: w.pen ?? w.penetration ?? w.ap ?? '',
|
|
range: w.range || '',
|
|
rof: w.rof || '',
|
|
clip: w.clip || '',
|
|
rld: w.rld || w.reload || '',
|
|
special: w.special || asArray(w.special_rules).join(', ')
|
|
})).filter(w => w.name);
|
|
return (fromRaw.length ? fromRaw : existing).slice(0, 8);
|
|
}
|
|
|
|
function gearFrom(raw, existingTab) {
|
|
const rawGear = asArray(raw?.gear).map((item, index) => {
|
|
if (typeof item === 'string') return { id: index + 1, name: item, qty: 1, note: '' };
|
|
return { id: index + 1, name: item.name || '', qty: item.qty || item.count || 1, note: item.role || item.source || '' };
|
|
}).filter(item => item.name);
|
|
const existing = asArray(existingTab.gear).map((item, index) => {
|
|
if (typeof item === 'string') return { id: index + 1, name: item, qty: 1, note: '' };
|
|
return { id: item.id || index + 1, name: item.name || '', qty: item.qty || item.count || 1, note: item.note || item.role || '' };
|
|
}).filter(item => item.name);
|
|
return rawGear.length ? rawGear : existing;
|
|
}
|
|
|
|
function armourFrom(raw, existingTab) {
|
|
const armour = raw?.armour || {};
|
|
const points = armour.armour_points_by_location || armour.armour_points || {};
|
|
const uniform = typeof points === 'number' ? points : 8;
|
|
return {
|
|
head: points.head ?? uniform,
|
|
body: points.body ?? uniform,
|
|
ra: points.right_arm ?? points.ra ?? uniform,
|
|
la: points.left_arm ?? points.la ?? uniform,
|
|
rl: points.right_leg ?? points.rl ?? uniform,
|
|
ll: points.left_leg ?? points.ll ?? uniform,
|
|
additions: asArray(armour.armour_additions).join('; ') || existingTab.armour?.additions || armour.name || existingTab.armour?.power || ''
|
|
};
|
|
}
|
|
|
|
function totalFrom(value, fallback) {
|
|
if (value && typeof value === 'object') return Number(value.total ?? value.current ?? fallback) || fallback;
|
|
return Number(value) || fallback;
|
|
}
|
|
|
|
function movementFrom(raw, existingTab) {
|
|
const movement = raw?.derived?.movement || existingTab.movement || {};
|
|
return {
|
|
half: Number(movement.half ?? movement.halfAction ?? 4) || 4,
|
|
full: Number(movement.full ?? movement.fullAction ?? 8) || 8,
|
|
charge: Number(movement.charge ?? 12) || 12,
|
|
run: Number(movement.run ?? 24) || 24
|
|
};
|
|
}
|
|
|
|
function notesFrom(raw, existingTab) {
|
|
const context = raw?.deathwatch_context;
|
|
const parts = [];
|
|
if (context?.reason_for_secondment) parts.push(`Deathwatch: ${context.reason_for_secondment}`);
|
|
if (context?.kill_team_role) parts.push(`Kill-team role: ${context.kill_team_role}`);
|
|
if (context?.personal_hook) parts.push(`Personal hook: ${context.personal_hook}`);
|
|
if (existingTab.notes) parts.push(existingTab.notes);
|
|
return parts.join('\n\n');
|
|
}
|
|
|
|
function psychicFrom(raw, existingTab) {
|
|
const powers = raw?.psychic_powers;
|
|
if (!powers) return text(existingTab.psychic || '');
|
|
const lines = [];
|
|
if (powers.psy_rating) lines.push(`Psy Rating ${powers.psy_rating}`);
|
|
for (const p of asArray(powers.starting_techniques)) lines.push(`${p.name}: ${p.summary || p.role || ''}`.trim());
|
|
for (const note of asArray(powers.notes)) lines.push(note);
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function talentsFrom(raw, existingTab) {
|
|
const source = raw?.talents_and_traits || existingTab.talents || DEFAULT_SPACE_MARINE_TALENTS;
|
|
const talents = asArray(source).map(item => typeof item === 'string' ? item : item.name).filter(Boolean);
|
|
return Array.from(new Set(talents.length ? talents : DEFAULT_SPACE_MARINE_TALENTS)).join('\n');
|
|
}
|
|
|
|
function buildTabInfo(name, raw, existingTab) {
|
|
const id = identityFrom(raw, existingTab);
|
|
const woundsTotal = raw?.derived?.wounds?.total ?? totalFrom(existingTab.wounds, 20);
|
|
const fateTotal = raw?.derived?.fate_points?.total ?? totalFrom(existingTab.fate, 3);
|
|
const xpTotal = raw?.derived?.experience?.xp_to_spend ?? existingTab.xp ?? 500;
|
|
const xpSpent = raw?.derived?.experience?.total_xp_spent ?? existingTab.xpSpent ?? 0;
|
|
const renown = raw?.derived?.renown?.rank || existingTab.renown || 'Initiated';
|
|
return {
|
|
...existingTab,
|
|
playerName: name,
|
|
charName: id.charName,
|
|
characterName: id.charName,
|
|
chapter: id.chapter,
|
|
demeanour: id.demeanour,
|
|
speciality: id.speciality,
|
|
rank: id.rank,
|
|
powerArmour: id.powerArmour,
|
|
description: id.description,
|
|
pastEvent: id.pastEvent,
|
|
personalDemeanour: id.personalDemeanour,
|
|
characteristics: characteristicsFrom(raw, existingTab),
|
|
skills: skillsFrom(raw, existingTab),
|
|
weapons: weaponsFrom(raw, existingTab),
|
|
gear: gearFrom(raw, existingTab),
|
|
armour: armourFrom(raw, existingTab),
|
|
talents: talentsFrom(raw, existingTab),
|
|
psychic: psychicFrom(raw, existingTab),
|
|
wounds: {
|
|
total: Number(woundsTotal) || 20,
|
|
current: Number(raw?.derived?.wounds?.current ?? existingTab.wounds?.current ?? woundsTotal) || Number(woundsTotal) || 20,
|
|
fatigue: Number(raw?.derived?.fatigue ?? existingTab.wounds?.fatigue ?? 0) || 0
|
|
},
|
|
insanity: {
|
|
current: Number(raw?.derived?.insanity?.current_points ?? existingTab.insanity?.current ?? 0) || 0,
|
|
battleFatigue: Number(raw?.derived?.insanity?.battle_fatigue ?? existingTab.insanity?.battleFatigue ?? 0) || 0,
|
|
primarchsCurse: Number(raw?.derived?.insanity?.primarchs_curse ?? existingTab.insanity?.primarchsCurse ?? 0) || 0
|
|
},
|
|
movement: movementFrom(raw, existingTab),
|
|
fate: {
|
|
total: Number(fateTotal) || 3,
|
|
current: Number(raw?.derived?.fate_points?.current ?? existingTab.fate?.current ?? fateTotal) || Number(fateTotal) || 3
|
|
},
|
|
corruption: Number(raw?.derived?.corruption?.current ?? existingTab.corruption ?? 0) || 0,
|
|
renown,
|
|
renownPoints: Number(raw?.derived?.renown?.current ?? existingTab.renownPoints ?? 0) || 0,
|
|
xp: Number(xpTotal) || 0,
|
|
xpSpent: Number(xpSpent) || 0,
|
|
notes: notesFrom(raw, existingTab),
|
|
rp: Number(existingTab.rp ?? 0) || 0
|
|
};
|
|
}
|
|
|
|
(async function main() {
|
|
const players = await playerHelpers.getAll();
|
|
const updated = [];
|
|
const skipped = [];
|
|
|
|
for (const player of players) {
|
|
if (player.name === 'gm') continue;
|
|
const file = path.join(DATA_DIR, player.name);
|
|
const raw = readJson(file);
|
|
if (!raw) {
|
|
skipped.push(player.name);
|
|
continue;
|
|
}
|
|
const tabInfo = buildTabInfo(player.name, raw, player.tabInfo || {});
|
|
await playerHelpers.update(player.name, {
|
|
rollerInfo: player.rollerInfo || {},
|
|
shopInfo: player.shopInfo || {},
|
|
tabInfo,
|
|
pw: player.pw || '',
|
|
pwHash: player.pwHash || ''
|
|
});
|
|
updated.push({ name: player.name, charName: tabInfo.charName, chapter: tabInfo.chapter, speciality: tabInfo.speciality });
|
|
}
|
|
|
|
console.log(JSON.stringify({ updated, skipped }, null, 2));
|
|
await new Promise(resolve => setTimeout(resolve, 250));
|
|
await pool.end();
|
|
})();
|