Compare commits

...
1 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 c3f2b0dfa7 V1.25.0: the DM approves a level, each player takes their own
- "Level up..." beside Rest approves a level for any of the party at once;
  with XP tracked, those who have the XP start ticked
- Each player is told with a notice and picks for themselves in the checklist:
  class (multiclass), subclass, Boon, spells within the level's allowance,
  the weapon upgrade; a waiting level shows on the sheet and campaign page
- The DM sees who still has picks to make, and can finish or take back any
- Hit points on a level up can be rolled (campaign setting): by the site, or
  at the table and typed in with physical dice
- Toasts: notices that slide in over any page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:30:31 +02:00
24 changed files with 782 additions and 66 deletions
+7 -3
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.24.0",
"version": "1.25.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
@@ -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;
+7
View File
@@ -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?
+6 -1
View File
@@ -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,
@@ -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']);
});
});
+59 -1
View File
@@ -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)
// ---------------------------------------------------------------------------
+65 -8
View File
@@ -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);
}
+47 -10
View File
@@ -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 |
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.24.0",
"version": "1.25.0",
"type": "module",
"scripts": {
"dev": "vite",
+18 -3
View File
@@ -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({
+18
View File
@@ -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).
+2
View File
@@ -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>
);
}
+61 -17
View File
@@ -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<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 }) {
// 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);
@@ -38,6 +42,7 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
// 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) {
@@ -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
</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">
@@ -190,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>
@@ -216,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>
</>
)}
+27
View File
@@ -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>
);
}
+14 -2
View File
@@ -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,13 @@ 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` } });
};
const onCharacterCreated = () => {
invalidate(['campaign', campaignId, 'characters']);
};
@@ -74,6 +84,7 @@ 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('encounter:participant-updated', onParticipantUpdated);
socket.on('encounter:round-advanced', onRoundAdvanced);
socket.on('campaign:deleted', onCampaignDeleted);
@@ -86,9 +97,10 @@ 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('encounter:participant-updated', onParticipantUpdated);
socket.off('encounter:round-advanced', onRoundAdvanced);
socket.off('campaign:deleted', onCampaignDeleted);
};
}, [campaignId, token, qc]);
}, [campaignId, token, qc, userId]);
}
+59
View File
@@ -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);
+45
View File
@@ -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);
};
}
+38 -8
View File
@@ -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
+45 -6
View File
@@ -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';
@@ -251,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);
@@ -292,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);
@@ -428,6 +432,22 @@ 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>
@@ -606,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
@@ -1288,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>
);
+23
View File
@@ -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">
@@ -125,6 +144,7 @@ export function PartyOverviewPage() {
<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>
);
}
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.24.0",
"version": "1.25.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.24.0",
"version": "1.25.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.24.0",
"version": "1.25.0",
"dependencies": {
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
@@ -33,7 +33,7 @@
}
},
"frontend": {
"version": "1.24.0",
"version": "1.25.0",
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.24.0",
"version": "1.25.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [