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(); })();