◂ Return Home
Lastlight
a character creator for the cursed of Alujam
◀ Back
Begin Anew
Onward ▶
Abandon this vessel?
All that you have wrought will be lost to the dark. Begin anew from nothing.
Stay the Hand
Discard
source (the HTML parser would close the script tag early). const open = '<' + 'script>'; const close = '<' + '/' + 'script>'; return escapeHtml(open + 'x' + close) === '<script>x</script>'; }); t('Prose textareas hard-cap to 1000 chars on render', () => { reset(); character.race='human'; character.subrace='standard'; character.raceLanguages=['Elvish']; character.class='fighter'; character.ambitions = 'a'.repeat(5000); saveCharacter(); // After save+load, length-clamp at load should bring it down. const r = loadCharacter(); return r && r.ambitions.length <= 1000; }); // -------- Weapon attack/damage RAW -------- t('Longsword attack uses Str, gets PB if proficient', () => { reset(); character.race='dragonborn'; character.draconicAncestry='Red'; character.class='paladin'; // martial weapon proficient character.baseScores={str:15,dex:8,con:14,int:9,wis:13,cha:13}; const s = computeWeaponStats('Longsword'); // Final Str = 15 + 2 (dragonborn) = 17, mod +3. PB +2. Attack = +5. return s.proficient && s.attackBonus === 5 && s.damageMod === 3 && s.abilityUsed === 'Str' ? true : JSON.stringify(s); }); t('Longbow attack uses Dex, +2 with Archery style', () => { reset(); character.race='elf'; character.subrace='wood'; character.class='fighter'; character.fightingStyle='Archery'; character.baseScores={str:10,dex:15,con:14,int:10,wis:14,cha:9}; const s = computeWeaponStats('Longbow'); // Final Dex = 15+2 (elf) +1 (wood elf wis +1, no dex bonus from wood) = 17, mod +3. PB +2. Archery +2. Attack = +7. // Wait: wood elf gives +1 wis. Elf base +2 dex. So dex=17. return s.proficient && s.abilityUsed === 'Dex' && s.attackBonus === 7 ? true : JSON.stringify(s); }); t('Rapier (finesse) picks higher of Str/Dex', () => { reset(); character.race='human'; character.subrace='standard'; character.class='rogue'; // proficient with rapiers character.baseScores={str:8,dex:15,con:14,int:13,wis:10,cha:12}; const s = computeWeaponStats('Rapier'); // Final Dex = 15+1 = 16, mod +3. Higher than Str (8+1=9, mod -1). return s.abilityUsed === 'Dex' && s.damageMod === 3 ? true : JSON.stringify(s); }); t('Wizard with mace: not proficient, no PB on attack', () => { reset(); character.race='human'; character.subrace='standard'; character.class='wizard'; // NOT proficient with mace character.baseScores={str:13,dex:14,con:14,int:15,wis:11,cha:8}; const s = computeWeaponStats('Mace'); // Final Str = 14, mod +2. No PB. Attack = +2. return !s.proficient && s.attackBonus === 2 ? true : JSON.stringify(s); }); t('Monk Martial Arts: shortsword can use Dex', () => { reset(); character.race='elf'; character.subrace='wood'; character.class='monk'; character.baseScores={str:10,dex:14,con:14,int:10,wis:14,cha:9}; const s = computeWeaponStats('Shortsword'); // Wood elf +2 dex → 16, mod +3. Str 10, mod 0. Monk uses Dex. return s.abilityUsed === 'Dex' && s.attackBonus === 5 ? true : JSON.stringify(s); }); t('Heavy crossbow proficiency only via Martial weapons', () => { reset(); character.race='human'; character.subrace='standard'; character.class='fighter'; // martial weapons → proficient return isWeaponProficient('Heavy crossbow') === true; }); t('Sorcerer not proficient with martial weapons', () => { reset(); character.race='human'; character.subrace='standard'; character.class='sorcerer'; return isWeaponProficient('Greatsword') === false; }); t('Unarmed strike: Wizard Str 8 → Attack +1 (Str), Damage 1-1 bludgeoning', () => { reset(); character.race='human'; character.subrace='standard'; character.raceLanguages=['Elvish']; character.class='wizard'; character.classSkills=['Arcana','History']; character.cantrips=['Fire Bolt','Mage Hand','Light']; character.spells=['Shield','Magic Missile','Mage Armor','Detect Magic','Sleep','Burning Hands']; character.equipmentChoices={ 0:'a quarterstaff', 1:'an arcane focus', 2:"a scholar's pack" }; character.background='sage'; character.bgLanguages=['Dwarvish','Goblin']; character.baseScores={ str:8, dex:14, con:14, int:15, wis:12, cha:8 }; character.name='X'; character.ambitions='a'; character.follies='a'; character.backstory='a'; saveCharacter(); goToStep(6); const txt = (document.querySelector('.weapon-extra-row') || {}).textContent || ''; return /Attack \+1 \(Str\)/.test(txt) && /Damage 1-1 bludgeoning/.test(txt) ? true : 'got: ' + txt.trim(); }); t('Unarmed strike: Monk uses Dex when Dex > Str', () => { reset(); character.race='elf'; character.subrace='wood'; character.class='monk'; character.classSkills=['Acrobatics','Stealth']; character.equipmentChoices={ 0:'a shortsword', 1:"a dungeoneer's pack" }; character.background='hermit'; character.bgLanguages=['Dwarvish']; character.baseScores={ str:13, dex:14, con:13, int:10, wis:14, cha:9 }; character.name='M'; character.ambitions='a'; character.follies='a'; character.backstory='a'; saveCharacter(); goToStep(6); const txt = (document.querySelector('.weapon-extra-row') || {}).textContent || ''; return /\(Dex\)/.test(txt) && /1d4\+3/.test(txt) ? true : 'got: ' + txt.trim(); }); t('Unarmed strike: ability mod 0 omits the modifier', () => { reset(); character.race='human'; character.subrace='standard'; character.raceLanguages=['Elvish']; character.class='cleric'; character.subclass='life'; character.classSkills=['Insight','Religion']; character.cantrips=['Sacred Flame','Guidance','Mending']; character.equipmentChoices={ 0:'a mace', 1:'leather armor', 2:'a light crossbow and 20 bolts', 3:"a priest's pack" }; character.background='acolyte'; character.bgLanguages=['Dwarvish','Goblin']; character.baseScores={ str:10, dex:13, con:14, int:8, wis:15, cha:12 }; character.name='C'; character.ambitions='a'; character.follies='a'; character.backstory='a'; saveCharacter(); goToStep(6); const txt = (document.querySelector('.weapon-extra-row') || {}).textContent || ''; // Standard human +1 makes Str 11 → mod 0. Damage = "1 bludgeoning" (no mod suffix). return /Damage 1 bludgeoning/.test(txt) ? true : 'got: ' + txt.trim(); }); t('getCharacterEquippedItems extracts longsword from picker subpick', () => { reset(); character.race='dragonborn'; character.draconicAncestry='Red'; character.class='paladin'; character.equipmentChoices = { 0:'any martial weapon and a shield', 1:'five javelins', 2:"a priest's pack", 3:'chain mail and a holy symbol' }; character.equipmentSubpicks = { 0:'Longsword' }; character.background = 'soldier'; character.bgToolChoices = { 0:'Dice set' }; const inv = getCharacterEquippedItems(); return inv.weapons.includes('Longsword') && inv.weapons.includes('Javelin') && inv.armor.includes('Chain mail') && inv.armor.includes('Shield'); }); // -------- Equipment expansion (packs / weapons / armor) -------- t('WEAPONS dict covers all PHB simple+martial weapons', () => { const all = [...SIMPLE_WEAPONS, ...MARTIAL_WEAPONS]; for (const w of all) if (!WEAPONS[w]) return `missing ${w}`; // Aliases (Light crossbow ↔ Crossbow (light), etc.) are allowed in WEAPONS. const allowedExtras = new Set(['Light crossbow','Hand crossbow','Heavy crossbow']); const extras = Object.keys(WEAPONS).filter(k => !all.includes(k) && !allowedExtras.has(k)); return extras.length === 0 ? true : 'unexpected extras: ' + extras.join(', '); }); t('expandEquipment wraps weapon names in
', () => { const html = expandEquipment('a longsword and a shield'); return /class="weapon-info"/.test(html) && /class="armor-info"/.test(html); }); t('expandEquipment wraps pack names', () => { const html = expandEquipment("a scholar's pack"); return /class="pack-info"/.test(html); }); t('expandEquipment matches weapon plurals (two daggers, five javelins)', () => { const html = expandEquipment('two daggers and five javelins'); return /weapon-info[^>]*>
daggers]*>
javelins { const html = expandEquipment('half plate'); return /armor-info[^>]*>
half plate { const html = expandEquipment('a light crossbow and 20 bolts'); return /weapon-info[^>]*>
light crossbow { reset(); // Skip race entirely — sheet should bail out. character.class = 'fighter'; character.classSkills = ['Athletics','Perception']; character.fightingStyle = 'Defense'; character.equipmentChoices = { 0:'chain mail', 1:'a martial weapon and a shield', 2:'a light crossbow and 20 bolts', 3:"a dungeoneer's pack" }; character.equipmentSubpicks = { 1:'Longsword' }; character.background = 'soldier'; character.bgToolChoices = { 0:'Dice set' }; character.baseScores = { str:14,dex:13,con:14,int:14,wis:8,cha:9 }; character.name='X'; character.ambitions='a'; character.follies='a'; character.backstory='a'; saveCharacter(); goToStep(6); return /Build incomplete/.test(document.body.textContent); }); // -------- Random name roller -------- t('rollRandomName returns a string for every race', () => { for (const rk of Object.keys(RACES)) { const n = rollRandomName(rk); if (typeof n !== 'string' || !n.length) return `bad result for ${rk}: ${n}`; } return true; }); t('rollRandomName Half-Elf draws from elf or human pools', () => { // Sample 50 rolls; should hit both pools given the _mix branching. const elfFamilies = new Set(NAME_LISTS.elf.family); const humanFamilies = new Set(NAME_LISTS.human.family); let elf = 0, human = 0; for (let i = 0; i < 50; i++) { const n = rollRandomName('halfElf'); const fam = n.split(' ').slice(1).join(' '); if (elfFamilies.has(fam)) elf++; else if (humanFamilies.has(fam)) human++; } return elf > 0 && human > 0; }); t('Half-Orc / Tiefling rolled names have no family component', () => { for (const rk of ['halfOrc','tiefling']) { for (let i = 0; i < 20; i++) { const n = rollRandomName(rk); if (n.includes(' ')) return `${rk} returned multi-word ${n}`; } } return true; }); // Sanity-check that the FromSoft easter-egg names are present in each race pool. If // someone strips a pool down later, this catches the regression early. t('FromSoft easter-egg names present in each race pool', () => { const expected = { dwarf: ['Siegmeyer','Vamos','Hewg'], elf: ['Gwynevere','Quelana','Ranni','Malenia'], halfling: ['Patches','Greirat','Boc'], human: ['Solaire','Artorias','Gehrman','Marika'], dragonborn: ['Seath','Kalameet','Radagon'], gnome: ['Logan','Cornyx','Sellen'], halfOrc: ['Smough','Yhorm','Radahn'], tiefling: ['Quelaag','Aldrich','Micolash','Mephistopheles'] }; for (const [race, names] of Object.entries(expected)) { const pool = NAME_LISTS[race].given; for (const n of names) if (!pool.includes(n)) return `${race} missing ${n}`; } return true; }); // -------- Random Height & Weight (PHB p. 121) -------- t('rollHeightWeight returns valid result for every race', () => { for (const rk of Object.keys(RACES)) { const r = getSubraceKeyOrNull(rk); const subKey = r; const out = rollHeightWeight(rk, subKey); if (!out) return `null for ${rk}`; if (!/^\d+'\d+"$/.test(out.height)) return `bad height for ${rk}: ${out.height}`; if (!/^\d+ lbs$/.test(out.weight)) return `bad weight for ${rk}: ${out.weight}`; } function getSubraceKeyOrNull(rk) { const subraces = RACES[rk].subraces; return subraces && Object.keys(subraces).length ? Object.keys(subraces)[0] : null; } return true; }); t('rollHeightWeight ranges respect PHB Halfling table (2\'9"–3\'3", 35–43 lb)', () => { // Halfling: 2'7" + 2d4 inches (so 2-8 extra) → 2'9"–3'3". Weight = 35 + heightMod × 1. for (let i = 0; i < 200; i++) { const out = rollHeightWeight('halfling', 'lightfoot'); const m = out.height.match(/^(\d+)'(\d+)"$/); const ft = parseInt(m[1], 10), inch = parseInt(m[2], 10); const totalIn = ft * 12 + inch; if (totalIn < 31 + 2 || totalIn > 31 + 8) return `height out of range: ${out.height}`; const wt = parseInt(out.weight, 10); if (wt < 35 + 2 || wt > 35 + 8) return `weight out of range: ${out.weight}`; } return true; }); t('rollHeightWeight ranges respect PHB Dragonborn (5\'8"–6\'10", 187–247 lb)', () => { // Dragonborn: 5'6" + 2d8 → +2..16 in → 5'8"–6'10". Weight = 175 + hMod × 2d6. for (let i = 0; i < 200; i++) { const out = rollHeightWeight('dragonborn'); const m = out.height.match(/^(\d+)'(\d+)"$/); const totalIn = parseInt(m[1], 10) * 12 + parseInt(m[2], 10); if (totalIn < 66 + 2 || totalIn > 66 + 16) return `height: ${out.height}`; const wt = parseInt(out.weight, 10); // Min: hMod=2 × 2d6 min 2 = 175 + 4 = 179. Max: hMod=16 × 12 = 175 + 192 = 367. if (wt < 179 || wt > 367) return `weight: ${out.weight}`; } return true; }); t('rollHeightWeight: Hill vs Mountain dwarf differ in base height/weight', () => { // Mountain dwarf (4'+) is taller and heavier base than Hill dwarf (3'8"+). // Sample a hundred of each and confirm the means clearly diverge. let hillHeights = 0, mtnHeights = 0; for (let i = 0; i < 100; i++) { const h = rollHeightWeight('dwarf','hill'); const m = h.height.match(/^(\d+)'(\d+)"$/); hillHeights += parseInt(m[1],10)*12 + parseInt(m[2],10); const m2 = rollHeightWeight('dwarf','mountain'); const mm = m2.height.match(/^(\d+)'(\d+)"$/); mtnHeights += parseInt(mm[1],10)*12 + parseInt(mm[2],10); } return mtnHeights > hillHeights ? true : `mtn=${mtnHeights} hill=${hillHeights}`; }); t('rollHeightWeight unknown race → null', () => rollHeightWeight('foo') === null); // -------- Random Age (PHB age sections) -------- t('rollAge returns an integer in race-specific PHB range', () => { for (const rk of Object.keys(RACES)) { const range = AGE_RANGES[rk]; if (!range) return `no AGE_RANGES for ${rk}`; for (let i = 0; i < 200; i++) { const a = rollAge(rk); if (!Number.isInteger(a)) return `${rk}: non-int ${a}`; if (a < range[0] || a > range[1]) return `${rk}: ${a} outside [${range[0]},${range[1]}]`; } } return true; }); t('rollAge: dwarf mean age > human mean age (longer-lived race)', () => { let dwarfSum = 0, humanSum = 0; const N = 500; for (let i = 0; i < N; i++) { dwarfSum += rollAge('dwarf'); humanSum += rollAge('human'); } return dwarfSum / N > humanSum / N ? true : `dwarf=${dwarfSum/N} human=${humanSum/N}`; }); t('rollAge: elf mean age clearly larger than dragonborn (much longer lifespan)', () => { let elfSum = 0, dragSum = 0; const N = 500; for (let i = 0; i < N; i++) { elfSum += rollAge('elf'); dragSum += rollAge('dragonborn'); } return elfSum / N > dragSum / N * 2 ? true : `elf=${elfSum/N} drag=${dragSum/N}`; }); t('rollAge unknown race → null', () => rollAge('foo') === null); // -------- Random Appearance (race-flavored eyes/skin/hair) -------- t('rollAppearance returns valid {eyes,skin,hair} for every race+subrace', () => { for (const rk of Object.keys(RACES)) { const subraces = RACES[rk].subraces; const subKey = subraces && Object.keys(subraces).length ? Object.keys(subraces)[0] : null; const out = rollAppearance(rk, subKey); if (!out) return `null for ${rk}`; for (const f of ['eyes','skin','hair']) { if (typeof out[f] !== 'string' || !out[f].length) return `${rk}: bad ${f}`; } } return true; }); t('rollAppearance: Dragonborn hair is always a "none" descriptor (scaled, no hair)', () => { for (let i = 0; i < 50; i++) { const out = rollAppearance('dragonborn'); if (!out.hair.toLowerCase().includes('none')) return `got: ${out.hair}`; } return true; }); t('rollAppearance: Dragonborn skin is always scaled', () => { for (let i = 0; i < 50; i++) { const out = rollAppearance('dragonborn'); if (!out.skin.includes('scaled')) return `got: ${out.skin}`; } return true; }); t('rollAppearance: Drow skin always dark (obsidian/charcoal/etc.)', () => { for (let i = 0; i < 50; i++) { const out = rollAppearance('elf','drow'); if (!/obsidian|charcoal|slate|shadow|ink|dark/.test(out.skin)) return `got: ${out.skin}`; } return true; }); t('rollAppearance: Tiefling eyes always start with "solid"', () => { for (let i = 0; i < 50; i++) { const out = rollAppearance('tiefling'); if (!out.eyes.startsWith('solid')) return `got: ${out.eyes}`; } return true; }); t('rollAppearance: high vs wood elf hair pools differ', () => { let high = new Set(), wood = new Set(); for (let i = 0; i < 200; i++) { high.add(rollAppearance('elf','high').hair); wood.add(rollAppearance('elf','wood').hair); } // Each pool has ~5–6 entries; intersection should be empty given current data. const inter = [...high].filter(h => wood.has(h)); return inter.length === 0 ? true : 'overlap: ' + inter.join(', '); }); t('rollAppearance unknown race → null', () => rollAppearance('foo') === null); // -------- Defense-in-depth: forged-save sanitization -------- t('FORGED: validateRace rejects half-elf with Cha bonus', () => { reset(); character.race = 'halfElf'; character.halfElfBonusAbilities = ['cha','str']; character.raceLanguages = ['Goblin']; return /Charisma/.test(validateRace()); }); t('FORGED: validateRace rejects duplicate halfElf bonus abilities', () => { reset(); character.race = 'halfElf'; character.halfElfBonusAbilities = ['str','str']; character.raceLanguages = ['Goblin']; return /different/.test(validateRace()); }); t('FORGED: validateRace rejects duplicate Variant Human ASIs', () => { reset(); character.race = 'human'; character.subrace = 'variant'; character.humanVariantAsis = ['str','str']; character.humanVariantSkill = 'Athletics'; character.humanVariantFeat = 'alert'; character.raceLanguages = ['Elvish']; return /different/.test(validateRace()); }); t('FORGED: getHumanVariantAsiBonus dedupes (no +2 stack)', () => { reset(); character.race = 'human'; character.subrace = 'variant'; character.humanVariantAsis = ['str','str']; return getHumanVariantAsiBonus('str') === 1; }); t('FORGED: loadCharacter strips Cha + duplicates from halfElfBonusAbilities', () => { localStorage.setItem(STORAGE_KEY, JSON.stringify({ halfElfBonusAbilities: ['cha','str','str','int','dex'] })); const r = loadCharacter(); return Array.isArray(r.halfElfBonusAbilities) && r.halfElfBonusAbilities.length === 2 && !r.halfElfBonusAbilities.includes('cha') && new Set(r.halfElfBonusAbilities).size === 2; }); t('FORGED: loadCharacter dedupes humanVariantAsis', () => { localStorage.setItem(STORAGE_KEY, JSON.stringify({ humanVariantAsis: ['str','str','dex'] })); const r = loadCharacter(); return Array.isArray(r.humanVariantAsis) && r.humanVariantAsis.length === 2 && new Set(r.humanVariantAsis).size === 2; }); // Regression: pre-fix, populated array fields were clobbered to {} on every reload. t('REGRESSION: loadCharacter preserves populated arrays through round-trip', () => { localStorage.setItem(STORAGE_KEY, JSON.stringify({ race: 'halfElf', halfElfBonusAbilities: ['str','dex'], halfElfSkills: ['Stealth','Perception'], classSkills: ['Athletics','Perception'], cantrips: ['Fire Bolt','Mage Hand'] })); const r = loadCharacter(); if (!Array.isArray(r.halfElfBonusAbilities) || r.halfElfBonusAbilities.length !== 2) return 'halfElfBonusAbilities lost: ' + JSON.stringify(r.halfElfBonusAbilities); if (!Array.isArray(r.halfElfSkills) || r.halfElfSkills.length !== 2) return 'halfElfSkills lost'; if (!Array.isArray(r.classSkills) || r.classSkills.length !== 2) return 'classSkills lost'; if (!Array.isArray(r.cantrips) || r.cantrips.length !== 2) return 'cantrips lost'; return true; }); // -------- Class & feat prereqs (proper point buy) -------- t('Grappler with final Str 8 fails (Str 13+ prereq)', () => { reset(); character.race = 'human'; character.subrace = 'variant'; character.humanVariantAsis = ['dex','con']; character.humanVariantSkill = 'Athletics'; character.humanVariantFeat = 'grappler'; character.raceLanguages = ['Elvish']; character.class = 'fighter'; character.baseScores = { str:8, dex:15, con:14, int:13, wis:13, cha:9 }; // 27pt return /Strength 13/.test(validateAbilities()); }); t('Heavily Armored on Wizard rejected (medium-armor prereq)', () => { reset(); character.race = 'human'; character.subrace = 'variant'; character.humanVariantAsis = ['int','con']; character.humanVariantSkill = 'Arcana'; character.humanVariantFeat = 'heavilyArmored'; character.raceLanguages = ['Elvish']; character.class = 'wizard'; character.equipmentChoices = { 0:'a quarterstaff', 1:'an arcane focus', 2:"a scholar's pack" }; return /medium armor proficiency/i.test(validateClass() || ''); }); t('War Caster on Fighter rejected (no spellcasting)', () => { reset(); character.race = 'human'; character.subrace = 'variant'; character.humanVariantAsis = ['str','con']; character.humanVariantSkill = 'Athletics'; character.humanVariantFeat = 'warCaster'; character.raceLanguages = ['Elvish']; character.class = 'fighter'; character.equipmentChoices = { 0:'chain mail', 1:'a martial weapon and a shield', 2:'a light crossbow and 20 bolts', 3:"a dungeoneer's pack" }; character.equipmentSubpicks = { 1:'Longsword' }; return /spellcasting feature/i.test(validateClass() || ''); }); t('War Caster on Sorcerer passes', () => { reset(); character.race = 'human'; character.subrace = 'variant'; character.humanVariantAsis = ['cha','con']; character.humanVariantSkill = 'Arcana'; character.humanVariantFeat = 'warCaster'; character.raceLanguages = ['Elvish']; character.class = 'sorcerer'; character.subclass = 'wildmagic'; character.equipmentChoices = { 0:'a light crossbow and 20 bolts', 1:'a component pouch', 2:"a dungeoneer's pack" }; return validateClass() === true; }); // -------- Spellcasting limits -------- t('Wizard: 5 spells in spellbook rejected (need exactly 6)', () => { reset(); character.race='human'; character.subrace='standard'; character.raceLanguages=['Elvish']; character.class='wizard'; character.classSkills=['Arcana','History']; character.cantrips=['Fire Bolt','Mage Hand','Light']; character.spells=['Shield','Magic Missile','Mage Armor','Detect Magic','Sleep']; return /6 spells/.test(validateSkills()); }); t('Wizard: 4 cantrips rejected (need exactly 3)', () => { reset(); character.race='human'; character.subrace='standard'; character.raceLanguages=['Elvish']; character.class='wizard'; character.classSkills=['Arcana','History']; character.cantrips=['Fire Bolt','Mage Hand','Light','Ray of Frost']; character.spells=['Shield','Magic Missile','Mage Armor','Detect Magic','Sleep','Burning Hands']; return /3 cantrips/.test(validateSkills()); }); // -------- Druid metal-armor filter -------- t('Druid equipment filters out metal armor (scale/chain/etc.)', () => { reset(); character.race='human'; character.subrace='standard'; character.raceLanguages=['Elvish']; character.class='druid'; saveCharacter(); goToStep(1); const txt = (document.querySelector('.equip-list') || {}).textContent || ''; const banned = ['chain mail','chain shirt','breastplate','half plate','plate armor','splint armor','ring mail','scale mail']; const found = banned.filter(b => txt.toLowerCase().includes(b)); return found.length === 0 ? true : 'leaked: ' + found.join(', '); }); // -------- Cleric (if proficient) equipment filter -------- t('Knowledge Cleric: warhammer hidden (no martial weapons)', () => { reset(); character.race='human'; character.subrace='standard'; character.raceLanguages=['Elvish']; character.class='cleric'; character.subclass='knowledge'; saveCharacter(); goToStep(1); const set1 = document.querySelectorAll('.equip-set')[0]; const text = set1 ? set1.textContent.toLowerCase() : ''; return !text.includes('warhammer'); }); t('War Cleric: warhammer visible (martial weapons granted)', () => { reset(); character.race='human'; character.subrace='standard'; character.raceLanguages=['Elvish']; character.class='cleric'; character.subclass='war'; saveCharacter(); goToStep(1); return /warhammer/i.test((document.querySelector('.equip-list') || {}).textContent || ''); }); // -------- Exhaustive coverage: every race × every class produces a sheet -------- t('Exhaustive: all 9 races × all 12 classes render valid sheets', () => { const races = Object.keys(RACES); const classes = Object.keys(CLASSES); let ok = 0; const fails = []; for (const rk of races) { for (const ck of classes) { try { reset(); character.race = rk; const r = getRace(); if (r.subraces && Object.keys(r.subraces).length) character.subrace = Object.keys(r.subraces)[0]; if (r.needsAncestry) character.draconicAncestry = 'Red'; if (r.needsDwarfTool) character.dwarfTool = "Smith's tools"; if (rk === 'halfElf') { character.halfElfBonusAbilities = ['str','dex']; character.halfElfSkills = ['Acrobatics','Investigation']; } const sub = getSubrace(); const langSlots = (r.extraLanguages || 0) + ((sub && sub.extraLanguages) || 0); if (langSlots > 0) { const known = new Set(r.languages); character.raceLanguages = LANGUAGES.filter(l => !known.has(l)).slice(0, langSlots); } if (sub && sub.cantripFrom) character.highElfCantrip = 'Fire Bolt'; if (sub && sub.humanVariantAsis) { character.humanVariantAsis = ['str','dex']; character.humanVariantSkill = 'Performance'; character.humanVariantFeat = 'alert'; } character.class = ck; const cl = getClass(); const subData = getSubclassData(); if (subData) { character.subclass = Object.keys(subData.options)[0]; const sc = getSubclass(); if (sc && sc.needsDragonType) character.draconicAncestor = 'Red'; } const choices = cl.skillChoices === 'any' ? Object.keys(SKILLS) : cl.skillChoices; const taken = new Set(); if (r.grantedSkills) r.grantedSkills.forEach(s => taken.add(s)); if (sub && sub.grantedSkills) sub.grantedSkills.forEach(s => taken.add(s)); if (rk === 'halfElf') character.halfElfSkills.forEach(s => taken.add(s)); if (character.humanVariantSkill) taken.add(character.humanVariantSkill); character.classSkills = choices.filter(s => !taken.has(s)).slice(0, cl.skillCount); if (cl.name === 'Fighter') character.fightingStyle = 'Defense'; if (ck === 'ranger') { character.favoredEnemy = 'Beasts'; character.favoredTerrain = 'Forest'; } if (cl.expertiseChoices) character.expertise = character.classSkills.slice(0, cl.expertiseChoices); cl.equipment.forEach((line, i) => { if (line.includes(' OR ')) { let opts = line.split(' OR ').filter(isEquipOptionAllowed); if (character.class === 'druid') opts = opts.filter(o => !/scale mail|chain mail|chain shirt|plate armor|splint armor|ring mail|half plate|breastplate/i.test(o)); character.equipmentChoices[i] = cleanEquipOpt(opts[0]); } const lineText = line.includes(' OR ') ? character.equipmentChoices[i] : line; const picker = placeholderPickList(lineText); if (picker) { const count = picker.count || 1; character.equipmentSubpicks[i] = count > 1 ? Array(count).fill(picker.list[0]) : picker.list[0]; } }); if (cl.spellcasting) { const sl = SPELL_LISTS[ck]; character.cantrips = sl.cantrips.slice(0, cl.spellcasting.cantrips); if (cl.spellcasting.spellsKnown) character.spells = sl[1].slice(0, cl.spellcasting.spellsKnown); } const subcl = getSubclass(); if (subcl) { if (subcl.extraLanguages) { const known = new Set(r.languages); if (sub && sub.grantedLanguages) sub.grantedLanguages.forEach(l => known.add(l)); if (subcl.grantedLanguages) subcl.grantedLanguages.forEach(l => known.add(l)); (character.raceLanguages || []).forEach(l => known.add(l)); character.domainExtraLanguages = LANGUAGES.filter(l => !known.has(l)).slice(0, subcl.extraLanguages); } if (subcl.domainSkillExpertise) character.domainExpertise = subcl.domainSkillExpertise.from.slice(0, subcl.domainSkillExpertise.count); if (subcl.domainCantripFrom) character.domainCantrip = SPELL_LISTS[subcl.domainCantripFrom].cantrips.find(c => !character.cantrips.includes(c)); if (subcl.domainSkillFrom) { const known = new Set(taken); character.classSkills.forEach(s => known.add(s)); character.domainSkill = subcl.domainSkillFrom.find(s => !known.has(s)); } } character.background = 'soldier'; character.bgToolChoices = { 0:'Dice set' }; character.baseScores = { str:14, dex:13, con:14, int:13, wis:9, cha:10 }; character.name = 'Test'; character.ambitions = 'a'; character.follies = 'a'; character.backstory = 'a'; for (let i = 0; i < 6; i++) { const v = STEPS[i].validate(); if (v !== true) { fails.push(`${rk}/${ck}: ${STEPS[i].id}: ${v}`); throw new Error('skip'); } } saveCharacter(); goToStep(6); const cont = document.getElementById('content'); if (/Build incomplete/.test(cont.textContent)) { fails.push(`${rk}/${ck}: sheet refused`); continue; } if (!cont.querySelector('.combat-stats')) { fails.push(`${rk}/${ck}: no combat-stats`); continue; } ok++; } catch (e) { if (e.message !== 'skip') fails.push(`${rk}/${ck}: ${e.message}`); } } } return fails.length === 0 ? true : `${ok}/108; first fails: ${fails.slice(0,3).join(' | ')}`; }); // ======================================================================== // COMPREHENSIVE PHB RAW SWEEP — covers race ASIs, speeds, saves, skill // counts, hit dice, HP/AC formulas, spell save DCs, initiative, passive // perception, subclass-at-L1 enforcement, racial traits on sheet, class // features on sheet, cleric domain spells, warlock patron features, // equipment filters, spell list isolation, weapon-prof edge cases, and a // 9×12×13 = 1404-combo render check. // ======================================================================== function _pbCost(s) { return Object.values(s).reduce((sum,v)=>sum+POINT_BUY_COSTS[v],0); } function _findStat(label) { const blocks = [...document.querySelectorAll('.combat-stats .stat-block')]; for (const b of blocks) { if (b.textContent.toLowerCase().includes(label.toLowerCase())) { const v = b.querySelector('.stat-val'); if (v) return v.textContent.trim(); } } return null; } function _setupSheet(race, subrace, cls, subcl, bs) { reset(); character.race=race; character.subrace=subrace; if (race === 'dwarf') character.dwarfTool="Smith's tools"; if (race === 'dragonborn') character.draconicAncestry='Red'; if (race === 'human' && subrace === 'standard') character.raceLanguages=['Elvish']; if (race === 'elf' && subrace === 'high') { character.raceLanguages=['Goblin']; character.highElfCantrip='Fire Bolt'; } if (race === 'halfElf') { character.halfElfBonusAbilities=['str','dex']; character.halfElfSkills=['Acrobatics','Investigation']; character.raceLanguages=['Goblin']; } character.class=cls; if (subcl) character.subclass=subcl; if (cls === 'sorcerer' && subcl === 'draconic') character.draconicAncestor='Red'; const cl = getClass(); const r = getRace(); const sub = getSubrace(); const choices = cl.skillChoices === 'any' ? Object.keys(SKILLS) : cl.skillChoices; const taken = new Set(); if (r.grantedSkills) r.grantedSkills.forEach(s => taken.add(s)); if (sub && sub.grantedSkills) sub.grantedSkills.forEach(s => taken.add(s)); if (race === 'halfElf') character.halfElfSkills.forEach(s => taken.add(s)); character.classSkills = choices.filter(s => !taken.has(s)).slice(0, cl.skillCount); if (cls === 'fighter') character.fightingStyle='Defense'; if (cls === 'ranger') { character.favoredEnemy = 'Beasts'; character.favoredTerrain = 'Forest'; } if (cl.expertiseChoices) character.expertise = character.classSkills.slice(0, cl.expertiseChoices); cl.equipment.forEach((line, i) => { if (line.includes(' OR ')) { let opts = line.split(' OR ').filter(isEquipOptionAllowed); if (cls === 'druid') opts = opts.filter(o => !/scale mail|chain mail|chain shirt|plate armor|splint armor|ring mail|half plate|breastplate/i.test(o)); character.equipmentChoices[i] = cleanEquipOpt(opts[0]); } const lineText = line.includes(' OR ') ? character.equipmentChoices[i] : line; const picker = placeholderPickList(lineText); if (picker) { const count = picker.count || 1; character.equipmentSubpicks[i] = count > 1 ? Array(count).fill(picker.list[0]) : picker.list[0]; } }); if (cl.spellcasting) { const sl = SPELL_LISTS[cls]; character.cantrips = sl.cantrips.slice(0, cl.spellcasting.cantrips); if (cl.spellcasting.spellsKnown) character.spells = sl[1].slice(0, cl.spellcasting.spellsKnown); } const sc = getSubclass(); if (sc) { if (sc.extraLanguages) { const known = new Set(r.languages); if (sc.grantedLanguages) sc.grantedLanguages.forEach(l => known.add(l)); (character.raceLanguages || []).forEach(l => known.add(l)); character.domainExtraLanguages = LANGUAGES.filter(l => !known.has(l)).slice(0, sc.extraLanguages); } if (sc.domainSkillExpertise) character.domainExpertise = sc.domainSkillExpertise.from.slice(0, sc.domainSkillExpertise.count); if (sc.domainCantripFrom) character.domainCantrip = SPELL_LISTS[sc.domainCantripFrom].cantrips.find(c => !character.cantrips.includes(c)); if (sc.domainSkillFrom) character.domainSkill = sc.domainSkillFrom.find(s => !character.classSkills.includes(s)); } character.background='soldier'; character.bgToolChoices={0:'Dice set'}; character.baseScores=bs; character.name='X'; character.ambitions='a'; character.follies='a'; character.backstory='a'; if (_pbCost(bs) !== 27) throw new Error('test pb!=27: '+_pbCost(bs)); saveCharacter(); goToStep(6); if (/Build incomplete/.test(document.getElementById('content').textContent)) { throw new Error('refused: '+document.getElementById('content').textContent.match(/Build incomplete[^.]*\./)[0]); } } // ---- Point buy bounds ---- t('PB: 16 not a valid base score', () => POINT_BUY_COSTS[16] === undefined); t('PB: 7 not a valid base score', () => POINT_BUY_COSTS[7] === undefined); t('PB: cost table matches PHB exactly', () => { const exp = {8:0,9:1,10:2,11:3,12:4,13:5,14:7,15:9}; return Object.entries(exp).every(([k,v]) => POINT_BUY_COSTS[k] === v) && Object.keys(POINT_BUY_COSTS).length === 8; }); t('PB: triple 15 + triple 8 sums to 27 and validates', () => { reset(); character.baseScores={str:15,dex:15,con:15,int:8,wis:8,cha:8}; return validateAbilities() === true; }); // ---- Racial ASI ---- t('Standard Human grants +1 to all six abilities', () => { reset(); character.race='human'; character.subrace='standard'; character.baseScores={str:8,dex:8,con:8,int:8,wis:8,cha:8}; return ABILITIES.every(a => getFinalScore(a) === 9); }); t('Half-Elf grants +2 Cha + 2 chosen non-Cha at +1', () => { reset(); character.race='halfElf'; character.halfElfBonusAbilities=['str','dex']; character.baseScores={str:8,dex:8,con:8,int:8,wis:8,cha:8}; return getFinalScore('cha')===10 && getFinalScore('str')===9 && getFinalScore('dex')===9 && getFinalScore('con')===8 && getFinalScore('int')===8 && getFinalScore('wis')===8; }); // ---- Speeds & sizes ---- t('PHB speeds: dwarf 25, elf 30 (wood 35), halfling 25, gnome 25, others 30', () => { return RACES.dwarf.speed === 25 && RACES.elf.speed === 30 && RACES.elf.subraces.wood.speedOverride === 35 && RACES.halfling.speed === 25 && RACES.gnome.speed === 25 && RACES.human.speed === 30 && RACES.dragonborn.speed === 30 && RACES.halfElf.speed === 30 && RACES.halfOrc.speed === 30 && RACES.tiefling.speed === 30; }); t('Halfling and Gnome are Small; rest Medium', () => { return RACES.halfling.size === 'Small' && RACES.gnome.size === 'Small' && ['dwarf','elf','human','dragonborn','halfElf','halfOrc','tiefling'].every(r => RACES[r].size === 'Medium'); }); // ---- Class saves (all 12) ---- const _SAVES = {barbarian:['str','con'],bard:['dex','cha'],cleric:['wis','cha'],druid:['int','wis'],fighter:['str','con'],monk:['str','dex'],paladin:['wis','cha'],ranger:['str','dex'],rogue:['dex','int'],sorcerer:['con','cha'],warlock:['wis','cha'],wizard:['int','wis']}; for (const [c, exp] of Object.entries(_SAVES)) { t(`${c} saves: ${exp.join(',')}`, () => JSON.stringify(CLASSES[c].saves.slice().sort()) === JSON.stringify(exp.slice().sort())); } // ---- Hit dice & skill counts (all 12) ---- const _HD = {barbarian:12,fighter:10,paladin:10,ranger:10,bard:8,cleric:8,druid:8,monk:8,rogue:8,warlock:8,sorcerer:6,wizard:6}; for (const [c, d] of Object.entries(_HD)) t(`${c} hit die d${d}`, () => CLASSES[c].hitDie === d); const _SCC = {barbarian:2,bard:3,cleric:2,druid:2,fighter:2,monk:2,paladin:2,ranger:3,rogue:4,sorcerer:2,warlock:2,wizard:2}; for (const [c, n] of Object.entries(_SCC)) t(`${c} picks ${n} skills`, () => CLASSES[c].skillCount === n); // ---- Spellcasting counts ---- t('Bard: 2 cantrips, 4 spells known', () => CLASSES.bard.spellcasting.cantrips === 2 && CLASSES.bard.spellcasting.spellsKnown === 4); t('Cleric: 3 cantrips, prepared', () => CLASSES.cleric.spellcasting.cantrips === 3 && CLASSES.cleric.spellcasting.type === 'prepared'); t('Druid: 2 cantrips, prepared', () => CLASSES.druid.spellcasting.cantrips === 2 && CLASSES.druid.spellcasting.type === 'prepared'); t('Sorcerer: 4 cantrips, 2 spells known', () => CLASSES.sorcerer.spellcasting.cantrips === 4 && CLASSES.sorcerer.spellcasting.spellsKnown === 2); t('Warlock: 2 cantrips, 2 spells known', () => CLASSES.warlock.spellcasting.cantrips === 2 && CLASSES.warlock.spellcasting.spellsKnown === 2); t('Wizard: 3 cantrips, 6 spellbook', () => CLASSES.wizard.spellcasting.cantrips === 3 && CLASSES.wizard.spellcasting.spellsKnown === 6); // ---- Subclass at L1 enforcement ---- t('Cleric requires Domain at L1', () => { reset(); character.race='human'; character.subrace='standard'; character.raceLanguages=['Elvish']; character.class='cleric'; return /[Dd]omain/.test(validateClass()); }); t('Sorcerer requires Origin at L1', () => { reset(); character.race='human'; character.subrace='standard'; character.raceLanguages=['Elvish']; character.class='sorcerer'; return /[Oo]rigin/.test(validateClass()); }); t('Warlock requires Patron at L1', () => { reset(); character.race='human'; character.subrace='standard'; character.raceLanguages=['Elvish']; character.class='warlock'; return /[Pp]atron/.test(validateClass()); }); t('Druid/Barb/Fighter/Monk/Paladin/Ranger/Rogue/Bard/Wizard: no subclass at L1', () => { return ['druid','barbarian','fighter','monk','paladin','ranger','rogue','bard','wizard'].every(c => SUBCLASSES[c] === undefined); }); t('Sorcerer Draconic without dragon ancestor: rejected', () => { reset(); character.race='human'; character.subrace='standard'; character.raceLanguages=['Elvish']; character.class='sorcerer'; character.subclass='draconic'; return /dragon ancestor/.test(validateClass()); }); // ---- HP formulas ---- t('HP: Standard Human Wizard d6+2=8', () => { _setupSheet('human','standard','wizard',null, {str:8,dex:14,con:14,int:15,wis:12,cha:8}); return parseInt(_findStat('Hit Points'),10) === 8; }); t('HP: Hill Dwarf Cleric: d8+3+1=12 (Toughness)', () => { _setupSheet('dwarf','hill','cleric','life', {str:13,dex:9,con:14,int:8,wis:15,cha:13}); return parseInt(_findStat('Hit Points'),10) === 12; }); t('HP: Mountain Dwarf Fighter: d10+3=13', () => { _setupSheet('dwarf','mountain','fighter',null, {str:13,dex:9,con:14,int:8,wis:15,cha:13}); return parseInt(_findStat('Hit Points'),10) === 13; }); t('HP: Half-Orc Barbarian: d12+3=15', () => { _setupSheet('halfOrc',null,'barbarian',null, {str:13,dex:14,con:15,int:13,wis:9,cha:8}); return parseInt(_findStat('Hit Points'),10) === 15; }); t('HP: Wood Elf Ranger: d10+2=12', () => { _setupSheet('elf','wood','ranger',null, {str:8,dex:15,con:14,int:13,wis:13,cha:9}); return parseInt(_findStat('Hit Points'),10) === 12; }); t('HP: Sorcerer Draconic +1/level (Resilience): 6+2+1=9', () => { _setupSheet('human','standard','sorcerer','draconic', {str:8,dex:14,con:14,int:10,wis:10,cha:15}); return parseInt(_findStat('Hit Points'),10) === 9; }); // ---- AC formulas ---- t('AC: Heavy + shield + Defense = 19', () => { _setupSheet('human','standard','fighter',null, {str:14,dex:14,con:14,int:8,wis:13,cha:9}); return parseInt(_findStat('Armor Class'),10) === 19; }); t('AC: Wood Elf Rogue Leather (light, no cap) = 14', () => { _setupSheet('elf','wood','rogue',null, {str:8,dex:15,con:14,int:13,wis:13,cha:9}); return parseInt(_findStat('Armor Class'),10) === 14; }); t('AC: Wood Elf Monk Unarmored 10+Dex+Wis = 15', () => { _setupSheet('elf','wood','monk',null, {str:13,dex:14,con:13,int:10,wis:14,cha:9}); return parseInt(_findStat('Armor Class'),10) === 15; }); t('AC: Half-Orc Barbarian Unarmored 10+Dex+Con = 15', () => { _setupSheet('halfOrc',null,'barbarian',null, {str:13,dex:14,con:15,int:13,wis:9,cha:8}); return parseInt(_findStat('Armor Class'),10) === 15; }); t('AC: Sorcerer Draconic 13 + Dex = 15', () => { _setupSheet('human','standard','sorcerer','draconic', {str:8,dex:14,con:14,int:10,wis:10,cha:15}); return parseInt(_findStat('Armor Class'),10) === 15; }); // ---- Spell save DC ---- t('Wizard DC = 8+prof+Int', () => { _setupSheet('human','standard','wizard',null, {str:8,dex:14,con:14,int:15,wis:12,cha:8}); const m = document.getElementById('content').textContent.match(/[Ss]ave DC[:\s]*?(\d+)/); return m && parseInt(m[1],10) === 13; }); t('Cleric DC = 8+prof+Wis', () => { _setupSheet('human','standard','cleric','life', {str:9,dex:13,con:14,int:8,wis:15,cha:13}); const m = document.getElementById('content').textContent.match(/[Ss]ave DC[:\s]*?(\d+)/); return m && parseInt(m[1],10) === 13; }); // ---- Initiative & Alert ---- t('Initiative = Dex mod', () => { _setupSheet('elf','wood','ranger',null, {str:8,dex:15,con:14,int:13,wis:13,cha:9}); return _findStat('Initiative') === '+3'; }); t('Alert feat: +5 to initiative (Variant Human)', () => { reset(); character.race='human'; character.subrace='variant'; character.humanVariantAsis=['dex','con']; character.humanVariantSkill='Acrobatics'; character.humanVariantFeat='alert'; character.raceLanguages=['Elvish']; character.class='fighter'; character.classSkills=['Athletics','Perception']; character.fightingStyle='Defense'; character.equipmentChoices={0:'chain mail',1:'a martial weapon and a shield',2:'a light crossbow and 20 bolts',3:"a dungeoneer's pack"}; character.equipmentSubpicks={1:'Longsword'}; character.background='soldier'; character.bgToolChoices={0:'Dice set'}; character.baseScores={str:8,dex:15,con:14,int:13,wis:13,cha:9}; character.name='X'; character.ambitions='a'; character.follies='a'; character.backstory='a'; saveCharacter(); goToStep(6); return _findStat('Initiative') === '+8'; }); // ---- Passive Perception ---- t('PP: 10 + Wis mod (no Perception prof)', () => { _setupSheet('human','standard','fighter',null, {str:14,dex:13,con:14,int:13,wis:9,cha:10}); const idx = character.classSkills.indexOf('Perception'); if (idx >= 0) character.classSkills.splice(idx, 1, 'History'); saveCharacter(); goToStep(6); return parseInt(_findStat('Passive Perception'),10) === 10; }); t('PP: + prof when Perception proficient', () => { _setupSheet('human','standard','fighter',null, {str:14,dex:13,con:14,int:13,wis:9,cha:10}); if (!character.classSkills.includes('Perception')) { character.classSkills[1] = 'Perception'; saveCharacter(); goToStep(6); } return parseInt(_findStat('Passive Perception'),10) === 12; }); t('PP: Observant feat +5', () => { reset(); character.race='human'; character.subrace='variant'; character.humanVariantAsis=['str','con']; character.humanVariantSkill='Athletics'; character.humanVariantFeat='observant'; character.featChoices={asiAbility:'wis'}; character.raceLanguages=['Elvish']; character.class='fighter'; character.classSkills=['Athletics','History']; character.fightingStyle='Defense'; character.equipmentChoices={0:'chain mail',1:'a martial weapon and a shield',2:'a light crossbow and 20 bolts',3:"a dungeoneer's pack"}; character.equipmentSubpicks={1:'Longsword'}; character.background='soldier'; character.bgToolChoices={0:'Dice set'}; character.baseScores={str:13,dex:13,con:14,int:13,wis:13,cha:8}; character.name='X'; character.ambitions='a'; character.follies='a'; character.backstory='a'; saveCharacter(); goToStep(6); return parseInt(_findStat('Passive Perception'),10) === 17; }); // ---- Racial trait surfacing ---- const _TRAITS = [ ['dwarf','hill','Dwarven Resilience'], ['dwarf','hill','Stonecunning'], ['dwarf','hill','Dwarven Toughness'], ['dwarf','mountain','Dwarven Armor Training'], ['elf','high','Trance'], ['elf','high','Fey Ancestry'], ['elf','wood','Mask of the Wild'], ['elf','wood','Fleet of Foot'], ['elf','drow','Sunlight Sensitivity'], ['elf','drow','Superior Darkvision'], ['halfling','lightfoot','Lucky'], ['halfling','lightfoot','Brave'], ['halfling','lightfoot','Naturally Stealthy'], ['halfling','stout','Stout Resilience'], ['gnome','forest','Gnome Cunning'], ['gnome','forest','Speak with Small Beasts'], ['gnome','rock','Tinker'], ['halfOrc',null,'Relentless Endurance'], ['halfOrc',null,'Savage Attacks'], ['tiefling',null,'Hellish Resistance'], ['tiefling',null,'Infernal Legacy'], ['dragonborn',null,'Breath Weapon'] ]; for (const [race, sub, trait] of _TRAITS) { t(`Sheet: ${race}${sub?'/'+sub:''} shows "${trait}"`, () => { _setupSheet(race, sub, 'fighter', null, {str:14,dex:13,con:14,int:13,wis:9,cha:10}); return document.getElementById('content').textContent.includes(trait); }); } // ---- Class feature surfacing ---- t('Sheet: Rogue Sneak Attack', () => { _setupSheet('human','standard','rogue',null, {str:8,dex:15,con:14,int:13,wis:13,cha:9}); return /Sneak Attack/.test(document.getElementById('content').textContent); }); t("Sheet: Rogue Thieves' Cant", () => { _setupSheet('human','standard','rogue',null, {str:8,dex:15,con:14,int:13,wis:13,cha:9}); return /Thieves['’]?\s*Cant/.test(document.getElementById('content').textContent); }); t('Sheet: Druid Druidic', () => { _setupSheet('human','standard','druid',null, {str:9,dex:13,con:14,int:8,wis:15,cha:13}); return /Druidic/.test(document.getElementById('content').textContent); }); t('Sheet: Barbarian Rage', () => { _setupSheet('halfOrc',null,'barbarian',null, {str:13,dex:14,con:15,int:13,wis:9,cha:8}); return /Rage/.test(document.getElementById('content').textContent); }); t('Sheet: Monk Martial Arts', () => { _setupSheet('elf','wood','monk',null, {str:13,dex:14,con:13,int:10,wis:14,cha:9}); return /Martial Arts/.test(document.getElementById('content').textContent); }); t('Sheet: Paladin Lay on Hands', () => { _setupSheet('dragonborn',null,'paladin',null, {str:15,dex:8,con:14,int:9,wis:13,cha:13}); return /Lay on Hands/.test(document.getElementById('content').textContent); }); // ---- Cleric domain spells ---- const _DOMS = { knowledge:['Command','Identify'], life:['Bless','Cure Wounds'], light:['Burning Hands','Faerie Fire'], nature:['Animal Friendship','Speak with Animals'], tempest:['Fog Cloud','Thunderwave'], trickery:['Charm Person','Disguise Self'], war:['Divine Favor','Shield of Faith'] }; for (const [dom, spells] of Object.entries(_DOMS)) { t(`Cleric ${dom} domain spells [${spells.join(', ')}] on sheet`, () => { _setupSheet('human','standard','cleric',dom, {str:9,dex:13,con:14,int:8,wis:15,cha:13}); const txt = document.getElementById('content').textContent; for (const s of spells) if (!txt.includes(s)) return 'missing '+s; return true; }); } // ---- Warlock patron features ---- t('Warlock Fiend: Dark One Blessing on sheet', () => { _setupSheet('tiefling',null,'warlock','fiend', {str:8,dex:14,con:14,int:10,wis:10,cha:15}); return /Dark One/.test(document.getElementById('content').textContent); }); t('Warlock Archfey: Fey Presence on sheet', () => { _setupSheet('tiefling',null,'warlock','archfey', {str:8,dex:14,con:14,int:10,wis:10,cha:15}); return /Fey Presence/.test(document.getElementById('content').textContent); }); t('Warlock GOO: Awakened Mind on sheet', () => { _setupSheet('tiefling',null,'warlock','greatoldone', {str:8,dex:14,con:14,int:10,wis:10,cha:15}); return /Awakened Mind/.test(document.getElementById('content').textContent); }); // ---- Equipment subpick required ---- t('Equip subpick required for "any martial weapon"', () => { reset(); character.race='human'; character.subrace='standard'; character.raceLanguages=['Elvish']; character.class='paladin'; character.classSkills=['Persuasion','Insight']; character.equipmentChoices={0:'any martial weapon and a shield',1:'five javelins',2:"a priest's pack",3:'chain mail and a holy symbol'}; return /specific martial weapon/i.test(validateClass() || ''); }); // ---- Spell list isolation ---- t('Cleric list does NOT contain Magic Missile', () => !SPELL_LISTS.cleric[1].includes('Magic Missile')); t('Wizard list contains Magic Missile + Find Familiar', () => SPELL_LISTS.wizard[1].includes('Magic Missile') && SPELL_LISTS.wizard[1].includes('Find Familiar')); t('Druid list contains Goodberry + Entangle', () => SPELL_LISTS.druid[1].includes('Goodberry') && SPELL_LISTS.druid[1].includes('Entangle')); t('Warlock cantrips include Eldritch Blast', () => SPELL_LISTS.warlock.cantrips.includes('Eldritch Blast')); t('Sorcerer 1st includes Chromatic Orb', () => SPELL_LISTS.sorcerer[1].includes('Chromatic Orb')); t('Bard cantrips include Vicious Mockery', () => SPELL_LISTS.bard.cantrips.includes('Vicious Mockery')); // ---- Conflict detection in skill picker ---- t('Class-skill picker marks bg-overlap as conflict (Rogue+Criminal)', () => { reset(); character.race='human'; character.subrace='standard'; character.raceLanguages=['Elvish']; character.class='rogue'; character.background='criminal'; character.bgToolChoices={0:'Dice set'}; saveCharacter(); goToStep(4); const stealth = [...document.querySelectorAll('[data-cs]')].find(el => el.querySelector('span').textContent === 'Stealth'); return stealth && stealth.classList.contains('conflict'); }); t('Class-skill picker marks race-grant as conflict (Wood Elf+Perception)', () => { reset(); character.race='elf'; character.subrace='wood'; character.class='fighter'; character.background='soldier'; character.bgToolChoices={0:'Dice set'}; saveCharacter(); goToStep(4); const perc = [...document.querySelectorAll('[data-cs]')].find(el => el.querySelector('span').textContent === 'Perception'); return perc && perc.classList.contains('conflict'); }); // ---- Weapon proficiency edge cases ---- t('Drow proficient with Hand crossbow', () => { reset(); character.race='elf'; character.subrace='drow'; character.class='wizard'; return isWeaponProficient('Hand crossbow'); }); t('Wood Elf proficient with Longbow', () => { reset(); character.race='elf'; character.subrace='wood'; character.class='cleric'; character.subclass='life'; return isWeaponProficient('Longbow'); }); t('Dwarf proficient with Battleaxe (race weapon training)', () => { reset(); character.race='dwarf'; character.subrace='hill'; character.class='wizard'; return isWeaponProficient('Battleaxe'); }); t('Sorcerer NOT proficient with longsword', () => { reset(); character.race='human'; character.subrace='standard'; character.class='sorcerer'; return isWeaponProficient('Longsword') === false; }); // ---- 9×12×13 = 1404 exhaustive coverage ---- t('Exhaustive: all 9 races × 12 classes × 13 backgrounds render valid sheets', () => { const races = Object.keys(RACES); const classes = Object.keys(CLASSES); const bgs = Object.keys(BACKGROUNDS); let ok = 0; const fails = []; for (const rk of races) { for (const ck of classes) { for (const bk of bgs) { try { reset(); character.race = rk; const r = getRace(); if (r.subraces && Object.keys(r.subraces).length) character.subrace = Object.keys(r.subraces)[0]; if (r.needsAncestry) character.draconicAncestry='Red'; if (r.needsDwarfTool) character.dwarfTool="Smith's tools"; if (rk === 'halfElf') { character.halfElfBonusAbilities = ['str','dex']; character.halfElfSkills = ['Acrobatics','Investigation']; } const sub = getSubrace(); const langSlots = (r.extraLanguages || 0) + ((sub && sub.extraLanguages) || 0); if (langSlots > 0) { const known = new Set(r.languages); character.raceLanguages = LANGUAGES.filter(l=>!known.has(l)).slice(0, langSlots); } if (sub && sub.cantripFrom) character.highElfCantrip = 'Fire Bolt'; if (sub && sub.humanVariantAsis) { character.humanVariantAsis=['str','dex']; character.humanVariantSkill='Performance'; character.humanVariantFeat='alert'; } character.class = ck; const cl = getClass(); const subData = getSubclassData(); if (subData) { character.subclass = Object.keys(subData.options)[0]; const sc = getSubclass(); if (sc && sc.needsDragonType) character.draconicAncestor='Red'; } const choices = cl.skillChoices === 'any' ? Object.keys(SKILLS) : cl.skillChoices; const taken = new Set(); if (r.grantedSkills) r.grantedSkills.forEach(s => taken.add(s)); if (sub && sub.grantedSkills) sub.grantedSkills.forEach(s => taken.add(s)); BACKGROUNDS[bk].skills.forEach(s => taken.add(s)); if (rk === 'halfElf') character.halfElfSkills.forEach(s => taken.add(s)); if (character.humanVariantSkill) taken.add(character.humanVariantSkill); character.classSkills = choices.filter(s => !taken.has(s)).slice(0, cl.skillCount); if (cl.name === 'Fighter') character.fightingStyle='Defense'; if (ck === 'ranger') { character.favoredEnemy = 'Beasts'; character.favoredTerrain = 'Forest'; } if (cl.expertiseChoices) character.expertise = character.classSkills.slice(0, cl.expertiseChoices); cl.equipment.forEach((line, i) => { if (line.includes(' OR ')) { let opts = line.split(' OR ').filter(isEquipOptionAllowed); if (character.class === 'druid') opts = opts.filter(o => !/scale mail|chain mail|chain shirt|plate armor|splint armor|ring mail|half plate|breastplate/i.test(o)); character.equipmentChoices[i] = cleanEquipOpt(opts[0]); } const lineText = line.includes(' OR ') ? character.equipmentChoices[i] : line; const picker = placeholderPickList(lineText); if (picker) { const count = picker.count || 1; character.equipmentSubpicks[i] = count > 1 ? Array(count).fill(picker.list[0]) : picker.list[0]; } }); if (cl.spellcasting) { const sl = SPELL_LISTS[ck]; character.cantrips = sl.cantrips.slice(0, cl.spellcasting.cantrips); if (cl.spellcasting.spellsKnown) character.spells = sl[1].slice(0, cl.spellcasting.spellsKnown); } const subcl = getSubclass(); if (subcl) { if (subcl.extraLanguages) { const known = new Set(r.languages); if (sub && sub.grantedLanguages) sub.grantedLanguages.forEach(l => known.add(l)); if (subcl.grantedLanguages) subcl.grantedLanguages.forEach(l => known.add(l)); (character.raceLanguages || []).forEach(l => known.add(l)); character.domainExtraLanguages = LANGUAGES.filter(l => !known.has(l)).slice(0, subcl.extraLanguages); } if (subcl.domainSkillExpertise) character.domainExpertise = subcl.domainSkillExpertise.from.slice(0, subcl.domainSkillExpertise.count); if (subcl.domainCantripFrom) character.domainCantrip = SPELL_LISTS[subcl.domainCantripFrom].cantrips.find(c => !character.cantrips.includes(c)); if (subcl.domainSkillFrom) { const known = new Set([...taken, ...character.classSkills]); character.domainSkill = subcl.domainSkillFrom.find(s => !known.has(s)); } } character.background = bk; character.bgToolChoices = {}; BACKGROUNDS[bk].tools.forEach((tt, i) => { const opts = bgToolOptions(tt); if (opts) character.bgToolChoices[i] = opts.list[0]; }); if (BACKGROUNDS[bk].languages) { const known = new Set(r.languages); (character.raceLanguages || []).forEach(l => known.add(l)); (character.domainExtraLanguages || []).forEach(l => known.add(l)); character.bgLanguages = LANGUAGES.filter(l => !known.has(l)).slice(0, BACKGROUNDS[bk].languages); } character.baseScores = {str:14,dex:13,con:14,int:13,wis:9,cha:10}; character.name='X'; character.ambitions='a'; character.follies='a'; character.backstory='a'; for (let i = 0; i < 6; i++) { const v = STEPS[i].validate(); if (v !== true) { fails.push(`${rk}/${ck}/${bk}: ${STEPS[i].id}: ${v}`); throw new Error('skip'); } } saveCharacter(); goToStep(6); const cont = document.getElementById('content'); if (/Build incomplete/.test(cont.textContent)) { fails.push(`${rk}/${ck}/${bk}: refused`); continue; } if (!cont.querySelector('.combat-stats')) { fails.push(`${rk}/${ck}/${bk}: no stats`); continue; } ok++; } catch (e) { if (e.message !== 'skip') fails.push(`${rk}/${ck}/${bk}: ${e.message}`); } } } } return fails.length === 0 ? true : `${ok}/1404; ${fails.length} fails. First: ` + fails.slice(0,3).join(' || '); }); // -------- Restore -------- character = _origChar; currentStep = _origStep; if (_origStorage) localStorage.setItem(STORAGE_KEY, _origStorage); else localStorage.removeItem(STORAGE_KEY); goToStep(currentStep); return results; } // Test panel: opt-in via ?test=1 in the URL. function __renderTestPanel() { const results = __runTests(); const passed = results.filter(r => r.pass).length; const failed = results.filter(r => !r.pass); const panel = document.createElement('div'); panel.className = 'test-panel'; panel.innerHTML = `
Unit Tests
${passed}/${results.length} pass
×
${results.map(r => `
${r.pass?'✓':'✗'} ${escapeHtml(r.name)}${r.info ? `
— ${escapeHtml(r.info)}
` : ''}
`).join('')}
`; document.body.appendChild(panel); panel.querySelector('.test-close').addEventListener('click', () => panel.remove()); return { pass: passed, total: results.length, failed: failed.map(f => f.name) }; } // Expose for console use. window.__runTests = __runTests; window.__renderTestPanel = __renderTestPanel; if (window.location.search.includes('test=1') || window.location.hash.includes('test')) { setTimeout(__renderTestPanel, 250); } goToStep(0);