Files
story_builder/rq3.js
T

948 lines
50 KiB
JavaScript
Raw Normal View History

2026-06-30 09:10:05 +10:00
// RQ3 rules engine. Pure functions only — no DB or HTTP dependencies.
// ---------- dice ----------
function rollDie(sides) {
return 1 + Math.floor(Math.random() * sides);
}
function rollDice(count, sides) {
let total = 0;
for (let i = 0; i < count; i++) total += rollDie(sides);
return total;
}
// "1d6", "2d8+2", "1d6-2" etc.
function rollNotation(notation) {
const m = /^(\d+)d(\d+)\s*([+-]\s*\d+)?$/i.exec(notation.trim());
if (!m) throw new Error(`Invalid dice notation: "${notation}"`);
const count = parseInt(m[1], 10);
const sides = parseInt(m[2], 10);
const mod = m[3] ? parseInt(m[3].replace(/\s/g, ''), 10) : 0;
return rollDice(count, sides) + mod;
}
function maxNotation(notation) {
const m = /^(\d+)d(\d+)\s*([+-]\s*\d+)?$/i.exec(notation.trim());
if (!m) throw new Error(`Invalid dice notation: "${notation}"`);
const count = parseInt(m[1], 10);
const sides = parseInt(m[2], 10);
const mod = m[3] ? parseInt(m[3].replace(/\s/g, ''), 10) : 0;
return count * sides + mod;
}
function rollPercentile() {
return rollDie(100);
}
// ---------- characteristic rolls ----------
const CHARACTERISTIC_ROLLS = {
str: '3d6',
con: '3d6',
dex: '3d6',
pow: '3d6',
app: '3d6',
siz: '2d6+6',
int: '2d6+6',
};
function rollCharacteristics() {
const out = {};
for (const [key, notation] of Object.entries(CHARACTERISTIC_ROLLS)) {
out[key] = rollNotation(notation);
}
return out;
}
// ---------- Strike Rank ----------
// SR = DEX Strike Rank + SIZ Strike Rank Modifier (no INT). Weapon SR adds on top for attacks.
const DEX_STRIKE_RANK_TABLE = [
{ min: 1, max: 9, sr: 4 },
{ min: 10, max: 15, sr: 3 },
{ min: 16, max: 19, sr: 2 },
{ min: 20, max: Infinity, sr: 1 },
];
const SIZ_STRIKE_RANK_MODIFIER_TABLE = [
{ min: 1, max: 9, sr: 3 },
{ min: 10, max: 15, sr: 2 },
{ min: 16, max: 19, sr: 1 },
{ min: 20, max: Infinity, sr: 0 },
];
function lookupBand(table, value) {
const band = table.find((b) => value >= b.min && value <= b.max);
if (!band) throw new Error(`No band found for value ${value}`);
return band.sr;
}
function dexStrikeRank(dex) {
return lookupBand(DEX_STRIKE_RANK_TABLE, dex);
}
function sizStrikeRankModifier(siz) {
return lookupBand(SIZ_STRIKE_RANK_MODIFIER_TABLE, siz);
}
// Base SR for a combatant, before any weapon SR is added for a specific attack.
function baseStrikeRank({ dex, siz }) {
return dexStrikeRank(dex) + sizStrikeRankModifier(siz);
}
// ---------- Hit Points per Location ----------
// Keyed by total HP band (CON+SIZ derived). Real per-location values, not approximations.
const HIT_LOCATION_HP_BANDS = [
{ min: 1, max: 3, leg: 1, abdomen: 1, chest: 2, arm: 1, head: 1 },
{ min: 4, max: 6, leg: 2, abdomen: 2, chest: 3, arm: 2, head: 2 },
{ min: 7, max: 9, leg: 3, abdomen: 3, chest: 4, arm: 3, head: 3 },
{ min: 10, max: 12, leg: 4, abdomen: 4, chest: 5, arm: 3, head: 4 },
{ min: 13, max: 15, leg: 5, abdomen: 5, chest: 6, arm: 4, head: 5 },
{ min: 16, max: 18, leg: 6, abdomen: 6, chest: 8, arm: 5, head: 6 },
{ min: 19, max: 21, leg: 7, abdomen: 7, chest: 9, arm: 6, head: 7 },
];
// Beyond 21 total HP the band table (drawn from the rulebook) doesn't extend;
// extrapolate by reusing the top band's per-point ratios rather than guessing.
function hitLocationHpBand(totalHp) {
const band = HIT_LOCATION_HP_BANDS.find((b) => totalHp >= b.min && totalHp <= b.max);
if (band) return band;
const top = HIT_LOCATION_HP_BANDS[HIT_LOCATION_HP_BANDS.length - 1];
const scale = totalHp / top.max;
return {
leg: Math.ceil(top.leg * scale),
abdomen: Math.ceil(top.abdomen * scale),
chest: Math.ceil(top.chest * scale),
arm: Math.ceil(top.arm * scale),
head: Math.ceil(top.head * scale),
};
}
function computeHitLocations(totalHp) {
const band = hitLocationHpBand(totalHp);
return [
{ location_name: 'R-Leg', max_hp: band.leg },
{ location_name: 'L-Leg', max_hp: band.leg },
{ location_name: 'Abdomen', max_hp: band.abdomen },
{ location_name: 'Chest', max_hp: band.chest },
{ location_name: 'R-Arm', max_hp: band.arm },
{ location_name: 'L-Arm', max_hp: band.arm },
{ location_name: 'Head', max_hp: band.head },
].map((loc) => ({ ...loc, current_hp: loc.max_hp, armor_ap: 0, disabled: false }));
}
// ---------- Hit Location rolls (humanoid table reused for all combatants) ----------
const HUMANOID_HIT_LOCATIONS_MELEE = [
{ min: 1, max: 4, location: 'R-Leg' },
{ min: 5, max: 8, location: 'L-Leg' },
{ min: 9, max: 11, location: 'Abdomen' },
{ min: 12, max: 12, location: 'Chest' },
{ min: 13, max: 15, location: 'R-Arm' },
{ min: 16, max: 18, location: 'L-Arm' },
{ min: 19, max: 20, location: 'Head' },
];
const HUMANOID_HIT_LOCATIONS_MISSILE = [
{ min: 1, max: 3, location: 'R-Leg' },
{ min: 4, max: 6, location: 'L-Leg' },
{ min: 7, max: 10, location: 'Abdomen' },
{ min: 11, max: 15, location: 'Chest' },
{ min: 16, max: 17, location: 'R-Arm' },
{ min: 18, max: 19, location: 'L-Arm' },
{ min: 20, max: 20, location: 'Head' },
];
function rollHitLocation(attackKind = 'melee') {
const table = attackKind === 'missile' ? HUMANOID_HIT_LOCATIONS_MISSILE : HUMANOID_HIT_LOCATIONS_MELEE;
const roll = rollDie(20);
const band = table.find((b) => roll >= b.min && roll <= b.max);
return { roll, location: band.location };
}
// ---------- Shielded hit locations ----------
// Which locations a given shield covers (additional AP applies if that location is hit while the shield is raised).
const SHIELD_COVERAGE = {
Buckler: ['shield-arm'],
'Heater/Target': ['shield-arm', 'extra-1'],
Hoplite: ['shield-arm', 'extra-1'],
Kite: ['shield-arm', 'extra-2'],
'Viking Round': ['contiguous'],
};
// ---------- Damage Bonus (STR+SIZ) ----------
const DAMAGE_BONUS_BANDS = [
{ min: 1, max: 12, notation: '-1d4' },
{ min: 13, max: 24, notation: '0' },
{ min: 25, max: 32, notation: '1d4' },
{ min: 33, max: 40, notation: '1d6' },
{ min: 41, max: 56, notation: '2d6' },
];
const DAMAGE_BONUS_BRACKET_SIZE = 16;
const DAMAGE_BONUS_BRACKET_DIE = '1d6';
function damageBonusNotation(strPlusSiz) {
const band = DAMAGE_BONUS_BANDS.find((b) => strPlusSiz >= b.min && strPlusSiz <= b.max);
if (band) return band.notation;
if (strPlusSiz < 1) return '-1d4';
const extraBrackets = Math.ceil((strPlusSiz - 56) / DAMAGE_BONUS_BRACKET_SIZE);
const parts = ['2d6'];
for (let i = 0; i < extraBrackets; i++) parts.push(DAMAGE_BONUS_BRACKET_DIE);
return parts.join('+');
}
function rollDamageBonus(strPlusSiz) {
const notation = damageBonusNotation(strPlusSiz);
if (notation === '0') return 0;
if (notation.startsWith('-')) return -rollNotation(notation.slice(1));
return notation.split('+').reduce((sum, part) => sum + rollNotation(part), 0);
}
// ---------- Skill Results (success tiers) ----------
// Exact rulebook bands - not a clean formula, so transcribed as data rather than computed.
// crit/spec are the upper bound of a 01-N range; fumble is the lower bound of an N-100 range.
const SKILL_RESULT_BANDS = [
{ skillMax: 7, crit: 1, spec: 1, fumbleMin: 96 },
{ skillMax: 10, crit: 1, spec: 2, fumbleMin: 96 },
{ skillMax: 12, crit: 1, spec: 2, fumbleMin: 97 },
{ skillMax: 17, crit: 1, spec: 3, fumbleMin: 97 },
{ skillMax: 22, crit: 1, spec: 4, fumbleMin: 97 },
{ skillMax: 27, crit: 1, spec: 5, fumbleMin: 97 },
{ skillMax: 29, crit: 1, spec: 6, fumbleMin: 97 },
{ skillMax: 30, crit: 2, spec: 6, fumbleMin: 97 },
{ skillMax: 32, crit: 2, spec: 6, fumbleMin: 98 },
{ skillMax: 37, crit: 2, spec: 7, fumbleMin: 98 },
{ skillMax: 42, crit: 2, spec: 8, fumbleMin: 98 },
{ skillMax: 47, crit: 2, spec: 9, fumbleMin: 98 },
{ skillMax: 49, crit: 2, spec: 10, fumbleMin: 98 },
{ skillMax: 50, crit: 3, spec: 10, fumbleMin: 98 },
{ skillMax: 52, crit: 3, spec: 10, fumbleMin: 99 },
{ skillMax: 57, crit: 3, spec: 11, fumbleMin: 99 },
{ skillMax: 62, crit: 3, spec: 12, fumbleMin: 99 },
{ skillMax: 67, crit: 3, spec: 13, fumbleMin: 99 },
{ skillMax: 69, crit: 3, spec: 14, fumbleMin: 99 },
{ skillMax: 70, crit: 4, spec: 14, fumbleMin: 99 },
{ skillMax: 72, crit: 4, spec: 14, fumbleMin: 100 },
{ skillMax: 77, crit: 4, spec: 15, fumbleMin: 100 },
{ skillMax: 82, crit: 4, spec: 16, fumbleMin: 100 },
{ skillMax: 87, crit: 4, spec: 17, fumbleMin: 100 },
{ skillMax: 89, crit: 4, spec: 18, fumbleMin: 100 },
{ skillMax: 92, crit: 5, spec: 18, fumbleMin: 100 },
{ skillMax: 97, crit: 5, spec: 19, fumbleMin: 100 },
{ skillMax: Infinity, crit: 5, spec: 20, fumbleMin: 100 },
];
function skillResultBand(skillPercent) {
const clamped = Math.max(1, skillPercent);
return SKILL_RESULT_BANDS.find((b) => clamped <= b.skillMax) || SKILL_RESULT_BANDS[SKILL_RESULT_BANDS.length - 1];
}
// Resolves a skill check into a tier. Returns { roll, skillPercent, tier }
// tier is one of: 'critical' | 'special' | 'success' | 'failure' | 'fumble'
function resolveSkillCheck(skillPercent) {
const band = skillResultBand(skillPercent);
const roll = rollPercentile();
let tier;
if (roll <= band.crit) tier = 'critical';
else if (roll <= band.spec) tier = 'special';
else if (roll <= skillPercent) tier = 'success';
else if (roll >= band.fumbleMin) tier = 'fumble';
else tier = 'failure';
return { roll, skillPercent, tier };
}
// ---------- Weapon type classification ----------
// 'impale' | 'slash' | 'crush' | 'dual' (declared per-attack) | 'none' (no special-tier bonus)
// 'dual' weapons resolve to 'impale' or 'slash' based on the declared mode for that attack.
const WEAPON_TYPE = {
IMPALE: 'impale',
SLASH: 'slash',
CRUSH: 'crush',
DUAL: 'dual',
NONE: 'none',
};
// ---------- Melee Weapons ----------
// category, weapon, damage, strMin, dexMin, enc, skillPercent (base), ap, sr, type
const MELEE_WEAPONS = [
{ category: 'Axe, 1H', weapon: 'Battleaxe', damage: '1d8+2', strMin: 13, dexMin: 9, enc: 1.0, skillPercent: 10, ap: 8, sr: 2, type: WEAPON_TYPE.SLASH },
{ category: 'Axe, 1H', weapon: 'Hatchet', damage: '1d6+1', strMin: 7, dexMin: 9, enc: 0.5, skillPercent: 10, ap: 6, sr: 2, type: WEAPON_TYPE.SLASH },
{ category: 'Axe, 2H', weapon: 'Battleaxe', damage: '1d8+2', strMin: 9, dexMin: 9, enc: 1.0, skillPercent: 5, ap: 8, sr: 2, type: WEAPON_TYPE.SLASH },
{ category: 'Axe, 2H', weapon: 'Great Axe', damage: '2d6+2', strMin: 11, dexMin: 9, enc: 2.0, skillPercent: 5, ap: 10, sr: 1, type: WEAPON_TYPE.SLASH },
{ category: 'Axe, 2H', weapon: 'Halberd', damage: '3d6', impaleDamage: '4d6', strMin: 13, dexMin: 9, enc: 3.0, skillPercent: 5, ap: 10, sr: 1, type: WEAPON_TYPE.DUAL },
{ category: 'Axe, 2H', weapon: 'Poleaxe', damage: '3d6', strMin: 11, dexMin: 9, enc: 2.5, skillPercent: 5, ap: 10, sr: 1, type: WEAPON_TYPE.SLASH },
{ category: 'Dagger', weapon: 'Dagger', damage: '1d4+2', strMin: null, dexMin: null, enc: 0.5, skillPercent: 15, ap: 6, sr: 3, type: WEAPON_TYPE.IMPALE },
{ category: 'Dagger', weapon: 'Knife', damage: '1d3+1', strMin: null, dexMin: null, enc: 0.2, skillPercent: 15, ap: 4, sr: 3, type: WEAPON_TYPE.IMPALE },
{ category: 'Dagger', weapon: 'Main Gauche', damage: '1d4+2', strMin: null, dexMin: 9, enc: 0.5, skillPercent: 10, ap: 10, sr: 3, type: WEAPON_TYPE.IMPALE },
{ category: 'Dagger', weapon: 'Sai', damage: '1d6', strMin: null, dexMin: 11, enc: 1.0, skillPercent: 5, ap: 10, sr: 2, type: WEAPON_TYPE.IMPALE },
{ category: 'Fist', weapon: 'Cestus, Heavy', damage: '1d3+2', strMin: 11, dexMin: null, enc: 1.5, skillPercent: 15, ap: 8, sr: 3, type: WEAPON_TYPE.CRUSH },
{ category: 'Fist', weapon: 'Cestus, Light', damage: '1d3+1', strMin: 7, dexMin: null, enc: 1.0, skillPercent: 15, ap: 4, sr: 3, type: WEAPON_TYPE.CRUSH },
{ category: 'Fist', weapon: 'Fighting Claw', damage: '1d4+1', strMin: 7, dexMin: 9, enc: 0.1, skillPercent: 15, ap: null, sr: 3, type: WEAPON_TYPE.SLASH },
{ category: 'Flail, 1H', weapon: 'Ball & Chain', damage: '1d10+1', strMin: 11, dexMin: 7, enc: 2.0, skillPercent: 5, ap: 8, sr: 2, type: WEAPON_TYPE.CRUSH },
{ category: 'Flail, 1H', weapon: 'Grain', damage: '1d6', strMin: 9, dexMin: null, enc: 1.0, skillPercent: 10, ap: 6, sr: 2, type: WEAPON_TYPE.CRUSH },
{ category: 'Flail, 1H', weapon: 'Three Chain', damage: '1d6+2', strMin: 9, dexMin: 13, enc: 2.0, skillPercent: 5, ap: 10, sr: 2, type: WEAPON_TYPE.CRUSH },
{ category: 'Flail, 2H', weapon: 'Military', damage: '2d6+2', strMin: 9, dexMin: null, enc: 2.5, skillPercent: 5, ap: 10, sr: 1, type: WEAPON_TYPE.CRUSH },
{ category: 'Hammer, 1H', weapon: 'Warhammer', damage: '1d6+2', strMin: 11, dexMin: 9, enc: 2.0, skillPercent: 10, ap: 8, sr: 2, type: WEAPON_TYPE.DUAL },
{ category: 'Hammer, 2H', weapon: 'Great Hammer', damage: '2d6+2', strMin: 9, dexMin: 9, enc: 2.5, skillPercent: 5, ap: 10, sr: 1, type: WEAPON_TYPE.DUAL },
{ category: 'Mace, 1H', weapon: 'Heavy Mace', damage: '1d10', strMin: 13, dexMin: 7, enc: 2.5, skillPercent: 15, ap: 10, sr: 2, type: WEAPON_TYPE.CRUSH },
{ category: 'Mace, 1H', weapon: 'Light Mace', damage: '1d8', strMin: 7, dexMin: 7, enc: 1.0, skillPercent: 15, ap: 6, sr: 2, type: WEAPON_TYPE.CRUSH },
{ category: 'Mace, 1H', weapon: 'Singlestick', damage: '1d6', strMin: 7, dexMin: 9, enc: 0.5, skillPercent: 15, ap: 5, sr: 2, type: WEAPON_TYPE.CRUSH },
{ category: 'Mace, 1H', weapon: 'Wooden Club', damage: '1d6', strMin: null, dexMin: 7, enc: 0.5, skillPercent: 15, ap: 4, sr: 2, type: WEAPON_TYPE.CRUSH },
{ category: 'Maul', weapon: 'Heavy Mace', damage: '1d10', strMin: 9, dexMin: 7, enc: 2.5, skillPercent: 10, ap: 10, sr: 2, type: WEAPON_TYPE.CRUSH },
{ category: 'Maul', weapon: 'Quarterstaff', damage: '1d8', strMin: 9, dexMin: 9, enc: 1.5, skillPercent: 10, ap: 8, sr: 1, type: WEAPON_TYPE.CRUSH },
{ category: 'Maul', weapon: 'Troll Maul', damage: '2d8', strMin: 17, dexMin: 7, enc: 5.5, skillPercent: 10, ap: 16, sr: 1, type: WEAPON_TYPE.CRUSH },
{ category: 'Maul', weapon: 'War Maul', damage: '1d10+2', strMin: 11, dexMin: 7, enc: 2.5, skillPercent: 10, ap: 12, sr: 1, type: WEAPON_TYPE.CRUSH },
{ category: 'Maul', weapon: 'Work Maul', damage: '2d6+2', strMin: 13, dexMin: 7, enc: 4.0, skillPercent: 10, ap: 12, sr: 2, type: WEAPON_TYPE.CRUSH },
{ category: 'Rapier', weapon: 'Rapier', damage: '1d6+1', strMin: 7, dexMin: 13, enc: 1.0, skillPercent: 5, ap: 8, sr: 2, type: WEAPON_TYPE.DUAL },
{ category: 'Shortsword', weapon: 'Gladius', damage: '1d6+1', strMin: null, dexMin: null, enc: 1.0, skillPercent: 10, ap: 10, sr: 2, type: WEAPON_TYPE.DUAL },
{ category: 'Shortsword', weapon: 'Kukri', damage: '1d4+3', strMin: null, dexMin: 11, enc: 0.5, skillPercent: 10, ap: 8, sr: 3, type: WEAPON_TYPE.SLASH },
{ category: 'Shield', weapon: 'Buckler', damage: '1d4', strMin: null, dexMin: 9, enc: 1.0, skillPercent: 5, ap: 8, sr: 3, type: WEAPON_TYPE.CRUSH, parryOnly: true },
{ category: 'Shield', weapon: 'Heater/Target', damage: '1d6', strMin: 9, dexMin: null, enc: 3.0, skillPercent: 15, ap: 12, sr: 3, type: WEAPON_TYPE.CRUSH, parryOnly: true },
{ category: 'Shield', weapon: 'Hoplite Shield', damage: '1d6', strMin: 12, dexMin: null, enc: 7.0, skillPercent: 15, ap: 18, sr: 3, type: WEAPON_TYPE.CRUSH, parryOnly: true },
{ category: 'Shield', weapon: 'Kite', damage: '1d6', strMin: 11, dexMin: null, enc: 5.0, skillPercent: 15, ap: 16, sr: 3, type: WEAPON_TYPE.CRUSH, parryOnly: true },
{ category: 'Shield', weapon: 'Viking Round', damage: '1d6', strMin: 9, dexMin: 7, enc: 4.0, skillPercent: 15, ap: 10, sr: 2, type: WEAPON_TYPE.CRUSH, parryOnly: true },
{ category: 'Spear, 1H', weapon: 'Javelin', damage: '1d6+1', strMin: 7, dexMin: 7, enc: 1.5, skillPercent: 5, ap: 8, sr: 2, type: WEAPON_TYPE.IMPALE },
{ category: 'Spear, 1H', weapon: 'Lance (mounted)', damage: '1d10+1', strMin: 7, dexMin: 7, enc: 3.5, skillPercent: 5, ap: 10, sr: 0, type: WEAPON_TYPE.IMPALE, noParry: true },
{ category: 'Spear, 1H', weapon: 'Pilum', damage: '1d6+1', strMin: 9, dexMin: 7, enc: 2.0, skillPercent: 5, ap: 10, sr: 2, type: WEAPON_TYPE.IMPALE },
{ category: 'Spear, 1H', weapon: 'Short Spear', damage: '1d8+1', strMin: 7, dexMin: 7, enc: 2.0, skillPercent: 5, ap: 10, sr: 2, type: WEAPON_TYPE.IMPALE },
{ category: 'Spear, 2H', weapon: 'Long Spear', damage: '1d10+1', strMin: 9, dexMin: 7, enc: 2.0, skillPercent: 15, ap: 10, sr: 1, type: WEAPON_TYPE.IMPALE },
{ category: 'Spear, 2H', weapon: 'Naginata', damage: '2d6+2', strMin: 7, dexMin: 11, enc: 2.0, skillPercent: 5, ap: 10, sr: 1, type: WEAPON_TYPE.IMPALE },
{ category: 'Spear, 2H', weapon: 'Pike', damage: '2d6+2', strMin: 11, dexMin: 7, enc: 3.5, skillPercent: 15, ap: 12, sr: 0, type: WEAPON_TYPE.IMPALE },
{ category: 'Spear, 2H', weapon: 'Short Spear', damage: '1d8+1', strMin: null, dexMin: 7, enc: 2.0, skillPercent: 15, ap: 10, sr: 2, type: WEAPON_TYPE.IMPALE },
{ category: 'Sword, 1H', weapon: 'Bastard Sword', damage: '1d10+1', strMin: 13, dexMin: 9, enc: 2.0, skillPercent: 10, ap: 12, sr: 2, type: WEAPON_TYPE.SLASH },
{ category: 'Sword, 1H', weapon: 'Broadsword', damage: '1d8+1', strMin: 9, dexMin: 7, enc: 1.5, skillPercent: 10, ap: 10, sr: 2, type: WEAPON_TYPE.DUAL },
{ category: 'Sword, 1H', weapon: 'Scimitar', damage: '1d6+2', strMin: 7, dexMin: 11, enc: 1.5, skillPercent: 10, ap: 10, sr: 2, type: WEAPON_TYPE.DUAL },
{ category: 'Sword, 2H', weapon: 'Bastard Sword', damage: '1d10+1', strMin: 9, dexMin: 9, enc: 2.0, skillPercent: 5, ap: 12, sr: 2, type: WEAPON_TYPE.SLASH },
{ category: 'Sword, 2H', weapon: 'Greatsword', damage: '2d8', strMin: 11, dexMin: 13, enc: 3.5, skillPercent: 5, ap: 12, sr: 1, type: WEAPON_TYPE.SLASH },
{ category: 'Tools', weapon: 'Hoe (2H)', damage: '1d6', strMin: 7, dexMin: 7, enc: 2.0, skillPercent: 10, ap: 8, sr: 1, type: WEAPON_TYPE.CRUSH, separateSkill: true },
{ category: 'Tools', weapon: 'Scythe', damage: '2d6', strMin: 11, dexMin: 9, enc: 2.5, skillPercent: 10, ap: 8, sr: 1, type: WEAPON_TYPE.SLASH, separateSkill: true },
{ category: 'Tools', weapon: 'Sickle (1H)', damage: '1d6', strMin: null, dexMin: null, enc: 0.5, skillPercent: 5, ap: 6, sr: 3, type: WEAPON_TYPE.DUAL, separateSkill: true },
{ category: 'Tools', weapon: 'Spade (2H)', damage: '1d6+2', strMin: 7, dexMin: 7, enc: 1.5, skillPercent: 5, ap: 8, sr: 2, type: WEAPON_TYPE.CRUSH, separateSkill: true },
];
// ---------- Natural Weapons ----------
const NATURAL_WEAPONS = [
{ weapon: 'Claw', damage: '1d6', skillPercent: 25, sr: 3, type: WEAPON_TYPE.SLASH },
{ weapon: 'Fist', damage: '1d3', skillPercent: 25, sr: 3, type: WEAPON_TYPE.CRUSH },
{ weapon: 'Grapple', damage: '1d6', skillPercent: 25, sr: 3, type: WEAPON_TYPE.CRUSH },
{ weapon: 'Head Butt', damage: '1d4', skillPercent: 10, sr: 3, type: WEAPON_TYPE.CRUSH },
{ weapon: 'Kick', damage: '1d6', skillPercent: 15, sr: 3, type: WEAPON_TYPE.CRUSH },
];
// ---------- Missile Weapons ----------
// rateOfFire: '1/SR' | '1/MR' | '1/2MR' | '1/3MR' etc.
const MISSILE_WEAPONS = [
{ weapon: 'Atlatl', strMin: 7, dexMin: 9, skillPercent: 5, enc: 0.5, damage: '1d6', damageNote: 'modifier, added to thrown weapon damage', ap: 6, rangeShort: null, rangeLong: 20, rateOfFire: '1/MR', type: WEAPON_TYPE.NONE },
{ weapon: 'Bow, Self', strMin: 9, dexMin: 9, skillPercent: 5, enc: 0.5, damage: '1d6+1', ap: 5, rangeShort: 90, rangeLong: 120, rateOfFire: '1/SR', type: WEAPON_TYPE.IMPALE },
{ weapon: 'Bow, Long', strMin: 11, dexMin: 9, skillPercent: 5, enc: 0.5, damage: '1d8+1', ap: 6, rangeShort: 90, rangeLong: 275, rateOfFire: '1/SR', type: WEAPON_TYPE.IMPALE },
{ weapon: 'Bow, Composite', strMin: 13, dexMin: 9, skillPercent: 5, enc: 0.5, damage: '1d8+1', ap: 7, rangeShort: 120, rangeLong: 225, rateOfFire: '1/SR', type: WEAPON_TYPE.IMPALE },
{ weapon: 'Crossbow, Heavy', strMin: 13, dexMin: 7, skillPercent: 25, enc: 8.0, damage: '2d6+2', ap: 10, rangeShort: 55, rangeLong: 300, rateOfFire: '1/3MR', type: WEAPON_TYPE.IMPALE },
{ weapon: 'Crossbow, Medium', strMin: 11, dexMin: 7, skillPercent: 25, enc: 4.8, damage: '2d4+2', ap: 8, rangeShort: 50, rangeLong: 270, rateOfFire: '1/2MR', type: WEAPON_TYPE.IMPALE },
{ weapon: 'Crossbow, Light', strMin: 9, dexMin: 7, skillPercent: 25, enc: 3.4, damage: '1d6+2', ap: 6, rangeShort: 40, rangeLong: 225, rateOfFire: '1/2MR', type: WEAPON_TYPE.IMPALE },
{ weapon: 'Repeater (12 shots)', strMin: 9, dexMin: 7, skillPercent: 25, enc: 3.2, damage: '1d6+2', ap: 6, rangeShort: 60, rangeLong: 170, rateOfFire: '1/SR', type: WEAPON_TYPE.IMPALE, reloadAfter12: 'DEX SR + 3' },
{ weapon: 'Stonebow', strMin: 11, dexMin: 7, skillPercent: 25, enc: 3.4, damage: '1d6+2', ap: 6, rangeShort: 30, rangeLong: 200, rateOfFire: '1/MR', type: WEAPON_TYPE.CRUSH },
{ weapon: 'Blowgun', strMin: null, dexMin: 11, skillPercent: 10, enc: 0.5, damage: '1d3', ap: 4, rangeShort: 30, rangeLong: 30, rateOfFire: '1/MR', type: WEAPON_TYPE.IMPALE, poisonNote: 'usually 2D10 potency' },
{ weapon: 'Sling', strMin: null, dexMin: 11, skillPercent: 5, enc: 0.1, damage: '1d8', ap: null, rangeShort: 100, rangeLong: 100, rateOfFire: '1/MR', type: WEAPON_TYPE.CRUSH },
{ weapon: 'Staff Sling', strMin: 9, dexMin: 11, skillPercent: 10, enc: 0.5, damage: '1d10', ap: 10, rangeShort: 120, rangeLong: 120, rateOfFire: '1/MR', type: WEAPON_TYPE.CRUSH },
{ weapon: 'Bolas', strMin: 9, dexMin: 13, skillPercent: 5, enc: 3.0, damage: null, ap: null, rangeShort: 15, rangeLong: 25, rateOfFire: '1/MR', type: WEAPON_TYPE.NONE },
{ weapon: 'Boomerang, War', strMin: 13, dexMin: 9, skillPercent: 10, enc: 1.0, damage: '1d8', ap: 6, rangeShort: 30, rangeLong: 50, rateOfFire: '1/MR', type: WEAPON_TYPE.NONE },
{ weapon: 'Boomerang, Hunting', strMin: 9, dexMin: 11, skillPercent: 5, enc: 0.5, damage: '1d4', ap: 3, rangeShort: 50, rangeLong: 50, rateOfFire: '1/SR', type: WEAPON_TYPE.NONE },
{ weapon: 'Dart', strMin: null, dexMin: 9, skillPercent: 10, enc: 0.5, damage: '1d6', ap: 4, rangeShort: 20, rangeLong: 30, rateOfFire: '1/SR', type: WEAPON_TYPE.IMPALE },
{ weapon: 'Javelin (thrown)', strMin: 9, dexMin: 9, skillPercent: 10, enc: 1.5, damage: '1d8', ap: 8, rangeShort: 20, rangeLong: 50, rateOfFire: '1/SR', type: WEAPON_TYPE.IMPALE },
{ weapon: 'Shuriken', strMin: null, dexMin: 13, skillPercent: 5, enc: 0.1, damage: '1d3', ap: null, rangeShort: 20, rangeLong: 30, rateOfFire: '1/SR', type: WEAPON_TYPE.IMPALE },
{ weapon: 'Throwing Axe', strMin: 9, dexMin: 11, skillPercent: 10, enc: 0.5, damage: '1d6', ap: 6, rangeShort: 20, rangeLong: 20, rateOfFire: '1/SR', type: WEAPON_TYPE.NONE },
{ weapon: 'Throwing Knife', strMin: null, dexMin: 11, skillPercent: 5, enc: 0.2, damage: '1d4', ap: 4, rangeShort: 20, rangeLong: 20, rateOfFire: '1/SR', type: WEAPON_TYPE.NONE },
{ weapon: 'Thrown Rock', strMin: null, dexMin: null, skillPercent: 15, enc: 0.5, damage: '1d3', ap: null, rangeShort: 20, rangeLong: 20, rateOfFire: '1/SR', type: WEAPON_TYPE.NONE },
{ weapon: 'Rope Lasso', strMin: 9, dexMin: 13, skillPercent: 5, enc: 1.0, damage: null, ap: null, rangeShort: 10, rangeLong: 10, rateOfFire: '1/5MR', type: WEAPON_TYPE.NONE },
{ weapon: 'Pole Lasso', strMin: 9, dexMin: 9, skillPercent: 20, enc: 3.0, damage: null, ap: 4, rangeShort: 3, rangeLong: 3, rateOfFire: '1/MR', type: WEAPON_TYPE.NONE },
{ weapon: 'Whip', strMin: 9, dexMin: 9, skillPercent: 10, enc: 1.0, damage: '1d4', ap: 6, rangeShort: 5, rangeLong: 5, rateOfFire: '1/MR', type: WEAPON_TYPE.NONE },
];
// ---------- Damage bonus (max, for Crush special hits) ----------
function maxDamageBonus(strPlusSiz) {
const notation = damageBonusNotation(strPlusSiz);
if (notation === '0') return 0;
if (notation.startsWith('-')) {
const m = /^(\d+)d(\d+)$/i.exec(notation.slice(1));
return m ? -parseInt(m[1], 10) : 0; // least-bad case for a penalty
}
return notation.split('+').reduce((sum, part) => sum + maxNotation(part), 0);
}
// ---------- Attack damage resolution ----------
// Critical is universal (Attack Results table): max weapon damage + damage bonus, ignores all armor.
// Special uses the weapon-type-specific Impale/Slash/Crush formula.
// Simple (success) is normal damage; knockback if it exceeds the target's SIZ.
// thrown: pass true to halve (round down) the rolled damage bonus, per RQ3 thrown-weapon rule.
function resolveWeaponEffectiveType(weapon, declaredMode) {
if (weapon.type === WEAPON_TYPE.DUAL) {
if (declaredMode !== WEAPON_TYPE.IMPALE && declaredMode !== WEAPON_TYPE.SLASH) {
throw new Error(`"${weapon.weapon}" is dual-mode; declaredMode must be 'impale' or 'slash'`);
}
return declaredMode;
}
return weapon.type;
}
function resolveAttackDamage({ weapon, tier, strPlusSiz, declaredMode, thrown = false }) {
const effectiveType = resolveWeaponEffectiveType(weapon, declaredMode);
let bonus = rollDamageBonus(strPlusSiz);
if (thrown) bonus = Math.floor(bonus / 2);
if (tier === 'fumble' || tier === 'failure') {
return { damage: 0, ignoresArmor: false, knockback: false, special: null };
}
if (tier === 'critical') {
const maxWeaponDamage = weapon.damage ? maxNotation(weapon.damage) : 0;
return { damage: maxWeaponDamage + bonus, ignoresArmor: true, knockback: true, special: null };
}
if (tier === 'special') {
if (effectiveType === WEAPON_TYPE.IMPALE) {
let damage;
if (weapon.impaleDamage) {
damage = rollNotation(weapon.impaleDamage) + bonus;
} else {
const rolled = weapon.damage ? rollNotation(weapon.damage) : 0;
const maxWeaponDamage = weapon.damage ? maxNotation(weapon.damage) : 0;
damage = rolled + bonus + maxWeaponDamage;
}
return { damage, ignoresArmor: false, knockback: true, special: 'impale' };
}
if (effectiveType === WEAPON_TYPE.SLASH) {
const damage = (weapon.damage ? rollNotation(weapon.damage) + rollNotation(weapon.damage) : 0) + bonus;
return { damage, ignoresArmor: false, knockback: true, special: 'slash' };
}
if (effectiveType === WEAPON_TYPE.CRUSH) {
const rolled = weapon.damage ? rollNotation(weapon.damage) : 0;
const damage = rolled + maxDamageBonus(strPlusSiz);
return { damage, ignoresArmor: false, knockback: true, special: 'crush' };
}
// NONE type (e.g. thrown axe/knife/boomerang/rock): no special-tier bonus, behaves as Simple.
const rolled = weapon.damage ? rollNotation(weapon.damage) : 0;
return { damage: rolled + bonus, ignoresArmor: false, knockback: true, special: null };
}
// tier === 'success' (Simple)
const rolled = weapon.damage ? rollNotation(weapon.damage) : 0;
const damage = rolled + bonus;
return { damage, ignoresArmor: false, knockback: null, special: null }; // caller compares damage to target SIZ for knockback
}
// ---------- Impale / Slash stuck-weapon follow-up ----------
function attemptWeaponRemoval(skillPercent, kind) {
const multiplier = kind === 'slash' ? 0.6 : 0.4; // Impale = 40% of skill, Slash = 60% of skill
const threshold = skillPercent * multiplier;
const band = skillResultBand(skillPercent);
const roll = rollPercentile();
if (roll >= band.fumbleMin) return { roll, success: false, weaponBreaks: true };
return { roll, success: roll <= threshold, weaponBreaks: false };
}
function removeStuckWeaponFromSelf(strPlusCon) {
return rollPercentile() <= strPlusCon;
}
function removeStuckWeaponWithFirstAid(firstAidSkillPercent) {
return rollPercentile() <= firstAidSkillPercent;
}
// ---------- Parry & Dodge ----------
// Per the Melee Sequence rules: a combatant may take 2 of {attack, parry, dodge} per round.
// Parry Results table effects don't gate on the attack's tier - each parry tier has its
// own fixed effect, and AP absorption naturally scales with how much damage gets through.
// critical: blocks all damage, from anything.
// special: absorbs AP like simple, plus an entangle/weapon-damage flavor effect (not
// mechanically resolved here - flagged for the caller/log to narrate).
// success ("simple"): absorbs the parrying item's AP; AP is reduced by 1 if damage exceeds it.
// failure: the attack hits normally.
// fumble: the attack hits normally, and the parrier rolls on the fumble table.
function resolveParry({ parrySkillPercent }) {
const check = resolveSkillCheck(parrySkillPercent);
const effect =
check.tier === 'critical' ? 'block-all' :
check.tier === 'special' ? 'absorb-ap-and-entangle' :
check.tier === 'success' ? 'absorb-ap' :
'attack-hits';
return { ...check, effect };
}
// Applies a parry's effect to incoming damage. parryingItemAp is the weapon/shield's AP.
// Returns { damageThrough, apReducedBy1 }.
function applyParryToDamage({ parryEffect, damage, parryingItemAp }) {
if (parryEffect === 'block-all') return { damageThrough: 0, apReducedBy1: false };
if (parryEffect === 'absorb-ap' || parryEffect === 'absorb-ap-and-entangle') {
const ap = parryingItemAp || 0;
const damageThrough = Math.max(0, damage - ap);
return { damageThrough, apReducedBy1: damage > ap };
}
return { damageThrough: damage, apReducedBy1: false }; // 'attack-hits'
}
function resolveDodge({ dodgeSkillPercent, attackTier }) {
const check = resolveSkillCheck(dodgeSkillPercent);
let avoided;
if (check.tier === 'fumble') avoided = false; // automatic normal hit unless rolled better (already lowest tier)
else if (check.tier === 'failure') avoided = false;
else if (attackTier === 'critical') avoided = check.tier === 'critical';
else if (attackTier === 'special') avoided = check.tier === 'critical' || check.tier === 'special';
else avoided = true; // success/special/critical dodge all avoid a normal (simple) attack
return { ...check, avoided };
}
// ---------- Attack Modifiers (situational, additive to skill %) ----------
const ATTACK_MODIFIERS = [
{ id: 'target-helpless', modifier: 25, description: 'Target helpless' },
{ id: 'target-surprised-noncombat', modifier: 20, description: 'Target surprised during non-combat, or knocked down' },
{ id: 'target-surprised-combat', modifier: 10, description: 'Target surprised during combat' },
{ id: 'unshielded-side-or-behind', modifier: 10, description: "Attack from target's unshielded side or from behind" },
{ id: 'prepared-attack', modifier: 10, description: 'Prepared attack (wait one MR)' },
{ id: 'attacking-from-above', modifier: 10, description: 'Attacking from above target' },
{ id: 'target-large', modifier: 5, perSiz: 10, sizThreshold: 10, direction: 'over', description: 'Target is above SIZ 10 (+5 per 10 SIZ over)' },
{ id: 'target-unseen', modifier: -75, description: 'Target cannot be seen or sensed' },
{ id: 'attacker-knocked-down', modifier: -20, description: 'Attacker has been knocked down' },
{ id: 'target-moving-missile', modifier: -10, description: 'Target moving (missile weapon only)' },
{ id: 'target-small', modifier: -10, perSiz: 1, sizThreshold: 4, direction: 'under', description: 'Target is below SIZ 4 (-10 per SIZ under)' },
{ id: 'attacker-mounted-moving', modifier: -10, description: 'Attacker is riding a moving animal' },
];
// Sums the flat modifiers for the given ids, plus any per-SIZ modifiers scaled by the
// target's actual SIZ (for 'target-large'/'target-small'). Returns the net skill % delta.
function sumAttackModifiers(selectedIds, { targetSiz } = {}) {
return selectedIds.reduce((total, id) => {
const mod = ATTACK_MODIFIERS.find((m) => m.id === id);
if (!mod) return total;
if (!mod.perSiz) return total + mod.modifier;
if (targetSiz == null) return total;
const delta = mod.direction === 'over' ? targetSiz - mod.sizThreshold : mod.sizThreshold - targetSiz;
if (delta <= 0) return total;
return total + mod.modifier * Math.ceil(delta / mod.perSiz);
}, 0);
}
// ---------- Character Culture & Cultural Weapon Bonuses ----------
const CHARACTER_CULTURES = [
{ min: 1, max: 1, culture: 'Primitive' },
{ min: 2, max: 3, culture: 'Nomad' },
{ min: 4, max: 6, culture: 'Barbarian' },
{ min: 7, max: 8, culture: 'Civilized' },
];
function rollCulture() {
const roll = rollDie(8);
return CHARACTER_CULTURES.find((c) => roll >= c.min && roll <= c.max).culture;
}
// Starting % bonuses by culture. "attackParry" applies to both attack and parry skill with
// that weapon/category; "attackOnly" and "parryOnly" apply to just the one.
const CULTURAL_WEAPON_BONUSES = {
Primitive: {
attackParry: [
{ categories: ['Spear, 1H', 'Spear, 2H'], bonus: 25 },
{ categories: ['Axe, 1H', 'Mace, 1H'], bonus: 25 },
],
attackOnly: [
{ categories: ['Javelin', 'Boomerang'], bonus: 20 },
{ categories: ['Sling'], bonus: 25 },
{ categories: ['Bow, Self'], bonus: 25 },
],
parryOnly: [
{ categories: ['Buckler', 'Heater/Target'], bonus: 25 },
],
},
Nomad: {
attackParry: [
{ categories: ['Axe, 1H', 'Mace, 1H', 'Spear, 1H', 'Sword, 1H'], bonus: 20 },
],
attackOnly: [
{ categories: ['Lance (mounted)'], bonus: 30 },
{ categories: ['Bow, Self', 'Bow, Long', 'Bow, Composite', 'Javelin'], bonus: 20 },
],
parryOnly: [
{ categories: ['Buckler', 'Heater/Target'], bonus: 20 },
],
},
Barbarian: {
attackParry: [
{ categories: ['Spear, 1H', 'Spear, 2H'], bonus: 25 },
{ categories: ['Axe, 1H', 'Mace, 1H', 'Sword, 1H'], bonus: 25 },
{ categories: ['Axe, 2H', 'Sword, 2H'], bonus: 15 },
],
attackOnly: [
{ categories: ['Bow, Self', 'Bow, Long', 'Bow, Composite', 'Javelin'], bonus: 25 },
],
parryOnly: [
{ categories: ['Buckler', 'Kite', 'Viking Round'], bonus: 25 }, // any shield except Heater/Target & Hoplite
],
},
Civilized: {
attackParry: [
{ categories: ['Sword, 1H'], weapons: ['Broadsword', 'Rapier', 'Scimitar'], bonus: 25 },
{ categories: ['Shortsword'], bonus: 25 },
{ categories: ['Spear, 1H', 'Spear, 2H'], bonus: 20 },
{ categories: ['Axe, 2H', 'Sword, 2H'], bonus: 15 },
],
attackOnly: [
{ categories: ['Crossbow, Heavy', 'Crossbow, Medium', 'Crossbow, Light', 'Sling'], bonus: 25 },
],
parryOnly: [
{ categories: ['Dagger'], weapons: ['Main Gauche'], bonus: 25 },
{ categories: ['Buckler', 'Heater/Target', 'Kite', 'Hoplite Shield'], bonus: 25 },
],
},
};
// Returns the attack/parry bonus a culture grants for a given weapon (category + name), if any.
function culturalWeaponBonus(culture, category, weaponName) {
const rules = CULTURAL_WEAPON_BONUSES[culture];
if (!rules) return { attack: 0, parry: 0 };
const matches = (entry) =>
entry.categories.includes(category) && (!entry.weapons || entry.weapons.includes(weaponName));
const attackParry = rules.attackParry.find(matches);
const attackOnly = rules.attackOnly.find(matches);
const parryOnly = rules.parryOnly.find(matches);
return {
attack: (attackParry && attackParry.bonus) || (attackOnly && attackOnly.bonus) || 0,
parry: (attackParry && attackParry.bonus) || (parryOnly && parryOnly.bonus) || 0,
};
}
// ---------- Armor ----------
// AP per location, and ENC/cost by adventurer size band (Small 6-10, Medium 11-15, Large 16-20, Troll 21-25).
const ARMOR_TABLE = [
{ name: 'Clothes', ap: 0, costPerEnc: null, bySize: { small: { enc: 2.0, cost: 40 }, medium: { enc: 2.5, cost: 45 }, large: { enc: 3.0, cost: 50 }, troll: { enc: 3.5, cost: 60 } } },
{ name: 'Soft Leather', ap: 1, costPerEnc: 20, bySize: { small: { enc: 3.0, cost: 60 }, medium: { enc: 3.5, cost: 70 }, large: { enc: 4.0, cost: 80 }, troll: { enc: 5.0, cost: 100 } } },
{ name: 'Stiff Leather', ap: 2, costPerEnc: 20, bySize: { small: { enc: 4.0, cost: 80 }, medium: { enc: 5.0, cost: 100 }, large: { enc: 6.0, cost: 120 }, troll: { enc: 7.0, cost: 140 } } },
{ name: 'Cuirbouilli', ap: 3, costPerEnc: 45, bySize: { small: { enc: 4.0, cost: 180 }, medium: { enc: 5.0, cost: 225 }, large: { enc: 6.0, cost: 270 }, troll: { enc: 7.0, cost: 315 } } },
{ name: 'Bezainted', ap: 4, costPerEnc: 70, bySize: { small: { enc: 6.0, cost: 420 }, medium: { enc: 7.5, cost: 563 }, large: { enc: 9.0, cost: 630 }, troll: { enc: 10.5, cost: 735 } } },
{ name: 'Ringmail', ap: 5, costPerEnc: 110, bySize: { small: { enc: 8.0, cost: 880 }, medium: { enc: 10.0, cost: 1100 }, large: { enc: 12.0, cost: 1320 }, troll: { enc: 14.0, cost: 1540 } } },
{ name: 'Lamellar', ap: 6, costPerEnc: 200, bySize: { small: { enc: 14.0, cost: 2800 }, medium: { enc: 18.0, cost: 3600 }, large: { enc: 21.5, cost: 4300 }, troll: { enc: 25.0, cost: 5000 } } },
{ name: 'Scale', ap: 6, costPerEnc: 120, bySize: { small: { enc: 16.0, cost: 1920 }, medium: { enc: 20.0, cost: 2400 }, large: { enc: 24.0, cost: 2880 }, troll: { enc: 28.0, cost: 3360 } } },
{ name: 'Chainmail', ap: 7, costPerEnc: 240, bySize: { small: { enc: 16.0, cost: 3840 }, medium: { enc: 20.0, cost: 4800 }, large: { enc: 24.0, cost: 5760 }, troll: { enc: 28.0, cost: 6720 } } },
{ name: 'Brigandine', ap: 7, costPerEnc: 200, bySize: { small: { enc: 17.5, cost: 3500 }, medium: { enc: 22.0, cost: 4400 }, large: { enc: 26.5, cost: 5300 }, troll: { enc: 31.0, cost: 6200 } } },
{ name: 'Plate', ap: 8, costPerEnc: 270, bySize: { small: { enc: 20.0, cost: 5400 }, medium: { enc: 25.0, cost: 6750 }, large: { enc: 30.0, cost: 8100 }, troll: { enc: 35.0, cost: 9450 } } },
];
const ENC_PER_HIT_LOCATION = {
Head: 0.1,
'R-Arm': 0.1,
'L-Arm': 0.1,
Chest: 0.2,
Abdomen: 0.1,
'R-Leg': 0.2,
'L-Leg': 0.2,
};
function armorByName(name) {
return ARMOR_TABLE.find((a) => a.name === name) || null;
}
// ---------- Experience / Improvement ----------
// "Roll/Add" = roll the die, divide by the given divisor (rounded to nearest), that's the points gained.
// Experience and Research (marked * in the rulebook) require a prior successful experience-increase
// roll: roll d100, improvement only happens if the roll exceeds the current skill/characteristic value.
const EXPERIENCE_IMPROVEMENT = {
experience: { roll: '1d6', divisor: 3, time: '1 week', requiresSuccessfulCheck: true },
training: { roll: '1d6-2', divisor: 2, time: 'hours equal to skill %', requiresSuccessfulCheck: false },
research: { roll: '1d6-2', divisor: 1, time: 'hours equal to skill %', requiresSuccessfulCheck: true },
powGain: { roll: '1d3-1', divisor: 1, time: '1 week', requiresSuccessfulCheck: false },
characteristic: { roll: '1d3-1', divisor: 1, time: 'characteristic × 25 hours', requiresSuccessfulCheck: false },
};
function rollImprovementPoints(method) {
const def = EXPERIENCE_IMPROVEMENT[method];
if (!def) throw new Error(`Unknown improvement method "${method}"`);
const rolled = rollNotation(def.roll);
const points = Math.max(0, Math.round(rolled / def.divisor));
return { rolled, points };
}
function rollExperienceCheck(currentValue) {
const roll = rollPercentile();
return { roll, success: roll > currentValue };
}
// Applies one improvement attempt. For 'experience'/'research', gates on rollExperienceCheck first.
function applyImprovement(method, currentValue) {
const def = EXPERIENCE_IMPROVEMENT[method];
if (!def) throw new Error(`Unknown improvement method "${method}"`);
if (def.requiresSuccessfulCheck) {
const check = rollExperienceCheck(currentValue);
if (!check.success) {
return { method, checkRoll: check.roll, success: false, pointsGained: 0, newValue: currentValue };
}
const { rolled, points } = rollImprovementPoints(method);
return { method, checkRoll: check.roll, success: true, rolled, pointsGained: points, newValue: currentValue + points };
}
const { rolled, points } = rollImprovementPoints(method);
return { method, success: true, rolled, pointsGained: points, newValue: currentValue + points };
}
// ---------- Fumble tables ----------
const MELEE_PARRY_FUMBLE_TABLE = [
{ min: 1, max: 5, effect: 'Lose next parry' },
{ min: 6, max: 10, effect: 'Lose next attack' },
{ min: 11, max: 15, effect: 'Lose next attack & parry' },
{ min: 16, max: 20, effect: 'Lose next attack, parry, & Dodge' },
{ min: 21, max: 25, effect: 'Lose next 1D3 attacks' },
{ min: 26, max: 30, effect: 'Lose next 1D3 attacks & parries' },
{ min: 31, max: 35, effect: 'Shield strap breaks, shield falls' },
{ min: 36, max: 40, effect: 'Shield strap breaks, shield falls; also lose next attack' },
{ min: 41, max: 45, effect: 'Armor strap breaks, roll hit location' },
{ min: 46, max: 50, effect: 'Armor strap breaks, roll hit location; also lose next attack & parry' },
{ min: 51, max: 55, effect: 'Fall; lose parry & Dodge, take 1D3 rounds to get up' },
{ min: 56, max: 60, effect: 'Twist ankle: Movement rate halved for 5D10 rounds' },
{ min: 61, max: 63, effect: 'Twist ankle & fall (apply both 51-55 and 56-60)' },
{ min: 64, max: 67, effect: 'Vision impaired: -25% on attacks & parries, 1D3 rounds unengaged to fix' },
{ min: 68, max: 70, effect: 'Vision impaired: -50% on attacks & parries, 1D6 rounds unengaged to fix' },
{ min: 71, max: 72, effect: 'Vision blocked: lose all attacks and parries, 1D6 rounds to fix' },
{ min: 73, max: 74, effect: 'Distracted: foes attack/parry at +25% for next round' },
{ min: 75, max: 78, effect: 'Attack: weapon dropped (1D2 rounds to recover). Parry: parrying weapon/shield dropped (1D2 rounds to recover)' },
{ min: 79, max: 82, effect: 'Weapon or parrying shield knocked away 1D6 meters (1D8 direction), 1D3+1 rounds to recover' },
{ min: 83, max: 86, effect: 'Weapon or shield shatters: 100% if unenchanted, -10%/pt Spirit or Sorcery magic, -20%/pt Divine magic' },
{ min: 87, max: 89, effect: 'Attack: hit nearest friend (self if none). Parry: foe automatically hits' },
{ min: 90, max: 91, effect: 'Attack: hit nearest friend for maximum damage (self if none). Parry: foe automatically hits' },
{ min: 92, max: 92, effect: 'Attack: hit nearest friend critically (self if none). Parry: foe automatically hits (rolled damage)' },
{ min: 93, max: 95, effect: 'Attack: hit self (rolled damage). Parry: foe automatically hits' },
{ min: 96, max: 97, effect: 'Attack: hit self for maximum damage. Parry: foe automatically hits' },
{ min: 98, max: 98, effect: 'Attack: critical hit on self. Parry: foe scores a critical' },
{ min: 99, max: 99, effect: 'Roll twice on this table, apply both results', rollTwice: true },
{ min: 100, max: 100, effect: 'Roll three times on this table, apply all results', rollThrice: true },
];
const MISSILE_FUMBLE_TABLE = [
{ min: 1, max: 10, effect: 'Lose next attack' },
{ min: 11, max: 20, effect: 'Lose next 1D4 attacks' },
{ min: 21, max: 30, effect: 'Lose all activities for next 1D3 melee rounds' },
{ min: 31, max: 40, effect: 'Weapon strap breaks; lose melee weapon' },
{ min: 41, max: 50, effect: 'Armor strap breaks, roll hit location' },
{ min: 51, max: 60, effect: 'Armor strap breaks, roll hit location; also lose attack and parry next round' },
{ min: 61, max: 65, effect: 'Fall to ground' },
{ min: 66, max: 70, effect: 'Vision impaired; -50% on attacks for 1D3 rounds' },
{ min: 71, max: 73, effect: 'Vision blocked; cannot see for next 1D3 rounds' },
{ min: 74, max: 80, effect: 'Drop weapon; lands 1D6-1 meters away (1D8 direction)' },
{ min: 81, max: 85, effect: 'Weapon shatters (resolve as Melee/Parry fumble 83-86)' },
{ min: 86, max: 89, effect: 'Hit nearest friend; rolled damage. If no friend, resolve as 81-85', noFriendFallbackMax: 85 },
{ min: 90, max: 92, effect: 'Impale nearest friend; if no friend, resolve as 81-85', noFriendFallbackMax: 85 },
{ min: 93, max: 94, effect: 'Critical hit on nearest friend; if no friend, resolve as 81-85', noFriendFallbackMax: 85 },
{ min: 95, max: 98, effect: 'Roll twice on this table, apply both results', rollTwice: true },
{ min: 99, max: 100, effect: 'Roll three times on this table, apply all results', rollThrice: true },
];
const NATURAL_WEAPONS_FUMBLE_TABLE = [
{ min: 1, max: 5, effect: 'Lose next Dodge' },
{ min: 6, max: 10, effect: 'Lose next attack' },
{ min: 11, max: 15, effect: 'Lose next Dodge and parry' },
{ min: 16, max: 20, effect: 'Lose next Dodge, parry, and attack' },
{ min: 21, max: 25, effect: 'Lose Dodge, parry, and attack for next 1D3 melee rounds' },
{ min: 26, max: 30, effect: 'Lose next 1D6 attacks' },
{ min: 31, max: 35, effect: 'Armor strap breaks; roll hit location' },
{ min: 36, max: 40, effect: 'Armor strap breaks; roll hit location; also lose next round as per 21-25' },
{ min: 41, max: 50, effect: 'Fall; lose Dodge and parry this round' },
{ min: 51, max: 60, effect: 'Fall and twist ankle; lose 1 meter of Movement per melee round for 5D10 rounds' },
{ min: 61, max: 70, effect: 'Vision impaired: -25% on attacks & parries, 1D3 rounds unengaged to fix' },
{ min: 71, max: 73, effect: 'Vision impaired: -50% on attacks & parries, 1D4 rounds unengaged to fix' },
{ min: 74, max: 75, effect: 'Vision blocked; blind for 1D3 rounds' },
{ min: 76, max: 80, effect: 'Distracted; all foes +25% attack next round' },
{ min: 81, max: 85, effect: 'Strain muscle; lose 1 HP in attacking limb and 3 Fatigue points' },
{ min: 86, max: 90, effect: 'Hit nearest friend, rolled damage. If no friend, resolve as 81-85', noFriendFallbackMax: 85 },
{ min: 91, max: 94, effect: 'Hit nearest friend, maximum damage. If no friend, resolve as 81-85', noFriendFallbackMax: 85 },
{ min: 95, max: 96, effect: 'Hit nearest friend, critical damage. If no friend, resolve as 81-85', noFriendFallbackMax: 85 },
{ min: 97, max: 98, effect: 'Hit self; maximum rolled damage' },
{ min: 99, max: 99, effect: 'Roll twice, apply both results', rollTwice: true },
{ min: 100, max: 100, effect: 'Roll three times, apply all results', rollThrice: true },
];
const FUMBLE_TABLES = {
meleeParry: MELEE_PARRY_FUMBLE_TABLE,
missile: MISSILE_FUMBLE_TABLE,
natural: NATURAL_WEAPONS_FUMBLE_TABLE,
};
function rollFumble(tableName, _depth = 0) {
const table = FUMBLE_TABLES[tableName];
if (!table) throw new Error(`Unknown fumble table "${tableName}"`);
const roll = rollPercentile();
const entry = table.find((e) => roll >= e.min && roll <= e.max);
const results = [{ roll, effect: entry.effect }];
if (_depth < 5 && entry.rollTwice) {
results.push(...rollFumble(tableName, _depth + 1).results);
} else if (_depth < 5 && entry.rollThrice) {
results.push(...rollFumble(tableName, _depth + 1).results);
results.push(...rollFumble(tableName, _depth + 1).results);
}
return { results };
}
// ---------- Hit location disable effects ----------
// From the original spec's mechanical hit-location table; not superseded by the gamesheet,
// which doesn't redefine these. Head injury is checked only when armor was penetrated.
const LOCATION_DISABLE_EFFECTS = {
'R-Arm': 'attack-skill-penalty-20',
'L-Arm': 'no-shield-or-offhand',
'R-Leg': 'skip-next-action',
'L-Leg': 'skip-next-action',
};
function locationDisabledEffect(locationName) {
return LOCATION_DISABLE_EFFECTS[locationName] || null;
}
// Roll on armor-penetrating Head damage: CON×5 roll to avoid Dazed/Stunned (GM d100 picks which).
function resolveHeadInjury(con) {
const roll = rollPercentile();
const conX5 = con * 5;
if (roll <= conX5) return { roll, conX5, outcome: 'ok' };
const subRoll = rollPercentile();
const outcome = subRoll <= 50 ? 'dazed' : 'stunned';
return { roll, conX5, outcome, subRoll };
}
// ---------- Damage application ----------
function applyDamageToLocation({ hitLocation, totalHp, damage, ignoresArmor }) {
const effectiveDamage = ignoresArmor ? damage : Math.max(0, damage - (hitLocation.armor_ap || 0));
const newLocationHp = hitLocation.current_hp - effectiveDamage;
const newTotalHp = totalHp - effectiveDamage;
const wasDisabled = !!hitLocation.disabled;
const disabled = wasDisabled || newLocationHp <= 0;
return {
effectiveDamage,
newLocationHp,
newTotalHp,
disabled,
justDisabled: disabled && !wasDisabled,
disableEffect: disabled ? locationDisabledEffect(hitLocation.location_name) : null,
};
}
function checkIncapacitation({ totalHp, con }) {
if (totalHp <= -con) return 'dead';
if (totalHp <= 0) return 'unconscious';
return 'conscious';
}
// ---------- Spirit/Battle Magic mechanics ----------
// Known spells cast automatically (no roll) and just spend MP. POW×5 is only rolled when an
// effect is resisted by a target (e.g. Demoralize).
function powVsPowChance(activePow, passivePow) {
return Math.max(5, Math.min(95, 50 + (activePow - passivePow) * 5));
}
const SPELL_MECHANICS = {
bladesharp: { name: 'Bladesharp', minMp: 1, maxMp: 4, resisted: false, effect: (mp) => ({ damageBonus: mp }) },
protection: { name: 'Protection', minMp: 1, maxMp: 4, resisted: false, effect: (mp) => ({ armorBonusAllLocations: mp }) },
heal: { name: 'Heal', minMp: 1, maxMp: 3, resisted: false, effect: (mp) => ({ healHp: mp }) },
disruption: { name: 'Disruption', minMp: 1, maxMp: 1, resisted: false, effect: () => ({ damage: rollNotation('1d3'), ignoresArmor: true }) },
demoralize: { name: 'Demoralize', minMp: 2, maxMp: 2, resisted: true, effect: () => ({ skillPenaltyPercent: -20, duration: 'next round' }) },
coordination: { name: 'Coordination', minMp: 1, maxMp: 2, resisted: false, effect: (mp) => ({ strikeRankBonus: mp }) },
};
function castSpell(mechanicId, mpSpent, { casterPow, targetPow } = {}) {
const def = SPELL_MECHANICS[mechanicId];
if (!def) throw new Error(`Unknown spell mechanic "${mechanicId}"`);
const mp = Math.max(def.minMp, Math.min(def.maxMp, mpSpent));
const result = { mechanicId, name: def.name, mpSpent: mp, ...def.effect(mp) };
if (def.resisted) {
const chance = casterPow != null && targetPow != null ? powVsPowChance(casterPow, targetPow) : 50;
const roll = rollPercentile();
result.resistChance = chance;
result.resistRoll = roll;
result.resisted = roll > chance;
}
return result;
}
function regenerateMp(current, max, amount = 1) {
return Math.min(max, current + amount);
}
// ---------- Turn scheduling ----------
// 10 strike ranks per melee round (Melee Sequence). Groups declared actions by SR, lowest first.
function buildStrikeRankSchedule(actions) {
const schedule = {};
for (let sr = 1; sr <= 10; sr++) schedule[sr] = [];
for (const action of actions) {
const sr = Math.min(10, Math.max(1, action.strikeRank));
schedule[sr].push(action);
}
return schedule;
}
module.exports = {
rollDie,
rollDice,
rollNotation,
maxNotation,
rollPercentile,
rollCharacteristics,
dexStrikeRank,
sizStrikeRankModifier,
baseStrikeRank,
computeHitLocations,
rollHitLocation,
SHIELD_COVERAGE,
damageBonusNotation,
rollDamageBonus,
resolveSkillCheck,
WEAPON_TYPE,
MELEE_WEAPONS,
NATURAL_WEAPONS,
MISSILE_WEAPONS,
maxDamageBonus,
resolveWeaponEffectiveType,
resolveAttackDamage,
attemptWeaponRemoval,
removeStuckWeaponFromSelf,
removeStuckWeaponWithFirstAid,
resolveParry,
applyParryToDamage,
resolveDodge,
ATTACK_MODIFIERS,
sumAttackModifiers,
CHARACTER_CULTURES,
rollCulture,
CULTURAL_WEAPON_BONUSES,
culturalWeaponBonus,
ARMOR_TABLE,
ENC_PER_HIT_LOCATION,
armorByName,
EXPERIENCE_IMPROVEMENT,
rollImprovementPoints,
rollExperienceCheck,
applyImprovement,
FUMBLE_TABLES,
rollFumble,
locationDisabledEffect,
resolveHeadInjury,
applyDamageToLocation,
checkIncapacitation,
powVsPowChance,
SPELL_MECHANICS,
castSpell,
regenerateMp,
buildStrikeRankSchedule,
};