// 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; } const DELIBERATE_METHOD = { totalPoints: 80, minSiz: 8, minInt: 8, minOther: 6, maxAny: 18, }; function validateDeliberate(chars) { const vals = { STR: chars.str, CON: chars.con, SIZ: chars.siz, INT: chars.int, POW: chars.pow, DEX: chars.dex, APP: chars.app }; const errors = []; const total = Object.values(vals).reduce((a, b) => a + b, 0); if (total !== DELIBERATE_METHOD.totalPoints) errors.push(`Total must be exactly 80 (got ${total})`); if (vals.SIZ < DELIBERATE_METHOD.minSiz) errors.push(`SIZ minimum is 8 (got ${vals.SIZ})`); if (vals.INT < DELIBERATE_METHOD.minInt) errors.push(`INT minimum is 8 (got ${vals.INT})`); for (const k of ['STR', 'CON', 'POW', 'DEX', 'APP']) { if (vals[k] < DELIBERATE_METHOD.minOther) errors.push(`${k} minimum is 6 (got ${vals[k]})`); } for (const [k, v] of Object.entries(vals)) { if (v > DELIBERATE_METHOD.maxAny) errors.push(`${k} cannot exceed 18 (got ${v})`); } return { valid: errors.length === 0, errors }; } function validateCombined(chars) { const vals = { STR: chars.str, CON: chars.con, SIZ: chars.siz, INT: chars.int, POW: chars.pow, DEX: chars.dex, APP: chars.app }; const errors = []; const total = Object.values(vals).reduce((a, b) => a + b, 0); if (total > 91) errors.push(`Total cannot exceed 91 (got ${total})`); for (const [k, v] of Object.entries(vals)) { if (v > 18) errors.push(`${k} cannot exceed 18 (got ${v})`); } return { valid: errors.length === 0, errors }; } // ---------- Skills Category Modifiers ---------- // Primary: +1% per point over 10, -1% per point under 10. // Secondary: +1% per 2 points over 10 (max +10%), -1% per 2 points under 10, using ceil. // Negative: inverse of primary. function _primary(c) { return c - 10; } function _secondary(c) { const diff = c - 10; if (diff === 0) return 0; const raw = Math.sign(diff) * Math.ceil(Math.abs(diff) / 2); return Math.min(raw, 10); // cap positive at +10%, no cap on negative } function _negative(c) { return 10 - c; } function computeSkillCategoryModifiers(chars) { const { str, con, siz, int, pow, dex, app } = chars; const agility = _primary(dex) + _secondary(str) + _negative(siz); const communication = _primary(int) + _secondary(pow) + _secondary(app); const knowledge = _primary(int); const magic = _primary(int) + _primary(pow) + _secondary(dex); const manipulation = _primary(int) + _primary(dex) + _secondary(str); const perception = _primary(int) + _secondary(pow) + _secondary(con); const stealth = _primary(dex) + _negative(siz) + _negative(pow); return { agility, communication, knowledge, magic, manipulation, perception, stealth, attack: manipulation, // attack modifier = manipulation modifier parry: agility, // parry modifier = agility modifier }; } function deriveCharacterStats(chars) { // RQ3: total HP = ceil((CON + SIZ) / 2), NOT CON + SIZ const totalHp = Math.ceil((chars.con + chars.siz) / 2); const skillModifiers = computeSkillCategoryModifiers(chars); return { totalHp, fatigue: chars.str + chars.con, magicPoints: chars.pow, damageBonus: damageBonusNotation(chars.str + chars.siz), strikeRank: baseStrikeRank({ dex: chars.dex, siz: chars.siz }), hitLocations: computeHitLocations(totalHp), skillModifiers, }; } // ---------- Base Skills ---------- // Skills available without training, derived from base chance + category modifier. // Skills with a 0 base chance are excluded (they must be trained before the modifier applies). const BASE_SKILLS = { // Agility boat: 5, climb: 40, dodge: 5, jump: 25, ride: 5, swim: 15, throw: 25, // Communication fastTalk: 5, orate: 5, sing: 5, // Knowledge firstAid: 10, animalLore: 5, humanLore: 5, mineralLore: 5, plantLore: 5, worldLore: 5, // Manipulation conceal: 5, sleight: 5, devise: 5, // Perception listen: 25, scan: 25, search: 25, track: 5, // Stealth hide: 10, sneak: 10, }; const SKILL_CATEGORY_MAP = { boat: 'agility', climb: 'agility', dodge: 'agility', jump: 'agility', ride: 'agility', swim: 'agility', throw: 'agility', fastTalk: 'communication', orate: 'communication', sing: 'communication', firstAid: 'knowledge', animalLore: 'knowledge', humanLore: 'knowledge', mineralLore: 'knowledge', plantLore: 'knowledge', worldLore: 'knowledge', conceal: 'manipulation', sleight: 'manipulation', devise: 'manipulation', listen: 'perception', scan: 'perception', search: 'perception', track: 'perception', hide: 'stealth', sneak: 'stealth', }; function computeBaseSkills(chars) { const mods = computeSkillCategoryModifiers(chars); const result = {}; for (const [skill, base] of Object.entries(BASE_SKILLS)) { result[skill] = Math.max(0, base + (mods[SKILL_CATEGORY_MAP[skill]] || 0)); } return result; } // ---------- Adventure Scene Choices ---------- // Procedural choices offered for every encounter scene. // 'fight' is always available and triggers combat rather than a skill roll. const SCENE_CHOICES = [ { id: 'fight', label: 'Fight', skill: null, safe: false }, { id: 'sneak', label: 'Sneak past', skill: 'sneak', safe: false }, { id: 'talk', label: 'Talk your way out', skill: 'fastTalk', safe: false }, { id: 'investigate', label: 'Investigate', skill: 'scan', safe: true }, { id: 'flee', label: 'Flee', skill: 'dodge', safe: false }, ]; // Resolve a scene choice against a skill percent. // Returns { tier, roll, outcome, escalates } where escalates=true means the scene // should turn hostile (offer fight). function resolveSceneChoice(choiceId, skillPercent) { if (choiceId === 'fight') { return { tier: null, roll: null, outcome: 'combat', escalates: false }; } const check = resolveSkillCheck(skillPercent); const choice = SCENE_CHOICES.find((c) => c.id === choiceId); let outcome, escalates; if (choiceId === 'sneak') { if (check.tier === 'critical' || check.tier === 'special' || check.tier === 'success') { outcome = check.tier === 'critical' ? 'You slipped past completely undetected — they have no idea you were ever there.' : check.tier === 'special' ? 'You slipped past without a sound.' : 'You crept past, just barely unnoticed.'; escalates = false; } else if (check.tier === 'fumble') { outcome = 'You stumbled noisily — they spotted you!'; escalates = true; } else { outcome = 'You were spotted. The situation turns hostile.'; escalates = true; } } else if (choiceId === 'talk') { if (check.tier === 'critical') { outcome = 'An inspired performance — they are charmed and share useful information.'; escalates = false; } else if (check.tier === 'special' || check.tier === 'success') { outcome = 'They buy it. The situation de-escalates.'; escalates = false; } else if (check.tier === 'fumble') { outcome = 'You said exactly the wrong thing. They are furious.'; escalates = true; } else { outcome = "They're not convinced. The tension remains."; escalates = false; } } else if (choiceId === 'investigate') { if (check.tier === 'critical' || check.tier === 'special' || check.tier === 'success') { outcome = check.tier === 'critical' ? 'Excellent observation — you notice every detail of the situation.' : check.tier === 'special' ? 'You pick up on something others would have missed.' : 'You observe the situation carefully and learn something useful.'; } else { outcome = "You couldn't make out anything useful from this distance."; } escalates = false; } else if (choiceId === 'flee') { if (check.tier === 'critical' || check.tier === 'special' || check.tier === 'success') { outcome = check.tier === 'fumble' ? '' : 'You broke away cleanly.'; escalates = false; } else if (check.tier === 'fumble') { outcome = 'You tripped! They close in with the advantage.'; escalates = true; } else { outcome = 'They caught up — you cannot escape.'; escalates = true; } } return { tier: check.tier, roll: check.roll, skillPercent, outcome, escalates, choiceId }; } // ---------- 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, DELIBERATE_METHOD, validateDeliberate, validateCombined, computeSkillCategoryModifiers, deriveCharacterStats, BASE_SKILLS, SKILL_CATEGORY_MAP, computeBaseSkills, SCENE_CHOICES, resolveSceneChoice, 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, };