Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7b6e0065a |
@@ -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,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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.` });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.21.0",
|
||||
"version": "1.22.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
@@ -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 & 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'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'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'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>
|
||||
|
||||
@@ -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 }, () => ['', '', '', '']));
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -1664,7 +1664,7 @@ export function EncounterPage() {
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
{isDM && encounter.difficulty && (
|
||||
{isDM && encounter.difficulty && campaignData?.campaign.trackXp && (
|
||||
<ExperiencePanel
|
||||
encounterId={encounterId}
|
||||
difficulty={encounter.difficulty}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Generated
+4
-4
@@ -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
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user