Files
story_builder/rq3.js
T
2026-06-30 09:10:05 +10:00

948 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
};