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. +

+ )}
@@ -190,7 +226,9 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character )} {(plan.features.length > 0 || plan.boon || plan.subclass) && ( -

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 ( +
+ {toasts.map((toast) => ( +
+ {toast.text} + {toast.action && ( + dismissToast(toast.id)}> + {toast.action.label} + + )} + +
+ ))} +
+ ); +} diff --git a/frontend/src/hooks/useCampaignSocket.ts b/frontend/src/hooks/useCampaignSocket.ts index bbd497b..666db95 100644 --- a/frontend/src/hooks/useCampaignSocket.ts +++ b/frontend/src/hooks/useCampaignSocket.ts @@ -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(['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]); } diff --git a/frontend/src/index.css b/frontend/src/index.css index 733685a..11d069b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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); diff --git a/frontend/src/lib/toasts.ts b/frontend/src/lib/toasts.ts new file mode 100644 index 0000000..822dfd9 --- /dev/null +++ b/frontend/src/lib/toasts.ts @@ -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. +// (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(); +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): 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); + }; +} diff --git a/frontend/src/pages/CampaignHomePage.tsx b/frontend/src/pages/CampaignHomePage.tsx index 637843f..0dcd15d 100644 --- a/frontend/src/pages/CampaignHomePage.tsx +++ b/frontend/src/pages/CampaignHomePage.tsx @@ -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 (
@@ -97,10 +99,15 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma {character.name} {inProgress && Building…} + {character.levelUpsPending > 0 && (isDM || mine) && ( + + ⬆ Level up {mine ? 'ready' : 'waiting on picks'} + + )}

- {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}`}

@@ -110,6 +117,11 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma HP {character.currentHp}/{character.maxHp} · AC {character.armorClass} )} + {mine && character.levelUpsPending > 0 && !inProgress && ( + + ⬆ Level up + + )} {inProgress ? 'Continue Build' : 'Open Sheet'} @@ -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() {

Your Character{myCharacters.length > 1 ? 's' : ''}

{myCharacters.map((c) => ( - {}} /> + {}} /> ))}
)} @@ -450,9 +463,23 @@ export function CampaignHomePage() {

{isDM ? 'Characters' : 'Rest of the Party'}

{isDM && characters?.some((c) => c.buildStage === 'COMPLETE') && ( - +
+ + +
)}
@@ -1010,6 +1037,9 @@ export function CampaignHomePage() { )} {isDM && resting && setResting(false)} />} + {isDM && levelling && ( + setLevelling(false)} /> + )} {isDM && opened && ( (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 (
+ {character.levelUpsPending > 0 && canEdit && ( +
+ + ⬆ {isOwner ? 'You can level up!' : `${character.name} has a level up waiting.`}{' '} + {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.)`} + + +
+ )}

@@ -606,13 +626,23 @@ export function CharacterSheetPage() { /> )} + {character.userId && character.userId !== user?.id && ( + + )}

- {isDM && levellingUp && setLevellingUp(false)} />} + {(isDM || (isOwner && character.levelUpsPending > 0)) && levellingUp && ( + { + setLevellingUp(false); + if (searchParams.has('levelup')) setSearchParams({}, { replace: true }); + }} + /> + )}
); diff --git a/frontend/src/pages/PartyOverviewPage.tsx b/frontend/src/pages/PartyOverviewPage.tsx index 82476c2..ad4513f 100644 --- a/frontend/src/pages/PartyOverviewPage.tsx +++ b/frontend/src/pages/PartyOverviewPage.tsx @@ -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 ; @@ -51,6 +53,11 @@ export function PartyOverviewPage() { 🎲 Call for a roll… )} + {isDM && characters?.some((c) => c.buildStage === 'COMPLETE') && ( + + )} {isDM && characters?.some((c) => c.buildStage === 'COMPLETE') && (