feat: inventory system, adventure scene engine, and PC generator fixes
- Inventory: items linked to PCs with name, qty, ENC, category; effective FP = STR+CON minus total ENC carried; CRUD API + inline UI on Characters tab - Adventure scene engine: new tab with procedural CYOA choices (Fight, Sneak, Talk, Investigate, Flee) resolved via skill checks; scenes generated from any rollable table with automatic L2 cascade; scene state persisted in DB - Fix total HP formula: was CON+SIZ, now correctly ceil((CON+SIZ)/2) per RQ3 - Fix hit location HPs: were computed from wrong total HP, now correct - Add fatigue points (STR+CON) to derived stats - Add all seven skill category modifiers computed from characteristics (primary/secondary/negative influences per rulebook), shown in generator UI - Add base skill computation (computeBaseSkills) for use in scene engine - Add RQ3 Players Book to docs/ as reference
This commit is contained in:
@@ -55,6 +55,189 @@ function rollCharacteristics() {
|
||||
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.
|
||||
@@ -898,6 +1081,16 @@ module.exports = {
|
||||
maxNotation,
|
||||
rollPercentile,
|
||||
rollCharacteristics,
|
||||
DELIBERATE_METHOD,
|
||||
validateDeliberate,
|
||||
validateCombined,
|
||||
computeSkillCategoryModifiers,
|
||||
deriveCharacterStats,
|
||||
BASE_SKILLS,
|
||||
SKILL_CATEGORY_MAP,
|
||||
computeBaseSkills,
|
||||
SCENE_CHOICES,
|
||||
resolveSceneChoice,
|
||||
dexStrikeRank,
|
||||
sizStrikeRankModifier,
|
||||
baseStrikeRank,
|
||||
|
||||
Reference in New Issue
Block a user