Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d0a4a2252 |
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.28.0",
|
||||
"version": "1.29.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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>
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
{' · '}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+4
-4
@@ -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
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user