Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbda10668b | ||
|
|
16b5ac05ca | ||
|
|
9d0a4a2252 | ||
|
|
555765916f |
@@ -99,6 +99,22 @@ including a character sheet or an encounter) and **Account**.
|
||||
checklist at level 2 or 3, and shown beside the class everywhere; the DM can
|
||||
change it from the sheet, and switch a 2014 path to its 2024 update. Every
|
||||
sheet shows **the road ahead**: what each level from 1 to 20 gives.
|
||||
- **Class features that change a number are worked out**: Unarmored Defense
|
||||
(barbarian, monk), Draconic Resilience, Fast Movement, Unarmored Movement,
|
||||
Aura of Protection, Diamond Soul, Slippery Mind and Jack of All Trades are
|
||||
counted on the sheet, by what is equipped (armor and shields are marked
|
||||
items) and by level in the class that gives them.
|
||||
- **Armor holds Dexterity back** as in the rules: all of it counts toward AC
|
||||
in light armor, at most +2 in medium, none in heavy.
|
||||
- **Stricter battles.** A spell with no slot of its level or higher left
|
||||
cannot be cast (cantrips never need one), and a player changes the weapon
|
||||
in hand only as the battle begins or on their own turn after attacking.
|
||||
- **Free roam**, a third kind of encounter beside battles and quests: the DM
|
||||
ticks the nearby locations and adds the characters who are there, and
|
||||
each player sends their own character to a place to meet who is found there
|
||||
and shop (a place's own shop, or an NPC's wares) where they stand.
|
||||
- **Completed encounters are hidden** from the campaign's list until "Show
|
||||
completed" is ticked.
|
||||
- **Faster battles.** A colour-coded **turn-order track** (PCs blue, NPCs
|
||||
green, monsters red) shows this round, a 🏁 flag where it ends, and the
|
||||
next. Click a name (or press 1–9) to **target**. Whoever is on turn picks a
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.27.0",
|
||||
"version": "1.31.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Free roam: a kind of encounter where the party wanders between places the
|
||||
-- DM has laid out. A location can have NPCs found there and things for sale.
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "EncounterMode" ADD VALUE 'FREE_ROAM';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "npcs" ADD COLUMN "locationId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "sale_offers" ADD COLUMN "locationId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "encounter_locations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"encounterId" TEXT NOT NULL,
|
||||
"locationId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "encounter_locations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "encounter_locations_encounterId_locationId_key" ON "encounter_locations"("encounterId", "locationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sale_offers_locationId_idx" ON "sale_offers"("locationId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "npcs" ADD CONSTRAINT "npcs_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_locations" ADD CONSTRAINT "encounter_locations_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_locations" ADD CONSTRAINT "encounter_locations_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Armor and shields say what kind they are, so the sheet can tell when a
|
||||
-- character is unarmored (Unarmored Defense, a monk's speed) or in heavy
|
||||
-- armor (a barbarian's Fast Movement). Copies of the library's armor already
|
||||
-- in a campaign are marked by name; anything else the DM marks on the item.
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ArmorType" AS ENUM ('LIGHT', 'MEDIUM', 'HEAVY', 'SHIELD');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "items" ADD COLUMN "armorType" "ArmorType";
|
||||
|
||||
UPDATE "items" SET "armorType" = 'LIGHT' WHERE "name" IN ('Padded Armor', 'Leather Armor', 'Studded Leather');
|
||||
UPDATE "items" SET "armorType" = 'MEDIUM' WHERE "name" IN ('Hide Armor', 'Chain Shirt', 'Scale Mail', 'Breastplate', 'Half Plate');
|
||||
UPDATE "items" SET "armorType" = 'HEAVY' WHERE "name" IN ('Ring Mail', 'Chain Mail', 'Splint Armor', 'Plate Armor');
|
||||
UPDATE "items" SET "armorType" = 'SHIELD' WHERE "name" = 'Shield';
|
||||
@@ -134,11 +134,13 @@ enum EncounterStatus {
|
||||
COMPLETE
|
||||
}
|
||||
|
||||
// What kind of scene an encounter is: a fight run in initiative order, or a
|
||||
// quest scene built around NPCs and an objective.
|
||||
// What kind of scene an encounter is: a fight run in initiative order, a
|
||||
// quest scene built around NPCs and an objective, or free roam, where the
|
||||
// party wanders between places the DM has laid out (to shop, to meet people).
|
||||
enum EncounterMode {
|
||||
BATTLE
|
||||
QUEST
|
||||
FREE_ROAM
|
||||
}
|
||||
|
||||
enum NpcDisposition {
|
||||
@@ -335,6 +337,10 @@ model Location {
|
||||
characters Character[]
|
||||
encounters Encounter[]
|
||||
visits CharacterLocationVisit[]
|
||||
// Who is found here, what is for sale here, and the free-roam scenes it is a stop in.
|
||||
npcs Npc[]
|
||||
wares SaleOffer[]
|
||||
freeRoams EncounterLocation[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("locations")
|
||||
@@ -591,6 +597,10 @@ model Item {
|
||||
// item with an AC effect.
|
||||
effects Json @default("[]")
|
||||
effectNote String?
|
||||
// Set on armor and shields. Its AC is still an effect; this says what kind
|
||||
// it is, so the sheet knows when a character is unarmored (Unarmored
|
||||
// Defense, a monk's speed) or in heavy armor (a barbarian's Fast Movement).
|
||||
armorType ArmorType?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
@@ -602,6 +612,13 @@ model Item {
|
||||
@@map("items")
|
||||
}
|
||||
|
||||
enum ArmorType {
|
||||
LIGHT
|
||||
MEDIUM
|
||||
HEAVY
|
||||
SHIELD
|
||||
}
|
||||
|
||||
// An Item from the campaign pool held by a character.
|
||||
model InventoryItem {
|
||||
id String @id @default(cuid())
|
||||
@@ -737,9 +754,13 @@ model Npc {
|
||||
// for exchangeCash cash, either way. Both null: this NPC does not exchange.
|
||||
exchangeGold Int?
|
||||
exchangeCash Int?
|
||||
// Where this NPC is usually found. In a free-roam scene they are met (and
|
||||
// traded with) by whoever goes there.
|
||||
locationId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||
encounterAppearances EncounterParticipant[]
|
||||
wares SaleOffer[]
|
||||
|
||||
@@ -782,11 +803,28 @@ model Encounter {
|
||||
rewards EncounterReward[]
|
||||
wares SaleOffer[]
|
||||
actions EncounterAction[]
|
||||
// Free roam: the places the party can go to in this scene.
|
||||
places EncounterLocation[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("encounters")
|
||||
}
|
||||
|
||||
// A place that is a stop in a free-roam scene: one of the campaign's
|
||||
// locations, picked by the DM as somewhere the party can go right now.
|
||||
model EncounterLocation {
|
||||
id String @id @default(cuid())
|
||||
encounterId String
|
||||
locationId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||||
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([encounterId, locationId])
|
||||
@@map("encounter_locations")
|
||||
}
|
||||
|
||||
// An attack or a spell in a battle, from the moment it is declared to the
|
||||
// moment its damage lands (game/combat.js). A player declares their own on
|
||||
// their turn; the DM declares anyone's; and with the campaign's auto-roll
|
||||
@@ -851,15 +889,17 @@ model EncounterReward {
|
||||
}
|
||||
|
||||
// Something for sale: a weapon or item from the campaign pools, at a price.
|
||||
// It belongs to an NPC (and is on offer wherever that NPC is met) or to an
|
||||
// encounter itself (a stall, a vending machine). Only the DM sets these up;
|
||||
// players see them, and buy, in a quest encounter in progress
|
||||
// (encounters.routes.js).
|
||||
// It belongs to an NPC (and is on offer wherever that NPC is met), to an
|
||||
// encounter itself (a stall, a vending machine), or to a location (a shop
|
||||
// that is always there, open to whoever goes to it in a free-roam scene).
|
||||
// Only the DM sets these up; players see them, and buy, in a quest or
|
||||
// free-roam encounter in progress (encounters.routes.js).
|
||||
model SaleOffer {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
npcId String? // exactly one of npcId / encounterId is set
|
||||
npcId String? // exactly one of npcId / encounterId / locationId is set
|
||||
encounterId String?
|
||||
locationId String?
|
||||
weaponId String? // exactly one of weaponId / itemId is set
|
||||
itemId String?
|
||||
price Int // for one, in `currency`
|
||||
@@ -871,12 +911,14 @@ model SaleOffer {
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
npc Npc? @relation(fields: [npcId], references: [id], onDelete: Cascade)
|
||||
encounter Encounter? @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||
weapon Weapon? @relation(fields: [weaponId], references: [id], onDelete: Cascade)
|
||||
item Item? @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([campaignId])
|
||||
@@index([npcId])
|
||||
@@index([encounterId])
|
||||
@@index([locationId])
|
||||
@@map("sale_offers")
|
||||
}
|
||||
|
||||
|
||||
@@ -187,8 +187,8 @@ const items = [
|
||||
{ name: 'Torches (bundle of 5)', description: 'Each burns for an hour: bright light for 20 feet, dim for 20 more.', isStartingOption: true },
|
||||
{ name: 'Healer’s Kit', description: 'Ten uses. Spend one to stabilise a creature at 0 hit points without a Medicine check.', isStartingOption: true },
|
||||
{ name: 'Thieves’ Tools', description: 'Picks, a small file and a mirror on a handle. Needed to pick a lock or disarm a trap.', isStartingOption: true },
|
||||
{ name: 'Shield', description: 'A wooden or steel shield carried on one arm.', isStartingOption: true, effects: [{ stat: 'AC', amount: 2 }], effectNote: 'Takes a hand: no two-handed weapon while it is up.' },
|
||||
{ name: 'Leather Armor', description: 'Boiled leather, light enough to sleep and sneak in.', isStartingOption: true, effects: [{ stat: 'AC', amount: 1 }] },
|
||||
{ name: 'Shield', armorType: 'SHIELD', description: 'A wooden or steel shield carried on one arm.', isStartingOption: true, effects: [{ stat: 'AC', amount: 2 }], effectNote: 'Takes a hand: no two-handed weapon while it is up.' },
|
||||
{ name: 'Leather Armor', armorType: 'LIGHT', description: 'Boiled leather, light enough to sleep and sneak in.', isStartingOption: true, effects: [{ stat: 'AC', amount: 1 }] },
|
||||
{ name: 'Spellcasting Focus', description: 'A wand, holy symbol, crystal or carved staff that your spells are cast through.', isStartingOption: true },
|
||||
{ name: 'Founding Fair Token', description: 'A stamped brass token from the Lantern Market. Good for one drink at any stall, or a small favour from whoever ran the game you won it at.', isStartingOption: true },
|
||||
|
||||
@@ -212,7 +212,7 @@ const items = [
|
||||
{ name: 'Seal-Stone Fragment', description: 'A shard of worked stone from the edge of the breach. Script runs along one face. It is warm, and holding it makes the bell script easier to look at.' },
|
||||
{ name: 'Seized House Documents', description: 'Ledgers and letters taken from the noble house after it was confronted. No value in coin; a great deal of value to anyone building a case.' },
|
||||
{ name: 'House Records', description: 'The fallen house’s private records: names, payments and meeting places for its contacts across Aldenfell.' },
|
||||
{ name: 'Masterwork Chain Mail', description: 'Close-linked mail from the Grey Dawn’s own armoury, fitted rather than issued.', minLevel: 5, effects: [{ stat: 'AC', amount: 7 }, { stat: 'SPEED', amount: -10 }], effectNote: 'Disadvantage on Stealth checks.' },
|
||||
{ name: 'Masterwork Chain Mail', armorType: 'HEAVY', description: 'Close-linked mail from the Grey Dawn’s own armoury, fitted rather than issued.', minLevel: 5, effects: [{ stat: 'AC', amount: 7 }, { stat: 'SPEED', amount: -10 }], effectNote: 'Disadvantage on Stealth checks.' },
|
||||
{ name: 'The Seal-Stone', description: 'The stone at the heart of the seal, whole again and quiet. It is heavier than it looks, and it is never quite cold.', minLevel: 7, effects: [{ stat: 'SAVE_WIS', amount: 2 }], effectNote: 'Grows warm near anything of the Hollow Below. Its other properties are the DM’s to decide: something tied to binding, warding or detecting.' },
|
||||
];
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -73,6 +73,15 @@ function armorClass(dexMod, armorBonus = 0) {
|
||||
return 10 + dexMod + armorBonus;
|
||||
}
|
||||
|
||||
// How much of a Dexterity modifier counts toward AC in the armor worn: all
|
||||
// of it in light armor or none, at most +2 in medium armor, and none of it
|
||||
// (a penalty included) in heavy armor.
|
||||
function armorDexterity(dexMod, worn = null) {
|
||||
if (worn === 'HEAVY') return 0;
|
||||
if (worn === 'MEDIUM') return Math.min(dexMod, 2);
|
||||
return dexMod;
|
||||
}
|
||||
|
||||
function initiative(dexMod) {
|
||||
return dexMod;
|
||||
}
|
||||
@@ -287,6 +296,12 @@ function activeGear(character) {
|
||||
|
||||
const effects = [];
|
||||
const notes = [];
|
||||
// The armor on their back (the heaviest, should there be two) and whether
|
||||
// a shield is on their arm: class features turn on what is worn.
|
||||
const kinds = held.map((h) => h.entry.armorType).filter(Boolean);
|
||||
const worn = ['HEAVY', 'MEDIUM', 'LIGHT'].find((kind) => kinds.includes(kind)) || null;
|
||||
const suit = worn ? held.find((h) => h.entry.armorType === worn) : null;
|
||||
const armor = { worn, name: suit ? suit.entry.name : null, shield: kinds.includes('SHIELD') };
|
||||
for (const { entry } of held) {
|
||||
for (const effect of Array.isArray(entry.effects) ? entry.effects : []) {
|
||||
if (GEAR_STATS.includes(effect.stat) && Number.isInteger(effect.amount) && effect.amount !== 0) {
|
||||
@@ -295,7 +310,7 @@ function activeGear(character) {
|
||||
}
|
||||
if (entry.effectNote) notes.push({ source: entry.name, note: entry.effectNote });
|
||||
}
|
||||
return { effects, notes };
|
||||
return { effects, notes, armor };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -347,11 +362,30 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
|
||||
const profBonus = proficiencyBonus(level);
|
||||
|
||||
// Every class the character has levels in, the first first. One entry for
|
||||
// most characters; a multiclassed one adds up per class (multiclass.js).
|
||||
const classes = multiclass.classLevels(character);
|
||||
const multi = classes.length > 1;
|
||||
// What their class features do to the numbers below (classFeatures.js).
|
||||
const modifiers = Object.fromEntries(ABILITIES.map((ability) => [ability, abilities[ability].modifier]));
|
||||
const features = classFeatureEffects(classes, modifiers, profBonus, gear.armor);
|
||||
// Medium and heavy armor hold Dexterity back; said on the gear card when it bites.
|
||||
const acDex = armorDexterity(abilities.DEX.modifier, gear.armor.worn);
|
||||
if (acDex !== abilities.DEX.modifier) {
|
||||
gear.notes.push({
|
||||
source: gear.armor.name,
|
||||
note: gear.armor.worn === 'HEAVY' ? 'Heavy armor: Dexterity does not count toward AC.' : 'Medium armor: at most +2 from Dexterity counts toward AC.',
|
||||
});
|
||||
}
|
||||
|
||||
const saves = {};
|
||||
for (const ability of ABILITIES) {
|
||||
const entry = saveProficiencies.find((s) => s.ability === ability);
|
||||
const proficient = entry ? entry.proficient : false;
|
||||
saves[ability] = { proficient, bonus: saveBonus(abilities[ability].modifier, proficient, profBonus) + fromGear('SAVES', `SAVE_${ability}`) };
|
||||
const proficient = (entry ? entry.proficient : false) || features.saveProficiencies.includes(ability);
|
||||
saves[ability] = {
|
||||
proficient,
|
||||
bonus: saveBonus(abilities[ability].modifier, proficient, profBonus) + features.saveBonus + fromGear('SAVES', `SAVE_${ability}`),
|
||||
};
|
||||
}
|
||||
|
||||
const skills = {};
|
||||
@@ -364,14 +398,10 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
ability,
|
||||
proficient,
|
||||
expertise,
|
||||
bonus: skillBonus(abilities[ability].modifier, proficient, profBonus, expertise) + fromGear('SKILLS', `SKILL_${skillKey}`),
|
||||
bonus: skillBonus(abilities[ability].modifier, proficient, profBonus, expertise) + (proficient ? 0 : features.halfProficiency) + fromGear('SKILLS', `SKILL_${skillKey}`),
|
||||
};
|
||||
}
|
||||
|
||||
// Every class the character has levels in, the first first. One entry for
|
||||
// most characters; a multiclassed one adds up per class (multiclass.js).
|
||||
const classes = multiclass.classLevels(character);
|
||||
const multi = classes.length > 1;
|
||||
const hitDieSize = getClassHitDieSize(characterClass);
|
||||
const casters = classes
|
||||
.map((entry) => ({ ...entry, ability: getClassSpellcastingAbility(entry.characterClass, entry.subclass) }))
|
||||
@@ -386,14 +416,17 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
return {
|
||||
abilities,
|
||||
proficiencyBonus: profBonus,
|
||||
armorClass: armorClass(abilities.DEX.modifier, armorBonus) + fromGear('AC'),
|
||||
initiative: initiative(abilities.DEX.modifier) + fromGear('INITIATIVE'),
|
||||
speed: Math.max(0, effectiveSpeed(species, speedOverride) + fromGear('SPEED')),
|
||||
// Unarmored Defense and the like replace the 10 + DEX that AC starts from.
|
||||
armorClass: (features.acBase !== null ? features.acBase + armorBonus : armorClass(acDex, armorBonus)) + fromGear('AC'),
|
||||
initiative: initiative(abilities.DEX.modifier) + features.halfProficiency + fromGear('INITIATIVE'),
|
||||
// A speed the DM has set is their last word: a class's extra speed is
|
||||
// added to the species' own speed only.
|
||||
speed: Math.max(0, effectiveSpeed(species, speedOverride) + (speedOverride != null ? 0 : features.speed) + fromGear('SPEED')),
|
||||
hp: {
|
||||
// hpRollAdjust: how far HP rolled on level-ups is from the average counted here.
|
||||
max: Math.max(
|
||||
1,
|
||||
(multi ? multiclass.multiclassHpMax(classes, abilities.CON.modifier) : hpMax(level, hitDieSize, abilities.CON.modifier)) + (Number(hpRollAdjust) || 0) + fromGear('MAX_HP')
|
||||
(multi ? multiclass.multiclassHpMax(classes, abilities.CON.modifier) : hpMax(level, hitDieSize, abilities.CON.modifier)) + (Number(hpRollAdjust) || 0) + features.hp + fromGear('MAX_HP')
|
||||
),
|
||||
},
|
||||
hitDice: multi ? multiclass.multiclassHitDice(classes, hitDiceUsed) : hitDiceState(characterClass, level, hitDiceUsed),
|
||||
@@ -404,6 +437,8 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
passiveInsight: passiveScore(skills.INSIGHT.bonus),
|
||||
// What equipped gear is doing to the numbers above, and what else it does.
|
||||
gear,
|
||||
// The class features counted in the numbers above: [{ name, source, text }].
|
||||
features: speedOverride != null ? features.inForce.filter((f) => !['Fast Movement', 'Unarmored Movement'].includes(f.name)) : features.inForce,
|
||||
spellcasting: spellcastingAbility ? casting(spellcastingAbility) : null,
|
||||
// A multiclassed caster's DC and attack for each class that casts.
|
||||
spellcastingByClass: casters.map((entry) => ({ characterClass: entry.characterClass, ...casting(entry.ability) })),
|
||||
@@ -549,6 +584,7 @@ module.exports = {
|
||||
passivePerception,
|
||||
passiveScore,
|
||||
armorClass,
|
||||
armorDexterity,
|
||||
initiative,
|
||||
hpMax,
|
||||
spellSaveDC,
|
||||
@@ -586,3 +622,4 @@ module.exports = {
|
||||
// multiclass.js and this file use each other, so it is required here, at
|
||||
// the end, and its functions are looked up when called.
|
||||
const multiclass = require('./multiclass');
|
||||
const { classFeatureEffects } = require('./classFeatures');
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// The class features that change a number on the sheet, worked out so nobody
|
||||
// has to add them by hand: Unarmored Defense, Draconic Resilience, Fast
|
||||
// Movement, Unarmored Movement, Aura of Protection, Diamond Soul, Slippery
|
||||
// Mind and Jack of All Trades. Everything else a class gives is a line of
|
||||
// text on the sheet (game/data/levelFeatures.js). Pure: no database.
|
||||
//
|
||||
// Each goes by the level in the class that gives it, so a multiclassed
|
||||
// character gets what each of their classes has earned (game/multiclass.js,
|
||||
// classLevels). Several depend on what is worn: armor and shields are items
|
||||
// marked with what kind they are (Item.armorType), and count while equipped.
|
||||
|
||||
const { SUBCLASS_LEVEL } = require('./data/subclasses');
|
||||
|
||||
// A monk's extra speed without armor or a shield, by monk level.
|
||||
function unarmoredMovement(monkLevel) {
|
||||
if (monkLevel < 2) return 0;
|
||||
if (monkLevel < 6) return 10;
|
||||
if (monkLevel < 10) return 15;
|
||||
if (monkLevel < 14) return 20;
|
||||
if (monkLevel < 18) return 25;
|
||||
return 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a character's class features do to the sheet's numbers.
|
||||
*
|
||||
* @param {Array<{characterClass: string, level: number, subclass: string|null}>} classes
|
||||
* @param {object} modifiers - ability modifiers by ability: { STR, DEX, CON, INT, WIS, CHA }
|
||||
* @param {number} profBonus
|
||||
* @param {{worn: 'LIGHT'|'MEDIUM'|'HEAVY'|null, shield: boolean}} armor - what is equipped
|
||||
* @returns {{
|
||||
* acBase: number|null, // what AC starts from in place of 10 + DEX, when that is better
|
||||
* speed: number, // added to walking speed
|
||||
* hp: number, // added to maximum HP
|
||||
* saveBonus: number, // added to every saving throw
|
||||
* saveProficiencies: string[],// saves the character is proficient in on top of their class's two
|
||||
* halfProficiency: number, // added to initiative and to skills they are not proficient in
|
||||
* inForce: Array<{name: string, source: string, text: string}>
|
||||
* }}
|
||||
*/
|
||||
function classFeatureEffects(classes, modifiers, profBonus, armor = { worn: null, shield: false }) {
|
||||
const levelIn = (characterClass) => {
|
||||
const entry = classes.find((c) => c.characterClass === characterClass);
|
||||
return entry ? entry.level : 0;
|
||||
};
|
||||
const unarmored = !armor.worn;
|
||||
const plain = 10 + modifiers.DEX;
|
||||
const inForce = [];
|
||||
const signed = (n) => (n >= 0 ? `+${n}` : `${n}`);
|
||||
|
||||
// Armor Class without armor: the best of the ways the character has of
|
||||
// working it out, and only when it beats 10 + DEX.
|
||||
const acWays = [];
|
||||
if (levelIn('BARBARIAN') >= 1 && unarmored) {
|
||||
acWays.push({ ac: plain + modifiers.CON, name: 'Unarmored Defense', source: 'Barbarian', text: `AC is 10 + Dexterity + Constitution (${signed(modifiers.CON)}) without armor; a shield still adds.` });
|
||||
}
|
||||
if (levelIn('MONK') >= 1 && unarmored && !armor.shield) {
|
||||
acWays.push({ ac: plain + modifiers.WIS, name: 'Unarmored Defense', source: 'Monk', text: `AC is 10 + Dexterity + Wisdom (${signed(modifiers.WIS)}) without armor or a shield.` });
|
||||
}
|
||||
const sorcerer = classes.find((c) => c.characterClass === 'SORCERER');
|
||||
const draconic = sorcerer && sorcerer.subclass === 'DRACONIC' && sorcerer.level >= SUBCLASS_LEVEL.SORCERER ? sorcerer : null;
|
||||
if (draconic && unarmored) {
|
||||
acWays.push({ ac: 13 + modifiers.DEX, name: 'Draconic Resilience', source: 'Draconic Bloodline', text: 'AC is 13 + Dexterity without armor.' });
|
||||
}
|
||||
const bestAc = acWays.sort((a, b) => b.ac - a.ac)[0];
|
||||
const acBase = bestAc && bestAc.ac > plain ? bestAc.ac : null;
|
||||
if (acBase !== null) inForce.push({ name: bestAc.name, source: bestAc.source, text: bestAc.text });
|
||||
|
||||
let hp = 0;
|
||||
if (draconic) {
|
||||
hp += draconic.level;
|
||||
inForce.push({ name: 'Draconic Resilience', source: 'Draconic Bloodline', text: `+1 maximum HP for each sorcerer level (+${draconic.level}).` });
|
||||
}
|
||||
|
||||
let speed = 0;
|
||||
if (levelIn('BARBARIAN') >= 5 && armor.worn !== 'HEAVY') {
|
||||
speed += 10;
|
||||
inForce.push({ name: 'Fast Movement', source: 'Barbarian', text: '+10 ft speed when not in heavy armor.' });
|
||||
}
|
||||
const monkSpeed = unarmored && !armor.shield ? unarmoredMovement(levelIn('MONK')) : 0;
|
||||
if (monkSpeed > 0) {
|
||||
speed += monkSpeed;
|
||||
inForce.push({ name: 'Unarmored Movement', source: 'Monk', text: `+${monkSpeed} ft speed without armor or a shield.` });
|
||||
}
|
||||
|
||||
let saveBonus = 0;
|
||||
if (levelIn('PALADIN') >= 6) {
|
||||
saveBonus = Math.max(1, modifiers.CHA);
|
||||
inForce.push({ name: 'Aura of Protection', source: 'Paladin', text: `+${saveBonus} (Charisma) to every saving throw while conscious. Allies in the aura add it by hand.` });
|
||||
}
|
||||
|
||||
const saveProficiencies = [];
|
||||
if (levelIn('MONK') >= 14) {
|
||||
saveProficiencies.push('STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA');
|
||||
inForce.push({ name: 'Diamond Soul', source: 'Monk', text: 'Proficient in every saving throw.' });
|
||||
}
|
||||
if (levelIn('ROGUE') >= 15) {
|
||||
saveProficiencies.push('WIS');
|
||||
inForce.push({ name: 'Slippery Mind', source: 'Rogue', text: 'Proficient in Wisdom saving throws.' });
|
||||
}
|
||||
|
||||
let halfProficiency = 0;
|
||||
if (levelIn('BARD') >= 2) {
|
||||
halfProficiency = Math.floor(profBonus / 2);
|
||||
inForce.push({ name: 'Jack of All Trades', source: 'Bard', text: `+${halfProficiency} (half proficiency) to initiative and to every skill you are not proficient in.` });
|
||||
}
|
||||
|
||||
return { acBase, speed, hp, saveBonus, saveProficiencies: [...new Set(saveProficiencies)], halfProficiency, inForce };
|
||||
}
|
||||
|
||||
module.exports = { classFeatureEffects, unarmoredMovement };
|
||||
@@ -0,0 +1,168 @@
|
||||
const { computeCharacterSheet } = require('./calculations');
|
||||
const { classFeatureEffects, unarmoredMovement } = require('./classFeatures');
|
||||
|
||||
// A human (+1 to everything) with STR 16, DEX 14 (+2), CON 14 (+2), INT 10,
|
||||
// WIS 16 (+3), CHA 16 (+3) once the species bonus is in.
|
||||
const scores = { species: 'HUMAN', strScore: 15, dexScore: 13, conScore: 13, intScore: 9, wisScore: 15, chaScore: 15, speciesBonusChoices: {} };
|
||||
const sheet = (character, saves = []) => computeCharacterSheet({ ...scores, level: 1, ...character }, [], saves);
|
||||
const wearing = (name, armorType, ac) => ({ equipped: true, level: 1, item: { name, armorType, effects: [{ stat: 'AC', amount: ac }] } });
|
||||
const names = (s) => s.features.map((f) => f.name);
|
||||
|
||||
describe('Armor Class without armor', () => {
|
||||
test('a barbarian adds Constitution, and keeps it behind a shield', () => {
|
||||
expect(sheet({ characterClass: 'BARBARIAN' }).armorClass).toBe(14); // 10 + 2 + 2
|
||||
const shielded = sheet({ characterClass: 'BARBARIAN', inventoryItems: [wearing('Shield', 'SHIELD', 2)] });
|
||||
expect(shielded.armorClass).toBe(16);
|
||||
expect(names(shielded)).toContain('Unarmored Defense');
|
||||
});
|
||||
|
||||
test('a monk adds Wisdom, but not with a shield', () => {
|
||||
expect(sheet({ characterClass: 'MONK' }).armorClass).toBe(15); // 10 + 2 + 3
|
||||
const shielded = sheet({ characterClass: 'MONK', inventoryItems: [wearing('Shield', 'SHIELD', 2)] });
|
||||
expect(shielded.armorClass).toBe(14); // 10 + 2, and the shield
|
||||
expect(names(shielded)).not.toContain('Unarmored Defense');
|
||||
});
|
||||
|
||||
test('armor on, it is the armor that counts', () => {
|
||||
const armored = sheet({ characterClass: 'BARBARIAN', inventoryItems: [wearing('Hide Armor', 'MEDIUM', 2)] });
|
||||
expect(armored.armorClass).toBe(14); // 10 + 2 + the hide's 2
|
||||
expect(names(armored)).toEqual([]);
|
||||
// Armor that is carried, not worn, changes nothing.
|
||||
const carried = sheet({ characterClass: 'BARBARIAN', inventoryItems: [{ ...wearing('Hide Armor', 'MEDIUM', 2), equipped: false }] });
|
||||
expect(carried.armorClass).toBe(14);
|
||||
expect(names(carried)).toContain('Unarmored Defense');
|
||||
});
|
||||
|
||||
test('it is left out when it would not help, and the DM’s extra AC still adds', () => {
|
||||
const frail = sheet({ characterClass: 'BARBARIAN', conScore: 7 }); // CON 8: -1
|
||||
expect(frail.armorClass).toBe(12);
|
||||
expect(names(frail)).toEqual([]);
|
||||
expect(sheet({ characterClass: 'BARBARIAN', armorBonus: 1 }).armorClass).toBe(15);
|
||||
});
|
||||
|
||||
test('a draconic sorcerer has 13 + Dexterity and a hit point a level, from the level the bloodline starts', () => {
|
||||
const before = sheet({ characterClass: 'SORCERER', subclass: 'DRACONIC', level: 2 });
|
||||
expect(before.armorClass).toBe(12);
|
||||
expect(before.hp.max).toBe(14); // 6 + 2, then 4 + 2
|
||||
const dragon = sheet({ characterClass: 'SORCERER', subclass: 'DRACONIC', level: 3 });
|
||||
expect(dragon.armorClass).toBe(15);
|
||||
expect(dragon.hp.max).toBe(23); // 20, and 3 more
|
||||
expect(sheet({ characterClass: 'SORCERER', subclass: 'WILD_MAGIC', level: 3 }).hp.max).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('speed', () => {
|
||||
test('a barbarian is 10 ft faster from level 5, out of heavy armor', () => {
|
||||
expect(sheet({ characterClass: 'BARBARIAN', level: 4 }).speed).toBe(30);
|
||||
expect(sheet({ characterClass: 'BARBARIAN', level: 5 }).speed).toBe(40);
|
||||
expect(sheet({ characterClass: 'BARBARIAN', level: 5, inventoryItems: [wearing('Hide Armor', 'MEDIUM', 2)] }).speed).toBe(40);
|
||||
expect(sheet({ characterClass: 'BARBARIAN', level: 5, inventoryItems: [wearing('Plate Armor', 'HEAVY', 8)] }).speed).toBe(30);
|
||||
});
|
||||
|
||||
test('a monk speeds up as they level, without armor or a shield', () => {
|
||||
expect([1, 2, 5, 6, 10, 14, 18, 20].map(unarmoredMovement)).toEqual([0, 10, 10, 15, 20, 25, 30, 30]);
|
||||
expect(sheet({ characterClass: 'MONK', level: 6 }).speed).toBe(45);
|
||||
expect(sheet({ characterClass: 'MONK', level: 6, inventoryItems: [wearing('Leather Armor', 'LIGHT', 1)] }).speed).toBe(30);
|
||||
expect(sheet({ characterClass: 'MONK', level: 6, inventoryItems: [wearing('Shield', 'SHIELD', 2)] }).speed).toBe(30);
|
||||
});
|
||||
|
||||
test('a speed the DM has set is the last word', () => {
|
||||
const set = sheet({ characterClass: 'BARBARIAN', level: 5, speedOverride: 40 });
|
||||
expect(set.speed).toBe(40);
|
||||
expect(names(set)).not.toContain('Fast Movement');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saving throws', () => {
|
||||
const classSaves = (...abilities) => abilities.map((ability) => ({ ability, proficient: true }));
|
||||
const bonuses = (s) => Object.fromEntries(Object.entries(s.saves).map(([ability, save]) => [ability, save.bonus]));
|
||||
|
||||
test('a paladin’s aura adds Charisma to every save from level 6, and at least 1', () => {
|
||||
const young = sheet({ characterClass: 'PALADIN', level: 5 }, classSaves('WIS', 'CHA'));
|
||||
expect(bonuses(young)).toEqual({ STR: 3, DEX: 2, CON: 2, INT: 0, WIS: 6, CHA: 6 });
|
||||
const aura = sheet({ characterClass: 'PALADIN', level: 6 }, classSaves('WIS', 'CHA'));
|
||||
expect(bonuses(aura)).toEqual({ STR: 6, DEX: 5, CON: 5, INT: 3, WIS: 9, CHA: 9 });
|
||||
const dour = sheet({ characterClass: 'PALADIN', level: 6, chaScore: 7 }, classSaves('WIS', 'CHA')); // CHA 8: -1
|
||||
expect(bonuses(dour).STR).toBe(4);
|
||||
});
|
||||
|
||||
test('a monk is proficient in every save from level 14', () => {
|
||||
const before = sheet({ characterClass: 'MONK', level: 13 }, classSaves('STR', 'DEX'));
|
||||
expect(before.saves.WIS).toEqual({ proficient: false, bonus: 3 });
|
||||
const soul = sheet({ characterClass: 'MONK', level: 14 }, classSaves('STR', 'DEX'));
|
||||
expect(Object.values(soul.saves).every((save) => save.proficient)).toBe(true);
|
||||
expect(bonuses(soul)).toEqual({ STR: 8, DEX: 7, CON: 7, INT: 5, WIS: 8, CHA: 8 }); // +5 proficiency
|
||||
});
|
||||
|
||||
test('a rogue is proficient in Wisdom saves from level 15', () => {
|
||||
expect(sheet({ characterClass: 'ROGUE', level: 14 }, classSaves('DEX', 'INT')).saves.WIS).toEqual({ proficient: false, bonus: 3 });
|
||||
expect(sheet({ characterClass: 'ROGUE', level: 15 }, classSaves('DEX', 'INT')).saves.WIS).toEqual({ proficient: true, bonus: 8 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Jack of All Trades', () => {
|
||||
test('a bard adds half proficiency to initiative and to skills they are not proficient in', () => {
|
||||
const skills = [{ skill: 'PERFORMANCE', proficient: true, expertise: false }];
|
||||
const novice = computeCharacterSheet({ ...scores, characterClass: 'BARD', level: 1 }, skills, []);
|
||||
expect(novice.initiative).toBe(2);
|
||||
const bard = computeCharacterSheet({ ...scores, characterClass: 'BARD', level: 2 }, skills, []);
|
||||
expect(bard.initiative).toBe(3); // +2 Dexterity, +1
|
||||
expect(bard.skills.STEALTH.bonus).toBe(3);
|
||||
expect(bard.skills.PERFORMANCE.bonus).toBe(5); // +3 Charisma, +2: proficient, so no half
|
||||
expect(bard.passivePerception).toBe(14); // 10 + 3 Wisdom + 1
|
||||
expect(computeCharacterSheet({ ...scores, characterClass: 'BARD', level: 9 }, skills, []).initiative).toBe(4); // half of +4
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiclassed', () => {
|
||||
test('each class counts by its own level', () => {
|
||||
// Paladin 6 / Barbarian 1: the aura, and Unarmored Defense, but no Fast Movement.
|
||||
const mixed = sheet({ characterClass: 'PALADIN', level: 7, classLevels: [{ characterClass: 'BARBARIAN', level: 1 }] });
|
||||
expect(names(mixed).sort()).toEqual(['Aura of Protection', 'Unarmored Defense']);
|
||||
expect(mixed.armorClass).toBe(14);
|
||||
// Barbarian 1 / Monk 1: the better of the two ways, not both.
|
||||
const both = sheet({ characterClass: 'BARBARIAN', level: 2, classLevels: [{ characterClass: 'MONK', level: 1 }] });
|
||||
expect(both.armorClass).toBe(15);
|
||||
expect(both.features).toHaveLength(1);
|
||||
expect(both.features[0].source).toBe('Monk');
|
||||
});
|
||||
|
||||
test('nothing for a class with none of these', () => {
|
||||
const effects = classFeatureEffects([{ characterClass: 'FIGHTER', level: 20, subclass: null }], { STR: 3, DEX: 2, CON: 2, INT: 0, WIS: 3, CHA: 3 }, 6);
|
||||
expect(effects).toEqual({ acBase: null, speed: 0, hp: 0, saveBonus: 0, saveProficiencies: [], halfProficiency: 0, inForce: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dexterity in armor', () => {
|
||||
const { armorDexterity } = require('./calculations');
|
||||
// Dexterity 18 (+4), and a clumsy one at 8 (-1).
|
||||
const nimble = (inventoryItems) => sheet({ characterClass: 'FIGHTER', dexScore: 17, inventoryItems });
|
||||
const clumsy = (inventoryItems) => sheet({ characterClass: 'FIGHTER', dexScore: 7, inventoryItems });
|
||||
|
||||
test('all of it in light armor or none, at most +2 in medium, none in heavy', () => {
|
||||
expect([null, 'LIGHT', 'MEDIUM', 'HEAVY', 'SHIELD'].map((worn) => armorDexterity(4, worn))).toEqual([4, 4, 2, 0, 4]);
|
||||
expect([null, 'LIGHT', 'MEDIUM', 'HEAVY'].map((worn) => armorDexterity(-1, worn))).toEqual([-1, -1, -1, 0]);
|
||||
});
|
||||
|
||||
test('the sheet’s AC follows, and the gear card says why', () => {
|
||||
expect(nimble([]).armorClass).toBe(14);
|
||||
expect(nimble([wearing('Leather Armor', 'LIGHT', 1)]).armorClass).toBe(15);
|
||||
const scale = nimble([wearing('Scale Mail', 'MEDIUM', 4)]);
|
||||
expect(scale.armorClass).toBe(16); // 10 + 2 of the 4 + 4
|
||||
expect(scale.gear.notes).toEqual([{ source: 'Scale Mail', note: 'Medium armor: at most +2 from Dexterity counts toward AC.' }]);
|
||||
const plate = nimble([wearing('Plate Armor', 'HEAVY', 8), wearing('Shield', 'SHIELD', 2)]);
|
||||
expect(plate.armorClass).toBe(20); // 18, and the shield
|
||||
expect(plate.gear.notes[0]).toEqual({ source: 'Plate Armor', note: 'Heavy armor: Dexterity does not count toward AC.' });
|
||||
// Heavy armor hides a Dexterity penalty too; medium does not.
|
||||
expect(clumsy([wearing('Plate Armor', 'HEAVY', 8)]).armorClass).toBe(18);
|
||||
expect(clumsy([wearing('Scale Mail', 'MEDIUM', 4)]).armorClass).toBe(13);
|
||||
// Nothing to say when the armor holds nothing back, and only Dexterity's part in AC changes.
|
||||
expect(sheet({ characterClass: 'FIGHTER', inventoryItems: [wearing('Scale Mail', 'MEDIUM', 4)] }).gear.notes).toEqual([]);
|
||||
expect(plate.initiative).toBe(4);
|
||||
expect(plate.saves.DEX.bonus).toBe(4);
|
||||
});
|
||||
|
||||
test('an item with +AC that is not marked as armor holds nothing back', () => {
|
||||
expect(nimble([wearing('Ring of Protection', null, 1)]).armorClass).toBe(15);
|
||||
});
|
||||
});
|
||||
@@ -172,4 +172,41 @@ function attacksOf({ monster = null, npc = null }) {
|
||||
return [{ name: 'Attack', attackBonus: stats.attackBonus || 0, damageDice: stats.damageDice || null, damageType: null }];
|
||||
}
|
||||
|
||||
module.exports = { attackOutcome, saveSucceeds, lands, settleTarget, settle, rollDamage, rollCritDamage, sideOf, opponentsOf, targetWeights, pickTarget, attacksOf };
|
||||
// Highest initiative first; ties keep the order combatants were added in.
|
||||
// Anyone who has not rolled yet waits at the bottom.
|
||||
function inTurnOrder(participants) {
|
||||
const rolled = (p) => (p.initiativeRoll == null ? 0 : 1);
|
||||
return [...participants].sort((a, b) => rolled(b) - rolled(a) || b.initiative - a.initiative || (a.id < b.id ? -1 : 1));
|
||||
}
|
||||
|
||||
// Whether a combatant may change the weapon in their hand in a battle that
|
||||
// is under way. There are two moments for it: as the battle begins, before
|
||||
// anyone has made a move (round 1, still the first combatant's turn, nothing
|
||||
// landed yet), and on their own turn once their attack has landed, before the
|
||||
// turn passes. `applied` is the battle's landed actions: [{ round,
|
||||
// actorParticipantId }]. `firstId` is whoever went first. `declared` is true
|
||||
// while somebody's attack or spell is declared and still being resolved: that
|
||||
// is a move being made.
|
||||
function weaponSwapOpen({ round, activeId, firstId, participantId, applied = [], declared = false }) {
|
||||
if (activeId == null) return true; // no turn has started
|
||||
const opening = round === 1 && activeId === firstId && applied.length === 0 && !declared;
|
||||
const afterOwnAttack = activeId === participantId && applied.some((a) => a.round === round && a.actorParticipantId === participantId);
|
||||
return opening || afterOwnAttack;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
attackOutcome,
|
||||
saveSucceeds,
|
||||
lands,
|
||||
settleTarget,
|
||||
settle,
|
||||
rollDamage,
|
||||
rollCritDamage,
|
||||
sideOf,
|
||||
opponentsOf,
|
||||
targetWeights,
|
||||
pickTarget,
|
||||
attacksOf,
|
||||
inTurnOrder,
|
||||
weaponSwapOpen,
|
||||
};
|
||||
|
||||
@@ -140,3 +140,36 @@ describe('what a creature attacks with', () => {
|
||||
expect(attacksOf({ npc: { attackBonus: null, damageDice: null } })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changing weapons in a battle', () => {
|
||||
const { inTurnOrder, weaponSwapOpen } = require('./combat');
|
||||
const base = { round: 1, activeId: 'a', firstId: 'a', participantId: 'b', applied: [] };
|
||||
|
||||
test('the turn order is by initiative, the unrolled last', () => {
|
||||
const order = inTurnOrder([
|
||||
{ id: '1', initiativeRoll: null, initiative: 0 },
|
||||
{ id: '2', initiativeRoll: 5, initiative: 7 },
|
||||
{ id: '3', initiativeRoll: 12, initiative: 12 },
|
||||
]);
|
||||
expect(order.map((p) => p.id)).toEqual(['3', '2', '1']);
|
||||
});
|
||||
|
||||
test('anyone may as the battle begins, before a move is made', () => {
|
||||
expect(weaponSwapOpen(base)).toBe(true);
|
||||
expect(weaponSwapOpen({ ...base, activeId: null })).toBe(true);
|
||||
// The first turn has passed, or something has landed: the moment is gone.
|
||||
expect(weaponSwapOpen({ ...base, activeId: 'b', participantId: 'c' })).toBe(false);
|
||||
expect(weaponSwapOpen({ ...base, applied: [{ round: 1, actorParticipantId: 'a' }] })).toBe(false);
|
||||
expect(weaponSwapOpen({ ...base, round: 2 })).toBe(false);
|
||||
expect(weaponSwapOpen({ ...base, declared: true })).toBe(false); // an attack is being resolved
|
||||
});
|
||||
|
||||
test('after that, only on their own turn once their attack has landed', () => {
|
||||
const later = { round: 2, activeId: 'b', firstId: 'a', participantId: 'b' };
|
||||
expect(weaponSwapOpen({ ...later, applied: [] })).toBe(false);
|
||||
expect(weaponSwapOpen({ ...later, applied: [{ round: 1, actorParticipantId: 'b' }] })).toBe(false); // last round's
|
||||
expect(weaponSwapOpen({ ...later, applied: [{ round: 2, actorParticipantId: 'b' }] })).toBe(true);
|
||||
// Somebody else's turn, even though they attacked earlier this round.
|
||||
expect(weaponSwapOpen({ ...later, activeId: 'c', applied: [{ round: 2, actorParticipantId: 'b' }] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,7 +266,7 @@ const CLASS_FEATURES = {
|
||||
],
|
||||
BARBARIAN: [
|
||||
{ name: 'Rage', text: 'Twice per day, for one minute: +2 melee damage, advantage on Strength checks and saves, and physical damage against you is halved.' },
|
||||
{ name: 'Unarmored Defense', text: 'Without armor, add your Constitution modifier to your AC (ask your DM to set your armor bonus).' },
|
||||
{ name: 'Unarmored Defense', text: 'Without armor, add your Constitution modifier to your AC (counted on your sheet). A shield still adds.' },
|
||||
],
|
||||
ROGUE: [
|
||||
{ name: 'Sneak Attack', text: 'Once per turn, deal an extra 1d6 damage to a target you have advantage against, or that has one of your allies next to it.' },
|
||||
@@ -306,7 +306,7 @@ const CLASS_FEATURES = {
|
||||
],
|
||||
MONK: [
|
||||
{ name: 'Martial Arts', text: 'Your unarmed strikes deal 1d4 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.' },
|
||||
{ name: 'Unarmored Defense', text: 'Without armor, add your Wisdom modifier to your AC (ask your DM to set your armor bonus).' },
|
||||
{ name: 'Unarmored Defense', text: 'Without armor or a shield, add your Wisdom modifier to your AC (counted on your sheet).' },
|
||||
],
|
||||
ARTIFICER: [
|
||||
{ name: 'Magical Tinkering', text: 'Touch a tiny object to give it a small magic: it shines, plays a recorded message, gives off a smell or sound, or shows a picture. You can keep as many going as your Intelligence modifier.' },
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
// matches the `Item` Prisma model minus `id`/`campaignId`, plus a stable `key`
|
||||
// for "add to campaign" lookups and a `category` for the library picker.
|
||||
//
|
||||
// Armor here is a flat bonus to AC on top of 10 + Dexterity, because that is
|
||||
// how gear effects work (game/calculations.js, GEAR_STATS): the standard
|
||||
// rules' "replaces 10 + DEX" and "DEX bonus capped at 2" are not modelled.
|
||||
// Each suit's bonus is its standard AC less 10, and what the bonus cannot say
|
||||
// is in the note. A DM who wants it stricter edits the copy in their campaign.
|
||||
// Armor's AC is a bonus on top of 10 + Dexterity, because that is how gear
|
||||
// effects work (game/calculations.js, GEAR_STATS): each suit's bonus is its
|
||||
// standard AC less 10. What kind of armor it is (armorType, from the category)
|
||||
// tells the sheet how much Dexterity counts in it: all of it in light armor,
|
||||
// at most +2 in medium, none in heavy (calculations.js, armorDexterity).
|
||||
// What neither can say (a Strength requirement, Stealth) is in the note.
|
||||
|
||||
const STEALTH = 'Disadvantage on Stealth checks.';
|
||||
const ARMOR_TYPE = { 'Light Armor': 'LIGHT', 'Medium Armor': 'MEDIUM', 'Heavy Armor': 'HEAVY', Shields: 'SHIELD' };
|
||||
const armor = (key, name, category, ac, description, note = null, more = []) => ({
|
||||
key,
|
||||
name,
|
||||
@@ -18,6 +20,8 @@ const armor = (key, name, category, ac, description, note = null, more = []) =>
|
||||
damageDice: null,
|
||||
effects: [{ stat: 'AC', amount: ac }, ...more],
|
||||
effectNote: note,
|
||||
// What kind it is: the sheet goes by this for Unarmored Defense and the like.
|
||||
armorType: ARMOR_TYPE[category],
|
||||
});
|
||||
const gear = (key, name, category, description, effects = [], effectNote = null, damageDice = null) => ({
|
||||
key,
|
||||
@@ -36,17 +40,17 @@ const ITEM_LIBRARY = [
|
||||
armor('studded-leather', 'Studded Leather', 'Light Armor', 2, 'Tough leather set with close rivets and spikes.'),
|
||||
|
||||
// Medium armor
|
||||
armor('hide-armor', 'Hide Armor', 'Medium Armor', 2, 'Thick furs and pelts.', 'Standard rules: counts at most +2 from Dexterity.'),
|
||||
armor('chain-shirt', 'Chain Shirt', 'Medium Armor', 3, 'Interlocking rings worn between layers of clothing.', 'Standard rules: counts at most +2 from Dexterity.'),
|
||||
armor('scale-mail', 'Scale Mail', 'Medium Armor', 4, 'A coat and leggings of leather covered in overlapping metal scales.', `${STEALTH} Standard rules: counts at most +2 from Dexterity.`),
|
||||
armor('breastplate', 'Breastplate', 'Medium Armor', 4, 'A fitted metal chest piece worn with supple leather.', 'Standard rules: counts at most +2 from Dexterity.'),
|
||||
armor('half-plate', 'Half Plate', 'Medium Armor', 5, 'Shaped metal plates covering most of the body.', `${STEALTH} Standard rules: counts at most +2 from Dexterity.`),
|
||||
armor('hide-armor', 'Hide Armor', 'Medium Armor', 2, 'Thick furs and pelts.', 'Medium armor: at most +2 from Dexterity counts (worked out on the sheet).'),
|
||||
armor('chain-shirt', 'Chain Shirt', 'Medium Armor', 3, 'Interlocking rings worn between layers of clothing.', 'Medium armor: at most +2 from Dexterity counts (worked out on the sheet).'),
|
||||
armor('scale-mail', 'Scale Mail', 'Medium Armor', 4, 'A coat and leggings of leather covered in overlapping metal scales.', `${STEALTH} Medium armor: at most +2 from Dexterity counts (worked out on the sheet).`),
|
||||
armor('breastplate', 'Breastplate', 'Medium Armor', 4, 'A fitted metal chest piece worn with supple leather.', 'Medium armor: at most +2 from Dexterity counts (worked out on the sheet).'),
|
||||
armor('half-plate', 'Half Plate', 'Medium Armor', 5, 'Shaped metal plates covering most of the body.', `${STEALTH} Medium armor: at most +2 from Dexterity counts (worked out on the sheet).`),
|
||||
|
||||
// Heavy armor: slow, loud, and in the standard rules it ignores Dexterity.
|
||||
armor('ring-mail', 'Ring Mail', 'Heavy Armor', 4, 'Leather armor with heavy rings sewn into it.', `${STEALTH} Standard rules: adds no Dexterity.`),
|
||||
armor('chain-mail', 'Chain Mail', 'Heavy Armor', 6, 'Interlocking metal rings over a layer of quilted fabric, with gauntlets.', `${STEALTH} Standard rules: adds no Dexterity, and needs Strength 13.`, [{ stat: 'SPEED', amount: -10 }]),
|
||||
armor('splint-armor', 'Splint Armor', 'Heavy Armor', 7, 'Narrow vertical strips of metal riveted to a leather backing.', `${STEALTH} Standard rules: adds no Dexterity, and needs Strength 15.`, [{ stat: 'SPEED', amount: -10 }]),
|
||||
armor('plate-armor', 'Plate Armor', 'Heavy Armor', 8, 'Shaped, interlocking metal plates covering the whole body.', `${STEALTH} Standard rules: adds no Dexterity, and needs Strength 15.`, [{ stat: 'SPEED', amount: -10 }]),
|
||||
armor('ring-mail', 'Ring Mail', 'Heavy Armor', 4, 'Leather armor with heavy rings sewn into it.', `${STEALTH} Heavy armor: Dexterity does not count (worked out on the sheet).`),
|
||||
armor('chain-mail', 'Chain Mail', 'Heavy Armor', 6, 'Interlocking metal rings over a layer of quilted fabric, with gauntlets.', `${STEALTH} Heavy armor: Dexterity does not count (worked out on the sheet). Standard rules: needs Strength 13.`, [{ stat: 'SPEED', amount: -10 }]),
|
||||
armor('splint-armor', 'Splint Armor', 'Heavy Armor', 7, 'Narrow vertical strips of metal riveted to a leather backing.', `${STEALTH} Heavy armor: Dexterity does not count (worked out on the sheet). Standard rules: needs Strength 15.`, [{ stat: 'SPEED', amount: -10 }]),
|
||||
armor('plate-armor', 'Plate Armor', 'Heavy Armor', 8, 'Shaped, interlocking metal plates covering the whole body.', `${STEALTH} Heavy armor: Dexterity does not count (worked out on the sheet). Standard rules: needs Strength 15.`, [{ stat: 'SPEED', amount: -10 }]),
|
||||
|
||||
// Shields
|
||||
armor('shield', 'Shield', 'Shields', 2, 'Wood or metal, carried in one hand.'),
|
||||
|
||||
@@ -57,7 +57,7 @@ const LEVEL_FEATURES = {
|
||||
],
|
||||
3: [rage('Three times', 2)],
|
||||
4: [BOON],
|
||||
5: [EXTRA_ATTACK, { name: 'Fast Movement', text: '+10 ft speed when not in heavy armor (ask your DM to raise your speed).' }],
|
||||
5: [EXTRA_ATTACK, { name: 'Fast Movement', text: '+10 ft speed when not in heavy armor (counted on your sheet).' }],
|
||||
6: [rage('Four times', 2)],
|
||||
7: [{ name: 'Feral Instinct', text: 'Advantage on initiative; you can act normally in a surprise round if you rage first.' }],
|
||||
8: [BOON],
|
||||
@@ -77,7 +77,7 @@ const LEVEL_FEATURES = {
|
||||
},
|
||||
BARD: {
|
||||
2: [
|
||||
{ name: 'Jack of All Trades', text: 'Add half your proficiency bonus (round down) to any check you are not proficient in.' },
|
||||
{ name: 'Jack of All Trades', text: 'Add half your proficiency bonus (round down) to any check you are not proficient in (counted on your sheet for skills and initiative).' },
|
||||
{ name: 'Song of Rest', text: 'During a short rest, allies who spend Hit Dice heal an extra 1d6.' },
|
||||
],
|
||||
3: [
|
||||
@@ -169,7 +169,7 @@ const LEVEL_FEATURES = {
|
||||
{ name: 'Flurry of Blows', text: '1 ki: two unarmed strikes as a bonus action.' },
|
||||
{ name: 'Patient Defense', text: '1 ki: Dodge as a bonus action.' },
|
||||
{ name: 'Step of the Wind', text: '1 ki: Dash or Disengage as a bonus action, jump distance doubled.' },
|
||||
{ name: 'Unarmored Movement', text: '+10 ft speed without armor (ask your DM to raise your speed).' },
|
||||
{ name: 'Unarmored Movement', text: '+10 ft speed without armor (counted on your sheet).' },
|
||||
],
|
||||
3: [
|
||||
{ name: 'Deflect Missiles', text: 'Reaction: reduce ranged weapon damage by 1d10 + Dexterity modifier + your level.' },
|
||||
@@ -182,28 +182,28 @@ const LEVEL_FEATURES = {
|
||||
],
|
||||
6: [
|
||||
{ name: 'Ki-Empowered Strikes', text: 'Your unarmed strikes count as magical.' },
|
||||
{ name: 'Unarmored Movement', text: '+15 ft speed without armor (ask your DM to raise your speed).', upgrade: true },
|
||||
{ name: 'Unarmored Movement', text: '+15 ft speed without armor (counted on your sheet).', upgrade: true },
|
||||
],
|
||||
7: [EVASION, { name: 'Stillness of Mind', text: 'Action: end charm or fear on yourself.' }],
|
||||
8: [BOON],
|
||||
9: [{ name: 'Unarmored Movement', text: '+15 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move.', upgrade: true }],
|
||||
9: [{ name: 'Unarmored Movement', text: '+15 ft speed without armor (counted on your sheet). You can run along walls and across water during your move.', upgrade: true }],
|
||||
10: [
|
||||
{ name: 'Purity of Body', text: 'Immune to poison and disease.' },
|
||||
{ name: 'Unarmored Movement', text: '+20 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move.', upgrade: true },
|
||||
{ name: 'Unarmored Movement', text: '+20 ft speed without armor (counted on your sheet). You can run along walls and across water during your move.', upgrade: true },
|
||||
],
|
||||
11: [{ name: 'Martial Arts', text: 'Your unarmed strikes deal 1d8 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.', upgrade: true }],
|
||||
12: [BOON],
|
||||
13: [{ name: 'Tongue of the Sun and Moon', text: 'You understand every spoken language, and anyone who speaks one understands you.' }],
|
||||
14: [
|
||||
{ name: 'Diamond Soul', text: 'You are proficient in every saving throw (add the bonus by hand); 1 ki rerolls a failed save.' },
|
||||
{ name: 'Unarmored Movement', text: '+25 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move.', upgrade: true },
|
||||
{ name: 'Diamond Soul', text: 'You are proficient in every saving throw (counted on your sheet); 1 ki rerolls a failed save.' },
|
||||
{ name: 'Unarmored Movement', text: '+25 ft speed without armor (counted on your sheet). You can run along walls and across water during your move.', upgrade: true },
|
||||
],
|
||||
15: [{ name: 'Timeless Body', text: 'You need no food or water and suffer none of the frailty of old age.' }],
|
||||
16: [BOON],
|
||||
17: [{ name: 'Martial Arts', text: 'Your unarmed strikes deal 1d10 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.', upgrade: true }],
|
||||
18: [
|
||||
{ name: 'Empty Body', text: '4 ki: invisible for one minute, and all damage but force against you is halved. 8 ki: cast Astral Projection on yourself.' },
|
||||
{ name: 'Unarmored Movement', text: '+30 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move.', upgrade: true },
|
||||
{ name: 'Unarmored Movement', text: '+30 ft speed without armor (counted on your sheet). You can run along walls and across water during your move.', upgrade: true },
|
||||
],
|
||||
19: [BOON],
|
||||
20: [{ name: 'Perfect Self', text: 'When you roll initiative with no ki left, regain 4 ki.' }],
|
||||
@@ -219,7 +219,7 @@ const LEVEL_FEATURES = {
|
||||
],
|
||||
4: [BOON],
|
||||
5: [EXTRA_ATTACK],
|
||||
6: [{ name: 'Aura of Protection', text: 'You and allies within 10 ft add your Charisma modifier to all saving throws while you are conscious.' }],
|
||||
6: [{ name: 'Aura of Protection', text: 'You and allies within 10 ft add your Charisma modifier to all saving throws while you are conscious (yours is counted on your sheet).' }],
|
||||
8: [BOON],
|
||||
10: [{ name: 'Aura of Courage', text: 'You and allies within 10 ft can’t be frightened.' }],
|
||||
11: [{ name: 'Improved Divine Smite', text: 'Every melee weapon hit deals an extra 1d8 radiant damage.' }],
|
||||
@@ -227,7 +227,7 @@ const LEVEL_FEATURES = {
|
||||
14: [{ name: 'Cleansing Touch', text: 'A few times per day (your Charisma modifier, at least once), action: end one spell on yourself or a willing creature you touch.' }],
|
||||
16: [BOON],
|
||||
18: [
|
||||
{ name: 'Aura of Protection', text: 'You and allies within 30 ft add your Charisma modifier to all saving throws while you are conscious.', upgrade: true },
|
||||
{ name: 'Aura of Protection', text: 'You and allies within 30 ft add your Charisma modifier to all saving throws while you are conscious (yours is counted on your sheet).', upgrade: true },
|
||||
{ name: 'Aura of Courage', text: 'You and allies within 30 ft can’t be frightened.', upgrade: true },
|
||||
],
|
||||
19: [BOON],
|
||||
@@ -275,7 +275,7 @@ const LEVEL_FEATURES = {
|
||||
12: [BOON],
|
||||
13: [sneakAttack('7d6')],
|
||||
14: [{ name: 'Blindsense', text: 'If you can hear, you know where any hidden or invisible creature within 10 ft is.' }],
|
||||
15: [sneakAttack('8d6'), { name: 'Slippery Mind', text: 'You are proficient in Wisdom saves (add the bonus by hand).' }],
|
||||
15: [sneakAttack('8d6'), { name: 'Slippery Mind', text: 'You are proficient in Wisdom saves (counted on your sheet).' }],
|
||||
16: [BOON],
|
||||
17: [sneakAttack('9d6')],
|
||||
18: [{ name: 'Elusive', text: 'No attack against you has advantage while you are not incapacitated.' }],
|
||||
|
||||
@@ -9,7 +9,7 @@ const SORCERER = [
|
||||
edition: '2014',
|
||||
summary: 'Dragon blood: tougher skin and one element at your call.',
|
||||
features: {
|
||||
3: [{ name: 'Dragon Ancestor', text: 'Draconic Bloodline. Choose a kind of dragon with your DM; its element is yours (red: fire, white: cold, blue: lightning, green: poison, black: acid). Thin scales make you hardier (ask your DM about Extra AC).' }],
|
||||
3: [{ name: 'Dragon Ancestor', text: 'Draconic Bloodline. Choose a kind of dragon with your DM; its element is yours (red: fire, white: cold, blue: lightning, green: poison, black: acid). Thin scales make you hardier: without armor your AC is 13 + Dexterity, and you have 1 more maximum HP for each sorcerer level (both counted on your sheet).' }],
|
||||
6: [{ name: 'Elemental Affinity', text: 'Draconic Bloodline. Add your Charisma modifier to one damage roll of your dragon’s element; spend 1 sorcery point to resist that element for 1 hour.' }],
|
||||
14: [{ name: 'Dragon Wings', text: 'Bonus action: sprout dragon wings and fly at your speed until you dismiss them.' }],
|
||||
18: [{ name: 'Draconic Presence', text: 'Action, 5 sorcery points: for a minute, creatures within 60 ft make a Wisdom save or are charmed (awe) or frightened (fear) of you.' }],
|
||||
|
||||
@@ -2638,7 +2638,7 @@ describe('gear effects', () => {
|
||||
const quick = async (auth = playerAuth) => (await request(app).get(`${ch}/quick-stats`).set(auth)).body.quickStats;
|
||||
|
||||
const before = (await sheet()).sheet;
|
||||
expect(before.gear).toEqual({ effects: [], notes: [] });
|
||||
expect(before.gear).toEqual({ effects: [], notes: [], armor: { worn: null, name: null, shield: false } });
|
||||
|
||||
// Only the DM says what gear does, and only with numbers the sheet has.
|
||||
const mail = {
|
||||
@@ -3983,6 +3983,81 @@ describe('battle actions', () => {
|
||||
expect(await hp(two.id)).toBe(0); // 7 HP, 20 damage
|
||||
const slotsAfter = (await request(app).get(`/characters/${mageId}`).set(dmAuth)).body.character.sheet.spellSlots.levels.find((l) => l.level === 3).remaining;
|
||||
expect(slotsAfter).toBe(slotsBefore - 1);
|
||||
|
||||
// A cantrip takes no slot, however often it is cast. Spells are listed cantrips first.
|
||||
expect(mira.spells.map((s) => [s.name, s.level])).toEqual([['Fire Bolt', 0], ['Fireball', 3]]);
|
||||
const used = async () => (await request(app).get(`/characters/${mageId}`).set(dmAuth)).body.character.sheet.spellSlots.levels.map((l) => l.used);
|
||||
const before = await used();
|
||||
await request(app).put(`${e}/targets`).set(dmAuth).send({ participantIds: [one.id] });
|
||||
const bolt = mira.spells.find((s) => s.name === 'Fire Bolt');
|
||||
const boltId = (await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'spell', characterSpellId: bolt.characterSpellId } })).body.actionId;
|
||||
action = (await request(app).patch(`${e}/actions/${boltId}`).set(dmAuth).send({ rolls: [{ participantId: one.id, d20: 19 }], damage: 3 })).body.encounter.actions[0];
|
||||
expect(action).toMatchObject({ kind: 'ATTACK', name: 'Fire Bolt' });
|
||||
expect(action.detail.slotLevel).toBeNull();
|
||||
const cast = await request(app).post(`${e}/actions/${boltId}/apply`).set(dmAuth).send({});
|
||||
expect(cast.status).toBe(200);
|
||||
expect(cast.body.slotNote).toBeNull();
|
||||
expect(await used()).toEqual(before);
|
||||
|
||||
// With her last 3rd-level slot gone, Fireball cannot be declared; the cantrip still can.
|
||||
await request(app).post(`/characters/${mageId}/spell-slots`).set(dmAuth).send({ action: 'set', level: 3, used: 2 });
|
||||
const dry = await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'spell', characterSpellId: fireball.characterSpellId } });
|
||||
expect(dry.status).toBe(400);
|
||||
expect(dry.body.error).toMatch(/no spell slot of level 3 or higher left/);
|
||||
expect((await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'spell', characterSpellId: bolt.characterSpellId } })).status).toBe(201);
|
||||
|
||||
// Every slot she has spent, at every level: the cantrip is still hers to cast, and lands.
|
||||
const all = (await request(app).get(`/characters/${mageId}`).set(dmAuth)).body.character.sheet.spellSlots.levels;
|
||||
for (const slot of all) await request(app).post(`/characters/${mageId}/spell-slots`).set(dmAuth).send({ action: 'set', level: slot.level, used: slot.max });
|
||||
const empty = (await view(dmAuth)).participants.find((p) => p.displayName === 'Mira').characterCombatInfo.spellSlots.levels;
|
||||
expect(empty.every((l) => l.remaining === 0)).toBe(true);
|
||||
const again = await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'spell', characterSpellId: bolt.characterSpellId } });
|
||||
expect(again.status).toBe(201);
|
||||
await request(app).patch(`${e}/actions/${again.body.actionId}`).set(dmAuth).send({ rolls: [{ participantId: one.id, d20: 1 }] });
|
||||
expect((await request(app).post(`${e}/actions/${again.body.actionId}/apply`).set(dmAuth).send({})).status).toBe(200);
|
||||
});
|
||||
|
||||
itIfDb('a weapon is changed as the battle begins or on your own turn after attacking, and not in between', async () => {
|
||||
const { dmAuth, playerAuth, campaign, e, one, heroId } = await battle('actswap');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const bow = (await request(app).post(`${c}/weapons`).set(dmAuth).send({ name: 'Shortbow', damageDice: '1d6', damageType: 'piercing', attackAbility: 'DEX', properties: [] })).body.weapon;
|
||||
await request(app).post(`/characters/${heroId}/weapons`).set(dmAuth).send({ weaponId: bow.id });
|
||||
await request(app).patch(c).set(dmAuth).send({ maxEquippedWeapons: 2 });
|
||||
const held = async () => (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character.weapons;
|
||||
const sword = (await held()).find((w) => w.weapon.name === 'Longsword');
|
||||
const equip = (auth, weaponId, equipped) => request(app).patch(`/characters/${heroId}/weapons/${weaponId}`).set(auth).send({ equipped });
|
||||
|
||||
// The battle has begun and nobody has made a move: the bow can come out, and go back.
|
||||
expect((await equip(playerAuth, bow.id, true)).status).toBe(200);
|
||||
expect((await equip(playerAuth, bow.id, false)).status).toBe(200);
|
||||
|
||||
// Aria attacks. While the attack is still being resolved nothing changes hands...
|
||||
await request(app).put(`${e}/targets`).set(playerAuth).send({ participantIds: [one.id] });
|
||||
const actionId = (await request(app).post(`${e}/actions`).set(playerAuth).send({ source: { type: 'weapon', characterWeaponId: sword.id } })).body.actionId;
|
||||
await request(app).patch(`${e}/actions/${actionId}`).set(playerAuth).send({ rolls: [{ participantId: one.id, d20: 2 }] });
|
||||
expect((await equip(playerAuth, bow.id, true)).status).toBe(400);
|
||||
await request(app).post(`${e}/actions/${actionId}/apply`).set(dmAuth).send({});
|
||||
// After her attack, before the next turn: she may swap.
|
||||
expect((await equip(playerAuth, bow.id, true)).status).toBe(200);
|
||||
|
||||
// The wizard's turn: Aria's hands stay as they are, either way. The DM can still change them.
|
||||
await request(app).post(`${e}/next-turn`).set(dmAuth);
|
||||
const refused = await equip(playerAuth, bow.id, false);
|
||||
expect(refused.status).toBe(400);
|
||||
expect(refused.body.error).toMatch(/is in a battle \(Ambush\).*own turn once their attack has landed/);
|
||||
expect((await equip(playerAuth, sword.weaponId, false)).status).toBe(400);
|
||||
expect((await equip(dmAuth, bow.id, false)).status).toBe(200);
|
||||
// Other gear is not held to it.
|
||||
expect((await held()).find((w) => w.weapon.name === 'Shortbow').equipped).toBe(false);
|
||||
|
||||
// Round the table to Aria again: not before she has attacked this turn.
|
||||
for (let i = 0; i < 3; i += 1) await request(app).post(`${e}/next-turn`).set(dmAuth);
|
||||
expect((await request(app).get(e).set(dmAuth)).body.encounter.currentRound).toBe(2);
|
||||
expect((await equip(playerAuth, bow.id, true)).status).toBe(400);
|
||||
|
||||
// Once the battle is over, gear is changed freely again.
|
||||
await request(app).patch(e).set(dmAuth).send({ status: 'COMPLETE' });
|
||||
expect((await equip(playerAuth, bow.id, true)).status).toBe(200);
|
||||
});
|
||||
|
||||
itIfDb('with auto-roll on, a monster\u2019s turn opens with its attack aimed and rolled, kept from the players until the DM accepts it', async () => {
|
||||
@@ -4047,3 +4122,129 @@ describe('battle actions', () => {
|
||||
expect(rolled.damage.dice).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('free roam', () => {
|
||||
itIfDb('whoever the DM adds wanders between its places, and shops where they stand', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('roam');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
const allyId = await buildCharacter(campaign.id, dmAuth, 'Bo');
|
||||
await request(app).post(`/characters/${heroId}/gold`).set(dmAuth).send({ amount: 50, reason: 'Purse' });
|
||||
const place = async (name) => (await request(app).post(`${c}/locations`).set(dmAuth).send({ name, description: `${name}.` })).body.location;
|
||||
const [market, inn, docks] = [await place('Market'), await place('Inn'), await place('Docks')];
|
||||
const rope = (await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Rope' })).body.item;
|
||||
const ale = (await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Ale' })).body.item;
|
||||
|
||||
// A shop at the market, and an innkeeper found at the inn who sells ale.
|
||||
expect((await request(app).post(`${c}/wares`).set(dmAuth).send({ locationId: market.id, itemId: rope.id, price: 2 })).status).toBe(201);
|
||||
expect((await request(app).post(`${c}/wares`).set(dmAuth).send({ locationId: market.id, npcId: 'x', itemId: rope.id, price: 2 })).status).toBe(400);
|
||||
const keeper = (await request(app).post(`${c}/npcs`).set(dmAuth).send({ name: 'Mara', role: 'Innkeeper', dmNotes: 'Waters the ale.', locationId: inn.id })).body.npc;
|
||||
expect((await request(app).post(`${c}/npcs`).set(dmAuth).send({ name: 'Nobody', locationId: 'nowhere' })).status).toBe(400);
|
||||
await request(app).post(`${c}/wares`).set(dmAuth).send({ npcId: keeper.id, itemId: ale.id, price: 1 });
|
||||
|
||||
const created = await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'A day in town', mode: 'FREE_ROAM', placeIds: [market.id, inn.id] });
|
||||
expect(created.status).toBe(201);
|
||||
const e = `/encounters/${created.body.encounter.id}`;
|
||||
const view = async (auth) => (await request(app).get(e).set(auth)).body.encounter;
|
||||
|
||||
// Nobody is in it until the DM adds them: the party may be split up.
|
||||
expect((await view(dmAuth)).participants).toEqual([]);
|
||||
expect((await request(app).get(e).set(playerAuth)).status).toBe(404);
|
||||
for (const characterId of [heroId, allyId]) await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId });
|
||||
let scene = await view(playerAuth);
|
||||
expect(scene.mode).toBe('FREE_ROAM');
|
||||
expect(scene.participants.map((p) => p.displayName).sort()).toEqual(['Aria', 'Bo']);
|
||||
expect(scene.places.map((p) => p.name)).toEqual(['Market', 'Inn']);
|
||||
expect(scene.places[0].wares[0].item.name).toBe('Rope');
|
||||
expect(scene.places[1].npcs[0]).toMatchObject({ name: 'Mara', role: 'Innkeeper' });
|
||||
expect(scene.places[1].npcs[0].dmNotes).toBeUndefined(); // the DM's alone
|
||||
expect(scene.places[1].npcs[0].wares[0].item.name).toBe('Ale');
|
||||
expect((await view(dmAuth)).places[1].npcs[0].dmNotes).toBe('Waters the ale.');
|
||||
|
||||
const visit = (auth, characterId, locationId) => request(app).post(`${e}/visit`).set(auth).send({ characterId, locationId });
|
||||
const buy = (auth, wareId, characterId) => request(app).post(`${e}/wares/${wareId}/buy`).set(auth).send({ characterId });
|
||||
const ropeWare = scene.places[0].wares[0].id;
|
||||
const aleWare = scene.places[1].npcs[0].wares[0].id;
|
||||
|
||||
// Nothing moves, and nothing is sold, before the scene begins.
|
||||
expect((await visit(playerAuth, heroId, market.id)).status).toBe(400);
|
||||
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' });
|
||||
expect((await buy(playerAuth, ropeWare, heroId)).status).toBe(400); // not at the market yet
|
||||
expect((await visit(playerAuth, heroId, docks.id)).status).toBe(400); // not one of the scene's places
|
||||
expect((await visit(playerAuth, allyId, market.id)).status).toBe(403); // not their character
|
||||
|
||||
scene = (await visit(playerAuth, heroId, market.id)).body.encounter;
|
||||
expect(scene.places[0].here.map((h) => h.name)).toEqual(['Aria']);
|
||||
expect((await buy(playerAuth, ropeWare, heroId)).status).toBe(201);
|
||||
// The innkeeper is at the inn: go there to buy her ale.
|
||||
const tooFar = await buy(playerAuth, aleWare, heroId);
|
||||
expect(tooFar.status).toBe(400);
|
||||
expect(tooFar.body.error).toMatch(/go to Inn first/);
|
||||
await visit(playerAuth, heroId, inn.id);
|
||||
expect((await buy(playerAuth, aleWare, heroId)).status).toBe(201);
|
||||
const hero = (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character;
|
||||
expect(hero.inventoryItems.map((i) => i.item.name).sort()).toEqual(['Ale', 'Rope']);
|
||||
expect(hero.currentLocation.name).toBe('Inn');
|
||||
// The DM can send anyone, and opens another place while the party roams.
|
||||
expect((await visit(dmAuth, allyId, market.id)).status).toBe(200);
|
||||
expect((await request(app).put(`${e}/places`).set(playerAuth).send({ locationIds: [] })).status).toBe(403);
|
||||
const more = (await request(app).put(`${e}/places`).set(dmAuth).send({ locationIds: [market.id, inn.id, docks.id] })).body.encounter;
|
||||
expect(more.places.map((p) => p.name)).toEqual(['Market', 'Inn', 'Docks']);
|
||||
// Having been there, the player now knows the place for good.
|
||||
const known = (await request(app).get(`${c}/locations`).set(playerAuth)).body.locations.map((l) => l.name).sort();
|
||||
expect(known).toEqual(['Inn', 'Market']);
|
||||
});
|
||||
|
||||
itIfDb('a shop at a place does not sell in a quest, and only a free-roam scene has places', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('roamnot');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
const market = (await request(app).post(`${c}/locations`).set(dmAuth).send({ name: 'Market' })).body.location;
|
||||
const rope = (await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Rope' })).body.item;
|
||||
const ware = (await request(app).post(`${c}/wares`).set(dmAuth).send({ locationId: market.id, itemId: rope.id, price: 1 })).body.ware;
|
||||
const quest = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Errand', mode: 'QUEST' })).body.encounter.id}`;
|
||||
await request(app).post(`${quest}/participants`).set(dmAuth).send({ characterId: heroId });
|
||||
await request(app).patch(quest).set(dmAuth).send({ status: 'ACTIVE' });
|
||||
expect((await request(app).post(`${quest}/wares/${ware.id}/buy`).set(playerAuth).send({ characterId: heroId })).status).toBe(404);
|
||||
expect((await request(app).post(`${quest}/visit`).set(playerAuth).send({ characterId: heroId, locationId: market.id })).status).toBe(400);
|
||||
expect((await request(app).get(quest).set(playerAuth)).body.encounter.places).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('class features on the sheet', () => {
|
||||
itIfDb('a barbarian’s Unarmored Defense and Fast Movement go by the armor that is equipped', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('unarmored');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const id = await buildAs(campaign.id, playerAuth, 'Korg', 'BARBARIAN', 5);
|
||||
await prisma.character.update({ where: { id }, data: { species: 'HUMAN', dexScore: 13, conScore: 15, speciesBonusChoices: {}, armorBonus: 0, speedOverride: null } });
|
||||
const sheet = async () => (await request(app).get(`/characters/${id}`).set(playerAuth)).body.character.sheet;
|
||||
|
||||
// Dexterity 14 (+2), Constitution 16 (+3): 10 + 2 + 3. A human's 30 ft, and 10 more.
|
||||
let s = await sheet();
|
||||
expect(s).toMatchObject({ armorClass: 15, speed: 40 });
|
||||
expect(s.features.map((f) => f.name)).toEqual(['Unarmored Defense', 'Fast Movement']);
|
||||
|
||||
// The library's armor comes marked. Hide (+2): 10 + 2 + 2, and no Unarmored Defense.
|
||||
const hide = (await request(app).post(`${c}/items/from-library`).set(dmAuth).send({ key: 'hide-armor' })).body.item;
|
||||
expect(hide.armorType).toBe('MEDIUM');
|
||||
await request(app).post(`/characters/${id}/inventory`).set(dmAuth).send({ itemId: hide.id });
|
||||
expect((await sheet()).armorClass).toBe(15); // carried, not worn
|
||||
await request(app).patch(`/characters/${id}/inventory/${hide.id}`).set(playerAuth).send({ equipped: true });
|
||||
s = await sheet();
|
||||
expect(s).toMatchObject({ armorClass: 14, speed: 40 });
|
||||
expect(s.features.map((f) => f.name)).toEqual(['Fast Movement']);
|
||||
|
||||
// The DM says what kind of thing an item is. Heavy: the extra speed goes, and Dexterity
|
||||
// no longer counts (10 + the armor's 2). Not armor: all of it is back, on top of the +2.
|
||||
expect((await request(app).patch(`${c}/items/${hide.id}`).set(playerAuth).send({ armorType: 'HEAVY' })).status).toBe(403);
|
||||
expect((await request(app).patch(`${c}/items/${hide.id}`).set(dmAuth).send({ armorType: 'PLATE' })).status).toBe(400);
|
||||
await request(app).patch(`${c}/items/${hide.id}`).set(dmAuth).send({ armorType: 'HEAVY' });
|
||||
expect(await sheet()).toMatchObject({ armorClass: 12, speed: 30 });
|
||||
await request(app).patch(`${c}/items/${hide.id}`).set(dmAuth).send({ armorType: null });
|
||||
expect(await sheet()).toMatchObject({ armorClass: 17, speed: 40 });
|
||||
|
||||
// A speed the DM sets is used as it is.
|
||||
await request(app).patch(`/characters/${id}`).set(dmAuth).send({ speedOverride: 35 });
|
||||
expect((await sheet()).speed).toBe(35);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -706,6 +706,8 @@ const itemSchema = z.object({
|
||||
damageScaling: z.number().int().min(1).max(MAX_DAMAGE_SCALING).optional(),
|
||||
minLevel: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
|
||||
isStartingOption: z.boolean().optional(),
|
||||
// Armor and shields: what kind it is. Null for anything else.
|
||||
armorType: z.enum(['LIGHT', 'MEDIUM', 'HEAVY', 'SHIELD']).optional().nullable(),
|
||||
...gearEffects,
|
||||
});
|
||||
|
||||
@@ -853,9 +855,11 @@ router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) =>
|
||||
|
||||
const createEncounterSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
mode: z.enum(['BATTLE', 'QUEST']).optional(),
|
||||
mode: z.enum(['BATTLE', 'QUEST', 'FREE_ROAM']).optional(),
|
||||
description: z.string().max(4000).optional().nullable(),
|
||||
locationId: z.string().optional().nullable(),
|
||||
// Free roam: the places the party can go to, from the campaign's locations.
|
||||
placeIds: z.array(z.string()).max(50).optional(),
|
||||
// Where it goes in the campaign's order: the top, the bottom, or straight
|
||||
// after another encounter (which wins over `position`). The bottom if
|
||||
// neither is given.
|
||||
@@ -867,11 +871,15 @@ router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req,
|
||||
try {
|
||||
const parsed = createEncounterSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { position, afterEncounterId, ...fields } = parsed.data;
|
||||
const { position, afterEncounterId, placeIds = [], ...fields } = parsed.data;
|
||||
if (fields.locationId) {
|
||||
const location = await prisma.location.findFirst({ where: { id: fields.locationId, campaignId: req.campaign.id } });
|
||||
if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' });
|
||||
}
|
||||
const places = [...new Set(placeIds)];
|
||||
if (places.length > 0 && (await prisma.location.count({ where: { id: { in: places }, campaignId: req.campaign.id } })) !== places.length) {
|
||||
return res.status(400).json({ error: 'Pick places from this campaign\'s locations.' });
|
||||
}
|
||||
|
||||
const order = await prisma.encounter.findMany({
|
||||
where: { campaignId: req.campaign.id },
|
||||
@@ -899,6 +907,12 @@ router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req,
|
||||
}
|
||||
return created;
|
||||
});
|
||||
|
||||
// A free-roam scene's places. Who is in it is the DM's to add, as with
|
||||
// any encounter: the party may be split, some of them somewhere else.
|
||||
if (encounter.mode === 'FREE_ROAM' && places.length > 0) {
|
||||
await prisma.encounterLocation.createMany({ data: places.map((locationId) => ({ encounterId: encounter.id, locationId })) });
|
||||
}
|
||||
res.status(201).json({ encounter });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -961,8 +975,16 @@ const npcSchema = z.object({
|
||||
// null for an NPC who does not change money.
|
||||
exchangeGold: z.number().int().min(1).max(1000000).optional().nullable(),
|
||||
exchangeCash: z.number().int().min(1).max(1000000).optional().nullable(),
|
||||
// Where they are usually found: met there in a free-roam scene.
|
||||
locationId: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
// Whether a location named for an NPC, a ware or a scene is one of this campaign's.
|
||||
async function locationInCampaign(locationId, campaignId) {
|
||||
return !locationId || Boolean(await prisma.location.findFirst({ where: { id: locationId, campaignId }, select: { id: true } }));
|
||||
}
|
||||
const NOT_A_PLACE = 'That location is not part of this campaign.';
|
||||
|
||||
// A rate has two sides: both are given, or both are cleared.
|
||||
function halfARate(data) {
|
||||
const sides = [data.exchangeGold, data.exchangeCash];
|
||||
@@ -982,6 +1004,7 @@ router.post('/:id/npcs', requireCampaignMember(), requireDM(), async (req, res,
|
||||
const parsed = npcSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
if (halfARate(parsed.data)) return res.status(400).json({ error: HALF_A_RATE });
|
||||
if (!(await locationInCampaign(parsed.data.locationId, req.campaign.id))) return res.status(400).json({ error: NOT_A_PLACE });
|
||||
const npc = await prisma.npc.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
|
||||
res.status(201).json({ npc });
|
||||
} catch (err) {
|
||||
@@ -994,6 +1017,7 @@ router.patch('/:id/npcs/:npcId', requireCampaignMember(), requireDM(), async (re
|
||||
const parsed = npcSchema.partial().safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
if (halfARate(parsed.data)) return res.status(400).json({ error: HALF_A_RATE });
|
||||
if (!(await locationInCampaign(parsed.data.locationId, req.campaign.id))) return res.status(400).json({ error: NOT_A_PLACE });
|
||||
const npc = await updatePoolEntry('npc', req.params.npcId, req.campaign.id, parsed.data);
|
||||
if (!npc) return res.status(404).json({ error: 'NPC not found in this campaign' });
|
||||
emitTradeChanged(req);
|
||||
@@ -1106,6 +1130,7 @@ const wareSchema = z
|
||||
.object({
|
||||
npcId: z.string().optional(),
|
||||
encounterId: z.string().optional(),
|
||||
locationId: z.string().optional(), // a shop that is always at this place
|
||||
weaponId: z.string().optional(),
|
||||
itemId: z.string().optional(),
|
||||
price: z.number().int().min(1).max(1000000),
|
||||
@@ -1113,7 +1138,7 @@ const wareSchema = z
|
||||
level: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
|
||||
stock: z.number().int().min(0).max(9999).optional().nullable(), // null: no limit
|
||||
})
|
||||
.refine((data) => Boolean(data.npcId) !== Boolean(data.encounterId), { message: 'Say who sells it: an NPC or an encounter, not both' })
|
||||
.refine((data) => [data.npcId, data.encounterId, data.locationId].filter(Boolean).length === 1, { message: 'Say where it is sold: by an NPC, at an encounter, or at a location (one of them)' })
|
||||
.refine((data) => Boolean(data.weaponId) !== Boolean(data.itemId), { message: 'Pick one weapon or one item to sell' });
|
||||
|
||||
router.post('/:id/wares', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
@@ -1123,7 +1148,7 @@ router.post('/:id/wares', requireCampaignMember(), requireDM(), async (req, res,
|
||||
const data = parsed.data;
|
||||
const inCampaign = async (model, id) => (id ? prisma[model].findFirst({ where: { id, campaignId: req.campaign.id } }) : null);
|
||||
|
||||
const seller = (await inCampaign('npc', data.npcId)) || (await inCampaign('encounter', data.encounterId));
|
||||
const seller = (await inCampaign('npc', data.npcId)) || (await inCampaign('encounter', data.encounterId)) || (await inCampaign('location', data.locationId));
|
||||
if (!seller) return res.status(400).json({ error: 'That seller is not part of this campaign.' });
|
||||
const entry = (await inCampaign('weapon', data.weaponId)) || (await inCampaign('item', data.itemId));
|
||||
if (!entry) return res.status(400).json({ error: `That ${data.weaponId ? 'weapon' : 'item'} is not part of this campaign.` });
|
||||
|
||||
@@ -48,7 +48,9 @@ const { speciesBonusProblem } = require('../game/data/species');
|
||||
const { SKILLS } = require('../game/data/skills');
|
||||
const { CURRENCIES, COINS, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, spendCoins, worthInGold, giveGear } = require('../utils/grants');
|
||||
const { recordVisits } = require('../utils/locations');
|
||||
const { SHEET_INCLUDE, sheetOf, settleHp } = require('../utils/sheet');
|
||||
const { SHEET_INCLUDE, sheetOf, settleHp, bySpellLevel } = require('../utils/sheet');
|
||||
const { inTurnOrder, weaponSwapOpen } = require('../game/combat');
|
||||
const { isFallen } = require('../utils/actions');
|
||||
const { absorbDamage, NO_DEATH_SAVES, lifeState, deathSaveInfo, settleDeathSaves, changeHp, rollDeathSave } = require('../utils/hp');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -173,7 +175,7 @@ function serializeCharacter(character, isOwner, isDM = true) {
|
||||
usable: canUseGear(character.level, cw.level),
|
||||
};
|
||||
}),
|
||||
spells: character.spells.map((cs) => ({ ...cs, usable: canUseGear(character.level, cs.spell.level) })),
|
||||
spells: [...character.spells].sort(bySpellLevel).map((cs) => ({ ...cs, usable: canUseGear(character.level, cs.spell.level) })),
|
||||
sheet: character.buildStage === 'COMPLETE' ? sheet : null,
|
||||
};
|
||||
}
|
||||
@@ -1439,7 +1441,7 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
|
||||
damageDice: ii.item.damageDice ? scaledDamageDice(ii.item.damageDice, ii.level, ii.item.damageScaling, ii.item.minLevel) : null,
|
||||
...effectsOf(ii.item),
|
||||
}));
|
||||
const spells = character.spells.map((cs) => ({
|
||||
const spells = [...character.spells].sort(bySpellLevel).map((cs) => ({
|
||||
characterSpellId: cs.id,
|
||||
name: cs.spell.name,
|
||||
level: cs.spell.level,
|
||||
@@ -1620,6 +1622,46 @@ function requireDmForGear(req, res) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A weapon is not swapped in the middle of a fight. For a character in a
|
||||
// battle that is under way, the weapon in hand changes only as the battle
|
||||
// begins (before anyone has made a move) or on their own turn once their
|
||||
// attack has landed, before the turn passes (game/combat.js, weaponSwapOpen).
|
||||
// Returns what to tell the player when it is not one of those moments. The
|
||||
// DM is not held to it: they referee, and put mistakes right.
|
||||
async function weaponSwapWait(character) {
|
||||
const rows = await prisma.encounterParticipant.findMany({
|
||||
where: { characterId: character.id, encounter: { status: 'ACTIVE', mode: 'BATTLE', archivedAt: null } },
|
||||
include: { encounter: true },
|
||||
});
|
||||
for (const row of rows) {
|
||||
const { encounter } = row;
|
||||
const participants = await prisma.encounterParticipant.findMany({
|
||||
where: { encounterId: encounter.id },
|
||||
include: { character: { select: { currentHp: true, deathSaveFailures: true, stable: true } } },
|
||||
});
|
||||
const first = inTurnOrder(participants).find((p) => !isFallen(p));
|
||||
// What has landed, and whether an attack somebody declared is still being
|
||||
// resolved (one the server lined up for a monster is not a move yet).
|
||||
const actions = await prisma.encounterAction.findMany({
|
||||
where: { encounterId: encounter.id, status: { in: ['APPLIED', 'PENDING'] } },
|
||||
select: { round: true, actorParticipantId: true, status: true, auto: true },
|
||||
});
|
||||
const applied = actions.filter((a) => a.status === 'APPLIED');
|
||||
const open = weaponSwapOpen({
|
||||
round: encounter.currentRound,
|
||||
activeId: encounter.activeParticipantId,
|
||||
firstId: first ? first.id : null,
|
||||
participantId: row.id,
|
||||
applied,
|
||||
declared: actions.some((a) => a.status === 'PENDING' && !a.auto),
|
||||
});
|
||||
if (!open) {
|
||||
return `${character.name} is in a battle (${encounter.name}). A weapon can be changed as the battle begins, before anyone has made a move, or on ${character.name}'s own turn once their attack has landed. The DM can change it at any time.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function emitCharacterUpdated(req) {
|
||||
req.app.get('io').to(`campaign:${req.character.campaignId}`).emit('character:updated', { characterId: req.character.id });
|
||||
}
|
||||
@@ -1745,6 +1787,11 @@ for (const gear of GEAR) {
|
||||
const blocked = await equipBlocked(req, gear.pool, req.params.poolId);
|
||||
if (blocked) return res.status(400).json({ error: blocked });
|
||||
}
|
||||
// Drawing or putting away a weapon in a battle waits for the right moment.
|
||||
if (gear.pool === 'weapon' && !req.isDM && parsed.data.equipped !== undefined && parsed.data.equipped !== existing.equipped) {
|
||||
const wait = await weaponSwapWait(req.character);
|
||||
if (wait) return res.status(400).json({ error: wait });
|
||||
}
|
||||
|
||||
const row = await prisma[gear.held].update({ where: whereHeld(req), data, include });
|
||||
if (gear.ownLevel) await settleHp(prisma, [req.character.id]);
|
||||
|
||||
@@ -8,10 +8,10 @@ const { loadCampaignMembership } = require('../middleware/campaignAccess');
|
||||
const { computeCharacterSheet, computeWeaponStats, scaledDamageDice, canUseGear, abilityModifier, encounterDifficulty, MAX_GEAR_LEVEL } = require('../game/calculations');
|
||||
const { CURRENCIES, InsufficientGoldError, adjustGold, spendCoins, giveGear } = require('../utils/grants');
|
||||
const { moveCharacters, visitedLocationIds } = require('../utils/locations');
|
||||
const { SHEET_INCLUDE } = require('../utils/sheet');
|
||||
const { SHEET_INCLUDE, bySpellLevel } = require('../utils/sheet');
|
||||
const { NO_DEATH_SAVES, lifeState, deathSaveInfo, changeHp } = require('../utils/hp');
|
||||
const { subclassName } = require('../game/data/subclasses');
|
||||
const { settle, rollDamage, rollCritDamage } = require('../game/combat');
|
||||
const { settle, rollDamage, rollCritDamage, inTurnOrder } = require('../game/combat');
|
||||
const { rollDie } = require('../game/dice');
|
||||
const { ActionError, targetRows, describeSource, autoActions } = require('../utils/actions');
|
||||
|
||||
@@ -59,13 +59,6 @@ function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
// Highest initiative first; ties keep the order combatants were added in.
|
||||
// Anyone who has not rolled yet waits at the bottom.
|
||||
function inTurnOrder(participants) {
|
||||
const rolled = (p) => (p.initiativeRoll == null ? 0 : 1);
|
||||
return [...participants].sort((a, b) => rolled(b) - rolled(a) || b.initiative - a.initiative || (a.id < b.id ? -1 : 1));
|
||||
}
|
||||
|
||||
// What is added to a combatant's d20 for initiative: the sheet's initiative
|
||||
// bonus for a character, the Dexterity modifier for a monster, and nothing for
|
||||
// an NPC (they have no ability scores). Takes a participant with its
|
||||
@@ -181,6 +174,7 @@ function characterCombatInfo(character, subclasses = true) {
|
||||
}));
|
||||
const spells = character.spells
|
||||
.filter((cs) => canUseGear(character.level, cs.spell.level))
|
||||
.sort(bySpellLevel)
|
||||
.map((cs) => ({
|
||||
characterSpellId: cs.id,
|
||||
name: cs.spell.name,
|
||||
@@ -296,13 +290,16 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
// Likewise their conditions: the character's own, which outlast the scene.
|
||||
out.conditions = character.conditions;
|
||||
out.character = { id: character.id, name: character.name, userId: character.userId };
|
||||
// Where each character is, is part of a free-roam scene; elsewhere it stays on their sheet.
|
||||
if (encounter.mode === 'FREE_ROAM') out.character.currentLocationId = character.currentLocationId;
|
||||
out.characterCombatInfo = combatInfo;
|
||||
}
|
||||
out.conditionRounds = roundsFor(p.conditionRounds, out.conditions);
|
||||
if (monster) out.monster = isDM ? monster : { id: monster.id, name: monster.name };
|
||||
if (npc) {
|
||||
const { dmNotes, ac, maxHp, attackBonus, damageDice, campaignId, createdAt, ...publicNpc } = npc;
|
||||
out.npc = isDM ? { ...publicNpc, dmNotes, ac, maxHp, attackBonus, damageDice } : publicNpc;
|
||||
// Where they are usually found is the DM's to know, like their notes and stats.
|
||||
const { dmNotes, ac, maxHp, attackBonus, damageDice, locationId, campaignId, createdAt, ...publicNpc } = npc;
|
||||
out.npc = isDM ? { ...publicNpc, dmNotes, ac, maxHp, attackBonus, damageDice, locationId } : publicNpc;
|
||||
}
|
||||
if (hideHp && !character) {
|
||||
// Rounded up, so anything still standing shows a sliver of bar.
|
||||
@@ -337,6 +334,31 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
.slice(0, 6)
|
||||
.map(showAction);
|
||||
|
||||
// Free roam: the places the party can go to, each with who is found there,
|
||||
// what is for sale there, and which of the characters in the scene are
|
||||
// there now. Everyone in the scene sees every place in it. An NPC's secrets
|
||||
// and stats stay with the DM, as anywhere.
|
||||
let places;
|
||||
if (encounter.mode === 'FREE_ROAM') {
|
||||
const rows = await prisma.encounterLocation.findMany({
|
||||
where: { encounterId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: { location: { include: { npcs: { include: { wares: WARES }, orderBy: { name: 'asc' } }, wares: WARES } } },
|
||||
});
|
||||
places = rows.map(({ location: place }) => ({
|
||||
id: place.id,
|
||||
name: place.name,
|
||||
description: place.description,
|
||||
wares: place.wares,
|
||||
npcs: place.npcs.map(({ dmNotes: secrets, ac, maxHp, attackBonus, damageDice, locationId, campaignId, createdAt, ...publicNpc }) =>
|
||||
isDM ? { ...publicNpc, dmNotes: secrets, ac, maxHp, attackBonus, damageDice, locationId } : publicNpc
|
||||
),
|
||||
here: encounter.participants
|
||||
.filter((p) => p.character && p.character.currentLocationId === place.id)
|
||||
.map((p) => ({ characterId: p.character.id, name: p.displayName, userId: p.character.userId })),
|
||||
}));
|
||||
}
|
||||
|
||||
const { dmNotes, rewards, campaign, location, ...scene } = encounter;
|
||||
const knowsPlace = isDM || (location && (await visitedLocationIds(prisma, viewerId, encounter.campaignId)).has(location.id));
|
||||
return {
|
||||
@@ -350,6 +372,7 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
fallen,
|
||||
actions,
|
||||
recentActions,
|
||||
...(places ? { places } : {}),
|
||||
rewards: isDM ? rewards : rewards.filter((r) => r.awardedAt),
|
||||
};
|
||||
}
|
||||
@@ -372,7 +395,8 @@ async function syncCharacterLocations(req) {
|
||||
where: { id: req.encounter.id },
|
||||
include: { participants: { where: { characterId: { not: null } }, include: { character: { select: { id: true, currentLocationId: true } } } } },
|
||||
});
|
||||
if (!encounter || encounter.status !== 'ACTIVE' || !encounter.locationId) return;
|
||||
// In free roam each character goes where their player sends them.
|
||||
if (!encounter || encounter.status !== 'ACTIVE' || !encounter.locationId || encounter.mode === 'FREE_ROAM') return;
|
||||
|
||||
const elsewhere = encounter.participants.map((p) => p.character).filter((c) => c.currentLocationId !== encounter.locationId);
|
||||
await moveCharacters(prisma, elsewhere.map((c) => c.id), encounter.locationId);
|
||||
@@ -396,7 +420,7 @@ router.get('/:id', requireEncounterAccess, async (req, res, next) => {
|
||||
|
||||
const patchEncounterSchema = z.object({
|
||||
status: z.enum(['PLANNING', 'ACTIVE', 'COMPLETE']).optional(),
|
||||
mode: z.enum(['BATTLE', 'QUEST']).optional(),
|
||||
mode: z.enum(['BATTLE', 'QUEST', 'FREE_ROAM']).optional(),
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
description: z.string().max(4000).nullable().optional(),
|
||||
dmNotes: z.string().max(4000).nullable().optional(),
|
||||
@@ -1143,6 +1167,58 @@ router.post('/:id/actions/:actionId/apply', requireEncounterAccess, requireDM, a
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Free roam — the party wanders between the places the DM has laid out for
|
||||
// the scene: to shop, to meet whoever is found there. The DM sets the places;
|
||||
// each player sends their own character to one (the DM can send anyone).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The whole list of places; an empty one clears it.
|
||||
router.put('/:id/places', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ locationIds: z.array(z.string()).max(50) }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const locationIds = [...new Set(parsed.data.locationIds)];
|
||||
if ((await prisma.location.count({ where: { id: { in: locationIds }, campaignId: req.encounter.campaignId } })) !== locationIds.length) {
|
||||
return res.status(400).json({ error: "Pick places from this campaign's locations." });
|
||||
}
|
||||
const had = await prisma.encounterLocation.findMany({ where: { encounterId: req.encounter.id }, select: { locationId: true } });
|
||||
const keep = new Set(had.map((row) => row.locationId));
|
||||
await prisma.$transaction([
|
||||
prisma.encounterLocation.deleteMany({ where: { encounterId: req.encounter.id, locationId: { notIn: locationIds } } }),
|
||||
// Added one at a time, in the order given, so the list keeps that order.
|
||||
...locationIds.filter((id) => !keep.has(id)).map((locationId) => prisma.encounterLocation.create({ data: { encounterId: req.encounter.id, locationId } })),
|
||||
]);
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// A character goes to one of the scene's places: their own player sends them, or the DM.
|
||||
router.post('/:id/visit', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ characterId: z.string(), locationId: z.string() }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { characterId, locationId } = parsed.data;
|
||||
if (req.encounter.mode !== 'FREE_ROAM') return res.status(400).json({ error: 'Only a free-roam scene has places to go to.' });
|
||||
if (req.encounter.status !== 'ACTIVE') return res.status(400).json({ error: 'The scene has not begun yet.' });
|
||||
|
||||
const row = await prisma.encounterParticipant.findFirst({ where: { encounterId: req.encounter.id, characterId }, include: { character: true } });
|
||||
if (!row) return res.status(400).json({ error: 'That character is not in this scene.' });
|
||||
if (!req.isDM && row.character.userId !== req.user.id) return res.status(403).json({ error: 'You can only move your own character.' });
|
||||
if (isFallen(row)) return res.status(400).json({ error: `${row.character.name} is dead.` });
|
||||
const place = await prisma.encounterLocation.findFirst({ where: { encounterId: req.encounter.id, locationId } });
|
||||
if (!place) return res.status(400).json({ error: 'That is not one of the places in this scene.' });
|
||||
|
||||
await moveCharacters(prisma, [characterId], locationId);
|
||||
emitCharacterUpdated(req, characterId);
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Experience — awarded once, shared evenly between the characters in the scene
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1333,10 +1409,18 @@ function money(amount, currency) {
|
||||
}
|
||||
|
||||
// Trading takes someone to trade with and the time to do it, so it happens
|
||||
// in a quest in progress and never in a battle.
|
||||
// in a quest or a free-roam scene in progress, and never in a battle.
|
||||
function assertTradeOpen(encounter) {
|
||||
if (encounter.mode !== 'QUEST') throw new TradeError('Trading happens in a quest, not in a battle.');
|
||||
if (encounter.status !== 'ACTIVE') throw new TradeError('Trading opens once the quest is in progress.');
|
||||
if (encounter.mode === 'BATTLE') throw new TradeError('Trading happens in a quest or while roaming, not in a battle.');
|
||||
if (encounter.status !== 'ACTIVE') throw new TradeError(`Trading opens once the ${encounter.mode === 'QUEST' ? 'quest' : 'scene'} is in progress.`);
|
||||
}
|
||||
|
||||
// In a free-roam scene a character trades where they are: the place has to
|
||||
// be one of the scene's, and they have to have gone there.
|
||||
async function assertAtPlace(req, character, locationId, what) {
|
||||
const place = locationId ? await prisma.encounterLocation.findFirst({ where: { encounterId: req.encounter.id, locationId }, include: { location: true } }) : null;
|
||||
if (!place) throw new TradeError(`${what} is not here to trade with.`);
|
||||
if (character.currentLocationId !== locationId) throw new TradeError(`${character.name} has to go to ${place.location.name} first.`);
|
||||
}
|
||||
|
||||
// The character doing the trading: in this scene, alive, and the player's
|
||||
@@ -1349,8 +1433,15 @@ async function loadTrader(req, characterId) {
|
||||
return row.character;
|
||||
}
|
||||
|
||||
// An NPC to trade with has to be in the scene and still standing.
|
||||
async function loadTradingNpc(req, npcId) {
|
||||
// An NPC to trade with has to be in the scene and still standing; in a
|
||||
// free-roam scene, at one of its places, with the character there too.
|
||||
async function loadTradingNpc(req, npcId, character) {
|
||||
if (req.encounter.mode === 'FREE_ROAM') {
|
||||
const npc = await prisma.npc.findFirst({ where: { id: npcId, campaignId: req.encounter.campaignId } });
|
||||
if (!npc) throw new TradeError('That NPC is not here to trade with.');
|
||||
await assertAtPlace(req, character, npc.locationId, npc.name);
|
||||
return npc;
|
||||
}
|
||||
const row = await prisma.encounterParticipant.findFirst({ where: { encounterId: req.encounter.id, npcId }, include: { npc: true } });
|
||||
if (!row || isFallen(row)) throw new TradeError('That NPC is not here to trade with.');
|
||||
return row.npc;
|
||||
@@ -1383,7 +1474,7 @@ router.post(
|
||||
const to = from === 'GOLD' ? 'CASH' : 'GOLD';
|
||||
|
||||
const character = await loadTrader(req, characterId);
|
||||
const npc = await loadTradingNpc(req, npcId);
|
||||
const npc = await loadTradingNpc(req, npcId, character);
|
||||
if (!npc.exchangeGold || !npc.exchangeCash) throw new TradeError(`${npc.name} does not change money.`);
|
||||
const rate = { GOLD: npc.exchangeGold, CASH: npc.exchangeCash };
|
||||
if (amount % rate[from] !== 0) {
|
||||
@@ -1425,7 +1516,12 @@ router.post(
|
||||
if (!ware || ware.campaignId !== req.encounter.campaignId || (ware.encounterId && ware.encounterId !== req.encounter.id)) {
|
||||
throw new TradeError('That is not for sale here.', 404);
|
||||
}
|
||||
const seller = ware.npcId ? (await loadTradingNpc(req, ware.npcId)).name : null;
|
||||
// A shop at a place sells to whoever has gone there, while that place is part of the scene.
|
||||
if (ware.locationId) {
|
||||
if (req.encounter.mode !== 'FREE_ROAM') throw new TradeError('That is not for sale here.', 404);
|
||||
await assertAtPlace(req, character, ware.locationId, 'That shop');
|
||||
}
|
||||
const seller = ware.npcId ? (await loadTradingNpc(req, ware.npcId, character)).name : null;
|
||||
const kind = ware.weapon ? 'weapon' : 'item';
|
||||
const entry = ware[kind];
|
||||
const total = ware.price * quantity;
|
||||
|
||||
@@ -60,7 +60,7 @@ const POOLS = {
|
||||
},
|
||||
items: {
|
||||
library: ITEM_LIBRARY,
|
||||
row: z.strictObject({ name: text(120), description: optionalText(2000), damageDice: dice, ...gear }),
|
||||
row: z.strictObject({ name: text(120), description: optionalText(2000), damageDice: dice, armorType: z.enum(['LIGHT', 'MEDIUM', 'HEAVY', 'SHIELD']).nullish(), ...gear }),
|
||||
},
|
||||
spells: {
|
||||
library: SPELL_LIBRARY,
|
||||
|
||||
@@ -115,8 +115,19 @@ function describeSource(actor, source) {
|
||||
const spell = known.spell;
|
||||
if (!canUseGear(character.level, spell.level)) throw new ActionError(`${spell.name} is beyond ${character.name}'s level.`);
|
||||
const casting = sheet.spellcasting;
|
||||
// A cantrip takes no slot; any other spell takes one of its own level or higher.
|
||||
const slotLevel = spell.level > 0 ? Math.max(spell.level, source.slotLevel || spell.level) : null;
|
||||
// A cantrip takes no slot; any other spell takes one of its own level, or
|
||||
// the lowest one above it that is left. With none left it cannot be cast.
|
||||
// (A character with no slots at all, handed a spell by the DM, casts it freely.)
|
||||
let slotLevel = null;
|
||||
if (spell.level > 0) {
|
||||
slotLevel = Math.max(spell.level, source.slotLevel || spell.level);
|
||||
if (sheet.spellSlots) {
|
||||
const wanted = slotLevel;
|
||||
const free = sheet.spellSlots.levels.find((l) => l.level >= wanted && l.remaining > 0);
|
||||
if (!free) throw new ActionError(`${character.name} has no spell slot of level ${wanted} or higher left, so cannot cast ${spell.name}.`);
|
||||
slotLevel = free.level;
|
||||
}
|
||||
}
|
||||
const base = { damageDice: spell.damageDice || null, damageBonus: 0, damageType: null, characterId: character.id, spellLevel: spell.level, slotLevel };
|
||||
if (spell.attackRoll) return { kind: 'ATTACK', name: spell.name, detail: { ...base, attackBonus: casting ? casting.attackBonus : 0 } };
|
||||
if (spell.saveAbility) {
|
||||
|
||||
@@ -34,4 +34,8 @@ async function settleHp(db, characterIds) {
|
||||
return changed;
|
||||
}
|
||||
|
||||
module.exports = { SHEET_INCLUDE, sheetOf, settleHp };
|
||||
// A character's spells in the order they are listed everywhere: cantrips
|
||||
// first, then by spell level, then by name.
|
||||
const bySpellLevel = (a, b) => a.spell.level - b.spell.level || a.spell.name.localeCompare(b.spell.name);
|
||||
|
||||
module.exports = { SHEET_INCLUDE, sheetOf, settleHp, bySpellLevel };
|
||||
|
||||
+74
-20
@@ -147,8 +147,10 @@ this order:
|
||||
Eye, Silver Tongue, Field Medic, Tough as Nails, …).
|
||||
|
||||
These are table rules written in plain words for the DM to apply. The app
|
||||
does not calculate anything from them: Rage does not add damage by itself, and
|
||||
Unarmored Defense does not change AC unless the DM sets the extra AC.
|
||||
does not calculate anything from most of them: Rage does not add damage by
|
||||
itself. The exception is the handful that change a number on the sheet
|
||||
(Unarmored Defense, Fast Movement and the rest): those are worked out for
|
||||
you, as section 8 ("Class features that are counted") sets out.
|
||||
|
||||
**Background** is five lines, one per question:
|
||||
|
||||
@@ -814,9 +816,9 @@ themselves.
|
||||
|
||||
| Stat | Formula |
|
||||
|---|---|
|
||||
| Armor Class | 10 + DEX modifier + equipped gear + the DM's extra AC |
|
||||
| Initiative | DEX modifier + equipped gear |
|
||||
| Speed | Species speed, unless a speed override is set, + equipped gear |
|
||||
| Armor Class | 10 + DEX modifier (as much of it as the armor worn lets through; or what a class feature gives without armor, below) + equipped gear + the DM's extra AC |
|
||||
| Initiative | DEX modifier + a bard's Jack of All Trades + equipped gear |
|
||||
| Speed | Species speed + a class's extra speed, unless a speed override is set, + equipped gear |
|
||||
| Passive Perception | 10 + Perception skill bonus + equipped gear |
|
||||
| Passive Investigation, Passive Insight | 10 + that skill's bonus (gear that raises the skill raises it too) |
|
||||
| Hit dice | One per level, of the class's die size (shown only; not spent or tracked) |
|
||||
@@ -832,6 +834,57 @@ item is doing what. An effect on an ability score changes the score itself,
|
||||
so everything in sections 5 to 10 that is built on that ability moves too.
|
||||
The full rules are in [ITEMS.md](ITEMS.md), section 5.
|
||||
|
||||
**Dexterity in armor.** How much of the DEX modifier counts toward AC goes by
|
||||
the armor that is equipped (items are marked light, medium or heavy:
|
||||
[ITEMS.md](ITEMS.md), section 5):
|
||||
|
||||
| Armor worn | DEX modifier counted toward AC |
|
||||
|---|---|
|
||||
| None, or light | All of it |
|
||||
| Medium | At most +2 (a penalty counts in full) |
|
||||
| Heavy | None of it, a penalty included |
|
||||
|
||||
Only AC is affected: initiative, Dexterity saves and skills use the full
|
||||
modifier. When the armor is holding something back, the "From Equipped Gear"
|
||||
card says so. A shield changes nothing here, and neither does an item with
|
||||
+AC that is not marked as armor.
|
||||
|
||||
### Class features that are counted
|
||||
|
||||
These class features change a number, so the sheet works them out. Nobody
|
||||
adds them by hand, and the "From Class Features" card on the sheet lists the
|
||||
ones in force and what each is doing.
|
||||
|
||||
| Feature | From | What the sheet does |
|
||||
|---|---|---|
|
||||
| Unarmored Defense | Barbarian 1 | AC starts from 10 + DEX + CON while no armor is equipped. A shield still adds |
|
||||
| Unarmored Defense | Monk 1 | AC starts from 10 + DEX + WIS while no armor and no shield is equipped |
|
||||
| Draconic Resilience | Sorcerer (Draconic Bloodline) 3 | AC starts from 13 + DEX while no armor is equipped, and maximum HP is 1 higher for each sorcerer level |
|
||||
| Fast Movement | Barbarian 5 | +10 ft speed unless heavy armor is equipped |
|
||||
| Unarmored Movement | Monk 2 | +10 ft speed (15 at monk level 6, 20 at 10, 25 at 14, 30 at 18) while no armor and no shield is equipped |
|
||||
| Aura of Protection | Paladin 6 | Charisma modifier (at least +1) added to every one of the paladin's own saving throws. Allies in the aura add it by hand |
|
||||
| Diamond Soul | Monk 14 | Proficient in every saving throw |
|
||||
| Slippery Mind | Rogue 15 | Proficient in Wisdom saving throws |
|
||||
| Jack of All Trades | Bard 2 | Half the proficiency bonus (rounded down) added to initiative and to every skill the bard is not proficient in (and so to the passive scores) |
|
||||
|
||||
- **By class level.** A multiclassed character gets each feature at the level
|
||||
it comes in the class that gives it: a Paladin 6 / Barbarian 1 has the aura
|
||||
and Unarmored Defense, but not Fast Movement.
|
||||
- **One way of working out AC.** With more than one (a barbarian who is also a
|
||||
monk), the best is used, never both. It is left out when plain 10 + DEX is
|
||||
better (a barbarian with a Constitution penalty). Gear and the DM's Extra AC
|
||||
add on top either way.
|
||||
- **What is worn** is read from equipped items marked as **armor** (light,
|
||||
medium or heavy) or as a **shield** ([ITEMS.md](ITEMS.md), section 5). The
|
||||
library's armor and shield are marked already. An item that only gives +AC
|
||||
and is not marked (a ring) is not armor and stops nothing.
|
||||
- **A speed override wins.** When the DM has set a speed for a character it is
|
||||
used as it is, and a class's extra speed is not added to it.
|
||||
- **If the DM used to add these by hand** (Constitution put into Extra AC for
|
||||
a barbarian, say), take that out, or it now counts twice.
|
||||
|
||||
Every other class feature is still a line of text for the table to apply.
|
||||
|
||||
**Extra AC** is a number only the DM can set on the sheet, added to AC on top
|
||||
of gear, for anything an item does not cover. It can be negative. (It used to
|
||||
be called "armor bonus" and stood in for all armor; characters keep whatever
|
||||
@@ -1343,7 +1396,8 @@ calculations for them.
|
||||
rest and lists conditions, but applies none of their effects
|
||||
- Counters for species traits, talents and Boons, and uses the special
|
||||
abilities give "a few times per day" beyond the ones in section 7
|
||||
- Armor rules beyond a flat bonus to AC (no "replaces 10 + DEX", no cap on DEX)
|
||||
- Armor proficiency, a Strength score needed for heavy armor, and
|
||||
disadvantage on Stealth (these are notes on the armor)
|
||||
- Weapon proficiency (everyone is proficient with everything)
|
||||
- Shared rolls: a roll made from the sheet is seen only by whoever clicked,
|
||||
and is not recorded
|
||||
@@ -1426,12 +1480,12 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
|
||||
| Class | Abilities |
|
||||
|---|---|
|
||||
| Artificer | **Magical Tinkering** — Touch a tiny object to give it a small magic: it shines, plays a recorded message, gives off a smell or sound, or shows a picture. You can keep as many going as your Intelligence modifier.<br>**Spellcasting** — You cast artificer spells through tools: thieves’ tools, artisan’s tools or an infused item serve as your focus. |
|
||||
| Barbarian | **Rage** — Twice per day, for one minute: +2 melee damage, advantage on Strength checks and saves, and physical damage against you is halved.<br>**Unarmored Defense** — Without armor, add your Constitution modifier to your AC (ask your DM to set your armor bonus). |
|
||||
| Barbarian | **Rage** — Twice per day, for one minute: +2 melee damage, advantage on Strength checks and saves, and physical damage against you is halved.<br>**Unarmored Defense** — Without armor, add your Constitution modifier to your AC (counted on your sheet). A shield still adds. |
|
||||
| Bard | **Bardic Inspiration** — A few times per day, as a bonus action, give an ally a d6 to add to one attack, check or save.<br>**Jack of Many Tales** — You have heard a story about nearly everything: ask your DM for one useful rumour per session. |
|
||||
| Cleric | **Divine Domain** — Choose with your DM what your god stands for (life, light, war, knowledge…). It shapes the favours you can ask.<br>**Healing Hands** — Once per rest, touch a creature to restore 1d8 + your Wisdom modifier hit points. |
|
||||
| Druid | **Druidic** — You know the secret language of druids and can leave hidden messages in it.<br>**Nature’s Voice** — You can calm and make simple requests of ordinary animals. |
|
||||
| Fighter | **Second Wind** — Once per rest, as a bonus action, recover 1d10 + your level hit points.<br>**Fighting Style** — Choose one with your DM: +2 to ranged attacks, +1 AC in armor, or +2 damage with a one-handed weapon. |
|
||||
| Monk | **Martial Arts** — Your unarmed strikes deal 1d4 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.<br>**Unarmored Defense** — Without armor, add your Wisdom modifier to your AC (ask your DM to set your armor bonus). |
|
||||
| Monk | **Martial Arts** — Your unarmed strikes deal 1d4 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.<br>**Unarmored Defense** — Without armor or a shield, add your Wisdom modifier to your AC (counted on your sheet). |
|
||||
| Paladin | **Divine Sense** — A few times per day, sense any celestial, fiend or undead within 60 feet.<br>**Lay on Hands** — A pool of healing equal to 5 × your level per day, spent by touch however you like. |
|
||||
| Ranger | **Favored Enemy** — Choose a kind of creature with your DM. Advantage on checks to track them and to recall what you know about them.<br>**Natural Explorer** — In the wild you cannot get lost, you find twice as much food, and difficult terrain does not slow your group. |
|
||||
| Rogue | **Sneak Attack** — Once per turn, deal an extra 1d6 damage to a target you have advantage against, or that has one of your allies next to it.<br>**Thieves’ Cant** — You know the secret slang and signs of the criminal underworld. |
|
||||
@@ -1706,7 +1760,7 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
|
||||
| 2 | **Reckless Attack**: On your first attack each turn you may attack with advantage; attacks against you have advantage until your next turn.<br>**Danger Sense**: Advantage on Dexterity saves against effects you can see (traps, breath, spells). |
|
||||
| 3 | **Rage** (replaces): Three times per day, for one minute: +2 melee damage, advantage on Strength checks and saves, and physical damage against you is halved. |
|
||||
| 4 | **Boon** (choose one) |
|
||||
| 5 | **Extra Attack**: Attack twice when you take the Attack action.<br>**Fast Movement**: +10 ft speed when not in heavy armor (ask your DM to raise your speed). |
|
||||
| 5 | **Extra Attack**: Attack twice when you take the Attack action.<br>**Fast Movement**: +10 ft speed when not in heavy armor (counted on your sheet). |
|
||||
| 6 | **Rage** (replaces): Four times per day, for one minute: +2 melee damage, advantage on Strength checks and saves, and physical damage against you is halved. |
|
||||
| 7 | **Feral Instinct**: Advantage on initiative; you can act normally in a surprise round if you rage first. |
|
||||
| 8 | **Boon** (choose one) |
|
||||
@@ -1776,7 +1830,7 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
|
||||
|
||||
| Level | Written into Special Abilities |
|
||||
|---|---|
|
||||
| 2 | **Jack of All Trades**: Add half your proficiency bonus (round down) to any check you are not proficient in.<br>**Song of Rest**: During a short rest, allies who spend Hit Dice heal an extra 1d6. |
|
||||
| 2 | **Jack of All Trades**: Add half your proficiency bonus (round down) to any check you are not proficient in (counted on your sheet for skills and initiative).<br>**Song of Rest**: During a short rest, allies who spend Hit Dice heal an extra 1d6. |
|
||||
| 3 | **Expertise**: Double proficiency on two proficient skills (add by hand). |
|
||||
| 4 | **Boon** (choose one) |
|
||||
| 5 | **Bardic Inspiration** (replaces): A few times per day, as a bonus action, give an ally a d8 to add to one attack, check or save.<br>**Font of Inspiration**: Regain Bardic Inspiration on a short rest.<br>**Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
|
||||
@@ -2059,23 +2113,23 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
|
||||
|
||||
| Level | Written into Special Abilities |
|
||||
|---|---|
|
||||
| 2 | **Ki**: Points equal to your level; they come back on a short rest. Ki save DC is 8 + proficiency bonus + Wisdom modifier.<br>**Flurry of Blows**: 1 ki: two unarmed strikes as a bonus action.<br>**Patient Defense**: 1 ki: Dodge as a bonus action.<br>**Step of the Wind**: 1 ki: Dash or Disengage as a bonus action, jump distance doubled.<br>**Unarmored Movement**: +10 ft speed without armor (ask your DM to raise your speed). |
|
||||
| 2 | **Ki**: Points equal to your level; they come back on a short rest. Ki save DC is 8 + proficiency bonus + Wisdom modifier.<br>**Flurry of Blows**: 1 ki: two unarmed strikes as a bonus action.<br>**Patient Defense**: 1 ki: Dodge as a bonus action.<br>**Step of the Wind**: 1 ki: Dash or Disengage as a bonus action, jump distance doubled.<br>**Unarmored Movement**: +10 ft speed without armor (counted on your sheet). |
|
||||
| 3 | **Deflect Missiles**: Reaction: reduce ranged weapon damage by 1d10 + Dexterity modifier + your level. |
|
||||
| 4 | **Boon** (choose one)<br>**Slow Fall**: Reaction: reduce falling damage by 5 × your level. |
|
||||
| 5 | **Extra Attack**: Attack twice when you take the Attack action.<br>**Stunning Strike**: 1 ki after a melee hit: the target makes a Constitution save or is stunned until your next turn.<br>**Martial Arts** (replaces): Your unarmed strikes deal 1d6 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action. |
|
||||
| 6 | **Ki-Empowered Strikes**: Your unarmed strikes count as magical.<br>**Unarmored Movement** (replaces): +15 ft speed without armor (ask your DM to raise your speed). |
|
||||
| 6 | **Ki-Empowered Strikes**: Your unarmed strikes count as magical.<br>**Unarmored Movement** (replaces): +15 ft speed without armor (counted on your sheet). |
|
||||
| 7 | **Evasion**: On a Dexterity save for half damage, take none on a success and half on a failure.<br>**Stillness of Mind**: Action: end charm or fear on yourself. |
|
||||
| 8 | **Boon** (choose one) |
|
||||
| 9 | **Unarmored Movement** (replaces): +15 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move. |
|
||||
| 10 | **Purity of Body**: Immune to poison and disease.<br>**Unarmored Movement** (replaces): +20 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move. |
|
||||
| 9 | **Unarmored Movement** (replaces): +15 ft speed without armor (counted on your sheet). You can run along walls and across water during your move. |
|
||||
| 10 | **Purity of Body**: Immune to poison and disease.<br>**Unarmored Movement** (replaces): +20 ft speed without armor (counted on your sheet). You can run along walls and across water during your move. |
|
||||
| 11 | **Martial Arts** (replaces): Your unarmed strikes deal 1d8 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action. |
|
||||
| 12 | **Boon** (choose one) |
|
||||
| 13 | **Tongue of the Sun and Moon**: You understand every spoken language, and anyone who speaks one understands you. |
|
||||
| 14 | **Diamond Soul**: You are proficient in every saving throw (add the bonus by hand); 1 ki rerolls a failed save.<br>**Unarmored Movement** (replaces): +25 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move. |
|
||||
| 14 | **Diamond Soul**: You are proficient in every saving throw (counted on your sheet); 1 ki rerolls a failed save.<br>**Unarmored Movement** (replaces): +25 ft speed without armor (counted on your sheet). You can run along walls and across water during your move. |
|
||||
| 15 | **Timeless Body**: You need no food or water and suffer none of the frailty of old age. |
|
||||
| 16 | **Boon** (choose one) |
|
||||
| 17 | **Martial Arts** (replaces): Your unarmed strikes deal 1d10 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action. |
|
||||
| 18 | **Empty Body**: 4 ki: invisible for one minute, and all damage but force against you is halved. 8 ki: cast Astral Projection on yourself.<br>**Unarmored Movement** (replaces): +30 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move. |
|
||||
| 18 | **Empty Body**: 4 ki: invisible for one minute, and all damage but force against you is halved. 8 ki: cast Astral Projection on yourself.<br>**Unarmored Movement** (replaces): +30 ft speed without armor (counted on your sheet). You can run along walls and across water during your move. |
|
||||
| 19 | **Boon** (choose one) |
|
||||
| 20 | **Perfect Self**: When you roll initiative with no ki left, regain 4 ki. |
|
||||
|
||||
@@ -2136,7 +2190,7 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
|
||||
| 3 | **Divine Health**: Immune to disease. |
|
||||
| 4 | **Boon** (choose one) |
|
||||
| 5 | **Extra Attack**: Attack twice when you take the Attack action.<br>**Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
|
||||
| 6 | **Aura of Protection**: You and allies within 10 ft add your Charisma modifier to all saving throws while you are conscious. |
|
||||
| 6 | **Aura of Protection**: You and allies within 10 ft add your Charisma modifier to all saving throws while you are conscious (yours is counted on your sheet). |
|
||||
| 7 | No new feature |
|
||||
| 8 | **Boon** (choose one) |
|
||||
| 9 | No new feature |
|
||||
@@ -2148,7 +2202,7 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
|
||||
| 15 | No new feature |
|
||||
| 16 | **Boon** (choose one) |
|
||||
| 17 | No new feature |
|
||||
| 18 | **Aura of Protection** (replaces): You and allies within 30 ft add your Charisma modifier to all saving throws while you are conscious.<br>**Aura of Courage** (replaces): You and allies within 30 ft can’t be frightened. |
|
||||
| 18 | **Aura of Protection** (replaces): You and allies within 30 ft add your Charisma modifier to all saving throws while you are conscious (yours is counted on your sheet).<br>**Aura of Courage** (replaces): You and allies within 30 ft can’t be frightened. |
|
||||
| 19 | **Boon** (choose one) |
|
||||
| 20 | No new feature |
|
||||
|
||||
@@ -2271,7 +2325,7 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
|
||||
| 12 | **Boon** (choose one) |
|
||||
| 13 | **Sneak Attack** (replaces): Once per turn, deal an extra 7d6 damage to a target you have advantage against, or that has one of your allies next to it. |
|
||||
| 14 | **Blindsense**: If you can hear, you know where any hidden or invisible creature within 10 ft is. |
|
||||
| 15 | **Sneak Attack** (replaces): Once per turn, deal an extra 8d6 damage to a target you have advantage against, or that has one of your allies next to it.<br>**Slippery Mind**: You are proficient in Wisdom saves (add the bonus by hand). |
|
||||
| 15 | **Sneak Attack** (replaces): Once per turn, deal an extra 8d6 damage to a target you have advantage against, or that has one of your allies next to it.<br>**Slippery Mind**: You are proficient in Wisdom saves (counted on your sheet). |
|
||||
| 16 | **Boon** (choose one) |
|
||||
| 17 | **Sneak Attack** (replaces): Once per turn, deal an extra 9d6 damage to a target you have advantage against, or that has one of your allies next to it. |
|
||||
| 18 | **Elusive**: No attack against you has advantage while you are not incapacitated. |
|
||||
@@ -2347,7 +2401,7 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
|
||||
|
||||
| Subclass | Level | Written into Special Abilities |
|
||||
|---|---|---|
|
||||
| **Draconic Bloodline**<br>Dragon blood: tougher skin and one element at your call.<br>_Player’s Handbook_ | 3 | **Dragon Ancestor**: Draconic Bloodline. Choose a kind of dragon with your DM; its element is yours (red: fire, white: cold, blue: lightning, green: poison, black: acid). Thin scales make you hardier (ask your DM about Extra AC). |
|
||||
| **Draconic Bloodline**<br>Dragon blood: tougher skin and one element at your call.<br>_Player’s Handbook_ | 3 | **Dragon Ancestor**: Draconic Bloodline. Choose a kind of dragon with your DM; its element is yours (red: fire, white: cold, blue: lightning, green: poison, black: acid). Thin scales make you hardier: without armor your AC is 13 + Dexterity, and you have 1 more maximum HP for each sorcerer level (both counted on your sheet). |
|
||||
| — | 6 | **Elemental Affinity**: Draconic Bloodline. Add your Charisma modifier to one damage roll of your dragon’s element; spend 1 sorcery point to resist that element for 1 hour. |
|
||||
| — | 14 | **Dragon Wings**: Bonus action: sprout dragon wings and fly at your speed until you dismiss them. |
|
||||
| — | 18 | **Draconic Presence**: Action, 5 sorcery points: for a minute, creatures within 60 ft make a Wisdom save or are charmed (awe) or frightened (fear) of you. |
|
||||
|
||||
+71
-22
@@ -11,7 +11,7 @@ This describes the code as it stands. The rules live in
|
||||
## Contents
|
||||
|
||||
1. [The basics](#1-the-basics)
|
||||
2. [Battle or quest](#2-battle-or-quest)
|
||||
2. [Battle, quest or free roam](#2-battle-quest-or-free-roam)
|
||||
3. [Status](#3-status)
|
||||
4. [The scene and DM notes](#4-the-scene-and-dm-notes)
|
||||
5. [Who is in the scene](#5-who-is-in-the-scene)
|
||||
@@ -28,8 +28,8 @@ This describes the code as it stands. The rules live in
|
||||
|
||||
## 1. The basics
|
||||
|
||||
- An encounter is one **scene** the DM runs: a fight, or a meeting with
|
||||
people who want something.
|
||||
- An encounter is one **scene** the DM runs: a fight, a meeting with people
|
||||
who want something, or a stretch of free roaming between places.
|
||||
- Encounters belong to a campaign and are listed on the campaign page. The DM
|
||||
creates them and sees all of them.
|
||||
- A player only sees an encounter that **one of their own characters is in**.
|
||||
@@ -39,31 +39,64 @@ This describes the code as it stands. The rules live in
|
||||
- The encounter page updates live for everyone who has it open.
|
||||
- The DM runs everything. Players watch, and see less than the DM does.
|
||||
|
||||
## 2. Battle or quest
|
||||
## 2. Battle, quest or free roam
|
||||
|
||||
Every encounter has a mode, chosen when it is created and switchable at any
|
||||
time from the top of the page.
|
||||
|
||||
| | Battle | NPC quest |
|
||||
|---|---|---|
|
||||
| For | A fight | A scene built around people and an objective |
|
||||
| Layout | Everyone in one list, in initiative order; once in progress, those who have had their turn move to "Next Round" at the bottom | Three groups: "People you meet", "The party", "Creatures" |
|
||||
| Rounds and turns | Yes | No |
|
||||
| Initiative | The d20 roll, typed in per combatant; the bonus is added | The same; each group lists highest first |
|
||||
| Start button | Begin Battle | Begin Quest |
|
||||
| End button | End Battle | Complete Quest |
|
||||
| Trade (section 10) | No | Yes, while in progress |
|
||||
| | Battle | NPC quest | Free roam |
|
||||
|---|---|---|---|
|
||||
| For | A fight | A scene built around people and an objective | The party wandering between places: shopping, meeting people |
|
||||
| Layout | Everyone in one list, in initiative order; once in progress, those who have had their turn move to "Next Round" at the bottom | Three groups: "People you meet", "The party", "Creatures" | A card per place, then the party |
|
||||
| Rounds and turns | Yes | No | No |
|
||||
| Initiative | The d20 roll, typed in per combatant; the bonus is added | The same; each group lists highest first | Not needed |
|
||||
| Start button | Begin Battle | Begin Quest | Begin Roaming |
|
||||
| End button | End Battle | Complete Quest | End Free Roam |
|
||||
| Trade (section 10) | No | Yes, while in progress, with the NPCs in the scene | Yes, while in progress, at the place a character has gone to |
|
||||
| Who is in it | Whoever the DM adds | Whoever the DM adds | Whoever the DM adds |
|
||||
|
||||
Everything else (hit points, conditions, rewards, the scene description, DM
|
||||
notes) works the same in both.
|
||||
notes) works the same in all three.
|
||||
|
||||
### Free roam
|
||||
|
||||
A free-roam encounter lays out **places the party can go to**: some of the
|
||||
campaign's locations, picked by the DM as the ones nearby.
|
||||
|
||||
- **Who is in it** is up to the DM, as with any encounter: nobody is added
|
||||
automatically, because the party may be split, with some of them somewhere
|
||||
else. Add the characters who are here from "Add to the scene"; a player
|
||||
sees the scene once their character is in it.
|
||||
- **Places.** On its page the DM ticks which of the campaign's locations are
|
||||
part of the scene ("Places in this scene"), and can tick more, or untick
|
||||
some, while it is in progress. Locations are made on the campaign page.
|
||||
- **Each place is a card** showing its description, who of the party is there
|
||||
now, the **NPCs found there** (name, role, description, what they sell and
|
||||
the rate they change money at), and the place's **own shop**. Everyone in
|
||||
the scene sees every place in it, whether or not they have been there.
|
||||
- **Going somewhere.** Once the DM presses Begin Roaming, a player picks their
|
||||
character and presses **Go here** on a place: the character is now there
|
||||
(it shows on their sheet too, and the place joins the ones they know). The
|
||||
DM can send anyone.
|
||||
- **Shopping.** A character buys from a place's shop, and buys from or changes
|
||||
money with an NPC found there, only while they are **at that place**. The
|
||||
Buy button says so otherwise.
|
||||
- **Setting it up (DM).** A place's own shop is stocked from its card ("Set up
|
||||
this place's own shop"): weapons and items from the campaign's pools, with a
|
||||
price and stock, exactly as an NPC's wares are. It belongs to the location,
|
||||
so it is there in every free-roam scene that includes the place. An NPC is
|
||||
put at a place with **Found at** on the NPC's pop-out
|
||||
([NPCS.md](NPCS.md)); what they sell is set there too.
|
||||
- An encounter's own location (the picker at the top) does not pull the party
|
||||
together in free roam: each character is where they were sent.
|
||||
|
||||
## 3. Status
|
||||
|
||||
| Status | Meaning | How to get there |
|
||||
|---|---|---|
|
||||
| Planning | Being set up | Every new encounter starts here |
|
||||
| In progress | Being played | Begin Battle / Begin Quest, or Reopen |
|
||||
| Complete | Finished | End Battle / Complete Quest |
|
||||
| In progress | Being played | Begin Battle / Begin Quest / Begin Roaming, or Reopen |
|
||||
| Complete | Finished | End Battle / Complete Quest / End Free Roam |
|
||||
|
||||
Status is a label. Nothing is locked when an encounter is complete: the DM
|
||||
can still change HP, add people and award rewards. Encounters cannot be
|
||||
@@ -96,6 +129,13 @@ with three pages or more, a menu to jump to any page), and
|
||||
the DM's archived list is paged the same way. The order runs on across pages:
|
||||
moving the last encounter on a page down carries it to the top of the next.
|
||||
|
||||
**Completed encounters are hidden.** An encounter that is **Complete** is
|
||||
left off the list, for the DM and the players alike, so the list is what is
|
||||
still to play. A **Show completed (N)** tick box above the list brings them
|
||||
back; the choice is remembered per browser. ▲ and ▼ move an encounter past its
|
||||
neighbour on the list as shown. Opening a completed encounter from a link
|
||||
still works, and nothing about it changes.
|
||||
|
||||
**Archive.** "Archive" (on the list or the encounter page) puts an encounter
|
||||
away without changing it. It moves to the DM's "Archived" section and
|
||||
disappears from the players' list; a player whose character is in it can still
|
||||
@@ -187,8 +227,14 @@ Each has a card. The card's left edge is coloured by kind, and it shows:
|
||||
(● proficient, ◆ expertise) with passive Perception, Investigation and
|
||||
Insight, and special abilities. A weapon that is not equipped is not on the
|
||||
card and cannot be used; "No weapon equipped" shows when there is none.
|
||||
Equipping is changed from "📋 Sheet", at any point in the scene. The name
|
||||
links to the character sheet.
|
||||
Equipping is changed from "📋 Sheet". In a **battle in progress** a player
|
||||
can change the weapon in their character's hand (equip or unequip) at two
|
||||
moments only: **as the battle begins**, before anyone has made a move
|
||||
(round 1, still the first combatant's turn, nothing declared or landed),
|
||||
and **on their own turn once their attack has landed**, before the turn
|
||||
passes. At any other moment the sheet refuses and says why. The DM is not
|
||||
held to this and can change it at any time; items are not affected, and
|
||||
neither is a quest or free roam. The name links to the character sheet.
|
||||
- **Monsters:** for the DM, AC, speed, attack and damage, and under "details"
|
||||
ability scores, trait and notes.
|
||||
- **NPCs:** role, attitude and description; for the DM also AC, attack,
|
||||
@@ -458,8 +504,8 @@ to someone else, keeping the d20 already rolled.
|
||||
|
||||
**4. Apply (DM).** **✔ Apply** (or the **A** key) lets it land: each target
|
||||
loses what the card says, with temp HP, death saves and the fallen handled as
|
||||
for any damage; a spell spends a slot of its level from its caster (if they
|
||||
have none left it says so and spends nothing); anyone hurt while
|
||||
for any damage; a spell spends the slot it was declared with from its caster
|
||||
(a cantrip spends nothing); anyone hurt while
|
||||
Concentrating gets a line telling the DM the save to call for. The card then
|
||||
moves to **⚔ What just happened** in the side column, where the last few
|
||||
actions stay for everyone to read. **✕ Discard** throws it away; a player can
|
||||
@@ -624,8 +670,9 @@ characters' sheets. XP never levels anyone up by itself
|
||||
|
||||
## 10. Trade
|
||||
|
||||
In an NPC quest the party can **change money** and **buy things**. Both take
|
||||
someone to deal with and the time to do it, so:
|
||||
In an NPC quest the party can **change money** and **buy things**. (In free
|
||||
roam they do the same at the place each character has gone to: section 2,
|
||||
"Free roam".) Both take someone to deal with and the time to do it, so:
|
||||
|
||||
- Trading is open only in an **NPC quest that is in progress**. Before "Begin
|
||||
Quest" and after "Complete Quest" everything on offer is still shown, but
|
||||
@@ -700,6 +747,8 @@ information cannot be dug out of the page.
|
||||
| Change HP or conditions | Yes | No |
|
||||
| Enter an initiative roll | Yes, for anyone | For their own character only |
|
||||
| Mark targets | Yes, any time | On their own character's turn |
|
||||
| Pick a free-roam scene's places, stock a place's shop | Yes | No |
|
||||
| Send a character to a place in free roam | Yes, anyone | Their own character |
|
||||
| Declare an attack or a spell | Yes, for anyone, and anything spelled out | For their own character, on its turn, with its weapons and spells |
|
||||
| Enter or roll an attack's d20 and damage | Yes | For their own character's attack |
|
||||
| Enter or roll a saving throw | Yes, for anyone | For their own character |
|
||||
|
||||
@@ -66,6 +66,19 @@ passivePerception = 10 + perceptionSkillBonus
|
||||
armorClass = 10 + dexModifier + armorBonus
|
||||
```
|
||||
|
||||
`dexModifier` here is as much of it as the equipped armor lets through
|
||||
(`armorDexterity`): all of it with none or light armor, at most +2 in medium
|
||||
armor, none in heavy armor. Armor is an item marked with its kind
|
||||
(`Item.armorType`); its own AC is a gear effect.
|
||||
|
||||
Without armor, a class feature can replace the `10 + dexModifier` part:
|
||||
Unarmored Defense (barbarian: `10 + DEX + CON`; monk, also without a shield:
|
||||
`10 + DEX + WIS`) and Draconic Resilience (`13 + DEX`). The best one is used,
|
||||
and only when it beats `10 + dexModifier`. These, and the other class features
|
||||
that change a sheet number (Fast Movement, Unarmored Movement, Aura of
|
||||
Protection, Diamond Soul, Slippery Mind, Jack of All Trades), live in
|
||||
`backend/src/game/classFeatures.js`; CHARACTERS.md section 8 lists them.
|
||||
|
||||
`armorBonus` is a flat number the player enters representing whatever armor/shield
|
||||
they have equipped (this matches the workbook's "Armor Bonus (from armor worn)"
|
||||
input cell). Do not try to model armor types as a lookup table unless you want to
|
||||
|
||||
+28
-15
@@ -168,6 +168,19 @@ Armor, shields, rings, cloaks and the like are ordinary items that have
|
||||
**effects**. An effect is a whole number, positive for a bonus and negative
|
||||
for a penalty, on one of the sheet's numbers:
|
||||
|
||||
**Armor and shields are marked as such.** An item's pop-out has an **Armor**
|
||||
menu: not armor (the default), light, medium, heavy, or shield. Its AC is
|
||||
still an effect (+AC, its usual AC less 10); the mark says what kind of thing
|
||||
it is. The sheet counts **all** of a character's Dexterity modifier toward AC
|
||||
in light armor, **at most +2** in medium armor and **none** in heavy armor
|
||||
([CHARACTERS.md](CHARACTERS.md), section 8), so plate is +8 and comes to AC
|
||||
18 whoever wears it. Some class features depend on what is worn too: a barbarian's and a monk's Unarmored
|
||||
Defense and a draconic sorcerer's scales stop while armor is equipped, a
|
||||
monk's also stop behind a shield, and a barbarian's extra speed stops in
|
||||
heavy armor ([CHARACTERS.md](CHARACTERS.md), section 8). The library's armor
|
||||
and shield come marked; mark your own. A ring or cloak that gives +AC is not
|
||||
armor: leave it unmarked.
|
||||
|
||||
| It can change | Examples |
|
||||
|---|---|
|
||||
| AC | Chain mail +6, a shield +2, a cursed cloak −1 |
|
||||
@@ -244,8 +257,8 @@ and [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
|
||||
|
||||
## 9. What is not modelled
|
||||
|
||||
- Armor rules beyond a flat bonus: armor does not replace 10 + DEX, cap the
|
||||
DEX bonus or need a Strength score (set the AC effect to match)
|
||||
- A Strength score needed for heavy armor, armor proficiency and Stealth
|
||||
disadvantage: these are notes on the armor for the table to apply
|
||||
- Effects on attack rolls, damage, spell save DC or proficiency bonus
|
||||
- Effects that depend on something (only at night, only against undead), or
|
||||
that grow with the item's level
|
||||
@@ -265,10 +278,10 @@ and [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
|
||||
Everything "📚 From Library" offers. "While equipped" is what the item does
|
||||
to the sheet of whoever has it on.
|
||||
|
||||
Armor is a flat bonus to AC on top of 10 + Dexterity: each suit's bonus is its
|
||||
standard AC less 10. What a flat bonus cannot say (heavy armor ignoring
|
||||
Dexterity, a Strength requirement, disadvantage on Stealth) is in the note,
|
||||
for the DM to apply or to build into the numbers of their own copy.
|
||||
Armor is a bonus to AC on top of 10 + Dexterity: each suit's bonus is its
|
||||
standard AC less 10, and it is marked light, medium or heavy, which sets how
|
||||
much Dexterity counts in it (section 5). What that cannot say (a Strength
|
||||
requirement, disadvantage on Stealth) is in the note, for the DM to apply.
|
||||
|
||||
This list is written from the app's own data by `npm run docs:lists` (in `/backend`). Re-run it after changing the data; do not edit the list by hand.
|
||||
|
||||
@@ -285,20 +298,20 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
|
||||
|
||||
| Item | While equipped | Note | Dice | Description |
|
||||
|---|---|---|---|---|
|
||||
| Hide Armor | +2 AC | Standard rules: counts at most +2 from Dexterity. | — | Thick furs and pelts. |
|
||||
| Chain Shirt | +3 AC | Standard rules: counts at most +2 from Dexterity. | — | Interlocking rings worn between layers of clothing. |
|
||||
| Scale Mail | +4 AC | Disadvantage on Stealth checks. Standard rules: counts at most +2 from Dexterity. | — | A coat and leggings of leather covered in overlapping metal scales. |
|
||||
| Breastplate | +4 AC | Standard rules: counts at most +2 from Dexterity. | — | A fitted metal chest piece worn with supple leather. |
|
||||
| Half Plate | +5 AC | Disadvantage on Stealth checks. Standard rules: counts at most +2 from Dexterity. | — | Shaped metal plates covering most of the body. |
|
||||
| Hide Armor | +2 AC | Medium armor: at most +2 from Dexterity counts (worked out on the sheet). | — | Thick furs and pelts. |
|
||||
| Chain Shirt | +3 AC | Medium armor: at most +2 from Dexterity counts (worked out on the sheet). | — | Interlocking rings worn between layers of clothing. |
|
||||
| Scale Mail | +4 AC | Disadvantage on Stealth checks. Medium armor: at most +2 from Dexterity counts (worked out on the sheet). | — | A coat and leggings of leather covered in overlapping metal scales. |
|
||||
| Breastplate | +4 AC | Medium armor: at most +2 from Dexterity counts (worked out on the sheet). | — | A fitted metal chest piece worn with supple leather. |
|
||||
| Half Plate | +5 AC | Disadvantage on Stealth checks. Medium armor: at most +2 from Dexterity counts (worked out on the sheet). | — | Shaped metal plates covering most of the body. |
|
||||
|
||||
### Heavy Armor (4)
|
||||
|
||||
| Item | While equipped | Note | Dice | Description |
|
||||
|---|---|---|---|---|
|
||||
| Ring Mail | +4 AC | Disadvantage on Stealth checks. Standard rules: adds no Dexterity. | — | Leather armor with heavy rings sewn into it. |
|
||||
| Chain Mail | +6 AC, −10 Speed | Disadvantage on Stealth checks. Standard rules: adds no Dexterity, and needs Strength 13. | — | Interlocking metal rings over a layer of quilted fabric, with gauntlets. |
|
||||
| Splint Armor | +7 AC, −10 Speed | Disadvantage on Stealth checks. Standard rules: adds no Dexterity, and needs Strength 15. | — | Narrow vertical strips of metal riveted to a leather backing. |
|
||||
| Plate Armor | +8 AC, −10 Speed | Disadvantage on Stealth checks. Standard rules: adds no Dexterity, and needs Strength 15. | — | Shaped, interlocking metal plates covering the whole body. |
|
||||
| Ring Mail | +4 AC | Disadvantage on Stealth checks. Heavy armor: Dexterity does not count (worked out on the sheet). | — | Leather armor with heavy rings sewn into it. |
|
||||
| Chain Mail | +6 AC, −10 Speed | Disadvantage on Stealth checks. Heavy armor: Dexterity does not count (worked out on the sheet). Standard rules: needs Strength 13. | — | Interlocking metal rings over a layer of quilted fabric, with gauntlets. |
|
||||
| Splint Armor | +7 AC, −10 Speed | Disadvantage on Stealth checks. Heavy armor: Dexterity does not count (worked out on the sheet). Standard rules: needs Strength 15. | — | Narrow vertical strips of metal riveted to a leather backing. |
|
||||
| Plate Armor | +8 AC, −10 Speed | Disadvantage on Stealth checks. Heavy armor: Dexterity does not count (worked out on the sheet). Standard rules: needs Strength 15. | — | Shaped, interlocking metal plates covering the whole body. |
|
||||
|
||||
### Shields (2)
|
||||
|
||||
|
||||
@@ -50,6 +50,13 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
|
||||
A new NPC is Neutral unless you choose otherwise.
|
||||
|
||||
**Found at.** An NPC can be given one of the campaign's locations as the place
|
||||
they are usually found, on their pop-out. In a **free-roam** encounter that
|
||||
includes that place, the NPC appears on its card, and a character who goes
|
||||
there can buy from them and change money with them
|
||||
([ENCOUNTERS.md](ENCOUNTERS.md), section 2, "Free roam"). It changes nothing in
|
||||
a quest or a battle, where an NPC takes part because the DM added them.
|
||||
|
||||
## 3. Managing NPCs
|
||||
|
||||
The NPC list is the "NPCs" part of the campaign page (DM only). Each NPC is a
|
||||
|
||||
+12
-2
@@ -180,8 +180,18 @@ Each spell says how it is resolved when it is cast in an encounter
|
||||
The library's spells come already set (Fire Bolt and Guiding Bolt as spell
|
||||
attacks; Fireball, Burning Hands, Hold Person and the like as saves), and
|
||||
copies already in a campaign were marked to match when this was added.
|
||||
Casting a spell of 1st level or higher this way spends a slot of its level
|
||||
when the DM applies it.
|
||||
Casting a spell of 1st level or higher this way spends a slot when the DM
|
||||
applies it: one of the spell's own level, or, when those are gone, the lowest
|
||||
slot above it that is left. **With no slot of its level or higher left the
|
||||
spell cannot be declared**: its button is off and marked "no slot left".
|
||||
**Cantrips never take a slot** and can be cast every turn, with every slot
|
||||
the caster has spent or with none to begin with. A spell above what
|
||||
the character's level allows is not offered at all (section on levels), and a
|
||||
weapon has to be equipped and within the character's level to attack with.
|
||||
|
||||
Wherever a character's spells are listed (the sheet, the encounter card, the
|
||||
attack picker) they run **cantrips first, then by spell level**, each with
|
||||
its level beside the name.
|
||||
|
||||
## 7. Spellcasting numbers
|
||||
|
||||
|
||||
+4
-1
@@ -109,7 +109,10 @@ On a character's sheet, the DM picks a weapon from the pool and clicks Give.
|
||||
keeps the level it has. A character holds one stack per weapon, so two
|
||||
copies of the same weapon at different levels is not possible.
|
||||
- The DM can take a weapon away with ✕ on the sheet.
|
||||
- The owning player, or the DM, ticks **Equipped**.
|
||||
- The owning player, or the DM, ticks **Equipped**. While the character is in
|
||||
a battle in progress, a player can only change it as the battle begins or on
|
||||
their own turn after their attack has landed
|
||||
([ENCOUNTERS.md](ENCOUNTERS.md), section 5); the DM always can.
|
||||
- **Only what is equipped counts.** A weapon has to be equipped to be used
|
||||
in an encounter, and gear only gives its effects while equipped. What is
|
||||
not equipped stays in the pack: still the character's, still listed on the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.27.0",
|
||||
"version": "1.31.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from './client';
|
||||
import type {
|
||||
Ability,
|
||||
ArmorType,
|
||||
ActionPatch,
|
||||
ActionSource,
|
||||
BuildOptions,
|
||||
@@ -646,6 +648,12 @@ export function usePatchPoolEntry(campaignId: string, pool: Pool) {
|
||||
exchangeCash?: number | null;
|
||||
effects?: GearEffect[];
|
||||
effectNote?: string | null;
|
||||
// A spell: how it lands in a battle. An NPC: where they are found.
|
||||
attackRoll?: boolean;
|
||||
saveAbility?: Ability | null;
|
||||
halfOnSave?: boolean;
|
||||
locationId?: string | null;
|
||||
armorType?: ArmorType | null; // an item: what kind of armor it is, if any
|
||||
}) =>
|
||||
(await apiClient.patch(`/campaigns/${campaignId}/${pool}/${id}`, data)).data,
|
||||
onSuccess: () => {
|
||||
@@ -840,7 +848,7 @@ export function useCreateEncounter(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
// Where it goes in the campaign's order: the top, the bottom (the default), or straight after another.
|
||||
mutationFn: async (data: { name: string; mode?: EncounterMode; position?: 'TOP' | 'BOTTOM'; afterEncounterId?: string }) =>
|
||||
mutationFn: async (data: { name: string; mode?: EncounterMode; position?: 'TOP' | 'BOTTOM'; afterEncounterId?: string; placeIds?: string[] }) =>
|
||||
(await apiClient.post<{ encounter: Encounter }>(`/campaigns/${campaignId}/encounters`, data)).data.encounter,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] }),
|
||||
});
|
||||
@@ -986,6 +994,28 @@ export function useSetTargets(encounterId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Free roam: the DM sets the places the party can go to in the scene.
|
||||
export function useSetPlaces(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (locationIds: string[]) => (await apiClient.put(`/encounters/${encounterId}/places`, { locationIds })).data,
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
// Free roam: a character goes to one of the scene's places.
|
||||
export function useVisitPlace(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { characterId: string; locationId: string }) => (await apiClient.post(`/encounters/${encounterId}/visit`, data)).data,
|
||||
onSuccess: (data, { characterId }) => {
|
||||
keepEncounter(qc, encounterId, data);
|
||||
qc.invalidateQueries({ queryKey: ['character', characterId] });
|
||||
qc.invalidateQueries({ queryKey: ['campaign'] }); // the places a player knows, and where the party is
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Declares an attack or a spell: by whoever is on turn unless an actor is
|
||||
// named, at the targets marked unless some are given.
|
||||
export function useDeclareAction(encounterId: string) {
|
||||
@@ -1121,7 +1151,7 @@ export function useCreateNpc(campaignId: string) {
|
||||
// in progress, characters in the scene change money and buy
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export type NewWare = ({ npcId: string } | { encounterId: string }) &
|
||||
export type NewWare = ({ npcId: string } | { encounterId: string } | { locationId: string }) &
|
||||
({ weaponId: string } | { itemId: string }) & { price: number; currency: Currency; level?: number; stock: number | null };
|
||||
|
||||
// Wares reach the screen inside the NPC list and inside each encounter.
|
||||
|
||||
@@ -384,6 +384,7 @@ export interface CharacterSheet {
|
||||
spellSlots: SpellSlots | null; // null for a character with no spell slots
|
||||
resources: ClassResource[]; // the class counters, with how many of each are left
|
||||
gear: ActiveGear;
|
||||
features: FeatureInForce[]; // class features already counted in the numbers above
|
||||
}
|
||||
|
||||
export interface Weapon {
|
||||
@@ -461,6 +462,18 @@ export interface Item {
|
||||
isStartingOption: boolean;
|
||||
effects: GearEffect[]; // armor is an item with an AC effect
|
||||
effectNote: string | null;
|
||||
armorType: ArmorType | null; // set on armor and shields: what kind it is
|
||||
}
|
||||
|
||||
// What kind of armor an item is. The sheet goes by what is equipped for the
|
||||
// class features that depend on it (Unarmored Defense, a monk's speed...).
|
||||
export type ArmorType = 'LIGHT' | 'MEDIUM' | 'HEAVY' | 'SHIELD';
|
||||
|
||||
// A class feature counted in the sheet's numbers, in a line.
|
||||
export interface FeatureInForce {
|
||||
name: string;
|
||||
source: string; // the class or subclass that gives it
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface InventoryItem {
|
||||
@@ -584,7 +597,8 @@ export interface MonsterAttack {
|
||||
}
|
||||
|
||||
export type EncounterStatus = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
|
||||
export type EncounterMode = 'BATTLE' | 'QUEST';
|
||||
// A fight in initiative order, a quest scene around NPCs, or free roam between places the DM has laid out.
|
||||
export type EncounterMode = 'BATTLE' | 'QUEST' | 'FREE_ROAM';
|
||||
export type NpcDisposition = 'FRIENDLY' | 'NEUTRAL' | 'HOSTILE';
|
||||
|
||||
// Something for sale: a weapon or item from the campaign pools, at a price.
|
||||
@@ -594,6 +608,7 @@ export interface SaleOffer {
|
||||
campaignId: string;
|
||||
npcId: string | null;
|
||||
encounterId: string | null;
|
||||
locationId: string | null; // a shop that is always at this place
|
||||
weaponId: string | null;
|
||||
itemId: string | null;
|
||||
price: number; // for one, in `currency`
|
||||
@@ -616,6 +631,18 @@ export interface NpcPublic {
|
||||
exchangeGold: number | null;
|
||||
exchangeCash: number | null;
|
||||
wares?: SaleOffer[]; // what they have for sale
|
||||
locationId?: string | null; // where they are usually found: met there in a free-roam scene
|
||||
}
|
||||
|
||||
// A place the party can go to in a free-roam scene: who is found there, what
|
||||
// is for sale there, and which of the characters in the scene are there now.
|
||||
export interface FreeRoamPlace {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
wares: SaleOffer[];
|
||||
npcs: (NpcPublic & Partial<Npc>)[];
|
||||
here: { characterId: string; name: string; userId: string | null }[];
|
||||
}
|
||||
|
||||
// The DM's full record. dmNotes and the combat stats never reach a player.
|
||||
@@ -802,7 +829,8 @@ export interface EncounterParticipant {
|
||||
// hidden from the players. Only the DM is ever sent one of these.
|
||||
hidden: boolean;
|
||||
npcId: string | null;
|
||||
character?: { id: string; name: string; userId: string | null } | null;
|
||||
// currentLocationId is sent in a free-roam scene only: where they have gone.
|
||||
character?: { id: string; name: string; userId: string | null; currentLocationId?: string | null } | null;
|
||||
// The DM gets the whole stat block; players only the id and name.
|
||||
monster?: ({ id: string; name: string } & Partial<Monster>) | null;
|
||||
npc?: (NpcPublic & Partial<Npc>) | null;
|
||||
@@ -838,6 +866,7 @@ export interface Encounter {
|
||||
// hidden ones), and the last few that landed.
|
||||
actions?: EncounterAction[];
|
||||
recentActions?: EncounterAction[];
|
||||
places?: FreeRoamPlace[]; // a free-roam scene's places; absent for any other kind
|
||||
rewards?: EncounterReward[]; // players only receive the awarded ones
|
||||
wares?: SaleOffer[]; // for sale at the scene itself, not by an NPC in it
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const STAT_HELP: Record<string, string> = {
|
||||
SKILL_BONUS: 'Skill Bonus — added when you roll to use a skill. The governing ability modifier, plus your proficiency bonus if you are proficient (doubled if you have expertise).',
|
||||
EXPERTISE: 'Expertise — doubles your proficiency bonus for this skill, on top of being proficient.',
|
||||
ABILITY_MODIFIER: 'Ability Modifier — the number added to rolls that use this ability. floor((score − 10) / 2).',
|
||||
ARMOR_BONUS: 'Extra AC — a number the DM sets, added to AC on top of 10 + Dex modifier and whatever equipped gear gives. Armor itself is an equipped item.',
|
||||
ARMOR_BONUS: 'Extra AC — a number the DM sets, added to AC on top of 10 + Dex modifier and whatever equipped gear gives. Armor itself is an equipped item, and medium or heavy armor holds back how much Dexterity counts. Unarmored Defense (barbarian, monk) and a draconic sorcerer’s scales are worked out for you: do not add them here.',
|
||||
SPEED: 'Speed — how far you can move on your turn, in feet. Set by your species, unless overridden.',
|
||||
SPELL_SAVE_DC: 'Spell Save DC — the difficulty a target must beat to resist your spell. 8 + proficiency bonus + your spellcasting ability modifier.',
|
||||
SPELL_ATTACK_BONUS: 'Spell Attack Bonus — added when you make a spell attack roll. Proficiency bonus + your spellcasting ability modifier.',
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { CharacterSummary, Encounter, Item, SaleOffer, Weapon } from '../ap
|
||||
|
||||
type ExchangeSide = 'GOLD' | 'CASH';
|
||||
|
||||
// (A free-roam scene has its own panel: components/encounter/FreeRoam.tsx.)
|
||||
// Trade in a quest: changing gold and cash with an NPC who does that, at
|
||||
// their own rate, and buying what the NPCs in the scene — or the scene itself
|
||||
// — have for sale. Everyone sees what is on offer; a player trades for their
|
||||
|
||||
@@ -73,8 +73,8 @@ function WareRow({ campaignId, ware, onError }: { campaignId: string; ware: Sale
|
||||
);
|
||||
}
|
||||
|
||||
// The DM's list of what one seller has for sale — an NPC, or an encounter
|
||||
// itself — with a form to put something from the campaign pools up for sale.
|
||||
// The DM's list of what one seller has for sale — an NPC, an encounter
|
||||
// itself, or a location (a shop that is always there) — with a form to put something from the campaign pools up for sale.
|
||||
export function WaresEditor({
|
||||
campaignId,
|
||||
seller,
|
||||
@@ -84,7 +84,7 @@ export function WaresEditor({
|
||||
onError,
|
||||
}: {
|
||||
campaignId: string;
|
||||
seller: { npcId: string } | { encounterId: string };
|
||||
seller: { npcId: string } | { encounterId: string } | { locationId: string };
|
||||
wares: SaleOffer[];
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useDeleteMonster, useDeletePoolEntry, usePatchPoolEntry, useUpdateMonster } from '../../api/queries';
|
||||
import { useCampaignLocations, useDeleteMonster, useDeletePoolEntry, usePatchPoolEntry, useUpdateMonster } from '../../api/queries';
|
||||
import { Modal } from '../Modal';
|
||||
import { NumberInput } from '../NumberInput';
|
||||
import { ErrorBanner } from '../Spinner';
|
||||
@@ -9,7 +9,7 @@ import { extractErrorMessage, fmtMod, spellLevelLabel, spellRequirementLabel } f
|
||||
import { abilityLine, rateLabel, type EntryKind, type EntryRef } from '../../lib/campaignEntries';
|
||||
import { Field, MonsterForm, ScalingSelect, SpellDamageSelect, SpellLandingSelect, StartLevelSelect } from './parts';
|
||||
import { landingValue } from '../../lib/campaignEntries';
|
||||
import type { GearEffect, Item, Monster, Npc, Spell, Weapon } from '../../api/types';
|
||||
import type { GearEffect, Item, Monster, Npc, Spell, Weapon, ArmorType } from '../../api/types';
|
||||
|
||||
const KIND_NOUNS: Record<EntryKind, string> = { weapon: 'weapon', item: 'item', spell: 'spell', monster: 'monster', npc: 'NPC' };
|
||||
const ATTACK_ABILITY_LABELS: Record<Weapon['attackAbility'], string> = { STR: 'STR', DEX: 'DEX', FINESSE: 'Finesse (the better of STR and DEX)' };
|
||||
@@ -90,6 +90,29 @@ function GearDetail({ campaignId, pool, entry, added, onClose }: { added: ReactN
|
||||
Starting option: a player may pick it while building a character
|
||||
</label>
|
||||
|
||||
{!weapon && 'armorType' in entry && (
|
||||
<div className="mt-md">
|
||||
<Field label="Armor">
|
||||
{(id) => (
|
||||
<select id={id} value={entry.armorType ?? ''} disabled={patch.isPending} onChange={(e) => patch.mutate({ id: entry.id, armorType: (e.target.value || null) as ArmorType | null }, { onError })}>
|
||||
<option value="">Not armor</option>
|
||||
<option value="LIGHT">Light armor</option>
|
||||
<option value="MEDIUM">Medium armor</option>
|
||||
<option value="HEAVY">Heavy armor</option>
|
||||
<option value="SHIELD">Shield</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<p className="combatant-sub mt-sm">
|
||||
Its AC is still set below, as +AC (its usual AC less 10). This says what kind it is. The sheet counts all of a character’s
|
||||
Dexterity toward AC in light armor, at most +2 in medium armor and none in heavy armor. It also decides the class features
|
||||
that depend on what is worn: a barbarian’s and a
|
||||
monk’s Unarmored Defense and a draconic sorcerer’s scales stop while armor is equipped, a monk’s also behind a shield, and a
|
||||
barbarian’s extra speed stops in heavy armor.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="section-label mt-md">While equipped</div>
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
These count for a character only while it is equipped and they are high enough level to use it. Armor is an item with
|
||||
@@ -230,6 +253,7 @@ function MonsterDetail({ campaignId, monster, added, onClose }: { added: ReactNo
|
||||
function NpcDetail({ campaignId, npc, weapons, items, added, onClose }: { added: ReactNode; campaignId: string; npc: Npc; weapons: Weapon[]; items: Item[]; onClose: () => void }) {
|
||||
const patch = usePatchPoolEntry(campaignId, 'npcs');
|
||||
const remove = useDeletePoolEntry(campaignId, 'npcs');
|
||||
const { data: locations } = useCampaignLocations(campaignId);
|
||||
const [gold, setGold] = useState(npc.exchangeGold ? String(npc.exchangeGold) : '');
|
||||
const [cash, setCash] = useState(npc.exchangeCash ? String(npc.exchangeCash) : '');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -263,6 +287,26 @@ function NpcDetail({ campaignId, npc, weapons, items, added, onClose }: { added:
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="section-label mt-md">Found at</div>
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
Where the party meets {npc.name} in a free-roam scene: whoever goes to that place can talk and trade with them there. (In a quest, they
|
||||
are met when you add them to it.)
|
||||
</p>
|
||||
<select
|
||||
aria-label={`Where ${npc.name} is found`}
|
||||
value={npc.locationId ?? ''}
|
||||
disabled={patch.isPending}
|
||||
onChange={(e) => patch.mutate({ id: npc.id, locationId: e.target.value || null }, { onError })}
|
||||
style={{ maxWidth: 320 }}
|
||||
>
|
||||
<option value="">Nowhere in particular</option>
|
||||
{(locations ?? []).map((loc) => (
|
||||
<option key={loc.id} value={loc.id}>
|
||||
📍 {loc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="section-label mt-md">Changes money</div>
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
Set a rate and the party can change gold into cash, or cash into gold, with {npc.name}. Silver and copper are not
|
||||
|
||||
@@ -259,6 +259,7 @@ export function ItemsSection({ campaignId, entries, onOpen, formRequested, onFor
|
||||
isStartingOption: fd.get('isStartingOption') === 'on',
|
||||
effects: [],
|
||||
effectNote: null,
|
||||
armorType: null,
|
||||
});
|
||||
close();
|
||||
onOpen({ kind: 'item', id: item.id, isNew: true });
|
||||
|
||||
@@ -335,7 +335,6 @@ export function ActionComposer({
|
||||
const attacks = actor && !info ? creatureAttacks(actor) : [];
|
||||
const noTarget = targetCount === 0;
|
||||
const go = (source: ActionSource) => declare.mutate({ source, actorParticipantId: actor?.id }, { onError });
|
||||
const slotsLeft = (level: number) => info?.spellSlots?.levels.find((l) => l.level === level)?.remaining ?? 0;
|
||||
|
||||
return (
|
||||
<div className="card action-composer">
|
||||
@@ -362,7 +361,9 @@ export function ActionComposer({
|
||||
{info && info.weapons.length === 0 && <span className="combatant-sub">No weapon equipped: equip one on the sheet to attack with it.</span>}
|
||||
{info?.spells.map((s) => {
|
||||
const cantrip = s.level === 0;
|
||||
const spent = !cantrip && Boolean(info.spellSlots) && slotsLeft(s.level) === 0;
|
||||
// The slot it would take: its own level, or the lowest one above it that is left.
|
||||
const slot = cantrip ? undefined : info.spellSlots?.levels.find((l) => l.level >= s.level && l.remaining > 0);
|
||||
const spent = !cantrip && Boolean(info.spellSlots) && !slot;
|
||||
const how = s.attackRoll
|
||||
? `${fmtMod(info.spellcasting?.attackBonus ?? 0)} to hit`
|
||||
: s.saveAbility
|
||||
@@ -372,15 +373,22 @@ export function ActionComposer({
|
||||
<button
|
||||
key={s.characterSpellId}
|
||||
className="btn action-option"
|
||||
disabled={noTarget || declare.isPending}
|
||||
title={[s.description, spent ? `No ${slotLevelLabel(s.level)}-level slot left: it can still be declared, but no slot will be spent.` : null].filter(Boolean).join(' — ')}
|
||||
disabled={noTarget || spent || declare.isPending}
|
||||
title={[
|
||||
s.description,
|
||||
cantrip ? 'A cantrip: no spell slot needed.' : null,
|
||||
spent ? `No spell slot of ${slotLevelLabel(s.level)} level or higher left: it cannot be cast until slots come back.` : null,
|
||||
slot ? `Takes a ${slotLevelLabel(slot.level)}-level slot when it lands (${slot.remaining} left).` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' — ')}
|
||||
onClick={() => go({ type: 'spell', characterSpellId: s.characterSpellId })}
|
||||
>
|
||||
<span className="action-option-name">
|
||||
✨ {s.name} {spent && <span className="badge badge-hostile">no slot</span>}
|
||||
✨ {s.name} <span className="badge">{spellLevelLabel(s.level)}</span> {spent && <span className="badge badge-hostile">no slot left</span>}
|
||||
</span>
|
||||
<span className="combatant-sub mono">
|
||||
{spellLevelLabel(s.level)} · {how}
|
||||
{cantrip ? 'no slot' : slot && slot.level > s.level ? `${slotLevelLabel(slot.level)}-level slot` : 'uses a slot'} · {how}
|
||||
{s.damageDice ? ` · ${s.damageDice}` : ''}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useBuyWare, useExchangeWithNpc, useSetPlaces, useVisitPlace } from '../../api/queries';
|
||||
import { NumberInput } from '../NumberInput';
|
||||
import { WaresEditor } from '../Wares';
|
||||
import { stockLabel, wareName } from '../../lib/wares';
|
||||
import { formatMoney, formatPurse } from '../../lib/format';
|
||||
import type { CharacterSummary, Encounter, FreeRoamPlace, Item, Location, SaleOffer, Weapon } from '../../api/types';
|
||||
|
||||
type ExchangeSide = 'GOLD' | 'CASH';
|
||||
|
||||
// A free-roam scene: the places the DM has laid out, each with who is found
|
||||
// there and what is for sale, and who of the party is there now. A player
|
||||
// sends their own character to a place and shops where they stand; the DM
|
||||
// can send anyone, picks which of the campaign's locations are part of the
|
||||
// scene, and stocks each place's own shop here. (What an NPC sells, and where
|
||||
// they are found, is set on the NPC, on the campaign page.)
|
||||
export function FreeRoamPanel({
|
||||
encounter,
|
||||
isDM,
|
||||
userId,
|
||||
characters,
|
||||
locations,
|
||||
weapons,
|
||||
items,
|
||||
onError,
|
||||
}: {
|
||||
encounter: Encounter;
|
||||
isDM: boolean;
|
||||
userId: string | undefined;
|
||||
characters: CharacterSummary[];
|
||||
locations: Location[]; // the campaign's, for the DM to pick places from
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const setPlaces = useSetPlaces(encounter.id);
|
||||
const visit = useVisitPlace(encounter.id);
|
||||
const buy = useBuyWare(encounter.id);
|
||||
const exchange = useExchangeWithNpc(encounter.id);
|
||||
const [roamerId, setRoamerId] = useState('');
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
const [amounts, setAmounts] = useState<Record<string, string>>({});
|
||||
const [sides, setSides] = useState<Record<string, ExchangeSide>>({});
|
||||
|
||||
const places = encounter.places ?? [];
|
||||
const open = encounter.status === 'ACTIVE';
|
||||
// Who can be moved and can shop: a player's own characters in the scene; for the DM, anyone's.
|
||||
const roamers = (encounter.participants ?? []).flatMap((p) => (p.character && !p.hidden && (isDM || p.character.userId === userId) ? [p.character] : []));
|
||||
const roamer = roamers.find((c) => c.id === roamerId) ?? roamers[0];
|
||||
const purse = characters.find((c) => c.id === roamer?.id);
|
||||
const whereabouts = places.find((place) => place.here.some((h) => h.characterId === roamer?.id));
|
||||
const busy = visit.isPending || buy.isPending || exchange.isPending;
|
||||
const placeIds = places.map((p) => p.id);
|
||||
|
||||
function go(place: FreeRoamPlace) {
|
||||
if (!roamer) return;
|
||||
setDone(null);
|
||||
visit.mutate({ characterId: roamer.id, locationId: place.id }, { onError, onSuccess: () => setDone(`${roamer.name} goes to ${place.name}.`) });
|
||||
}
|
||||
|
||||
function handleBuy(ware: SaleOffer) {
|
||||
if (!roamer) return;
|
||||
setDone(null);
|
||||
buy.mutate(
|
||||
{ wareId: ware.id, characterId: roamer.id },
|
||||
{ onError, onSuccess: () => setDone(`${roamer.name} bought ${(ware.weapon ?? ware.item)?.name} for ${formatMoney(ware.price, ware.currency, true)}.`) }
|
||||
);
|
||||
}
|
||||
|
||||
const wareRow = (ware: SaleOffer, atPlace: boolean) => (
|
||||
<div key={ware.id} className="reward-row">
|
||||
<div className="flex-between">
|
||||
<span>
|
||||
<strong>{wareName(ware)}</strong>
|
||||
<span className="combatant-sub"> · {stockLabel(ware)}</span>
|
||||
</span>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={!open || !roamer || !atPlace || ware.stock === 0 || busy}
|
||||
title={!open ? 'The scene has not begun' : !atPlace ? `${roamer?.name ?? 'Your character'} has to go here first` : undefined}
|
||||
onClick={() => handleBuy(ware)}
|
||||
>
|
||||
Buy · {formatMoney(ware.price, ware.currency)}
|
||||
</button>
|
||||
</div>
|
||||
{ware.item?.description && <div className="combatant-sub">{ware.item.description}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<div className="card-title">
|
||||
<span>🧭 Places to go</span>
|
||||
{!open && <span className="badge">{encounter.status === 'PLANNING' ? 'Not begun yet' : 'Over'}</span>}
|
||||
</div>
|
||||
{roamers.length > 0 ? (
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
<label className="flex-row gap-sm" style={{ flex: 1, minWidth: 180 }}>
|
||||
{roamers.length > 1 ? 'Moving' : 'You are'}
|
||||
<select aria-label="Which character is moving and shopping" value={roamer?.id ?? ''} onChange={(e) => setRoamerId(e.target.value)} style={{ flex: 1 }}>
|
||||
{roamers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="combatant-sub">📍 {whereabouts ? whereabouts.name : 'not at any of these places yet'}</span>
|
||||
{purse && purse.gold !== null && <span className="mono">{formatPurse(purse)}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<p className="combatant-sub" style={{ margin: 0 }}>
|
||||
{isDM ? 'No characters are in this scene yet: add them from "Add to the scene".' : 'Your character has to be in the scene to go anywhere.'}
|
||||
</p>
|
||||
)}
|
||||
{done && <p className="combatant-sub" style={{ marginBottom: 0 }}>✓ {done}</p>}
|
||||
{places.length === 0 && (
|
||||
<p className="combatant-sub" style={{ marginBottom: 0 }}>
|
||||
{isDM ? 'No places yet. Tick the locations the party can go to, below.' : 'The DM has not opened any places yet.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isDM && (
|
||||
<details className="mt-sm" open={places.length === 0}>
|
||||
<summary className="section-label" style={{ cursor: 'pointer' }}>
|
||||
Places in this scene ({places.length} of {locations.length} locations)
|
||||
</summary>
|
||||
{locations.length === 0 ? (
|
||||
<p className="combatant-sub">
|
||||
This campaign has no locations yet. Add some under Locations on the <Link to={`/campaigns/${encounter.campaignId}`}>campaign page</Link>.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
{locations.map((loc) => {
|
||||
const included = placeIds.includes(loc.id);
|
||||
return (
|
||||
<label key={loc.id} className="checkbox-row" title={loc.description ?? undefined}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={included}
|
||||
disabled={setPlaces.isPending}
|
||||
onChange={() => setPlaces.mutate(included ? placeIds.filter((id) => id !== loc.id) : [...placeIds, loc.id], { onError })}
|
||||
/>
|
||||
📍 {loc.name}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="place-grid">
|
||||
{places.map((place) => {
|
||||
const atPlace = Boolean(roamer) && place.here.some((h) => h.characterId === roamer?.id);
|
||||
return (
|
||||
<div key={place.id} className={`card place-card${atPlace ? ' is-here' : ''}`}>
|
||||
<div className="card-title">
|
||||
<span>📍 {place.name}</span>
|
||||
{atPlace ? (
|
||||
<span className="badge badge-live">{roamer?.name} is here</span>
|
||||
) : (
|
||||
<button className="btn btn-primary" disabled={!open || !roamer || busy} onClick={() => go(place)}>
|
||||
Go here
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{place.description && <p style={{ margin: '0 0 0.4rem', whiteSpace: 'pre-wrap' }}>{place.description}</p>}
|
||||
<div className="combatant-sub">{place.here.length > 0 ? `Here now: ${place.here.map((h) => h.name).join(', ')}` : 'Nobody from the party is here.'}</div>
|
||||
|
||||
{place.npcs.map((npc) => {
|
||||
const rate = { GOLD: npc.exchangeGold ?? 0, CASH: npc.exchangeCash ?? 0 };
|
||||
const changes = rate.GOLD > 0 && rate.CASH > 0;
|
||||
const from = sides[npc.id] ?? 'GOLD';
|
||||
const to = from === 'GOLD' ? 'CASH' : 'GOLD';
|
||||
const amount = Math.floor(Number(amounts[npc.id]));
|
||||
const whole = changes && amount > 0 && amount % rate[from] === 0;
|
||||
return (
|
||||
<div key={npc.id} className="mt-sm">
|
||||
<div className="section-label">
|
||||
🧑 {npc.name}
|
||||
{npc.role ? `, ${npc.role}` : ''}
|
||||
</div>
|
||||
{npc.description && <p className="combatant-sub" style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{npc.description}</p>}
|
||||
{npc.dmNotes && (
|
||||
<p className="combatant-sub" style={{ margin: 0 }} title="Only you can see this">
|
||||
🔒 {npc.dmNotes}
|
||||
</p>
|
||||
)}
|
||||
{(npc.wares ?? []).map((ware) => wareRow(ware, atPlace))}
|
||||
{changes && (
|
||||
<div className="reward-row">
|
||||
<span className="combatant-sub">
|
||||
💱 Changes {formatMoney(rate.GOLD, 'GOLD', true)} for {formatMoney(rate.CASH, 'CASH')}, either way.
|
||||
</span>
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={1000000}
|
||||
placeholder="Amount"
|
||||
aria-label={`How much to change with ${npc.name}`}
|
||||
value={amounts[npc.id] ?? ''}
|
||||
onChange={(e) => setAmounts((a) => ({ ...a, [npc.id]: e.target.value }))}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<select aria-label={`What to hand ${npc.name}`} value={from} onChange={(e) => setSides((s) => ({ ...s, [npc.id]: e.target.value as ExchangeSide }))}>
|
||||
<option value="GOLD">gold into cash</option>
|
||||
<option value="CASH">cash into gold</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={!open || !roamer || !atPlace || !whole || busy}
|
||||
onClick={() => {
|
||||
if (!roamer) return;
|
||||
setDone(null);
|
||||
exchange.mutate(
|
||||
{ characterId: roamer.id, npcId: npc.id, from, amount },
|
||||
{
|
||||
onError,
|
||||
onSuccess: ({ received, currency }) => {
|
||||
setDone(`${roamer.name} changed ${formatMoney(amount, from, true)} for ${formatMoney(received, currency, true)} with ${npc.name}.`);
|
||||
setAmounts((a) => ({ ...a, [npc.id]: '' }));
|
||||
},
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
{whole ? `Get ${formatMoney((amount / rate[from]) * rate[to], to, true)}` : 'Exchange'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(npc.wares ?? []).length === 0 && !changes && <div className="combatant-sub">Nothing to sell: someone to talk to.</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{place.wares.length > 0 && (
|
||||
<div className="mt-sm">
|
||||
<div className="section-label">🛒 For sale here</div>
|
||||
{place.wares.map((ware) => wareRow(ware, atPlace))}
|
||||
</div>
|
||||
)}
|
||||
{place.npcs.length === 0 && place.wares.length === 0 && !isDM && <div className="combatant-sub mt-sm">Nothing to buy here.</div>}
|
||||
|
||||
{isDM && (
|
||||
<details className="mt-sm">
|
||||
<summary className="section-label" style={{ cursor: 'pointer' }}>
|
||||
Set up this place’s own shop
|
||||
</summary>
|
||||
<p className="combatant-sub" style={{ marginTop: '0.3rem' }}>
|
||||
Sold here whenever this place is part of a free-roam scene. To have an NPC found here, set their location on the NPC (campaign page).
|
||||
</p>
|
||||
<WaresEditor campaignId={encounter.campaignId} seller={{ locationId: place.id }} wares={place.wares} weapons={weapons} items={items} onError={onError} />
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -978,6 +978,22 @@ input[type='radio'] {
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
/* Free roam: a card per place the party can go to. */
|
||||
.place-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 0.9rem;
|
||||
margin-top: 0.9rem;
|
||||
align-items: start;
|
||||
}
|
||||
.place-card {
|
||||
margin: 0;
|
||||
}
|
||||
.place-card.is-here {
|
||||
border-color: var(--accent-strong);
|
||||
box-shadow: 0 0 14px rgba(201, 162, 39, 0.2);
|
||||
}
|
||||
|
||||
/* What the scene is waiting on, in a sentence. */
|
||||
.cue-card {
|
||||
display: flex;
|
||||
|
||||
@@ -68,6 +68,17 @@ function storedEncounterSort(): EncounterSort {
|
||||
const ENCOUNTER_PLACE_KEY = 'dnd_encounter_place';
|
||||
const AFTER = 'after:';
|
||||
|
||||
// Finished encounters are left off the list until asked for; the choice is remembered.
|
||||
const SHOW_COMPLETED_KEY = 'dnd_show_completed_encounters';
|
||||
|
||||
function storedShowCompleted(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(SHOW_COMPLETED_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function storedEncounterPlace(): string {
|
||||
return localStorage.getItem(ENCOUNTER_PLACE_KEY) === 'TOP' ? 'TOP' : 'BOTTOM';
|
||||
}
|
||||
@@ -80,7 +91,7 @@ function sortEncounters(encounters: Encounter[], sort: EncounterSort): Encounter
|
||||
custom: () => 0,
|
||||
name: (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
|
||||
status: (a, b) => STATUS_RANK[a.status] - STATUS_RANK[b.status],
|
||||
type: (a, b) => Number(a.mode === 'QUEST') - Number(b.mode === 'QUEST'),
|
||||
type: (a, b) => MODE_RANK[a.mode] - MODE_RANK[b.mode],
|
||||
newest: (a, b) => time(b) - time(a),
|
||||
oldest: (a, b) => time(a) - time(b),
|
||||
};
|
||||
@@ -135,7 +146,8 @@ function CharacterRow({ character, isDM, mine = false, onDelete }: { character:
|
||||
);
|
||||
}
|
||||
|
||||
const MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest' };
|
||||
const MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest', FREE_ROAM: '🧭 Free roam' };
|
||||
const MODE_RANK: Record<EncounterMode, number> = { BATTLE: 0, QUEST: 1, FREE_ROAM: 2 };
|
||||
|
||||
// A long note, cut down to a line for a search result.
|
||||
const snippet = (text: string) => (text.length > 110 ? `${text.slice(0, 110).trimEnd()}…` : text);
|
||||
@@ -193,13 +205,16 @@ export function CampaignHomePage() {
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [resting, setResting] = useState(false);
|
||||
const [levelling, setLevelling] = useState(false);
|
||||
const [showCompleted, setShowCompleted] = useState(storedShowCompleted);
|
||||
const [deleteConfirmName, setDeleteConfirmName] = useState('');
|
||||
|
||||
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
|
||||
// An encounter picked to go after may have been archived or deleted since.
|
||||
const encounterPlace = newEncounterPlace.startsWith(AFTER) && !liveEncounters.some((e) => AFTER + e.id === newEncounterPlace) ? 'BOTTOM' : newEncounterPlace;
|
||||
const archivedEncounters = encounters?.filter((e) => e.archivedAt) || [];
|
||||
const shownEncounters = sortEncounters(liveEncounters, encounterSort);
|
||||
// Complete encounters are hidden unless "Show completed" is ticked.
|
||||
const completedCount = liveEncounters.filter((e) => e.status === 'COMPLETE').length;
|
||||
const shownEncounters = sortEncounters(showCompleted ? liveEncounters : liveEncounters.filter((e) => e.status !== 'COMPLETE'), encounterSort);
|
||||
const encountersPage = usePagination(shownEncounters, 10);
|
||||
const archivedEncountersPage = usePagination(archivedEncounters, 10);
|
||||
const sharedNotes = notes?.filter((n) => n.visibility === 'SHARED');
|
||||
@@ -243,11 +258,15 @@ export function CampaignHomePage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Swaps an encounter with its neighbour in the campaign's own order.
|
||||
function moveEncounter(index: number, step: -1 | 1) {
|
||||
// Swaps an encounter with its neighbour on the list as shown (which may
|
||||
// leave the completed ones out) in the campaign's own order.
|
||||
function moveEncounter(shownIndex: number, step: -1 | 1) {
|
||||
const neighbour = shownEncounters[shownIndex + step];
|
||||
const moving = shownEncounters[shownIndex];
|
||||
if (!neighbour || !moving) return;
|
||||
const ids = liveEncounters.map((e) => e.id);
|
||||
const target = index + step;
|
||||
if (target < 0 || target >= ids.length) return;
|
||||
const index = ids.indexOf(moving.id);
|
||||
const target = ids.indexOf(neighbour.id);
|
||||
[ids[index], ids[target]] = [ids[target], ids[index]];
|
||||
reorderEncounters.mutate([...ids, ...archivedEncounters.map((e) => e.id)], {
|
||||
onError: (err) => setFormError(extractErrorMessage(err)),
|
||||
@@ -511,6 +530,23 @@ export function CampaignHomePage() {
|
||||
<div className="section-block">
|
||||
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
|
||||
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>Encounters</h2>
|
||||
{completedCount > 0 && (
|
||||
<label className="checkbox-row subtitle" title="Encounters that are complete are left off the list until this is ticked">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showCompleted}
|
||||
onChange={(e) => {
|
||||
setShowCompleted(e.target.checked);
|
||||
try {
|
||||
localStorage.setItem(SHOW_COMPLETED_KEY, e.target.checked ? '1' : '0');
|
||||
} catch {
|
||||
// Private browsing: the choice lasts as long as the page.
|
||||
}
|
||||
}}
|
||||
/>
|
||||
Show completed ({completedCount})
|
||||
</label>
|
||||
)}
|
||||
{liveEncounters.length > 1 && (
|
||||
<label className="flex-row subtitle">
|
||||
Sort by
|
||||
@@ -542,6 +578,7 @@ export function CampaignHomePage() {
|
||||
<select aria-label="Encounter type" value={newEncounterMode} onChange={(e) => setNewEncounterMode(e.target.value as EncounterMode)}>
|
||||
<option value="BATTLE">Battle</option>
|
||||
<option value="QUEST">NPC quest</option>
|
||||
<option value="FREE_ROAM">Free roam</option>
|
||||
</select>
|
||||
{liveEncounters.length > 0 && (
|
||||
<select
|
||||
@@ -566,7 +603,7 @@ export function CampaignHomePage() {
|
||||
</select>
|
||||
)}
|
||||
<button type="submit" className="btn btn-primary" disabled={createEncounter.isPending}>
|
||||
{newEncounterMode === 'QUEST' ? '📜 Start Quest' : '⚔ Start Encounter'}
|
||||
{newEncounterMode === 'QUEST' ? '📜 Start Quest' : newEncounterMode === 'FREE_ROAM' ? '🧭 Start Free Roam' : '⚔ Start Encounter'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
@@ -579,6 +616,9 @@ export function CampaignHomePage() {
|
||||
{encounters && liveEncounters.length === 0 && (
|
||||
<div className="empty-state">{archivedEncounters.length > 0 ? 'Every encounter is archived.' : 'No encounters yet.'}</div>
|
||||
)}
|
||||
{liveEncounters.length > 0 && shownEncounters.length === 0 && (
|
||||
<div className="empty-state">Every encounter here is complete. Tick “Show completed” to see them.</div>
|
||||
)}
|
||||
{encountersPage.pageItems.map((enc) => {
|
||||
// Its place in the whole list, not on this page: that is what moving it changes.
|
||||
const index = shownEncounters.indexOf(enc);
|
||||
|
||||
@@ -1589,6 +1589,8 @@ function BuildPreview({
|
||||
for (const ability of all) if (!abilityList.some((a) => a.name === ability.name)) abilityList.push(ability);
|
||||
}
|
||||
const wisMod = abilityFinals.WIS !== undefined ? abilityModifier(abilityFinals.WIS) : null;
|
||||
// Unarmored Defense, as the sheet will count it: a new character wears nothing yet.
|
||||
const unarmoredDefense = Math.max(0, characterClass === 'BARBARIAN' ? (conMod ?? 0) : characterClass === 'MONK' ? (wisMod ?? 0) : 0);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
@@ -1651,7 +1653,7 @@ function BuildPreview({
|
||||
<span className="stat-label">
|
||||
AC <InfoTip term="AC" />
|
||||
</span>
|
||||
<div className="stat-value">{dexMod !== null ? armorClass(dexMod, armorBonus) : '—'}</div>
|
||||
<div className="stat-value">{dexMod !== null ? armorClass(dexMod, armorBonus) + unarmoredDefense : '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="stat-label">
|
||||
|
||||
@@ -682,6 +682,27 @@ export function CharacterSheetPage() {
|
||||
<p className="subtitle mt-sm">📍 Currently at: <strong>{character.currentLocation.name}</strong></p>
|
||||
)}
|
||||
|
||||
{sheet.features.length > 0 && (
|
||||
<div className="card mt-md">
|
||||
<div className="card-title">From Class Features</div>
|
||||
{sheet.features.map((feature) => (
|
||||
<div key={`${feature.source}:${feature.name}:${feature.text}`} className="detail-row">
|
||||
<span>
|
||||
<strong>{feature.name}</strong>
|
||||
<span className="combatant-sub" style={{ marginLeft: 6 }}>
|
||||
{feature.source}
|
||||
</span>
|
||||
</span>
|
||||
<span className="combatant-sub">{feature.text}</span>
|
||||
</div>
|
||||
))}
|
||||
<p className="subtitle" style={{ margin: '0.4rem 0 0' }}>
|
||||
Already counted in every number on this sheet: nothing to add by hand. What is worn decides some of them (armor and shields are
|
||||
items marked as such); a speed set by the DM is used as it is.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(sheet.gear.effects.length > 0 || sheet.gear.notes.length > 0) && (
|
||||
<div className="card mt-md">
|
||||
<div className="card-title">From Equipped Gear</div>
|
||||
@@ -887,8 +908,15 @@ export function CharacterSheetPage() {
|
||||
<div key={cs.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
<div>
|
||||
<strong>{cs.spell.name}</strong>
|
||||
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>
|
||||
<span
|
||||
className="badge"
|
||||
style={{ marginLeft: 6 }}
|
||||
title={cs.spell.level === 0 ? 'A cantrip: cast as often as you like, no spell slot needed.' : `Casting it takes a spell slot of level ${cs.spell.level} or higher.`}
|
||||
>
|
||||
{spellLevelLabel(cs.spell.level)}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>
|
||||
{cs.spell.level === 0 ? 'no slot needed' : 'uses a slot'}
|
||||
{cs.spell.damageDice && (
|
||||
<>
|
||||
{' · '}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useAuth } from '../auth/useAuth';
|
||||
import { RollFeed } from '../components/RollFeed';
|
||||
import { InitiativeTrack } from '../components/encounter/InitiativeTrack';
|
||||
import { ActionCard, ActionComposer, RecentActions } from '../components/encounter/ActionPanel';
|
||||
import { FreeRoamPanel } from '../components/encounter/FreeRoam';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
@@ -72,7 +73,7 @@ const areaPenalty = (targetCount: number, step: number) => Math.max(0, targetCou
|
||||
|
||||
const KIND_LABELS = { CHARACTER: 'PC', MONSTER: 'Monster', NPC: 'NPC' } as const;
|
||||
const KIND_BADGES = { CHARACTER: 'badge-player', MONSTER: 'badge-hostile', NPC: 'badge-friendly' } as const;
|
||||
const MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest' };
|
||||
const MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest', FREE_ROAM: '🧭 Free roam' };
|
||||
const STATUS_LABELS = { PLANNING: 'Planning', ACTIVE: 'In progress', COMPLETE: 'Complete' } as const;
|
||||
|
||||
function StatChip({ label, children }: { label: string; children: ReactNode }) {
|
||||
@@ -755,7 +756,7 @@ function attackLines(p: EncounterParticipant): string[] {
|
||||
);
|
||||
const spells = info.spells
|
||||
.filter((s) => s.damageDice)
|
||||
.map((s) => `${s.name}: ${s.damageDice}${info.spellcasting ? ` · ${fmtMod(info.spellcasting.attackBonus)} to hit / DC ${info.spellcasting.saveDC}` : ''}`);
|
||||
.map((s) => `${s.name} (${spellLevelLabel(s.level)}): ${s.damageDice}${info.spellcasting ? ` · ${fmtMod(info.spellcasting.attackBonus)} to hit / DC ${info.spellcasting.saveDC}` : ''}`);
|
||||
return [...weapons, ...spells];
|
||||
}
|
||||
const stats = p.monster ?? p.npc;
|
||||
@@ -1380,6 +1381,7 @@ export function EncounterPage() {
|
||||
if (error || !encounter) return <ErrorBanner message="Could not load this encounter." />;
|
||||
|
||||
const isBattle = encounter.mode === 'BATTLE';
|
||||
const isRoam = encounter.mode === 'FREE_ROAM';
|
||||
const participants = encounter.participants ?? []; // already in initiative order
|
||||
// The fallen: dead characters, and monsters and NPCs at 0 HP. Only the DM is
|
||||
// sent their cards, and they sit apart from those still in the fight.
|
||||
@@ -1507,7 +1509,7 @@ export function EncounterPage() {
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>
|
||||
{isBattle ? '⚔' : '📜'} {encounter.name}
|
||||
{isBattle ? '⚔' : isRoam ? '🧭' : '📜'} {encounter.name}
|
||||
</h1>
|
||||
<div className="flex-row flex-wrap">
|
||||
<span className="badge badge-dm">{MODE_LABELS[encounter.mode]}</span>
|
||||
@@ -1539,6 +1541,7 @@ export function EncounterPage() {
|
||||
>
|
||||
<option value="BATTLE">Battle</option>
|
||||
<option value="QUEST">NPC quest</option>
|
||||
<option value="FREE_ROAM">Free roam</option>
|
||||
</select>
|
||||
<select
|
||||
aria-label="Location"
|
||||
@@ -1560,7 +1563,7 @@ export function EncounterPage() {
|
||||
)}
|
||||
{encounter.status === 'PLANNING' && (
|
||||
<button className="btn btn-primary" onClick={() => patchEncounter.mutate({ status: 'ACTIVE' }, fail)}>
|
||||
{isBattle ? 'Begin Battle' : 'Begin Quest'}
|
||||
{isBattle ? 'Begin Battle' : isRoam ? 'Begin Roaming' : 'Begin Quest'}
|
||||
</button>
|
||||
)}
|
||||
{encounter.status === 'ACTIVE' && isBattle && (
|
||||
@@ -1580,7 +1583,7 @@ export function EncounterPage() {
|
||||
)}
|
||||
{encounter.status === 'ACTIVE' && (
|
||||
<button className="btn btn-danger" onClick={() => patchEncounter.mutate({ status: 'COMPLETE' }, fail)}>
|
||||
{isBattle ? 'End Battle' : 'Complete Quest'}
|
||||
{isBattle ? 'End Battle' : isRoam ? 'End Free Roam' : 'Complete Quest'}
|
||||
</button>
|
||||
)}
|
||||
{encounter.status === 'COMPLETE' && (
|
||||
@@ -1654,7 +1657,7 @@ export function EncounterPage() {
|
||||
|
||||
{(isDM || encounter.description) && (
|
||||
<div className="card">
|
||||
<div className="card-title">{isBattle ? 'The Scene' : 'Quest'}</div>
|
||||
<div className="card-title">{isBattle ? 'The Scene' : isRoam ? 'Where the party is' : 'Quest'}</div>
|
||||
{isDM ? (
|
||||
<textarea
|
||||
key={`description:${encounter.description ?? ''}`}
|
||||
@@ -1665,7 +1668,9 @@ export function EncounterPage() {
|
||||
placeholder={
|
||||
isBattle
|
||||
? 'Describe the battlefield: terrain, cover, hazards… (shown to players)'
|
||||
: 'What is being asked of the party, and by whom? (shown to players)'
|
||||
: isRoam
|
||||
? 'Where the party finds itself, and what is around to visit… (shown to players)'
|
||||
: 'What is being asked of the party, and by whom? (shown to players)'
|
||||
}
|
||||
onBlur={(e) => {
|
||||
const description = e.target.value.trim() || null;
|
||||
@@ -1678,6 +1683,19 @@ export function EncounterPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRoam && (
|
||||
<FreeRoamPanel
|
||||
encounter={encounter}
|
||||
isDM={isDM}
|
||||
userId={user?.id}
|
||||
characters={builtCharacters}
|
||||
locations={locations ?? []}
|
||||
weapons={weapons ?? []}
|
||||
items={items ?? []}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="encounter-layout mt-md">
|
||||
<div>
|
||||
{actions.map((action) => (
|
||||
|
||||
Generated
+4
-4
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.27.0",
|
||||
"version": "1.31.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.27.0",
|
||||
"version": "1.31.0",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"version": "1.27.0",
|
||||
"version": "1.31.0",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@prisma/client": "^7.10.0",
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"version": "1.27.0",
|
||||
"version": "1.31.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"axios": "^1.20.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.27.0",
|
||||
"version": "1.31.0",
|
||||
"private": true,
|
||||
"description": "D&D 5e campaign manager — monorepo root",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user