Compare commits

...
1 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 e7b6e0065a V1.22.0: campaign settings for dice, scores, multiclassing, auto-rolls, XP and crits
- Settings grouped into Characters, Dice & rolls, Combat and Progression
- Dice: digital, physical or both; a campaign's own choice applies to every character
- Ability scores by 4d6, the standard array or point buy (27 points, 8 to 15)
- Track XP can be turned off for milestone levelling: XP is hidden and not awarded
- New settings saved ahead of their features: multiclassing, auto-rolled monster
  and NPC attacks, roll visibility, hidden monster rolls, HP on level up, crit rule
- docs/CAMPAIGN_SETTINGS.md lists every setting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:12:05 +02:00
24 changed files with 697 additions and 96 deletions
+7
View File
@@ -18,6 +18,7 @@ monsters, and a real-time encounter/combat tracker.
| `docs/ENCOUNTERS.md` | Battles and NPC quests, turns, rewards, trade, what players see |
| `docs/MONSTERS.md` | The monster list and built-in bestiary |
| `docs/NPCS.md` | Non-player characters, what they sell and the rates they change money at |
| `docs/CAMPAIGN_SETTINGS.md` | Every campaign setting: dice, ability scores, subclasses and multiclassing, auto-rolled attacks, XP, crits |
| `docs/SEEDS.md` | World seeds: ready-made campaigns a DM starts from a code, and how to add one |
`CLAUDE_BUILD_PROMPT.md`, `docs/GAME_RULES_REFERENCE.md` and
@@ -96,6 +97,12 @@ including a character sheet or an encounter) and **Account**.
level 2 or 3 and shown beside the class everywhere; the DM can change one
from the sheet. A campaign can do without them: untick "Characters choose a
subclass" in its Settings and every class follows one path, unasked.
- **Campaign settings** come in four groups. **Characters**: starting gold
and gear, how ability scores are made (4d6, standard array or point buy),
subclasses, multiclassing. **Dice & rolls**: digital, physical or both, and
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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.21.0",
"version": "1.22.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
@@ -0,0 +1,30 @@
-- Campaign settings: which dice, multiclassing, auto-rolled monster and NPC
-- attacks, XP tracking, HP on a level up, the ability-score method, who sees
-- a roll, and what a critical hit does.
-- CreateEnum
CREATE TYPE "CampaignDiceMode" AS ENUM ('DIGITAL', 'PHYSICAL', 'BOTH');
-- CreateEnum
CREATE TYPE "HpMethod" AS ENUM ('AVERAGE', 'ROLL');
-- CreateEnum
CREATE TYPE "ScoreMethod" AS ENUM ('ROLL_4D6', 'STANDARD_ARRAY', 'POINT_BUY');
-- CreateEnum
CREATE TYPE "RollVisibility" AS ENUM ('ALL', 'OWN');
-- CreateEnum
CREATE TYPE "CritRule" AS ENUM ('DOUBLE_DICE', 'MAX_PLUS_ROLL');
-- AlterTable
ALTER TABLE "campaigns" ADD COLUMN "abilityScoreMethod" "ScoreMethod" NOT NULL DEFAULT 'ROLL_4D6',
ADD COLUMN "autoRollMonsters" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "autoRollNpcs" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "critRule" "CritRule" NOT NULL DEFAULT 'DOUBLE_DICE',
ADD COLUMN "diceMode" "CampaignDiceMode" NOT NULL DEFAULT 'BOTH',
ADD COLUMN "hideMonsterRolls" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "hpOnLevelUp" "HpMethod" NOT NULL DEFAULT 'AVERAGE',
ADD COLUMN "multiclassEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "rollVisibility" "RollVisibility" NOT NULL DEFAULT 'ALL',
ADD COLUMN "trackXp" BOOLEAN NOT NULL DEFAULT true;
+55
View File
@@ -138,6 +138,42 @@ enum DiceMode {
PHYSICAL
}
// Which dice a campaign plays with. BOTH leaves it to each character (and
// every roll can still be typed in from a real die).
enum CampaignDiceMode {
DIGITAL
PHYSICAL
BOTH
}
// How a character's maximum HP grows on a level up: the fixed average of the
// hit die (rounded up), or a roll of it.
enum HpMethod {
AVERAGE
ROLL
}
// How the builder arrives at the six ability scores.
enum ScoreMethod {
ROLL_4D6
STANDARD_ARRAY
POINT_BUY
}
// Who sees a roll. ALL: everyone at the table. OWN: a player sees their own;
// the DM always sees every roll.
enum RollVisibility {
ALL
OWN
}
// What a critical hit does to the damage: roll the dice twice (the rule), or
// take the dice at their maximum and roll them once more on top.
enum CritRule {
DOUBLE_DICE
MAX_PLUS_ROLL
}
// ---------------------------------------------------------------------------
// Identity & campaigns
// ---------------------------------------------------------------------------
@@ -182,6 +218,25 @@ model Campaign {
// Off: nobody chooses a subclass. Each class follows its first one without
// being asked, and subclasses are not named anywhere.
subclassesEnabled Boolean @default(true)
// On: a character can take levels in more than one class (game/multiclass.js).
multiclassEnabled Boolean @default(false)
// Which dice are rolled at this table; see CampaignDiceMode.
diceMode CampaignDiceMode @default(BOTH)
// On: when a monster's (or an NPC's) turn comes up in a battle, the server
// picks a target, rolls the attack and the damage, and puts it to the DM to
// accept, change or throw out.
autoRollMonsters Boolean @default(false)
autoRollNpcs Boolean @default(false)
// Off: experience is not kept. No XP on the sheets, nothing to award after
// an encounter, and nobody is shown as due a level.
trackXp Boolean @default(true)
hpOnLevelUp HpMethod @default(AVERAGE)
abilityScoreMethod ScoreMethod @default(ROLL_4D6)
rollVisibility RollVisibility @default(ALL)
// On: the DM's rolls for monsters and NPCs are seen by the DM alone until
// they are put into play.
hideMonsterRolls Boolean @default(true)
critRule CritRule @default(DOUBLE_DICE)
ownerId String
// Set while the campaign is archived: kept in full, but moved off the main
// list and closed to new members until the DM restores it.
+30
View File
@@ -48,6 +48,31 @@ function rollAbilityScore() {
return abilityScoreFromDice(dice);
}
// The other two ways to make ability scores (Campaign.abilityScoreMethod).
// The standard array is six fixed scores, assigned like rolled ones. Point
// buy starts every score at 8 and spends a budget to raise them, to 15 at most.
const STANDARD_ARRAY = [15, 14, 13, 12, 10, 8];
const POINT_BUY_BUDGET = 27;
const POINT_BUY_COSTS = { 8: 0, 9: 1, 10: 2, 11: 3, 12: 4, 13: 5, 14: 7, 15: 9 };
// What a set of point-buy scores costs, or null if a score is out of range.
function pointBuyCost(scores) {
let total = 0;
for (const score of Object.values(scores)) {
const cost = POINT_BUY_COSTS[score];
if (cost === undefined) return null;
total += cost;
}
return total;
}
// Which dice a character rolls: the campaign's, unless it leaves the choice
// to each character (BOTH).
function effectiveDiceMode(campaign, character) {
if (campaign && campaign.diceMode && campaign.diceMode !== 'BOTH') return campaign.diceMode;
return character.diceMode;
}
// Fisher-Yates-ish random pick of `count` distinct items from `options`,
// using the same crypto RNG as dice rolls (never Math.random for anything
// that ends up on a character sheet).
@@ -154,6 +179,11 @@ module.exports = {
rollDie,
rollAbilityScore,
abilityScoreFromDice,
STANDARD_ARRAY,
POINT_BUY_BUDGET,
POINT_BUY_COSTS,
pointBuyCost,
effectiveDiceMode,
speciesChoicesForRoll,
classChoicesForRoll,
randomSample,
+37
View File
@@ -0,0 +1,37 @@
const { pointBuyCost, POINT_BUY_BUDGET, STANDARD_ARRAY, effectiveDiceMode } = require('./buildFlow');
describe('point buy', () => {
test('all eights cost nothing', () => {
expect(pointBuyCost({ STR: 8, DEX: 8, CON: 8, INT: 8, WIS: 8, CHA: 8 })).toBe(0);
});
test('the classic 15/15/15/8/8/8 spends exactly the budget', () => {
expect(pointBuyCost({ STR: 15, DEX: 15, CON: 15, INT: 8, WIS: 8, CHA: 8 })).toBe(POINT_BUY_BUDGET);
});
test('14 and 15 cost two points a step', () => {
expect(pointBuyCost({ STR: 13 })).toBe(5);
expect(pointBuyCost({ STR: 14 })).toBe(7);
expect(pointBuyCost({ STR: 15 })).toBe(9);
});
test('a score out of range is refused', () => {
expect(pointBuyCost({ STR: 16 })).toBeNull();
expect(pointBuyCost({ STR: 7 })).toBeNull();
});
});
test('the standard array is the six usual scores', () => {
expect(STANDARD_ARRAY).toEqual([15, 14, 13, 12, 10, 8]);
});
describe('effectiveDiceMode', () => {
test('a campaign with one kind of dice decides for everyone', () => {
expect(effectiveDiceMode({ diceMode: 'PHYSICAL' }, { diceMode: 'DIGITAL' })).toBe('PHYSICAL');
expect(effectiveDiceMode({ diceMode: 'DIGITAL' }, { diceMode: 'PHYSICAL' })).toBe('DIGITAL');
});
test('BOTH leaves it to the character', () => {
expect(effectiveDiceMode({ diceMode: 'BOTH' }, { diceMode: 'PHYSICAL' })).toBe('PHYSICAL');
});
});
@@ -3456,3 +3456,87 @@ describe('a campaign without subclasses', () => {
expect((await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ subclass: 'SAMURAI' })).body.character.subclassName).toBe('Samurai');
});
});
describe('campaign settings', () => {
itIfDb('are kept, checked, and read back by every member', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('settings');
expect(campaign).toMatchObject({
diceMode: 'BOTH',
multiclassEnabled: false,
autoRollMonsters: false,
autoRollNpcs: false,
trackXp: true,
hpOnLevelUp: 'AVERAGE',
abilityScoreMethod: 'ROLL_4D6',
rollVisibility: 'ALL',
hideMonsterRolls: true,
critRule: 'DOUBLE_DICE',
});
const patch = (auth, body) => request(app).patch(`/campaigns/${campaign.id}`).set(auth).send(body);
expect((await patch(playerAuth, { trackXp: false })).status).toBe(403);
expect((await patch(dmAuth, { diceMode: 'SOMETIMES' })).status).toBe(400);
const changed = await patch(dmAuth, { diceMode: 'PHYSICAL', critRule: 'MAX_PLUS_ROLL', multiclassEnabled: true, rollVisibility: 'OWN' });
expect(changed.status).toBe(200);
const seen = (await request(app).get(`/campaigns/${campaign.id}`).set(playerAuth)).body.campaign;
expect(seen).toMatchObject({ diceMode: 'PHYSICAL', critRule: 'MAX_PLUS_ROLL', multiclassEnabled: true, rollVisibility: 'OWN' });
});
itIfDb("a campaign's dice decide for every character, unless it leaves them to each", async () => {
const { dmAuth, campaign } = await setupCampaign('settingsdice');
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ diceMode: 'PHYSICAL' });
const character = (await request(app).post(`/campaigns/${campaign.id}/characters`).set(dmAuth).send({ name: 'Rolla' })).body.character;
const read = (await request(app).get(`/characters/${character.id}`).set(dmAuth)).body.character;
expect(read).toMatchObject({ diceMode: 'PHYSICAL', diceModeChoosable: false });
expect((await request(app).patch(`/characters/${character.id}/build/dice-mode`).set(dmAuth).send({ diceMode: 'DIGITAL' })).status).toBe(400);
// Physical dice must be typed in.
expect((await request(app).post(`/characters/${character.id}/build/roll`).set(dmAuth).send({})).status).toBe(400);
expect((await request(app).post(`/characters/${character.id}/build/roll`).set(dmAuth).send({ value: 2 })).status).toBe(200);
});
// Walks a new character to the ability-score step.
async function toScores(campaignId, auth) {
const id = (await request(app).post(`/campaigns/${campaignId}/characters`).set(auth).send({ name: 'Scorer' })).body.character.id;
const species = (await request(app).post(`/characters/${id}/build/roll`).set(auth)).body.buildState.speciesChoices.options[0];
await request(app).post(`/characters/${id}/build/choose`).set(auth).send(species === 'HALF_ELF' ? { species, halfElfBonusAbilities: ['STR', 'INT'] } : { species });
let classRoll = await request(app).post(`/characters/${id}/build/roll`).set(auth);
while (classRoll.body.buildState.classChoices.type === 'reroll') classRoll = await request(app).post(`/characters/${id}/build/roll`).set(auth);
await request(app).post(`/characters/${id}/build/choose`).set(auth).send({ characterClass: classRoll.body.buildState.classChoices.options[0] });
return id;
}
itIfDb('the standard array gives everyone the same six scores to place', async () => {
const { dmAuth, campaign } = await setupCampaign('settingsarray');
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ abilityScoreMethod: 'STANDARD_ARRAY' });
const id = await toScores(campaign.id, dmAuth);
const rolled = await request(app).post(`/characters/${id}/build/roll`).set(dmAuth);
expect(rolled.status).toBe(200);
expect(rolled.body.buildState.abilityRolls.map((r) => r.total)).toEqual([15, 14, 13, 12, 10, 8]);
const chosen = await request(app).post(`/characters/${id}/build/choose`).set(dmAuth).send({ assignments: { STR: 0, DEX: 1, CON: 2, INT: 3, WIS: 4, CHA: 5 } });
expect(chosen.status).toBe(200);
});
itIfDb('point buy spends 27 points on scores from 8 to 15, and nothing is rolled', async () => {
const { dmAuth, campaign } = await setupCampaign('settingsbuy');
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ abilityScoreMethod: 'POINT_BUY' });
const id = await toScores(campaign.id, dmAuth);
expect((await request(app).post(`/characters/${id}/build/roll`).set(dmAuth)).status).toBe(400);
const buy = (pointBuy) => request(app).post(`/characters/${id}/build/choose`).set(dmAuth).send({ pointBuy });
expect((await buy({ STR: 15, DEX: 15, CON: 15, INT: 15, WIS: 8, CHA: 8 })).status).toBe(400); // 36 points
expect((await buy({ STR: 16, DEX: 8, CON: 8, INT: 8, WIS: 8, CHA: 8 })).status).toBe(400);
const bought = await buy({ STR: 15, DEX: 14, CON: 14, INT: 8, WIS: 10, CHA: 10 }); // 9 + 7 + 7 + 0 + 2 + 2
expect(bought.status).toBe(200);
expect(bought.body.buildState.abilityRolls.map((r) => r.total)).toEqual([15, 14, 14, 8, 10, 10]);
expect(bought.body.buildStage).toBe('ASSIGNING_PROFICIENCIES');
});
itIfDb('without XP tracking there is nothing to award', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('settingsxp');
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ trackXp: false });
const characterId = await buildCharacter(campaign.id, playerAuth, 'Aria');
const encounterId = (await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Riddle', mode: 'QUEST' })).body.encounter.id;
await request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send({ characterId });
const res = await request(app).post(`/encounters/${encounterId}/award-xp`).set(dmAuth).send({ xp: 100 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/experience/);
});
});
+20
View File
@@ -36,6 +36,16 @@ function campaignSummary(campaign, role) {
maxEquipped: campaign.maxEquipped,
maxEquippedWeapons: campaign.maxEquippedWeapons,
subclassesEnabled: campaign.subclassesEnabled,
multiclassEnabled: campaign.multiclassEnabled,
diceMode: campaign.diceMode,
autoRollMonsters: campaign.autoRollMonsters,
autoRollNpcs: campaign.autoRollNpcs,
trackXp: campaign.trackXp,
hpOnLevelUp: campaign.hpOnLevelUp,
abilityScoreMethod: campaign.abilityScoreMethod,
rollVisibility: campaign.rollVisibility,
hideMonsterRolls: campaign.hideMonsterRolls,
critRule: campaign.critRule,
ownerId: campaign.ownerId,
archivedAt: campaign.archivedAt,
role,
@@ -73,6 +83,16 @@ const createCampaignSchema = z.object({
maxEquippedWeapons: z.number().int().min(0).max(20).optional(),
// Off: nobody chooses a subclass, and each class follows its first one.
subclassesEnabled: z.boolean().optional(),
multiclassEnabled: z.boolean().optional(),
diceMode: z.enum(['DIGITAL', 'PHYSICAL', 'BOTH']).optional(),
autoRollMonsters: z.boolean().optional(),
autoRollNpcs: z.boolean().optional(),
trackXp: z.boolean().optional(),
hpOnLevelUp: z.enum(['AVERAGE', 'ROLL']).optional(),
abilityScoreMethod: z.enum(['ROLL_4D6', 'STANDARD_ARRAY', 'POINT_BUY']).optional(),
rollVisibility: z.enum(['ALL', 'OWN']).optional(),
hideMonsterRolls: z.boolean().optional(),
critRule: z.enum(['DOUBLE_DICE', 'MAX_PLUS_ROLL']).optional(),
});
// POST /campaigns — create, creator becomes DM/owner
+50 -13
View File
@@ -33,6 +33,10 @@ const {
composeFromPicks,
validateTalents,
composeSpecialAbilities,
STANDARD_ARRAY,
POINT_BUY_BUDGET,
pointBuyCost,
effectiveDiceMode,
} = require('../game/buildFlow');
const characterOptions = require('../game/data/characterOptions');
const { SKILLS } = require('../game/data/skills');
@@ -55,7 +59,7 @@ async function loadFullCharacter(id) {
weapons: { include: { weapon: true } },
spells: { include: { spell: true } },
currentLocation: true,
campaign: { select: { subclassesEnabled: true } },
campaign: { select: { subclassesEnabled: true, diceMode: true } },
},
});
}
@@ -115,7 +119,10 @@ function serializeCharacter(character, isOwner, isDM = true) {
background: character.background,
appearance: character.appearance,
privateNotes: isOwner ? character.privateNotes : undefined,
diceMode: character.diceMode,
// The dice this character rolls with, and whether that is theirs to choose
// (the campaign leaves it to each character) or the campaign's.
diceMode: effectiveDiceMode(character.campaign, character),
diceModeChoosable: !character.campaign || character.campaign.diceMode === 'BOTH',
currentLocationId: restricted ? null : character.currentLocationId,
currentLocation: restricted ? null : character.currentLocation || null,
gold: restricted ? null : character.gold,
@@ -796,7 +803,7 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
// a user-facing error string on failure, otherwise returns the value(s) in
// the exact same shape the digital path would have produced.
function resolveSingleRoll(character, body, sides) {
if (character.diceMode !== 'PHYSICAL') return rollDie(sides);
if (effectiveDiceMode(character.campaign, character) !== 'PHYSICAL') return rollDie(sides);
const value = Number(body?.value);
if (!validateManualRoll(sides, value)) {
throw new Error(`Enter the d${sides} you rolled — a whole number from 1 to ${sides}.`);
@@ -805,7 +812,11 @@ function resolveSingleRoll(character, body, sides) {
}
function resolveAbilityRolls(character, body) {
if (character.diceMode !== 'PHYSICAL') {
// The standard array is not rolled at all: the same six scores for everyone.
if (character.campaign.abilityScoreMethod === 'STANDARD_ARRAY') {
return STANDARD_ARRAY.map((total) => ({ dice: [], dropped: null, total }));
}
if (effectiveDiceMode(character.campaign, character) !== 'PHYSICAL') {
return Array.from({ length: 6 }, () => rollAbilityScore());
}
const sets = body?.abilitySets;
@@ -826,6 +837,9 @@ router.patch('/:id/build/dice-mode', requireCharacterAccess({ write: true }), as
const parsed = z.object({ diceMode: z.enum(['DIGITAL', 'PHYSICAL']) }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (req.campaign.diceMode !== 'BOTH') {
return res.status(400).json({ error: `This campaign plays with ${req.campaign.diceMode === 'PHYSICAL' ? 'physical' : 'digital'} dice only.` });
}
const alreadyRolling = character.buildStage !== 'ROLLING_SPECIES' || Boolean((character.buildState || {}).speciesRoll);
if (alreadyRolling) {
return res.status(400).json({ error: 'Dice mode can only be chosen before the first roll of the build.' });
@@ -867,6 +881,9 @@ router.post('/:id/build/roll', requireCharacterAccess({ write: true }), async (r
if (buildState.abilityRolls) {
return res.status(400).json({ error: 'Ability scores were already rolled for this character.' });
}
if (req.campaign.abilityScoreMethod === 'POINT_BUY') {
return res.status(400).json({ error: 'This campaign uses point buy: nothing is rolled, the scores are bought.' });
}
buildState.abilityRolls = resolveAbilityRolls(character, req.body);
} else {
return res.status(400).json({ error: `No die roll is required at the "${stage}" step.` });
@@ -893,15 +910,20 @@ const chooseSchemas = {
// Motivation is story only, so it is simply picked from the list (the stage
// keeps its old name from when it was a die roll).
ROLLING_MOTIVATION: z.object({ motivation: z.number().int() }),
// Rolled scores (and the standard array) are assigned by index; point buy
// sends the six scores themselves.
ASSIGNING_SCORES: z.object({
assignments: z.object({
STR: z.number().int().min(0).max(5),
DEX: z.number().int().min(0).max(5),
CON: z.number().int().min(0).max(5),
INT: z.number().int().min(0).max(5),
WIS: z.number().int().min(0).max(5),
CHA: z.number().int().min(0).max(5),
}),
pointBuy: z.record(z.enum(ABILITIES), z.number().int().min(8).max(15)).optional(),
assignments: z
.object({
STR: z.number().int().min(0).max(5),
DEX: z.number().int().min(0).max(5),
CON: z.number().int().min(0).max(5),
INT: z.number().int().min(0).max(5),
WIS: z.number().int().min(0).max(5),
CHA: z.number().int().min(0).max(5),
})
.optional(),
}),
// Skill proficiencies are now auto-generated (see autoSelectSkills, applied
// when this stage is entered from ASSIGNING_SCORES below) — this payload is
@@ -1007,8 +1029,23 @@ router.post('/:id/build/choose', requireCharacterAccess({ write: true }), async
buildState.motivationChoice = motivation.key;
buildState.motivationText = motivation.label;
} else if (stage === 'ASSIGNING_SCORES') {
let { assignments } = parsed.data;
if (req.campaign.abilityScoreMethod === 'POINT_BUY') {
const scores = parsed.data.pointBuy;
if (!scores || ABILITIES.some((ability) => scores[ability] === undefined)) {
return res.status(400).json({ error: 'Buy a score for each of the six abilities.' });
}
const cost = pointBuyCost(scores);
if (cost === null || cost > POINT_BUY_BUDGET) {
return res.status(400).json({ error: `Those scores cost ${cost} points; you have ${POINT_BUY_BUDGET}.` });
}
// Kept the way rolled scores are, each assigned to its own ability.
buildState.abilityRolls = ABILITIES.map((ability) => ({ dice: [], dropped: null, total: scores[ability] }));
buildState.pointBuy = scores;
assignments = Object.fromEntries(ABILITIES.map((ability, i) => [ability, i]));
}
if (!buildState.abilityRolls) return res.status(400).json({ error: 'Roll ability scores first.' });
const { assignments } = parsed.data;
if (!assignments) return res.status(400).json({ error: 'Assign each score to an ability.' });
const usedIndexes = Object.values(assignments);
const uniqueIndexes = new Set(usedIndexes);
if (uniqueIndexes.size !== 6 || Math.max(...usedIndexes) > 5) {
+8 -2
View File
@@ -17,8 +17,10 @@ router.use(requireAuth);
async function requireEncounterAccess(req, res, next) {
try {
const encounter = await prisma.encounter.findUnique({ where: { id: req.params.id } });
if (!encounter) return res.status(404).json({ error: 'Encounter not found' });
const found = await prisma.encounter.findUnique({ where: { id: req.params.id }, include: { campaign: true } });
if (!found) return res.status(404).json({ error: 'Encounter not found' });
// The campaign rides along for its settings (req.campaign), not on the encounter.
const { campaign, ...encounter } = found;
const membership = await loadCampaignMembership(req.user.id, encounter.campaignId);
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
@@ -35,6 +37,7 @@ async function requireEncounterAccess(req, res, next) {
}
req.encounter = encounter;
req.campaign = campaign;
req.membership = membership;
req.isDM = isDM;
return next();
@@ -793,6 +796,9 @@ router.post('/:id/award-xp', requireEncounterAccess, requireDM, async (req, res,
try {
const parsed = awardXpSchema.safeParse(req.body || {});
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (!req.campaign.trackXp) {
return res.status(400).json({ error: 'This campaign does not keep experience points.' });
}
if (req.encounter.xpAwarded != null) {
return res.status(400).json({ error: `XP has already been awarded for this encounter (${req.encounter.xpAwarded}). Change a character's XP on their sheet.` });
}
+10
View File
@@ -180,6 +180,16 @@ const seedSchema = z.strictObject({
maxEquipped: z.number().int().min(1).max(20).optional(),
maxEquippedWeapons: z.number().int().min(0).max(20).optional(),
subclassesEnabled: z.boolean().optional(),
multiclassEnabled: z.boolean().optional(),
diceMode: z.enum(['DIGITAL', 'PHYSICAL', 'BOTH']).optional(),
autoRollMonsters: z.boolean().optional(),
autoRollNpcs: z.boolean().optional(),
trackXp: z.boolean().optional(),
hpOnLevelUp: z.enum(['AVERAGE', 'ROLL']).optional(),
abilityScoreMethod: z.enum(['ROLL_4D6', 'STANDARD_ARRAY', 'POINT_BUY']).optional(),
rollVisibility: z.enum(['ALL', 'OWN']).optional(),
hideMonsterRolls: z.boolean().optional(),
critRule: z.enum(['DOUBLE_DICE', 'MAX_PLUS_ROLL']).optional(),
}),
locations: z.array(z.strictObject({ name: text(80), description: optionalText(2000) })).default([]),
weapons: poolSchema,
+49
View File
@@ -0,0 +1,49 @@
# Campaign Settings
Each campaign has its own settings. The DM changes them on the campaign page
under **Campaign Settings → Edit**, and players read them but can't change
them. They are columns on the `Campaign` row (`backend/prisma/schema.prisma`),
returned with the campaign by `GET /campaigns/:id`, and changed with
`PATCH /campaigns/:id` (DM only). A world seed can set any of them
([SEEDS.md](SEEDS.md)).
Changing a setting never rewrites what already happened. For example,
turning XP tracking off keeps everyone's XP; it just stops showing it.
## Characters
| Setting | Default | What it does |
|---|---|---|
| `startingGold` | 0 | Gold every new character starts with. |
| `startingItemCount` | 0 | How many starting weapons and items (together) the builder lets a character take. |
| `startingSpellCount` | 0 | How many starting spells the builder lets a character take. |
| `abilityScoreMethod` | `ROLL_4D6` | How the builder makes the six scores: `ROLL_4D6` (roll 4d6 and drop the lowest, six times, then place them), `STANDARD_ARRAY` (place 15, 14, 13, 12, 10 and 8), or `POINT_BUY` (every score starts at 8 and is raised with 27 points, to 15 at most). |
| `skipMotivationRoll` | off | Leaves the motivation step out of the builder. |
| `subclassesEnabled` | on | Characters choose a subclass. Off: each class follows its first subclass, unasked and unnamed. |
| `multiclassEnabled` | off | On a level up, a character may take the level in another class whose ability requirements they meet. |
| `maxEquipped` / `maxEquippedWeapons` | 2 / 1 | How much a character can have equipped at once, and how much of it can be weapons. |
## Dice and rolls
| Setting | Default | What it does |
|---|---|---|
| `diceMode` | `BOTH` | `DIGITAL`: the app rolls every die. `PHYSICAL`: everyone rolls real dice and types in what they show (the server checks each value is on the die). `BOTH`: each character picks one or the other before their first build roll. |
| `rollVisibility` | `ALL` | `ALL`: everyone sees every roll. `OWN`: a player sees only their own rolls. The DM always sees every roll. |
| `hideMonsterRolls` | on | The DM's rolls for monsters and NPCs stay hidden from players until they are put into play. |
## Combat
| Setting | Default | What it does |
|---|---|---|
| `autoRollMonsters` | off | When a monster's turn comes up in a battle, the server picks its target, rolls the attack and the damage, and offers the result to the DM to accept, change or throw out. |
| `autoRollNpcs` | off | The same for NPCs. Friendly NPCs attack monsters; hostile ones attack the party. |
| `showMonsterHp` | on | Players see the exact HP of monsters and NPCs. Off: they see a health bar. |
| `critRule` | `DOUBLE_DICE` | What a critical hit does to damage. `DOUBLE_DICE`: the damage dice are rolled twice. `MAX_PLUS_ROLL`: the dice count at their maximum, plus one roll of them. |
| `areaAttackAcPenalty` | 2 | In an attack on several targets, each target's AC counts this much higher for every target after the first. 0 turns it off. |
## Progression
| Setting | Default | What it does |
|---|---|---|
| `trackXp` | on | Experience points are kept: on the sheets, awarded after encounters, with "level up due" shown. Off (milestone levelling): no XP is shown or awarded, and the DM decides when the party levels. |
| `hpOnLevelUp` | `AVERAGE` | How maximum HP grows on a level up. `AVERAGE`: the hit die's average, rounded up, plus Constitution. `ROLL`: a roll of the hit die plus Constitution. |
+15 -2
View File
@@ -68,7 +68,11 @@ first entry in its money log) and goes straight into the builder.
### Dice mode
Chosen once, before the first roll, and then locked:
The campaign's `diceMode` setting decides this
([CAMPAIGN_SETTINGS.md](CAMPAIGN_SETTINGS.md)). A campaign set to digital or
physical dice applies that to every character, and the builder doesn't ask.
A campaign set to **both** lets each character choose once, before their
first roll. After that the choice is locked:
| Mode | How rolls happen |
|---|---|
@@ -263,6 +267,13 @@ The six abilities are Strength, Dexterity, Constitution, Intelligence, Wisdom
and Charisma.
- The **rolled score** is what the dice gave (3–18). This is what is stored.
How it is made follows the campaign's `abilityScoreMethod`:
- **Roll 4d6** (the default): roll four d6, drop the lowest, and add the rest.
This is done six times, and the six totals are placed on the abilities.
- **Standard array**: the scores 15, 14, 13, 12, 10 and 8 are placed instead.
- **Point buy**: every score starts at 8. Raising a score costs 1 point per
step up to 13, and 2 points per step for 14 and 15. The budget is
27 points. The scores go straight to their abilities.
- The **final score** is the rolled score plus the species bonus. This is what
the sheet shows and what every calculation uses.
- The **modifier** is `(final score − 10) ÷ 2`, rounded down.
@@ -293,7 +304,9 @@ Ability scores cannot be changed after the build is finalized.
- **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
who levels by milestones can ignore XP altogether.
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.
| Level | XP needed | Level | XP needed | Level | XP needed | Level | XP needed |
|---|---|---|---|---|---|---|---|
+1 -1
View File
@@ -113,7 +113,7 @@ The file is ordinary JavaScript, so it can build its lists however it likes
| Field | What it is |
|---|---|
| `code` | Six capital letters or digits. What a DM types |
| `campaign` | The campaign's settings: `name`, and optionally `description`, `startingGold`, `startingItemCount`, `startingSpellCount`, `skipMotivationRoll`, `showMonsterHp`, `areaAttackAcPenalty`, `maxEquipped`, `maxEquippedWeapons`, `subclassesEnabled`. Anything left out takes the app's default |
| `campaign` | The campaign's settings: `name`, and optionally `description`, `startingGold`, `startingItemCount`, `startingSpellCount`, `skipMotivationRoll`, `showMonsterHp`, `areaAttackAcPenalty`, `maxEquipped`, `maxEquippedWeapons`, `subclassesEnabled`, `multiclassEnabled`, `diceMode`, `autoRollMonsters`, `autoRollNpcs`, `trackXp`, `hpOnLevelUp`, `abilityScoreMethod`, `rollVisibility`, `hideMonsterRolls`, `critRule`. Anything left out takes the app's default |
| `locations` | `{ name, description }` |
| `weapons`, `items`, `spells`, `monsters` | The pools. Each entry is a row or a library copy (below) |
| `npcs` | NPC rows, each optionally with `wares` |
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.21.0",
"version": "1.22.0",
"type": "module",
"scripts": {
"dev": "vite",
+19 -1
View File
@@ -40,6 +40,12 @@ export type BuildStage =
| 'COMPLETE';
export type DiceMode = 'DIGITAL' | 'PHYSICAL';
// The campaign's dice: BOTH leaves it to each character, and any roll can be typed in.
export type CampaignDiceMode = DiceMode | 'BOTH';
export type HpMethod = 'AVERAGE' | 'ROLL';
export type ScoreMethod = 'ROLL_4D6' | 'STANDARD_ARRAY' | 'POINT_BUY';
export type RollVisibility = 'ALL' | 'OWN';
export type CritRule = 'DOUBLE_DICE' | 'MAX_PLUS_ROLL';
// The fixed lists the builder's story steps are made of (served by the API so
// the page never keeps its own copy).
@@ -101,6 +107,17 @@ export interface Campaign {
// how many of those can be weapons. Only equipped gear is used in a fight.
maxEquipped: number;
maxEquippedWeapons: number;
multiclassEnabled: boolean;
diceMode: CampaignDiceMode;
// On: a monster's (or NPC's) turn comes with its attack already rolled, for the DM to accept or change.
autoRollMonsters: boolean;
autoRollNpcs: boolean;
trackXp: boolean; // off: no XP anywhere, nobody shown as due a level
hpOnLevelUp: HpMethod;
abilityScoreMethod: ScoreMethod;
rollVisibility: RollVisibility;
hideMonsterRolls: boolean; // the DM's monster and NPC rolls stay with the DM until put into play
critRule: CritRule;
ownerId: string;
archivedAt: string | null; // set while the campaign is archived
role: 'DM' | 'PLAYER';
@@ -417,7 +434,8 @@ export interface CharacterDetail extends CharacterCondition {
// Absent entirely (not null) in the API response for anyone but the
// owning player — a real server-enforced privacy boundary, not a UI hide.
privateNotes?: string | null;
diceMode: DiceMode;
diceMode: DiceMode; // the dice this character rolls with: the campaign's, or their own pick when the campaign leaves it to them
diceModeChoosable: boolean;
currentLocationId: string | null;
currentLocation: Location | null;
gold: number | null;
+20
View File
@@ -309,6 +309,12 @@ textarea {
font-size: 1rem;
}
.btn-sm {
padding: 0.2rem 0.6rem;
font-size: 0.85rem;
min-width: 2rem;
}
/* Big dice-roll button for the character builder */
.btn-roll {
font-family: var(--font-display);
@@ -397,6 +403,20 @@ input[type='radio'] {
font-size: 0.9rem;
}
/* A group of campaign settings under its own heading. */
.settings-group {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.6rem 0.9rem 0.9rem;
margin: 0 0 0.9rem;
}
.settings-group legend {
padding: 0 0.4rem;
font-weight: 600;
color: var(--accent-strong);
}
/* ---------------------------------------------------------------------- */
/* Badges / stat blocks */
/* ---------------------------------------------------------------------- */
+146 -62
View File
@@ -41,7 +41,7 @@ import { effectsSummary } from '../lib/gear';
import { extractErrorMessage, spellLevelLabel } from '../lib/format';
import { matchesSearch, type EntryKind, type EntryRef } from '../lib/campaignEntries';
import { speciesIcon } from '../lib/speciesIcons';
import type { CharacterSummary, Encounter, EncounterMode } from '../api/types';
import type { CampaignDiceMode, CharacterSummary, CritRule, Encounter, EncounterMode, HpMethod, RollVisibility, ScoreMethod } from '../api/types';
// How the encounter list can be ordered. "custom" is the campaign's own order,
// which the DM arranges; the rest only change what this browser shows.
@@ -787,79 +787,163 @@ export function CampaignHomePage() {
startingItemCount: Number(fd.get('startingItemCount')),
startingSpellCount: Number(fd.get('startingSpellCount')),
skipMotivationRoll: fd.get('skipMotivationRoll') === 'on',
abilityScoreMethod: fd.get('abilityScoreMethod') as ScoreMethod,
showMonsterHp: fd.get('showMonsterHp') === 'on',
subclassesEnabled: fd.get('subclassesEnabled') === 'on',
multiclassEnabled: fd.get('multiclassEnabled') === 'on',
areaAttackAcPenalty: Number(fd.get('areaAttackAcPenalty')),
maxEquipped: Number(fd.get('maxEquipped')),
maxEquippedWeapons: Number(fd.get('maxEquippedWeapons')),
diceMode: fd.get('diceMode') as CampaignDiceMode,
rollVisibility: fd.get('rollVisibility') as RollVisibility,
hideMonsterRolls: fd.get('hideMonsterRolls') === 'on',
autoRollMonsters: fd.get('autoRollMonsters') === 'on',
autoRollNpcs: fd.get('autoRollNpcs') === 'on',
critRule: fd.get('critRule') as CritRule,
trackXp: fd.get('trackXp') === 'on',
hpOnLevelUp: fd.get('hpOnLevelUp') as HpMethod,
});
}}
>
<div className="grid grid-cols-3">
<div className="field">
<label>Starting gold</label>
<NumberInput name="startingGold" min={0} max={1000000} keepValue defaultValue={campaign.startingGold} />
<fieldset className="settings-group">
<legend>Characters</legend>
<div className="grid grid-cols-3">
<div className="field">
<label>Starting gold</label>
<NumberInput name="startingGold" min={0} max={1000000} keepValue defaultValue={campaign.startingGold} />
</div>
<div className="field">
<label>Starting items (weapons + items)</label>
<NumberInput name="startingItemCount" min={0} max={50} keepValue defaultValue={campaign.startingItemCount} />
</div>
<div className="field">
<label>Starting spells</label>
<NumberInput name="startingSpellCount" min={0} max={50} keepValue defaultValue={campaign.startingSpellCount} />
</div>
</div>
<div className="field">
<label>Starting items (weapons + items)</label>
<NumberInput name="startingItemCount" min={0} max={50} keepValue defaultValue={campaign.startingItemCount} />
<label>Ability scores are made by</label>
<select name="abilityScoreMethod" defaultValue={campaign.abilityScoreMethod}>
<option value="ROLL_4D6">Rolling 4d6 and dropping the lowest, six times</option>
<option value="STANDARD_ARRAY">The standard array: 15, 14, 13, 12, 10, 8</option>
<option value="POINT_BUY">Point buy: 27 points, scores 8 to 15</option>
</select>
</div>
<div className="field">
<label>Starting spells</label>
<NumberInput name="startingSpellCount" min={0} max={50} keepValue defaultValue={campaign.startingSpellCount} />
<label className="checkbox-row">
<input type="checkbox" name="skipMotivationRoll" defaultChecked={campaign.skipMotivationRoll} />
Skip the motivation step when building a character
</label>
<label className="checkbox-row mt-sm">
<input type="checkbox" name="subclassesEnabled" defaultChecked={campaign.subclassesEnabled} />
Characters choose a subclass (untick and every class follows one path, with nothing to choose)
</label>
<label className="checkbox-row mt-sm">
<input type="checkbox" name="multiclassEnabled" defaultChecked={campaign.multiclassEnabled} />
Multiclassing: on a level up a character may take a level in another class whose ability requirements they meet
</label>
<label className="flex-row flex-wrap gap-sm mt-sm">
Equipped at once
<NumberInput
name="maxEquipped"
min={1}
max={20}
keepValue
defaultValue={campaign.maxEquipped}
style={{ width: 70 }}
title="How many weapons and items a character can have equipped together. Only equipped gear can be used in an encounter or gives its effects."
/>
things, of which
<NumberInput
name="maxEquippedWeapons"
min={0}
max={20}
keepValue
defaultValue={campaign.maxEquippedWeapons}
style={{ width: 70 }}
title="How many of those can be weapons. A weapon for each hand, like a pair of daggers, is made as one weapon."
/>
<span className="combatant-sub">
can be weapons. Armor and shields are items and count. Lowering these takes nothing off anyone; they are checked when
something is put on.
</span>
</label>
</fieldset>
<fieldset className="settings-group">
<legend>Dice &amp; rolls</legend>
<div className="grid grid-cols-2">
<div className="field">
<label>Dice at this table</label>
<select name="diceMode" defaultValue={campaign.diceMode}>
<option value="BOTH">Both: digital, or type in what a real die shows</option>
<option value="DIGITAL">Digital only: the app rolls</option>
<option value="PHYSICAL">Physical only: real dice, results typed in</option>
</select>
</div>
<div className="field">
<label>Who sees a roll</label>
<select name="rollVisibility" defaultValue={campaign.rollVisibility}>
<option value="ALL">Everyone sees every roll</option>
<option value="OWN">Players see their own; the DM sees all</option>
</select>
</div>
</div>
</div>
<label className="checkbox-row">
<input type="checkbox" name="skipMotivationRoll" defaultChecked={campaign.skipMotivationRoll} />
Skip the motivation step when building a character
</label>
<label className="checkbox-row mt-sm">
<input type="checkbox" name="showMonsterHp" defaultChecked={campaign.showMonsterHp} />
Players see the exact hit points of monsters and NPCs in encounters (untick for a health bar only)
</label>
<label className="checkbox-row mt-sm">
<input type="checkbox" name="subclassesEnabled" defaultChecked={campaign.subclassesEnabled} />
Characters choose a subclass (untick and every class follows one path, with nothing to choose)
</label>
<label className="flex-row flex-wrap gap-sm mt-sm">
Area attack AC penalty
<NumberInput
name="areaAttackAcPenalty"
min={0}
max={10}
keepValue
defaultValue={campaign.areaAttackAcPenalty}
style={{ width: 70 }}
title="An attack on several targets is harder to land: each target's AC counts as this much higher for every target beyond the first. 0 turns the penalty off."
/>
<span className="combatant-sub">added to each target's AC for every target beyond the first (0 for none)</span>
</label>
<label className="flex-row flex-wrap gap-sm mt-sm">
Equipped at once
<NumberInput
name="maxEquipped"
min={1}
max={20}
keepValue
defaultValue={campaign.maxEquipped}
style={{ width: 70 }}
title="How many weapons and items a character can have equipped together. Only equipped gear can be used in an encounter or gives its effects."
/>
things, of which
<NumberInput
name="maxEquippedWeapons"
min={0}
max={20}
keepValue
defaultValue={campaign.maxEquippedWeapons}
style={{ width: 70 }}
title="How many of those can be weapons. A weapon for each hand, like a pair of daggers, is made as one weapon."
/>
<span className="combatant-sub">
can be weapons. Armor and shields are items and count. Lowering these takes nothing off anyone; they are checked when
something is put on.
</span>
</label>
<label className="checkbox-row">
<input type="checkbox" name="hideMonsterRolls" defaultChecked={campaign.hideMonsterRolls} />
Keep my rolls for monsters and NPCs to myself until they are put into play
</label>
</fieldset>
<fieldset className="settings-group">
<legend>Combat</legend>
<label className="checkbox-row">
<input type="checkbox" name="autoRollMonsters" defaultChecked={campaign.autoRollMonsters} />
Auto-roll monster attacks: on a monster&apos;s turn its target, to-hit and damage are rolled for me to accept or change
</label>
<label className="checkbox-row mt-sm">
<input type="checkbox" name="autoRollNpcs" defaultChecked={campaign.autoRollNpcs} />
Auto-roll NPC attacks the same way (friendly NPCs attack monsters, hostile ones the party)
</label>
<label className="checkbox-row mt-sm">
<input type="checkbox" name="showMonsterHp" defaultChecked={campaign.showMonsterHp} />
Players see the exact hit points of monsters and NPCs in encounters (untick for a health bar only)
</label>
<div className="field mt-sm">
<label>A critical hit</label>
<select name="critRule" defaultValue={campaign.critRule}>
<option value="DOUBLE_DICE">Rolls the damage dice twice (the rule)</option>
<option value="MAX_PLUS_ROLL">Takes the dice at their maximum, plus one roll of them</option>
</select>
</div>
<label className="flex-row flex-wrap gap-sm mt-sm">
Area attack AC penalty
<NumberInput
name="areaAttackAcPenalty"
min={0}
max={10}
keepValue
defaultValue={campaign.areaAttackAcPenalty}
style={{ width: 70 }}
title="An attack on several targets is harder to land: each target's AC counts as this much higher for every target beyond the first. 0 turns the penalty off."
/>
<span className="combatant-sub">added to each target&apos;s AC for every target beyond the first (0 for none)</span>
</label>
</fieldset>
<fieldset className="settings-group">
<legend>Progression</legend>
<label className="checkbox-row">
<input type="checkbox" name="trackXp" defaultChecked={campaign.trackXp} />
Track experience points (untick for milestone levelling: no XP, and you decide when the party levels)
</label>
<div className="field mt-sm">
<label>Hit points on a level up</label>
<select name="hpOnLevelUp" defaultValue={campaign.hpOnLevelUp}>
<option value="AVERAGE">The hit die&apos;s average (rounded up) + Constitution</option>
<option value="ROLL">Roll the hit die + Constitution</option>
</select>
</div>
</fieldset>
<button type="submit" className="btn btn-primary mt-md" disabled={updateCampaign.isPending}>
Save Settings
</button>
+100 -4
View File
@@ -11,7 +11,7 @@ import { CLASS_SPELLCASTING_ABILITY, CLASS_HIT_DIE, CLASS_SAVE_PROFICIENCIES } f
import { ABILITY_GUIDE, CLASS_GUIDE, SKILL_GUIDE, SPECIES_GUIDE, SPECIES_SPEED, STEP_GUIDE, STEP_NAMES, hitDieToughness } from '../lib/guide';
import { speciesIcon } from '../lib/speciesIcons';
import { armorClass, hpMax, passivePerception, proficiencyBonus, saveBonus, spellAttackBonus, spellSaveDC } from '../lib/previewCalc';
import type { Ability, BuildOptions, CharacterClass, DiceMode, NamedAbility, PickGroup, Picks, Species } from '../api/types';
import type { Ability, BuildOptions, CharacterClass, DiceMode, NamedAbility, PickGroup, Picks, ScoreMethod, Species } from '../api/types';
interface SpeciesChoices {
type: 'choice' | 'wildcard';
@@ -21,9 +21,10 @@ interface ClassChoices {
type: 'choice' | 'wildcard' | 'reroll';
options: string[];
}
// A rolled score; the standard array and point buy come as scores alone, no dice.
interface AbilityRoll {
dice: number[];
dropped: number;
dropped: number | null;
total: number;
}
interface BuildState {
@@ -38,6 +39,7 @@ interface BuildState {
motivationText?: string | null;
abilityRolls?: AbilityRoll[];
abilityAssignments?: Record<Ability, number>;
pointBuy?: Record<Ability, number>;
skillProficiencies?: { skill: string; proficient: boolean; expertise: boolean }[];
motivationChoice?: number;
talents?: string[];
@@ -47,6 +49,10 @@ interface BuildState {
}
const ABILITIES: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
// Point buy, as the server checks it (game/buildFlow.js): every score starts
// at 8, and raising it to 15 at most spends from a budget of 27.
const POINT_BUY_BUDGET = 27;
const POINT_BUY_COSTS: Record<number, number> = { 8: 0, 9: 1, 10: 2, 11: 3, 12: 4, 13: 5, 14: 7, 15: 9 };
const HALF_ELF_ELIGIBLE: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS'];
const ALL_SKILLS = Object.keys(SKILL_LABELS);
// The six steps, in order, matching STEP_NAMES.
@@ -179,7 +185,8 @@ export function CharacterBuilderPage() {
}
}
const needsDiceModeChoice = stage === 'ROLLING_SPECIES' && !bs.speciesRoll && !diceModeConfirmed;
// Only asked when the campaign leaves the dice to each character.
const needsDiceModeChoice = character.diceModeChoosable && stage === 'ROLLING_SPECIES' && !bs.speciesRoll && !diceModeConfirmed;
// On its own step, the current pick is whatever was clicked, falling back to
// what was chosen before (when the player has come back to change it).
@@ -452,6 +459,8 @@ export function CharacterBuilderPage() {
<AssignScoresStep
bs={bs}
diceMode={diceMode}
method={campaignData?.campaign.abilityScoreMethod ?? 'ROLL_4D6'}
onPointBuy={(pointBuy) => safeChoose({ pointBuy })}
assignments={assignments}
setAssignments={setAssignments}
onRoll={safeRoll}
@@ -948,6 +957,8 @@ function BuildProgress({ stage }: { stage: string }) {
function AssignScoresStep({
bs,
diceMode,
method,
onPointBuy,
assignments,
setAssignments,
onRoll,
@@ -957,6 +968,8 @@ function AssignScoresStep({
}: {
bs: BuildState;
diceMode: DiceMode;
method: ScoreMethod;
onPointBuy: (scores: Record<Ability, number>) => void;
assignments: Partial<Record<Ability, number>>;
setAssignments: (a: Partial<Record<Ability, number>>) => void;
onRoll: (body?: { abilitySets?: number[][] }) => void;
@@ -964,6 +977,21 @@ function AssignScoresStep({
rolling: boolean;
submitting: boolean;
}) {
if (method === 'POINT_BUY') {
return <PointBuyStep bs={bs} onSubmit={onPointBuy} submitting={submitting} />;
}
if (!bs.abilityRolls && method === 'STANDARD_ARRAY') {
return (
<div className="card mt-md text-center">
<div className="card-title">Step 4 — Ability Scores</div>
<p className="step-intro">{STEP_GUIDE.SCORES}</p>
<p>This campaign uses the standard array: the six scores 15, 14, 13, 12, 10 and 8, for you to place.</p>
<button className="btn btn-primary" onClick={() => onRoll()} disabled={rolling}>
Use the standard array
</button>
</div>
);
}
if (!bs.abilityRolls) {
if (diceMode === 'PHYSICAL') {
return <PhysicalAbilityRollForm onRoll={onRoll} rolling={rolling} />;
@@ -1017,7 +1045,7 @@ function AssignScoresStep({
<div className="flex-row flex-wrap gap-sm mt-sm">
{bs.abilityRolls.map((r, i) => (
<span key={i} className="badge" style={usedIndexes.has(i) ? { opacity: 0.4 } : { borderColor: 'var(--accent)' }}>
{r.dice.join('+')} (drop {r.dropped}) = {r.total}
{r.dice.length > 0 ? `${r.dice.join('+')} (drop ${r.dropped}) = ${r.total}` : r.total}
</span>
))}
</div>
@@ -1076,6 +1104,74 @@ function AssignScoresStep({
);
}
// Point buy: each ability starts at 8 and is raised with points from the
// budget. The scores go straight to their abilities; nothing is rolled.
function PointBuyStep({ bs, onSubmit, submitting }: { bs: BuildState; onSubmit: (scores: Record<Ability, number>) => void; submitting: boolean }) {
const [scores, setScores] = useState<Record<Ability, number>>(() => {
const bought = bs.pointBuy;
return Object.fromEntries(ABILITIES.map((a) => [a, bought?.[a] ?? 8])) as Record<Ability, number>;
});
const spent = ABILITIES.reduce((sum, a) => sum + POINT_BUY_COSTS[scores[a]], 0);
const left = POINT_BUY_BUDGET - spent;
const species = bs.species ?? null;
const keyAbilities = bs.characterClass ? CLASS_GUIDE[bs.characterClass].keyAbilities : [];
const canRaise = (a: Ability) => scores[a] < 15 && POINT_BUY_COSTS[scores[a] + 1] - POINT_BUY_COSTS[scores[a]] <= left;
return (
<div className="card mt-md">
<div className="card-title">
<span>Step 4 — Buy Ability Scores</span>
<span className={`badge${left < 0 ? ' badge-hostile' : ''}`}>
{left} of {POINT_BUY_BUDGET} points left
</span>
</div>
<p className="step-intro">
Every score starts at 8. Raising one costs a point a step up to 13, and two a step for 14 and 15. Spend up to {POINT_BUY_BUDGET}.
</p>
<div className="grid grid-cols-3 mt-md">
{ABILITIES.map((ability) => {
const bonus = speciesBonusFor(species, ability, bs.halfElfBonusAbilities || []);
const final = scores[ability] + bonus;
return (
<div className={`stat-block${keyAbilities.includes(ability) ? ' is-key' : ''}`} key={ability}>
<span className="stat-label">{ABILITY_LABELS[ability]}</span>
<span className="stat-sub">{ABILITY_GUIDE[ability].short}</span>
{keyAbilities.includes(ability) && <span className="key-marker">★ Key for your class</span>}
<div className="flex-row gap-sm" style={{ justifyContent: 'center' }}>
<button
className="btn btn-sm"
aria-label={`Lower ${ABILITY_LABELS[ability]}`}
disabled={scores[ability] <= 8}
onClick={() => setScores({ ...scores, [ability]: scores[ability] - 1 })}
>
−
</button>
<span className="stat-value">{scores[ability]}</span>
<button
className="btn btn-sm"
aria-label={`Raise ${ABILITY_LABELS[ability]}`}
disabled={!canRaise(ability)}
onClick={() => setScores({ ...scores, [ability]: scores[ability] + 1 })}
>
+
</button>
</div>
<span className="stat-sub">
{scores[ability]} {bonus ? `+${bonus}` : ''} = {final} ({fmtMod(abilityModifier(final))})
</span>
</div>
);
})}
</div>
<StepFooter
hint={left > 0 ? `You still have ${left} point${left === 1 ? '' : 's'} to spend (or keep them).` : 'All points spent. Press Next.'}
disabled={left < 0 || submitting}
onNext={() => onSubmit(scores)}
/>
</div>
);
}
function PhysicalAbilityRollForm({ onRoll, rolling }: { onRoll: (body?: { abilitySets?: number[][] }) => void; rolling: boolean }) {
const [sets, setSets] = useState<string[][]>(Array.from({ length: 6 }, () => ['', '', '', '']));
+7 -2
View File
@@ -250,6 +250,8 @@ export function CharacterSheetPage() {
useCampaignSocket(character?.campaignId);
const { data: campaignData } = useCampaign(character?.campaignId);
// Off for the campaign: no XP is kept, so none is shown.
const trackXp = campaignData?.campaign.trackXp ?? true;
const { data: campaignWeapons } = useCampaignWeapons(character?.campaignId);
const { data: campaignSpells } = useCampaignSpells(character?.campaignId);
const { data: campaignItems } = useCampaignItems(character?.campaignId);
@@ -429,8 +431,9 @@ export function CharacterSheetPage() {
</h1>
<p className="subtitle">
{character.species && SPECIES_LABELS[character.species]} {classLabel(character.characterClass, character.subclassName)} · Level{' '}
{character.level} · {xpLabel(character.xp, character.xpProgress)}
{character.xpProgress.levelUpDue && (
{character.level}
{trackXp && ` · ${xpLabel(character.xp, character.xpProgress)}`}
{trackXp && character.xpProgress.levelUpDue && (
<span className="badge badge-live" style={{ marginLeft: 6 }} title="They have the XP for the next level. Levelling up is the DM's to do.">
⬆ Level up due
</span>
@@ -584,6 +587,7 @@ export function CharacterSheetPage() {
</select>
</label>
)}
{trackXp && (
<label className="flex-row gap-sm" title="Experience points. An encounter's XP is added here when you award it. Enough for the next level marks the level as due; it does not level the character up.">
XP
<NumberInput
@@ -596,6 +600,7 @@ export function CharacterSheetPage() {
onBlur={(e) => Number(e.target.value) !== character.xp && patchCharacter.mutate({ xp: Math.max(0, Math.floor(Number(e.target.value)) || 0) })}
/>
</label>
)}
<button
className="btn btn-primary"
onClick={() => setLevellingUp(true)}
+1 -1
View File
@@ -1664,7 +1664,7 @@ export function EncounterPage() {
onError={onError}
/>
{isDM && encounter.difficulty && (
{isDM && encounter.difficulty && campaignData?.campaign.trackXp && (
<ExperiencePanel
encounterId={encounterId}
difficulty={encounter.difficulty}
+1 -1
View File
@@ -125,7 +125,7 @@ export function PartyOverviewPage() {
<td data-label="Class">{c.characterClass ? classLabel(c.characterClass, c.subclassName) : inProgress ? 'Building…' : '—'}</td>
<td data-label="Level">
{c.level}
{!inProgress && (
{!inProgress && data?.campaign.trackXp && (
<div className="combatant-sub mono" style={{ whiteSpace: 'nowrap' }}>
{xpLabel(c.xp, c.xpProgress)}
{c.xpProgress.levelUpDue && (
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.21.0",
"version": "1.22.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.21.0",
"version": "1.22.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.21.0",
"version": "1.22.0",
"dependencies": {
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
@@ -33,7 +33,7 @@
}
},
"frontend": {
"version": "1.21.0",
"version": "1.22.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.21.0",
"version": "1.22.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [