diff --git a/README.md b/README.md
index adbcc3a..96efd56 100644
--- a/README.md
+++ b/README.md
@@ -114,9 +114,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
diff --git a/backend/package.json b/backend/package.json
index 321390b..6729730 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "backend",
- "version": "1.24.0",
+ "version": "1.25.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
diff --git a/backend/prisma/migrations/20261011141537_level_up_approvals/migration.sql b/backend/prisma/migrations/20261011141537_level_up_approvals/migration.sql
new file mode 100644
index 0000000..22bf212
--- /dev/null
+++ b/backend/prisma/migrations/20261011141537_level_up_approvals/migration.sql
@@ -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;
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index 99c2a8b..3ff7760 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -357,6 +357,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?
diff --git a/backend/src/game/calculations.js b/backend/src/game/calculations.js
index fffcfdb..6f7bafb 100644
--- a/backend/src/game/calculations.js
+++ b/backend/src/game/calculations.js
@@ -325,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 };
@@ -389,7 +390,11 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
initiative: initiative(abilities.DEX.modifier) + fromGear('INITIATIVE'),
speed: Math.max(0, effectiveSpeed(species, speedOverride) + fromGear('SPEED')),
hp: {
- max: Math.max(1, (multi ? multiclass.multiclassHpMax(classes, abilities.CON.modifier) : hpMax(level, hitDieSize, abilities.CON.modifier)) + fromGear('MAX_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,
diff --git a/backend/src/routes/api.integration.test.js b/backend/src/routes/api.integration.test.js
index 149c6c4..ac56378 100644
--- a/backend/src/routes/api.integration.test.js
+++ b/backend/src/routes/api.integration.test.js
@@ -3694,3 +3694,97 @@ describe('multiclassing', () => {
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 before = (await request(app).get(`/characters/${id}`).set(dmAuth)).body.character.sheet.hp.max;
+ 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 and +2 Constitution (CON 14), where the average would have given 6 + 2.
+ expect(low.body.character.sheet.hp.max).toBe(before + 2 + 2);
+
+ // 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 + 4 + rolled.body.hpRolled + 2);
+ });
+});
+
+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']);
+ });
+});
diff --git a/backend/src/routes/campaigns.routes.js b/backend/src/routes/campaigns.routes.js
index 83c5529..c92bdab 100644
--- a/backend/src/routes/campaigns.routes.js
+++ b/backend/src/routes/campaigns.routes.js
@@ -255,6 +255,7 @@ 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,
@@ -262,7 +263,8 @@ function characterSummary(character, subclasses = true) {
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,
@@ -369,6 +371,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)
// ---------------------------------------------------------------------------
diff --git a/backend/src/routes/characters.routes.js b/backend/src/routes/characters.routes.js
index 8c71440..dc08b1c 100644
--- a/backend/src/routes/characters.routes.js
+++ b/backend/src/routes/characters.routes.js
@@ -8,6 +8,7 @@ const {
computeCharacterSheet,
computeWeaponStats,
getClassSaveProficiencies,
+ getClassHitDieSize,
getClassSpellcastingAbility,
scaledDamageDice,
canUseGear,
@@ -65,7 +66,7 @@ async function loadFullCharacter(id) {
spells: { include: { spell: true } },
currentLocation: true,
classLevels: true,
- campaign: { select: { subclassesEnabled: true, multiclassEnabled: true, diceMode: true } },
+ campaign: { select: { subclassesEnabled: true, multiclassEnabled: true, diceMode: true, hpOnLevelUp: true } },
},
});
}
@@ -103,6 +104,8 @@ 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) => ({
@@ -112,7 +115,8 @@ function serializeCharacter(character, isOwner, isDM = true) {
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: {
@@ -549,11 +553,14 @@ 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;
@@ -586,6 +593,19 @@ function planFor(req, res, character, toClass) {
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) => {
@@ -596,7 +616,17 @@ router.get('/:id/level-up', requireCharacterAccess(), async (req, res, next) =>
if (!planned) return;
const { plan, sheet } = planned;
const after = sheetOf({ ...withLevelIn(character, plan.characterClass), spellSlotsUsed: [] });
- res.json({ plan: { ...plan, maxHp: { from: sheet.hp.max, to: after.hp.max }, spellSlots: after.spellSlots } });
+ // 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);
}
@@ -607,6 +637,7 @@ const levelUpSchema = z.object({
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
});
@@ -635,6 +666,14 @@ 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;
@@ -648,7 +687,21 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
return res.status(400).json({ error: err.message });
}
const armorBonus = clamp(character.armorBonus + gained.armorBonus, -30, 30);
- const maxHp = sheetOf({ ...withLevelIn(character, plan.characterClass, newSubclass || undefined), 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) {
@@ -670,6 +723,9 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
where: { id: character.id },
data: {
level: plan.toLevel,
+ hpRollAdjust,
+ // An approved level is now taken.
+ levelUpsPending: Math.max(0, character.levelUpsPending - 1),
...(first && newSubclass ? { subclass: newSubclass } : {}),
specialAbilities: gained.specialAbilities,
armorBonus,
@@ -683,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);
}
diff --git a/docs/CHARACTERS.md b/docs/CHARACTERS.md
index 4e31cb8..0fbefa9 100644
--- a/docs/CHARACTERS.md
+++ b/docs/CHARACTERS.md
@@ -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 |
@@ -1238,7 +1274,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 |
diff --git a/frontend/package.json b/frontend/package.json
index d5a8613..ad17ff4 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "1.24.0",
+ "version": "1.25.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/api/queries.ts b/frontend/src/api/queries.ts
index 3eeb71f..4c26a55 100644
--- a/frontend/src/api/queries.ts
+++ b/frontend/src/api/queries.ts
@@ -16,6 +16,7 @@ import type {
GearEffect,
GoldTransaction,
Item,
+ LevelUpApproval,
LevelUpPlan,
Location,
MulticlassReference,
@@ -268,9 +269,9 @@ export function useLevelUpPlan(characterId: string, enabled: boolean, toClass: C
export function useLevelUp(characterId: string) {
const qc = useQueryClient();
return useMutation({
- mutationFn: async (data: { characterClass?: CharacterClass; 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'] });
@@ -294,6 +295,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({
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index b297b2a..9281517 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -303,6 +303,7 @@ 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;
@@ -508,6 +509,7 @@ 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;
@@ -922,6 +924,9 @@ export interface LevelUpPlan {
// 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;
@@ -934,11 +939,24 @@ 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 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
+}
+
export type RestType = 'SHORT' | 'LONG';
// What a rest did for one character (POST /campaigns/:id/rest).
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
index 420b0a1..e429cb5 100644
--- a/frontend/src/components/Layout.tsx
+++ b/frontend/src/components/Layout.tsx
@@ -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() {
)}
{user && }
+
);
}
diff --git a/frontend/src/components/LevelUpBulkModal.tsx b/frontend/src/components/LevelUpBulkModal.tsx
new file mode 100644
index 0000000..aa20710
--- /dev/null
+++ b/frontend/src/components/LevelUpBulkModal.tsx
@@ -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>(() => new Set(party.filter((c) => trackXp && c.xpProgress.levelUpDue && canLevel(c)).map((c) => c.id)));
+ const [error, setError] = useState(null);
+ const [done, setDone] = useState(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 (
+
+ {done.map((result) => (
+
+ {result.name}
+ {result.skipped ? (
+
{result.skipped}
+ ) : (
+
+ 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.'}
+
+ )}
+
+ ))}
+
+
+
+
+ );
+ }
+
+ return (
+
+ {error && }
+
+
+ {trackXp && (
+
+ )}
+
+
+
+ {party.length === 0 &&
No finished characters yet.
}
+ {party.map((c) => (
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/LevelUpModal.tsx b/frontend/src/components/LevelUpModal.tsx
index dc9d9ea..b5261c3 100644
--- a/frontend/src/components/LevelUpModal.tsx
+++ b/frontend/src/components/LevelUpModal.tsx
@@ -2,8 +2,10 @@ import { useState, type ReactNode } from 'react';
import { useLevelUp, useLevelUpPlan } from '../api/queries';
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 { CharacterClass, 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,8 +29,10 @@ function allowance(spells: NonNullable): 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 }) {
+// 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(null);
const { data: plan, isLoading, error: planError } = useLevelUpPlan(character.id, true, toClass);
@@ -38,6 +42,7 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
// undefined until picked: the equipped weapon is offered first.
const [weaponChoice, setWeaponChoice] = useState(undefined);
const [spellIds, setSpellIds] = useState>(new Set());
+ const [hpRoll, setHpRoll] = useState('');
const [error, setError] = useState(null);
if (isLoading || !plan) {
@@ -49,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) => {
@@ -68,9 +76,14 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
async function handleLevelUp() {
setError(null);
try {
- await levelUp.mutateAsync({
+ 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));
@@ -124,9 +137,32 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
-
- Max HP {plan.maxHp.from} → {plan.maxHp.to}, and healed to full. One more Hit Die.
-
+ {plan.hp.method === 'AVERAGE' ? (
+
+ Max HP {plan.maxHp.from} → {plan.maxHp.to}, and healed to full. One more Hit Die (d{plan.hp.die}).
+
+ ) : needsHpRoll ? (
+
+
+ This campaign rolls for hit points. Roll a d{plan.hp.die} at the table and enter what it shows; your Constitution
+ modifier is added for you. Healed to full, and one more Hit Die.
+
+
+
+ ) : (
+
+ This campaign rolls for hit points: a d{plan.hp.die} 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.
+
Written into Special Abilities for you. Where a line says "choose", edit it there once the player has.
+
+ 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.'}
+
)}
@@ -216,16 +254,22 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
{grantable.map((spell) => (
))}
)}
- {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.`}
>
)}
diff --git a/frontend/src/components/Toasts.tsx b/frontend/src/components/Toasts.tsx
new file mode 100644
index 0000000..8da52fe
--- /dev/null
+++ b/frontend/src/components/Toasts.tsx
@@ -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([]);
+ useEffect(() => subscribeToasts(setToasts), []);
+ if (toasts.length === 0) return null;
+ return (
+