Prepare Deathwatch session tools
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
const { missionHelpers, pool } = require('../mariadb');
|
||||
|
||||
const scenes = [
|
||||
{
|
||||
title: 'The Living Vault',
|
||||
type: 'investigation',
|
||||
mood: 'The Sample vault collapses, but its last heartbeat becomes a transmission.',
|
||||
description: 'Da energifeltet kollapser, falder stilheden ikke. Den bliver dybere. Væggene omkring vaulten trækker sig sammen som muskler, og panelerne begynder at bløde sort væske ned over runer og datakabler. Auspexen skriger kort. Ikke en alarm. Et hjerteslag. Brother Malachiel drejer langsomt hovedet mod prøvens kammer. "Det her var ikke kun et laboratorium. Det var en sender."',
|
||||
objectives: [
|
||||
'Stop or copy the vault datapulse.',
|
||||
'Identify the destination marker: THETA-92.',
|
||||
'Keep Malachiel clear of the psychic feedback.'
|
||||
],
|
||||
checks: [
|
||||
{ name: 'Stop/copy datapulse', skill: 'Tech-Use', characteristic: 'Int', target: 45, modifier: -10, reward: 'Recover full THETA-92 coordinates.' },
|
||||
{ name: 'Read psychic carrier wave', skill: 'Psyniscience', characteristic: 'Per', target: 45, modifier: 0, reward: 'Confirm the signal is warp-amplified.' },
|
||||
{ name: 'Interpret forbidden pattern', skill: 'Forbidden Lore', characteristic: 'Int', target: 45, modifier: -10, reward: 'Learn the signal points to a second Bile site.' }
|
||||
],
|
||||
complications: [
|
||||
'3 successes before 2 failures gives full coordinates.',
|
||||
'On failure, the team only gets partial coordinates and starts the next mission with weaker intel.',
|
||||
'Critical failure gives one marine a vision of Bile smiling in a white laboratory.'
|
||||
],
|
||||
rewards: { xp: 75, rp: 1 },
|
||||
extraChallenges: [
|
||||
{ id: 'full-coordinates', text: 'Recover full THETA-92 coordinates before the vault dies.', rewardType: 'xp', rewardValue: 75, completed: false },
|
||||
{ id: 'clean-copy', text: 'Copy the signal without letting it ping the Kill-team biometrics.', rewardType: 'renown', rewardValue: 1, completed: false }
|
||||
],
|
||||
outro: 'På skærmen står kun én markør tilbage: THETA-92 // ACTIVE // RECEIVING'
|
||||
},
|
||||
{
|
||||
title: 'Prisoner Signal',
|
||||
type: 'social',
|
||||
mood: 'A weak vox-call cuts through the alarms. Someone is still alive.',
|
||||
description: 'Mens skibet ryster, bryder en svag vox-kanal gennem alarmstøjen. Først kun statisk. Så hosten. Så en stemme. "Imperial signal... hvis nogen hører dette... vi er stadig i live. Ikke mange. Ikke længe." Kortet opdaterer med en sektion tæt på jeres flugtrute: SPECIMEN HOLDING.',
|
||||
objectives: [
|
||||
'Decide whether to spend escape time on the prisoners.',
|
||||
'Rescue Magos-Biologis Ceryx Vorn if possible.',
|
||||
'Identify any biomorphic trap among the prisoners.'
|
||||
],
|
||||
checks: [
|
||||
{ name: 'Open stasis cages safely', skill: 'Tech-Use', characteristic: 'Int', target: 45, modifier: 0, reward: 'Release prisoners without killing support systems.' },
|
||||
{ name: 'Triage movable prisoners', skill: 'Medicae', characteristic: 'Int', target: 45, modifier: 0, reward: 'Save the prisoners who can survive extraction.' },
|
||||
{ name: 'Spot biomorphic trap', skill: 'Awareness', characteristic: 'Per', target: 45, modifier: 10, reward: 'Detect the altered prisoner before it wakes.' }
|
||||
],
|
||||
enemies: [
|
||||
{ name: 'Gene-Warped Prisoner', type: 'mutant trap', count: 1 }
|
||||
],
|
||||
combat: {
|
||||
enemy: 'Gene-Warped Prisoner',
|
||||
weapon: 'Bone talons',
|
||||
ws: 35,
|
||||
bs: 0,
|
||||
modifier: 0,
|
||||
damage: '1d10+5',
|
||||
targetTB: 4,
|
||||
targetArmour: 2,
|
||||
mode: 'single',
|
||||
rof: 1
|
||||
},
|
||||
complications: [
|
||||
'If the trap is missed, one prisoner wakes as a Fear 1 biomorphic attacker.',
|
||||
'Each extra round spent here tightens the escape timer.',
|
||||
'Magos Vorn knows Vesalius was transport, not source.'
|
||||
],
|
||||
rewards: { xp: 100, rp: 2 },
|
||||
extraChallenges: [
|
||||
{ id: 'save-vorn', text: 'Extract Magos-Biologis Ceryx Vorn alive.', rewardType: 'xp', rewardValue: 100, completed: false },
|
||||
{ id: 'save-four', text: 'Rescue four or more prisoners.', rewardType: 'renown', rewardValue: 1, completed: false }
|
||||
],
|
||||
outro: 'Magos Vorn griber jeres arm med en rystende metalhånd. "Vesalius var ikke kilden. Det var kun transporten."'
|
||||
},
|
||||
{
|
||||
title: 'Engine Shrine Sabotage',
|
||||
type: 'puzzle_combat',
|
||||
mood: 'The engine room is a chapel of wet steel, plasma heat, and living machine-hate.',
|
||||
description: 'Motorsektionen føles ikke som et maskinrum. Den føles som et kapel bygget af kød og stål. Plasmaledninger hænger som tarme fra loftet. Tandhjul drejer i våde rytmer. Midt i det hele står tre reaktorkerner, hver omviklet af sort organisk væv. Hvis Vesalius skal dø, skal det ske her.',
|
||||
objectives: [
|
||||
'Plant charges on all three engine cores.',
|
||||
'Overload reactor couplings so Vesalius cannot be repaired.',
|
||||
'Survive plasma venting and released Gland Hounds.'
|
||||
],
|
||||
checks: [
|
||||
{ name: 'Place demolition charges', skill: 'Demolitions', characteristic: 'Int', target: 45, modifier: 0, reward: 'Arm the charges in the right sequence.' },
|
||||
{ name: 'Overload reactor couplings', skill: 'Tech-Use', characteristic: 'Int', target: 45, modifier: -10, reward: 'Make the destruction permanent.' },
|
||||
{ name: 'Rip open armour panels', skill: 'Strength', characteristic: 'S', target: 45, modifier: 10, reward: 'Expose the core without losing time.' },
|
||||
{ name: 'Avoid plasma venting', skill: 'Dodge', characteristic: 'Ag', target: 45, modifier: 0, reward: 'Avoid 1d10+4 Energy Pen 3.' }
|
||||
],
|
||||
enemies: [
|
||||
{ name: 'Gland Hound', type: 'bio-hound', count: 2 }
|
||||
],
|
||||
combat: {
|
||||
enemy: 'Gland Hound',
|
||||
weapon: 'Bite',
|
||||
ws: 40,
|
||||
bs: 0,
|
||||
modifier: 0,
|
||||
damage: '1d10+5',
|
||||
targetTB: 4,
|
||||
targetArmour: 2,
|
||||
mode: 'single',
|
||||
rof: 1
|
||||
},
|
||||
complications: [
|
||||
'Skill challenge: 5 successes before 3 failures.',
|
||||
'Failure causes 1d10+4 Energy Pen 3 or lost time.',
|
||||
'At 2 failures, two Gland Hounds enter the shrine.'
|
||||
],
|
||||
rewards: { xp: 100, rp: 2 },
|
||||
extraChallenges: [
|
||||
{ id: 'clean-sabotage', text: 'Complete sabotage before three failures.', rewardType: 'xp', rewardValue: 100, completed: false },
|
||||
{ id: 'no-delay', text: 'Leave the engine shrine without reducing the escape timer.', rewardType: 'renown', rewardValue: 1, completed: false }
|
||||
],
|
||||
outro: 'Den første ladning armeres. Så den næste. Så den tredje. Hele skibet skriger.'
|
||||
},
|
||||
{
|
||||
title: 'The Theta Transmission',
|
||||
type: 'investigation',
|
||||
mood: 'Bile leaves them coordinates not as a gift, but as contempt.',
|
||||
description: 'På vej mod flugtfartøjet tænder alle skærme omkring jer samtidigt. Biles ansigt vises ikke klart. Det er forvrænget, kopieret, skåret sammen af flere stemmer. "I ødelagde et bur. Ikke dyret." Bag stemmen ser I et glimt: sne, sort klippe, et underjordisk laboratorium, og rækker af stasis-kapsler mærket med imperiale genetiske segl. Så kommer koordinaterne. Ikke som gave. Som hån.',
|
||||
objectives: [
|
||||
'Choose one intel priority for the next mission.',
|
||||
'Confirm Facility THETA-92 as the next target.',
|
||||
'Record whether the Sample was extracted or destroyed.'
|
||||
],
|
||||
checks: [
|
||||
{ name: 'Lock coordinates', skill: 'Navigation', characteristic: 'Int', target: 45, modifier: 0, reward: '+10 to first infiltration/navigation test next mission.' },
|
||||
{ name: 'Scrub vox corruption', skill: 'Tech-Use', characteristic: 'Int', target: 45, modifier: -10, reward: 'Preserve enemy/facility intel cleanly.' },
|
||||
{ name: 'Frame strategic report', skill: 'Command', characteristic: 'Fel', target: 45, modifier: 10, reward: 'Turn the discovery into actionable Deathwatch orders.' }
|
||||
],
|
||||
complications: [
|
||||
'Intel priority choices: enemy intel, facility map, sample data, or prisoner testimony.',
|
||||
'If Sample extracted, quarantine becomes a next-session pressure.',
|
||||
'If Sample destroyed, Mechanicus demands data restitution.'
|
||||
],
|
||||
rewards: { xp: 50, rp: 1 },
|
||||
extraChallenges: [
|
||||
{ id: 'intel-priority', text: 'Choose and record a clear intel priority for THETA-92.', rewardType: 'xp', rewardValue: 50, completed: false },
|
||||
{ id: 'strategic-edge', text: 'Frame the report so Deathwatch command gains a strategic edge.', rewardType: 'renown', rewardValue: 1, completed: false }
|
||||
],
|
||||
outro: 'THETA-92 blinker på hololithen. Vesalius brænder bag jer. Men krigen er allerede flyttet videre.'
|
||||
}
|
||||
];
|
||||
|
||||
(async function main() {
|
||||
const name = 'Vesalius Echo: Theta-92';
|
||||
const existing = (await missionHelpers.getAll()).find(m => m.name === name);
|
||||
if (existing) {
|
||||
console.log(JSON.stringify({ status: 'exists', id: existing.id, name: existing.name }, null, 2));
|
||||
await pool.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const id = await missionHelpers.create({
|
||||
name,
|
||||
theme: 'chaos_biotech',
|
||||
sceneCount: scenes.length,
|
||||
enemyCount: 4,
|
||||
threatLevel: 'High',
|
||||
playerCount: 4,
|
||||
gmPlayer: 'gm',
|
||||
scenes
|
||||
});
|
||||
|
||||
console.log(JSON.stringify({ status: 'created', id, name }, null, 2));
|
||||
await pool.end();
|
||||
})();
|
||||
@@ -0,0 +1,348 @@
|
||||
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();
|
||||
})();
|
||||
@@ -0,0 +1,110 @@
|
||||
const { playerHelpers, pool } = require('../mariadb');
|
||||
|
||||
const talentDescriptions = {
|
||||
'Ambidextrous': 'Reduces the penalty for using the off-hand and supports two-weapon fighting.',
|
||||
'Astartes Weapon Training': 'May use standard Space Marine weapon groups without the normal untrained penalty.',
|
||||
'Bulging Biceps': 'Can fire heavy weapons more effectively and benefits from massive Astartes strength.',
|
||||
'Deathwatch Training': 'Against xenos, may confirm Righteous Fury more reliably and apply Deathwatch combat doctrine.',
|
||||
'Heightened Senses (Hearing, Sight)': '+10 bonus to relevant Awareness tests using hearing or sight.',
|
||||
'Killing Strike': 'May spend Fate or use focused attack timing to make a melee attack harder to defend against.',
|
||||
'Nerves of Steel': 'May re-roll failed tests to avoid or recover from Pinning.',
|
||||
'Psy Rating 3': 'Counts as Psy Rating 3 for psychic power strength, range, and force weapon effects.',
|
||||
'Quick Draw': 'Can ready a weapon as a Free Action once per round.',
|
||||
'Resistance (Psychic Powers)': '+10 bonus to resist psychic powers and warp effects.',
|
||||
'True Grit': 'Reduces incoming Critical Damage, making the Marine much harder to kill outright.',
|
||||
'Unarmed Master': 'Unarmed attacks are dangerous Astartes attacks rather than weak improvised strikes.',
|
||||
'Unnatural Strength (x2)': 'Doubles Strength Bonus for lifting, melee damage, and opposed Strength uses.',
|
||||
'Unnatural Toughness (x2)': 'Doubles Toughness Bonus for resisting damage and physical punishment.',
|
||||
'Bolter Mastery': 'Improves effectiveness with bolter weapons, often represented as extra damage or reliability.',
|
||||
'Combat Master': 'Enemies do not gain the normal ganging-up bonus against this character in melee.',
|
||||
'Melee Weapon Training': 'Can use the listed melee weapon type without the untrained penalty.',
|
||||
'Pistol Training': 'Can use the listed pistol type without the untrained penalty.',
|
||||
'Swift Attack': 'Can make multiple melee attacks as a Full Action.',
|
||||
'Lightning Attack': 'Can make even more rapid melee attacks than Swift Attack.',
|
||||
'Fearless': 'Immune to Fear and Pinning, but may be unable to retreat from threats.',
|
||||
'Jaded': 'Immune or resistant to mundane horror and many shocking sights.',
|
||||
'Hatred': '+10 Weapon Skill against the hated foe and must often pursue them aggressively.',
|
||||
'Rapid Reload': 'Reduces reload time for ranged weapons.',
|
||||
'Die Hard': 'Harder to die from blood loss and severe injury.',
|
||||
'Crushing Blow': 'Adds extra melee damage from brutal force.',
|
||||
'Two Weapon Wielder': 'Allows more effective fighting with two weapons when prerequisites are met.',
|
||||
'Unrelenting Devastation': 'Devastator-style heavy fire discipline. When using heavy weapons against massed enemies, the Marine can turn sustained fire into extra Horde pressure or area control.',
|
||||
'Air of Authority': 'Bonus/effect: On a successful Command Test, affects 1d10 + Fellowship Bonus NPC targets. May Command NPCs not under your authority at -10. No effect on hostile targets.',
|
||||
'Iron Discipline': 'Followers and allies under the character’s command are steadier under fire and less likely to break, panic, or ignore orders.',
|
||||
'Hatred (Xenos)': '+10 Weapon Skill against xenos enemies; the character is driven to engage hated aliens aggressively.',
|
||||
'Wings of Angels': 'Assault Marine jump-pack mastery. Improves control and impact when charging, leaping, or striking from above with a jump pack.'
|
||||
};
|
||||
|
||||
const psychicDescriptions = {
|
||||
'Psy Rating 3': 'Power level 3. Increases psychic effect strength and force weapon impact.',
|
||||
'Smite': 'Focused psychic attack. Use Willpower/Focus Power; on success it deals direct warp lightning damage.',
|
||||
'Inspire': 'Sustained support power that strengthens allies against Fear, Pinning, and morale collapse.',
|
||||
'Reading': 'Reads aura and surface impressions to reveal corruption, psychic traces, wounds, and emotional state.',
|
||||
'Augury': 'Seeks omens and likely outcomes; useful for mission planning and danger prediction.',
|
||||
'Avenger': 'Violent psychic flame/force attack for area damage.',
|
||||
'Short Range Telepathy': 'Silent mind-to-mind communication at short range.'
|
||||
};
|
||||
|
||||
const psychicNoteDescriptions = {
|
||||
'Using psychic powers risks Psychic Phenomena depending on power level and roll.': 'When a Librarian manifests powers, some rolls can trigger Psychic Phenomena or worse warp side effects. Push powers only when the risk is worth it.',
|
||||
'Force weapon damage and penetration increase by Psy Rating when wielded by this Librarian.': 'A force weapon channels the Librarian’s Psy Rating. For Malachiel, add Psy Rating 3 to relevant force weapon damage and penetration when activated as allowed by the rules.'
|
||||
};
|
||||
|
||||
function lines(value) {
|
||||
return String(value || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function nameFromLine(line) {
|
||||
return String(line || '').replace(/:.*/, '').trim();
|
||||
}
|
||||
|
||||
function descriptionsFor(value, source) {
|
||||
const out = {};
|
||||
for (const line of lines(value)) {
|
||||
const name = nameFromLine(line);
|
||||
if (source[name]) out[name] = source[name];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
(async function main() {
|
||||
const players = await playerHelpers.getAll();
|
||||
const updated = [];
|
||||
|
||||
for (const player of players) {
|
||||
if (player.name === 'gm') continue;
|
||||
const tabInfo = player.tabInfo || {};
|
||||
const nextTabInfo = {
|
||||
...tabInfo,
|
||||
talentDescriptions: {
|
||||
...(tabInfo.talentDescriptions || {}),
|
||||
...descriptionsFor(tabInfo.talents, talentDescriptions)
|
||||
},
|
||||
psychicDescriptions: {
|
||||
...(tabInfo.psychicDescriptions || {}),
|
||||
...descriptionsFor(tabInfo.psychic, { ...psychicDescriptions, ...psychicNoteDescriptions })
|
||||
}
|
||||
};
|
||||
|
||||
await playerHelpers.update(player.name, {
|
||||
rollerInfo: player.rollerInfo || {},
|
||||
shopInfo: player.shopInfo || {},
|
||||
tabInfo: nextTabInfo,
|
||||
pw: player.pw || '',
|
||||
pwHash: player.pwHash || ''
|
||||
});
|
||||
updated.push({
|
||||
name: player.name,
|
||||
talents: Object.keys(nextTabInfo.talentDescriptions).length,
|
||||
psychic: Object.keys(nextTabInfo.psychicDescriptions).length
|
||||
});
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ updated }, null, 2));
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
await pool.end();
|
||||
})().catch(async error => {
|
||||
console.error(error);
|
||||
try { await pool.end(); } catch {}
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
const { missionHelpers, pool } = require('../mariadb');
|
||||
|
||||
function rebalanceScene(scene) {
|
||||
if (scene.title === 'The Living Vault') {
|
||||
return {
|
||||
...scene,
|
||||
checks: [
|
||||
{ name: 'Anchor the psychic lock', skill: 'Psyniscience', characteristic: 'Wp', target: 45, modifier: 10, reward: 'Malachiel steadies the vault before the signal tears free.' },
|
||||
{ name: 'Read psychic carrier wave', skill: 'Psyniscience', characteristic: 'Per', target: 45, modifier: 10, reward: 'Confirm the signal is warp-amplified.' },
|
||||
{ name: 'Copy datapulse checksum', skill: 'Tech-Use', characteristic: 'Int', target: 45, modifier: 0, reward: 'Recover THETA-92 coordinates without needing a perfect decode.' }
|
||||
],
|
||||
complications: [
|
||||
'2 successes gives full coordinates; 1 success gives partial coordinates with a bad omen.',
|
||||
'On 2+ failures, the vault tags one marine with a bio-psychic echo.',
|
||||
'Critical failure gives one marine a vision of Bile smiling in a white laboratory.'
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (scene.title === 'Prisoner Signal') {
|
||||
return {
|
||||
...scene,
|
||||
checks: [
|
||||
{ name: 'Open stasis cages safely', skill: 'Tech-Use', characteristic: 'Int', target: 45, modifier: 10, reward: 'Release prisoners without killing support systems.' },
|
||||
{ name: 'Triage movable prisoners', skill: 'Medicae', characteristic: 'Int', target: 45, modifier: 10, reward: 'Save the prisoners who can survive extraction.' },
|
||||
{ name: 'Spot biomorphic trap', skill: 'Awareness', characteristic: 'Per', target: 45, modifier: 10, reward: 'Detect the altered prisoner before it wakes.' }
|
||||
],
|
||||
enemies: [
|
||||
{ name: 'Gene-Warped Prisoner', type: 'mutant trap', count: 1, wounds: 12, ws: 35, bs: 0, ap: 2, damage: '1d10+4', agBonus: 4 }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (scene.title === 'Engine Shrine Sabotage') {
|
||||
return {
|
||||
...scene,
|
||||
checks: [
|
||||
{ name: 'Place demolition charges', skill: 'Demolitions', characteristic: 'Int', target: 45, modifier: 10, reward: 'Arm the charges in the right sequence.' },
|
||||
{ name: 'Sense the living machine-spirit', skill: 'Psyniscience', characteristic: 'Wp', target: 45, modifier: 0, reward: 'Malachiel predicts the reactor pulse before it vents.' },
|
||||
{ name: 'Rip open armour panels', skill: 'Strength', characteristic: 'S', target: 45, modifier: 10, reward: 'Expose the core without losing time.' },
|
||||
{ name: 'Avoid plasma venting', skill: 'Dodge', characteristic: 'Ag', target: 45, modifier: 10, reward: 'Avoid 1d10+4 Energy Pen 3.' }
|
||||
],
|
||||
enemies: [
|
||||
{ name: 'Gland Hound', type: 'bio-hound', count: 1, wounds: 14, ws: 40, bs: 0, ap: 2, damage: '1d10+5', agBonus: 5 }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (scene.title === 'The Theta Transmission') {
|
||||
return {
|
||||
...scene,
|
||||
type: 'briefing',
|
||||
checks: [
|
||||
{ name: 'Hold the transmission in memory', skill: 'Willpower', characteristic: 'Wp', target: 45, modifier: 10, reward: 'Preserve the coordinates despite Bile’s psychic static.' },
|
||||
{ name: 'Lock coordinates', skill: 'Navigation', characteristic: 'Int', target: 45, modifier: 10, reward: '+10 to first infiltration/navigation test next mission.' },
|
||||
{ name: 'Frame strategic report', skill: 'Command', characteristic: 'Fel', target: 45, modifier: 10, reward: 'Turn the discovery into actionable Deathwatch orders.' }
|
||||
],
|
||||
complications: [
|
||||
'Intel priority choices: enemy intel, facility map, sample data, or prisoner testimony.',
|
||||
'If Sample extracted, quarantine becomes a next-session pressure.',
|
||||
'If Sample destroyed, Mechanicus demands data restitution.',
|
||||
'This scene should set the hook, not punish the team with another heavy failure cascade.'
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return scene;
|
||||
}
|
||||
|
||||
(async function main() {
|
||||
const missions = await missionHelpers.getAll();
|
||||
const mission = missions.find(m => m.name === 'Vesalius Echo: Theta-92');
|
||||
if (!mission) {
|
||||
console.error('Mission not found: Vesalius Echo: Theta-92');
|
||||
await pool.end();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const scenes = (mission.scenes || []).map(rebalanceScene);
|
||||
const ok = await missionHelpers.update(mission.id, {
|
||||
name: mission.name,
|
||||
theme: mission.theme,
|
||||
sceneCount: scenes.length,
|
||||
enemyCount: 3,
|
||||
threatLevel: 'Medium-High',
|
||||
playerCount: mission.player_count || 4,
|
||||
scenes
|
||||
});
|
||||
|
||||
console.log(JSON.stringify({ status: ok ? 'updated' : 'failed', id: mission.id, name: mission.name }, null, 2));
|
||||
await pool.end();
|
||||
})();
|
||||
@@ -0,0 +1,223 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { pool } = require('../mariadb');
|
||||
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
const MAX_LINES = 8;
|
||||
const MAX_CHARS = 900;
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const BACKUP_DIR = path.join(ROOT, 'backups');
|
||||
const JSON_RULES = path.join(ROOT, 'rules', 'rules-database.json');
|
||||
|
||||
const BAD_TITLE_RE = /^([_\-—–.·•\s\d()+%†]+|NAME|SKILLS|CHARACTERISTICS|SQUAD MODE|COHESION:|SQUAD DESIGNATION:|ISBN:.*)$/i;
|
||||
const STAT_TITLE_RE = /^(\(?\d+[-–]?\d*\)?(\s+\(?[\d–-]+\)?){1,}|.*Pen\s+\d+.*|.*Reload.*|.*Tearing.*|.*Unnatural .*x2.*)$/i;
|
||||
const NOISE_LINE_RE = /^(\d{1,3}|chapter\s+[ivx]+|[ivx]+:\s+|table\s+\d|page\s+\d|www\.|isbn|™|®|©|credits|contents|printed in|fantasy flight|games workshop|character name:|player name:|effects:|type:\s*✔|trained\s*$|basic\s*$|advanced\s+skills|actioN descriptioNs)$/i;
|
||||
const SHEET_RE = /(SPACE MARINE ABILITIES|POWER ARMOUR ABILITIES|PrimarCh’s Curse|Battle Fatigue|CurreNt PoiNts|weapon skill\s+ballistic skill|character name:\s+player name)/i;
|
||||
const STATBLOCK_RE = /(Movement:\s*\d|Wounds:\s*\d|Skills:.*Talents:|Armour:.*Weapons:|wS\s+BS\s+S\s+T\s+ag\s+int\s+Per\s+WP\s+fel)/i;
|
||||
const TABLE_NOISE_RE = /(Rank \d .* Advances|Advance\s+Cost\s+Type|Objective\/Difficulty\s+Requisition|Name\s+Wt\s+Req\s+Renown)/i;
|
||||
const MECHANICS_RE = /(Test|Action|Reaction|Damage|Wounds?|Skill|Talent|Trait|bonus|penalty|Requisition|Renown|Cohesion|Fear|Insanity|Horde|Armou?r|Weapon|Psychic|Initiative|Critical|Fatigue|Pinning|Prone|Dodge|Parry|Opposed|Difficulty|Full Action|Half Action|Free Action|Toughness|Willpower|Agility|Fellowship|Ballistic|Weapon Skill)/i;
|
||||
const LORE_TITLE_RE = /^(GAME|THE LION|SANGUINIUS|LEMAN RUSS|THE DEATHWING|THE RAVENWING|THE LION AND THE WOLF|IMPERIUM|ULTIMA|SEGEMENTUM|THE AGES OF HUMANITY|THE SOUL BINDING|EXTERMINATUS|RADICALS AND PURITANS|BLACK SHIELDS|CATECHISM OF THE XENOS|THE HADEX ANOMALY|OF THE 41ST MILLENNIUM|WE ARE THE IMPERIUM)/i;
|
||||
|
||||
function cleanWhitespace(text) {
|
||||
return String(text || '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/[\x00-\x09\x0B\x0C\x0E-\x1F]/g, '')
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\n\s+/g, '\n')
|
||||
.replace(/\s+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function cleanTitle(title) {
|
||||
return cleanWhitespace(title)
|
||||
.replace(/[〔〕]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isBadTitle(title) {
|
||||
const t = cleanTitle(title);
|
||||
if (!t || t.length < 3) return true;
|
||||
if (BAD_TITLE_RE.test(t)) return true;
|
||||
if (STAT_TITLE_RE.test(t)) return true;
|
||||
if (/^(Prerequisites:|[–-][A-Z]\.|†+|[0-9]{2,}|[0-9]+[–-][0-9]+)$/i.test(t)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function noiseScore(rule) {
|
||||
const title = cleanTitle(rule.title);
|
||||
const content = cleanWhitespace(rule.content);
|
||||
let score = 0;
|
||||
if (isBadTitle(title)) score += 4;
|
||||
if (SHEET_RE.test(content) || SHEET_RE.test(title)) score += 8;
|
||||
if (STATBLOCK_RE.test(content) || STATBLOCK_RE.test(title)) score += 5;
|
||||
if (TABLE_NOISE_RE.test(content) || TABLE_NOISE_RE.test(title)) score += 4;
|
||||
if (content.length > 1300) score += 2;
|
||||
const lines = content.split('\n').filter(Boolean);
|
||||
const noisyLines = lines.filter(line => NOISE_LINE_RE.test(line) || /^\W+$/.test(line) || line.length > 160).length;
|
||||
if (lines.length && noisyLines / lines.length > 0.45) score += 4;
|
||||
if ((title.match(/\b[A-Z]{2,}\b/g) || []).length > 3) score += 2;
|
||||
return score;
|
||||
}
|
||||
|
||||
function meaningfulLines(rule) {
|
||||
const title = cleanTitle(rule.title);
|
||||
const content = cleanWhitespace(rule.content);
|
||||
const raw = content.split('\n').map(line => cleanWhitespace(line)).filter(Boolean);
|
||||
const lines = [];
|
||||
for (const line of raw) {
|
||||
if (!line || line === title) continue;
|
||||
if (NOISE_LINE_RE.test(line)) continue;
|
||||
if (/^\W+$/.test(line)) continue;
|
||||
if (/^[A-Z][A-Za-z\s]+:\s*$/.test(line) && line.length < 24) continue;
|
||||
if (line.length > 180 && !/[.;:]$/.test(line)) continue;
|
||||
if (/(\s{8,}| {4,})/.test(line)) continue;
|
||||
if (/^(S|T|ag|int|per|wp|fel|wS|BS)(\s+\S+){4,}$/i.test(line)) continue;
|
||||
lines.push(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function sanitizeContent(rule) {
|
||||
const summary = cleanWhitespace(rule.summary);
|
||||
const lines = meaningfulLines(rule);
|
||||
const picked = [];
|
||||
|
||||
if (summary && summary.length > 20 && !SHEET_RE.test(summary)) {
|
||||
picked.push(summary);
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (picked.join('\n').length > MAX_CHARS) break;
|
||||
if (picked.some(existing => existing.toLowerCase() === line.toLowerCase())) continue;
|
||||
picked.push(line);
|
||||
if (picked.length >= MAX_LINES) break;
|
||||
}
|
||||
|
||||
let content = picked.join('\n').trim();
|
||||
if (content.length > MAX_CHARS) {
|
||||
content = content.slice(0, MAX_CHARS).replace(/\s+\S*$/, '').trim();
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function shouldDelete(rule) {
|
||||
const title = cleanTitle(rule.title);
|
||||
const content = cleanWhitespace(rule.content);
|
||||
const score = noiseScore(rule);
|
||||
const useful = sanitizeContent(rule);
|
||||
if (LORE_TITLE_RE.test(title) && !MECHANICS_RE.test(useful)) return true;
|
||||
if (LORE_TITLE_RE.test(title) && String(rule.category || '').toLowerCase() === 'general') return true;
|
||||
if (!useful || useful.length < 35) return true;
|
||||
if (SHEET_RE.test(content)) return true;
|
||||
if (score >= 10) return true;
|
||||
if (isBadTitle(title) && score >= 7) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function sanitizeRule(rule) {
|
||||
return {
|
||||
...rule,
|
||||
title: cleanTitle(rule.title),
|
||||
content: sanitizeContent(rule),
|
||||
summary: cleanWhitespace(rule.summary),
|
||||
};
|
||||
}
|
||||
|
||||
function backupFileName(prefix) {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
return path.join(BACKUP_DIR, `${prefix}-${stamp}.json`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM rules ORDER BY id');
|
||||
const dbBackupPath = backupFileName('rules-table-before-sanitize');
|
||||
fs.writeFileSync(dbBackupPath, JSON.stringify(rows, null, 2));
|
||||
|
||||
const deletes = [];
|
||||
const updates = [];
|
||||
for (const row of rows) {
|
||||
if (shouldDelete(row)) {
|
||||
deletes.push(row);
|
||||
continue;
|
||||
}
|
||||
const next = sanitizeRule(row);
|
||||
if (
|
||||
next.title !== row.title ||
|
||||
next.content !== row.content ||
|
||||
next.summary !== row.summary
|
||||
) {
|
||||
updates.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
let jsonStats = null;
|
||||
if (fs.existsSync(JSON_RULES)) {
|
||||
const json = JSON.parse(fs.readFileSync(JSON_RULES, 'utf8'));
|
||||
const rules = Array.isArray(json.rules) ? json.rules : [];
|
||||
const jsonBackupPath = backupFileName('rules-json-before-sanitize');
|
||||
fs.writeFileSync(jsonBackupPath, JSON.stringify(json, null, 2));
|
||||
const kept = [];
|
||||
let jsonDeleted = 0;
|
||||
let jsonUpdated = 0;
|
||||
for (const rule of rules) {
|
||||
if (shouldDelete(rule)) {
|
||||
jsonDeleted += 1;
|
||||
continue;
|
||||
}
|
||||
const next = sanitizeRule(rule);
|
||||
if (next.content !== rule.content || next.title !== rule.title || next.summary !== rule.summary) jsonUpdated += 1;
|
||||
kept.push(next);
|
||||
}
|
||||
jsonStats = { before: rules.length, after: kept.length, deleted: jsonDeleted, updated: jsonUpdated, backup: jsonBackupPath };
|
||||
if (APPLY) fs.writeFileSync(JSON_RULES, JSON.stringify({ ...json, rules: kept }, null, 2));
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
apply: APPLY,
|
||||
db: {
|
||||
before: rows.length,
|
||||
after: rows.length - deletes.length,
|
||||
deleted: deletes.length,
|
||||
updated: updates.length,
|
||||
backup: dbBackupPath,
|
||||
deleteSamples: deletes.slice(0, 12).map(r => ({ id: r.id, title: r.title, score: noiseScore(r) })),
|
||||
updateSamples: updates.slice(0, 8).map(r => ({ id: r.id, title: r.title, chars: r.content.length, preview: r.content.slice(0, 160) })),
|
||||
},
|
||||
json: jsonStats,
|
||||
}, null, 2));
|
||||
|
||||
if (APPLY) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
for (const row of updates) {
|
||||
await conn.execute(
|
||||
'UPDATE rules SET title = ?, content = ?, summary = ? WHERE id = ?',
|
||||
[row.title, row.content, row.summary || '', row.id]
|
||||
);
|
||||
}
|
||||
for (const row of deletes) {
|
||||
await conn.execute('DELETE FROM rules WHERE id = ?', [row.id]);
|
||||
}
|
||||
await conn.commit();
|
||||
} catch (error) {
|
||||
await conn.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(async () => { await pool.end(); }, 250);
|
||||
}
|
||||
|
||||
main().catch(async error => {
|
||||
console.error(error);
|
||||
try { await pool.end(); } catch {}
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,441 @@
|
||||
const { missionHelpers, pool } = require('../mariadb');
|
||||
|
||||
const missionTitle = 'Vesalius Echo: Theta-92';
|
||||
|
||||
const storyFlags = [
|
||||
'malachiel_freed',
|
||||
'vorn_rescued',
|
||||
'safe_prisoners_rescued',
|
||||
'tyrant_horror_released',
|
||||
'nurgle_lieutenant_revealed',
|
||||
'theta92_coordinates_recovered',
|
||||
'bile_escape_vector_detected',
|
||||
'emergency_resupply_requested'
|
||||
];
|
||||
|
||||
const canon = {
|
||||
primaryVillain: 'Fabius Bile',
|
||||
chaosTheme: 'Nurgle corruption has infected part of the experiment chain through Apothecary Morbus Threx.',
|
||||
nurgleLieutenant: {
|
||||
name: 'Apothecary Morbus Threx',
|
||||
role: 'Corrupted former Apothecary working under Bile.',
|
||||
secret: "He used Nurgle rites and plague-biotech to accelerate Bile's experiments, making them more stable, infectious, and horrifying than normal gene-work should allow.",
|
||||
relationshipToBile: 'Bile sees Threx as useful but dangerous. Threx believes decay, disease, and forced adaptation are the true path to perfecting flesh.'
|
||||
},
|
||||
voidInstabilitySource: {
|
||||
source: 'A trapped psyker inside the vault.',
|
||||
effect: 'The psyker torment caused void-vox anomalies, unstable reality pockets, equipment disruption, jump pack failures, and displacement incidents.',
|
||||
chaosLink: 'The psyker did not intentionally serve Chaos, but the suffering created a breach that Nurgle corruption exploited.'
|
||||
},
|
||||
nextMissionSeed: {
|
||||
title: 'THETA-92: The Infested World',
|
||||
hiddenTruth: 'The target planet is Tyranid-infested, and Bile or his agents are experimenting with Tyranid biology under Nurgle-corrupted conditions.',
|
||||
opening: 'The Kill-team pursues the escape craft and receives emergency resupply mid-transit or on approach.'
|
||||
}
|
||||
};
|
||||
|
||||
const scenes = [
|
||||
{
|
||||
title: 'The Living Vault',
|
||||
type: 'investigation_rescue',
|
||||
mood: 'After the lab fight, the next chamber is not a debrief. It is a living vault, a prison, and a transmitter.',
|
||||
description: 'Bag laboratoriets sidste barrikade åbner et rum sig, der ikke føles bygget, men dyrket. Glasbeholdere, stasis-kister og sorte kabler hænger som årer fra loftet. Inde i vaulten bevæger noget sig stadig. Ikke alt er dødt. Ikke alt er menneskeligt. Midt i det hele står Brother Malachiel, fanget i et felt af runer, bio-kabler og psykisk støj.',
|
||||
readAloud: 'Bag laboratoriets sidste barrikade åbner et rum sig, der ikke føles bygget, men dyrket. Glasbeholdere, stasis-kister og sorte kabler hænger som årer fra loftet. Inde i vaulten bevæger noget sig stadig. Ikke alt er dødt. Ikke alt er menneskeligt. Midt i det hele står Brother Malachiel, fanget i et felt af runer, bio-kabler og psykisk støj.',
|
||||
objectives: [
|
||||
'Secure the vault after the laboratory combat.',
|
||||
'Free or stabilize Brother Malachiel.',
|
||||
'Determine which prisoners are safe and which are latent bio-horrors.',
|
||||
'Discover that the vault is also a transmitter.'
|
||||
],
|
||||
checks: [
|
||||
{
|
||||
name: 'Stabilize Malachiel',
|
||||
skill: 'Psyniscience',
|
||||
characteristic: 'Wp',
|
||||
target: 55,
|
||||
modifier: 0,
|
||||
reward: 'Malachiel is freed without psychic backlash.'
|
||||
},
|
||||
{
|
||||
name: 'Read the prisoners',
|
||||
skill: 'Scrutiny',
|
||||
characteristic: 'Per',
|
||||
target: 45,
|
||||
modifier: 0,
|
||||
reward: 'The team identifies which prisoners are safe.'
|
||||
},
|
||||
{
|
||||
name: 'Decode vault function',
|
||||
skill: 'Tech-Use',
|
||||
characteristic: 'Int',
|
||||
target: 45,
|
||||
modifier: 0,
|
||||
reward: 'The team learns the vault is transmitting to THETA-92.'
|
||||
}
|
||||
],
|
||||
specialPlayerOptions: [
|
||||
'The Chaplain may test faith, fear, guilt, or corruption among prisoners.',
|
||||
'Malachiel, once freed, may use Librarian senses to detect which captives are warp-tainted.',
|
||||
'The Apothecary may identify Nurgle plague-biotech in the sample tissue.',
|
||||
'The Techmarine may isolate the vault transmitter.'
|
||||
],
|
||||
enemies: [
|
||||
{
|
||||
name: 'Chaos-Infected Tyrant Horror',
|
||||
type: 'nurgle_tyranid_horror',
|
||||
count: 1,
|
||||
trigger: 'Appears if the team releases prisoners carelessly or fails the corruption check.',
|
||||
description: 'A prisoner or sample body erupts into a Tyranid-like horror carrying Nurgle corruption.',
|
||||
wounds: 22,
|
||||
ws: 45,
|
||||
bs: 0,
|
||||
ap: 4,
|
||||
damage: '1d10+7 R Pen 3 Toxic (1)',
|
||||
agBonus: 4,
|
||||
fear: 1
|
||||
}
|
||||
],
|
||||
combat: {
|
||||
enemy: 'Chaos-Infected Tyrant Horror',
|
||||
weapon: 'Rending talons',
|
||||
ws: 45,
|
||||
bs: 0,
|
||||
modifier: 0,
|
||||
damage: '1d10+7',
|
||||
targetTB: 4,
|
||||
targetArmour: 4,
|
||||
mode: 'single',
|
||||
rof: 1
|
||||
},
|
||||
complications: [
|
||||
'Failure on Stabilize Malachiel causes psychic feedback and worsens the vault timer.',
|
||||
'Failure on Read the prisoners means a prisoner later mutates into a Tyrant Horror.',
|
||||
'The trapped psyker is the source of the void-vox anomalies and earlier displacement events.',
|
||||
'The psyker is not a willing servant of Chaos, but the suffering created a breach Nurgle corruption exploited.'
|
||||
],
|
||||
rewards: { xp: 75, rp: 1 },
|
||||
extraChallenges: [
|
||||
{ id: 'malachiel-clean-freed', text: 'Free Malachiel without severe psychic backlash.', rewardType: 'xp', rewardValue: 75, completed: false },
|
||||
{ id: 'identify-infected-prisoners', text: 'Correctly identify infected prisoners before releasing them.', rewardType: 'renown', rewardValue: 1, completed: false }
|
||||
],
|
||||
storyFlags: ['malachiel_freed', 'tyrant_horror_released', 'theta92_coordinates_recovered'],
|
||||
outro: 'Vaultens sidste puls bliver til et koordinatglimt: THETA-92 // ACTIVE // RECEIVING.',
|
||||
revealed: false
|
||||
},
|
||||
{
|
||||
title: 'Prisoner Signal',
|
||||
type: 'social_horror_choice',
|
||||
mood: 'A weak vox-call offers survivors, bait, and a moral cost under a tightening escape timer.',
|
||||
description: "Mens vaultens sidste systemer dør, skærer en svag vox-kanal gennem alarmstøjen. Først statisk. Så hosten. Så en stemme. 'Imperial signal... hvis nogen hører dette... vi er stadig i live. Ikke mange. Ikke længe.' Kortet opdaterer: SPECIMEN HOLDING ligger tæt på jeres flugtrute.",
|
||||
readAloud: "Mens vaultens sidste systemer dør, skærer en svag vox-kanal gennem alarmstøjen. Først statisk. Så hosten. Så en stemme. 'Imperial signal... hvis nogen hører dette... vi er stadig i live. Ikke mange. Ikke længe.' Kortet opdaterer: SPECIMEN HOLDING ligger tæt på jeres flugtrute.",
|
||||
objectives: [
|
||||
'Decide whether to spend time rescuing prisoners.',
|
||||
'Find Magos-Biologis Ceryx Vorn.',
|
||||
'Avoid releasing infected Tyrant Horrors.',
|
||||
'Learn that Vesalius was transport, not the source.'
|
||||
],
|
||||
checks: [
|
||||
{
|
||||
name: 'Open specimen holding safely',
|
||||
skill: 'Tech-Use',
|
||||
characteristic: 'Int',
|
||||
target: 45,
|
||||
modifier: 0,
|
||||
reward: 'The cells open without killing support systems or waking every bio-weapon.'
|
||||
},
|
||||
{
|
||||
name: 'Triage safe prisoners',
|
||||
skill: 'Medicae',
|
||||
characteristic: 'Int',
|
||||
target: 45,
|
||||
modifier: 0,
|
||||
reward: 'The team identifies which prisoners can be moved and which are contaminated.'
|
||||
},
|
||||
{
|
||||
name: 'Hold prisoner panic under control',
|
||||
skill: 'Command',
|
||||
characteristic: 'Fel',
|
||||
target: 45,
|
||||
modifier: 0,
|
||||
reward: 'The survivors move fast enough that the escape timer does not worsen.'
|
||||
}
|
||||
],
|
||||
importantNpc: {
|
||||
name: 'Magos-Biologis Ceryx Vorn',
|
||||
role: 'Survivor and witness',
|
||||
information: [
|
||||
'Vesalius was not the source of the experiments.',
|
||||
'The ship was transporting refined samples toward THETA-92.',
|
||||
'A corrupted Apothecary named Morbus Threx introduced plague-stabilizing agents into the gene-vats.',
|
||||
'The Nurgle contamination made the organisms more resilient but less controllable.'
|
||||
]
|
||||
},
|
||||
enemies: [
|
||||
{
|
||||
name: 'Chaos-Infected Tyrant Horror',
|
||||
type: 'biomorphic_prisoner_trap',
|
||||
count: 1,
|
||||
wounds: 20,
|
||||
ws: 42,
|
||||
bs: 0,
|
||||
ap: 3,
|
||||
damage: '1d10+6 R Pen 3',
|
||||
agBonus: 4,
|
||||
fear: 1
|
||||
}
|
||||
],
|
||||
combat: {
|
||||
enemy: 'Chaos-Infected Tyrant Horror',
|
||||
weapon: 'Bone talons',
|
||||
ws: 42,
|
||||
bs: 0,
|
||||
modifier: 0,
|
||||
damage: '1d10+6',
|
||||
targetTB: 4,
|
||||
targetArmour: 3,
|
||||
mode: 'single',
|
||||
rof: 1
|
||||
},
|
||||
complications: [
|
||||
'Every extra round spent here tightens the escape timer.',
|
||||
'If the biomorphic trap is missed, one prisoner becomes a Fear 1 attacker.',
|
||||
"If Vorn survives, he reveals that Bile's work has been altered by Nurgle corruption.",
|
||||
'This is a dilemma: saving people costs time, but abandoning Vorn loses the clearest Morbus Threx clue.'
|
||||
],
|
||||
rewards: { xp: 100, rp: 1 },
|
||||
extraChallenges: [
|
||||
{ id: 'vorn-rescued', text: 'Extract Magos-Biologis Ceryx Vorn alive.', rewardType: 'xp', rewardValue: 100, completed: false },
|
||||
{ id: 'safe-prisoners-rescued', text: 'Rescue four or more safe prisoners.', rewardType: 'renown', rewardValue: 1, completed: false }
|
||||
],
|
||||
storyFlags: ['vorn_rescued', 'safe_prisoners_rescued', 'nurgle_lieutenant_revealed', 'tyrant_horror_released'],
|
||||
outro: 'Vorns stemme er metallisk og ødelagt: "Vesalius var kun transporten. Threx gjorde dem stabile. Bile sendte dem videre."',
|
||||
revealed: false
|
||||
},
|
||||
{
|
||||
title: 'Engine Shrine Sabotage',
|
||||
type: 'puzzle_combat',
|
||||
mood: 'The reactor is protected by living Nurgle-infected growth. Shooting it is not enough. Burning it alone is not enough.',
|
||||
description: 'Motorsektionen føles ikke som et maskinrum. Den føles som et kapel bygget af kød og stål. Plasmaledninger hænger som tarme fra loftet. Tre reaktorkerner står i midten, hver omviklet af sortgrønt organisk væv. Når I nærmer jer, åbner vævet små munde og ånder varm, syg damp ud over gulvet.',
|
||||
readAloud: 'Motorsektionen føles ikke som et maskinrum. Den føles som et kapel bygget af kød og stål. Plasmaledninger hænger som tarme fra loftet. Tre reaktorkerner står i midten, hver omviklet af sortgrønt organisk væv. Når I nærmer jer, åbner vævet små munde og ånder varm, syg damp ud over gulvet.',
|
||||
objectives: [
|
||||
'Destroy the engine shrine so Vesalius cannot be repaired.',
|
||||
'Solve the Threefold Purge: cold, chemical purge, then fire/plasma.',
|
||||
'Plant charges on the exposed reactor cores.',
|
||||
'Survive or outrun the defenders released by the shrine.'
|
||||
],
|
||||
puzzleName: 'The Threefold Purge',
|
||||
puzzleLogic: {
|
||||
coreRule: 'The bio-growth must be broken by combining cold, chemical purge, and fire/plasma in the correct sequence.',
|
||||
wrongSolutions: [
|
||||
'Only shooting the growth causes it to split and regenerate.',
|
||||
'Only burning the growth makes it release toxic spores.',
|
||||
'Only freezing/cooling it makes it harden around the reactor core.'
|
||||
],
|
||||
correctSequence: [
|
||||
'Cool the growth using reactor coolant or ruptured coolant lines.',
|
||||
'Apply chemical purge from nearby canisters or Mechanicus sterilant tanks.',
|
||||
'Burn or overload it with plasma/fire while it is brittle and chemically exposed.'
|
||||
]
|
||||
},
|
||||
interactiveElements: [
|
||||
{ name: 'Coolant Line', use: 'Freezes or hardens the growth temporarily.', skill: 'Tech-Use or Strength', target: 40 },
|
||||
{ name: 'Sterilant Canisters', use: 'Weakens the plague membrane after cooling.', skill: 'Medicae, Tech-Use, or Ballistic Skill to shoot canister accurately', target: 45 },
|
||||
{ name: 'Plasma Vent', use: 'Burns exposed growth and opens the core for demolition charges.', skill: 'Tech-Use or Demolition', target: 45 }
|
||||
],
|
||||
checks: [
|
||||
{
|
||||
name: 'Rupture or redirect coolant line',
|
||||
skill: 'Tech-Use',
|
||||
characteristic: 'Int',
|
||||
target: 40,
|
||||
modifier: 0,
|
||||
reward: 'The growth becomes brittle for one round.'
|
||||
},
|
||||
{
|
||||
name: 'Apply sterilant purge',
|
||||
skill: 'Medicae',
|
||||
characteristic: 'Int',
|
||||
target: 45,
|
||||
modifier: 0,
|
||||
reward: 'The plague membrane weakens after cooling.'
|
||||
},
|
||||
{
|
||||
name: 'Open plasma vent and plant charges',
|
||||
skill: 'Demolitions',
|
||||
characteristic: 'Int',
|
||||
target: 45,
|
||||
modifier: 0,
|
||||
reward: 'All three charges are planted and Vesalius cannot be repaired.'
|
||||
}
|
||||
],
|
||||
skillChallenge: {
|
||||
goal: '5 successes before 3 failures',
|
||||
successResult: 'All three charges are planted and Vesalius cannot be repaired.',
|
||||
failureResult: 'The reactor vents plasma: 1d10+4 Energy Pen 3 or lost escape time.',
|
||||
at2Failures: 'Two Gland Hounds or Tyrant Horrors enter the shrine.'
|
||||
},
|
||||
enemies: [
|
||||
{
|
||||
name: 'Gland Hound',
|
||||
type: 'bio-hound',
|
||||
count: 2,
|
||||
wounds: 18,
|
||||
ws: 40,
|
||||
bs: 0,
|
||||
ap: 2,
|
||||
damage: '1d10+5 R Pen 2 Toxic (1)',
|
||||
agBonus: 5
|
||||
},
|
||||
{
|
||||
name: 'Chaos-Infected Tyrant Horror',
|
||||
type: 'nurgle_tyranid_horror',
|
||||
count: 1,
|
||||
wounds: 22,
|
||||
ws: 45,
|
||||
bs: 0,
|
||||
ap: 4,
|
||||
damage: '1d10+7 R Pen 3 Toxic (1)',
|
||||
agBonus: 4,
|
||||
fear: 1
|
||||
},
|
||||
{
|
||||
name: 'Nurgle Plague-Servitor',
|
||||
type: 'plague_servitor',
|
||||
count: 1,
|
||||
wounds: 16,
|
||||
ws: 35,
|
||||
bs: 25,
|
||||
ap: 5,
|
||||
damage: '1d10+4 I Pen 2 Toxic (1)',
|
||||
agBonus: 2,
|
||||
optional: true
|
||||
}
|
||||
],
|
||||
combat: {
|
||||
enemy: 'Gland Hound',
|
||||
weapon: 'Bite',
|
||||
ws: 40,
|
||||
bs: 0,
|
||||
modifier: 0,
|
||||
damage: '1d10+5',
|
||||
targetTB: 4,
|
||||
targetArmour: 2,
|
||||
mode: 'single',
|
||||
rof: 1
|
||||
},
|
||||
complications: [
|
||||
'Skill challenge: 5 successes before 3 failures.',
|
||||
'Wrong sequence regenerates, spores, or hardens the growth.',
|
||||
'Failure causes 1d10+4 Energy Pen 3 or lost escape time.',
|
||||
'At 2 failures, two Gland Hounds or Tyrant Horrors enter the shrine.'
|
||||
],
|
||||
rewards: { xp: 100, rp: 1 },
|
||||
extraChallenges: [
|
||||
{ id: 'threefold-purge-solved', text: 'Solve the purge puzzle without reaching 3 failures.', rewardType: 'xp', rewardValue: 100, completed: false },
|
||||
{ id: 'no-escape-delay', text: 'Leave the engine shrine without reducing the escape timer.', rewardType: 'renown', rewardValue: 1, completed: false }
|
||||
],
|
||||
storyFlags: ['emergency_resupply_requested'],
|
||||
outro: 'Kølingen rammer først. Sterilanten hvæser bagefter. Plasmaen gør resten. Under jer begynder Vesalius at dø.',
|
||||
revealed: false
|
||||
},
|
||||
{
|
||||
title: 'The Theta Escape Vector',
|
||||
type: 'briefing_hook',
|
||||
mood: 'No calm debrief. Vesalius burns behind them while a small craft flees toward THETA-92.',
|
||||
description: "Vesalius brænder bag jer, men sejren når aldrig at blive stille. Voxen knitrer fra jeres skib: 'Kill-team, void-augur har kontakt. Lille craft. Ingen imperial ident-kode. Kurs væk fra Vesalius.' Hololithen tegner en tynd rød linje gennem tomrummet mod en nærliggende planet. På skærmen står kun én markør: THETA-92 // ACTIVE // RECEIVING.",
|
||||
readAloud: "Vesalius brænder bag jer, men sejren når aldrig at blive stille. Voxen knitrer fra jeres skib: 'Kill-team, void-augur har kontakt. Lille craft. Ingen imperial ident-kode. Kurs væk fra Vesalius.' Hololithen tegner en tynd rød linje gennem tomrummet mod en nærliggende planet. På skærmen står kun én markør: THETA-92 // ACTIVE // RECEIVING.",
|
||||
objectives: [
|
||||
'Confirm the escape vector.',
|
||||
'Choose whether to pursue immediately.',
|
||||
'Record what was recovered: Malachiel, prisoners, Vorn, sample data, or corrupted tissue.',
|
||||
'Request emergency resupply to be sent after the Kill-team.'
|
||||
],
|
||||
checks: [
|
||||
{
|
||||
name: 'Confirm escape vector',
|
||||
skill: 'Awareness',
|
||||
characteristic: 'Per',
|
||||
target: 45,
|
||||
modifier: 10,
|
||||
reward: 'The void-augur track confirms a small craft fleeing toward THETA-92.'
|
||||
},
|
||||
{
|
||||
name: 'Lock pursuit solution',
|
||||
skill: 'Navigation',
|
||||
characteristic: 'Int',
|
||||
target: 45,
|
||||
modifier: 0,
|
||||
reward: 'The Kill-team can pursue immediately without waiting for full command clearance.'
|
||||
},
|
||||
{
|
||||
name: 'Request moving resupply',
|
||||
skill: 'Command',
|
||||
characteristic: 'Fel',
|
||||
target: 45,
|
||||
modifier: 10,
|
||||
reward: 'A Thunderhawk, drop pod, or support craft is dispatched after them during pursuit.'
|
||||
}
|
||||
],
|
||||
bileMessageOptional: {
|
||||
useIfNeeded: true,
|
||||
message: "En forvrænget stemme bryder kort gennem voxen. 'I ødelagde et bur. Ikke dyret. Kom bare efter mig, Astartes. THETA-92 har allerede åbnet sig.'",
|
||||
purpose: 'Bile is not just bragging. He is baiting them toward a prepared situation.'
|
||||
},
|
||||
nextMissionHook: [
|
||||
'The planet appears cold, remote, and officially unimportant.',
|
||||
'The auspex briefly registers impossible bio-signatures before static kills the feed.',
|
||||
'The Kill-team does not yet know the planet is Tyranid-infested.',
|
||||
'Command cannot wait for full resupply. A support craft will be dispatched after them.'
|
||||
],
|
||||
complications: [
|
||||
'Do not run this as a calm debrief.',
|
||||
'The stronger hook is the detected escape vector, not a long villain monologue.',
|
||||
'Seed Tyranid infestation only as impossible bio-signatures before static kills the feed.',
|
||||
'Resupply is not safe at base; it is sent after them while they continue toward the planet.'
|
||||
],
|
||||
rewards: { xp: 50, rp: 1 },
|
||||
extraChallenges: [
|
||||
{ id: 'pursuit-priority-recorded', text: 'Choose and record a clear pursuit/intel priority for THETA-92.', rewardType: 'xp', rewardValue: 50, completed: false },
|
||||
{ id: 'strategic-edge-report', text: 'Frame the report so Deathwatch command gains a strategic edge.', rewardType: 'renown', rewardValue: 1, completed: false }
|
||||
],
|
||||
storyFlags: ['theta92_coordinates_recovered', 'bile_escape_vector_detected', 'emergency_resupply_requested'],
|
||||
outro: 'Der er ingen tid til hvile. THETA-92 modtager stadig. Et lille fartøj løber foran jer. Resupply må indhente jer under jagten.',
|
||||
revealed: false
|
||||
}
|
||||
];
|
||||
|
||||
(async function main() {
|
||||
const existing = (await missionHelpers.getAll()).find(m => m.name === missionTitle);
|
||||
if (!existing) {
|
||||
throw new Error(`Mission not found: ${missionTitle}`);
|
||||
}
|
||||
|
||||
const ok = await missionHelpers.update(existing.id, {
|
||||
name: existing.name,
|
||||
theme: 'chaos_biotech_nurgle_theta92',
|
||||
sceneCount: scenes.length,
|
||||
enemyCount: 4,
|
||||
threatLevel: 'High',
|
||||
playerCount: existing.player_count || 4,
|
||||
scenes: scenes.map(scene => ({ ...scene, missionCanon: canon, missionStoryFlags: storyFlags }))
|
||||
});
|
||||
|
||||
if (!ok) throw new Error(`Failed to update mission ${existing.id}`);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
status: 'updated',
|
||||
id: existing.id,
|
||||
name: existing.name,
|
||||
active: existing.is_active,
|
||||
sceneCount: scenes.length,
|
||||
sceneTitles: scenes.map(scene => scene.title),
|
||||
storyFlags
|
||||
}, null, 2));
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
await pool.end();
|
||||
})().catch(async error => {
|
||||
console.error(error);
|
||||
try { await pool.end(); } catch {}
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
const { playerHelpers, pool } = require('../mariadb');
|
||||
|
||||
const name = 'chris';
|
||||
|
||||
const characteristics = {
|
||||
WS: 43,
|
||||
BS: 41,
|
||||
S: 40,
|
||||
T: 42,
|
||||
Ag: 39,
|
||||
Int: 48,
|
||||
Per: 45,
|
||||
Wp: 51,
|
||||
Fel: 37
|
||||
};
|
||||
|
||||
function skill(trained = false) {
|
||||
return { trained, plus10: false, plus20: false };
|
||||
}
|
||||
|
||||
const skills = {
|
||||
'Awareness (Per)': skill(true),
|
||||
'Common Lore (Int)': skill(true),
|
||||
'Dodge (Ag)': skill(true),
|
||||
'Forbidden Lore (Int) - Xenos': skill(true),
|
||||
'Intimidate (S)': skill(true),
|
||||
'Literacy (Int)': skill(true),
|
||||
'Psyniscience (Per)': skill(true),
|
||||
'Scholastic Lore (Int) - Codex Astartes': skill(true),
|
||||
'Scholastic Lore (Int) - Occult': skill(true),
|
||||
'Search (Per)': skill(true),
|
||||
'Speak Language (Int) - High Gothic': skill(true),
|
||||
'Speak Language (Int) - Low Gothic': skill(true),
|
||||
'Silent Move (Ag)': skill(false),
|
||||
'Tech-Use (Int)': skill(false),
|
||||
'Navigation (Int)': skill(false),
|
||||
'Command (Fel)': skill(false)
|
||||
};
|
||||
|
||||
const tabInfo = {
|
||||
charName: 'Brother Malachiel',
|
||||
playerName: name,
|
||||
chapter: 'dark angels',
|
||||
speciality: 'librarian',
|
||||
rank: '1',
|
||||
characteristics,
|
||||
skills,
|
||||
weapons: [
|
||||
{
|
||||
name: 'Astartes Bolter',
|
||||
class: 'Basic',
|
||||
damage: '2d10+5',
|
||||
type: 'Explosive',
|
||||
pen: '5',
|
||||
range: '100',
|
||||
rof: 'S/2/4',
|
||||
clip: '28',
|
||||
rld: 'Full',
|
||||
special: 'Tearing, Fire selector'
|
||||
},
|
||||
{
|
||||
name: 'Astartes Bolt Pistol',
|
||||
class: 'Pistol',
|
||||
damage: '2d10+5',
|
||||
type: 'Explosive',
|
||||
pen: '5',
|
||||
range: '30',
|
||||
rof: 'S/2/-',
|
||||
clip: '14',
|
||||
rld: 'Full',
|
||||
special: 'Tearing'
|
||||
},
|
||||
{
|
||||
name: 'Astartes Force Sword',
|
||||
class: 'Melee',
|
||||
damage: '1d10+5 + SB',
|
||||
type: 'Rending',
|
||||
pen: '5',
|
||||
range: '',
|
||||
rof: '',
|
||||
clip: '',
|
||||
rld: '',
|
||||
special: 'Balanced, Special, Psy Rating 3 included'
|
||||
}
|
||||
],
|
||||
gear: [
|
||||
{ name: 'Astartes Power Armour', qty: 1 },
|
||||
{ name: 'Astartes Bolter with Fire Selector', qty: 1 },
|
||||
{ name: 'Astartes Bolt Pistol', qty: 1 },
|
||||
{ name: 'Astartes Force Sword', qty: 1 },
|
||||
{ name: 'Astartes Frag Grenade', qty: 3 },
|
||||
{ name: 'Astartes Krak Grenade', qty: 3 },
|
||||
{ name: 'Astartes Combat Knife', qty: 1 },
|
||||
{ name: 'Emperor Tarot Deck', qty: 1 }
|
||||
],
|
||||
armour: { body: 'Mark VII Power Armour', ap: 8, head: 8, ra: 8, la: 8, rl: 8, ll: 8 },
|
||||
talents: [
|
||||
'Astartes Weapon Training',
|
||||
'Deathwatch Training',
|
||||
'Psy Rating 3',
|
||||
'Resistance (Psychic Powers)',
|
||||
'Nerves of Steel',
|
||||
'True Grit',
|
||||
'Unnatural Strength (x2)',
|
||||
'Unnatural Toughness (x2)'
|
||||
],
|
||||
psychicPowers: ['Smite', 'Inspire', 'Reading'],
|
||||
fate: 3,
|
||||
wounds: 20,
|
||||
movement: { half: 4, full: 8, charge: 12, halfAction: 4, fullAction: 8, run: 24 },
|
||||
renown: 'Initiated',
|
||||
rp: 0,
|
||||
xp: 0,
|
||||
xpSpent: 0,
|
||||
notes: 'Dark Angels Deathwatch Librarian introduced during Operation Vesalius Scene 4.'
|
||||
};
|
||||
|
||||
(async function main() {
|
||||
const existing = await playerHelpers.getByName(name);
|
||||
if (existing) {
|
||||
await playerHelpers.update(name, {
|
||||
rollerInfo: existing.rollerInfo || {},
|
||||
shopInfo: existing.shopInfo || {},
|
||||
tabInfo,
|
||||
pw: existing.pw || '',
|
||||
pwHash: existing.pwHash || ''
|
||||
});
|
||||
console.log(JSON.stringify({ status: 'updated', name }, null, 2));
|
||||
} else {
|
||||
const id = await playerHelpers.create({
|
||||
name,
|
||||
rollerInfo: {},
|
||||
shopInfo: {},
|
||||
tabInfo,
|
||||
pw: '1234',
|
||||
pwHash: ''
|
||||
});
|
||||
console.log(JSON.stringify({ status: 'created', id, name }, null, 2));
|
||||
}
|
||||
await pool.end();
|
||||
})();
|
||||
Reference in New Issue
Block a user