Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3f2b0dfa7 |
@@ -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,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;
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.24.0",
|
||||
"version": "1.25.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Toasts: short notices that slide in over any page ("Aria can level up").
|
||||
// A tiny store of its own, so anything can raise one (a socket event, a
|
||||
// mutation) without being inside a particular component. <Toasts />
|
||||
// (components/Toasts.tsx), mounted once in the layout, shows them.
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
text: string;
|
||||
// Where "tone" is 'good' the toast is a piece of good news; default is neutral.
|
||||
tone?: 'good' | 'warn';
|
||||
// A link to follow up on it: its label and where it goes.
|
||||
action?: { label: string; to: string };
|
||||
}
|
||||
|
||||
type Listener = (toasts: Toast[]) => void;
|
||||
|
||||
let toasts: Toast[] = [];
|
||||
let nextId = 1;
|
||||
const listeners = new Set<Listener>();
|
||||
const publish = () => listeners.forEach((listener) => listener(toasts));
|
||||
|
||||
// How long a toast stays before it goes by itself.
|
||||
const TOAST_MS = 12000;
|
||||
|
||||
export function showToast(toast: Omit<Toast, 'id'>): number {
|
||||
const id = nextId++;
|
||||
toasts = [...toasts, { ...toast, id }].slice(-5);
|
||||
publish();
|
||||
setTimeout(() => dismissToast(id), TOAST_MS);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function dismissToast(id: number) {
|
||||
if (!toasts.some((toast) => toast.id === id)) return;
|
||||
toasts = toasts.filter((toast) => toast.id !== id);
|
||||
publish();
|
||||
}
|
||||
|
||||
export function subscribeToasts(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
listener(toasts);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
@@ -32,13 +32,14 @@ import { NumberInput } from '../components/NumberInput';
|
||||
import { SectionNav, type Section } from '../components/SectionNav';
|
||||
import { LifeBadge } from '../components/DeathSaves';
|
||||
import { RestModal } from '../components/RestModal';
|
||||
import { LevelUpBulkModal } from '../components/LevelUpBulkModal';
|
||||
import { Pagination } from '../components/Paginated';
|
||||
import { usePagination } from '../hooks/usePagination';
|
||||
import { CampaignSearch, type SearchGroup } from '../components/campaign/CampaignSearch';
|
||||
import { EntryDetail } from '../components/campaign/EntryDetail';
|
||||
import { ItemsSection, MonstersSection, NpcsSection, SpellsSection, WeaponsSection } from '../components/campaign/PoolSections';
|
||||
import { effectsSummary } from '../lib/gear';
|
||||
import { extractErrorMessage, spellLevelLabel } from '../lib/format';
|
||||
import { SPECIES_LABELS, classLabel, extractErrorMessage, spellLevelLabel } from '../lib/format';
|
||||
import { matchesSearch, type EntryKind, type EntryRef } from '../lib/campaignEntries';
|
||||
import { speciesIcon } from '../lib/speciesIcons';
|
||||
import type { CampaignDiceMode, CharacterSummary, CritRule, Encounter, EncounterMode, HpMethod, RollVisibility, ScoreMethod } from '../api/types';
|
||||
@@ -86,7 +87,8 @@ function sortEncounters(encounters: Encounter[], sort: EncounterSort): Encounter
|
||||
return [...encounters].sort(compare[sort]);
|
||||
}
|
||||
|
||||
function CharacterRow({ character, isDM, onDelete }: { character: CharacterSummary; isDM: boolean; onDelete: () => void }) {
|
||||
// `mine`: the viewer's own character, who gets the way into a level up that is waiting.
|
||||
function CharacterRow({ character, isDM, mine = false, onDelete }: { character: CharacterSummary; isDM: boolean; mine?: boolean; onDelete: () => void }) {
|
||||
const inProgress = character.buildStage !== 'COMPLETE';
|
||||
return (
|
||||
<div className="card flex-between flex-wrap">
|
||||
@@ -97,10 +99,15 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
|
||||
{character.name}
|
||||
</Link>
|
||||
{inProgress && <span className="badge">Building…</span>}
|
||||
{character.levelUpsPending > 0 && (isDM || mine) && (
|
||||
<span className="badge badge-live" title={mine ? 'Your DM has approved a level: open the sheet to choose what you gain' : 'Approved, and waiting for the picks to be made'}>
|
||||
⬆ Level up {mine ? 'ready' : 'waiting on picks'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.9rem' }}>
|
||||
{character.species ? `${character.species} ` : ''}
|
||||
{character.characterClass || (inProgress ? 'Not yet built' : '')}{character.subclassName && ` (${character.subclassName})`} · Level {character.level}
|
||||
{character.species ? `${SPECIES_LABELS[character.species] ?? character.species} ` : ''}
|
||||
{character.characterClass ? classLabel(character.characterClass, character.subclassName, character.classes) : inProgress ? 'Not yet built' : ''} · Level {character.level}
|
||||
{character.currentLocationName && ` · 📍 ${character.currentLocationName}`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -110,6 +117,11 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
|
||||
<LifeBadge deathSaves={character.deathSaves} /> HP {character.currentHp}/{character.maxHp} · AC {character.armorClass}
|
||||
</div>
|
||||
)}
|
||||
{mine && character.levelUpsPending > 0 && !inProgress && (
|
||||
<Link to={`/characters/${character.id}?levelup=1`} className="btn btn-primary">
|
||||
⬆ Level up
|
||||
</Link>
|
||||
)}
|
||||
<Link to={inProgress ? `/characters/${character.id}/build` : `/characters/${character.id}`} className="btn">
|
||||
{inProgress ? 'Continue Build' : 'Open Sheet'}
|
||||
</Link>
|
||||
@@ -180,6 +192,7 @@ export function CampaignHomePage() {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [resting, setResting] = useState(false);
|
||||
const [levelling, setLevelling] = useState(false);
|
||||
const [deleteConfirmName, setDeleteConfirmName] = useState('');
|
||||
|
||||
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
|
||||
@@ -440,7 +453,7 @@ export function CampaignHomePage() {
|
||||
<div className="section-block">
|
||||
<h2 style={{ fontSize: '1.1rem' }}>Your Character{myCharacters.length > 1 ? 's' : ''}</h2>
|
||||
{myCharacters.map((c) => (
|
||||
<CharacterRow key={c.id} character={c} isDM={false} onDelete={() => {}} />
|
||||
<CharacterRow key={c.id} character={c} isDM={false} mine onDelete={() => {}} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -450,9 +463,23 @@ export function CampaignHomePage() {
|
||||
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
|
||||
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>{isDM ? 'Characters' : 'Rest of the Party'}</h2>
|
||||
{isDM && characters?.some((c) => c.buildStage === 'COMPLETE') && (
|
||||
<button className="btn" onClick={() => setResting(true)} title="A short or long rest for any of the party: Hit Dice, HP and spell slots">
|
||||
🛏 Rest…
|
||||
</button>
|
||||
<div className="flex-row flex-wrap">
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => setLevelling(true)}
|
||||
title="Approve a level for any of the party at once: each player is told, and picks what they gain themselves"
|
||||
>
|
||||
⬆ Level up…
|
||||
{campaign.trackXp && characters.some((c) => c.xpProgress.levelUpDue) && (
|
||||
<span className="badge badge-live" style={{ marginLeft: 6 }}>
|
||||
{characters.filter((c) => c.xpProgress.levelUpDue).length} due
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button className="btn" onClick={() => setResting(true)} title="A short or long rest for any of the party: Hit Dice, HP and spell slots">
|
||||
🛏 Rest…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<form onSubmit={handleCreateCharacter} className="flex-row flex-wrap mt-sm" style={{ marginBottom: '0.75rem' }}>
|
||||
@@ -1010,6 +1037,9 @@ export function CampaignHomePage() {
|
||||
)}
|
||||
|
||||
{isDM && resting && <RestModal campaignId={campaignId} characters={characters ?? []} onClose={() => setResting(false)} />}
|
||||
{isDM && levelling && (
|
||||
<LevelUpBulkModal campaignId={campaignId} characters={characters ?? []} trackXp={campaign.trackXp} onClose={() => setLevelling(false)} />
|
||||
)}
|
||||
|
||||
{isDM && opened && (
|
||||
<EntryDetail
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
useCharacter,
|
||||
usePatchCharacter,
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
useCampaigns,
|
||||
useCopyCharacter,
|
||||
useCampaignLocations,
|
||||
useApproveLevelUps,
|
||||
} from '../api/queries';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -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
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user