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>
This commit is contained in:
2026-10-11 15:12:05 +02:00
co-authored by Claude Opus 5.5
parent 57fba68a48
commit e7b6e0065a
24 changed files with 697 additions and 96 deletions
+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 && (