Seven steps in one commit, 1.15.0 to 1.21.0. They change the same files and were not saved apart, so they go in together. - The battle at a glance (1.15). A strip across the top of an encounter lists everyone in turn order and stays in view; a click goes to that card. On a wide screen the stat block of whoever is on turn stays beside the cards, and Pin keeps anyone else's there. Saving throws, every skill and passive Perception, Investigation and Insight are on encounter cards and in the character panel, which "Sheet" on a card and a button on the party overview open. For the DM, N is Next Turn and S opens the sheet of whoever is on turn. The party overview shows passive scores and spell save DC. - Subclasses (1.16). Three per class, picked in the level-up checklist at level 2 (cleric, druid, wizard) or 3, shown beside the class everywhere, and changed by the DM from the sheet: the old one's lines come out of Special Abilities and the new one's go in. Characters already at that level keep the path they were on. - Character state (1.17). Inspiration, exhaustion and conditions on the sheet and in the panel, and counters for what a class does a set number of times (Rage, Ki, Channel Divinity...), which the player spends and a rest gives back. A long rest takes a level of exhaustion off. A character's conditions are their own now: they stay from one encounter to the next. - Faster battles (1.18). Up to 20 of a monster added at once, numbered. One button rolls initiative for the monsters and NPCs. A condition can be given a number of rounds and then comes off by itself. Hovering over a condition shows its rule, and damaging someone who is Concentrating shows the save to call for. - Rolls from the sheet (1.19). A number with a dotted underline rolls when clicked, with its bonus: skills, saves, ability checks, attacks, damage, spell dice, and a monster's attack, damage and abilities. The dice roller has advantage and disadvantage. "Call for a roll" rolls one check or save for the whole party. Rolls stay in the browser. - XP and difficulty (1.20). An encounter shows the DM how hard it is, Trivial to Deadly, from the monsters' challenge ratings against the characters in it, and what it is worth in XP; one button shares that out, once. The sheet and the party overview show XP and when a level is due. Levelling up stays the DM's decision. - Subclasses are optional per campaign (1.21). With "Characters choose a subclass" unticked in the campaign's Settings nobody is asked, each class follows its first subclass, and none is named. - Conditions, exhaustion, counters and subclass features are reminders for the table: none of them changes a number on the sheet. - The docs cover all of it, with generated tables of what each subclass adds. - New migrations: a character's subclass; inspiration, exhaustion, conditions and spent counters on a character; the rounds left on a combatant's conditions; the XP awarded for an encounter; the campaign's subclass setting. - All packages at version 1.21.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
111 lines
5.3 KiB
JavaScript
111 lines
5.3 KiB
JavaScript
// Resting (the Hollowcrown book's Rules Cheat Sheet, "Resting"). Each function
|
|
// takes a finished character and its sheet and returns the columns to write
|
|
// plus an account of what happened, for the DM to read. Pure: no DB calls.
|
|
//
|
|
// Short rest (an hour): spend Hit Dice to heal, each one the class's die plus
|
|
// the Constitution modifier. A warlock's spell slots come back.
|
|
// Class counters that restore on a short rest come back.
|
|
// Long rest (a night): all HP, all spell slots, every class counter, Hit Dice
|
|
// up to half the level (at least one), one level of exhaustion less, and temp
|
|
// HP is gone, except what the Hardy Boon gives.
|
|
|
|
const { changeHp, lifeState, NO_DEATH_SAVES } = require('./hp');
|
|
const { hardyTempHp } = require('../game/levelUp');
|
|
const { restResources } = require('../game/classResources');
|
|
|
|
// Once a day on a short rest a wizard or a Land druid (from level 2) gets some
|
|
// spent slots back. Which ones is the player's choice, so the DM puts them
|
|
// back on the sheet; this is the reminder, or null when it does not apply.
|
|
function recoveryNote(character, sheet) {
|
|
const feature = character.characterClass === 'WIZARD' ? 'Arcane Recovery' : character.characterClass === 'DRUID' && character.level >= 2 && character.subclass === 'LAND' ? 'Natural Recovery' : null;
|
|
if (!feature || !sheet.spellSlots || !sheet.spellSlots.levels.some((slot) => slot.used > 0)) return null;
|
|
const budget = Math.ceil(character.level / 2);
|
|
return `${feature}: once per day, ${character.name} can regain spent slots adding up to ${budget} slot level${budget === 1 ? '' : 's'} (none of 6th level or higher). Put them back on the sheet.`;
|
|
}
|
|
|
|
// The class counters a rest refills: the tally to store and a line for the DM.
|
|
function refill(sheet, restType) {
|
|
const { resourcesUsed, restored } = restResources(sheet.resources || [], restType);
|
|
return { resourcesUsed, notes: restored.length > 0 ? [`Back: ${restored.join(', ')}.`] : [] };
|
|
}
|
|
|
|
// A problem with the Hit Dice asked for, or null. `rolled` is the total the
|
|
// dice showed, for dice rolled at the table.
|
|
function hitDiceProblem(character, sheet, dice, rolled) {
|
|
if (dice === 0) return null;
|
|
const { size, remaining } = sheet.hitDice;
|
|
if (lifeState(character) === 'DYING') return `${character.name} is dying: steady or heal them before they spend Hit Dice.`;
|
|
if (dice > remaining) return `${character.name} has ${remaining} Hit ${remaining === 1 ? 'Die' : 'Dice'} left, not ${dice}.`;
|
|
if (rolled != null && (rolled < dice || rolled > dice * size)) {
|
|
return `${character.name}: ${dice}d${size} adds up to between ${dice} and ${dice * size}, not ${rolled}.`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// `rolls` is what each Hit Die showed, or `rolled` their total when they were
|
|
// rolled at the table. The Constitution modifier is added once per die, and
|
|
// the rest never takes HP away.
|
|
function shortRest(character, sheet, { dice = 0, rolls = null, rolled = null } = {}) {
|
|
const total = dice === 0 ? 0 : rolls ? rolls.reduce((sum, roll) => sum + roll, 0) : rolled;
|
|
const conBonus = dice * sheet.abilities.CON.modifier;
|
|
const healed = Math.max(0, total + conBonus);
|
|
const pact = Boolean(sheet.spellSlots) && sheet.spellSlots.restoresOn === 'SHORT';
|
|
const slotsRestored = pact && sheet.spellSlots.levels.some((slot) => slot.used > 0);
|
|
|
|
const counters = refill(sheet, 'SHORT');
|
|
|
|
const data = {
|
|
...(healed > 0 ? changeHp(character, character.currentHp + healed, sheet.hp.max) : {}),
|
|
hitDiceUsed: sheet.hitDice.used + dice,
|
|
...(pact ? { spellSlotsUsed: [] } : {}),
|
|
resourcesUsed: counters.resourcesUsed,
|
|
};
|
|
const hpAfter = data.currentHp ?? character.currentHp;
|
|
return {
|
|
data,
|
|
result: {
|
|
hpBefore: character.currentHp,
|
|
hpAfter,
|
|
maxHp: sheet.hp.max,
|
|
tempHp: character.tempHp,
|
|
hitDice: { size: sheet.hitDice.size, spent: dice, rolls, total, conBonus, healed: hpAfter - character.currentHp, left: sheet.hitDice.remaining - dice, of: sheet.hitDice.count, regained: 0 },
|
|
slotsRestored,
|
|
notes: [...counters.notes, recoveryNote(character, sheet)].filter(Boolean),
|
|
},
|
|
};
|
|
}
|
|
|
|
function longRest(character, sheet) {
|
|
const regained = Math.min(sheet.hitDice.used, Math.max(1, Math.floor(character.level / 2)));
|
|
const tempHp = hardyTempHp(character.specialAbilities);
|
|
const counters = refill(sheet, 'LONG');
|
|
const exhaustion = Math.max(0, (character.exhaustion || 0) - 1);
|
|
const data = {
|
|
currentHp: sheet.hp.max,
|
|
tempHp,
|
|
...NO_DEATH_SAVES,
|
|
hitDiceUsed: sheet.hitDice.used - regained,
|
|
spellSlotsUsed: [],
|
|
resourcesUsed: counters.resourcesUsed,
|
|
exhaustion,
|
|
};
|
|
return {
|
|
data,
|
|
result: {
|
|
hpBefore: character.currentHp,
|
|
hpAfter: sheet.hp.max,
|
|
maxHp: sheet.hp.max,
|
|
tempHp,
|
|
hitDice: { size: sheet.hitDice.size, spent: 0, rolls: null, total: 0, conBonus: 0, healed: 0, left: sheet.hitDice.remaining + regained, of: sheet.hitDice.count, regained },
|
|
slotsRestored: Boolean(sheet.spellSlots) && sheet.spellSlots.levels.some((slot) => slot.used > 0),
|
|
notes: [
|
|
...counters.notes,
|
|
...(character.exhaustion > 0 ? [exhaustion > 0 ? `Exhaustion down to level ${exhaustion}.` : 'No longer exhausted.'] : []),
|
|
...(tempHp > 0 ? [`Hardy: ${tempHp} Temp HP for the day.`] : []),
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
module.exports = { hitDiceProblem, shortRest, longRest };
|