Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8811f14732 | ||
|
|
c3f2b0dfa7 | ||
|
|
86ef106969 |
@@ -99,6 +99,18 @@ including a character sheet or an encounter) and **Account**.
|
||||
checklist at level 2 or 3, and shown beside the class everywhere; the DM can
|
||||
change it from the sheet, and switch a 2014 path to its 2024 update. Every
|
||||
sheet shows **the road ahead**: what each level from 1 to 20 gives.
|
||||
- **Dice that roll.** Each die is drawn as its own shape, tumbles in and
|
||||
lands on its result; a natural 20 glows. In a campaign the server makes
|
||||
every roll and the **DM sees each one** as it lands, in the roller's
|
||||
"Everyone's rolls" tab, beside the dice button, and on the encounter page.
|
||||
A campaign can play with digital dice, real dice typed in (and checked), or
|
||||
both, and can keep each player's rolls between them and the DM.
|
||||
- **Multiclassing** (a campaign setting): the level-up checklist asks which
|
||||
class gains the level, checks both classes' ability requirements, and works
|
||||
out HP, Hit Dice, spell slots, counters and features by each class's own
|
||||
level. The sheet suggests well-known multiclass routes from where the
|
||||
character stands. Eldritch Knights and Arcane Tricksters now have their
|
||||
spell slots.
|
||||
- **43 species** (the 2014 core, the 2024 Aasimar and Goliath, and the species
|
||||
of Monsters of the Multiverse) and **13 classes**, the Artificer included. A campaign can do without them: untick "Characters choose a
|
||||
subclass" in its Settings and every class follows one path, unasked.
|
||||
@@ -108,9 +120,13 @@ including a character sheet or an encounter) and **Account**.
|
||||
who sees a roll. **Combat**: auto-rolled monster and NPC attacks, the
|
||||
critical hit rule, monster HP, the area penalty. **Progression**: XP
|
||||
tracking (off for milestone levelling) and HP on a level up.
|
||||
- **⬆ Level Up…** on a sheet (DM) takes the character up a level with the
|
||||
whole checklist: HP, the class's new special abilities, a Boon, spells and
|
||||
the free weapon upgrade.
|
||||
- **⬆ Level up…** (DM, beside Rest) approves a level for any of the party at
|
||||
once. Each player gets a notice, sees what the level gives and makes their
|
||||
own picks in the checklist: HP (the average, or a roll where the campaign
|
||||
rolls), the class's new special abilities, a subclass, a Boon, spells and
|
||||
the free weapon upgrade. A level waiting shows on the sheet and the
|
||||
campaign page until it is taken; the DM sees who still has picks to make,
|
||||
and can finish any of them.
|
||||
- The character sheet is long, so it starts with a row of **section links**
|
||||
that jump straight to that part of the page.
|
||||
- On a small screen every table (weapons, items, spells, monsters, the party
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.23.0",
|
||||
"version": "1.26.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Multiclassing: levels a character takes in classes besides their first.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "character_class_levels" (
|
||||
"id" TEXT NOT NULL,
|
||||
"characterId" TEXT NOT NULL,
|
||||
"characterClass" "CharacterClass" NOT NULL,
|
||||
"level" INTEGER NOT NULL DEFAULT 1,
|
||||
"subclass" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "character_class_levels_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "character_class_levels_characterId_characterClass_key" ON "character_class_levels"("characterId", "characterClass");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "character_class_levels" ADD CONSTRAINT "character_class_levels_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Level-ups the DM approves for players to take, and rolled HP on a level up.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "characters" ADD COLUMN "hpRollAdjust" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "levelUpsPending" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Rolls made at the table's dice roller, kept so the DM can see each one.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dice_rolls" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"rollerName" TEXT NOT NULL,
|
||||
"label" TEXT,
|
||||
"notation" TEXT NOT NULL,
|
||||
"dice" JSONB NOT NULL,
|
||||
"modifier" INTEGER NOT NULL DEFAULT 0,
|
||||
"total" INTEGER NOT NULL,
|
||||
"natural" INTEGER,
|
||||
"mode" TEXT NOT NULL DEFAULT 'NORMAL',
|
||||
"physical" BOOLEAN NOT NULL DEFAULT false,
|
||||
"hidden" BOOLEAN NOT NULL DEFAULT false,
|
||||
"encounterId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "dice_rolls_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dice_rolls_campaignId_createdAt_idx" ON "dice_rolls"("campaignId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dice_rolls" ADD CONSTRAINT "dice_rolls_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -290,10 +290,40 @@ model Campaign {
|
||||
saleOffers SaleOffer[]
|
||||
notes Note[]
|
||||
locations Location[]
|
||||
diceRolls DiceRoll[]
|
||||
|
||||
@@map("campaigns")
|
||||
}
|
||||
|
||||
// A roll made at the table's dice roller, kept so the table (and above all
|
||||
// the DM) can see it. Digital rolls are made by the server; physical ones are
|
||||
// typed in and checked against their dice. Who sees a roll follows the
|
||||
// campaign's rollVisibility; a `hidden` one is the DM's alone.
|
||||
model DiceRoll {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
userId String
|
||||
rollerName String // who rolled, as shown: kept so a roll outlives a renamed account
|
||||
label String? // what it was for: "Aria: Stealth"
|
||||
notation String // "1d20+3", "2d6+1d8+2"
|
||||
// Every die, in order: [{ sides, value, kept, sign }]. `kept` is false for
|
||||
// the d20 that advantage or disadvantage dropped.
|
||||
dice Json
|
||||
modifier Int @default(0)
|
||||
total Int
|
||||
natural Int? // the d20 that counted, for a roll of a single d20
|
||||
mode String @default("NORMAL") // NORMAL | ADVANTAGE | DISADVANTAGE
|
||||
physical Boolean @default(false) // rolled at the table and typed in
|
||||
hidden Boolean @default(false) // the DM's alone
|
||||
encounterId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([campaignId, createdAt])
|
||||
@@map("dice_rolls")
|
||||
}
|
||||
|
||||
model Location {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
@@ -357,6 +387,13 @@ model Character {
|
||||
// until the class reaches the level it chooses at.
|
||||
subclass String?
|
||||
level Int @default(1)
|
||||
// Levels the DM has approved that the character has not yet taken: each is
|
||||
// one level-up checklist waiting for its picks. The player resolves them
|
||||
// (or the DM does), one at a time.
|
||||
levelUpsPending Int @default(0)
|
||||
// Where the campaign rolls HP on a level up: how far the rolls so far are
|
||||
// from the average they replace, in all (a 2 on a d8 is -3). Added to max HP.
|
||||
hpRollAdjust Int @default(0)
|
||||
xp Int @default(0)
|
||||
motivation String?
|
||||
|
||||
@@ -436,12 +473,31 @@ model Character {
|
||||
notes Note[]
|
||||
encounterEntries EncounterParticipant[]
|
||||
locationVisits CharacterLocationVisit[]
|
||||
// Levels in classes other than characterClass, for a multiclassed character
|
||||
// (game/multiclass.js). The first class's own level is `level` less these.
|
||||
classLevels CharacterClassLevel[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@index([userId])
|
||||
@@map("characters")
|
||||
}
|
||||
|
||||
// A class a character has taken levels in besides their first (characterClass),
|
||||
// with its own level and subclass. A single-class character has none.
|
||||
model CharacterClassLevel {
|
||||
id String @id @default(cuid())
|
||||
characterId String
|
||||
characterClass CharacterClass
|
||||
level Int @default(1)
|
||||
subclass String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([characterId, characterClass])
|
||||
@@map("character_class_levels")
|
||||
}
|
||||
|
||||
model CharacterSkillProficiency {
|
||||
id String @id @default(cuid())
|
||||
characterId String
|
||||
|
||||
@@ -7,7 +7,16 @@ const { CLASSES } = require('./data/classes');
|
||||
const { SKILLS } = require('./data/skills');
|
||||
const { resourceState } = require('./classResources');
|
||||
const { CR_XP, XP_THRESHOLDS, COUNT_MULTIPLIERS, LEVEL_XP } = require('./data/xp');
|
||||
const { FULL_CASTER_SLOTS, HALF_CASTER_SLOTS, PACT_SLOTS, CASTER_KIND, CLASS_SLOT_TABLES, MAX_SLOT_LEVEL } = require('./data/spellSlots');
|
||||
const {
|
||||
FULL_CASTER_SLOTS,
|
||||
HALF_CASTER_SLOTS,
|
||||
THIRD_CASTER_SLOTS,
|
||||
PACT_SLOTS,
|
||||
CASTER_KIND,
|
||||
CLASS_SLOT_TABLES,
|
||||
SUBCLASS_CASTER_KIND,
|
||||
MAX_SLOT_LEVEL,
|
||||
} = require('./data/spellSlots');
|
||||
|
||||
const ABILITIES = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
|
||||
|
||||
@@ -122,9 +131,18 @@ function getClassHitDieSize(characterClass) {
|
||||
return data ? data.hitDie : 8;
|
||||
}
|
||||
|
||||
function getClassSpellcastingAbility(characterClass) {
|
||||
// The ability a class casts with; a subclass that casts in a class that does
|
||||
// not (Eldritch Knight, Arcane Trickster) uses Intelligence.
|
||||
function getClassSpellcastingAbility(characterClass, subclass = null) {
|
||||
const data = characterClass ? CLASSES[characterClass] : null;
|
||||
return data ? data.spellcastingAbility : null;
|
||||
if (data && data.spellcastingAbility) return data.spellcastingAbility;
|
||||
return SUBCLASS_CASTER_KIND[subclass] ? 'INT' : null;
|
||||
}
|
||||
|
||||
// How a class casts (FULL, HALF, THIRD or PACT), counting a casting subclass;
|
||||
// null for none.
|
||||
function casterKind(characterClass, subclass = null) {
|
||||
return CASTER_KIND[characterClass] || SUBCLASS_CASTER_KIND[subclass] || null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -140,15 +158,15 @@ function clampLevel(level) {
|
||||
// How many spell slots a class has at a level, by slot level (1st first), and
|
||||
// which rest gives them back. Null for a class that never has any. A warlock's
|
||||
// slots are all of one slot level, so the lower ones are 0.
|
||||
function spellSlotsFor(characterClass, level) {
|
||||
const kind = CASTER_KIND[characterClass];
|
||||
function spellSlotsFor(characterClass, level, subclass = null) {
|
||||
const kind = casterKind(characterClass, subclass);
|
||||
if (!kind) return null;
|
||||
const row = clampLevel(level) - 1;
|
||||
if (kind === 'PACT') {
|
||||
const [count, slotLevel] = PACT_SLOTS[row];
|
||||
return { kind, restoresOn: 'SHORT', max: Array.from({ length: slotLevel }, (_, i) => (i === slotLevel - 1 ? count : 0)) };
|
||||
}
|
||||
const table = CLASS_SLOT_TABLES[characterClass] || (kind === 'FULL' ? FULL_CASTER_SLOTS : HALF_CASTER_SLOTS);
|
||||
const table = CLASS_SLOT_TABLES[characterClass] || { FULL: FULL_CASTER_SLOTS, HALF: HALF_CASTER_SLOTS, THIRD: THIRD_CASTER_SLOTS }[kind];
|
||||
return { kind, restoresOn: 'LONG', max: [...table[row]] };
|
||||
}
|
||||
|
||||
@@ -157,8 +175,12 @@ function spellSlotsFor(characterClass, level) {
|
||||
// first), which may be shorter than the class's row or hold more than it now
|
||||
// allows. Null when there are no slots to track (not a caster, or a level 1
|
||||
// paladin).
|
||||
function spellSlotState(characterClass, level, used = []) {
|
||||
const slots = spellSlotsFor(characterClass, level);
|
||||
function spellSlotState(characterClass, level, used = [], subclass = null) {
|
||||
return slotStateOf(spellSlotsFor(characterClass, level, subclass), used);
|
||||
}
|
||||
|
||||
// The slot tracker for a set of slots (spellSlotsFor's shape).
|
||||
function slotStateOf(slots, used = []) {
|
||||
if (!slots) return null;
|
||||
const levels = slots.max
|
||||
.map((max, i) => {
|
||||
@@ -170,8 +192,8 @@ function spellSlotState(characterClass, level, used = []) {
|
||||
}
|
||||
|
||||
// The highest level of spell a class can cast at a level; 0 for none.
|
||||
function maxSpellLevel(characterClass, level) {
|
||||
const slots = spellSlotsFor(characterClass, level);
|
||||
function maxSpellLevel(characterClass, level, subclass = null) {
|
||||
const slots = spellSlotsFor(characterClass, level, subclass);
|
||||
return slots ? slots.max.length : 0;
|
||||
}
|
||||
|
||||
@@ -303,6 +325,7 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
speedOverride = null,
|
||||
spellSlotsUsed = [],
|
||||
hitDiceUsed = 0,
|
||||
hpRollAdjust = 0,
|
||||
} = character;
|
||||
|
||||
const rolled = { STR: strScore, DEX: dexScore, CON: conScore, INT: intScore, WIS: wisScore, CHA: chaScore };
|
||||
@@ -345,8 +368,20 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
};
|
||||
}
|
||||
|
||||
// Every class the character has levels in, the first first. One entry for
|
||||
// most characters; a multiclassed one adds up per class (multiclass.js).
|
||||
const classes = multiclass.classLevels(character);
|
||||
const multi = classes.length > 1;
|
||||
const hitDieSize = getClassHitDieSize(characterClass);
|
||||
const spellcastingAbility = getClassSpellcastingAbility(characterClass);
|
||||
const casters = classes
|
||||
.map((entry) => ({ ...entry, ability: getClassSpellcastingAbility(entry.characterClass, entry.subclass) }))
|
||||
.filter((entry) => entry.ability && (casterKind(entry.characterClass, entry.subclass) !== 'THIRD' || entry.level >= 3));
|
||||
const casting = (ability) => ({
|
||||
ability,
|
||||
saveDC: spellSaveDC(profBonus, abilities[ability].modifier),
|
||||
attackBonus: spellAttackBonus(profBonus, abilities[ability].modifier),
|
||||
});
|
||||
const spellcastingAbility = casters.length > 0 ? casters[0].ability : null;
|
||||
|
||||
return {
|
||||
abilities,
|
||||
@@ -354,8 +389,14 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
armorClass: armorClass(abilities.DEX.modifier, armorBonus) + fromGear('AC'),
|
||||
initiative: initiative(abilities.DEX.modifier) + fromGear('INITIATIVE'),
|
||||
speed: Math.max(0, effectiveSpeed(species, speedOverride) + fromGear('SPEED')),
|
||||
hp: { max: Math.max(1, hpMax(level, hitDieSize, abilities.CON.modifier) + fromGear('MAX_HP')) },
|
||||
hitDice: hitDiceState(characterClass, level, hitDiceUsed),
|
||||
hp: {
|
||||
// hpRollAdjust: how far HP rolled on level-ups is from the average counted here.
|
||||
max: Math.max(
|
||||
1,
|
||||
(multi ? multiclass.multiclassHpMax(classes, abilities.CON.modifier) : hpMax(level, hitDieSize, abilities.CON.modifier)) + (Number(hpRollAdjust) || 0) + fromGear('MAX_HP')
|
||||
),
|
||||
},
|
||||
hitDice: multi ? multiclass.multiclassHitDice(classes, hitDiceUsed) : hitDiceState(characterClass, level, hitDiceUsed),
|
||||
saves,
|
||||
skills,
|
||||
passivePerception: passivePerception(skills.PERCEPTION.bonus) + fromGear('PASSIVE_PERCEPTION'),
|
||||
@@ -363,17 +404,15 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
passiveInsight: passiveScore(skills.INSIGHT.bonus),
|
||||
// What equipped gear is doing to the numbers above, and what else it does.
|
||||
gear,
|
||||
spellcasting: spellcastingAbility
|
||||
? {
|
||||
ability: spellcastingAbility,
|
||||
saveDC: spellSaveDC(profBonus, abilities[spellcastingAbility].modifier),
|
||||
attackBonus: spellAttackBonus(profBonus, abilities[spellcastingAbility].modifier),
|
||||
}
|
||||
: null,
|
||||
spellcasting: spellcastingAbility ? casting(spellcastingAbility) : null,
|
||||
// A multiclassed caster's DC and attack for each class that casts.
|
||||
spellcastingByClass: casters.map((entry) => ({ characterClass: entry.characterClass, ...casting(entry.ability) })),
|
||||
// The slot tracker; null for a character with no spell slots.
|
||||
spellSlots: spellSlotState(characterClass, level, spellSlotsUsed),
|
||||
spellSlots: multi ? slotStateOf(multiclass.multiclassSpellSlots(classes), spellSlotsUsed) : spellSlotState(characterClass, level, spellSlotsUsed, character.subclass),
|
||||
// A multiclassed warlock's pact slots, apart from the others; null otherwise.
|
||||
pactSlots: multi ? multiclass.pactSlotsOf(classes) : null,
|
||||
// The class counters (Rage, Ki...), with how many of each are left.
|
||||
resources: resourceState(character, abilities, profBonus),
|
||||
resources: multi ? multiclass.multiclassResources(classes, character.resourcesUsed, abilities, profBonus) : resourceState(character, abilities, profBonus),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -524,6 +563,8 @@ module.exports = {
|
||||
MAX_LEVEL,
|
||||
MAX_SLOT_LEVEL,
|
||||
spellSlotsFor,
|
||||
slotStateOf,
|
||||
casterKind,
|
||||
spellSlotState,
|
||||
maxSpellLevel,
|
||||
hitDiceState,
|
||||
@@ -541,3 +582,7 @@ module.exports = {
|
||||
scaledDamageDice,
|
||||
canUseGear,
|
||||
};
|
||||
|
||||
// multiclass.js and this file use each other, so it is required here, at
|
||||
// the end, and its functions are looked up when called.
|
||||
const multiclass = require('./multiclass');
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Suggested multiclass routes: well-known ways to combine classes, for a
|
||||
// player choosing where their next level goes. Each route starts from a class
|
||||
// (`from`) and lists its `steps` in order: take `class` up to `toLevel` (that
|
||||
// class's own level). `subclass` is the subclass the route assumes, if any,
|
||||
// and `why` what it buys. The requirements are checked against the character
|
||||
// when shown (game/multiclass.js); none of this is enforced.
|
||||
|
||||
const MULTICLASS_PATHS = [
|
||||
{
|
||||
key: 'HEXADIN',
|
||||
name: 'Hexadin',
|
||||
from: 'PALADIN',
|
||||
steps: [{ class: 'PALADIN', toLevel: 2 }, { class: 'WARLOCK', toLevel: 1, subclass: 'HEXBLADE' }, { class: 'PALADIN', toLevel: 18 }],
|
||||
why: 'Hexblade lets you swing with Charisma, so one ability powers attacks, smites and auras.',
|
||||
},
|
||||
{
|
||||
key: 'SORCADIN',
|
||||
name: 'Sorcadin',
|
||||
from: 'PALADIN',
|
||||
steps: [{ class: 'PALADIN', toLevel: 6 }, { class: 'SORCERER', toLevel: 14 }],
|
||||
why: 'Aura of Protection, then a deep pool of spell slots to pour into Divine Smite.',
|
||||
},
|
||||
{
|
||||
key: 'SORLOCK',
|
||||
name: 'Sorlock',
|
||||
from: 'SORCERER',
|
||||
steps: [{ class: 'SORCERER', toLevel: 2 }, { class: 'WARLOCK', toLevel: 2 }, { class: 'SORCERER', toLevel: 18 }],
|
||||
why: 'Agonizing Eldritch Blast, quickened with Metamagic, and pact slots that come back on a short rest.',
|
||||
},
|
||||
{
|
||||
key: 'GLOOM_ASSASSIN',
|
||||
name: 'Gloom Assassin',
|
||||
from: 'RANGER',
|
||||
steps: [{ class: 'RANGER', toLevel: 5, subclass: 'GLOOM_STALKER' }, { class: 'ROGUE', toLevel: 3, subclass: 'ASSASSIN' }, { class: 'RANGER', toLevel: 12 }],
|
||||
why: 'An enormous first round: extra attacks, advantage and automatic critical hits on the surprised.',
|
||||
},
|
||||
{
|
||||
key: 'FIGHTER_DIP',
|
||||
name: 'Fighter dip for anyone who swings',
|
||||
from: null,
|
||||
steps: [{ class: 'FIGHTER', toLevel: 2 }],
|
||||
why: 'Two levels give a fighting style, Second Wind and Action Surge: one huge turn per rest.',
|
||||
},
|
||||
{
|
||||
key: 'CLERIC_DIP',
|
||||
name: 'Cleric dip for armor and healing',
|
||||
from: 'WIZARD',
|
||||
steps: [{ class: 'CLERIC', toLevel: 1 }, { class: 'WIZARD', toLevel: 19 }],
|
||||
why: 'Armor and shields, healing words and a domain’s gifts on a wizard, for one level.',
|
||||
},
|
||||
{
|
||||
key: 'BLADESINGER_FIGHTER',
|
||||
name: 'Fighting wizard',
|
||||
from: 'FIGHTER',
|
||||
steps: [{ class: 'FIGHTER', toLevel: 2 }, { class: 'WIZARD', toLevel: 18, subclass: 'BLADESINGING' }],
|
||||
why: 'Starts in armor with Action Surge, then grows into a Bladesinger with real spells.',
|
||||
},
|
||||
{
|
||||
key: 'ROGUE_FIGHTER',
|
||||
name: 'Skirmisher',
|
||||
from: 'ROGUE',
|
||||
steps: [{ class: 'ROGUE', toLevel: 1 }, { class: 'FIGHTER', toLevel: 2 }, { class: 'ROGUE', toLevel: 17 }],
|
||||
why: 'Action Surge for a second Sneak Attack chance in a turn, and better armor.',
|
||||
},
|
||||
{
|
||||
key: 'MONK_CLERIC',
|
||||
name: 'Wisdom warrior',
|
||||
from: 'MONK',
|
||||
steps: [{ class: 'MONK', toLevel: 5 }, { class: 'CLERIC', toLevel: 2 }, { class: 'MONK', toLevel: 13 }],
|
||||
why: 'Stunning Strike and Extra Attack first, then Wisdom-powered healing and Channel Divinity.',
|
||||
},
|
||||
{
|
||||
key: 'BARBARIAN_FIGHTER',
|
||||
name: 'Raging champion',
|
||||
from: 'BARBARIAN',
|
||||
steps: [{ class: 'BARBARIAN', toLevel: 5 }, { class: 'FIGHTER', toLevel: 3, subclass: 'CHAMPION' }, { class: 'BARBARIAN', toLevel: 12 }],
|
||||
why: 'Reckless Attack with critical hits on 19–20: advantage makes those crits come often.',
|
||||
},
|
||||
{
|
||||
key: 'DRUID_CLERIC',
|
||||
name: 'Nature priest',
|
||||
from: 'DRUID',
|
||||
steps: [{ class: 'CLERIC', toLevel: 1, subclass: 'LIFE' }, { class: 'DRUID', toLevel: 19 }],
|
||||
why: 'One Life Domain level makes every druid healing spell heal more, and adds heavy armor.',
|
||||
},
|
||||
{
|
||||
key: 'BARD_WARLOCK',
|
||||
name: 'Eldritch bard',
|
||||
from: 'BARD',
|
||||
steps: [{ class: 'BARD', toLevel: 6 }, { class: 'WARLOCK', toLevel: 2 }, { class: 'BARD', toLevel: 12 }],
|
||||
why: 'Eldritch Blast with Agonizing Blast as a dependable attack for a support caster.',
|
||||
},
|
||||
{
|
||||
key: 'ARTIFICER_WIZARD',
|
||||
name: 'Arcane engineer',
|
||||
from: 'ARTIFICER',
|
||||
steps: [{ class: 'ARTIFICER', toLevel: 2 }, { class: 'WIZARD', toLevel: 18 }],
|
||||
why: 'Infusions and a tool focus early, then a wizard’s full spell list, all on Intelligence.',
|
||||
},
|
||||
{
|
||||
key: 'RANGER_DRUID',
|
||||
name: 'Wild warden',
|
||||
from: 'RANGER',
|
||||
steps: [{ class: 'RANGER', toLevel: 5 }, { class: 'DRUID', toLevel: 2 }, { class: 'RANGER', toLevel: 13 }],
|
||||
why: 'Extra Attack and a ranger’s toolkit, plus Wild Shape for scouting and a second health bar.',
|
||||
},
|
||||
{
|
||||
key: 'WARLOCK_PALADIN',
|
||||
name: 'Blade pact knight',
|
||||
from: 'WARLOCK',
|
||||
steps: [{ class: 'WARLOCK', toLevel: 3, subclass: 'HEXBLADE' }, { class: 'PALADIN', toLevel: 2 }, { class: 'WARLOCK', toLevel: 15 }],
|
||||
why: 'A pact weapon swung with Charisma, and short-rest slots to burn on Divine Smite.',
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = { MULTICLASS_PATHS };
|
||||
@@ -74,6 +74,31 @@ const ARTIFICER_SLOTS = [
|
||||
[4, 3, 3, 3, 2],
|
||||
];
|
||||
|
||||
// Eldritch Knight, Arcane Trickster: a third of a full caster's pace, from
|
||||
// level 3 (the subclass is what casts, not the class).
|
||||
const THIRD_CASTER_SLOTS = [
|
||||
[],
|
||||
[],
|
||||
[2],
|
||||
[3],
|
||||
[3],
|
||||
[3],
|
||||
[4, 2],
|
||||
[4, 2],
|
||||
[4, 2],
|
||||
[4, 3],
|
||||
[4, 3],
|
||||
[4, 3],
|
||||
[4, 3, 2],
|
||||
[4, 3, 2],
|
||||
[4, 3, 2],
|
||||
[4, 3, 3],
|
||||
[4, 3, 3],
|
||||
[4, 3, 3],
|
||||
[4, 3, 3, 1],
|
||||
[4, 3, 3, 1],
|
||||
];
|
||||
|
||||
// Warlock: a few slots, all of one slot level. [how many, which level].
|
||||
const PACT_SLOTS = [
|
||||
[1, 1],
|
||||
@@ -115,6 +140,19 @@ const CASTER_KIND = {
|
||||
// A class whose slots follow its own table rather than its kind's.
|
||||
const CLASS_SLOT_TABLES = { ARTIFICER: ARTIFICER_SLOTS };
|
||||
|
||||
// Subclasses that cast in a class that otherwise does not, with Intelligence.
|
||||
const SUBCLASS_CASTER_KIND = { ELDRITCH_KNIGHT: 'THIRD', ARCANE_TRICKSTER: 'THIRD' };
|
||||
|
||||
const MAX_SLOT_LEVEL = 9;
|
||||
|
||||
module.exports = { FULL_CASTER_SLOTS, HALF_CASTER_SLOTS, ARTIFICER_SLOTS, PACT_SLOTS, CASTER_KIND, CLASS_SLOT_TABLES, MAX_SLOT_LEVEL };
|
||||
module.exports = {
|
||||
FULL_CASTER_SLOTS,
|
||||
HALF_CASTER_SLOTS,
|
||||
ARTIFICER_SLOTS,
|
||||
THIRD_CASTER_SLOTS,
|
||||
PACT_SLOTS,
|
||||
CASTER_KIND,
|
||||
CLASS_SLOT_TABLES,
|
||||
SUBCLASS_CASTER_KIND,
|
||||
MAX_SLOT_LEVEL,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Dice: reading an expression ("2d6+3"), rolling it, and checking dice that
|
||||
// were rolled at the table and typed in. Used by the table's dice roller
|
||||
// (POST /campaigns/:id/rolls) and by attacks in an encounter (game/combat.js).
|
||||
// Pure but for the random source, which can be passed in for tests.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const rollDie = (sides) => crypto.randomInt(1, sides + 1);
|
||||
|
||||
const MAX_DICE = 100;
|
||||
const MAX_SIDES = 1000;
|
||||
|
||||
// Reads the dice expressions the app writes: "1d8", "2d6", "1d6+1d8",
|
||||
// "2d4+2", "1d8 - 1". Returns { dice: [{ count, sides, sign }], flat }, or null
|
||||
// for anything else (free text is allowed where a DM types dice) and for
|
||||
// something too big to be a real roll. Mirrors parseDice in the frontend's
|
||||
// lib/diceRoller.ts.
|
||||
function parseDice(expression) {
|
||||
const terms = String(expression || '')
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[−–]/g, '-')
|
||||
.toLowerCase()
|
||||
.replace(/-/g, '+-')
|
||||
.split('+')
|
||||
.filter(Boolean);
|
||||
if (terms.length === 0) return null;
|
||||
const parsed = { dice: [], flat: 0 };
|
||||
let total = 0;
|
||||
for (const term of terms) {
|
||||
const die = /^(-?)(\d*)d(\d+)$/.exec(term);
|
||||
if (die) {
|
||||
const count = Number(die[2]) || 1;
|
||||
const sides = Number(die[3]);
|
||||
total += count;
|
||||
if (total > MAX_DICE || sides < 2 || sides > MAX_SIDES) return null;
|
||||
parsed.dice.push({ count, sides, sign: die[1] ? -1 : 1 });
|
||||
} else if (/^-?\d+$/.test(term)) {
|
||||
parsed.flat += Number(term);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return parsed.dice.length > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
// The dice at the start of a line of text that says more: "2d8+3" from
|
||||
// "2d8+3 fire". Null when it does not start with dice.
|
||||
function leadingDice(text) {
|
||||
const match = /^\s*((?:\d*d\d+|\d+)(?:\s*[+\-−–]\s*(?:\d*d\d+|\d+))*)/i.exec(String(text || ''));
|
||||
return match && parseDice(match[1]) ? match[1].replace(/\s+/g, '') : null;
|
||||
}
|
||||
|
||||
// Every die of a parsed expression, one entry each, in order: [{ sides, sign }].
|
||||
function diceOf(parsed) {
|
||||
return parsed.dice.flatMap((term) => Array.from({ length: term.count }, () => ({ sides: term.sides, sign: term.sign })));
|
||||
}
|
||||
|
||||
// Whether `values` could be what these dice showed: one whole number per die,
|
||||
// each on its die.
|
||||
function validValues(dice, values) {
|
||||
return Array.isArray(values) && values.length === dice.length && dice.every((die, i) => Number.isInteger(values[i]) && values[i] >= 1 && values[i] <= die.sides);
|
||||
}
|
||||
|
||||
// Rolls a parsed expression, or takes `values` (dice rolled at the table,
|
||||
// already checked with validValues) in place of rolling. Returns
|
||||
// { dice: [{ sides, value, kept, sign }], modifier, total }.
|
||||
function rollExpression(parsed, { values = null, roll = rollDie } = {}) {
|
||||
const dice = diceOf(parsed).map((die, i) => ({ sides: die.sides, value: values ? values[i] : roll(die.sides), kept: true, sign: die.sign }));
|
||||
return { dice, modifier: parsed.flat, total: dice.reduce((sum, die) => sum + die.sign * die.value, 0) + parsed.flat };
|
||||
}
|
||||
|
||||
const D20_MODES = ['NORMAL', 'ADVANTAGE', 'DISADVANTAGE'];
|
||||
|
||||
// How many d20s a mode rolls: two with advantage or disadvantage.
|
||||
const d20Count = (mode) => (mode === 'NORMAL' ? 1 : 2);
|
||||
|
||||
// A d20 plus a modifier, as the mode has it: one die, or two with the higher
|
||||
// (advantage) or lower (disadvantage) kept. `values` as in rollExpression.
|
||||
// Returns { dice, modifier, total, natural }: `natural` is the d20 that counted.
|
||||
function rollD20(modifier = 0, mode = 'NORMAL', { values = null, roll = rollDie } = {}) {
|
||||
const rolled = Array.from({ length: d20Count(mode) }, (_, i) => (values ? values[i] : roll(20)));
|
||||
const natural = mode === 'ADVANTAGE' ? Math.max(...rolled) : mode === 'DISADVANTAGE' ? Math.min(...rolled) : rolled[0];
|
||||
// Of two equal dice only the first is marked as the one kept.
|
||||
const keptIndex = rolled.indexOf(natural);
|
||||
return {
|
||||
dice: rolled.map((value, i) => ({ sides: 20, value, kept: i === keptIndex, sign: 1 })),
|
||||
modifier,
|
||||
total: natural + modifier,
|
||||
natural,
|
||||
};
|
||||
}
|
||||
|
||||
// What a critical hit does to damage dice (Campaign.critRule). DOUBLE_DICE:
|
||||
// every die is rolled twice. MAX_PLUS_ROLL: every die counts at its maximum,
|
||||
// and is rolled once more on top. Takes and returns a parsed expression's
|
||||
// roll; the flat modifier is never doubled.
|
||||
function rollCritical(parsed, critRule = 'DOUBLE_DICE', { roll = rollDie } = {}) {
|
||||
const base = diceOf(parsed);
|
||||
const first = base.map((die) => ({ sides: die.sides, value: critRule === 'MAX_PLUS_ROLL' ? die.sides : roll(die.sides), kept: true, sign: die.sign, maxed: critRule === 'MAX_PLUS_ROLL' }));
|
||||
const second = base.map((die) => ({ sides: die.sides, value: roll(die.sides), kept: true, sign: die.sign }));
|
||||
const dice = [...first, ...second];
|
||||
return { dice, modifier: parsed.flat, total: dice.reduce((sum, die) => sum + die.sign * die.value, 0) + parsed.flat };
|
||||
}
|
||||
|
||||
module.exports = { rollDie, parseDice, leadingDice, diceOf, validValues, rollExpression, rollD20, d20Count, rollCritical, D20_MODES, MAX_DICE };
|
||||
@@ -0,0 +1,77 @@
|
||||
const { parseDice, leadingDice, diceOf, validValues, rollExpression, rollD20, rollCritical } = require('./dice');
|
||||
|
||||
// A die that always shows the same face, or the faces given in turn.
|
||||
const fixed = (...faces) => {
|
||||
let i = 0;
|
||||
return () => faces[i++ % faces.length];
|
||||
};
|
||||
|
||||
describe('parseDice', () => {
|
||||
test('reads the expressions the app writes', () => {
|
||||
expect(parseDice('2d6+3')).toEqual({ dice: [{ count: 2, sides: 6, sign: 1 }], flat: 3 });
|
||||
expect(parseDice('1d6 + 1d8 − 1')).toEqual({ dice: [{ count: 1, sides: 6, sign: 1 }, { count: 1, sides: 8, sign: 1 }], flat: -1 });
|
||||
expect(parseDice('d20')).toEqual({ dice: [{ count: 1, sides: 20, sign: 1 }], flat: 0 });
|
||||
});
|
||||
|
||||
test('refuses free text, bare numbers and absurd rolls', () => {
|
||||
expect(parseDice('a big bite')).toBeNull();
|
||||
expect(parseDice('7')).toBeNull();
|
||||
expect(parseDice('101d6')).toBeNull();
|
||||
expect(parseDice('60d6+60d6')).toBeNull();
|
||||
expect(parseDice('1d1')).toBeNull();
|
||||
});
|
||||
|
||||
test('leadingDice takes the dice off the front of a line', () => {
|
||||
expect(leadingDice('2d8+3 fire')).toBe('2d8+3');
|
||||
expect(leadingDice('fire, 2d8')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rolling', () => {
|
||||
test('rolls every die and adds the flat part', () => {
|
||||
const roll = rollExpression(parseDice('2d6+1d4+2'), { roll: fixed(3, 5, 4) });
|
||||
expect(roll.dice.map((d) => [d.sides, d.value])).toEqual([[6, 3], [6, 5], [4, 4]]);
|
||||
expect(roll).toMatchObject({ modifier: 2, total: 14 });
|
||||
});
|
||||
|
||||
test('a subtracted die takes away', () => {
|
||||
expect(rollExpression(parseDice('1d8-1d4'), { roll: fixed(6, 2) }).total).toBe(4);
|
||||
});
|
||||
|
||||
test('dice rolled at the table are checked against each die, then used as they are', () => {
|
||||
const dice = diceOf(parseDice('1d8+1d6'));
|
||||
expect(validValues(dice, [8, 6])).toBe(true);
|
||||
expect(validValues(dice, [9, 1])).toBe(false);
|
||||
expect(validValues(dice, [3])).toBe(false);
|
||||
expect(validValues(dice, [3, 2.5])).toBe(false);
|
||||
expect(rollExpression(parseDice('1d8+1d6+1'), { values: [8, 6] }).total).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a d20', () => {
|
||||
test('rolls one die and adds the modifier', () => {
|
||||
expect(rollD20(5, 'NORMAL', { roll: fixed(12) })).toMatchObject({ total: 17, natural: 12, dice: [{ sides: 20, value: 12, kept: true }] });
|
||||
});
|
||||
|
||||
test('keeps the higher with advantage and the lower with disadvantage', () => {
|
||||
const adv = rollD20(2, 'ADVANTAGE', { roll: fixed(7, 15) });
|
||||
expect(adv).toMatchObject({ natural: 15, total: 17 });
|
||||
expect(adv.dice.map((d) => d.kept)).toEqual([false, true]);
|
||||
expect(rollD20(0, 'DISADVANTAGE', { roll: fixed(7, 15) }).natural).toBe(7);
|
||||
expect(rollD20(0, 'ADVANTAGE', { values: [20, 20] }).dice.map((d) => d.kept)).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a critical hit', () => {
|
||||
test('rolls every damage die twice, and the flat part once', () => {
|
||||
const crit = rollCritical(parseDice('1d8+3'), 'DOUBLE_DICE', { roll: fixed(5, 7) });
|
||||
expect(crit.dice.map((d) => d.value)).toEqual([5, 7]);
|
||||
expect(crit.total).toBe(15);
|
||||
});
|
||||
|
||||
test('or takes the dice at their most and rolls them once more', () => {
|
||||
const crit = rollCritical(parseDice('2d6+1'), 'MAX_PLUS_ROLL', { roll: fixed(2, 3) });
|
||||
expect(crit.dice.map((d) => d.value)).toEqual([6, 6, 2, 3]);
|
||||
expect(crit.total).toBe(18);
|
||||
});
|
||||
});
|
||||
+42
-16
@@ -7,6 +7,7 @@ const { SUBCLASS_LEVEL, subclassesOf, findSubclass, defaultSubclass } = require(
|
||||
const { CLASS_FEATURES } = require('./data/characterOptions');
|
||||
const { CASTER_KIND } = require('./data/spellSlots');
|
||||
const { getClassSpellcastingAbility, maxSpellLevel, scaledDamageDice, MAX_GEAR_LEVEL } = require('./calculations');
|
||||
const { classLevels, classOptions, joiningFeatures } = require('./multiclass');
|
||||
|
||||
// A choice that cannot be made: the message is for whoever made it.
|
||||
class LevelUpError extends Error {}
|
||||
@@ -211,25 +212,42 @@ function subclassByDefault(characterClass, level, currentKey) {
|
||||
}
|
||||
|
||||
// The checklist for a character's next level. Takes the character with its
|
||||
// weapons loaded (each with its pool entry). `subclasses` is the campaign's
|
||||
// setting: off, there is no subclass to pick, and the class's first one is
|
||||
// taken without asking (`subclassTaken`), its features listed with the rest.
|
||||
function levelUpPlan(character, { subclasses = true } = {}) {
|
||||
// weapons and class levels loaded (each weapon with its pool entry).
|
||||
// `subclasses` is the campaign's setting: off, there is no subclass to pick,
|
||||
// and the class's first one is taken without asking (`subclassTaken`), its
|
||||
// features listed with the rest. `multiclass` (the campaign's setting) lists
|
||||
// the classes the level could go to (`classChoice`, by the final ability
|
||||
// `scores`), and `toClass` is the one it goes to: the character's first class
|
||||
// unless another is named. Features, Boons, spells and the subclass all go by
|
||||
// that class's own level.
|
||||
function levelUpPlan(character, { subclasses = true, multiclass = false, toClass = null, scores = {} } = {}) {
|
||||
const toLevel = character.level + 1;
|
||||
const taken = subclasses ? null : subclassByDefault(character.characterClass, toLevel, character.subclass);
|
||||
const entries = [
|
||||
...featuresAt(character.characterClass, toLevel, taken ? null : character.subclass),
|
||||
...(taken ? subclassFeaturesThrough(findSubclass(character.characterClass, taken), toLevel) : []),
|
||||
];
|
||||
const characterClass = toClass || character.characterClass;
|
||||
const entry = classLevels(character).find((e) => e.characterClass === characterClass) || { characterClass, level: 0, subclass: null };
|
||||
const classLevel = entry.level + 1;
|
||||
const joining = entry.level === 0;
|
||||
const taken = subclasses || joining ? null : subclassByDefault(characterClass, classLevel, entry.subclass);
|
||||
const entries = joining
|
||||
? joiningFeatures(characterClass)
|
||||
: [
|
||||
...featuresAt(characterClass, classLevel, taken ? null : entry.subclass),
|
||||
...(taken ? subclassFeaturesThrough(findSubclass(characterClass, taken), classLevel) : []),
|
||||
];
|
||||
return {
|
||||
fromLevel: character.level,
|
||||
toLevel,
|
||||
// The class taking the level, and its own level once it has.
|
||||
characterClass,
|
||||
classLevel,
|
||||
// True when this is the character's first level in this class.
|
||||
joining,
|
||||
classChoice: multiclass ? { options: classOptions(character, scores) } : null,
|
||||
// Set when a subclass has to be picked on the way to this level.
|
||||
subclass: subclasses ? subclassChoice(character.characterClass, toLevel, character.subclass) : null,
|
||||
subclass: subclasses ? subclassChoice(characterClass, classLevel, entry.subclass) : null,
|
||||
subclassTaken: taken,
|
||||
features: entries.filter((entry) => !entry.boon).map(({ name, text, upgrade }) => ({ name, text, upgrade: Boolean(upgrade) })),
|
||||
boon: entries.some((entry) => entry.boon) ? boonOptions(character.specialAbilities) : null,
|
||||
spells: spellsGainedAt(character.characterClass, toLevel),
|
||||
features: entries.filter((e) => !e.boon).map(({ name, text, upgrade }) => ({ name, text, upgrade: Boolean(upgrade) })),
|
||||
boon: entries.some((e) => e.boon) ? boonOptions(character.specialAbilities) : null,
|
||||
spells: spellsGainedAt(characterClass, classLevel),
|
||||
// The free upgrade: one weapon of standard growth goes up a level, never
|
||||
// past its holder.
|
||||
weapons: (character.weapons || [])
|
||||
@@ -242,11 +260,19 @@ function levelUpPlan(character, { subclasses = true } = {}) {
|
||||
damageDice: scaledDamageDice(cw.weapon.damageDice, cw.level, cw.weapon.damageScaling, cw.weapon.minLevel),
|
||||
nextDamageDice: scaledDamageDice(cw.weapon.damageDice, cw.level + 1, cw.weapon.damageScaling, cw.weapon.minLevel),
|
||||
})),
|
||||
// The class tables stop at level 10; past it only the numbers move.
|
||||
beyondTables: toLevel > MAX_FEATURE_LEVEL,
|
||||
// The class tables stop at level 20; past it only the numbers move.
|
||||
beyondTables: classLevel > MAX_FEATURE_LEVEL,
|
||||
};
|
||||
}
|
||||
|
||||
// What taking a planned level does to a character's special abilities (see
|
||||
// advance): a first level in a new class writes what that class grants at
|
||||
// level 1 and teaches; any other goes through advance at the class's own level.
|
||||
function advanceIn(specialAbilities, plan, boonKey, { key = null, chosen = false } = {}) {
|
||||
if (plan.joining) return { specialAbilities: addFeatures(specialAbilities, joiningFeatures(plan.characterClass)), armorBonus: 0, tempHp: 0 };
|
||||
return advance(specialAbilities, plan.characterClass, plan.classLevel, boonKey, { key, chosen });
|
||||
}
|
||||
|
||||
// The road from level 1 to 20 for a class (and subclass): what every level
|
||||
// gives, as the progression timeline shows it. Level 1 is what the builder
|
||||
// writes; a level that grants a Boon says so. `fromSubclass` marks the
|
||||
@@ -274,4 +300,4 @@ function progression(characterClass, subclassKey = null) {
|
||||
return levels;
|
||||
}
|
||||
|
||||
module.exports = { LevelUpError, progression, featuresAt, owesSubclass, subclassChoice, subclassByDefault, switchSubclass, addFeatures, boonTaken, boonOptions, hardyTempHp, advance, spellsGainedAt, levelUpPlan };
|
||||
module.exports = { LevelUpError, progression, advanceIn, featuresAt, owesSubclass, subclassChoice, subclassByDefault, switchSubclass, addFeatures, boonTaken, boonOptions, hardyTempHp, advance, spellsGainedAt, levelUpPlan };
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
// Multiclassing (the 2014 Player’s Handbook rules): a character takes a level
|
||||
// in another class instead of the one they have. Their levels add up to their
|
||||
// character level; hit dice, hit points, class features and counters go by
|
||||
// each class's own level, and spell slots by the levels of every class that
|
||||
// casts. Pure: no DB calls.
|
||||
//
|
||||
// A character's first class is Character.characterClass (with its subclass);
|
||||
// the others are CharacterClassLevel rows. The first class's own level is the
|
||||
// character level less the others'.
|
||||
|
||||
const { CLASSES } = require('./data/classes');
|
||||
const { FULL_CASTER_SLOTS, PACT_SLOTS } = require('./data/spellSlots');
|
||||
const { CLASS_FEATURES } = require('./data/characterOptions');
|
||||
const { resourceState } = require('./classResources');
|
||||
const calculations = require('./calculations');
|
||||
|
||||
// What a class asks of the scores before anyone can take a level in it (or
|
||||
// leave it for another). Each entry is a list of alternatives; every ability
|
||||
// in an alternative must be 13 or more.
|
||||
const MULTICLASS_PREREQS = {
|
||||
ARTIFICER: [['INT']],
|
||||
BARBARIAN: [['STR']],
|
||||
BARD: [['CHA']],
|
||||
CLERIC: [['WIS']],
|
||||
DRUID: [['WIS']],
|
||||
FIGHTER: [['STR'], ['DEX']],
|
||||
MONK: [['DEX', 'WIS']],
|
||||
PALADIN: [['STR', 'CHA']],
|
||||
RANGER: [['DEX', 'WIS']],
|
||||
ROGUE: [['DEX']],
|
||||
SORCERER: [['CHA']],
|
||||
WARLOCK: [['CHA']],
|
||||
WIZARD: [['INT']],
|
||||
};
|
||||
const PREREQ_SCORE = 13;
|
||||
|
||||
const ABILITY_NAMES = { STR: 'Strength', DEX: 'Dexterity', CON: 'Constitution', INT: 'Intelligence', WIS: 'Wisdom', CHA: 'Charisma' };
|
||||
|
||||
// "Strength 13 or Dexterity 13", "Dexterity 13 and Wisdom 13".
|
||||
function prereqText(characterClass) {
|
||||
return (MULTICLASS_PREREQS[characterClass] || [])
|
||||
.map((all) => all.map((ability) => `${ABILITY_NAMES[ability]} ${PREREQ_SCORE}`).join(' and '))
|
||||
.join(' or ');
|
||||
}
|
||||
|
||||
// Whether final scores ({ STR: 14, ... }) meet a class's requirement.
|
||||
function meetsPrereq(characterClass, scores) {
|
||||
return (MULTICLASS_PREREQS[characterClass] || []).some((all) => all.every((ability) => (scores[ability] || 0) >= PREREQ_SCORE));
|
||||
}
|
||||
|
||||
// What a class teaches someone who comes to it from another (the 2014
|
||||
// multiclassing table), as a line for the special abilities.
|
||||
const MULTICLASS_PROFICIENCIES = {
|
||||
ARTIFICER: 'light and medium armor, shields, thieves’ tools and tinker’s tools',
|
||||
BARBARIAN: 'shields, simple and martial weapons',
|
||||
BARD: 'light armor, one skill of your choice and one musical instrument',
|
||||
CLERIC: 'light and medium armor and shields',
|
||||
DRUID: 'light and medium armor and shields (no metal)',
|
||||
FIGHTER: 'light and medium armor, shields, simple and martial weapons',
|
||||
MONK: 'simple weapons and shortswords',
|
||||
PALADIN: 'light and medium armor, shields, simple and martial weapons',
|
||||
RANGER: 'light and medium armor, shields, simple and martial weapons, and one skill from the ranger’s list',
|
||||
ROGUE: 'light armor, one skill from the rogue’s list, and thieves’ tools',
|
||||
SORCERER: 'nothing new: a sorcerer’s magic is in the blood',
|
||||
WARLOCK: 'light armor and simple weapons',
|
||||
WIZARD: 'nothing new: a wizard’s training is the spellbook',
|
||||
};
|
||||
|
||||
// The lines a character's first level in another class writes: what the class
|
||||
// grants at level 1 (multiclassing does not bring its saves or skills), and
|
||||
// what it teaches.
|
||||
function joiningFeatures(characterClass) {
|
||||
return [
|
||||
...(CLASS_FEATURES[characterClass] || []),
|
||||
{ name: `${CLASSES[characterClass].name} Training`, text: `Multiclassing into ${CLASSES[characterClass].name} teaches ${MULTICLASS_PROFICIENCIES[characterClass]}.` },
|
||||
];
|
||||
}
|
||||
|
||||
// Every class a character has levels in, the first first:
|
||||
// [{ characterClass, level, subclass, first }]. Takes a character with its
|
||||
// classLevels (an empty list or none: a single class).
|
||||
function classLevels(character) {
|
||||
const others = [...(character.classLevels || [])]
|
||||
.filter((row) => row.level > 0 && row.characterClass !== character.characterClass)
|
||||
.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
|
||||
const elsewhere = others.reduce((sum, row) => sum + row.level, 0);
|
||||
return [
|
||||
{ characterClass: character.characterClass, level: Math.max(1, (character.level || 1) - elsewhere), subclass: character.subclass || null, first: true },
|
||||
...others.map((row) => ({ characterClass: row.characterClass, level: row.level, subclass: row.subclass || null, first: false })),
|
||||
];
|
||||
}
|
||||
|
||||
// The classes a character could put their next level in: those they have
|
||||
// (always), and those whose requirement they and every class they have meet.
|
||||
// `scores` are the final ability scores.
|
||||
function classOptions(character, scores) {
|
||||
const have = classLevels(character);
|
||||
const leaving = have.filter((entry) => !meetsPrereq(entry.characterClass, scores));
|
||||
return Object.keys(CLASSES).map((characterClass) => {
|
||||
const current = have.find((entry) => entry.characterClass === characterClass);
|
||||
if (current) return { characterClass, level: current.level, eligible: true, problems: [] };
|
||||
const problems = [];
|
||||
if (!meetsPrereq(characterClass, scores)) problems.push(`${CLASSES[characterClass].name} needs ${prereqText(characterClass)}.`);
|
||||
for (const entry of leaving) problems.push(`Leaving ${CLASSES[entry.characterClass].name} for another class needs ${prereqText(entry.characterClass)}.`);
|
||||
return { characterClass, level: 0, eligible: problems.length === 0, problems };
|
||||
});
|
||||
}
|
||||
|
||||
// The character as it would be with one more level in `characterClass`: the
|
||||
// level and the class rows moved on. For working out the sheet afterwards.
|
||||
function withLevelIn(character, characterClass, subclass = undefined) {
|
||||
const next = { ...character, level: character.level + 1 };
|
||||
if (characterClass === character.characterClass) {
|
||||
if (subclass !== undefined) next.subclass = subclass;
|
||||
return next;
|
||||
}
|
||||
const rows = [...(character.classLevels || [])];
|
||||
const at = rows.findIndex((row) => row.characterClass === characterClass);
|
||||
if (at >= 0) rows[at] = { ...rows[at], level: rows[at].level + 1, ...(subclass !== undefined ? { subclass } : {}) };
|
||||
else rows.push({ characterClass, level: 1, subclass: subclass ?? null, createdAt: new Date() });
|
||||
next.classLevels = rows;
|
||||
return next;
|
||||
}
|
||||
|
||||
// Hit points: the first class's die in full at level 1, then each level the
|
||||
// average of its own class's die (rounded up), plus Constitution every level.
|
||||
function multiclassHpMax(classes, conMod) {
|
||||
let total = 0;
|
||||
classes.forEach((entry, i) => {
|
||||
const die = calculations.getClassHitDieSize(entry.characterClass);
|
||||
const average = Math.floor(die / 2) + 1;
|
||||
const levels = entry.level;
|
||||
total += i === 0 ? die + conMod + (levels - 1) * (average + conMod) : levels * (average + conMod);
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
// Hit Dice: one per level, of each class's own size. Spent ones are counted
|
||||
// together; `size` is the first class's, the one rests roll.
|
||||
function multiclassHitDice(classes, used = 0) {
|
||||
const count = classes.reduce((sum, entry) => sum + entry.level, 0);
|
||||
const spent = Math.min(Math.max(Number(used) || 0, 0), count);
|
||||
return {
|
||||
size: calculations.getClassHitDieSize(classes[0].characterClass),
|
||||
count,
|
||||
used: spent,
|
||||
remaining: count - spent,
|
||||
pools: classes.map((entry) => ({ characterClass: entry.characterClass, size: calculations.getClassHitDieSize(entry.characterClass), count: entry.level })),
|
||||
};
|
||||
}
|
||||
|
||||
// How many levels a class adds to the shared caster level: all of a full
|
||||
// caster's, half a half caster's (an artificer's rounded up), a third of a
|
||||
// casting subclass's, and none of a warlock's (pact slots stand apart).
|
||||
function casterLevelOf(entry) {
|
||||
const kind = calculations.casterKind(entry.characterClass, entry.subclass);
|
||||
if (kind === 'FULL') return entry.level;
|
||||
if (kind === 'HALF') return entry.characterClass === 'ARTIFICER' ? Math.ceil(entry.level / 2) : Math.floor(entry.level / 2);
|
||||
if (kind === 'THIRD') return Math.floor(entry.level / 3);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The spell slots of a multiclassed character (pact slots aside): a single
|
||||
// casting class keeps its own table; two or more add up to a caster level on
|
||||
// the full caster table. A character whose only casting is a warlock's has
|
||||
// those pact slots. Null for none.
|
||||
function multiclassSpellSlots(classes) {
|
||||
const casters = classes.filter((entry) => {
|
||||
const kind = calculations.casterKind(entry.characterClass, entry.subclass);
|
||||
return kind && kind !== 'PACT';
|
||||
});
|
||||
if (casters.length === 0) {
|
||||
const warlock = classes.find((entry) => calculations.casterKind(entry.characterClass) === 'PACT');
|
||||
return warlock ? calculations.spellSlotsFor('WARLOCK', warlock.level) : null;
|
||||
}
|
||||
if (casters.length === 1) return calculations.spellSlotsFor(casters[0].characterClass, casters[0].level, casters[0].subclass);
|
||||
const casterLevel = casters.reduce((sum, entry) => sum + casterLevelOf(entry), 0);
|
||||
if (casterLevel < 1) return null;
|
||||
return { kind: 'FULL', restoresOn: 'LONG', max: [...FULL_CASTER_SLOTS[Math.min(casterLevel, FULL_CASTER_SLOTS.length) - 1]] };
|
||||
}
|
||||
|
||||
// A multiclassed warlock's pact slots, kept apart from the others' (they come
|
||||
// back on a short rest): { count, slotLevel }, or null when the warlock's are
|
||||
// the only slots (they are then the sheet's spell slots) or there is none.
|
||||
function pactSlotsOf(classes) {
|
||||
const warlock = classes.find((entry) => calculations.casterKind(entry.characterClass) === 'PACT');
|
||||
if (!warlock) return null;
|
||||
const others = classes.some((entry) => {
|
||||
const kind = calculations.casterKind(entry.characterClass, entry.subclass);
|
||||
return kind && kind !== 'PACT';
|
||||
});
|
||||
if (!others) return null;
|
||||
const [count, slotLevel] = PACT_SLOTS[Math.min(warlock.level, PACT_SLOTS.length) - 1];
|
||||
return { count, slotLevel };
|
||||
}
|
||||
|
||||
// The class counters of every class, each by its own class level. Where two
|
||||
// classes give the same counter (Channel Divinity), the larger one counts.
|
||||
function multiclassResources(classes, resourcesUsed, abilities, profBonus) {
|
||||
const byKey = new Map();
|
||||
for (const entry of classes) {
|
||||
for (const resource of resourceState({ ...entry, resourcesUsed }, abilities, profBonus)) {
|
||||
const had = byKey.get(resource.key);
|
||||
if (!had || resource.max > had.max) byKey.set(resource.key, resource);
|
||||
}
|
||||
}
|
||||
return [...byKey.values()];
|
||||
}
|
||||
|
||||
// Added to (not replacing) the exports object: calculations.js holds it from
|
||||
// before this file has finished loading.
|
||||
Object.assign(module.exports, {
|
||||
MULTICLASS_PREREQS,
|
||||
PREREQ_SCORE,
|
||||
prereqText,
|
||||
meetsPrereq,
|
||||
joiningFeatures,
|
||||
classLevels,
|
||||
classOptions,
|
||||
withLevelIn,
|
||||
multiclassHpMax,
|
||||
multiclassHitDice,
|
||||
casterLevelOf,
|
||||
multiclassSpellSlots,
|
||||
pactSlotsOf,
|
||||
multiclassResources,
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
const {
|
||||
prereqText,
|
||||
meetsPrereq,
|
||||
classLevels,
|
||||
classOptions,
|
||||
withLevelIn,
|
||||
multiclassHpMax,
|
||||
multiclassSpellSlots,
|
||||
pactSlotsOf,
|
||||
casterLevelOf,
|
||||
} = require('./multiclass');
|
||||
const { computeCharacterSheet, spellSlotsFor } = require('./calculations');
|
||||
const { levelUpPlan, advanceIn } = require('./levelUp');
|
||||
const { MULTICLASS_PATHS } = require('./data/multiclassPaths');
|
||||
const { findSubclass } = require('./data/subclasses');
|
||||
|
||||
const scores = (over = {}) => ({ STR: 10, DEX: 10, CON: 10, INT: 10, WIS: 10, CHA: 10, ...over });
|
||||
|
||||
describe('requirements', () => {
|
||||
test('say what a class needs, with either-or where there is a choice', () => {
|
||||
expect(prereqText('FIGHTER')).toBe('Strength 13 or Dexterity 13');
|
||||
expect(prereqText('MONK')).toBe('Dexterity 13 and Wisdom 13');
|
||||
});
|
||||
|
||||
test('are met by final scores of 13 or more', () => {
|
||||
expect(meetsPrereq('FIGHTER', scores({ DEX: 13 }))).toBe(true);
|
||||
expect(meetsPrereq('PALADIN', scores({ STR: 15, CHA: 12 }))).toBe(false);
|
||||
});
|
||||
|
||||
test('apply to the class left as well as the one joined', () => {
|
||||
const wizard = { characterClass: 'WIZARD', level: 3, classLevels: [] };
|
||||
const options = classOptions(wizard, scores({ CHA: 14 })); // INT only 10: can't leave wizard
|
||||
const warlock = options.find((o) => o.characterClass === 'WARLOCK');
|
||||
expect(warlock.eligible).toBe(false);
|
||||
expect(warlock.problems.join(' ')).toMatch(/Leaving Wizard/);
|
||||
expect(options.find((o) => o.characterClass === 'WIZARD')).toMatchObject({ eligible: true, level: 3 });
|
||||
expect(classOptions(wizard, scores({ INT: 15, CHA: 14 })).find((o) => o.characterClass === 'WARLOCK').eligible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('class levels', () => {
|
||||
test('the first class has the levels the others leave', () => {
|
||||
const character = { characterClass: 'PALADIN', subclass: 'DEVOTION', level: 5, classLevels: [{ characterClass: 'WARLOCK', level: 2, subclass: null }] };
|
||||
expect(classLevels(character)).toEqual([
|
||||
{ characterClass: 'PALADIN', level: 3, subclass: 'DEVOTION', first: true },
|
||||
{ characterClass: 'WARLOCK', level: 2, subclass: null, first: false },
|
||||
]);
|
||||
expect(classLevels({ characterClass: 'BARD', level: 4 })).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a level in a class moves the character and that class on', () => {
|
||||
const character = { characterClass: 'FIGHTER', level: 2, classLevels: [] };
|
||||
const next = withLevelIn(character, 'ROGUE');
|
||||
expect(next.level).toBe(3);
|
||||
expect(classLevels(next).map((e) => [e.characterClass, e.level])).toEqual([['FIGHTER', 2], ['ROGUE', 1]]);
|
||||
expect(classLevels(withLevelIn(next, 'FIGHTER')).map((e) => e.level)).toEqual([3, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hit points', () => {
|
||||
test('the first class’s die in full, then each level’s own class average', () => {
|
||||
// Fighter 1 (10) + Wizard 2 (4 + 4), Constitution +1 at each of 3 levels.
|
||||
expect(multiclassHpMax([{ characterClass: 'FIGHTER', level: 1 }, { characterClass: 'WIZARD', level: 2 }], 1)).toBe(10 + 8 + 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spell slots', () => {
|
||||
test('one casting class keeps its own table', () => {
|
||||
expect(multiclassSpellSlots([{ characterClass: 'FIGHTER', level: 5 }, { characterClass: 'WIZARD', level: 3 }]).max).toEqual([4, 2]);
|
||||
});
|
||||
|
||||
test('two casting classes add up to a caster level on the full table', () => {
|
||||
// Paladin 6 (3) + Sorcerer 4 (4) = caster level 7.
|
||||
expect(multiclassSpellSlots([{ characterClass: 'PALADIN', level: 6 }, { characterClass: 'SORCERER', level: 4 }]).max).toEqual([4, 3, 3, 1]);
|
||||
expect(casterLevelOf({ characterClass: 'ARTIFICER', level: 3 })).toBe(2); // rounds up
|
||||
expect(casterLevelOf({ characterClass: 'FIGHTER', level: 6, subclass: 'ELDRITCH_KNIGHT' })).toBe(2);
|
||||
});
|
||||
|
||||
test('a warlock’s pact slots stand apart', () => {
|
||||
const classes = [{ characterClass: 'SORCERER', level: 5 }, { characterClass: 'WARLOCK', level: 3 }];
|
||||
expect(multiclassSpellSlots(classes).max).toEqual([4, 3, 2]);
|
||||
expect(pactSlotsOf(classes)).toEqual({ count: 2, slotLevel: 2 });
|
||||
expect(multiclassSpellSlots([{ characterClass: 'FIGHTER', level: 4 }, { characterClass: 'WARLOCK', level: 2 }]).kind).toBe('PACT');
|
||||
});
|
||||
|
||||
test('the Eldritch Knight and Arcane Trickster cast from level 3', () => {
|
||||
expect(spellSlotsFor('FIGHTER', 3, 'ELDRITCH_KNIGHT').max).toEqual([2]);
|
||||
expect(spellSlotsFor('ROGUE', 7, 'ARCANE_TRICKSTER').max).toEqual([4, 2]);
|
||||
expect(spellSlotsFor('FIGHTER', 3, 'CHAMPION')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the sheet', () => {
|
||||
const base = { strScore: 16, dexScore: 12, conScore: 14, intScore: 10, wisScore: 10, chaScore: 14, species: 'HUMAN' };
|
||||
|
||||
test('a multiclassed character adds up hit dice, hit points, slots and counters by class', () => {
|
||||
const sheet = computeCharacterSheet({ ...base, characterClass: 'PALADIN', level: 5, classLevels: [{ characterClass: 'SORCERER', level: 2 }] });
|
||||
expect(sheet.hitDice).toMatchObject({ count: 5, pools: [{ characterClass: 'PALADIN', size: 10, count: 3 }, { characterClass: 'SORCERER', size: 6, count: 2 }] });
|
||||
expect(sheet.hp.max).toBe(10 + 2 * 6 + 2 * 4 + 5 * 2); // CON 15 (14 + 1): +2
|
||||
expect(sheet.spellSlots.levels.map((l) => l.max)).toEqual([4, 2]); // caster level 1 + 2 = 3
|
||||
expect(sheet.spellcastingByClass.map((c) => c.characterClass)).toEqual(['PALADIN', 'SORCERER']);
|
||||
expect(sheet.resources.find((r) => r.key === 'SORCERY_POINTS').max).toBe(2);
|
||||
expect(sheet.resources.find((r) => r.key === 'LAY_ON_HANDS').max).toBe(15); // 5 × paladin level 3
|
||||
});
|
||||
|
||||
test('a single class is worked out as before', () => {
|
||||
const sheet = computeCharacterSheet({ ...base, characterClass: 'FIGHTER', level: 3, subclass: 'ELDRITCH_KNIGHT' });
|
||||
expect(sheet.hitDice.pools).toBeUndefined();
|
||||
expect(sheet.spellcasting.ability).toBe('INT');
|
||||
expect(sheet.spellSlots.levels).toEqual([{ level: 1, max: 2, used: 0, remaining: 2 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('levelling up into another class', () => {
|
||||
const fighter = { characterClass: 'FIGHTER', level: 4, specialAbilities: null, weapons: [], classLevels: [] };
|
||||
|
||||
test('plans the new class at its own level 1, with what it grants and teaches', () => {
|
||||
const plan = levelUpPlan(fighter, { multiclass: true, toClass: 'ROGUE', scores: scores({ STR: 15, DEX: 14 }) });
|
||||
expect(plan).toMatchObject({ characterClass: 'ROGUE', classLevel: 1, joining: true, toLevel: 5, boon: null, subclass: null });
|
||||
expect(plan.features.map((f) => f.name)).toEqual(['Sneak Attack', 'Thieves’ Cant', 'Rogue Training']);
|
||||
expect(plan.classChoice.options.find((o) => o.characterClass === 'ROGUE').eligible).toBe(true);
|
||||
const { specialAbilities } = advanceIn(null, plan, undefined);
|
||||
expect(specialAbilities).toMatch(/^Rogue Training — /m);
|
||||
});
|
||||
|
||||
test('a class’s Boons and subclass go by its own level', () => {
|
||||
const multi = { ...fighter, level: 5, classLevels: [{ characterClass: 'ROGUE', level: 2 }] };
|
||||
const plan = levelUpPlan(multi, { multiclass: true, toClass: 'ROGUE' });
|
||||
expect(plan).toMatchObject({ classLevel: 3, joining: false });
|
||||
expect(plan.subclass.options.map((o) => o.key)).toContain('ARCANE_TRICKSTER');
|
||||
expect(levelUpPlan(multi, { multiclass: true }).characterClass).toBe('FIGHTER');
|
||||
});
|
||||
|
||||
test('without the setting there is no class to choose', () => {
|
||||
expect(levelUpPlan(fighter).classChoice).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('every suggested route names real classes and subclasses', () => {
|
||||
for (const path of MULTICLASS_PATHS) {
|
||||
expect(path.steps.length).toBeGreaterThan(0);
|
||||
for (const step of path.steps) {
|
||||
if (step.subclass) expect(findSubclass(step.class, step.subclass)).not.toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -3644,3 +3644,207 @@ describe('reference', () => {
|
||||
expect((await request(app).get('/reference/progression?class=BARBARIAN')).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiclassing', () => {
|
||||
itIfDb('takes a level in another class only where the campaign allows it and the scores meet it', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('multiclass');
|
||||
const id = await buildAs(campaign.id, playerAuth, 'Duo', 'FIGHTER', 3);
|
||||
await prisma.character.update({ where: { id }, data: { strScore: 15, dexScore: 14, intScore: 8 } });
|
||||
const plan = (cls) => request(app).get(`/characters/${id}/level-up${cls ? `?class=${cls}` : ''}`).set(dmAuth);
|
||||
const levelUp = (body) => request(app).post(`/characters/${id}/level-up`).set(dmAuth).send(body);
|
||||
|
||||
expect((await plan('ROGUE')).status).toBe(400); // the campaign doesn't allow it
|
||||
expect((await plan()).body.plan.classChoice).toBeNull();
|
||||
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ multiclassEnabled: true });
|
||||
|
||||
const wizard = await plan('WIZARD');
|
||||
expect(wizard.status).toBe(400);
|
||||
expect(wizard.body.error).toMatch(/Intelligence 13/);
|
||||
const rogue = (await plan('ROGUE')).body.plan;
|
||||
expect(rogue).toMatchObject({ characterClass: 'ROGUE', classLevel: 1, joining: true, toLevel: 4 });
|
||||
expect(rogue.classChoice.options.find((o) => o.characterClass === 'ROGUE').eligible).toBe(true);
|
||||
|
||||
const res = await levelUp({ characterClass: 'ROGUE' });
|
||||
expect(res.status).toBe(200);
|
||||
const character = res.body.character;
|
||||
expect(character.level).toBe(4);
|
||||
expect(character.classes).toEqual([
|
||||
{ characterClass: 'FIGHTER', level: 3, subclass: null, subclassName: null },
|
||||
{ characterClass: 'ROGUE', level: 1, subclass: null, subclassName: null },
|
||||
]);
|
||||
expect(character.specialAbilities).toMatch(/Rogue Training — /);
|
||||
expect(character.sheet.hitDice.pools.map((p) => p.size)).toEqual([10, 8]);
|
||||
|
||||
// The rogue's own level 3 asks for its subclass; the fighter's level goes on as before.
|
||||
await levelUp({ characterClass: 'ROGUE' });
|
||||
const third = (await plan('ROGUE')).body.plan;
|
||||
expect(third).toMatchObject({ classLevel: 3, joining: false });
|
||||
expect((await levelUp({ characterClass: 'ROGUE' })).status).toBe(400); // subclass needed
|
||||
const tricked = (await levelUp({ characterClass: 'ROGUE', subclass: 'ARCANE_TRICKSTER' })).body.character;
|
||||
expect(tricked.classes[1]).toMatchObject({ characterClass: 'ROGUE', level: 3, subclass: 'ARCANE_TRICKSTER' });
|
||||
expect(tricked.sheet.spellSlots.levels[0].max).toBe(2); // a third caster at rogue level 3
|
||||
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(playerAuth)).body.characters.find((c) => c.id === id);
|
||||
expect(listed.classes.map((c) => `${c.characterClass} ${c.level}`)).toEqual(['FIGHTER 3', 'ROGUE 3']);
|
||||
});
|
||||
|
||||
itIfDb('the reference lists each class’s requirement and the suggested routes', async () => {
|
||||
const { playerAuth } = await setupCampaign('multiref');
|
||||
const res = await request(app).get('/reference/multiclass').set(playerAuth);
|
||||
expect(res.body.prereqs.MONK.text).toBe('Dexterity 13 and Wisdom 13');
|
||||
expect(res.body.paths.map((p) => p.key)).toContain('SORLOCK');
|
||||
});
|
||||
});
|
||||
|
||||
describe('approved level-ups', () => {
|
||||
itIfDb('the DM approves for many at once; each player then takes their own level, once', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('approve');
|
||||
const ariaId = await buildAs(campaign.id, playerAuth, 'Aria', 'FIGHTER', 1);
|
||||
const boId = await buildAs(campaign.id, dmAuth, 'Bo', 'WIZARD', 1);
|
||||
const approve = (auth, body) => request(app).post(`/campaigns/${campaign.id}/level-up`).set(auth).send(body);
|
||||
const levelUp = (id, auth, body = {}) => request(app).post(`/characters/${id}/level-up`).set(auth).send(body);
|
||||
|
||||
// Not the player's to take until it is approved, and not theirs to approve.
|
||||
expect((await request(app).get(`/characters/${ariaId}/level-up`).set(playerAuth)).status).toBe(403);
|
||||
expect((await levelUp(ariaId, playerAuth)).status).toBe(403);
|
||||
expect((await approve(playerAuth, { characterIds: [ariaId] })).status).toBe(403);
|
||||
|
||||
const approved = await approve(dmAuth, { characterIds: [ariaId, boId] });
|
||||
expect(approved.status).toBe(200);
|
||||
expect(approved.body.results.map((r) => [r.name, r.levelUpsPending, r.skipped])).toEqual([['Aria', 1, null], ['Bo', 1, null]]);
|
||||
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(playerAuth)).body.characters;
|
||||
expect(listed.find((c) => c.id === ariaId).levelUpsPending).toBe(1);
|
||||
|
||||
// The player sees the plan and takes the level; a second try has nothing approved.
|
||||
const plan = await request(app).get(`/characters/${ariaId}/level-up`).set(playerAuth);
|
||||
expect(plan.status).toBe(200);
|
||||
expect(plan.body.plan).toMatchObject({ toLevel: 2, hp: { method: 'AVERAGE', die: 10, average: 6 } });
|
||||
const taken = await levelUp(ariaId, playerAuth);
|
||||
expect(taken.status).toBe(200);
|
||||
expect(taken.body.character).toMatchObject({ level: 2, levelUpsPending: 0 });
|
||||
expect(taken.body.hpRolled).toBeNull();
|
||||
expect((await levelUp(ariaId, playerAuth)).status).toBe(403);
|
||||
// Bo is the DM's own character; another player cannot take that level.
|
||||
expect((await levelUp(boId, playerAuth)).status).toBe(403);
|
||||
|
||||
// An approval can be taken back, and stops at the top level.
|
||||
const revoked = await approve(dmAuth, { characterIds: [boId, ariaId], revoke: true });
|
||||
expect(revoked.body.results.find((r) => r.name === 'Bo')).toMatchObject({ levelUpsPending: 0, skipped: null });
|
||||
expect(revoked.body.results.find((r) => r.name === 'Aria').skipped).toMatch(/no level up waiting/);
|
||||
await prisma.character.update({ where: { id: boId }, data: { level: 20 } });
|
||||
expect((await approve(dmAuth, { characterIds: [boId] })).body.results[0].skipped).toMatch(/level 20/);
|
||||
});
|
||||
|
||||
itIfDb('an approved level is no longer shown as due', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('approvedue');
|
||||
const id = await buildAs(campaign.id, playerAuth, 'Aria', 'FIGHTER', 1);
|
||||
await request(app).patch(`/characters/${id}`).set(dmAuth).send({ xp: 300 });
|
||||
const read = async () => (await request(app).get(`/characters/${id}`).set(playerAuth)).body.character;
|
||||
expect((await read()).xpProgress.levelUpDue).toBe(true);
|
||||
await request(app).post(`/campaigns/${campaign.id}/level-up`).set(dmAuth).send({ characterIds: [id] });
|
||||
expect(await read()).toMatchObject({ levelUpsPending: 1, xpProgress: { levelUpDue: false } });
|
||||
});
|
||||
|
||||
itIfDb('where the campaign rolls hit points, the die is rolled here or typed in from the table', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('hproll');
|
||||
const id = await buildAs(campaign.id, playerAuth, 'Aria', 'FIGHTER', 1);
|
||||
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ hpOnLevelUp: 'ROLL', diceMode: 'PHYSICAL' });
|
||||
const start = (await request(app).get(`/characters/${id}`).set(dmAuth)).body.character.sheet;
|
||||
const before = start.hp.max;
|
||||
const con = start.abilities.CON.modifier; // whatever the species made of it
|
||||
const levelUp = (body) => request(app).post(`/characters/${id}/level-up`).set(dmAuth).send(body);
|
||||
expect((await request(app).get(`/characters/${id}/level-up`).set(dmAuth)).body.plan.hp).toEqual({ method: 'ROLL', die: 10, average: 6, physical: true });
|
||||
|
||||
expect((await levelUp({})).status).toBe(400); // the roll has to be entered
|
||||
expect((await levelUp({ hpRoll: 11 })).status).toBe(400); // not on a d10
|
||||
const low = await levelUp({ hpRoll: 2 });
|
||||
expect(low.body.hpRolled).toBe(2);
|
||||
// 2 from the die plus Constitution, where the average would have given 6.
|
||||
expect(low.body.character.sheet.hp.max).toBe(before + 2 + con);
|
||||
|
||||
// Digital dice: the server rolls, and the sheet follows whatever came up.
|
||||
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ diceMode: 'DIGITAL' });
|
||||
const rolled = await levelUp({ hpRoll: 10, subclass: 'CHAMPION' }); // a typed value is ignored
|
||||
expect(rolled.body.hpRolled).toBeGreaterThanOrEqual(1);
|
||||
expect(rolled.body.hpRolled).toBeLessThanOrEqual(10);
|
||||
expect(rolled.body.character.sheet.hp.max).toBe(before + 2 + con + rolled.body.hpRolled + con);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a player picking spells on a level up', () => {
|
||||
itIfDb('is offered the campaign spells they can cast, and held to the level’s allowance', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('playerspells');
|
||||
const id = await buildAs(campaign.id, playerAuth, 'Mira', 'WIZARD', 1);
|
||||
const spell = async (name, level) => (await request(app).post(`/campaigns/${campaign.id}/spells`).set(dmAuth).send({ name, level })).body.spell.id;
|
||||
const [a, b, c, high] = [await spell('Alarm', 1), await spell('Shield', 1), await spell('Sleep', 1), await spell('Fireball', 3)];
|
||||
await request(app).post(`/campaigns/${campaign.id}/level-up`).set(dmAuth).send({ characterIds: [id] });
|
||||
|
||||
const plan = (await request(app).get(`/characters/${id}/level-up`).set(playerAuth)).body.plan;
|
||||
expect(plan.spells).toMatchObject({ newSpells: 2, maxSpellLevel: 1 });
|
||||
expect(plan.spellOptions.map((s) => s.name)).toEqual(['Alarm', 'Shield', 'Sleep']); // not Fireball: too high
|
||||
|
||||
const levelUp = (spellIds) => request(app).post(`/characters/${id}/level-up`).set(playerAuth).send({ subclass: 'EVOCATION', spellIds });
|
||||
expect((await levelUp([a, b, c])).status).toBe(400); // one too many
|
||||
expect((await levelUp([a, high])).status).toBe(400); // out of reach
|
||||
const done = await levelUp([a, b]);
|
||||
expect(done.status).toBe(200);
|
||||
expect(done.body.character.spells.map((s) => s.spell.name).sort()).toEqual(['Alarm', 'Shield']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dice rolls', () => {
|
||||
itIfDb('are rolled by the server, kept, and shown to whoever may see them', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('rolls');
|
||||
const roll = (auth, body) => request(app).post(`/campaigns/${campaign.id}/rolls`).set(auth).send(body);
|
||||
const seen = async (auth) => (await request(app).get(`/campaigns/${campaign.id}/rolls`).set(auth)).body.rolls;
|
||||
|
||||
const check = await roll(playerAuth, { kind: 'check', label: 'Aria: Stealth', modifier: 5, mode: 'ADVANTAGE' });
|
||||
expect(check.status).toBe(201);
|
||||
const r = check.body.roll;
|
||||
expect(r).toMatchObject({ label: 'Aria: Stealth', notation: '1d20+5', mode: 'ADVANTAGE', physical: false, hidden: false, rollerName: 'Player' });
|
||||
expect(r.dice).toHaveLength(2);
|
||||
expect(r.natural).toBe(Math.max(...r.dice.map((d) => d.value)));
|
||||
expect(r.total).toBe(r.natural + 5);
|
||||
|
||||
const damage = (await roll(playerAuth, { kind: 'dice', expression: '2d6 + 3' })).body.roll;
|
||||
expect(damage.notation).toBe('2d6+3');
|
||||
expect(damage.total).toBe(damage.dice[0].value + damage.dice[1].value + 3);
|
||||
expect((await roll(playerAuth, { kind: 'dice', expression: 'a bite' })).status).toBe(400);
|
||||
expect((await roll(playerAuth, { kind: 'dice' })).status).toBe(400);
|
||||
|
||||
// A player cannot roll behind the screen; the DM can, and only the DM sees it.
|
||||
expect((await roll(playerAuth, { kind: 'check', hidden: true })).body.roll.hidden).toBe(false);
|
||||
const secret = (await roll(dmAuth, { kind: 'check', label: 'Goblin: Stealth', hidden: true })).body.roll;
|
||||
expect(secret.hidden).toBe(true);
|
||||
expect((await seen(dmAuth)).map((x) => x.id)).toContain(secret.id);
|
||||
expect((await seen(playerAuth)).map((x) => x.id)).not.toContain(secret.id);
|
||||
expect((await seen(dmAuth))[0].id).toBe(secret.id); // newest first
|
||||
|
||||
// With rolls kept to oneself, a player sees only their own; the DM still sees all.
|
||||
const open = (await roll(dmAuth, { kind: 'check', label: 'In the open' })).body.roll;
|
||||
expect((await seen(playerAuth)).map((x) => x.id)).toContain(open.id);
|
||||
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ rollVisibility: 'OWN' });
|
||||
expect((await seen(playerAuth)).every((x) => x.rollerName === 'Player')).toBe(true);
|
||||
expect((await seen(dmAuth)).length).toBeGreaterThan((await seen(playerAuth)).length);
|
||||
});
|
||||
|
||||
itIfDb('follow the campaign\u2019s dice: typed in and checked with physical dice, never typed in with digital', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('rollsmode');
|
||||
const roll = (body) => request(app).post(`/campaigns/${campaign.id}/rolls`).set(playerAuth).send(body);
|
||||
const set = (diceMode) => request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ diceMode });
|
||||
|
||||
// Both: either way.
|
||||
expect((await roll({ kind: 'check', values: [14], modifier: 2 })).body.roll).toMatchObject({ total: 16, natural: 14, physical: true });
|
||||
expect((await roll({ kind: 'check', values: [21] })).status).toBe(400);
|
||||
expect((await roll({ kind: 'check', mode: 'DISADVANTAGE', values: [14] })).status).toBe(400); // two d20s
|
||||
expect((await roll({ kind: 'check', mode: 'DISADVANTAGE', values: [14, 6] })).body.roll.natural).toBe(6);
|
||||
expect((await roll({ kind: 'dice', expression: '1d8+1d6+1', values: [8, 6] })).body.roll.total).toBe(15);
|
||||
expect((await roll({ kind: 'dice', expression: '1d8+1d6', values: [8, 7] })).status).toBe(400);
|
||||
|
||||
await set('PHYSICAL');
|
||||
expect((await roll({ kind: 'check' })).status).toBe(400);
|
||||
expect((await roll({ kind: 'check', values: [3] })).status).toBe(201);
|
||||
await set('DIGITAL');
|
||||
expect((await roll({ kind: 'check', values: [20] })).status).toBe(400);
|
||||
expect((await roll({ kind: 'check' })).status).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@ const { deathSaveInfo, lifeState } = require('../utils/hp');
|
||||
const { hitDiceProblem, shortRest, longRest } = require('../utils/rest');
|
||||
const { CURRENCIES } = require('../utils/grants');
|
||||
const { subclassName } = require('../game/data/subclasses');
|
||||
const { classLevels } = require('../game/multiclass');
|
||||
const { parseDice, diceOf, validValues, rollExpression, rollD20, d20Count, D20_MODES, MAX_DICE } = require('../game/dice');
|
||||
const { findSeed, normalizeCode, CODE_LENGTH } = require('../seeds');
|
||||
const { createCampaignFromSeed } = require('../seeds/createCampaign');
|
||||
|
||||
@@ -254,8 +256,16 @@ function characterSummary(character, subclasses = true) {
|
||||
characterClass: character.characterClass,
|
||||
subclassName: subclasses ? subclassName(character.characterClass, character.subclass) : null,
|
||||
level: character.level,
|
||||
levelUpsPending: character.levelUpsPending,
|
||||
// Every class it has levels in; one entry unless it has multiclassed.
|
||||
classes: classLevels(character).map((entry) => ({
|
||||
characterClass: entry.characterClass,
|
||||
level: entry.level,
|
||||
subclassName: subclasses ? subclassName(entry.characterClass, entry.subclass) : null,
|
||||
})),
|
||||
xp: character.xp,
|
||||
xpProgress: xpProgress(character.xp, character.level),
|
||||
// A level already approved is no longer due.
|
||||
xpProgress: xpProgress(character.xp, Math.min(MAX_LEVEL, character.level + character.levelUpsPending)),
|
||||
buildStage: character.buildStage,
|
||||
currentHp: character.currentHp,
|
||||
tempHp: character.tempHp,
|
||||
@@ -362,6 +372,62 @@ router.post('/:id/characters', requireCampaignMember(), async (req, res, next) =
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Level-ups — the DM approves a level for any of the party at once. Each
|
||||
// approval is a checklist waiting (Character.levelUpsPending): the
|
||||
// character's player is told and makes their own picks (POST
|
||||
// /characters/:id/level-up), so five players levelling together do not queue
|
||||
// for the DM. A character with no player is the DM's to finish.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const approveLevelUpSchema = z.object({
|
||||
characterIds: z.array(z.string()).min(1).max(200),
|
||||
// Takes an approval back (one not yet used) instead of giving one.
|
||||
revoke: z.boolean().default(false),
|
||||
});
|
||||
|
||||
router.post('/:id/level-up', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = approveLevelUpSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { revoke } = parsed.data;
|
||||
const ids = [...new Set(parsed.data.characterIds)];
|
||||
|
||||
const characters = await prisma.character.findMany({
|
||||
where: { id: { in: ids }, campaignId: req.campaign.id, buildStage: 'COMPLETE' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (characters.length !== ids.length) return res.status(400).json({ error: 'Pick finished characters from this campaign.' });
|
||||
|
||||
const results = characters.map((character) => {
|
||||
const who = { characterId: character.id, name: character.name, userId: character.userId, level: character.level };
|
||||
if (revoke) {
|
||||
return character.levelUpsPending > 0
|
||||
? { ...who, levelUpsPending: character.levelUpsPending - 1, skipped: null }
|
||||
: { ...who, levelUpsPending: 0, skipped: `${character.name} has no level up waiting.` };
|
||||
}
|
||||
if (character.level + character.levelUpsPending >= MAX_LEVEL) {
|
||||
return { ...who, levelUpsPending: character.levelUpsPending, skipped: `${character.name} is already at (or approved up to) level ${MAX_LEVEL}.` };
|
||||
}
|
||||
return { ...who, levelUpsPending: character.levelUpsPending + 1, skipped: null };
|
||||
});
|
||||
|
||||
const changed = results.filter((result) => !result.skipped);
|
||||
await prisma.$transaction(
|
||||
changed.map((result) => prisma.character.update({ where: { id: result.characterId }, data: { levelUpsPending: result.levelUpsPending } }))
|
||||
);
|
||||
const io = req.app.get('io');
|
||||
for (const { characterId, userId } of changed) {
|
||||
io.to(`campaign:${req.campaign.id}`).emit('character:updated', { characterId });
|
||||
// Tells the character's player there is a level to take (ids only).
|
||||
if (!revoke) io.to(`campaign:${req.campaign.id}`).emit('character:level-up-granted', { characterId, userId });
|
||||
}
|
||||
res.json({ revoke, results });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rests — the DM rests any of the party at once (utils/rest.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1178,4 +1244,128 @@ router.post('/:id/monsters/from-library', requireCampaignMember(), requireDM(),
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dice rolls — the table's dice roller (game/dice.js). A digital roll is made
|
||||
// here, never taken from the client; a physical one is typed in and checked
|
||||
// against its dice. Every roll is kept and announced, so the table (and above
|
||||
// all the DM) sees it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const rollSchema = z
|
||||
.object({
|
||||
label: z.string().max(120).optional().nullable(),
|
||||
// A d20 plus `modifier` (a check, a save, an attack), or a dice expression (damage).
|
||||
kind: z.enum(['check', 'dice']),
|
||||
modifier: z.number().int().min(-99).max(99).default(0),
|
||||
mode: z.enum(D20_MODES).default('NORMAL'),
|
||||
expression: z.string().max(60).optional(),
|
||||
// Dice rolled at the table: what each showed, in order. Left out, the dice are rolled here.
|
||||
values: z.array(z.number().int().min(1).max(1000)).max(MAX_DICE).optional(),
|
||||
hidden: z.boolean().default(false), // the DM's alone
|
||||
encounterId: z.string().optional().nullable(),
|
||||
})
|
||||
.refine((roll) => roll.kind === 'check' || roll.expression, { message: 'A dice roll needs its dice, like "2d6+3".' });
|
||||
|
||||
// Rolls older than this are cleared away as new ones come in.
|
||||
const ROLL_KEEP_DAYS = 14;
|
||||
|
||||
const publicRoll = ({ id, userId, rollerName, label, notation, dice, modifier, total, natural, mode, physical, hidden, encounterId, createdAt }) => ({
|
||||
id,
|
||||
userId,
|
||||
rollerName,
|
||||
label,
|
||||
notation,
|
||||
dice,
|
||||
modifier,
|
||||
total,
|
||||
natural,
|
||||
mode,
|
||||
physical,
|
||||
hidden,
|
||||
encounterId,
|
||||
createdAt,
|
||||
});
|
||||
|
||||
// Which rolls a member may see: the DM every one; a player their own, and
|
||||
// everyone's (but the DM's hidden ones) where the campaign shows all rolls.
|
||||
function visibleRolls(req) {
|
||||
if (req.membership.role === 'DM') return {};
|
||||
if (req.campaign.rollVisibility === 'OWN') return { userId: req.user.id };
|
||||
return { OR: [{ userId: req.user.id }, { hidden: false }] };
|
||||
}
|
||||
|
||||
router.post('/:id/rolls', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = rollSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { label, kind, modifier, mode, expression, values, encounterId } = parsed.data;
|
||||
const isDM = req.membership.role === 'DM';
|
||||
|
||||
// The campaign's dice decide whether a roll is made here or typed in.
|
||||
const diceMode = req.campaign.diceMode;
|
||||
if (values && diceMode === 'DIGITAL') return res.status(400).json({ error: 'This campaign plays with digital dice: the app rolls.' });
|
||||
if (!values && diceMode === 'PHYSICAL') return res.status(400).json({ error: 'This campaign plays with physical dice: roll at the table and enter what the dice show.' });
|
||||
|
||||
let rolled;
|
||||
let notation;
|
||||
if (kind === 'check') {
|
||||
const dice = Array.from({ length: d20Count(mode) }, () => ({ sides: 20 }));
|
||||
if (values && !validValues(dice, values)) {
|
||||
return res.status(400).json({ error: `Enter ${dice.length === 1 ? 'the d20' : 'both d20s'} you rolled: whole numbers from 1 to 20.` });
|
||||
}
|
||||
rolled = rollD20(modifier, mode, { values });
|
||||
notation = `1d20${modifier ? (modifier > 0 ? `+${modifier}` : `${modifier}`) : ''}`;
|
||||
} else {
|
||||
const dice = parseDice(expression);
|
||||
if (!dice) return res.status(400).json({ error: 'Those dice cannot be read. Write them like "2d6+3".' });
|
||||
if (values && !validValues(diceOf(dice), values)) {
|
||||
return res.status(400).json({ error: 'Enter what each die showed, in order, each within its die.' });
|
||||
}
|
||||
rolled = { ...rollExpression(dice, { values }), natural: null };
|
||||
notation = expression.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
const [roll] = await prisma.$transaction([
|
||||
prisma.diceRoll.create({
|
||||
data: {
|
||||
campaignId: req.campaign.id,
|
||||
userId: req.user.id,
|
||||
rollerName: req.user.displayName || 'Someone',
|
||||
label: label || null,
|
||||
notation,
|
||||
dice: rolled.dice,
|
||||
modifier: rolled.modifier,
|
||||
total: rolled.total,
|
||||
natural: rolled.natural,
|
||||
mode: kind === 'check' ? mode : 'NORMAL',
|
||||
physical: Boolean(values),
|
||||
hidden: isDM && parsed.data.hidden, // only the DM rolls behind the screen
|
||||
encounterId: encounterId || null,
|
||||
},
|
||||
}),
|
||||
prisma.diceRoll.deleteMany({ where: { campaignId: req.campaign.id, createdAt: { lt: new Date(Date.now() - ROLL_KEEP_DAYS * 24 * 60 * 60 * 1000) } } }),
|
||||
]);
|
||||
// Ids only: each member's page asks for the roll and gets it only if it is theirs to see.
|
||||
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('roll:created', { rollId: roll.id, userId: roll.userId });
|
||||
res.status(201).json({ roll: publicRoll(roll) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /campaigns/:id/rolls — the latest rolls this member may see, newest first.
|
||||
router.get('/:id/rolls', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 30, 1), 100);
|
||||
const rolls = await prisma.diceRoll.findMany({
|
||||
where: { campaignId: req.campaign.id, ...visibleRolls(req) },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
res.json({ rolls: rolls.map(publicRoll) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -8,6 +8,7 @@ const {
|
||||
computeCharacterSheet,
|
||||
computeWeaponStats,
|
||||
getClassSaveProficiencies,
|
||||
getClassHitDieSize,
|
||||
getClassSpellcastingAbility,
|
||||
scaledDamageDice,
|
||||
canUseGear,
|
||||
@@ -18,7 +19,9 @@ const {
|
||||
MAX_SLOT_LEVEL,
|
||||
ABILITIES,
|
||||
} = require('../game/calculations');
|
||||
const { LevelUpError, advance, levelUpPlan, switchSubclass, subclassByDefault } = require('../game/levelUp');
|
||||
const { LevelUpError, advance, advanceIn, levelUpPlan, switchSubclass, subclassByDefault } = require('../game/levelUp');
|
||||
const { classLevels, withLevelIn } = require('../game/multiclass');
|
||||
const { CLASSES } = require('../game/data/classes');
|
||||
const { resourceTally, MAX_EXHAUSTION } = require('../game/classResources');
|
||||
const { SUBCLASS_LEVEL, SUBCLASSES, subclassesOf, findSubclass, subclassName, newerVersionOf, subclassSummary } = require('../game/data/subclasses');
|
||||
const {
|
||||
@@ -62,7 +65,8 @@ async function loadFullCharacter(id) {
|
||||
weapons: { include: { weapon: true } },
|
||||
spells: { include: { spell: true } },
|
||||
currentLocation: true,
|
||||
campaign: { select: { subclassesEnabled: true, diceMode: true } },
|
||||
classLevels: true,
|
||||
campaign: { select: { subclassesEnabled: true, multiclassEnabled: true, diceMode: true, hpOnLevelUp: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -100,8 +104,19 @@ function serializeCharacter(character, isOwner, isDM = true) {
|
||||
// The 2024 subclass that updates the one the character follows, if any.
|
||||
subclassUpgrade: subclasses && newerVersionOf(character.characterClass, character.subclass) ? subclassSummary(newerVersionOf(character.characterClass, character.subclass)) : null,
|
||||
level: character.level,
|
||||
// Levels the DM has approved that are still to be taken (their picks made).
|
||||
levelUpsPending: character.levelUpsPending,
|
||||
// Every class the character has levels in, the first first; one entry
|
||||
// unless they have multiclassed.
|
||||
classes: classLevels(character).map((entry) => ({
|
||||
characterClass: entry.characterClass,
|
||||
level: entry.level,
|
||||
subclass: subclasses ? entry.subclass : null,
|
||||
subclassName: subclasses ? subclassName(entry.characterClass, entry.subclass) : null,
|
||||
})),
|
||||
xp: character.xp,
|
||||
xpProgress: xpProgress(character.xp, character.level),
|
||||
// A level already approved is no longer due.
|
||||
xpProgress: xpProgress(character.xp, Math.min(MAX_LEVEL, character.level + character.levelUpsPending)),
|
||||
motivation: character.motivation,
|
||||
speciesBonusChoices: character.speciesBonusChoices || {},
|
||||
rolledScores: {
|
||||
@@ -538,33 +553,91 @@ router.post('/:id/resources', requireCharacterAccess({ write: true }), async (re
|
||||
// and the free weapon upgrade.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Answers the request and returns false when this character cannot be levelled up.
|
||||
// Answers the request and returns false when this character cannot be levelled
|
||||
// up. The DM always can; the character's own player can once the DM has
|
||||
// approved a level for them (levelUpsPending).
|
||||
function canLevelUp(req, res, character) {
|
||||
let refusal = null;
|
||||
if (!req.isDM) refusal = [403, 'Only the DM can level a character up.'];
|
||||
else if (character.buildStage !== 'COMPLETE') refusal = [400, 'Character build is not finished yet.'];
|
||||
if (!req.isDM && !(req.isOwner && character.levelUpsPending > 0)) {
|
||||
refusal = [403, req.isOwner ? 'Your DM has not approved a level up yet.' : 'Only the DM can level a character up.'];
|
||||
} else if (character.buildStage !== 'COMPLETE') refusal = [400, 'Character build is not finished yet.'];
|
||||
else if (character.level >= MAX_LEVEL) refusal = [400, `${character.name} is already level ${MAX_LEVEL}, the highest there is.`];
|
||||
if (refusal) res.status(refusal[0]).json({ error: refusal[1] });
|
||||
return !refusal;
|
||||
}
|
||||
|
||||
// The plan for a character's next level in `toClass` (their first class when
|
||||
// not given). Answers the request and returns null when the class can't take
|
||||
// it: another class needs the campaign to allow multiclassing, and the
|
||||
// character to meet both classes' requirements.
|
||||
function planFor(req, res, character, toClass) {
|
||||
const sheet = sheetOf(character);
|
||||
const scores = Object.fromEntries(Object.entries(sheet.abilities).map(([ability, detail]) => [ability, detail.final]));
|
||||
const multiclass = Boolean(character.campaign && character.campaign.multiclassEnabled);
|
||||
if (toClass && !CLASSES[toClass]) {
|
||||
res.status(400).json({ error: 'That is not a class.' });
|
||||
return null;
|
||||
}
|
||||
const plan = levelUpPlan(character, { subclasses: usesSubclasses(character), multiclass, toClass, scores });
|
||||
if (plan.characterClass !== character.characterClass && !classLevels(character).some((e) => e.characterClass === plan.characterClass)) {
|
||||
if (!multiclass) {
|
||||
res.status(400).json({ error: 'This campaign does not allow multiclassing.' });
|
||||
return null;
|
||||
}
|
||||
const option = plan.classChoice.options.find((o) => o.characterClass === plan.characterClass);
|
||||
if (!option.eligible) {
|
||||
res.status(400).json({ error: option.problems.join(' ') });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return { plan, sheet };
|
||||
}
|
||||
|
||||
// How the level's hit points are come by: the campaign's method, the die of
|
||||
// the class taking the level, the average it would count, and whether the
|
||||
// die is rolled at the table (physical dice) and typed in.
|
||||
function hpGain(character, plan) {
|
||||
const die = getClassHitDieSize(plan.characterClass);
|
||||
return {
|
||||
method: (character.campaign && character.campaign.hpOnLevelUp) || 'AVERAGE',
|
||||
die,
|
||||
average: Math.floor(die / 2) + 1,
|
||||
physical: effectiveDiceMode(character.campaign, character) === 'PHYSICAL',
|
||||
};
|
||||
}
|
||||
|
||||
// What the next level brings, for the DM to look over before saying yes.
|
||||
// `?class=` asks about a level in another class (multiclassing).
|
||||
router.get('/:id/level-up', requireCharacterAccess(), async (req, res, next) => {
|
||||
try {
|
||||
const character = await loadFullCharacter(req.character.id);
|
||||
if (!canLevelUp(req, res, character)) return;
|
||||
const plan = levelUpPlan(character, { subclasses: usesSubclasses(character) });
|
||||
const after = sheetOf({ ...character, level: plan.toLevel, spellSlotsUsed: [] });
|
||||
res.json({ plan: { ...plan, maxHp: { from: sheetOf(character).hp.max, to: after.hp.max }, spellSlots: after.spellSlots } });
|
||||
const planned = planFor(req, res, character, typeof req.query.class === 'string' ? req.query.class : null);
|
||||
if (!planned) return;
|
||||
const { plan, sheet } = planned;
|
||||
const after = sheetOf({ ...withLevelIn(character, plan.characterClass), spellSlotsUsed: [] });
|
||||
// The spells that could be granted now: the campaign's, of a level the
|
||||
// class can cast, that the character does not know. Sent with the plan
|
||||
// because a player otherwise sees only the spells they hold.
|
||||
const spellOptions = plan.spells
|
||||
? await prisma.spell.findMany({
|
||||
where: { campaignId: character.campaignId, level: { lte: plan.spells.maxSpellLevel }, id: { notIn: character.spells.map((held) => held.spellId) } },
|
||||
select: { id: true, name: true, level: true, school: true, description: true },
|
||||
orderBy: [{ level: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
: [];
|
||||
res.json({ plan: { ...plan, maxHp: { from: sheet.hp.max, to: after.hp.max }, hp: hpGain(character, plan), spellSlots: after.spellSlots, spellOptions } });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const levelUpSchema = z.object({
|
||||
characterClass: z.enum(Object.keys(CLASSES)).optional(), // the class taking the level; the first one when left out
|
||||
subclass: z.string().optional(), // which subclass, at the level the class chooses one
|
||||
boon: z.string().optional(), // which Boon, at a level that grants one
|
||||
weaponId: z.string().nullable().optional(), // the weapon that gets the free upgrade
|
||||
hpRoll: z.number().int().min(1).max(12).optional(), // the hit die rolled at the table, where HP is rolled with physical dice
|
||||
spellIds: z.array(z.string()).max(50).default([]), // spells newly granted
|
||||
});
|
||||
|
||||
@@ -576,7 +649,9 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
|
||||
const character = await loadFullCharacter(req.character.id);
|
||||
if (!canLevelUp(req, res, character)) return;
|
||||
|
||||
const plan = levelUpPlan(character, { subclasses: usesSubclasses(character) });
|
||||
const planned = planFor(req, res, character, parsed.data.characterClass || null);
|
||||
if (!planned) return;
|
||||
const { plan } = planned;
|
||||
if (plan.subclass && !subclass) return res.status(400).json({ error: `Level ${plan.toLevel} needs a subclass: pick one.` });
|
||||
if (!plan.subclass && subclass) return res.status(400).json({ error: `There is no subclass to pick at level ${plan.toLevel}.` });
|
||||
if (subclass && !plan.subclass.options.some((option) => option.key === subclass)) {
|
||||
@@ -591,18 +666,42 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
|
||||
if (new Set(spellIds).size !== spellIds.length || spellsFound !== spellIds.length) {
|
||||
return res.status(400).json({ error: "Pick the new spells from this campaign's spell list, each once." });
|
||||
}
|
||||
// The DM grants what they like; a player picks within what the level allows.
|
||||
if (!req.isDM && spellIds.length > 0) {
|
||||
const allowed = plan.spells ? plan.spells.newSpells + plan.spells.newCantrips : 0;
|
||||
const inReach = plan.spells ? await prisma.spell.count({ where: { id: { in: spellIds }, level: { lte: plan.spells.maxSpellLevel } } }) : 0;
|
||||
if (spellIds.length > allowed || inReach !== spellIds.length) {
|
||||
return res.status(400).json({ error: allowed > 0 ? `This level allows ${allowed} new spell${allowed === 1 ? '' : 's'}, of a level you can cast.` : 'This level grants no new spell.' });
|
||||
}
|
||||
}
|
||||
|
||||
// The subclass picked, or the one taken for them where the campaign has no choosing.
|
||||
const newSubclass = subclass || plan.subclassTaken || null;
|
||||
const first = plan.characterClass === character.characterClass;
|
||||
const heldSubclass = (classLevels(character).find((e) => e.characterClass === plan.characterClass) || {}).subclass || null;
|
||||
let gained;
|
||||
try {
|
||||
// The subclass picked, or the one taken for them where the campaign has no choosing.
|
||||
const newSubclass = subclass || plan.subclassTaken;
|
||||
gained = advance(character.specialAbilities, character.characterClass, plan.toLevel, boon, newSubclass ? { key: newSubclass, chosen: true } : { key: character.subclass });
|
||||
gained = advanceIn(character.specialAbilities, plan, boon, newSubclass ? { key: newSubclass, chosen: true } : { key: heldSubclass });
|
||||
} catch (err) {
|
||||
if (!(err instanceof LevelUpError)) throw err;
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
const armorBonus = clamp(character.armorBonus + gained.armorBonus, -30, 30);
|
||||
const maxHp = sheetOf({ ...character, level: plan.toLevel, armorBonus }).hp.max;
|
||||
// Rolled hit points: the die is rolled here, or typed in from the table.
|
||||
const hp = hpGain(character, plan);
|
||||
let hpRolled = null;
|
||||
if (hp.method === 'ROLL') {
|
||||
if (hp.physical) {
|
||||
if (!validateManualRoll(hp.die, parsed.data.hpRoll)) {
|
||||
return res.status(400).json({ error: `Roll a d${hp.die} for hit points and enter what it shows (1 to ${hp.die}).` });
|
||||
}
|
||||
hpRolled = parsed.data.hpRoll;
|
||||
} else {
|
||||
hpRolled = rollDie(hp.die);
|
||||
}
|
||||
}
|
||||
const hpRollAdjust = character.hpRollAdjust + (hpRolled == null ? 0 : hpRolled - hp.average);
|
||||
const maxHp = sheetOf({ ...withLevelIn(character, plan.characterClass, newSubclass || undefined), armorBonus, hpRollAdjust }).hp.max;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (weaponId) {
|
||||
@@ -612,11 +711,22 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
|
||||
});
|
||||
}
|
||||
await tx.characterSpell.createMany({ data: spellIds.map((spellId) => ({ characterId: character.id, spellId })), skipDuplicates: true });
|
||||
// A level in another class goes on that class's row.
|
||||
if (!first) {
|
||||
await tx.characterClassLevel.upsert({
|
||||
where: { characterId_characterClass: { characterId: character.id, characterClass: plan.characterClass } },
|
||||
create: { characterId: character.id, characterClass: plan.characterClass, level: 1, subclass: newSubclass },
|
||||
update: { level: plan.classLevel, ...(newSubclass ? { subclass: newSubclass } : {}) },
|
||||
});
|
||||
}
|
||||
await tx.character.update({
|
||||
where: { id: character.id },
|
||||
data: {
|
||||
level: plan.toLevel,
|
||||
...(subclass || plan.subclassTaken ? { subclass: subclass || plan.subclassTaken } : {}),
|
||||
hpRollAdjust,
|
||||
// An approved level is now taken.
|
||||
levelUpsPending: Math.max(0, character.levelUpsPending - 1),
|
||||
...(first && newSubclass ? { subclass: newSubclass } : {}),
|
||||
specialAbilities: gained.specialAbilities,
|
||||
armorBonus,
|
||||
tempHp: Math.max(character.tempHp, gained.tempHp),
|
||||
@@ -629,7 +739,8 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
|
||||
|
||||
const full = await loadFullCharacter(character.id);
|
||||
emitCharacterUpdated(req);
|
||||
res.json({ character: serializeCharacter(full, req.isOwner) });
|
||||
// hpRolled: what the hit die showed, where HP is rolled (null otherwise).
|
||||
res.json({ character: serializeCharacter(full, req.isOwner, req.isDM), hpRolled });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -768,6 +879,9 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
|
||||
privateNotes: req.isOwner ? source.privateNotes : null,
|
||||
diceMode: source.diceMode,
|
||||
buildStage: 'COMPLETE',
|
||||
classLevels: {
|
||||
create: (source.classLevels || []).map(({ characterClass, level, subclass }) => ({ characterClass, level, subclass })),
|
||||
},
|
||||
skillProficiencies: {
|
||||
create: source.skillProficiencies.map(({ skill, proficient, expertise }) => ({ skill, proficient, expertise })),
|
||||
},
|
||||
|
||||
@@ -6,6 +6,8 @@ const { requireAuth } = require('../middleware/auth');
|
||||
const { CLASSES } = require('../game/data/classes');
|
||||
const { findSubclass } = require('../game/data/subclasses');
|
||||
const { progression } = require('../game/levelUp');
|
||||
const { MULTICLASS_PREREQS, PREREQ_SCORE, prereqText } = require('../game/multiclass');
|
||||
const { MULTICLASS_PATHS } = require('../game/data/multiclassPaths');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -23,4 +25,14 @@ router.get('/progression', (req, res) => {
|
||||
res.json({ characterClass, subclass: subclass || null, levels: progression(characterClass, subclass) });
|
||||
});
|
||||
|
||||
// GET /reference/multiclass — what each class asks of the ability scores to
|
||||
// multiclass into or out of it, and the suggested routes (data/multiclassPaths.js).
|
||||
router.get('/multiclass', (req, res) => {
|
||||
res.json({
|
||||
prereqScore: PREREQ_SCORE,
|
||||
prereqs: Object.fromEntries(Object.keys(CLASSES).map((cls) => [cls, { alternatives: MULTICLASS_PREREQS[cls], text: prereqText(cls) }])),
|
||||
paths: MULTICLASS_PATHS,
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
const { computeCharacterSheet } = require('../game/calculations');
|
||||
|
||||
const SHEET_INCLUDE = {
|
||||
// A multiclassed character's other classes (game/multiclass.js).
|
||||
classLevels: true,
|
||||
skillProficiencies: true,
|
||||
saveProficiencies: true,
|
||||
inventoryItems: { include: { item: true } },
|
||||
|
||||
@@ -27,8 +27,8 @@ turning XP tracking off keeps everyone's XP; it just stops showing it.
|
||||
|
||||
| Setting | Default | What it does |
|
||||
|---|---|---|
|
||||
| `diceMode` | `BOTH` | `DIGITAL`: the app rolls every die. `PHYSICAL`: everyone rolls real dice and types in what they show (the server checks each value is on the die). `BOTH`: each character picks one or the other before their first build roll. |
|
||||
| `rollVisibility` | `ALL` | `ALL`: everyone sees every roll. `OWN`: a player sees only their own rolls. The DM always sees every roll. |
|
||||
| `diceMode` | `BOTH` | `DIGITAL`: the app rolls every die. `PHYSICAL`: everyone rolls real dice and types in what they show (the server checks each value is on the die). `BOTH`: either. In the builder each character picks one before their first roll; in the dice roller each person ticks "I roll real dice" when they want to. It covers the builder, the dice roller and the hit point roll on a level up. |
|
||||
| `rollVisibility` | `ALL` | Who sees a roll made in the dice roller. `ALL`: everyone sees every roll. `OWN`: a player sees only their own rolls. The DM always sees every roll, and can roll hidden either way. |
|
||||
| `hideMonsterRolls` | on | The DM's rolls for monsters and NPCs stay hidden from players until they are put into play. |
|
||||
|
||||
## Combat
|
||||
|
||||
+132
-23
@@ -362,8 +362,10 @@ Ability scores cannot be changed after the build is finalized.
|
||||
## 6. Level and XP
|
||||
|
||||
- Every character starts at **level 1**. The range is 1 to 20.
|
||||
- Only the DM can change level or XP. There are two ways to change a level,
|
||||
both on the sheet: the **⬆ Level Up…** button, and the Level field.
|
||||
- Only the DM decides that a character levels, and only the DM changes XP. A
|
||||
level is taken in one of three ways: the DM **approves** it and the player
|
||||
takes it themselves (below), the DM levels the character directly with
|
||||
**⬆ Level up now…** on the sheet, or the DM types into the Level field.
|
||||
- **XP** (experience points) is shown under the character's name on the sheet
|
||||
as "37 / 300 XP": what they have, and what their next level takes. The party
|
||||
overview shows the same under each level.
|
||||
@@ -372,7 +374,7 @@ Ability scores cannot be changed after the build is finalized.
|
||||
The DM can also type a character's XP into the **XP** field beside Level.
|
||||
- **XP does not level a character up.** When a character has the XP for
|
||||
their next level the sheet shows "⬆ Level up due" (the party overview shows
|
||||
⬆), and the DM levels them with "⬆ Level Up…" when the table is ready. A DM
|
||||
⬆), and the DM approves the level when the table is ready. A DM
|
||||
who levels by milestones can turn **Track experience points** off in the
|
||||
campaign settings. That hides XP everywhere, closes the award panel, and
|
||||
leaves levelling entirely to the DM.
|
||||
@@ -385,16 +387,50 @@ Ability scores cannot be changed after the build is finalized.
|
||||
| 4 | 2,700 | 9 | 48,000 | 14 | 140,000 | 19 | 305,000 |
|
||||
| 5 | 6,500 | 10 | 64,000 | 15 | 165,000 | 20 | 355,000 |
|
||||
|
||||
### Approving a level, and who takes it
|
||||
|
||||
So that five players levelling together do not queue for the DM, the DM
|
||||
**approves** and each player **takes** their own level:
|
||||
|
||||
1. The DM presses **⬆ Level up…** on the campaign page or the party overview
|
||||
(beside 🛏 Rest…). A pop-out lists the party; with XP tracked, those who have
|
||||
the XP for their next level start ticked ("Everyone", "Those with the XP"
|
||||
and "Nobody" tick in one go). **Approve** gives each ticked character one
|
||||
level to take. One character can also be approved from their sheet
|
||||
(**⬆ Approve a level up**).
|
||||
2. Each approved character's player is told at once, wherever in the campaign
|
||||
they are: a notice slides in ("⬆ Aria can level up!") with a button
|
||||
straight to the checklist. If they are not online, it waits: their sheet
|
||||
and their row on the campaign page show "⬆ Level up ready" until it is taken.
|
||||
3. The player opens the checklist, sees what the level gives, makes the picks
|
||||
that are theirs (the class for a multiclass, subclass, Boon, new spells,
|
||||
the weapon upgrade, the hit point roll) and confirms. The level is theirs
|
||||
then, healed to full, as if the DM had done it.
|
||||
|
||||
Things to know:
|
||||
|
||||
- An approval is one level. Approving twice lets a character take two, one
|
||||
checklist at a time. "Take one back" in the pop-out undoes an approval that
|
||||
has not been used.
|
||||
- The DM sees who still has picks to make: a banner on the party overview,
|
||||
and a badge on each character. The DM can open the checklist and finish it
|
||||
for anyone ("Open the checklist" on the sheet).
|
||||
- A character with no player (an ally the DM runs) is the DM's to finish.
|
||||
- A player may pick as many new spells as the level allows, of a level they
|
||||
can cast; the DM, doing it for them, is not held to that.
|
||||
- Once approved, a level no longer shows as "due".
|
||||
|
||||
### Level Up: the checklist
|
||||
|
||||
"⬆ Level Up…" takes a character up **one level** and does everything that
|
||||
goes with it. It opens a pop-out that lists the five steps and asks for the
|
||||
choices; nothing changes until the DM presses "Level up to N".
|
||||
The checklist takes a character up **one level** and does everything that
|
||||
goes with it. It opens as a pop-out that lists the steps and asks for the
|
||||
choices; nothing changes until "Level up to N" is pressed. It is the same
|
||||
whether a player takes an approved level or the DM uses **⬆ Level up now…**.
|
||||
|
||||
| Step | What the site does | What the DM chooses |
|
||||
| Step | What the site does | What is chosen |
|
||||
|---|---|---|
|
||||
| 1. Level | Raises the level by one. Proficiency bonus, saves, skills and spell DC follow | Nothing |
|
||||
| 2. Hit points | Works out the new max HP and heals to it (a dead character stays dead). One more Hit Die | Nothing |
|
||||
| 1. Level | Raises the level by one. Proficiency bonus, saves, skills and spell DC follow | With multiclassing on: which class gains the level |
|
||||
| 2. Hit points | Works out the new max HP and heals to it (a dead character stays dead). One more Hit Die. Where the campaign **rolls** hit points, the class's die is rolled by the site, or rolled at the table and typed in (physical dice); the roll takes the place of the average for good | The die roll, with physical dice |
|
||||
| 3. Special abilities | Writes the class's and the subclass's new features for that level into Special Abilities ([section 21](#21-what-each-class-gains-as-it-levels)) | The **subclass**, at the level the class chooses one; the **Boon**, at a level that grants one |
|
||||
| 4. Spells | Gives a caster a fresh set of spell slots for the new level, and grants the spells ticked | Which spells to grant, from the campaign's spell list |
|
||||
| 5. Gear | Raises one weapon by one gear level | Which weapon, or none |
|
||||
@@ -499,6 +535,57 @@ the DM's to keep.
|
||||
whose gear level is below the character's new level (the equipped one is
|
||||
offered first). Powerful and legendary weapons never get it.
|
||||
|
||||
### Multiclassing
|
||||
|
||||
With **Multiclassing** on in the campaign settings, the level-up checklist
|
||||
starts by asking **which class gains this level**: one the character has, or
|
||||
a new one. A new class is open only when the character's final ability
|
||||
scores meet its requirement *and* the requirement of every class they already
|
||||
have (the 2014 rules):
|
||||
|
||||
| Class | Needs | Class | Needs |
|
||||
|---|---|---|---|
|
||||
| Artificer | Intelligence 13 | Paladin | Strength 13 and Charisma 13 |
|
||||
| Barbarian | Strength 13 | Ranger | Dexterity 13 and Wisdom 13 |
|
||||
| Bard | Charisma 13 | Rogue | Dexterity 13 |
|
||||
| Cleric | Wisdom 13 | Sorcerer | Charisma 13 |
|
||||
| Druid | Wisdom 13 | Warlock | Charisma 13 |
|
||||
| Fighter | Strength 13 or Dexterity 13 | Wizard | Intelligence 13 |
|
||||
| Monk | Dexterity 13 and Wisdom 13 | | |
|
||||
|
||||
Everything then goes by **each class's own level**:
|
||||
|
||||
- A first level in a new class writes what that class grants at level 1
|
||||
(not its saving throws or skills) and a "… Training" line with what it
|
||||
teaches a newcomer. The class's later features, Boons and subclass come at
|
||||
its own levels: a fighter 5 / rogue 3 chooses a roguish archetype now.
|
||||
- **Hit points**: the first class's die in full at level 1, then each level the
|
||||
average of that level's class die, plus Constitution every level. Hit Dice
|
||||
are counted per class.
|
||||
- **Spell slots**: one casting class keeps its own table. Two or more add up a
|
||||
caster level (all of a full caster's levels, half a paladin's or ranger's,
|
||||
half an artificer's rounded up, a third of an Eldritch Knight's or Arcane
|
||||
Trickster's) and use the full caster table. A warlock's pact slots stand
|
||||
apart: the sheet says how many, back on a short rest.
|
||||
- **Spellcasting**: each casting class has its own DC and attack bonus, listed
|
||||
on the sheet.
|
||||
- **Counters** (Rage, Ki, Channel Divinity...) go by their class's level; two
|
||||
classes giving the same counter keep the larger.
|
||||
|
||||
The first class is the character's `characterClass`; each other class is a
|
||||
row of its own (`CharacterClassLevel`: class, level, subclass). Everywhere a
|
||||
class is named, a multiclassed character reads "Fighter 5 (Champion) / Rogue 3".
|
||||
|
||||
**Routes.** The sheet's "The Road Ahead" card shows the road from 1 to 20 for
|
||||
each class the character has, and, with multiclassing on, **Multiclass routes
|
||||
from here**: well-known combinations (the Hexadin, the Sorlock, a two-level
|
||||
fighter dip…) with each step, how far along the character is, and whether
|
||||
their scores meet every class on the way. They are suggestions; nothing is
|
||||
enforced.
|
||||
|
||||
**Eldritch Knight and Arcane Trickster** cast with Intelligence from level 3,
|
||||
with a third caster's slots, whether or not the character has multiclassed.
|
||||
|
||||
### The Level field
|
||||
|
||||
Typing a number into Level sets it outright: for a character who joins late,
|
||||
@@ -775,9 +862,11 @@ calculations but is only ever set if the DM supplies it during step 5.
|
||||
|
||||
### Rolling from the sheet
|
||||
|
||||
Numbers with a dotted underline roll when clicked. The dice roller opens with
|
||||
the result under a label saying what it was ("Aria: Stealth"), the die that
|
||||
came up, the bonus and the total. A natural 20 or 1 is called out.
|
||||
Numbers with a dotted underline roll when clicked. The dice roller opens
|
||||
under a label saying what the roll is ("Aria: Stealth"): the dice tumble in,
|
||||
each drawn as its own shape (a d4's triangle, a d20's hexagon…), land on what
|
||||
they rolled, and the total follows with the bonus. A natural 20 glows and a
|
||||
natural 1 shakes.
|
||||
|
||||
| Click | Rolls |
|
||||
|---|---|
|
||||
@@ -796,10 +885,30 @@ came up, the bonus and the total. A natural 20 or 1 is called out.
|
||||
- **Advantage and disadvantage.** The roller has Disadv. / Normal / Adv.
|
||||
buttons. Whichever is lit applies to every d20 rolled until it is changed:
|
||||
two d20s are rolled and the higher or lower kept, and both are shown.
|
||||
- A roll is made in the browser of whoever clicked. It is not sent to the
|
||||
server, nobody else sees it, and it changes nothing: a damage roll is not
|
||||
applied, a spell slot is not spent, and an initiative roll still has to be
|
||||
typed into its field.
|
||||
- **The server rolls.** In a campaign every digital roll is made by the
|
||||
server, never by the browser, and is kept. A roll changes nothing by
|
||||
itself: a damage roll is not applied, a spell slot is not spent, and an
|
||||
initiative roll still has to be typed into its field.
|
||||
- **The DM sees every roll.** Each roll is announced to the table as it is
|
||||
made. The roller's second tab ("Everyone's rolls" for the DM, "Table" for a
|
||||
player) lists them, newest first: who rolled, what for, the dice as they
|
||||
fell, the total, and whether it was a natural 20 or 1, rolled with
|
||||
advantage, or rolled with real dice. Somebody else's roll also pops up
|
||||
beside the dice button for a few seconds as it lands, and an encounter page
|
||||
has the same list in its side column for the DM. What a **player** sees
|
||||
follows the campaign's "Who sees a roll" setting: everyone's rolls, or only
|
||||
their own.
|
||||
- **Hidden rolls.** The DM can tick "🙈 Roll hidden" in the roller: such a
|
||||
roll is the DM's alone, whatever the setting.
|
||||
- **Real dice.** The campaign's "Dice at this table" setting decides how a
|
||||
roll is made. *Digital*: the app rolls. *Physical*: the roller asks for the
|
||||
dice to be rolled at the table and typed in, one box per die (both d20s
|
||||
with advantage or disadvantage), and the server checks each value is on its
|
||||
die. *Both*: the app rolls unless the roller's "🎯 I roll real dice" is
|
||||
ticked, which each person chooses for themselves. A typed-in roll is marked
|
||||
"at the table" in the list.
|
||||
- Rolls are kept for two weeks. Outside a campaign (nothing opened yet) the
|
||||
roller still works, rolling in the browser and showing nobody.
|
||||
- Of dice written with more after them ("2d8+3 fire") the dice are rolled.
|
||||
Text that does not start with dice (a DM may type anything as an item's or
|
||||
a spell's dice) is shown as plain text.
|
||||
@@ -813,8 +922,9 @@ advantage or disadvantage; untick anyone who is not rolling. Each character
|
||||
gets their own d20 and their own bonus, and with a DC each is marked "made it"
|
||||
or "failed", with how many made it and whether a group check (at least half)
|
||||
succeeds. On the party overview it offers every finished character who is not
|
||||
dead; in an encounter, the characters in it who are still up. These rolls are
|
||||
the DM's own, like any other: the players do not see them.
|
||||
dead; in an encounter, the characters in it who are still up. These are the
|
||||
DM's own quick rolls, made on the DM's page: they are not in the table's list
|
||||
and the players do not see them.
|
||||
|
||||
## 10. Gear pools, starting options and gear levels
|
||||
|
||||
@@ -1187,7 +1297,8 @@ Another Campaign" card on its sheet.
|
||||
| Give inspiration, or take it away | No | Yes | No |
|
||||
| Spend inspiration | Yes | Yes | No |
|
||||
| Set exhaustion; give or remove a condition | No | Yes | No |
|
||||
| Level a character up (the checklist), pick a subclass or a Boon | No | Yes | No |
|
||||
| Approve a level up, level a character directly, change the Level field | No | Yes | No |
|
||||
| Take an approved level (the checklist): pick the subclass, Boon, spells, weapon | Yes, once approved | Yes | No |
|
||||
| Set or change the subclass on the sheet (in a campaign that uses subclasses) | No | Yes | No |
|
||||
| Switch subclasses on or off for the campaign | No | Yes | No |
|
||||
| Edit special abilities, background, appearance (after the build) | No | Yes | No |
|
||||
@@ -1222,10 +1333,8 @@ calculations for them.
|
||||
|
||||
- The effects of special abilities: species traits and class features are
|
||||
written on the sheet for the DM to apply, but nothing is calculated from them
|
||||
- The spell slots of the spellcasting subclasses of non-casters (Eldritch
|
||||
Knight, Arcane Trickster): their lines say what they can do, and the DM
|
||||
grants their spells
|
||||
- Multiclassing
|
||||
- A multiclassed warlock's pact slots are counted at the table: the sheet
|
||||
says how many there are, and the tracker keeps the other classes' slots
|
||||
- Spell preparation limits and concentration
|
||||
- Ability score increases and feats on level-up (a Boon is given in their
|
||||
place, and Weapon Mastery and Arcane Focus are added by the player, not by
|
||||
|
||||
@@ -211,6 +211,13 @@ section 7): a **long rest** gives every slot back; a **short rest** gives a
|
||||
warlock theirs. Levelling up with "⬆ Level Up…" gives a fresh set. Changing
|
||||
the Level field keeps what was spent, as far as the new level has those slots.
|
||||
|
||||
An **Artificer** casts from level 1 at half a full caster's pace, rounding up
|
||||
(2 slots at levels 1 and 2, 2nd-level slots at 5). An **Eldritch Knight** or
|
||||
**Arcane Trickster** casts from level 3 at a third of the pace, with
|
||||
Intelligence. A **multiclassed** character adds their casting classes up to a
|
||||
caster level on the full caster table, with a warlock's pact slots kept apart
|
||||
([CHARACTERS.md](CHARACTERS.md), "Multiclassing").
|
||||
|
||||
**Bard, Cleric, Druid, Sorcerer, Wizard** (back on a long rest):
|
||||
|
||||
| Character level | 1st | 2nd | 3rd | 4th | 5th |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.23.0",
|
||||
"version": "1.26.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
CharacterDetail,
|
||||
CharacterSummary,
|
||||
Currency,
|
||||
DiceRollRecord,
|
||||
DiceMode,
|
||||
Encounter,
|
||||
EncounterMode,
|
||||
@@ -16,8 +17,10 @@ import type {
|
||||
GearEffect,
|
||||
GoldTransaction,
|
||||
Item,
|
||||
LevelUpApproval,
|
||||
LevelUpPlan,
|
||||
Location,
|
||||
MulticlassReference,
|
||||
Monster,
|
||||
Note,
|
||||
Npc,
|
||||
@@ -26,6 +29,7 @@ import type {
|
||||
QuickStats,
|
||||
RestResult,
|
||||
RestType,
|
||||
RollSpec,
|
||||
Spell,
|
||||
Weapon,
|
||||
} from './types';
|
||||
@@ -252,12 +256,14 @@ export function useCharacterState(characterId: string) {
|
||||
}
|
||||
|
||||
// What a character's next level brings. DM only.
|
||||
export function useLevelUpPlan(characterId: string, enabled: boolean) {
|
||||
// `toClass`: the plan for a level in that class (multiclassing); the first class when null.
|
||||
export function useLevelUpPlan(characterId: string, enabled: boolean, toClass: CharacterClass | null = null) {
|
||||
return useQuery({
|
||||
queryKey: ['character', characterId, 'level-up'],
|
||||
queryKey: ['character', characterId, 'level-up', toClass],
|
||||
enabled,
|
||||
gcTime: 0, // never open on the plan for a level the character has already reached
|
||||
queryFn: async () => (await apiClient.get<{ plan: LevelUpPlan }>(`/characters/${characterId}/level-up`)).data.plan,
|
||||
queryFn: async () =>
|
||||
(await apiClient.get<{ plan: LevelUpPlan }>(`/characters/${characterId}/level-up`, { params: toClass ? { class: toClass } : {} })).data.plan,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -265,9 +271,9 @@ export function useLevelUpPlan(characterId: string, enabled: boolean) {
|
||||
export function useLevelUp(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { subclass?: string; boon?: string; weaponId?: string | null; spellIds?: string[] }) =>
|
||||
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/level-up`, data)).data.character,
|
||||
onSuccess: (character) => {
|
||||
mutationFn: async (data: { characterClass?: CharacterClass; subclass?: string; boon?: string; weaponId?: string | null; spellIds?: string[]; hpRoll?: number }) =>
|
||||
(await apiClient.post<{ character: CharacterDetail; hpRolled: number | null }>(`/characters/${characterId}/level-up`, data)).data,
|
||||
onSuccess: ({ character }) => {
|
||||
invalidateCharacter(qc, characterId);
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet's HP
|
||||
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
|
||||
@@ -291,6 +297,20 @@ export function useRest(campaignId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// The DM approves a level for any of the party at once (or takes an approval
|
||||
// back); each character's player then takes theirs.
|
||||
export function useApproveLevelUps(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { characterIds: string[]; revoke?: boolean }) =>
|
||||
(await apiClient.post<{ revoke: boolean; results: LevelUpApproval[] }>(`/campaigns/${campaignId}/level-up`, data)).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['character'] });
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCharacter(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -362,6 +382,38 @@ export function useBuildOptions() {
|
||||
});
|
||||
}
|
||||
|
||||
// The latest rolls at the table that this member may see, newest first.
|
||||
export function useRolls(campaignId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['campaign', campaignId, 'rolls'],
|
||||
enabled: Boolean(campaignId),
|
||||
queryFn: async () => (await apiClient.get<{ rolls: DiceRollRecord[] }>(`/campaigns/${campaignId}/rolls`, { params: { limit: 40 } })).data.rolls,
|
||||
});
|
||||
}
|
||||
|
||||
// Makes a roll at the table: the server rolls it (or checks dice typed in),
|
||||
// keeps it and tells the table.
|
||||
export function useRoll(campaignId: string | null | undefined) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (spec: RollSpec) => (await apiClient.post<{ roll: DiceRollRecord }>(`/campaigns/${campaignId}/rolls`, spec)).data.roll,
|
||||
onSuccess: (roll) => {
|
||||
// Shown at once in the feed, without waiting for the server to say so.
|
||||
qc.setQueryData<DiceRollRecord[]>(['campaign', campaignId, 'rolls'], (rolls) => (rolls && !rolls.some((r) => r.id === roll.id) ? [roll, ...rolls] : rolls));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Multiclassing requirements and the suggested routes.
|
||||
export function useMulticlassReference(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ['multiclass-reference'],
|
||||
enabled,
|
||||
staleTime: Infinity,
|
||||
queryFn: async () => (await apiClient.get<MulticlassReference>('/reference/multiclass')).data,
|
||||
});
|
||||
}
|
||||
|
||||
// What each level from 1 to 20 gives a class, with a subclass's lines when one is given.
|
||||
export function useProgression(characterClass: CharacterClass | null | undefined, subclass?: string | null) {
|
||||
return useQuery({
|
||||
|
||||
+100
-2
@@ -132,6 +132,21 @@ export interface SubclassSummary {
|
||||
replaces: string | null;
|
||||
}
|
||||
|
||||
// What each class asks of the scores to multiclass, and the suggested routes
|
||||
// (GET /reference/multiclass). An alternative is a list of abilities that must
|
||||
// all be at least prereqScore.
|
||||
export interface MulticlassReference {
|
||||
prereqScore: number;
|
||||
prereqs: Record<CharacterClass, { alternatives: Ability[][]; text: string }>;
|
||||
paths: {
|
||||
key: string;
|
||||
name: string;
|
||||
from: CharacterClass | null;
|
||||
steps: { class: CharacterClass; toLevel: number; subclass?: string }[];
|
||||
why: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
// One level of a class's road from 1 to 20 (GET /reference/progression).
|
||||
export interface ProgressionLevel {
|
||||
level: number;
|
||||
@@ -216,7 +231,7 @@ export interface SpellSlotLevel {
|
||||
// A character's spell slots, from class and level. A warlock's (PACT) are all
|
||||
// of one slot level and come back on a short rest; everyone else's on a long one.
|
||||
export interface SpellSlots {
|
||||
kind: 'FULL' | 'HALF' | 'PACT';
|
||||
kind: 'FULL' | 'HALF' | 'THIRD' | 'PACT';
|
||||
restoresOn: 'SHORT' | 'LONG';
|
||||
levels: SpellSlotLevel[];
|
||||
}
|
||||
@@ -227,6 +242,16 @@ export interface HitDice {
|
||||
count: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
// A multiclassed character's dice by class (each class's own size).
|
||||
pools?: { characterClass: CharacterClass; size: number; count: number }[];
|
||||
}
|
||||
|
||||
// A class a character has levels in. One entry unless they have multiclassed.
|
||||
export interface ClassLevel {
|
||||
characterClass: CharacterClass;
|
||||
level: number;
|
||||
subclass?: string | null;
|
||||
subclassName: string | null;
|
||||
}
|
||||
|
||||
// A class counter (Rage, Ki, Channel Divinity...): how many uses there are,
|
||||
@@ -278,6 +303,8 @@ export interface CharacterSummary extends CharacterCondition {
|
||||
characterClass: CharacterClass | null;
|
||||
subclassName: string | null;
|
||||
level: number;
|
||||
levelUpsPending: number; // levels the DM has approved, still to be taken
|
||||
classes: ClassLevel[];
|
||||
xp: number;
|
||||
xpProgress: XpProgress;
|
||||
buildStage: BuildStage;
|
||||
@@ -350,6 +377,10 @@ export interface CharacterSheet {
|
||||
passiveInvestigation: number;
|
||||
passiveInsight: number;
|
||||
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
|
||||
// A multiclassed caster's DC and attack for each class that casts.
|
||||
spellcastingByClass: { characterClass: CharacterClass; ability: Ability; saveDC: number; attackBonus: number }[];
|
||||
// A multiclassed warlock's pact slots, apart from the other slots; null otherwise.
|
||||
pactSlots: { count: number; slotLevel: number } | null;
|
||||
spellSlots: SpellSlots | null; // null for a character with no spell slots
|
||||
resources: ClassResource[]; // the class counters, with how many of each are left
|
||||
gear: ActiveGear;
|
||||
@@ -478,6 +509,8 @@ export interface CharacterDetail extends CharacterCondition {
|
||||
// The 2024 subclass that updates the one followed, if there is one.
|
||||
subclassUpgrade: SubclassSummary | null;
|
||||
level: number;
|
||||
levelUpsPending: number; // levels the DM has approved, still to be taken
|
||||
classes: ClassLevel[];
|
||||
xp: number;
|
||||
xpProgress: XpProgress;
|
||||
motivation: string | null;
|
||||
@@ -883,7 +916,17 @@ export interface BoonOption {
|
||||
export interface LevelUpPlan {
|
||||
fromLevel: number;
|
||||
toLevel: number;
|
||||
// The class taking the level, its own level after it, and whether it is the
|
||||
// character's first level in that class (multiclassing).
|
||||
characterClass: CharacterClass;
|
||||
classLevel: number;
|
||||
joining: boolean;
|
||||
// With multiclassing on: every class the level could go to, and why not.
|
||||
classChoice: { options: { characterClass: CharacterClass; level: number; eligible: boolean; problems: string[] }[] } | null;
|
||||
maxHp: { from: number; to: number };
|
||||
// How the level's hit points are come by: the average, or a roll of the
|
||||
// class's die (typed in from the table when `physical`).
|
||||
hp: { method: HpMethod; die: number; average: number; physical: boolean };
|
||||
// Set when a subclass has to be picked on the way to this level: the level
|
||||
// the class chooses at, and each option with everything it would write.
|
||||
subclass: { level: number; options: { key: string; name: string; summary: string; features: { name: string; text: string }[] }[] } | null;
|
||||
@@ -896,9 +939,64 @@ export interface LevelUpPlan {
|
||||
// What the book lets a caster be granted now; null for a class with no spells.
|
||||
spells: { maxSpellLevel: number; unlocksSpellLevel: number | null; newSpells: number; newCantrips: number } | null;
|
||||
spellSlots: SpellSlots | null; // the slots at the new level
|
||||
// The campaign's spells that could be granted now: of a level the class can
|
||||
// cast, and not yet known.
|
||||
spellOptions: { id: string; name: string; level: number; school: string | null; description: string | null }[];
|
||||
// Held weapons that can take the free upgrade of one level.
|
||||
weapons: { weaponId: string; name: string; equipped: boolean; level: number; damageDice: string; nextDamageDice: string }[];
|
||||
beyondTables: boolean; // past level 10, where the class tables stop
|
||||
beyondTables: boolean; // past level 20, where the class tables stop
|
||||
}
|
||||
|
||||
// What approving (or taking back) a level did for one character (POST /campaigns/:id/level-up).
|
||||
export interface LevelUpApproval {
|
||||
characterId: string;
|
||||
name: string;
|
||||
userId: string | null;
|
||||
level: number;
|
||||
levelUpsPending: number;
|
||||
skipped: string | null; // why nothing changed for them
|
||||
}
|
||||
|
||||
// One die of a roll: its size, what it showed, whether it counted (the lower
|
||||
// d20 with advantage does not), and whether it adds (1) or takes away (-1).
|
||||
// `maxed`: counted at its most rather than rolled (a critical hit).
|
||||
export interface RolledDie {
|
||||
sides: number;
|
||||
value: number;
|
||||
kept: boolean;
|
||||
sign: number;
|
||||
maxed?: boolean;
|
||||
}
|
||||
|
||||
// A roll made at the table's dice roller, as everyone who may see it gets it.
|
||||
export interface DiceRollRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
rollerName: string;
|
||||
label: string | null; // what it was for: "Aria: Stealth"
|
||||
notation: string; // "1d20+3"
|
||||
dice: RolledDie[];
|
||||
modifier: number;
|
||||
total: number;
|
||||
natural: number | null; // the d20 that counted, for a single d20
|
||||
mode: 'NORMAL' | 'ADVANTAGE' | 'DISADVANTAGE';
|
||||
physical: boolean; // rolled at the table and typed in
|
||||
hidden: boolean; // the DM's alone
|
||||
encounterId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// What is asked of the server for a roll: a d20 plus a modifier, or dice.
|
||||
// `values` are dice rolled at the table, in order.
|
||||
export interface RollSpec {
|
||||
label?: string | null;
|
||||
kind: 'check' | 'dice';
|
||||
modifier?: number;
|
||||
mode?: 'NORMAL' | 'ADVANTAGE' | 'DISADVANTAGE';
|
||||
expression?: string;
|
||||
values?: number[];
|
||||
hidden?: boolean;
|
||||
encounterId?: string | null;
|
||||
}
|
||||
|
||||
export type RestType = 'SHORT' | 'LONG';
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { fmtMod } from '../lib/format';
|
||||
import { useCampaign, useRoll, useRolls } from '../api/queries';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { useLastCampaignId } from '../lib/lastCampaign';
|
||||
import { extractErrorMessage, fmtMod } from '../lib/format';
|
||||
import { onRollRequest, parseDice, rollD20, rollDie, type RollMode, type RollRequest } from '../lib/diceRoller';
|
||||
import { DiceRow } from './dice/Die';
|
||||
import { settleMs } from '../lib/diceAnimation';
|
||||
import { RollFeed, RollLine } from './RollFeed';
|
||||
import { NumberInput } from './NumberInput';
|
||||
import type { DiceRollRecord, RollSpec, RolledDie } from '../api/types';
|
||||
|
||||
const DICE = [4, 6, 8, 10, 12, 20, 100];
|
||||
const MAX_DICE = 20;
|
||||
const HISTORY_LENGTH = 8;
|
||||
// How long another player's roll stays up beside the dice button.
|
||||
const INCOMING_MS = 6000;
|
||||
const BY_HAND_KEY = 'dice-by-hand';
|
||||
|
||||
const MODES: { mode: RollMode; label: string; title: string }[] = [
|
||||
{ mode: 'DISADVANTAGE', label: 'Disadv.', title: 'Disadvantage: roll two d20s and keep the lower' },
|
||||
@@ -12,77 +23,164 @@ const MODES: { mode: RollMode; label: string; title: string }[] = [
|
||||
{ mode: 'ADVANTAGE', label: 'Adv.', title: 'Advantage: roll two d20s and keep the higher' },
|
||||
];
|
||||
|
||||
interface Roll {
|
||||
id: number;
|
||||
label?: string; // what it was for, when it was asked for from a sheet
|
||||
notation: string; // "1d20+3", "1d8+1d6+2"
|
||||
dice: number[]; // every die rolled, in order
|
||||
modifier: number;
|
||||
total: number;
|
||||
// The d20 that counted, for a roll of a single d20 (a natural 20 or 1 means something).
|
||||
natural: number | null;
|
||||
mode: RollMode; // how a single d20 was rolled
|
||||
}
|
||||
|
||||
function notation(count: number, sides: number, modifier: number): string {
|
||||
return `${count}d${sides}${modifier ? fmtMod(modifier) : ''}`;
|
||||
}
|
||||
|
||||
// A d20 plus a bonus, with advantage or disadvantage as chosen.
|
||||
function d20Roll(modifier: number, mode: RollMode, label?: string): Omit<Roll, 'id'> {
|
||||
const { kept, dice } = rollD20(mode);
|
||||
return { label, notation: notation(1, 20, modifier), dice, modifier, total: kept + modifier, natural: kept, mode };
|
||||
// What a spec is written as: "1d20+3", "2d6+1".
|
||||
function specNotation(spec: RollSpec): string {
|
||||
return spec.kind === 'check' ? notation(1, 20, spec.modifier ?? 0) : (spec.expression ?? '').replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
// What a request from a sheet comes to; null for dice that cannot be read.
|
||||
function answer(request: RollRequest, mode: RollMode): Omit<Roll, 'id'> | null {
|
||||
if (request.kind === 'check') return d20Roll(request.modifier, mode, request.label);
|
||||
const parsed = parseDice(request.expression);
|
||||
if (!parsed) return null;
|
||||
const dice: number[] = [];
|
||||
let total = parsed.flat;
|
||||
for (const term of parsed.dice) {
|
||||
for (let i = 0; i < term.count; i++) {
|
||||
const die = rollDie(term.sides);
|
||||
dice.push(die);
|
||||
total += term.sign * die;
|
||||
}
|
||||
// The size of each die a spec rolls, in order; null for dice that cannot be read.
|
||||
function specDice(spec: RollSpec): number[] | null {
|
||||
if (spec.kind === 'check') return spec.mode && spec.mode !== 'NORMAL' ? [20, 20] : [20];
|
||||
const parsed = parseDice(spec.expression ?? '');
|
||||
return parsed ? parsed.dice.flatMap((term) => Array.from({ length: term.count }, () => term.sides)) : null;
|
||||
}
|
||||
|
||||
let localId = 0;
|
||||
|
||||
// A roll made in the browser, in the shape the server gives: used outside a
|
||||
// campaign, where there is no table to show it to. `values` are dice typed in.
|
||||
function localRoll(spec: RollSpec, rollerName: string, values?: number[]): DiceRollRecord | null {
|
||||
const base = { id: `local-${++localId}`, userId: 'local', rollerName, label: spec.label ?? null, physical: Boolean(values), hidden: false, encounterId: null, createdAt: new Date().toISOString() };
|
||||
if (spec.kind === 'check') {
|
||||
const mode = spec.mode ?? 'NORMAL';
|
||||
const rolled = values ?? rollD20(mode).dice;
|
||||
const natural = mode === 'ADVANTAGE' ? Math.max(...rolled) : mode === 'DISADVANTAGE' ? Math.min(...rolled) : rolled[0];
|
||||
const keptAt = rolled.indexOf(natural);
|
||||
const dice: RolledDie[] = rolled.map((value, i) => ({ sides: 20, value, kept: i === keptAt, sign: 1 }));
|
||||
return { ...base, notation: specNotation(spec), dice, modifier: spec.modifier ?? 0, total: natural + (spec.modifier ?? 0), natural, mode };
|
||||
}
|
||||
return { label: request.label, notation: request.expression.replace(/\s+/g, ''), dice, modifier: parsed.flat, total, natural: null, mode: 'NORMAL' };
|
||||
const parsed = parseDice(spec.expression ?? '');
|
||||
if (!parsed) return null;
|
||||
let at = 0;
|
||||
const dice: RolledDie[] = parsed.dice.flatMap((term) =>
|
||||
Array.from({ length: term.count }, () => ({ sides: term.sides, value: values ? values[at++] : rollDie(term.sides), kept: true, sign: term.sign }))
|
||||
);
|
||||
return { ...base, notation: specNotation(spec), dice, modifier: parsed.flat, total: dice.reduce((sum, d) => sum + d.sign * d.value, 0) + parsed.flat, natural: null, mode: 'NORMAL' };
|
||||
}
|
||||
|
||||
// A table-side dice roller, reachable from every page. It rolls what is set
|
||||
// up here, and whatever is clicked on a sheet or an encounter card (a skill,
|
||||
// a save, an attack, damage: lib/diceRoller.ts), with the bonus already in.
|
||||
// These rolls are for the table: they are not sent to the server and do not
|
||||
// feed the character builder, whose dice stay locked in server-side.
|
||||
// The table's dice roller, reachable from every page. It rolls what is set up
|
||||
// here, and whatever is clicked on a sheet or an encounter card (a skill, a
|
||||
// save, an attack, damage: lib/diceRoller.ts), with the bonus already in.
|
||||
//
|
||||
// In a campaign the server makes every digital roll, keeps it and tells the
|
||||
// table: the DM sees each roll as it lands, and players see what the
|
||||
// campaign's "who sees a roll" setting allows. The campaign's dice setting
|
||||
// decides how a roll is made: by the app, by real dice typed in, or either.
|
||||
// Outside a campaign the dice are rolled here and go nowhere.
|
||||
export function DiceRoller() {
|
||||
const { user } = useAuth();
|
||||
const lastCampaignId = useLastCampaignId();
|
||||
const { data: campaignData } = useCampaign(user && lastCampaignId ? lastCampaignId : undefined);
|
||||
const campaign = campaignData?.campaign;
|
||||
const campaignId = campaign ? campaign.id : null;
|
||||
const isDM = campaign?.role === 'DM';
|
||||
const diceMode = campaign?.diceMode ?? 'DIGITAL';
|
||||
const { data: feed = [] } = useRolls(campaignId);
|
||||
const send = useRoll(campaignId);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tab, setTab] = useState<'roll' | 'table'>('roll');
|
||||
const [sides, setSides] = useState(20);
|
||||
const [count, setCount] = useState(1);
|
||||
const [modifier, setModifier] = useState(0);
|
||||
const [mode, setMode] = useState<RollMode>('NORMAL');
|
||||
const [history, setHistory] = useState<Roll[]>([]);
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const [byHand, setByHand] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(BY_HAND_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [latest, setLatest] = useState<DiceRollRecord | null>(null);
|
||||
const [history, setHistory] = useState<DiceRollRecord[]>([]); // outside a campaign only
|
||||
// A roll waiting for real dice to be typed in, and what has been typed.
|
||||
const [pending, setPending] = useState<RollSpec | null>(null);
|
||||
const [typed, setTyped] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// The id of somebody else's roll that has just arrived, while it is shown.
|
||||
const [incomingId, setIncomingId] = useState<string | null>(null);
|
||||
|
||||
const push = (roll: Omit<Roll, 'id'>) => setHistory((prev) => [{ ...roll, id: (prev[0]?.id ?? 0) + 1 }, ...prev].slice(0, HISTORY_LENGTH));
|
||||
// Real dice are typed in when the campaign plays with them, or allows both and the roller prefers them.
|
||||
const typing = diceMode === 'PHYSICAL' || (diceMode === 'BOTH' && byHand);
|
||||
|
||||
// A roll asked for from elsewhere on the page: rolled, and shown. It is
|
||||
// rolled the way the roller is set at that moment.
|
||||
const currentMode = useRef(mode);
|
||||
async function perform(spec: RollSpec, values?: number[]) {
|
||||
setError(null);
|
||||
let roll: DiceRollRecord | null;
|
||||
if (campaignId) {
|
||||
try {
|
||||
roll = await send.mutateAsync({ ...spec, values, hidden: isDM && hidden });
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
roll = localRoll(spec, 'You', values);
|
||||
if (!roll) return;
|
||||
setHistory((prev) => [roll!, ...prev].slice(0, HISTORY_LENGTH));
|
||||
}
|
||||
setLatest(roll);
|
||||
setPending(null);
|
||||
setTyped([]);
|
||||
}
|
||||
|
||||
// A roll to make: straight away, or held until its dice are typed in.
|
||||
function ask(spec: RollSpec) {
|
||||
if (!specDice(spec)) return;
|
||||
setTab('roll');
|
||||
setOpen(true);
|
||||
if (typing) {
|
||||
setPending(spec);
|
||||
setTyped([]);
|
||||
setError(null);
|
||||
} else {
|
||||
perform(spec);
|
||||
}
|
||||
}
|
||||
|
||||
// A roll asked for from elsewhere on the page, rolled the way the roller is set at that moment.
|
||||
const current = useRef({ ask, mode });
|
||||
useEffect(() => {
|
||||
currentMode.current = mode;
|
||||
current.current = { ask, mode };
|
||||
});
|
||||
useEffect(
|
||||
() =>
|
||||
onRollRequest((request) => {
|
||||
const roll = answer(request, currentMode.current);
|
||||
if (!roll) return;
|
||||
push(roll);
|
||||
setOpen(true);
|
||||
onRollRequest((request: RollRequest) => {
|
||||
const { ask: askNow, mode: modeNow } = current.current;
|
||||
askNow(request.kind === 'check' ? { kind: 'check', label: request.label, modifier: request.modifier, mode: modeNow } : { kind: 'dice', label: request.label, expression: request.expression });
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// Somebody else's roll arriving: shown beside the dice button for a moment,
|
||||
// so the DM sees each roll without opening anything. What the feed held
|
||||
// when it first loaded is not news.
|
||||
const newest = feed[0];
|
||||
const newestId = newest?.id ?? null;
|
||||
const newestIsMine = newest?.userId === user?.id;
|
||||
const seen = useRef<string | null | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!campaignId) return;
|
||||
if (seen.current === undefined) {
|
||||
seen.current = newestId;
|
||||
return;
|
||||
}
|
||||
if (!newestId || newestId === seen.current) return;
|
||||
seen.current = newestId;
|
||||
// One's own roll is not news, and takes down whatever was showing.
|
||||
const show = setTimeout(() => setIncomingId(newestIsMine ? null : newestId), 0);
|
||||
const hide = setTimeout(() => setIncomingId((shown) => (shown === newestId ? null : shown)), INCOMING_MS);
|
||||
return () => {
|
||||
clearTimeout(show);
|
||||
clearTimeout(hide);
|
||||
};
|
||||
}, [campaignId, newestId, newestIsMine]);
|
||||
const incoming = incomingId ? (feed.find((roll) => roll.id === incomingId) ?? null) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
|
||||
@@ -90,17 +188,29 @@ export function DiceRoller() {
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open]);
|
||||
|
||||
const latest = history[0];
|
||||
const singleD20 = sides === 20 && count === 1;
|
||||
const setup: RollSpec = singleD20 ? { kind: 'check', modifier, mode } : { kind: 'dice', expression: notation(count, sides, modifier) };
|
||||
const pendingDice = pending ? (specDice(pending) ?? []) : [];
|
||||
const typedValues = pendingDice.map((_, i) => Number(typed[i]));
|
||||
const typedOk = pendingDice.length > 0 && pendingDice.every((dieSides, i) => typed[i] && Number.isInteger(typedValues[i]) && typedValues[i] >= 1 && typedValues[i] <= dieSides);
|
||||
|
||||
function roll() {
|
||||
if (singleD20) return push(d20Roll(modifier, mode));
|
||||
const dice = Array.from({ length: count }, () => rollDie(sides));
|
||||
push({ notation: notation(count, sides, modifier), dice, modifier, total: dice.reduce((sum, d) => sum + d, 0) + modifier, natural: null, mode: 'NORMAL' });
|
||||
function chooseByHand(value: boolean) {
|
||||
setByHand(value);
|
||||
try {
|
||||
localStorage.setItem(BY_HAND_KEY, value ? '1' : '0');
|
||||
} catch {
|
||||
// Private browsing: the choice lasts as long as the page.
|
||||
}
|
||||
if (!value && pending) {
|
||||
// "Let the app roll" with a roll waiting: it is rolled now.
|
||||
const spec = pending;
|
||||
setPending(null);
|
||||
perform(spec);
|
||||
}
|
||||
}
|
||||
|
||||
const natural = latest?.natural ?? null;
|
||||
const modeNote = (r: Roll) => (r.mode === 'ADVANTAGE' ? 'advantage' : r.mode === 'DISADVANTAGE' ? 'disadvantage' : '');
|
||||
const settle = latest ? settleMs(latest.dice.length) : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -114,111 +224,217 @@ export function DiceRoller() {
|
||||
🎲
|
||||
</button>
|
||||
|
||||
{incoming && !(open && tab === 'table') && (
|
||||
<button className="dice-incoming" onClick={() => {
|
||||
setTab('table');
|
||||
setOpen(true);
|
||||
setIncomingId(null);
|
||||
}} title="Open the table's rolls">
|
||||
<RollLine roll={incoming} mine={false} rolling size={30} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="dice-panel" role="dialog" aria-label="Dice roller">
|
||||
<div className="flex-between">
|
||||
<div className="card-title" style={{ margin: 0 }}>
|
||||
Roll Dice
|
||||
</div>
|
||||
{campaignId ? (
|
||||
<div className="dice-tabs" role="tablist">
|
||||
<button role="tab" aria-selected={tab === 'roll'} className={`die-btn${tab === 'roll' ? ' is-selected' : ''}`} onClick={() => setTab('roll')}>
|
||||
Roll
|
||||
</button>
|
||||
<button role="tab" aria-selected={tab === 'table'} className={`die-btn${tab === 'table' ? ' is-selected' : ''}`} onClick={() => setTab('table')}>
|
||||
{isDM ? 'Everyone’s rolls' : 'Table'} {feed.length > 0 && <span className="combatant-sub">({feed.length})</span>}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card-title" style={{ margin: 0 }}>
|
||||
Roll Dice
|
||||
</div>
|
||||
)}
|
||||
<button className="btn btn-ghost" onClick={() => setOpen(false)} aria-label="Close the dice roller">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="dice-choices mt-sm" role="group" aria-label="Which die">
|
||||
{DICE.map((d) => (
|
||||
<button key={d} className={`die-btn${d === sides ? ' is-selected' : ''}`} aria-pressed={d === sides} onClick={() => setSides(d)}>
|
||||
d{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-between mt-sm">
|
||||
<div className="flex-row gap-sm">
|
||||
<span className="dice-label">Dice</span>
|
||||
<button className="btn" aria-label="One fewer die" disabled={count <= 1} onClick={() => setCount(count - 1)}>
|
||||
−
|
||||
</button>
|
||||
<span className="mono" aria-live="polite" style={{ minWidth: '1.5ch', textAlign: 'center' }}>
|
||||
{count}
|
||||
</span>
|
||||
<button className="btn" aria-label="One more die" disabled={count >= MAX_DICE} onClick={() => setCount(count + 1)}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<label className="flex-row gap-sm">
|
||||
<span className="dice-label">Bonus</span>
|
||||
<input
|
||||
type="number"
|
||||
min={-99}
|
||||
max={99}
|
||||
value={modifier}
|
||||
onChange={(e) => setModifier(Math.max(-99, Math.min(99, Math.trunc(Number(e.target.value)) || 0)))}
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* For any single d20: one rolled here, and every check, save and attack clicked on a sheet. */}
|
||||
<div className="dice-choices mt-sm" role="group" aria-label="How a d20 is rolled">
|
||||
{MODES.map((m) => (
|
||||
<button key={m.mode} className={`die-btn${m.mode === mode ? ' is-selected' : ''}`} aria-pressed={m.mode === mode} title={m.title} onClick={() => setMode(m.mode)}>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="btn btn-primary btn-block btn-lg mt-sm" onClick={roll}>
|
||||
🎲 Roll {notation(count, sides, modifier)}
|
||||
{singleD20 && mode !== 'NORMAL' && ` (${modeNote({ mode } as Roll)})`}
|
||||
</button>
|
||||
|
||||
<div className="dice-result" aria-live="polite">
|
||||
{latest ? (
|
||||
<>
|
||||
{latest.label && <div className="dice-label roll-label">{latest.label}</div>}
|
||||
{/* Keyed on the roll so the pop animation replays every time, even on a repeat result. */}
|
||||
<div key={latest.id} className={`roll-result animate${natural === 20 ? ' is-crit' : natural === 1 ? ' is-fumble' : ''}`}>
|
||||
{latest.total}
|
||||
</div>
|
||||
{natural === 20 && <div className="dice-callout">Natural 20!</div>}
|
||||
{natural === 1 && <div className="dice-callout is-fumble">Natural 1</div>}
|
||||
{(latest.dice.length > 1 || latest.modifier !== 0 || latest.label) && (
|
||||
<div className="dice-breakdown mono">
|
||||
{latest.natural != null && latest.dice.length > 1
|
||||
? `${latest.dice.join(' / ')} → ${latest.natural} (${modeNote(latest)})`
|
||||
: latest.natural != null
|
||||
? latest.natural
|
||||
: `${latest.notation}: ${latest.dice.join(', ')}`}
|
||||
{latest.modifier !== 0 && ` ${latest.modifier > 0 ? '+' : '−'} ${Math.abs(latest.modifier)}`} = {latest.total}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="subtitle" style={{ margin: 0 }}>
|
||||
Pick a die and roll, or click a skill, save or attack on a sheet.
|
||||
{tab === 'table' && campaignId ? (
|
||||
<div className="mt-sm">
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
{isDM
|
||||
? 'Every roll made at this table, as it lands. Hidden ones are yours alone.'
|
||||
: campaign?.rollVisibility === 'OWN'
|
||||
? 'Your rolls. This campaign keeps each player’s rolls between them and the DM.'
|
||||
: 'Rolls made at this table.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{history.length > 1 && (
|
||||
<RollFeed rolls={feed} userId={user?.id} empty="Nobody has rolled yet." />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-between">
|
||||
<span className="dice-label">Earlier rolls</span>
|
||||
<button className="btn btn-ghost" onClick={() => setHistory([])}>
|
||||
Clear
|
||||
</button>
|
||||
<div className="dice-choices mt-sm" role="group" aria-label="Which die">
|
||||
{DICE.map((d) => (
|
||||
<button key={d} className={`die-btn${d === sides ? ' is-selected' : ''}`} aria-pressed={d === sides} onClick={() => setSides(d)}>
|
||||
d{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{history.slice(1).map((r) => (
|
||||
<div key={r.id} className="flex-between dice-history-row">
|
||||
<span>
|
||||
{r.label ? `${r.label} · ` : ''}
|
||||
{r.notation}
|
||||
{r.dice.length > 1 && <span className="dice-history-dice"> ({r.dice.join(', ')})</span>}
|
||||
|
||||
<div className="flex-between mt-sm">
|
||||
<div className="flex-row gap-sm">
|
||||
<span className="dice-label">Dice</span>
|
||||
<button className="btn" aria-label="One fewer die" disabled={count <= 1} onClick={() => setCount(count - 1)}>
|
||||
−
|
||||
</button>
|
||||
<span className="mono" aria-live="polite" style={{ minWidth: '1.5ch', textAlign: 'center' }}>
|
||||
{count}
|
||||
</span>
|
||||
<strong className="mono">{r.total}</strong>
|
||||
<button className="btn" aria-label="One more die" disabled={count >= MAX_DICE} onClick={() => setCount(count + 1)}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<label className="flex-row gap-sm">
|
||||
<span className="dice-label">Bonus</span>
|
||||
<input
|
||||
type="number"
|
||||
min={-99}
|
||||
max={99}
|
||||
value={modifier}
|
||||
onChange={(e) => setModifier(Math.max(-99, Math.min(99, Math.trunc(Number(e.target.value)) || 0)))}
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* For any single d20: one rolled here, and every check, save and attack clicked on a sheet. */}
|
||||
<div className="dice-choices mt-sm" role="group" aria-label="How a d20 is rolled">
|
||||
{MODES.map((m) => (
|
||||
<button key={m.mode} className={`die-btn${m.mode === mode ? ' is-selected' : ''}`} aria-pressed={m.mode === mode} title={m.title} onClick={() => setMode(m.mode)}>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(diceMode === 'BOTH' || (isDM && campaignId)) && (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
{diceMode === 'BOTH' && (
|
||||
<label className="checkbox-row" title="Roll real dice at the table and type in what they show">
|
||||
<input type="checkbox" checked={byHand} onChange={(e) => chooseByHand(e.target.checked)} />
|
||||
🎯 I roll real dice
|
||||
</label>
|
||||
)}
|
||||
{isDM && campaignId && (
|
||||
<label className="checkbox-row" title="Only you see a hidden roll">
|
||||
<input type="checkbox" checked={hidden} onChange={(e) => setHidden(e.target.checked)} />
|
||||
🙈 Roll hidden
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{diceMode === 'PHYSICAL' && <p className="combatant-sub mt-sm" style={{ marginBottom: 0 }}>This campaign plays with real dice: roll at the table and enter what they show.</p>}
|
||||
|
||||
{pending ? (
|
||||
<form
|
||||
className="dice-entry mt-sm"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (typedOk) perform(pending, typedValues);
|
||||
}}
|
||||
>
|
||||
<div className="dice-label roll-label">{pending.label || 'Your roll'}</div>
|
||||
<p style={{ margin: '0.2rem 0 0.4rem' }}>
|
||||
Roll <strong className="mono">{specNotation(pending)}</strong>
|
||||
{pending.kind === 'check' && pending.mode && pending.mode !== 'NORMAL' ? ` with ${pending.mode.toLowerCase()} (both d20s)` : ''} and enter what{' '}
|
||||
{pendingDice.length === 1 ? 'it shows' : 'each die shows'}:
|
||||
</p>
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
{pendingDice.map((dieSides, i) => (
|
||||
<label key={i} className="dice-entry-die">
|
||||
<span className="dice-label">d{dieSides}</span>
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={dieSides}
|
||||
placeholder={`1–${dieSides}`}
|
||||
value={typed[i] ?? ''}
|
||||
autoFocus={i === 0}
|
||||
onChange={(e) => setTyped((prev) => Object.assign([...prev], { [i]: e.target.value }))}
|
||||
style={{ width: 64 }}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-row gap-sm mt-sm">
|
||||
<button type="submit" className="btn btn-primary" disabled={!typedOk || send.isPending}>
|
||||
Enter roll
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setPending(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
{diceMode === 'BOTH' && (
|
||||
<button type="button" className="btn btn-ghost" onClick={() => chooseByHand(false)}>
|
||||
Let the app roll
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button className="btn btn-primary btn-block btn-lg mt-sm" onClick={() => ask(setup)} disabled={send.isPending}>
|
||||
{typing ? '🎯' : '🎲'} Roll {notation(count, sides, modifier)}
|
||||
{singleD20 && mode !== 'NORMAL' && ` (${mode === 'ADVANTAGE' ? 'advantage' : 'disadvantage'})`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && <p className="dice-error">{error}</p>}
|
||||
|
||||
<div className="dice-result" aria-live="polite">
|
||||
{latest ? (
|
||||
// Keyed on the roll so the dice tumble again every time, even on a repeat result.
|
||||
<div key={latest.id} className="dice-stage">
|
||||
{latest.label && <div className="dice-label roll-label">{latest.label}</div>}
|
||||
<DiceRow dice={latest.dice} rolling size={latest.dice.length > 6 ? 40 : 58} />
|
||||
<div className={`roll-result roll-reveal${natural === 20 ? ' is-crit' : natural === 1 ? ' is-fumble' : ''}`} style={{ animationDelay: `${settle}ms` }}>
|
||||
{latest.total}
|
||||
</div>
|
||||
<div className="roll-reveal" style={{ animationDelay: `${settle}ms` }}>
|
||||
{natural === 20 && <div className="dice-callout">Natural 20!</div>}
|
||||
{natural === 1 && <div className="dice-callout is-fumble">Natural 1</div>}
|
||||
<div className="dice-breakdown mono">
|
||||
{latest.notation}
|
||||
{latest.mode !== 'NORMAL' && ` (${latest.mode === 'ADVANTAGE' ? 'advantage' : 'disadvantage'})`}
|
||||
{latest.modifier !== 0 && `: dice ${latest.total - latest.modifier} ${latest.modifier > 0 ? '+' : '−'} ${Math.abs(latest.modifier)}`} = {latest.total}
|
||||
{latest.hidden && ' · hidden'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="subtitle" style={{ margin: 0 }}>
|
||||
Pick a die and roll, or click a skill, save or attack on a sheet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!campaignId && history.length > 1 && (
|
||||
<>
|
||||
<div className="flex-between">
|
||||
<span className="dice-label">Earlier rolls</span>
|
||||
<button className="btn btn-ghost" onClick={() => setHistory([])}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{history.slice(1).map((r) => (
|
||||
<div key={r.id} className="flex-between dice-history-row">
|
||||
<span>
|
||||
{r.label ? `${r.label} · ` : ''}
|
||||
{r.notation}
|
||||
{r.dice.length > 1 && <span className="dice-history-dice"> ({r.dice.map((d) => d.value).join(', ')})</span>}
|
||||
</span>
|
||||
<strong className="mono">{r.total}</strong>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{campaignId && (
|
||||
<p className="combatant-sub" style={{ margin: 0 }}>
|
||||
{isDM ? 'Your rolls are shown to the table unless hidden.' : campaign?.rollVisibility === 'OWN' ? 'Your DM sees this roll.' : 'The table sees this roll.'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAuth } from '../auth/useAuth';
|
||||
import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
|
||||
import { DiceRoller } from './DiceRoller';
|
||||
import { CharacterPanel } from './CharacterPanel';
|
||||
import { Toasts } from './Toasts';
|
||||
|
||||
export function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
@@ -70,6 +71,7 @@ export function Layout() {
|
||||
)}
|
||||
<DiceRoller />
|
||||
{user && <CharacterPanel />}
|
||||
<Toasts />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState } from 'react';
|
||||
import { useApproveLevelUps } from '../api/queries';
|
||||
import { classLabel, extractErrorMessage } from '../lib/format';
|
||||
import { Modal } from './Modal';
|
||||
import { ErrorBanner } from './Spinner';
|
||||
import type { CharacterSummary, LevelUpApproval } from '../api/types';
|
||||
|
||||
const MAX_LEVEL = 20;
|
||||
|
||||
// The DM's level-up button: approve a level for any of the party at once.
|
||||
// Each approval is a checklist waiting for its character: their player is
|
||||
// told and makes the picks themselves, so nobody queues for the DM. A
|
||||
// character without a player is the DM's to finish, from its sheet.
|
||||
// `trackXp`: with XP kept, those who have the XP for their next level start
|
||||
// ticked; without it (milestones) the DM ticks who levels.
|
||||
export function LevelUpBulkModal({
|
||||
campaignId,
|
||||
characters,
|
||||
trackXp,
|
||||
onClose,
|
||||
}: {
|
||||
campaignId: string;
|
||||
characters: CharacterSummary[];
|
||||
trackXp: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const approve = useApproveLevelUps(campaignId);
|
||||
const party = characters.filter((c) => c.buildStage === 'COMPLETE');
|
||||
const canLevel = (c: CharacterSummary) => c.level + c.levelUpsPending < MAX_LEVEL;
|
||||
const [picked, setPicked] = useState<Set<string>>(() => new Set(party.filter((c) => trackXp && c.xpProgress.levelUpDue && canLevel(c)).map((c) => c.id)));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState<LevelUpApproval[] | null>(null);
|
||||
|
||||
function toggle(id: string) {
|
||||
setPicked((current) => {
|
||||
const next = new Set(current);
|
||||
if (!next.delete(id)) next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function send(characterIds: string[], revoke = false) {
|
||||
setError(null);
|
||||
try {
|
||||
const { results } = await approve.mutateAsync({ characterIds, revoke });
|
||||
if (!revoke) setDone(results);
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<Modal title="Level ups approved" subtitle="Each player makes their own picks" onClose={onClose}>
|
||||
{done.map((result) => (
|
||||
<div key={result.characterId} className="reward-row">
|
||||
<strong>{result.name}</strong>
|
||||
{result.skipped ? (
|
||||
<div className="combatant-sub">{result.skipped}</div>
|
||||
) : (
|
||||
<div className="combatant-sub">
|
||||
Level {result.level} → {result.level + 1} is theirs to take.{' '}
|
||||
{result.userId ? 'Their player has been told and chooses what they gain.' : 'No player: finish it from the sheet.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="modal-foot">
|
||||
<button className="btn btn-primary" onClick={onClose}>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Level up" subtitle="Approve a level for the party; each player then picks what they gain" wide onClose={onClose}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
<button className="btn" onClick={() => setPicked(new Set(party.filter(canLevel).map((c) => c.id)))}>
|
||||
Everyone
|
||||
</button>
|
||||
{trackXp && (
|
||||
<button className="btn" onClick={() => setPicked(new Set(party.filter((c) => c.xpProgress.levelUpDue && canLevel(c)).map((c) => c.id)))}>
|
||||
Those with the XP
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-ghost" onClick={() => setPicked(new Set())}>
|
||||
Nobody
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-md">
|
||||
{party.length === 0 && <p className="combatant-sub">No finished characters yet.</p>}
|
||||
{party.map((c) => (
|
||||
<label key={c.id} className="checkbox-row level-choice" style={canLevel(c) ? undefined : { opacity: 0.55 }}>
|
||||
<input type="checkbox" checked={picked.has(c.id)} disabled={!canLevel(c)} onChange={() => toggle(c.id)} />
|
||||
<span style={{ flex: 1 }}>
|
||||
<strong>{c.name}</strong>{' '}
|
||||
<span className="combatant-sub">
|
||||
{classLabel(c.characterClass, c.subclassName, c.classes)} · Level {c.level}
|
||||
</span>
|
||||
{trackXp && c.xpProgress.levelUpDue && <span className="badge badge-live" style={{ marginLeft: 6 }}>has the XP</span>}
|
||||
{c.levelUpsPending > 0 && (
|
||||
<span className="badge" style={{ marginLeft: 6 }}>
|
||||
{c.levelUpsPending} waiting on their picks
|
||||
</span>
|
||||
)}
|
||||
{!c.userId && <span className="badge" style={{ marginLeft: 6 }}>no player: yours to finish</span>}
|
||||
</span>
|
||||
{c.levelUpsPending > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
title="Takes back one approved level that has not been taken yet"
|
||||
disabled={approve.isPending}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
send([c.id], true);
|
||||
}}
|
||||
>
|
||||
Take one back
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-foot">
|
||||
<button className="btn btn-primary" disabled={picked.size === 0 || approve.isPending} onClick={() => send([...picked])}>
|
||||
{approve.isPending ? 'Approving…' : `Approve a level for ${picked.size} character${picked.size === 1 ? '' : 's'}`}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useLevelUp, useLevelUpPlan } from '../api/queries';
|
||||
import { extractErrorMessage, slotLevelLabel, slotsSummary, spellLevelLabel } from '../lib/format';
|
||||
import { CLASS_LABELS, classLabel, extractErrorMessage, slotLevelLabel, slotsSummary, spellLevelLabel } from '../lib/format';
|
||||
import { Modal } from './Modal';
|
||||
import { NumberInput } from './NumberInput';
|
||||
import { Spinner, ErrorBanner } from './Spinner';
|
||||
import type { CharacterDetail, LevelUpPlan, Spell } from '../api/types';
|
||||
import { showToast } from '../lib/toasts';
|
||||
import type { CharacterClass, CharacterDetail, LevelUpPlan } from '../api/types';
|
||||
|
||||
// One numbered step of the checklist.
|
||||
function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
|
||||
@@ -27,15 +29,20 @@ function allowance(spells: NonNullable<LevelUpPlan['spells']>): string {
|
||||
// The levelling-up checklist for one character, one level at a time: the
|
||||
// level, HP, the class's new special abilities (with a subclass or a Boon
|
||||
// where the level calls for one), new spells and fresh slots, and the free weapon upgrade. The
|
||||
// site does every step; the DM makes the choices and says yes.
|
||||
export function LevelUpModal({ character, campaignSpells, onClose }: { character: CharacterDetail; campaignSpells: Spell[]; onClose: () => void }) {
|
||||
const { data: plan, isLoading, error: planError } = useLevelUpPlan(character.id, true);
|
||||
// site does every step; whoever is levelling makes the choices and says yes:
|
||||
// the DM, or the character's own player once the DM has approved the level.
|
||||
// A player picks spells within what the level allows; the DM grants freely.
|
||||
export function LevelUpModal({ character, isDM, onClose }: { character: CharacterDetail; isDM: boolean; onClose: () => void }) {
|
||||
// The class taking the level: the first one, unless another is picked (multiclassing).
|
||||
const [toClass, setToClass] = useState<CharacterClass | null>(null);
|
||||
const { data: plan, isLoading, error: planError } = useLevelUpPlan(character.id, true, toClass);
|
||||
const levelUp = useLevelUp(character.id);
|
||||
const [subclass, setSubclass] = useState('');
|
||||
const [boon, setBoon] = useState('');
|
||||
// undefined until picked: the equipped weapon is offered first.
|
||||
const [weaponChoice, setWeaponChoice] = useState<string | undefined>(undefined);
|
||||
const [spellIds, setSpellIds] = useState<Set<string>>(new Set());
|
||||
const [hpRoll, setHpRoll] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (isLoading || !plan) {
|
||||
@@ -47,13 +54,16 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
|
||||
}
|
||||
|
||||
const weaponId = weaponChoice ?? plan.weapons.find((w) => w.equipped)?.weaponId ?? plan.weapons[0]?.weaponId ?? '';
|
||||
const known = new Set(character.spells.map((s) => s.spellId));
|
||||
const grantable = plan.spells
|
||||
? campaignSpells.filter((s) => !known.has(s.id) && s.level <= plan.spells!.maxSpellLevel).sort((a, b) => a.level - b.level || a.name.localeCompare(b.name))
|
||||
: [];
|
||||
const grantable = plan.spellOptions;
|
||||
// A player picks up to the level's allowance; the DM is not held to it.
|
||||
const spellAllowance = plan.spells ? plan.spells.newSpells + plan.spells.newCantrips : 0;
|
||||
const spellsFull = !isDM && spellIds.size >= spellAllowance;
|
||||
const needsHpRoll = plan.hp.method === 'ROLL' && plan.hp.physical;
|
||||
const hpRollValid = Number.isInteger(Number(hpRoll)) && Number(hpRoll) >= 1 && Number(hpRoll) <= plan.hp.die;
|
||||
const pickedBoon = plan.boon?.find((b) => b.key === boon);
|
||||
const pickedSubclass = plan.subclass?.options.find((option) => option.key === subclass);
|
||||
const owed = plan.subclass && !subclass ? 'Choose a subclass first' : plan.boon && !boon ? 'Choose a Boon first' : null;
|
||||
const owed =
|
||||
plan.subclass && !subclass ? 'Choose a subclass first' : plan.boon && !boon ? 'Choose a Boon first' : needsHpRoll && !hpRollValid ? `Roll your d${plan.hp.die} for hit points` : null;
|
||||
|
||||
function toggleSpell(id: string) {
|
||||
setSpellIds((current) => {
|
||||
@@ -66,7 +76,14 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
|
||||
async function handleLevelUp() {
|
||||
setError(null);
|
||||
try {
|
||||
await levelUp.mutateAsync({ subclass: plan!.subclass ? subclass : undefined, boon: plan!.boon ? boon : undefined, weaponId: weaponId || null, spellIds: [...spellIds] });
|
||||
const { hpRolled } = await levelUp.mutateAsync({
|
||||
hpRoll: needsHpRoll ? Number(hpRoll) : undefined,
|
||||
characterClass: plan!.characterClass,
|
||||
subclass: plan!.subclass ? subclass : undefined, boon: plan!.boon ? boon : undefined, weaponId: weaponId || null, spellIds: [...spellIds] });
|
||||
showToast({
|
||||
tone: 'good',
|
||||
text: `${character.name} is level ${plan!.toLevel}!${hpRolled != null ? ` The d${plan!.hp.die} for hit points came up ${hpRolled}.` : ''}`,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
@@ -78,15 +95,74 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
<Step n={1} title="Level">
|
||||
{plan.classChoice && (
|
||||
<div className="mb-2">
|
||||
<label className="flex-row flex-wrap gap-sm">
|
||||
Which class gains this level?
|
||||
<select
|
||||
value={plan.characterClass}
|
||||
onChange={(e) => {
|
||||
setToClass(e.target.value as CharacterClass);
|
||||
setSubclass('');
|
||||
setBoon('');
|
||||
setSpellIds(new Set());
|
||||
}}
|
||||
>
|
||||
{plan.classChoice.options.map((o) => (
|
||||
<option key={o.characterClass} value={o.characterClass} disabled={!o.eligible} title={o.problems.join(' ')}>
|
||||
{CLASS_LABELS[o.characterClass]}
|
||||
{o.level > 0 ? ` (level ${o.level} → ${o.level + 1})` : o.eligible ? ' (new class)' : ' (requirements not met)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{plan.joining && (
|
||||
<p className="combatant-sub">
|
||||
Multiclassing into {CLASS_LABELS[plan.characterClass]}: it starts at its own level 1, with what that class grants at level 1 (not its saves or
|
||||
skills) and what it teaches a newcomer.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p>
|
||||
Level <strong>{plan.fromLevel}</strong> → <strong>{plan.toLevel}</strong>. Proficiency bonus, saves, skills and spell DC follow by themselves.
|
||||
Level <strong>{plan.fromLevel}</strong> → <strong>{plan.toLevel}</strong>
|
||||
{(character.classes.length > 1 || plan.characterClass !== character.characterClass) && (
|
||||
<span className="combatant-sub">
|
||||
{' '}
|
||||
· {CLASS_LABELS[plan.characterClass]} {plan.classLevel - 1} → {plan.classLevel} (now {classLabel(character.characterClass, character.subclassName, character.classes)})
|
||||
</span>
|
||||
)}
|
||||
. Proficiency bonus, saves, skills and spell DC follow by themselves.
|
||||
</p>
|
||||
</Step>
|
||||
|
||||
<Step n={2} title="Hit points">
|
||||
<p>
|
||||
Max HP <strong>{plan.maxHp.from}</strong> → <strong>{plan.maxHp.to}</strong>, and healed to full. One more Hit Die.
|
||||
</p>
|
||||
{plan.hp.method === 'AVERAGE' ? (
|
||||
<p>
|
||||
Max HP <strong>{plan.maxHp.from}</strong> → <strong>{plan.maxHp.to}</strong>, and healed to full. One more Hit Die (d{plan.hp.die}).
|
||||
</p>
|
||||
) : needsHpRoll ? (
|
||||
<div>
|
||||
<p style={{ marginBottom: '0.3rem' }}>
|
||||
This campaign rolls for hit points. Roll a <strong>d{plan.hp.die}</strong> at the table and enter what it shows; your Constitution
|
||||
modifier is added for you. Healed to full, and one more Hit Die.
|
||||
</p>
|
||||
<label className="flex-row gap-sm">
|
||||
I rolled
|
||||
<NumberInput min={1} max={plan.hp.die} placeholder={`1–${plan.hp.die}`} value={hpRoll} onChange={(e) => setHpRoll(e.target.value)} style={{ width: 80 }} />
|
||||
{hpRollValid && (
|
||||
<span className="combatant-sub">
|
||||
Max HP {plan.maxHp.from} → {plan.maxHp.to - plan.hp.average + Number(hpRoll)}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<p>
|
||||
This campaign rolls for hit points: a <strong>d{plan.hp.die}</strong> is rolled when you confirm (between {plan.maxHp.to - plan.hp.average + 1} and{' '}
|
||||
{plan.maxHp.to - plan.hp.average + plan.hp.die} max HP), plus healing to full and one more Hit Die.
|
||||
</p>
|
||||
)}
|
||||
</Step>
|
||||
|
||||
<Step n={3} title="Special abilities">
|
||||
@@ -120,7 +196,7 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
|
||||
)}
|
||||
{plan.features.length === 0 && !plan.boon && !plan.subclass && (
|
||||
<p className="combatant-sub">
|
||||
{plan.beyondTables ? 'The class tables stop at level 10. Add anything new to the special abilities by hand.' : 'No new feature at this level.'}
|
||||
{plan.beyondTables ? 'The class tables stop at level 20. Add anything new to the special abilities by hand.' : 'No new feature at this level.'}
|
||||
</p>
|
||||
)}
|
||||
{plan.features.map((feature) => (
|
||||
@@ -150,7 +226,9 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
|
||||
</div>
|
||||
)}
|
||||
{(plan.features.length > 0 || plan.boon || plan.subclass) && (
|
||||
<p className="combatant-sub">Written into Special Abilities for you. Where a line says "choose", edit it there once the player has.</p>
|
||||
<p className="combatant-sub">
|
||||
Written into Special Abilities for you. {isDM ? 'Where a line says "choose", edit it there once the player has.' : 'Where a line says "choose", agree it with your DM.'}
|
||||
</p>
|
||||
)}
|
||||
</Step>
|
||||
|
||||
@@ -176,16 +254,22 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
|
||||
<div className="level-spells">
|
||||
{grantable.map((spell) => (
|
||||
<label key={spell.id} className="checkbox-row level-choice">
|
||||
<input type="checkbox" checked={spellIds.has(spell.id)} onChange={() => toggleSpell(spell.id)} />
|
||||
<span>
|
||||
{spell.name} <span className="combatant-sub">{spellLevelLabel(spell.level)}</span>
|
||||
<input type="checkbox" checked={spellIds.has(spell.id)} disabled={spellsFull && !spellIds.has(spell.id)} onChange={() => toggleSpell(spell.id)} />
|
||||
<span title={spell.description ?? undefined}>
|
||||
{spell.name}{' '}
|
||||
<span className="combatant-sub">
|
||||
{spellLevelLabel(spell.level)}
|
||||
{spell.school ? ` · ${spell.school}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="combatant-sub">
|
||||
{spellIds.size > 0 && `${spellIds.size} picked. `}A player may also swap one known spell for another: take the old one away on the sheet.
|
||||
{isDM
|
||||
? `${spellIds.size > 0 ? `${spellIds.size} picked. ` : ''}A player may also swap one known spell for another: take the old one away on the sheet.`
|
||||
: `${spellIds.size} of ${spellAllowance} picked. Hover a spell to read what it does.`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useMulticlassReference } from '../api/queries';
|
||||
import { CLASS_LABELS } from '../lib/format';
|
||||
import { Spinner } from './Spinner';
|
||||
import type { Ability, CharacterClass, ClassLevel, MulticlassReference } from '../api/types';
|
||||
|
||||
// Whether final scores meet a class's multiclassing requirement.
|
||||
function meets(reference: MulticlassReference, characterClass: CharacterClass, scores: Record<Ability, number>): boolean {
|
||||
return reference.prereqs[characterClass].alternatives.some((all) => all.every((a) => scores[a] >= reference.prereqScore));
|
||||
}
|
||||
|
||||
// Suggested multiclass routes from where a character stands: each route's
|
||||
// steps, how far along the character is, and whether their scores meet every
|
||||
// class the route takes them through (multiclassing needs the requirement of
|
||||
// the class joined and of each class left).
|
||||
export function MulticlassRoutes({ classes, scores }: { classes: ClassLevel[]; scores: Record<Ability, number> }) {
|
||||
const { data: reference, isLoading } = useMulticlassReference(true);
|
||||
if (isLoading || !reference) return <Spinner label="Reading the routes…" />;
|
||||
|
||||
const levelIn = (cls: CharacterClass) => classes.find((c) => c.characterClass === cls)?.level ?? 0;
|
||||
const have = new Set(classes.map((c) => c.characterClass));
|
||||
const paths = reference.paths.filter((p) => !p.from || have.has(p.from));
|
||||
const yours = classes.map((c) => c.characterClass);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
Multiclassing is on. A level can go to another class when {classes.length > 1 ? 'every class you have' : `your ${CLASS_LABELS[yours[0]]}`} and the new class’s
|
||||
requirements are met: {yours.map((c) => `${CLASS_LABELS[c]} needs ${reference.prereqs[c].text}`).join('; ')}.
|
||||
</p>
|
||||
<div className="choice-grid">
|
||||
{paths.map((path) => {
|
||||
const classesOnRoute = [...new Set(path.steps.map((s) => s.class))];
|
||||
const blocked = classesOnRoute.filter((cls) => !meets(reference, cls, scores));
|
||||
return (
|
||||
<div key={path.key} className="choice-card" style={{ cursor: 'default' }}>
|
||||
<span className="choice-card-title">
|
||||
{path.name}
|
||||
{blocked.length === 0 ? <span className="badge badge-friendly">Open to you</span> : <span className="badge badge-hostile">Needs scores</span>}
|
||||
</span>
|
||||
<span className="choice-card-blurb">{path.why}</span>
|
||||
<ol style={{ margin: 0, paddingLeft: '1.2rem' }}>
|
||||
{path.steps.map((step, i) => {
|
||||
const done = levelIn(step.class) >= step.toLevel;
|
||||
return (
|
||||
<li key={i} style={done ? { opacity: 0.6 } : undefined}>
|
||||
{done ? '✓ ' : ''}
|
||||
{CLASS_LABELS[step.class]} to level {step.toLevel}
|
||||
{step.subclass ? ` (${step.subclass.toLowerCase().replace(/_/g, ' ')})` : ''}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{blocked.length > 0 && (
|
||||
<span className="choice-card-facts">{blocked.map((cls) => `${CLASS_LABELS[cls]}: ${reference.prereqs[cls].text}`).join(' · ')}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { DiceRow } from './dice/Die';
|
||||
import type { DiceRollRecord } from '../api/types';
|
||||
|
||||
const MODE_NOTE = { NORMAL: '', ADVANTAGE: 'advantage', DISADVANTAGE: 'disadvantage' } as const;
|
||||
|
||||
function timeOf(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// One roll as a line: who, what for, the dice as they fell, and the total.
|
||||
export function RollLine({ roll, mine, rolling = false, size = 34 }: { roll: DiceRollRecord; mine: boolean; rolling?: boolean; size?: number }) {
|
||||
const crit = roll.natural === 20;
|
||||
const fumble = roll.natural === 1;
|
||||
return (
|
||||
<div className={`roll-line${crit ? ' is-crit' : fumble ? ' is-fumble' : ''}`}>
|
||||
<div className="roll-line-head">
|
||||
<span>
|
||||
<strong>{mine ? 'You' : roll.rollerName}</strong>
|
||||
{roll.label && <span> · {roll.label}</span>}
|
||||
</span>
|
||||
<span className="combatant-sub">{timeOf(roll.createdAt)}</span>
|
||||
</div>
|
||||
<div className="roll-line-body">
|
||||
<DiceRow dice={roll.dice} rolling={rolling} size={size} />
|
||||
<span className="roll-line-math mono">
|
||||
{roll.modifier !== 0 && `${roll.modifier > 0 ? '+' : '−'} ${Math.abs(roll.modifier)} `}=
|
||||
</span>
|
||||
<strong className="roll-line-total mono">{roll.total}</strong>
|
||||
</div>
|
||||
<div className="roll-line-tags">
|
||||
<span className="combatant-sub mono">{roll.notation}</span>
|
||||
{roll.mode !== 'NORMAL' && <span className="badge">{MODE_NOTE[roll.mode]}</span>}
|
||||
{crit && <span className="badge badge-friendly">Natural 20</span>}
|
||||
{fumble && <span className="badge badge-hostile">Natural 1</span>}
|
||||
{roll.physical && <span className="badge" title="Rolled with real dice and typed in">🎯 at the table</span>}
|
||||
{roll.hidden && <span className="badge badge-dm" title="Only you see this roll">🙈 hidden</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The table's rolls, newest first: every roll this member may see. For the
|
||||
// DM that is every roll anyone makes.
|
||||
export function RollFeed({ rolls, userId, empty }: { rolls: DiceRollRecord[]; userId: string | undefined; empty: string }) {
|
||||
if (rolls.length === 0) return <p className="subtitle">{empty}</p>;
|
||||
return (
|
||||
<div className="roll-feed">
|
||||
{rolls.map((roll) => (
|
||||
<RollLine key={roll.id} roll={roll} mine={roll.userId === userId} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { dismissToast, subscribeToasts, type Toast } from '../lib/toasts';
|
||||
|
||||
// The stack of toasts (lib/toasts.ts), mounted once in the layout.
|
||||
export function Toasts() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
useEffect(() => subscribeToasts(setToasts), []);
|
||||
if (toasts.length === 0) return null;
|
||||
return (
|
||||
<div className="toasts" role="status" aria-live="polite">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className={`toast${toast.tone ? ` toast-${toast.tone}` : ''}`}>
|
||||
<span className="toast-text">{toast.text}</span>
|
||||
{toast.action && (
|
||||
<Link to={toast.action.to} className="btn btn-primary btn-sm" onClick={() => dismissToast(toast.id)}>
|
||||
{toast.action.label}
|
||||
</Link>
|
||||
)}
|
||||
<button className="btn btn-ghost btn-sm" aria-label="Dismiss" onClick={() => dismissToast(toast.id)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { STAGGER_MS, TUMBLE_MS, prefersStill } from '../../lib/diceAnimation';
|
||||
import type { RolledDie } from '../../api/types';
|
||||
|
||||
// The outline of each die, seen face on, in a 100 × 100 box: the shape that
|
||||
// says which die it is, and the lines that give it facets.
|
||||
const SHAPES: Record<number, { outline: string; facets?: string[] }> = {
|
||||
4: { outline: '50,8 94,86 6,86', facets: ['50,8 50,86'] },
|
||||
6: { outline: '14,14 86,14 86,86 14,86' },
|
||||
8: { outline: '50,4 94,50 50,96 6,50', facets: ['6,50 94,50'] },
|
||||
10: { outline: '50,4 92,40 50,96 8,40', facets: ['8,40 50,58 92,40', '50,58 50,96'] },
|
||||
12: { outline: '50,4 93,35 77,90 23,90 7,35', facets: ['50,4 50,28', '93,35 72,42', '77,90 64,70', '23,90 36,70', '7,35 28,42', '50,28 72,42 64,70 36,70 28,42 50,28'] },
|
||||
20: { outline: '50,3 91,26 91,74 50,97 9,74 9,26', facets: ['50,22 80,70 20,70 50,22', '50,3 50,22', '91,74 80,70', '9,74 20,70', '9,26 50,22 91,26', '20,70 50,97 80,70'] },
|
||||
};
|
||||
|
||||
// Where the number sits: lower in a triangle, whose middle is below its centre.
|
||||
const TEXT_Y: Record<number, number> = { 4: 66, 10: 46, 20: 58 };
|
||||
|
||||
const randomFace = (sides: number) => Math.floor(Math.random() * sides) + 1;
|
||||
|
||||
// One die, drawn as its own shape with the number it shows. With `rolling` it
|
||||
// tumbles in, flickering through faces, and lands on its value; `order` is
|
||||
// its place among the dice rolled with it. A die that was dropped (the lower
|
||||
// d20 with advantage) is faded; a natural 20 or 1 on a d20 is marked.
|
||||
export function Die({ die, rolling = false, order = 0, size = 56 }: { die: RolledDie; rolling?: boolean; order?: number; size?: number }) {
|
||||
const animate = rolling && !prefersStill();
|
||||
const [landed, setLanded] = useState(!animate);
|
||||
const [face, setFace] = useState(animate ? randomFace(die.sides) : die.value);
|
||||
|
||||
useEffect(() => {
|
||||
if (!animate) return;
|
||||
const flicker = setInterval(() => setFace(randomFace(die.sides)), 70);
|
||||
const land = setTimeout(
|
||||
() => {
|
||||
clearInterval(flicker);
|
||||
setFace(die.value);
|
||||
setLanded(true);
|
||||
},
|
||||
TUMBLE_MS + order * STAGGER_MS
|
||||
);
|
||||
return () => {
|
||||
clearInterval(flicker);
|
||||
clearTimeout(land);
|
||||
};
|
||||
}, [animate, die.sides, die.value, order]);
|
||||
|
||||
const shape = SHAPES[die.sides];
|
||||
const shown = landed ? die.value : face;
|
||||
const crit = landed && die.sides === 20 && die.kept && die.value === 20;
|
||||
const fumble = landed && die.sides === 20 && die.kept && die.value === 1;
|
||||
const classes = ['die', `die-d${shape ? die.sides : 'x'}`, landed ? 'is-landed' : 'is-rolling', die.kept ? '' : 'is-dropped', crit ? 'is-crit' : '', fumble ? 'is-fumble' : '', die.sign < 0 ? 'is-minus' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classes}
|
||||
style={{ width: size, height: size, animationDelay: animate ? `${order * STAGGER_MS}ms` : undefined, animationDuration: animate ? `${TUMBLE_MS}ms` : undefined }}
|
||||
title={`d${die.sides}: ${die.value}${die.kept ? '' : ' (dropped)'}${die.maxed ? ' (at its most, for the critical hit)' : ''}`}
|
||||
aria-label={`d${die.sides} showing ${landed ? die.value : 'rolling'}`}
|
||||
>
|
||||
<svg viewBox="0 0 100 100" width={size} height={size} aria-hidden="true">
|
||||
{shape ? (
|
||||
<>
|
||||
<polygon className="die-body" points={shape.outline} strokeLinejoin="round" />
|
||||
{shape.facets?.map((points) => (
|
||||
<polyline key={points} className="die-facet" points={points} fill="none" />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
// Any other die (a d100, a d3...): a plain round one.
|
||||
<circle className="die-body" cx="50" cy="50" r="44" />
|
||||
)}
|
||||
<text className="die-face" x="50" y={TEXT_Y[die.sides] ?? 52} textAnchor="middle" dominantBaseline="middle" fontSize={shown > 99 ? 26 : shown > 9 ? 34 : 40}>
|
||||
{shown}
|
||||
</text>
|
||||
</svg>
|
||||
<span className="die-kind">d{die.sides}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// A roll's dice in a row, landing one after another.
|
||||
export function DiceRow({ dice, rolling = false, size = 56 }: { dice: RolledDie[]; rolling?: boolean; size?: number }) {
|
||||
return (
|
||||
<span className="dice-row">
|
||||
{dice.map((die, i) => (
|
||||
<Die key={i} die={die} rolling={rolling} order={i} size={size} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { useQueryClient, type QueryKey } from '@tanstack/react-query';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { API_URL } from '../api/client';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { showToast } from '../lib/toasts';
|
||||
import type { CharacterDetail } from '../api/types';
|
||||
|
||||
let sharedSocket: Socket | null = null;
|
||||
|
||||
@@ -24,8 +26,9 @@ const FLUSH_MS = 50;
|
||||
// sync with server-pushed events (HP/condition changes, encounter rounds,
|
||||
// new characters) so every open tab in the campaign updates live.
|
||||
export function useCampaignSocket(campaignId: string | undefined) {
|
||||
const { token } = useAuth();
|
||||
const { token, user } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
const userId = user?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (!campaignId || !token) return;
|
||||
@@ -56,6 +59,17 @@ export function useCampaignSocket(campaignId: string | undefined) {
|
||||
// A character arriving somewhere new adds it to the places their player can see.
|
||||
invalidate(['campaign', campaignId, 'locations']);
|
||||
};
|
||||
// The DM approved a level for a character: its player is told, wherever
|
||||
// in the campaign they are, with a way straight to the checklist.
|
||||
const onLevelUpGranted = ({ characterId, userId: ownerId }: { characterId?: string; userId?: string | null } = {}) => {
|
||||
if (!characterId || !ownerId || ownerId !== userId) return;
|
||||
const name = qc.getQueryData<CharacterDetail>(['character', characterId])?.name ?? 'Your character';
|
||||
showToast({ text: `⬆ ${name} can level up! See what you gain and make your picks.`, tone: 'good', action: { label: 'Level up', to: `/characters/${characterId}?levelup=1` } });
|
||||
};
|
||||
// Somebody rolled: the feed asks again, and gets the roll if it is this member's to see.
|
||||
const onRollCreated = () => {
|
||||
invalidate(['campaign', campaignId, 'rolls']);
|
||||
};
|
||||
const onCharacterCreated = () => {
|
||||
invalidate(['campaign', campaignId, 'characters']);
|
||||
};
|
||||
@@ -74,6 +88,8 @@ export function useCampaignSocket(campaignId: string | undefined) {
|
||||
|
||||
socket.on('character:updated', onCharacterUpdated);
|
||||
socket.on('character:created', onCharacterCreated);
|
||||
socket.on('character:level-up-granted', onLevelUpGranted);
|
||||
socket.on('roll:created', onRollCreated);
|
||||
socket.on('encounter:participant-updated', onParticipantUpdated);
|
||||
socket.on('encounter:round-advanced', onRoundAdvanced);
|
||||
socket.on('campaign:deleted', onCampaignDeleted);
|
||||
@@ -86,9 +102,11 @@ export function useCampaignSocket(campaignId: string | undefined) {
|
||||
socket.emit('campaign:leave', campaignId);
|
||||
socket.off('character:updated', onCharacterUpdated);
|
||||
socket.off('character:created', onCharacterCreated);
|
||||
socket.off('character:level-up-granted', onLevelUpGranted);
|
||||
socket.off('roll:created', onRollCreated);
|
||||
socket.off('encounter:participant-updated', onParticipantUpdated);
|
||||
socket.off('encounter:round-advanced', onRoundAdvanced);
|
||||
socket.off('campaign:deleted', onCampaignDeleted);
|
||||
};
|
||||
}, [campaignId, token, qc]);
|
||||
}, [campaignId, token, qc, userId]);
|
||||
}
|
||||
|
||||
@@ -403,6 +403,65 @@ input[type='radio'] {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Toasts: notices that slide in over any page (components/Toasts.tsx). */
|
||||
.toasts {
|
||||
position: fixed;
|
||||
top: 4.2rem;
|
||||
right: 1rem;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-width: min(26rem, calc(100vw - 2rem));
|
||||
}
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
|
||||
animation: toastIn 0.25s ease-out;
|
||||
}
|
||||
.toast-good {
|
||||
border-color: var(--accent-strong);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45), 0 0 14px rgba(201, 162, 39, 0.3);
|
||||
}
|
||||
.toast-text {
|
||||
flex: 1;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
@keyframes toastIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(1.5rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* A notice across the top of a card or page that something is waiting. */
|
||||
.notice-banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
margin-bottom: 0.9rem;
|
||||
border: 1px solid var(--accent-strong);
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(160deg, var(--bg-card-hover) 0%, var(--bg-inset) 100%);
|
||||
}
|
||||
|
||||
/* A group of campaign settings under its own heading. */
|
||||
.settings-group {
|
||||
border: 1px solid var(--border);
|
||||
@@ -553,6 +612,269 @@ input[type='radio'] {
|
||||
animation: rollPop 0.4s ease-out;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Dice (components/dice/Die.tsx): each die drawn as its own shape. It */
|
||||
/* tumbles in, flickering through faces, and lands on what it rolled. */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
.dice-row {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
.die {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.55));
|
||||
}
|
||||
.die-body {
|
||||
fill: #2b2212;
|
||||
stroke: var(--accent);
|
||||
stroke-width: 4;
|
||||
}
|
||||
.die-facet {
|
||||
stroke: var(--accent);
|
||||
stroke-width: 1.5;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.die-face {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
fill: var(--accent-strong);
|
||||
paint-order: stroke;
|
||||
stroke: #1a140a;
|
||||
stroke-width: 5;
|
||||
}
|
||||
.die-kind {
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
line-height: 1;
|
||||
}
|
||||
/* Each kind of die has its own tint, as a set of real dice might. */
|
||||
.die-d4 .die-body { fill: #2a1d1d; }
|
||||
.die-d6 .die-body { fill: #2b2212; }
|
||||
.die-d8 .die-body { fill: #1c2a22; }
|
||||
.die-d10 .die-body { fill: #1b2431; }
|
||||
.die-d12 .die-body { fill: #2a1f31; }
|
||||
.die-d20 .die-body { fill: #33260f; }
|
||||
|
||||
.die.is-rolling {
|
||||
animation-name: dieTumble;
|
||||
animation-timing-function: cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
.die.is-rolling .die-face {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.die.is-landed {
|
||||
animation: dieLand 0.28s ease-out;
|
||||
}
|
||||
.die.is-dropped {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.die.is-dropped .die-face {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.die.is-minus .die-body {
|
||||
stroke: var(--danger-strong);
|
||||
}
|
||||
.die.is-crit .die-body {
|
||||
stroke: var(--success-strong);
|
||||
fill: #20331a;
|
||||
}
|
||||
.die.is-crit {
|
||||
filter: drop-shadow(0 0 10px rgba(122, 178, 102, 0.9));
|
||||
animation: dieLand 0.28s ease-out, dieGlow 1.2s ease-in-out 0.28s 2;
|
||||
}
|
||||
.die.is-crit .die-face {
|
||||
fill: #c9f0b8;
|
||||
}
|
||||
.die.is-fumble .die-body {
|
||||
stroke: var(--danger-strong);
|
||||
fill: #331616;
|
||||
}
|
||||
.die.is-fumble {
|
||||
filter: drop-shadow(0 0 8px rgba(216, 84, 84, 0.8));
|
||||
animation: dieShake 0.45s ease-in-out;
|
||||
}
|
||||
.die.is-fumble .die-face {
|
||||
fill: #f1b0b0;
|
||||
}
|
||||
@keyframes dieTumble {
|
||||
0% {
|
||||
transform: translate(-70px, -90px) rotate(-540deg) scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
25% {
|
||||
opacity: 1;
|
||||
}
|
||||
55% {
|
||||
transform: translate(10px, 6px) rotate(-90deg) scale(1.08);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-4px, -10px) rotate(20deg) scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, 0) rotate(0deg) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes dieLand {
|
||||
0% {
|
||||
transform: scale(1.18);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes dieGlow {
|
||||
50% {
|
||||
filter: drop-shadow(0 0 18px rgba(122, 178, 102, 1));
|
||||
}
|
||||
}
|
||||
@keyframes dieShake {
|
||||
20% {
|
||||
transform: translateX(-4px) rotate(-6deg);
|
||||
}
|
||||
40% {
|
||||
transform: translateX(4px) rotate(5deg);
|
||||
}
|
||||
60% {
|
||||
transform: translateX(-3px) rotate(-3deg);
|
||||
}
|
||||
80% {
|
||||
transform: translateX(2px) rotate(2deg);
|
||||
}
|
||||
}
|
||||
/* The total, and what goes with it, appear once the dice have landed. */
|
||||
.roll-reveal {
|
||||
animation: rollReveal 0.35s ease-out both;
|
||||
}
|
||||
@keyframes rollReveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.6);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.dice-stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
width: 100%;
|
||||
}
|
||||
.dice-tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.dice-tabs .die-btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
}
|
||||
.dice-entry {
|
||||
padding: 0.6rem;
|
||||
border: 1px dashed var(--accent);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
.dice-entry-die {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.dice-error {
|
||||
color: var(--danger-strong);
|
||||
font-size: 0.9rem;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
/* A roll as a line of the table's feed (components/RollFeed.tsx). */
|
||||
.roll-feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.roll-line {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-inset);
|
||||
text-align: left;
|
||||
}
|
||||
.roll-line.is-crit {
|
||||
border-left-color: var(--success-strong);
|
||||
}
|
||||
.roll-line.is-fumble {
|
||||
border-left-color: var(--danger-strong);
|
||||
}
|
||||
.roll-line-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.roll-line-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.roll-line-body .dice-row {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.roll-line-total {
|
||||
font-size: 1.5rem;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.roll-line-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.roll-line-tags .badge {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
/* Somebody else's roll, shown beside the dice button as it lands. */
|
||||
.dice-incoming {
|
||||
position: fixed;
|
||||
bottom: 1.25rem;
|
||||
left: calc(1.25rem + 60px + 0.6rem);
|
||||
z-index: 84;
|
||||
width: min(300px, calc(100vw - 6.5rem));
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
animation: toastIn 0.25s ease-out;
|
||||
}
|
||||
.dice-incoming .roll-line {
|
||||
border-color: var(--accent);
|
||||
background: linear-gradient(180deg, #1c160d 0%, #120e08 100%);
|
||||
box-shadow: var(--shadow-deep);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.die.is-rolling,
|
||||
.die.is-landed,
|
||||
.die.is-crit,
|
||||
.die.is-fumble,
|
||||
.roll-reveal,
|
||||
.dice-incoming {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Quick stats drawer */
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// How the dice move (components/dice/Die.tsx), for whatever needs to wait for
|
||||
// them: the total appears once the last die has landed.
|
||||
|
||||
// How long a die tumbles before it lands, and how much later each die after
|
||||
// the first lands, so a handful settle one after another.
|
||||
export const TUMBLE_MS = 850;
|
||||
export const STAGGER_MS = 110;
|
||||
|
||||
// Whether the visitor has asked their device for less motion.
|
||||
export const prefersStill = () => typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches);
|
||||
|
||||
// How long a roll of this many dice takes to settle.
|
||||
export const settleMs = (count: number) => (prefersStill() ? 0 : TUMBLE_MS + Math.max(0, count - 1) * STAGGER_MS);
|
||||
@@ -97,9 +97,13 @@ export function xpLabel(xp: number, progress: { nextLevelAt: number | null }): s
|
||||
return progress.nextLevelAt == null ? `${xp.toLocaleString()} XP` : `${xp.toLocaleString()} / ${progress.nextLevelAt.toLocaleString()} XP`;
|
||||
}
|
||||
|
||||
// "Fighter" or, with a subclass chosen, "Fighter (Champion)".
|
||||
export function classLabel(characterClass: string | null, subclassName?: string | null): string {
|
||||
// "Fighter" or, with a subclass chosen, "Fighter (Champion)". A multiclassed
|
||||
// character (`classes` with more than one) reads "Fighter 3 (Champion) / Rogue 2".
|
||||
export function classLabel(characterClass: string | null, subclassName?: string | null, classes?: { characterClass: string; level: number; subclassName: string | null }[]): string {
|
||||
if (!characterClass) return '';
|
||||
if (classes && classes.length > 1) {
|
||||
return classes.map((c) => `${CLASS_LABELS[c.characterClass] ?? c.characterClass} ${c.level}${c.subclassName ? ` (${c.subclassName})` : ''}`).join(' / ');
|
||||
}
|
||||
const label = CLASS_LABELS[characterClass] ?? characterClass;
|
||||
return subclassName ? `${label} (${subclassName})` : label;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Toasts: short notices that slide in over any page ("Aria can level up").
|
||||
// A tiny store of its own, so anything can raise one (a socket event, a
|
||||
// mutation) without being inside a particular component. <Toasts />
|
||||
// (components/Toasts.tsx), mounted once in the layout, shows them.
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
text: string;
|
||||
// Where "tone" is 'good' the toast is a piece of good news; default is neutral.
|
||||
tone?: 'good' | 'warn';
|
||||
// A link to follow up on it: its label and where it goes.
|
||||
action?: { label: string; to: string };
|
||||
}
|
||||
|
||||
type Listener = (toasts: Toast[]) => void;
|
||||
|
||||
let toasts: Toast[] = [];
|
||||
let nextId = 1;
|
||||
const listeners = new Set<Listener>();
|
||||
const publish = () => listeners.forEach((listener) => listener(toasts));
|
||||
|
||||
// How long a toast stays before it goes by itself.
|
||||
const TOAST_MS = 12000;
|
||||
|
||||
export function showToast(toast: Omit<Toast, 'id'>): number {
|
||||
const id = nextId++;
|
||||
toasts = [...toasts, { ...toast, id }].slice(-5);
|
||||
publish();
|
||||
setTimeout(() => dismissToast(id), TOAST_MS);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function dismissToast(id: number) {
|
||||
if (!toasts.some((toast) => toast.id === id)) return;
|
||||
toasts = toasts.filter((toast) => toast.id !== id);
|
||||
publish();
|
||||
}
|
||||
|
||||
export function subscribeToasts(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
listener(toasts);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
@@ -32,13 +32,14 @@ import { NumberInput } from '../components/NumberInput';
|
||||
import { SectionNav, type Section } from '../components/SectionNav';
|
||||
import { LifeBadge } from '../components/DeathSaves';
|
||||
import { RestModal } from '../components/RestModal';
|
||||
import { LevelUpBulkModal } from '../components/LevelUpBulkModal';
|
||||
import { Pagination } from '../components/Paginated';
|
||||
import { usePagination } from '../hooks/usePagination';
|
||||
import { CampaignSearch, type SearchGroup } from '../components/campaign/CampaignSearch';
|
||||
import { EntryDetail } from '../components/campaign/EntryDetail';
|
||||
import { ItemsSection, MonstersSection, NpcsSection, SpellsSection, WeaponsSection } from '../components/campaign/PoolSections';
|
||||
import { effectsSummary } from '../lib/gear';
|
||||
import { extractErrorMessage, spellLevelLabel } from '../lib/format';
|
||||
import { SPECIES_LABELS, classLabel, extractErrorMessage, spellLevelLabel } from '../lib/format';
|
||||
import { matchesSearch, type EntryKind, type EntryRef } from '../lib/campaignEntries';
|
||||
import { speciesIcon } from '../lib/speciesIcons';
|
||||
import type { CampaignDiceMode, CharacterSummary, CritRule, Encounter, EncounterMode, HpMethod, RollVisibility, ScoreMethod } from '../api/types';
|
||||
@@ -86,7 +87,8 @@ function sortEncounters(encounters: Encounter[], sort: EncounterSort): Encounter
|
||||
return [...encounters].sort(compare[sort]);
|
||||
}
|
||||
|
||||
function CharacterRow({ character, isDM, onDelete }: { character: CharacterSummary; isDM: boolean; onDelete: () => void }) {
|
||||
// `mine`: the viewer's own character, who gets the way into a level up that is waiting.
|
||||
function CharacterRow({ character, isDM, mine = false, onDelete }: { character: CharacterSummary; isDM: boolean; mine?: boolean; onDelete: () => void }) {
|
||||
const inProgress = character.buildStage !== 'COMPLETE';
|
||||
return (
|
||||
<div className="card flex-between flex-wrap">
|
||||
@@ -97,10 +99,15 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
|
||||
{character.name}
|
||||
</Link>
|
||||
{inProgress && <span className="badge">Building…</span>}
|
||||
{character.levelUpsPending > 0 && (isDM || mine) && (
|
||||
<span className="badge badge-live" title={mine ? 'Your DM has approved a level: open the sheet to choose what you gain' : 'Approved, and waiting for the picks to be made'}>
|
||||
⬆ Level up {mine ? 'ready' : 'waiting on picks'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.9rem' }}>
|
||||
{character.species ? `${character.species} ` : ''}
|
||||
{character.characterClass || (inProgress ? 'Not yet built' : '')}{character.subclassName && ` (${character.subclassName})`} · Level {character.level}
|
||||
{character.species ? `${SPECIES_LABELS[character.species] ?? character.species} ` : ''}
|
||||
{character.characterClass ? classLabel(character.characterClass, character.subclassName, character.classes) : inProgress ? 'Not yet built' : ''} · Level {character.level}
|
||||
{character.currentLocationName && ` · 📍 ${character.currentLocationName}`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -110,6 +117,11 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
|
||||
<LifeBadge deathSaves={character.deathSaves} /> HP {character.currentHp}/{character.maxHp} · AC {character.armorClass}
|
||||
</div>
|
||||
)}
|
||||
{mine && character.levelUpsPending > 0 && !inProgress && (
|
||||
<Link to={`/characters/${character.id}?levelup=1`} className="btn btn-primary">
|
||||
⬆ Level up
|
||||
</Link>
|
||||
)}
|
||||
<Link to={inProgress ? `/characters/${character.id}/build` : `/characters/${character.id}`} className="btn">
|
||||
{inProgress ? 'Continue Build' : 'Open Sheet'}
|
||||
</Link>
|
||||
@@ -180,6 +192,7 @@ export function CampaignHomePage() {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [resting, setResting] = useState(false);
|
||||
const [levelling, setLevelling] = useState(false);
|
||||
const [deleteConfirmName, setDeleteConfirmName] = useState('');
|
||||
|
||||
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
|
||||
@@ -440,7 +453,7 @@ export function CampaignHomePage() {
|
||||
<div className="section-block">
|
||||
<h2 style={{ fontSize: '1.1rem' }}>Your Character{myCharacters.length > 1 ? 's' : ''}</h2>
|
||||
{myCharacters.map((c) => (
|
||||
<CharacterRow key={c.id} character={c} isDM={false} onDelete={() => {}} />
|
||||
<CharacterRow key={c.id} character={c} isDM={false} mine onDelete={() => {}} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -450,9 +463,23 @@ export function CampaignHomePage() {
|
||||
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
|
||||
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>{isDM ? 'Characters' : 'Rest of the Party'}</h2>
|
||||
{isDM && characters?.some((c) => c.buildStage === 'COMPLETE') && (
|
||||
<button className="btn" onClick={() => setResting(true)} title="A short or long rest for any of the party: Hit Dice, HP and spell slots">
|
||||
🛏 Rest…
|
||||
</button>
|
||||
<div className="flex-row flex-wrap">
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => setLevelling(true)}
|
||||
title="Approve a level for any of the party at once: each player is told, and picks what they gain themselves"
|
||||
>
|
||||
⬆ Level up…
|
||||
{campaign.trackXp && characters.some((c) => c.xpProgress.levelUpDue) && (
|
||||
<span className="badge badge-live" style={{ marginLeft: 6 }}>
|
||||
{characters.filter((c) => c.xpProgress.levelUpDue).length} due
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button className="btn" onClick={() => setResting(true)} title="A short or long rest for any of the party: Hit Dice, HP and spell slots">
|
||||
🛏 Rest…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<form onSubmit={handleCreateCharacter} className="flex-row flex-wrap mt-sm" style={{ marginBottom: '0.75rem' }}>
|
||||
@@ -1010,6 +1037,9 @@ export function CampaignHomePage() {
|
||||
)}
|
||||
|
||||
{isDM && resting && <RestModal campaignId={campaignId} characters={characters ?? []} onClose={() => setResting(false)} />}
|
||||
{isDM && levelling && (
|
||||
<LevelUpBulkModal campaignId={campaignId} characters={characters ?? []} trackXp={campaign.trackXp} onClose={() => setLevelling(false)} />
|
||||
)}
|
||||
|
||||
{isDM && opened && (
|
||||
<EntryDetail
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
useCharacter,
|
||||
usePatchCharacter,
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
useCampaigns,
|
||||
useCopyCharacter,
|
||||
useCampaignLocations,
|
||||
useApproveLevelUps,
|
||||
} from '../api/queries';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
@@ -42,6 +43,7 @@ import { Rollable } from '../components/Rollable';
|
||||
import { withBonus } from '../lib/diceRoller';
|
||||
import { LevelUpModal } from '../components/LevelUpModal';
|
||||
import { ProgressionTimeline } from '../components/ProgressionTimeline';
|
||||
import { MulticlassRoutes } from '../components/MulticlassRoutes';
|
||||
import { GearInForce } from '../components/CharacterPanel';
|
||||
import { effectsSummary } from '../lib/gear';
|
||||
import { InfoTip } from '../components/InfoTip';
|
||||
@@ -65,6 +67,7 @@ import {
|
||||
formatPurse,
|
||||
purseWorthInGold,
|
||||
spellLevelLabel,
|
||||
slotLevelLabel,
|
||||
} from '../lib/format';
|
||||
import { speciesIcon } from '../lib/speciesIcons';
|
||||
import { hpMax as previewHpMax, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
@@ -249,6 +252,7 @@ export function CharacterSheetPage() {
|
||||
const { data: character, isLoading, error } = useCharacter(characterId);
|
||||
useRememberCampaign(character?.campaignId);
|
||||
const patchCharacter = usePatchCharacter(characterId);
|
||||
const approveLevelUp = useApproveLevelUps(character?.campaignId ?? '');
|
||||
useCampaignSocket(character?.campaignId);
|
||||
|
||||
const { data: campaignData } = useCampaign(character?.campaignId);
|
||||
@@ -290,7 +294,9 @@ export function CharacterSheetPage() {
|
||||
const [pickedSpellId, setPickedSpellId] = useState('');
|
||||
const [copyTargetId, setCopyTargetId] = useState('');
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [levellingUp, setLevellingUp] = useState(false);
|
||||
// ?levelup=1 (the link in the "can level up" notice) opens the checklist straight away.
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [levellingUp, setLevellingUp] = useState(searchParams.get('levelup') === '1');
|
||||
|
||||
const weaponsPage = usePagination(character?.weapons, 8);
|
||||
const spellsPage = usePagination(character?.spells, 8);
|
||||
@@ -426,13 +432,29 @@ export function CharacterSheetPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{character.levelUpsPending > 0 && canEdit && (
|
||||
<div className="notice-banner" role="status">
|
||||
<span style={{ flex: 1 }}>
|
||||
<strong>⬆ {isOwner ? 'You can level up!' : `${character.name} has a level up waiting.`}</strong>{' '}
|
||||
{isOwner
|
||||
? `Level ${character.level} → ${character.level + 1}: see what you gain and make your picks.`
|
||||
: character.userId
|
||||
? 'Their player has been told; you can also finish it for them.'
|
||||
: 'No player: it is yours to finish.'}
|
||||
{character.levelUpsPending > 1 && ` (${character.levelUpsPending} levels approved.)`}
|
||||
</span>
|
||||
<button className="btn btn-primary" onClick={() => setLevellingUp(true)}>
|
||||
{isOwner ? 'Choose what you gain' : 'Open the checklist'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>
|
||||
<span aria-hidden="true">{speciesIcon(character.species)}</span> {character.name}
|
||||
</h1>
|
||||
<p className="subtitle">
|
||||
{character.species && SPECIES_LABELS[character.species]} {classLabel(character.characterClass, character.subclassName)} · Level{' '}
|
||||
{character.species && SPECIES_LABELS[character.species]} {classLabel(character.characterClass, character.subclassName, character.classes)} · Level{' '}
|
||||
{character.level}
|
||||
{trackXp && ` · ${xpLabel(character.xp, character.xpProgress)}`}
|
||||
{trackXp && character.xpProgress.levelUpDue && (
|
||||
@@ -604,13 +626,23 @@ export function CharacterSheetPage() {
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{character.userId && character.userId !== user?.id && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => approveLevelUp.mutate({ characterIds: [character.id] }, { onError: (err) => setFormError(extractErrorMessage(err)) })}
|
||||
disabled={character.level + character.levelUpsPending >= 20 || approveLevelUp.isPending}
|
||||
title="Approves one level: their player is told, sees what they gain and makes the picks themselves"
|
||||
>
|
||||
⬆ Approve a level up{character.levelUpsPending > 0 ? ` (${character.levelUpsPending} waiting)` : ''}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
className="btn"
|
||||
onClick={() => setLevellingUp(true)}
|
||||
disabled={character.level >= 20}
|
||||
title="Go up one level with the levelling-up checklist: HP, new special abilities, spells and the free weapon upgrade"
|
||||
title="Go up one level yourself with the levelling-up checklist: HP, new special abilities, spells and the free weapon upgrade"
|
||||
>
|
||||
⬆ Level Up…
|
||||
⬆ Level up now…
|
||||
</button>
|
||||
<label className="flex-row gap-sm" title="Hit Dice this character still has to spend on a short rest. The Rest button on the campaign page keeps count for you.">
|
||||
Hit Dice left
|
||||
@@ -746,6 +778,20 @@ export function CharacterSheetPage() {
|
||||
</Rollable>{' '}
|
||||
<InfoTip term="SPELL_ATTACK_BONUS" />
|
||||
</p>
|
||||
{sheet.spellcastingByClass.length > 1 && (
|
||||
<p className="combatant-sub" style={{ margin: '0.2rem 0 0' }}>
|
||||
By class:{' '}
|
||||
{sheet.spellcastingByClass
|
||||
.map((c) => `${CLASS_LABELS[c.characterClass]} (${ABILITY_LABELS[c.ability]}) DC ${c.saveDC}, ${fmtMod(c.attackBonus)}`)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
{sheet.pactSlots && (
|
||||
<p className="combatant-sub" style={{ margin: '0.2rem 0 0' }}>
|
||||
Pact Magic: {sheet.pactSlots.count} slot{sheet.pactSlots.count === 1 ? '' : 's'} of {slotLevelLabel(sheet.pactSlots.slotLevel)} level, back on a short rest.
|
||||
Keep count of these at the table; the tracker below is the other classes’ slots.
|
||||
</p>
|
||||
)}
|
||||
{sheet.spellSlots ? (
|
||||
<SpellSlotTracker
|
||||
characterId={characterId}
|
||||
@@ -1138,12 +1184,38 @@ export function CharacterSheetPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{character.classes.length > 1 && (
|
||||
<p className="section-label" style={{ margin: '0.5rem 0 0.3rem' }}>
|
||||
{CLASS_LABELS[character.characterClass]} {character.classes[0].level}
|
||||
{character.subclassName ? ` (${character.subclassName})` : ''}
|
||||
</p>
|
||||
)}
|
||||
<ProgressionTimeline
|
||||
characterClass={character.characterClass}
|
||||
subclass={character.subclass}
|
||||
subclassName={character.subclassName}
|
||||
currentLevel={character.level}
|
||||
currentLevel={character.classes[0]?.level ?? character.level}
|
||||
/>
|
||||
{character.classes.slice(1).map((c) => (
|
||||
<div key={c.characterClass} className="mt-md">
|
||||
<p className="section-label" style={{ margin: '0 0 0.3rem' }}>
|
||||
{CLASS_LABELS[c.characterClass]} {c.level}
|
||||
{c.subclassName ? ` (${c.subclassName})` : ''}
|
||||
</p>
|
||||
<ProgressionTimeline characterClass={c.characterClass} subclass={c.subclass} subclassName={c.subclassName} currentLevel={c.level} />
|
||||
</div>
|
||||
))}
|
||||
{campaignData?.campaign.multiclassEnabled && (
|
||||
<details className="mt-md">
|
||||
<summary className="section-label" style={{ cursor: 'pointer' }}>
|
||||
Multiclass routes from here
|
||||
</summary>
|
||||
<MulticlassRoutes
|
||||
classes={character.classes}
|
||||
scores={Object.fromEntries(Object.entries(sheet.abilities).map(([a, d]) => [a, d.final])) as Record<Ability, number>}
|
||||
/>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1246,7 +1318,16 @@ export function CharacterSheetPage() {
|
||||
<Pagination page={notesPage.page} pageCount={notesPage.pageCount} onPrev={notesPage.prevPage} onNext={notesPage.nextPage} onJump={notesPage.setPage} />
|
||||
</div>
|
||||
|
||||
{isDM && levellingUp && <LevelUpModal character={character} campaignSpells={campaignSpells ?? []} onClose={() => setLevellingUp(false)} />}
|
||||
{(isDM || (isOwner && character.levelUpsPending > 0)) && levellingUp && (
|
||||
<LevelUpModal
|
||||
character={character}
|
||||
isDM={isDM}
|
||||
onClose={() => {
|
||||
setLevellingUp(false);
|
||||
if (searchParams.has('levelup')) setSearchParams({}, { replace: true });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,10 +24,12 @@ import {
|
||||
useAddReward,
|
||||
useDeleteReward,
|
||||
useAwardReward,
|
||||
useRolls,
|
||||
type NewReward,
|
||||
} from '../api/queries';
|
||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { RollFeed } from '../components/RollFeed';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
@@ -1182,6 +1184,8 @@ export function EncounterPage() {
|
||||
useRememberCampaign(encounter?.campaignId);
|
||||
|
||||
const { data: campaignData } = useCampaign(encounter?.campaignId);
|
||||
// The table's rolls, for the DM to watch beside the fight.
|
||||
const { data: rolls } = useRolls(encounter?.campaignId);
|
||||
const isDM = campaignData?.campaign.role === 'DM';
|
||||
const { data: characters } = useCampaignCharacters(encounter?.campaignId);
|
||||
const { data: monsters } = useCampaignMonsters(encounter?.campaignId, isDM);
|
||||
@@ -1654,6 +1658,17 @@ export function EncounterPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDM && (
|
||||
<details className="card" open={(rolls ?? []).length > 0}>
|
||||
<summary className="card-title" style={{ cursor: 'pointer', marginBottom: 0 }}>
|
||||
🎲 Rolls at the table
|
||||
</summary>
|
||||
<div className="mt-sm">
|
||||
<RollFeed rolls={(rolls ?? []).slice(0, 8)} userId={user?.id} empty="Nobody has rolled yet. Every roll made in the dice roller shows here." />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<TradePanel
|
||||
encounter={encounter}
|
||||
isDM={isDM}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { usePagination } from '../hooks/usePagination';
|
||||
import { InfoTip } from '../components/InfoTip';
|
||||
import { SPECIES_LABELS, classLabel, formatPurse, slotsSummary, xpLabel } from '../lib/format';
|
||||
import { RestModal } from '../components/RestModal';
|
||||
import { LevelUpBulkModal } from '../components/LevelUpBulkModal';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
import { LifeBadge } from '../components/DeathSaves';
|
||||
import { ConditionChip, StateBadges } from '../components/CharacterState';
|
||||
@@ -29,6 +30,7 @@ export function PartyOverviewPage() {
|
||||
const { data: characters, isLoading: charsLoading, error: charsError } = useCampaignCharacters(campaignId);
|
||||
const { pageItems, page, pageCount, prevPage, nextPage, setPage } = usePagination(characters, 12);
|
||||
const [resting, setResting] = useState(false);
|
||||
const [levelling, setLevelling] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
if (campaignLoading || charsLoading) return <Spinner label="Gathering the party…" />;
|
||||
@@ -51,6 +53,11 @@ export function PartyOverviewPage() {
|
||||
🎲 Call for a roll…
|
||||
</button>
|
||||
)}
|
||||
{isDM && characters?.some((c) => c.buildStage === 'COMPLETE') && (
|
||||
<button className="btn" onClick={() => setLevelling(true)} title="Approve a level for any of the party at once: each player is told, and picks what they gain themselves">
|
||||
⬆ Level up…
|
||||
</button>
|
||||
)}
|
||||
{isDM && characters?.some((c) => c.buildStage === 'COMPLETE') && (
|
||||
<button className="btn" onClick={() => setResting(true)} title="A short or long rest for any of the party: Hit Dice, HP and spell slots">
|
||||
🛏 Rest…
|
||||
@@ -64,6 +71,18 @@ export function PartyOverviewPage() {
|
||||
|
||||
{characters?.length === 0 && <div className="empty-state">No characters yet.</div>}
|
||||
|
||||
{isDM && characters?.some((c) => c.levelUpsPending > 0) && (
|
||||
<div className="notice-banner" role="status">
|
||||
<span style={{ flex: 1 }}>
|
||||
<strong>⬆ Level ups waiting on picks:</strong>{' '}
|
||||
{characters
|
||||
.filter((c) => c.levelUpsPending > 0)
|
||||
.map((c) => `${c.name}${c.userId ? '' : ' (no player: yours to finish)'}`)
|
||||
.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{characters && characters.length > 0 && (
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
@@ -122,9 +141,10 @@ export function PartyOverviewPage() {
|
||||
)}
|
||||
</td>
|
||||
<td data-label="Species">{c.species ? SPECIES_LABELS[c.species] : '—'}</td>
|
||||
<td data-label="Class">{c.characterClass ? classLabel(c.characterClass, c.subclassName) : inProgress ? 'Building…' : '—'}</td>
|
||||
<td data-label="Class">{c.characterClass ? classLabel(c.characterClass, c.subclassName, c.classes) : inProgress ? 'Building…' : '—'}</td>
|
||||
<td data-label="Level">
|
||||
{c.level}
|
||||
{c.levelUpsPending > 0 && <span title="A level up is approved and waiting for its picks"> ⬆</span>}
|
||||
{!inProgress && data?.campaign.trackXp && (
|
||||
<div className="combatant-sub mono" style={{ whiteSpace: 'nowrap' }}>
|
||||
{xpLabel(c.xp, c.xpProgress)}
|
||||
@@ -167,6 +187,9 @@ export function PartyOverviewPage() {
|
||||
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} onJump={setPage} />
|
||||
{isDM && checking && <GroupCheckModal characters={checkers} onClose={() => setChecking(false)} />}
|
||||
{isDM && resting && <RestModal campaignId={campaignId} characters={characters ?? []} onClose={() => setResting(false)} />}
|
||||
{isDM && levelling && (
|
||||
<LevelUpBulkModal campaignId={campaignId} characters={characters ?? []} trackXp={data?.campaign.trackXp ?? true} onClose={() => setLevelling(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.23.0",
|
||||
"version": "1.26.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.23.0",
|
||||
"version": "1.26.0",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"version": "1.23.0",
|
||||
"version": "1.26.0",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@prisma/client": "^7.10.0",
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"version": "1.23.0",
|
||||
"version": "1.26.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"axios": "^1.20.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.23.0",
|
||||
"version": "1.26.0",
|
||||
"private": true,
|
||||
"description": "D&D 5e campaign manager — monorepo root",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user