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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user