Compare commits

..
1 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 9d0a4a2252 V1.29.0: free roam by invitation, no slot no spell, weapons change at the right moment
- Free roam no longer adds the whole party: the DM adds who is there
- A spell with no slot of its level or higher left cannot be declared; it
  takes the lowest slot left at or above its level. Cantrips take none
- In a battle in progress a player changes the weapon in hand only as the
  battle begins or on their own turn after their attack has landed
- Spells are listed cantrips first, then by level, with the level shown

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:05:25 +02:00
19 changed files with 280 additions and 62 deletions
+4 -1
View File
@@ -99,8 +99,11 @@ including a character sheet or an encounter) and **Account**.
checklist at level 2 or 3, and shown beside the class everywhere; the DM can
change it from the sheet, and switch a 2014 path to its 2024 update. Every
sheet shows **the road ahead**: what each level from 1 to 20 gives.
- **Stricter battles.** A spell with no slot of its level or higher left
cannot be cast (cantrips never need one), and a player changes the weapon
in hand only as the battle begins or on their own turn after attacking.
- **Free roam**, a third kind of encounter beside battles and quests: the DM
ticks the nearby locations, the whole party is in it from the start, and
ticks the nearby locations and adds the characters who are there, and
each player sends their own character to a place to meet who is found there
and shop (a place's own shop, or an NPC's wares) where they stand.
- **Completed encounters are hidden** from the campaign's list until "Show
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.28.0",
"version": "1.29.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
+38 -1
View File
@@ -172,4 +172,41 @@ function attacksOf({ monster = null, npc = null }) {
return [{ name: 'Attack', attackBonus: stats.attackBonus || 0, damageDice: stats.damageDice || null, damageType: null }];
}
module.exports = { attackOutcome, saveSucceeds, lands, settleTarget, settle, rollDamage, rollCritDamage, sideOf, opponentsOf, targetWeights, pickTarget, attacksOf };
// Highest initiative first; ties keep the order combatants were added in.
// Anyone who has not rolled yet waits at the bottom.
function inTurnOrder(participants) {
const rolled = (p) => (p.initiativeRoll == null ? 0 : 1);
return [...participants].sort((a, b) => rolled(b) - rolled(a) || b.initiative - a.initiative || (a.id < b.id ? -1 : 1));
}
// Whether a combatant may change the weapon in their hand in a battle that
// is under way. There are two moments for it: as the battle begins, before
// anyone has made a move (round 1, still the first combatant's turn, nothing
// landed yet), and on their own turn once their attack has landed, before the
// turn passes. `applied` is the battle's landed actions: [{ round,
// actorParticipantId }]. `firstId` is whoever went first. `declared` is true
// while somebody's attack or spell is declared and still being resolved: that
// is a move being made.
function weaponSwapOpen({ round, activeId, firstId, participantId, applied = [], declared = false }) {
if (activeId == null) return true; // no turn has started
const opening = round === 1 && activeId === firstId && applied.length === 0 && !declared;
const afterOwnAttack = activeId === participantId && applied.some((a) => a.round === round && a.actorParticipantId === participantId);
return opening || afterOwnAttack;
}
module.exports = {
attackOutcome,
saveSucceeds,
lands,
settleTarget,
settle,
rollDamage,
rollCritDamage,
sideOf,
opponentsOf,
targetWeights,
pickTarget,
attacksOf,
inTurnOrder,
weaponSwapOpen,
};
+33
View File
@@ -140,3 +140,36 @@ describe('what a creature attacks with', () => {
expect(attacksOf({ npc: { attackBonus: null, damageDice: null } })).toEqual([]);
});
});
describe('changing weapons in a battle', () => {
const { inTurnOrder, weaponSwapOpen } = require('./combat');
const base = { round: 1, activeId: 'a', firstId: 'a', participantId: 'b', applied: [] };
test('the turn order is by initiative, the unrolled last', () => {
const order = inTurnOrder([
{ id: '1', initiativeRoll: null, initiative: 0 },
{ id: '2', initiativeRoll: 5, initiative: 7 },
{ id: '3', initiativeRoll: 12, initiative: 12 },
]);
expect(order.map((p) => p.id)).toEqual(['3', '2', '1']);
});
test('anyone may as the battle begins, before a move is made', () => {
expect(weaponSwapOpen(base)).toBe(true);
expect(weaponSwapOpen({ ...base, activeId: null })).toBe(true);
// The first turn has passed, or something has landed: the moment is gone.
expect(weaponSwapOpen({ ...base, activeId: 'b', participantId: 'c' })).toBe(false);
expect(weaponSwapOpen({ ...base, applied: [{ round: 1, actorParticipantId: 'a' }] })).toBe(false);
expect(weaponSwapOpen({ ...base, round: 2 })).toBe(false);
expect(weaponSwapOpen({ ...base, declared: true })).toBe(false); // an attack is being resolved
});
test('after that, only on their own turn once their attack has landed', () => {
const later = { round: 2, activeId: 'b', firstId: 'a', participantId: 'b' };
expect(weaponSwapOpen({ ...later, applied: [] })).toBe(false);
expect(weaponSwapOpen({ ...later, applied: [{ round: 1, actorParticipantId: 'b' }] })).toBe(false); // last round's
expect(weaponSwapOpen({ ...later, applied: [{ round: 2, actorParticipantId: 'b' }] })).toBe(true);
// Somebody else's turn, even though they attacked earlier this round.
expect(weaponSwapOpen({ ...later, activeId: 'c', applied: [{ round: 2, actorParticipantId: 'b' }] })).toBe(false);
});
});
+70 -2
View File
@@ -3983,6 +3983,71 @@ describe('battle actions', () => {
expect(await hp(two.id)).toBe(0); // 7 HP, 20 damage
const slotsAfter = (await request(app).get(`/characters/${mageId}`).set(dmAuth)).body.character.sheet.spellSlots.levels.find((l) => l.level === 3).remaining;
expect(slotsAfter).toBe(slotsBefore - 1);
// A cantrip takes no slot, however often it is cast. Spells are listed cantrips first.
expect(mira.spells.map((s) => [s.name, s.level])).toEqual([['Fire Bolt', 0], ['Fireball', 3]]);
const used = async () => (await request(app).get(`/characters/${mageId}`).set(dmAuth)).body.character.sheet.spellSlots.levels.map((l) => l.used);
const before = await used();
await request(app).put(`${e}/targets`).set(dmAuth).send({ participantIds: [one.id] });
const bolt = mira.spells.find((s) => s.name === 'Fire Bolt');
const boltId = (await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'spell', characterSpellId: bolt.characterSpellId } })).body.actionId;
action = (await request(app).patch(`${e}/actions/${boltId}`).set(dmAuth).send({ rolls: [{ participantId: one.id, d20: 19 }], damage: 3 })).body.encounter.actions[0];
expect(action).toMatchObject({ kind: 'ATTACK', name: 'Fire Bolt' });
expect(action.detail.slotLevel).toBeNull();
const cast = await request(app).post(`${e}/actions/${boltId}/apply`).set(dmAuth).send({});
expect(cast.status).toBe(200);
expect(cast.body.slotNote).toBeNull();
expect(await used()).toEqual(before);
// With her last 3rd-level slot gone, Fireball cannot be declared; the cantrip still can.
await request(app).post(`/characters/${mageId}/spell-slots`).set(dmAuth).send({ action: 'set', level: 3, used: 2 });
const dry = await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'spell', characterSpellId: fireball.characterSpellId } });
expect(dry.status).toBe(400);
expect(dry.body.error).toMatch(/no spell slot of level 3 or higher left/);
expect((await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'spell', characterSpellId: bolt.characterSpellId } })).status).toBe(201);
});
itIfDb('a weapon is changed as the battle begins or on your own turn after attacking, and not in between', async () => {
const { dmAuth, playerAuth, campaign, e, one, heroId } = await battle('actswap');
const c = `/campaigns/${campaign.id}`;
const bow = (await request(app).post(`${c}/weapons`).set(dmAuth).send({ name: 'Shortbow', damageDice: '1d6', damageType: 'piercing', attackAbility: 'DEX', properties: [] })).body.weapon;
await request(app).post(`/characters/${heroId}/weapons`).set(dmAuth).send({ weaponId: bow.id });
await request(app).patch(c).set(dmAuth).send({ maxEquippedWeapons: 2 });
const held = async () => (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character.weapons;
const sword = (await held()).find((w) => w.weapon.name === 'Longsword');
const equip = (auth, weaponId, equipped) => request(app).patch(`/characters/${heroId}/weapons/${weaponId}`).set(auth).send({ equipped });
// The battle has begun and nobody has made a move: the bow can come out, and go back.
expect((await equip(playerAuth, bow.id, true)).status).toBe(200);
expect((await equip(playerAuth, bow.id, false)).status).toBe(200);
// Aria attacks. While the attack is still being resolved nothing changes hands...
await request(app).put(`${e}/targets`).set(playerAuth).send({ participantIds: [one.id] });
const actionId = (await request(app).post(`${e}/actions`).set(playerAuth).send({ source: { type: 'weapon', characterWeaponId: sword.id } })).body.actionId;
await request(app).patch(`${e}/actions/${actionId}`).set(playerAuth).send({ rolls: [{ participantId: one.id, d20: 2 }] });
expect((await equip(playerAuth, bow.id, true)).status).toBe(400);
await request(app).post(`${e}/actions/${actionId}/apply`).set(dmAuth).send({});
// After her attack, before the next turn: she may swap.
expect((await equip(playerAuth, bow.id, true)).status).toBe(200);
// The wizard's turn: Aria's hands stay as they are, either way. The DM can still change them.
await request(app).post(`${e}/next-turn`).set(dmAuth);
const refused = await equip(playerAuth, bow.id, false);
expect(refused.status).toBe(400);
expect(refused.body.error).toMatch(/is in a battle \(Ambush\).*own turn once their attack has landed/);
expect((await equip(playerAuth, sword.weaponId, false)).status).toBe(400);
expect((await equip(dmAuth, bow.id, false)).status).toBe(200);
// Other gear is not held to it.
expect((await held()).find((w) => w.weapon.name === 'Shortbow').equipped).toBe(false);
// Round the table to Aria again: not before she has attacked this turn.
for (let i = 0; i < 3; i += 1) await request(app).post(`${e}/next-turn`).set(dmAuth);
expect((await request(app).get(e).set(dmAuth)).body.encounter.currentRound).toBe(2);
expect((await equip(playerAuth, bow.id, true)).status).toBe(400);
// Once the battle is over, gear is changed freely again.
await request(app).patch(e).set(dmAuth).send({ status: 'COMPLETE' });
expect((await equip(playerAuth, bow.id, true)).status).toBe(200);
});
itIfDb('with auto-roll on, a monster\u2019s turn opens with its attack aimed and rolled, kept from the players until the DM accepts it', async () => {
@@ -4049,7 +4114,7 @@ describe('battle actions', () => {
});
describe('free roam', () => {
itIfDb('the party is in it from the start, wanders between its places, and shops where it stands', async () => {
itIfDb('whoever the DM adds wanders between its places, and shops where they stand', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('roam');
const c = `/campaigns/${campaign.id}`;
const heroId = await buildCharacter(campaign.id, playerAuth, 'Aria');
@@ -4072,7 +4137,10 @@ describe('free roam', () => {
const e = `/encounters/${created.body.encounter.id}`;
const view = async (auth) => (await request(app).get(e).set(auth)).body.encounter;
// Everyone alive is in it without being added, so the player can open it.
// Nobody is in it until the DM adds them: the party may be split up.
expect((await view(dmAuth)).participants).toEqual([]);
expect((await request(app).get(e).set(playerAuth)).status).toBe(404);
for (const characterId of [heroId, allyId]) await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId });
let scene = await view(playerAuth);
expect(scene.mode).toBe('FREE_ROAM');
expect(scene.participants.map((p) => p.displayName).sort()).toEqual(['Aria', 'Bo']);
+4 -18
View File
@@ -906,24 +906,10 @@ router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req,
return created;
});
// Free roam is for the whole party: its places are set, and everyone
// alive and finished is in it from the start (so their players see it).
if (encounter.mode === 'FREE_ROAM') {
if (places.length > 0) await prisma.encounterLocation.createMany({ data: places.map((locationId) => ({ encounterId: encounter.id, locationId })) });
const party = await prisma.character.findMany({ where: { campaignId: req.campaign.id, buildStage: 'COMPLETE' }, include: SHEET_INCLUDE, orderBy: { createdAt: 'asc' } });
const present = party.filter((character) => lifeState(character) !== 'DEAD');
if (present.length > 0) {
await prisma.encounterParticipant.createMany({
data: present.map((character) => ({
encounterId: encounter.id,
characterId: character.id,
displayName: character.name,
currentHp: character.currentHp,
maxHp: sheetOf(character).hp.max,
isMonster: false,
})),
});
}
// A free-roam scene's places. Who is in it is the DM's to add, as with
// any encounter: the party may be split, some of them somewhere else.
if (encounter.mode === 'FREE_ROAM' && places.length > 0) {
await prisma.encounterLocation.createMany({ data: places.map((locationId) => ({ encounterId: encounter.id, locationId })) });
}
res.status(201).json({ encounter });
} catch (err) {
+50 -3
View File
@@ -48,7 +48,9 @@ const { speciesBonusProblem } = require('../game/data/species');
const { SKILLS } = require('../game/data/skills');
const { CURRENCIES, COINS, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, spendCoins, worthInGold, giveGear } = require('../utils/grants');
const { recordVisits } = require('../utils/locations');
const { SHEET_INCLUDE, sheetOf, settleHp } = require('../utils/sheet');
const { SHEET_INCLUDE, sheetOf, settleHp, bySpellLevel } = require('../utils/sheet');
const { inTurnOrder, weaponSwapOpen } = require('../game/combat');
const { isFallen } = require('../utils/actions');
const { absorbDamage, NO_DEATH_SAVES, lifeState, deathSaveInfo, settleDeathSaves, changeHp, rollDeathSave } = require('../utils/hp');
const router = express.Router();
@@ -173,7 +175,7 @@ function serializeCharacter(character, isOwner, isDM = true) {
usable: canUseGear(character.level, cw.level),
};
}),
spells: character.spells.map((cs) => ({ ...cs, usable: canUseGear(character.level, cs.spell.level) })),
spells: [...character.spells].sort(bySpellLevel).map((cs) => ({ ...cs, usable: canUseGear(character.level, cs.spell.level) })),
sheet: character.buildStage === 'COMPLETE' ? sheet : null,
};
}
@@ -1439,7 +1441,7 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
damageDice: ii.item.damageDice ? scaledDamageDice(ii.item.damageDice, ii.level, ii.item.damageScaling, ii.item.minLevel) : null,
...effectsOf(ii.item),
}));
const spells = character.spells.map((cs) => ({
const spells = [...character.spells].sort(bySpellLevel).map((cs) => ({
characterSpellId: cs.id,
name: cs.spell.name,
level: cs.spell.level,
@@ -1620,6 +1622,46 @@ function requireDmForGear(req, res) {
return false;
}
// A weapon is not swapped in the middle of a fight. For a character in a
// battle that is under way, the weapon in hand changes only as the battle
// begins (before anyone has made a move) or on their own turn once their
// attack has landed, before the turn passes (game/combat.js, weaponSwapOpen).
// Returns what to tell the player when it is not one of those moments. The
// DM is not held to it: they referee, and put mistakes right.
async function weaponSwapWait(character) {
const rows = await prisma.encounterParticipant.findMany({
where: { characterId: character.id, encounter: { status: 'ACTIVE', mode: 'BATTLE', archivedAt: null } },
include: { encounter: true },
});
for (const row of rows) {
const { encounter } = row;
const participants = await prisma.encounterParticipant.findMany({
where: { encounterId: encounter.id },
include: { character: { select: { currentHp: true, deathSaveFailures: true, stable: true } } },
});
const first = inTurnOrder(participants).find((p) => !isFallen(p));
// What has landed, and whether an attack somebody declared is still being
// resolved (one the server lined up for a monster is not a move yet).
const actions = await prisma.encounterAction.findMany({
where: { encounterId: encounter.id, status: { in: ['APPLIED', 'PENDING'] } },
select: { round: true, actorParticipantId: true, status: true, auto: true },
});
const applied = actions.filter((a) => a.status === 'APPLIED');
const open = weaponSwapOpen({
round: encounter.currentRound,
activeId: encounter.activeParticipantId,
firstId: first ? first.id : null,
participantId: row.id,
applied,
declared: actions.some((a) => a.status === 'PENDING' && !a.auto),
});
if (!open) {
return `${character.name} is in a battle (${encounter.name}). A weapon can be changed as the battle begins, before anyone has made a move, or on ${character.name}'s own turn once their attack has landed. The DM can change it at any time.`;
}
}
return null;
}
function emitCharacterUpdated(req) {
req.app.get('io').to(`campaign:${req.character.campaignId}`).emit('character:updated', { characterId: req.character.id });
}
@@ -1745,6 +1787,11 @@ for (const gear of GEAR) {
const blocked = await equipBlocked(req, gear.pool, req.params.poolId);
if (blocked) return res.status(400).json({ error: blocked });
}
// Drawing or putting away a weapon in a battle waits for the right moment.
if (gear.pool === 'weapon' && !req.isDM && parsed.data.equipped !== undefined && parsed.data.equipped !== existing.equipped) {
const wait = await weaponSwapWait(req.character);
if (wait) return res.status(400).json({ error: wait });
}
const row = await prisma[gear.held].update({ where: whereHeld(req), data, include });
if (gear.ownLevel) await settleHp(prisma, [req.character.id]);
+3 -9
View File
@@ -8,10 +8,10 @@ const { loadCampaignMembership } = require('../middleware/campaignAccess');
const { computeCharacterSheet, computeWeaponStats, scaledDamageDice, canUseGear, abilityModifier, encounterDifficulty, MAX_GEAR_LEVEL } = require('../game/calculations');
const { CURRENCIES, InsufficientGoldError, adjustGold, spendCoins, giveGear } = require('../utils/grants');
const { moveCharacters, visitedLocationIds } = require('../utils/locations');
const { SHEET_INCLUDE } = require('../utils/sheet');
const { SHEET_INCLUDE, bySpellLevel } = require('../utils/sheet');
const { NO_DEATH_SAVES, lifeState, deathSaveInfo, changeHp } = require('../utils/hp');
const { subclassName } = require('../game/data/subclasses');
const { settle, rollDamage, rollCritDamage } = require('../game/combat');
const { settle, rollDamage, rollCritDamage, inTurnOrder } = require('../game/combat');
const { rollDie } = require('../game/dice');
const { ActionError, targetRows, describeSource, autoActions } = require('../utils/actions');
@@ -59,13 +59,6 @@ function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
// Highest initiative first; ties keep the order combatants were added in.
// Anyone who has not rolled yet waits at the bottom.
function inTurnOrder(participants) {
const rolled = (p) => (p.initiativeRoll == null ? 0 : 1);
return [...participants].sort((a, b) => rolled(b) - rolled(a) || b.initiative - a.initiative || (a.id < b.id ? -1 : 1));
}
// What is added to a combatant's d20 for initiative: the sheet's initiative
// bonus for a character, the Dexterity modifier for a monster, and nothing for
// an NPC (they have no ability scores). Takes a participant with its
@@ -181,6 +174,7 @@ function characterCombatInfo(character, subclasses = true) {
}));
const spells = character.spells
.filter((cs) => canUseGear(character.level, cs.spell.level))
.sort(bySpellLevel)
.map((cs) => ({
characterSpellId: cs.id,
name: cs.spell.name,
+13 -2
View File
@@ -115,8 +115,19 @@ function describeSource(actor, source) {
const spell = known.spell;
if (!canUseGear(character.level, spell.level)) throw new ActionError(`${spell.name} is beyond ${character.name}'s level.`);
const casting = sheet.spellcasting;
// A cantrip takes no slot; any other spell takes one of its own level or higher.
const slotLevel = spell.level > 0 ? Math.max(spell.level, source.slotLevel || spell.level) : null;
// A cantrip takes no slot; any other spell takes one of its own level, or
// the lowest one above it that is left. With none left it cannot be cast.
// (A character with no slots at all, handed a spell by the DM, casts it freely.)
let slotLevel = null;
if (spell.level > 0) {
slotLevel = Math.max(spell.level, source.slotLevel || spell.level);
if (sheet.spellSlots) {
const wanted = slotLevel;
const free = sheet.spellSlots.levels.find((l) => l.level >= wanted && l.remaining > 0);
if (!free) throw new ActionError(`${character.name} has no spell slot of level ${wanted} or higher left, so cannot cast ${spell.name}.`);
slotLevel = free.level;
}
}
const base = { damageDice: spell.damageDice || null, damageBonus: 0, damageType: null, characterId: character.id, spellLevel: spell.level, slotLevel };
if (spell.attackRoll) return { kind: 'ATTACK', name: spell.name, detail: { ...base, attackBonus: casting ? casting.attackBonus : 0 } };
if (spell.saveAbility) {
+5 -1
View File
@@ -34,4 +34,8 @@ async function settleHp(db, characterIds) {
return changed;
}
module.exports = { SHEET_INCLUDE, sheetOf, settleHp };
// A character's spells in the order they are listed everywhere: cantrips
// first, then by spell level, then by name.
const bySpellLevel = (a, b) => a.spell.level - b.spell.level || a.spell.name.localeCompare(b.spell.name);
module.exports = { SHEET_INCLUDE, sheetOf, settleHp, bySpellLevel };
+15 -7
View File
@@ -53,7 +53,7 @@ time from the top of the page.
| Start button | Begin Battle | Begin Quest | Begin Roaming |
| End button | End Battle | Complete Quest | End Free Roam |
| Trade (section 10) | No | Yes, while in progress, with the NPCs in the scene | Yes, while in progress, at the place a character has gone to |
| Who is in it | Whoever the DM adds | Whoever the DM adds | The whole party, from the start |
| Who is in it | Whoever the DM adds | Whoever the DM adds | Whoever the DM adds |
Everything else (hit points, conditions, rewards, the scene description, DM
notes) works the same in all three.
@@ -63,8 +63,10 @@ notes) works the same in all three.
A free-roam encounter lays out **places the party can go to**: some of the
campaign's locations, picked by the DM as the ones nearby.
- **Creating one** puts every finished, living character in it straight away,
so every player sees it in their list without being added.
- **Who is in it** is up to the DM, as with any encounter: nobody is added
automatically, because the party may be split, with some of them somewhere
else. Add the characters who are here from "Add to the scene"; a player
sees the scene once their character is in it.
- **Places.** On its page the DM ticks which of the campaign's locations are
part of the scene ("Places in this scene"), and can tick more, or untick
some, while it is in progress. Locations are made on the campaign page.
@@ -225,8 +227,14 @@ Each has a card. The card's left edge is coloured by kind, and it shows:
(● proficient, ◆ expertise) with passive Perception, Investigation and
Insight, and special abilities. A weapon that is not equipped is not on the
card and cannot be used; "No weapon equipped" shows when there is none.
Equipping is changed from "📋 Sheet", at any point in the scene. The name
links to the character sheet.
Equipping is changed from "📋 Sheet". In a **battle in progress** a player
can change the weapon in their character's hand (equip or unequip) at two
moments only: **as the battle begins**, before anyone has made a move
(round 1, still the first combatant's turn, nothing declared or landed),
and **on their own turn once their attack has landed**, before the turn
passes. At any other moment the sheet refuses and says why. The DM is not
held to this and can change it at any time; items are not affected, and
neither is a quest or free roam. The name links to the character sheet.
- **Monsters:** for the DM, AC, speed, attack and damage, and under "details"
ability scores, trait and notes.
- **NPCs:** role, attitude and description; for the DM also AC, attack,
@@ -496,8 +504,8 @@ to someone else, keeping the d20 already rolled.
**4. Apply (DM).** **✔ Apply** (or the **A** key) lets it land: each target
loses what the card says, with temp HP, death saves and the fallen handled as
for any damage; a spell spends a slot of its level from its caster (if they
have none left it says so and spends nothing); anyone hurt while
for any damage; a spell spends the slot it was declared with from its caster
(a cantrip spends nothing); anyone hurt while
Concentrating gets a line telling the DM the save to call for. The card then
moves to **⚔ What just happened** in the side column, where the last few
actions stay for everyone to read. **✕ Discard** throws it away; a player can
+11 -2
View File
@@ -180,8 +180,17 @@ Each spell says how it is resolved when it is cast in an encounter
The library's spells come already set (Fire Bolt and Guiding Bolt as spell
attacks; Fireball, Burning Hands, Hold Person and the like as saves), and
copies already in a campaign were marked to match when this was added.
Casting a spell of 1st level or higher this way spends a slot of its level
when the DM applies it.
Casting a spell of 1st level or higher this way spends a slot when the DM
applies it: one of the spell's own level, or, when those are gone, the lowest
slot above it that is left. **With no slot of its level or higher left the
spell cannot be declared**: its button is off and marked "no slot left".
**Cantrips never take a slot** and can be cast every turn. A spell above what
the character's level allows is not offered at all (section on levels), and a
weapon has to be equipped and within the character's level to attack with.
Wherever a character's spells are listed (the sheet, the encounter card, the
attack picker) they run **cantrips first, then by spell level**, each with
its level beside the name.
## 7. Spellcasting numbers
+4 -1
View File
@@ -109,7 +109,10 @@ On a character's sheet, the DM picks a weapon from the pool and clicks Give.
keeps the level it has. A character holds one stack per weapon, so two
copies of the same weapon at different levels is not possible.
- The DM can take a weapon away with ✕ on the sheet.
- The owning player, or the DM, ticks **Equipped**.
- The owning player, or the DM, ticks **Equipped**. While the character is in
a battle in progress, a player can only change it as the battle begins or on
their own turn after their attack has landed
([ENCOUNTERS.md](ENCOUNTERS.md), section 5); the DM always can.
- **Only what is equipped counts.** A weapon has to be equipped to be used
in an encounter, and gear only gives its effects while equipped. What is
not equipped stays in the pack: still the character's, still listed on the
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.28.0",
"version": "1.29.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -335,7 +335,6 @@ export function ActionComposer({
const attacks = actor && !info ? creatureAttacks(actor) : [];
const noTarget = targetCount === 0;
const go = (source: ActionSource) => declare.mutate({ source, actorParticipantId: actor?.id }, { onError });
const slotsLeft = (level: number) => info?.spellSlots?.levels.find((l) => l.level === level)?.remaining ?? 0;
return (
<div className="card action-composer">
@@ -362,7 +361,9 @@ export function ActionComposer({
{info && info.weapons.length === 0 && <span className="combatant-sub">No weapon equipped: equip one on the sheet to attack with it.</span>}
{info?.spells.map((s) => {
const cantrip = s.level === 0;
const spent = !cantrip && Boolean(info.spellSlots) && slotsLeft(s.level) === 0;
// The slot it would take: its own level, or the lowest one above it that is left.
const slot = cantrip ? undefined : info.spellSlots?.levels.find((l) => l.level >= s.level && l.remaining > 0);
const spent = !cantrip && Boolean(info.spellSlots) && !slot;
const how = s.attackRoll
? `${fmtMod(info.spellcasting?.attackBonus ?? 0)} to hit`
: s.saveAbility
@@ -372,15 +373,22 @@ export function ActionComposer({
<button
key={s.characterSpellId}
className="btn action-option"
disabled={noTarget || declare.isPending}
title={[s.description, spent ? `No ${slotLevelLabel(s.level)}-level slot left: it can still be declared, but no slot will be spent.` : null].filter(Boolean).join(' — ')}
disabled={noTarget || spent || declare.isPending}
title={[
s.description,
cantrip ? 'A cantrip: no spell slot needed.' : null,
spent ? `No spell slot of ${slotLevelLabel(s.level)} level or higher left: it cannot be cast until slots come back.` : null,
slot ? `Takes a ${slotLevelLabel(slot.level)}-level slot when it lands (${slot.remaining} left).` : null,
]
.filter(Boolean)
.join(' — ')}
onClick={() => go({ type: 'spell', characterSpellId: s.characterSpellId })}
>
<span className="action-option-name">
✨ {s.name} {spent && <span className="badge badge-hostile">no slot</span>}
✨ {s.name} <span className="badge">{spellLevelLabel(s.level)}</span> {spent && <span className="badge badge-hostile">no slot left</span>}
</span>
<span className="combatant-sub mono">
{spellLevelLabel(s.level)} · {how}
{cantrip ? 'no slot' : slot && slot.level > s.level ? `${slotLevelLabel(slot.level)}-level slot` : 'uses a slot'} · {how}
{s.damageDice ? ` · ${s.damageDice}` : ''}
</span>
</button>
+8 -1
View File
@@ -887,8 +887,15 @@ export function CharacterSheetPage() {
<div key={cs.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div>
<strong>{cs.spell.name}</strong>
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>
<span
className="badge"
style={{ marginLeft: 6 }}
title={cs.spell.level === 0 ? 'A cantrip: cast as often as you like, no spell slot needed.' : `Casting it takes a spell slot of level ${cs.spell.level} or higher.`}
>
{spellLevelLabel(cs.spell.level)}
</span>
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>
{cs.spell.level === 0 ? 'no slot needed' : 'uses a slot'}
{cs.spell.damageDice && (
<>
{' · '}
+1 -1
View File
@@ -756,7 +756,7 @@ function attackLines(p: EncounterParticipant): string[] {
);
const spells = info.spells
.filter((s) => s.damageDice)
.map((s) => `${s.name}: ${s.damageDice}${info.spellcasting ? ` · ${fmtMod(info.spellcasting.attackBonus)} to hit / DC ${info.spellcasting.saveDC}` : ''}`);
.map((s) => `${s.name} (${spellLevelLabel(s.level)}): ${s.damageDice}${info.spellcasting ? ` · ${fmtMod(info.spellcasting.attackBonus)} to hit / DC ${info.spellcasting.saveDC}` : ''}`);
return [...weapons, ...spells];
}
const stats = p.monster ?? p.npc;
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.28.0",
"version": "1.29.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.28.0",
"version": "1.29.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.28.0",
"version": "1.29.0",
"dependencies": {
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
@@ -33,7 +33,7 @@
}
},
"frontend": {
"version": "1.28.0",
"version": "1.29.0",
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.28.0",
"version": "1.29.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [