Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dcb41ae4a | ||
|
|
c852ecb2e4 | ||
|
|
bdce3399ee | ||
|
|
f764878960 | ||
|
|
6b466d497e | ||
|
|
f8462a6e31 | ||
|
|
754eb0ee68 | ||
|
|
f8cee43ca2 | ||
|
|
e4456b0df2 | ||
|
|
ec48b65481 | ||
|
|
4362b7423e | ||
|
|
98a89185c1 |
@@ -14,10 +14,10 @@ monsters, and a real-time encounter/combat tracker.
|
||||
| `docs/CHARACTERS.md` | Building a character, every number on the sheet, levels, money, permissions |
|
||||
| `docs/WEAPONS.md` | The weapon pool, weapon levels and how damage grows |
|
||||
| `docs/SPELLS.md` | The spell list, who can use which spells, the spell library |
|
||||
| `docs/ITEMS.md` | The item pool and character inventory |
|
||||
| `docs/ENCOUNTERS.md` | Battles and NPC quests, turns, rewards, what players see |
|
||||
| `docs/ITEMS.md` | The item pool, character inventory, items for sale |
|
||||
| `docs/ENCOUNTERS.md` | Battles and NPC quests, turns, rewards, trade, what players see |
|
||||
| `docs/MONSTERS.md` | The monster list and built-in bestiary |
|
||||
| `docs/NPCS.md` | Non-player characters |
|
||||
| `docs/NPCS.md` | Non-player characters, what they sell and the rates they change money at |
|
||||
|
||||
`CLAUDE_BUILD_PROMPT.md`, `docs/GAME_RULES_REFERENCE.md` and
|
||||
`docs/ROLL_YOUR_CHARACTER.md` are the original specifications the first
|
||||
@@ -25,6 +25,25 @@ version was built from. The app has since moved on in places (gear levels,
|
||||
the builder's story steps, picking a motivation); the documents above are the
|
||||
current behaviour.
|
||||
|
||||
## Getting around
|
||||
|
||||
The same four destinations are always on screen: **Campaigns** (the list),
|
||||
**Campaign** and **Party** (for the campaign you were last in, from any page,
|
||||
including a character sheet or an encounter) and **Account**.
|
||||
|
||||
- On a wide screen they are links along the top bar, and the dice roller
|
||||
floats at the bottom left.
|
||||
- On a phone or small tablet (900px wide or less) they are a bar along the
|
||||
bottom of the screen, with the **dice roller** as its last button. Nothing
|
||||
floats over the page: on a character sheet the ⚡ quick stats button sits in
|
||||
the top bar.
|
||||
- The campaign page and the character sheet are long, so each starts with a
|
||||
row of **section links** (Characters, Encounters, NPCs, Monsters...) that
|
||||
jump straight to that part of the page.
|
||||
- On a small screen every table (weapons, items, spells, monsters, the party
|
||||
overview) is shown as a stack of cards, one per row, with each figure
|
||||
labelled, and rows of controls wrap rather than run off the side.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Backend:** Node.js + Express, JavaScript, Prisma ORM, PostgreSQL, Socket.io, JWT auth (bcrypt + jsonwebtoken), zod validation.
|
||||
@@ -80,8 +99,12 @@ cd backend
|
||||
npm run seed:hollowcrown
|
||||
```
|
||||
|
||||
On a server, apply new migrations with `npx prisma migrate deploy`. Change
|
||||
your password from the account page (click your name in the top bar).
|
||||
On a server, after pulling a new version, run `npm run deploy` in `backend`
|
||||
and then restart the backend. It applies new migrations **and** regenerates
|
||||
the Prisma client; without the second half the server keeps running with the
|
||||
old data model and pages that use anything new fail to load (for example
|
||||
"Could not load this encounter"). Change your password from the account page
|
||||
("Account" in the navigation).
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.3.0",
|
||||
"version": "1.10.1",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
@@ -10,11 +10,12 @@
|
||||
"scripts": {
|
||||
"dev": "node --watch src/index.js",
|
||||
"start": "node src/index.js",
|
||||
"test": "node --experimental-vm-modules node_modules/.bin/jest",
|
||||
"test": "jest",
|
||||
"docs:lists": "node scripts/generate-doc-lists.js",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:seed": "prisma db seed",
|
||||
"deploy": "prisma migrate deploy && prisma generate",
|
||||
"seed:hollowcrown": "node prisma/seed-hollowcrown.js",
|
||||
"prisma:studio": "prisma studio"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- CreateEnum: the three coins. Silver is worth 0.1 gold, copper 0.01 gold.
|
||||
CREATE TYPE "Currency" AS ENUM ('GOLD', 'SILVER', 'COPPER');
|
||||
|
||||
-- AlterTable: a character's purse holds each coin separately
|
||||
ALTER TABLE "characters" ADD COLUMN "silver" INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "characters" ADD COLUMN "copper" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- AlterTable: every ledger entry and coin reward is in one coin; all existing ones are gold
|
||||
ALTER TABLE "gold_transactions" ADD COLUMN "currency" "Currency" NOT NULL DEFAULT 'GOLD';
|
||||
ALTER TABLE "encounter_rewards" ADD COLUMN "currency" "Currency" NOT NULL DEFAULT 'GOLD';
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable: death saves, counted while a character is at 0 HP
|
||||
ALTER TABLE "characters" ADD COLUMN "deathSaveSuccesses" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "deathSaveFailures" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "stable" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable: the combatant currently marked as the target
|
||||
ALTER TABLE "encounters" ADD COLUMN "targetParticipantId" TEXT;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- AlterTable: several combatants can be marked as targets at once (an area attack)
|
||||
ALTER TABLE "encounters" ADD COLUMN "targetParticipantIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- Carry over a single target marked before this.
|
||||
UPDATE "encounters"
|
||||
SET "targetParticipantIds" = ARRAY["targetParticipantId"]
|
||||
WHERE "targetParticipantId" IS NOT NULL;
|
||||
|
||||
ALTER TABLE "encounters" DROP COLUMN "targetParticipantId";
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable: how much an area attack adds to each AC per extra target
|
||||
ALTER TABLE "campaigns" ADD COLUMN "areaAttackAcPenalty" INTEGER NOT NULL DEFAULT 2;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable: spells that bring back the dead
|
||||
ALTER TABLE "spells" ADD COLUMN "revives" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- The library's two revival spells, wherever a campaign already has them.
|
||||
UPDATE "spells" SET "revives" = true WHERE "name" IN ('Revivify', 'Raise Dead');
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterEnum: cash ($), a modern currency for crossover worlds. It has no fixed
|
||||
-- worth in gold, so it is never exchanged or used to make change.
|
||||
ALTER TYPE "Currency" ADD VALUE 'CASH';
|
||||
|
||||
-- AlterTable: cash is counted on its own, like each coin
|
||||
ALTER TABLE "characters" ADD COLUMN "cash" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- AlterTable: an NPC who changes money does it at their own rate, this many
|
||||
-- gold for that much cash. Both are null for an NPC who does not.
|
||||
ALTER TABLE "npcs" ADD COLUMN "exchangeGold" INTEGER;
|
||||
ALTER TABLE "npcs" ADD COLUMN "exchangeCash" INTEGER;
|
||||
|
||||
-- CreateTable: something for sale, by an NPC or at an encounter itself
|
||||
CREATE TABLE "sale_offers" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"npcId" TEXT,
|
||||
"encounterId" TEXT,
|
||||
"weaponId" TEXT,
|
||||
"itemId" TEXT,
|
||||
"price" INTEGER NOT NULL,
|
||||
"currency" "Currency" NOT NULL DEFAULT 'GOLD',
|
||||
"level" INTEGER NOT NULL DEFAULT 1,
|
||||
"stock" INTEGER,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "sale_offers_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_npcId_fkey" FOREIGN KEY ("npcId") REFERENCES "npcs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_weaponId_fkey" FOREIGN KEY ("weaponId") REFERENCES "weapons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -118,6 +118,16 @@ enum RewardType {
|
||||
ITEM
|
||||
}
|
||||
|
||||
// The money a character carries. The coins' worth is fixed in utils/grants.js:
|
||||
// 1 gold = 10 silver = 100 copper. Cash ($) is modern money for crossover
|
||||
// worlds; it has no worth in coins and is never exchanged for them.
|
||||
enum Currency {
|
||||
GOLD
|
||||
SILVER
|
||||
COPPER
|
||||
CASH
|
||||
}
|
||||
|
||||
enum NoteVisibility {
|
||||
DM_ONLY
|
||||
SHARED
|
||||
@@ -160,6 +170,9 @@ model Campaign {
|
||||
skipMotivationRoll Boolean @default(false)
|
||||
// Off: players see only a health bar for monsters and NPCs, not the numbers.
|
||||
showMonsterHp Boolean @default(true)
|
||||
// An attack on several targets is harder to land: each target's AC counts
|
||||
// as this much higher for every target beyond the first. 0 turns it off.
|
||||
areaAttackAcPenalty Int @default(2)
|
||||
ownerId String
|
||||
// Set while the campaign is archived: kept in full, but moved off the main
|
||||
// list and closed to new members until the DM restores it.
|
||||
@@ -175,6 +188,7 @@ model Campaign {
|
||||
monsters Monster[]
|
||||
npcs Npc[]
|
||||
encounters Encounter[]
|
||||
saleOffers SaleOffer[]
|
||||
notes Note[]
|
||||
locations Location[]
|
||||
|
||||
@@ -256,13 +270,23 @@ model Character {
|
||||
|
||||
currentHp Int @default(0)
|
||||
tempHp Int @default(0)
|
||||
// Death saves, counted while the character is at 0 HP (utils/hp.js). Three
|
||||
// failures is dead; three successes sets `stable` and clears the tallies.
|
||||
deathSaveSuccesses Int @default(0)
|
||||
deathSaveFailures Int @default(0)
|
||||
stable Boolean @default(false) // at 0 HP but no longer rolling
|
||||
armorBonus Int @default(0) // flat AC bonus from equipped armor/shield
|
||||
speedOverride Int?
|
||||
|
||||
// The next three are composed by the builder from the player's picks
|
||||
// (game/data/characterOptions.js). After that only the DM can edit them.
|
||||
specialAbilities String? // shown in the quick-stats panel
|
||||
// The purse: each coin (and cash) is counted on its own and only changes through the
|
||||
// money ledger (GoldTransaction).
|
||||
gold Int @default(0)
|
||||
silver Int @default(0)
|
||||
copper Int @default(0)
|
||||
cash Int @default(0) // whole dollars
|
||||
|
||||
background String?
|
||||
appearance String?
|
||||
@@ -349,6 +373,7 @@ model Weapon {
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
assigned CharacterWeapon[]
|
||||
rewards EncounterReward[]
|
||||
offers SaleOffer[]
|
||||
|
||||
@@map("weapons")
|
||||
}
|
||||
@@ -383,6 +408,7 @@ model Item {
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
held InventoryItem[]
|
||||
rewards EncounterReward[]
|
||||
offers SaleOffer[]
|
||||
|
||||
@@map("items")
|
||||
}
|
||||
@@ -403,12 +429,14 @@ model InventoryItem {
|
||||
@@map("inventory_items")
|
||||
}
|
||||
|
||||
// Ledger behind Character.gold: every change to the balance is one row here.
|
||||
// Ledger behind a character's purse: every change to a coin balance is one
|
||||
// row here. (The name predates silver and copper.)
|
||||
model GoldTransaction {
|
||||
id String @id @default(cuid())
|
||||
characterId String
|
||||
currency Currency @default(GOLD) // which coin this entry is in
|
||||
amount Int // signed: positive = gained, negative = spent
|
||||
balanceAfter Int
|
||||
balanceAfter Int // that coin's balance afterwards
|
||||
reason String?
|
||||
createdById String?
|
||||
createdAt DateTime @default(now())
|
||||
@@ -436,6 +464,9 @@ model Spell {
|
||||
duration String?
|
||||
description String?
|
||||
isStartingOption Boolean @default(false)
|
||||
// Brings back the dead: a character who can cast it may revive a dead ally
|
||||
// on their turn in a battle (encounters.routes.js).
|
||||
revives Boolean @default(false)
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
known CharacterSpell[]
|
||||
@@ -499,10 +530,15 @@ model Npc {
|
||||
maxHp Int?
|
||||
attackBonus Int?
|
||||
damageDice String?
|
||||
// A money changer swaps gold and cash at their own rate: exchangeGold gold
|
||||
// for exchangeCash cash, either way. Both null: this NPC does not exchange.
|
||||
exchangeGold Int?
|
||||
exchangeCash Int?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
encounterAppearances EncounterParticipant[]
|
||||
wares SaleOffer[]
|
||||
|
||||
@@map("npcs")
|
||||
}
|
||||
@@ -516,6 +552,10 @@ model Encounter {
|
||||
currentRound Int @default(1)
|
||||
// Whose turn it is in a battle; null until the fight starts.
|
||||
activeParticipantId String?
|
||||
// Who is being attacked (or healed) right now, shown to everyone, in the
|
||||
// order they were picked. More than one makes it an area attack. Picked by
|
||||
// the DM or by the player on turn, and cleared when the turn passes.
|
||||
targetParticipantIds String[] @default([])
|
||||
description String? // the scene / objective, shown to everyone
|
||||
dmNotes String? // never sent to players
|
||||
// Where the scene takes place. While the encounter is in progress, the
|
||||
@@ -533,6 +573,7 @@ model Encounter {
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||
participants EncounterParticipant[]
|
||||
rewards EncounterReward[]
|
||||
wares SaleOffer[]
|
||||
|
||||
@@map("encounters")
|
||||
}
|
||||
@@ -544,7 +585,8 @@ model EncounterReward {
|
||||
id String @id @default(cuid())
|
||||
encounterId String
|
||||
type RewardType
|
||||
gold Int? // GOLD
|
||||
gold Int? // GOLD: how many coins
|
||||
currency Currency @default(GOLD) // GOLD: which coin they are
|
||||
weaponId String? // WEAPON
|
||||
itemId String? // ITEM
|
||||
quantity Int @default(1)
|
||||
@@ -561,6 +603,33 @@ model EncounterReward {
|
||||
@@map("encounter_rewards")
|
||||
}
|
||||
|
||||
// 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).
|
||||
model SaleOffer {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
npcId String? // exactly one of npcId / encounterId is set
|
||||
encounterId String?
|
||||
weaponId String? // exactly one of weaponId / itemId is set
|
||||
itemId String?
|
||||
price Int // for one, in `currency`
|
||||
currency Currency @default(GOLD)
|
||||
level Int @default(1) // gear level it is handed over at
|
||||
stock Int? // how many are left; null = no limit
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
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)
|
||||
weapon Weapon? @relation(fields: [weaponId], references: [id], onDelete: Cascade)
|
||||
item Item? @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("sale_offers")
|
||||
}
|
||||
|
||||
// A row per combatant in an encounter — a character, a monster or an NPC.
|
||||
// A monster's currentHp/maxHp live here, per instance. A character's HP has
|
||||
// one source of truth, the Character row: encounters read and write that, and
|
||||
|
||||
@@ -115,7 +115,7 @@ const SPELL_LIBRARY = [
|
||||
{ key: 'nondetection', name: 'Nondetection', level: 3, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: '8 hours', description: 'Hide a target from divination magic.', classes: ['BARD', 'RANGER', 'WIZARD'] },
|
||||
{ key: 'protection-from-energy', name: 'Protection from Energy', level: 3, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 hour', description: 'Resistance to one damage type of your choice.', classes: ['CLERIC', 'DRUID', 'RANGER', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'remove-curse', name: 'Remove Curse', level: 3, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'Lift a curse from a creature or object.', classes: ['CLERIC', 'PALADIN', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'revivify', name: 'Revivify', level: 3, school: 'Necromancy', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'A creature that died within the last minute returns to life with 1 hit point.', classes: ['CLERIC', 'PALADIN'] },
|
||||
{ key: 'revivify', name: 'Revivify', level: 3, school: 'Necromancy', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'A creature that died within the last minute returns to life with 1 hit point.', revives: true, classes: ['CLERIC', 'PALADIN'] },
|
||||
{ key: 'sleet-storm', name: 'Sleet Storm', level: 3, school: 'Conjuration', castingTime: '1 action', range: '150 feet', duration: 'Concentration, up to 1 minute', description: 'Freezing rain falls in a 40-foot-radius cylinder, icing the ground.', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'slow', name: 'Slow', level: 3, school: 'Transmutation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 minute', description: 'Up to six creatures are hindered, halving their speed and worse.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'spirit-guardians', name: 'Spirit Guardians', level: 3, school: 'Conjuration', castingTime: '1 action', range: 'Self (15-foot radius)', duration: 'Concentration, up to 10 minutes', description: 'Protective spirits swirl around you, damaging enemies that enter.', damageDice: '3d8', classes: ['CLERIC'] },
|
||||
@@ -150,7 +150,7 @@ const SPELL_LIBRARY = [
|
||||
{ key: 'insect-plague', name: 'Insect Plague', level: 5, school: 'Conjuration', castingTime: '1 action', range: '300 feet', duration: 'Concentration, up to 10 minutes', description: 'A swarm of biting, stinging insects fills a 20-foot-radius sphere.', damageDice: '4d10', classes: ['CLERIC', 'DRUID', 'SORCERER'] },
|
||||
{ key: 'legend-lore', name: 'Legend Lore', level: 5, school: 'Divination', castingTime: '10 minutes', range: 'Self', duration: 'Instantaneous', description: 'Learn what you know, or have heard, about a name, place, or object.', classes: ['BARD', 'CLERIC', 'WIZARD'] },
|
||||
{ key: 'mass-cure-wounds', name: 'Mass Cure Wounds', level: 5, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'Up to six creatures in a 30-foot-radius sphere regain hit points.', classes: ['BARD', 'CLERIC', 'DRUID'] },
|
||||
{ key: 'raise-dead', name: 'Raise Dead', level: 5, school: 'Necromancy', castingTime: '1 hour', range: 'Touch', duration: 'Instantaneous', description: 'Return a dead creature to life, as long as its body is mostly intact.', classes: ['BARD', 'CLERIC', 'PALADIN'] },
|
||||
{ key: 'raise-dead', name: 'Raise Dead', level: 5, school: 'Necromancy', castingTime: '1 hour', range: 'Touch', duration: 'Instantaneous', description: 'Return a dead creature to life, as long as its body is mostly intact.', revives: true, classes: ['BARD', 'CLERIC', 'PALADIN'] },
|
||||
{ key: 'wall-of-force', name: 'Wall of Force', level: 5, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'An invisible wall of force springs into existence, impenetrable to physical force.', classes: ['WIZARD'] },
|
||||
{ key: 'wall-of-stone', name: 'Wall of Stone', level: 5, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'A nonmagical wall of solid stone springs into existence.', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
|
||||
];
|
||||
|
||||
@@ -584,7 +584,7 @@ describe('hit points', () => {
|
||||
const full = (await patch(playerAuth, { currentHp: 9999 })).body.character;
|
||||
expect(full.currentHp).toBe(level5.sheet.hp.max);
|
||||
|
||||
expect((await patch(playerAuth, { currentHp: -50 })).body.character.currentHp).toBe(0);
|
||||
expect((await patch(playerAuth, { currentHp: -1 })).body.character.currentHp).toBe(0);
|
||||
|
||||
await patch(playerAuth, { currentHp: 9999 });
|
||||
const level1 = (await patch(dmAuth, { level: 1 })).body.character;
|
||||
@@ -613,7 +613,7 @@ describe('hit points', () => {
|
||||
expect((await combatant()).currentHp).toBe(maxHp - 1);
|
||||
|
||||
// The tracker is clamped like the sheet, and can't give a character a different max.
|
||||
await request(app).patch(`/encounters/${encounterId}/participants/${participantId}`).set(dmAuth).send({ currentHp: -20, maxHp: 500 });
|
||||
await request(app).patch(`/encounters/${encounterId}/participants/${participantId}`).set(dmAuth).send({ currentHp: -1, maxHp: 500 });
|
||||
expect(await sheetHp()).toBe(0);
|
||||
expect((await combatant()).maxHp).toBe(maxHp);
|
||||
|
||||
@@ -674,6 +674,39 @@ describe('money tracker', () => {
|
||||
[15, 15, 'Starting gold', 'Player'],
|
||||
]);
|
||||
});
|
||||
|
||||
itIfDb('keeps cash apart from the coins', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('cash');
|
||||
const create = (name) => request(app).post(`/campaigns/${campaign.id}/characters`).set(playerAuth).send({ name });
|
||||
const characterId = (await create('Drifter')).body.character.id;
|
||||
const money = (body) => request(app).post(`/characters/${characterId}/gold`).set(playerAuth).send(body);
|
||||
|
||||
await money({ amount: 5 });
|
||||
const paid = await money({ amount: 40, currency: 'CASH', reason: 'Odd job' });
|
||||
expect(paid.status).toBe(201);
|
||||
expect(paid.body).toMatchObject({ gold: 5, cash: 40 });
|
||||
expect(paid.body.transaction).toMatchObject({ currency: 'CASH', amount: 40, balanceAfter: 40 });
|
||||
|
||||
// Cash is paid in cash alone: gold doesn't cover it, and it doesn't cover gold.
|
||||
const overspend = await money({ amount: -41, currency: 'CASH' });
|
||||
expect(overspend.status).toBe(400);
|
||||
expect(overspend.body.error).toContain('$40');
|
||||
expect((await money({ amount: -6 })).status).toBe(400);
|
||||
const spent = await money({ amount: -15, currency: 'CASH', reason: 'Diner' });
|
||||
expect(spent.body).toMatchObject({ gold: 5, silver: 0, copper: 0, cash: 25 });
|
||||
expect(spent.body.transactions).toHaveLength(1);
|
||||
|
||||
// It has no worth in coins, so it can't be exchanged, and it only moves through the ledger.
|
||||
const exchange = (body) => request(app).post(`/characters/${characterId}/gold/exchange`).set(playerAuth).send(body);
|
||||
expect((await exchange({ from: 'CASH', to: 'GOLD', amount: 10 })).status).toBe(400);
|
||||
expect((await exchange({ from: 'GOLD', to: 'CASH', amount: 1 })).status).toBe(400);
|
||||
expect((await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ cash: 999 })).status).toBe(400);
|
||||
|
||||
const sheet = (await request(app).get(`/characters/${characterId}`).set(playerAuth)).body.character;
|
||||
expect(sheet.cash).toBe(25);
|
||||
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(dmAuth)).body.characters;
|
||||
expect(listed.find((c) => c.id === characterId).cash).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe('starting options', () => {
|
||||
@@ -956,7 +989,11 @@ describe('encounters', () => {
|
||||
|
||||
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId });
|
||||
const playerView = byName((await request(app).get(e).set(playerAuth)).body.encounter);
|
||||
expect(playerView['Mayor Orla'].npc).toEqual({ id: mayor.id, name: 'Mayor Orla', role: 'Mayor', disposition: 'HOSTILE', description: 'Tired but kind.' });
|
||||
// What a player gets is exactly this: the public face, plus what the NPC trades (nothing, here).
|
||||
expect(playerView['Mayor Orla'].npc).toEqual({
|
||||
id: mayor.id, name: 'Mayor Orla', role: 'Mayor', disposition: 'HOSTILE', description: 'Tired but kind.',
|
||||
exchangeGold: null, exchangeCash: null, wares: [],
|
||||
});
|
||||
|
||||
// Deleting an NPC takes it out of the scenes it was in.
|
||||
expect((await request(app).delete(`${c}/npcs/${hermit.id}`).set(dmAuth)).status).toBe(204);
|
||||
@@ -1418,6 +1455,35 @@ describe('default content library', () => {
|
||||
expect((await request(app).get(`/campaigns/${campaign.id}/monsters`).set(dmAuth)).body.monsters).toHaveLength(1);
|
||||
expect((await request(app).get(`/campaigns/${campaign.id}/monsters`).set(playerAuth)).status).toBe(403);
|
||||
|
||||
// Every library monster carries the whole stat block.
|
||||
for (const m of listRes.body.monsters) {
|
||||
expect(m).toMatchObject({
|
||||
challengeRating: expect.any(String),
|
||||
ac: expect.any(Number),
|
||||
maxHp: expect.any(Number),
|
||||
speed: expect.any(Number),
|
||||
attackBonus: expect.any(Number),
|
||||
damageDice: expect.any(String),
|
||||
notes: expect.any(String),
|
||||
});
|
||||
for (const a of ['str', 'dex', 'con', 'int', 'wis', 'cha']) expect(m[`${a}Score`]).toEqual(expect.any(Number));
|
||||
}
|
||||
|
||||
// A hand-made monster takes the whole stat block, and every field can be changed afterwards.
|
||||
const block = {
|
||||
name: 'Gloamling', challengeRating: '1/2', ac: 13, maxHp: 18, speed: 40, attackBonus: 4, damageDice: '1d6+2 necrotic',
|
||||
strScore: 8, dexScore: 16, conScore: 12, intScore: 6, wisScore: 11, chaScore: 5,
|
||||
specialTrait: 'Shadow Step', notes: 'Fly 30. Resists necrotic.',
|
||||
};
|
||||
const made = await request(app).post(`/campaigns/${campaign.id}/monsters`).set(dmAuth).send(block);
|
||||
expect(made.status).toBe(201);
|
||||
expect(made.body.monster).toMatchObject(block);
|
||||
const patchMonster = (auth, body) => request(app).patch(`/campaigns/${campaign.id}/monsters/${made.body.monster.id}`).set(auth).send(body);
|
||||
const edited = await patchMonster(dmAuth, { speed: 20, wisScore: 14, challengeRating: '1', specialTrait: null });
|
||||
expect(edited.status).toBe(200);
|
||||
expect(edited.body.monster).toMatchObject({ ...block, speed: 20, wisScore: 14, challengeRating: '1', specialTrait: null });
|
||||
expect((await patchMonster(playerAuth, { ac: 1 })).status).toBe(403);
|
||||
|
||||
const playerAddRes = await request(app)
|
||||
.post(`/campaigns/${campaign.id}/monsters/from-library`)
|
||||
.set(playerAuth)
|
||||
@@ -1839,7 +1905,9 @@ describe('what a player may see', () => {
|
||||
expect(dm.Ogre.hpPercent).toBeUndefined();
|
||||
|
||||
await request(app).patch(`${e}/participants/${ogreRow.id}`).set(dmAuth).send({ currentHp: 0 });
|
||||
expect((await view(playerAuth)).Ogre.hpPercent).toBe(0);
|
||||
// At 0 HP it is out of the fight, and out of the players' sight.
|
||||
expect((await view(playerAuth)).Ogre).toBeUndefined();
|
||||
expect((await view(dmAuth)).Ogre).toMatchObject({ currentHp: 0, hidden: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1896,6 +1964,15 @@ describe('initiative', () => {
|
||||
expect(playerView.Goblin.initiative).toBe(12);
|
||||
expect(playerView.Quick.character.userId).toBeTruthy();
|
||||
|
||||
// Only the die stops at 20: a natural 20 plus a bonus goes past it.
|
||||
expect((await patch(goblinRow, dmAuth, { initiativeRoll: 20 })).body.participant).toMatchObject({ initiativeRoll: 20, initiative: 22 });
|
||||
|
||||
// Quests take initiative exactly as battles do.
|
||||
await request(app).patch(e).set(dmAuth).send({ mode: 'QUEST' });
|
||||
expect((await patch(heroRow, playerAuth, { initiativeRoll: 19 })).body.participant.initiative).toBe(19 + heroBonus);
|
||||
await request(app).patch(e).set(dmAuth).send({ mode: 'BATTLE' });
|
||||
await patch(heroRow, playerAuth, { initiativeRoll: 20 });
|
||||
|
||||
// The DM can re-roll anyone, or clear a roll.
|
||||
expect((await patch(goblinRow, dmAuth, { initiativeRoll: 1 })).body.participant.initiative).toBe(3);
|
||||
expect((await patch(heroRow, dmAuth, { initiativeRoll: null })).body.participant).toMatchObject({ initiativeRoll: null, initiative: 0 });
|
||||
@@ -1903,6 +1980,68 @@ describe('initiative', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('targets', () => {
|
||||
itIfDb('are picked by the DM or the player on turn, shown to all, and go when the turn does', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('target');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Archer');
|
||||
const goblin = (await request(app).post(`${c}/monsters/from-library`).set(dmAuth).send({ key: 'goblin' })).body.monster;
|
||||
const e = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Ambush' })).body.encounter.id}`;
|
||||
const add = async (body) => (await request(app).post(`${e}/participants`).set(dmAuth).send(body)).body.participant;
|
||||
const hero = await add({ characterId: heroId, initiativeRoll: 20 });
|
||||
const first = await add({ monsterId: goblin.id, initiativeRoll: 10 });
|
||||
const second = await add({ monsterId: goblin.id, initiativeRoll: 1 });
|
||||
const target = (auth, ...participantIds) => request(app).put(`${e}/targets`).set(auth).send({ participantIds });
|
||||
const seen = async (auth) => (await request(app).get(e).set(auth)).body.encounter.targetParticipantIds;
|
||||
|
||||
// Before the battle only the DM can mark anyone.
|
||||
expect((await target(playerAuth, first.id)).status).toBe(403);
|
||||
expect((await target(dmAuth, first.id)).body.encounter.targetParticipantIds).toEqual([first.id]);
|
||||
expect(await seen(playerAuth)).toEqual([first.id]);
|
||||
expect((await target(dmAuth, first.id, 'nobody')).status).toBe(400);
|
||||
|
||||
// Beginning the battle clears them and puts the hero on turn: now the
|
||||
// player can, and several at once is an area attack, kept in the order picked.
|
||||
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' });
|
||||
expect(await seen(dmAuth)).toEqual([]);
|
||||
expect((await target(playerAuth, second.id, first.id, second.id)).status).toBe(200);
|
||||
expect(await seen(dmAuth)).toEqual([second.id, first.id]);
|
||||
|
||||
// Removing a target unmarks it and leaves the rest.
|
||||
await request(app).delete(`${e}/participants/${second.id}`).set(dmAuth);
|
||||
expect(await seen(dmAuth)).toEqual([first.id]);
|
||||
|
||||
// The turn passing clears them, and the player is no longer on turn.
|
||||
await request(app).post(`${e}/next-turn`).set(dmAuth);
|
||||
expect(await seen(dmAuth)).toEqual([]);
|
||||
expect((await target(playerAuth, first.id)).status).toBe(403);
|
||||
|
||||
// Whoever is on turn can't be a target, their own or as part of a group.
|
||||
expect((await target(dmAuth, first.id)).status).toBe(400);
|
||||
expect((await target(dmAuth, hero.id, first.id)).status).toBe(400);
|
||||
|
||||
// The goblin's turn: the DM marks the hero, and can clear it again.
|
||||
expect((await target(dmAuth, hero.id)).body.encounter.targetParticipantIds).toEqual([hero.id]);
|
||||
expect((await target(dmAuth)).body.encounter.targetParticipantIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('area attack AC penalty', () => {
|
||||
itIfDb('is a campaign setting the DM changes, 2 unless told otherwise', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('areapenalty');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
expect(campaign.areaAttackAcPenalty).toBe(2);
|
||||
|
||||
expect((await request(app).patch(c).set(playerAuth).send({ areaAttackAcPenalty: 0 })).status).toBe(403);
|
||||
expect((await request(app).patch(c).set(dmAuth).send({ areaAttackAcPenalty: 11 })).status).toBe(400);
|
||||
expect((await request(app).patch(c).set(dmAuth).send({ areaAttackAcPenalty: -1 })).status).toBe(400);
|
||||
expect((await request(app).patch(c).set(dmAuth).send({ areaAttackAcPenalty: 0 })).body.campaign.areaAttackAcPenalty).toBe(0);
|
||||
|
||||
// Players are told it too: their cards show the penalty on a marked target.
|
||||
expect((await request(app).get(c).set(playerAuth)).body.campaign.areaAttackAcPenalty).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('number limits', () => {
|
||||
itIfDb('are enforced by the server, whatever a form sends', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('limits');
|
||||
@@ -1963,3 +2102,431 @@ describe('temporary hit points', () => {
|
||||
expect([now.currentHp, now.tempHp]).toEqual([0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('death saves', () => {
|
||||
itIfDb('start at 0 HP, are settled by three of a kind, and end with healing', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('deathsaves');
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Fallen');
|
||||
const ch = `/characters/${heroId}`;
|
||||
const sheet = async () => (await request(app).get(ch).set(playerAuth)).body.character;
|
||||
const hp = (auth, currentHp) => request(app).patch(ch).set(auth).send({ currentHp });
|
||||
const save = (auth, body) => request(app).post(`${ch}/death-saves`).set(auth).send(body);
|
||||
const max = (await sheet()).sheet.hp.max;
|
||||
expect((await sheet()).deathSaves).toEqual({ state: 'UP', successes: 0, failures: 0 });
|
||||
|
||||
// No saves while standing, and a save is a d20.
|
||||
expect((await save(playerAuth, { action: 'roll', roll: 12 })).status).toBe(400);
|
||||
await hp(playerAuth, 0);
|
||||
expect((await sheet()).deathSaves).toEqual({ state: 'DYING', successes: 0, failures: 0 });
|
||||
expect((await save(playerAuth, { action: 'roll', roll: 21 })).status).toBe(400);
|
||||
|
||||
// 10 and up succeeds, below fails, and a 1 fails twice.
|
||||
expect((await save(playerAuth, { action: 'roll', roll: 10 })).body.character.deathSaves).toEqual({ state: 'DYING', successes: 1, failures: 0 });
|
||||
expect((await save(playerAuth, { action: 'roll', roll: 9 })).body.character.deathSaves).toEqual({ state: 'DYING', successes: 1, failures: 1 });
|
||||
|
||||
// Being hit while down is a failed save; healing clears the lot.
|
||||
expect((await hp(dmAuth, -1)).body.character.deathSaves).toEqual({ state: 'DYING', successes: 1, failures: 2 });
|
||||
expect((await hp(playerAuth, 3)).body.character.deathSaves).toEqual({ state: 'UP', successes: 0, failures: 0 });
|
||||
|
||||
// Three successes: stable, at 0 HP, with nothing left to roll.
|
||||
await hp(playerAuth, 0);
|
||||
for (const roll of [15, 11, 19]) await save(playerAuth, { action: 'roll', roll });
|
||||
let now = await sheet();
|
||||
expect([now.currentHp, now.deathSaves]).toEqual([0, { state: 'STABLE', successes: 0, failures: 0 }]);
|
||||
expect((await save(playerAuth, { action: 'roll', roll: 12 })).status).toBe(400);
|
||||
|
||||
// Damage ends that; a 20 puts them back up with 1 HP.
|
||||
expect((await hp(dmAuth, -1)).body.character.deathSaves).toEqual({ state: 'DYING', successes: 0, failures: 1 });
|
||||
now = (await save(playerAuth, { action: 'roll', roll: 20 })).body.character;
|
||||
expect([now.currentHp, now.deathSaves]).toEqual([1, { state: 'UP', successes: 0, failures: 0 }]);
|
||||
|
||||
// Someone else can steady them without a roll.
|
||||
await hp(playerAuth, 0);
|
||||
expect((await save(playerAuth, { action: 'stabilize' })).body.character.deathSaves.state).toBe('STABLE');
|
||||
|
||||
// The tallies are the DM's to set by hand; a 1 on top of two failures kills.
|
||||
expect((await save(playerAuth, { action: 'set', successes: 0, failures: 0 })).status).toBe(403);
|
||||
expect((await save(dmAuth, { action: 'set', successes: 2, failures: 2 })).body.character.deathSaves).toEqual({ state: 'DYING', successes: 2, failures: 2 });
|
||||
expect((await save(playerAuth, { action: 'roll', roll: 1 })).body.character.deathSaves.state).toBe('DEAD');
|
||||
|
||||
// The dead can't be healed or roll; only the DM brings them back.
|
||||
expect((await hp(playerAuth, max)).status).toBe(400);
|
||||
expect((await save(playerAuth, { action: 'roll', roll: 20 })).status).toBe(400);
|
||||
expect((await save(playerAuth, { action: 'revive' })).status).toBe(403);
|
||||
now = (await save(dmAuth, { action: 'revive' })).body.character;
|
||||
expect([now.currentHp, now.deathSaves]).toEqual([1, { state: 'UP', successes: 0, failures: 0 }]);
|
||||
});
|
||||
|
||||
itIfDb('follow the character into an encounter, where one huge blow kills outright', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('deathsaves-enc');
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Doomed');
|
||||
const ch = `/characters/${heroId}`;
|
||||
const max = (await request(app).get(ch).set(playerAuth)).body.character.sheet.hp.max;
|
||||
const e = `/encounters/${(await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Last Stand' })).body.encounter.id}`;
|
||||
const row = (await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId })).body.participant;
|
||||
const strike = (currentHp) => request(app).patch(`${e}/participants/${row.id}`).set(dmAuth).send({ currentHp });
|
||||
const combatant = async () => (await request(app).get(e).set(playerAuth)).body.encounter.participants[0];
|
||||
|
||||
// Temp HP that soaks the whole blow keeps them on their feet.
|
||||
await request(app).patch(ch).set(playerAuth).send({ tempHp: 2 });
|
||||
await strike(max - 2);
|
||||
expect((await combatant()).characterCombatInfo.deathSaves.state).toBe('UP');
|
||||
|
||||
// Dropped by the DM in the tracker; the player's own save shows there.
|
||||
await strike(1 - max); // max - 1 past zero: one short of killing outright
|
||||
expect((await combatant()).characterCombatInfo.deathSaves).toEqual({ state: 'DYING', successes: 0, failures: 0 });
|
||||
await request(app).post(`${ch}/death-saves`).set(playerAuth).send({ action: 'roll', roll: 4 });
|
||||
expect((await combatant()).characterCombatInfo.deathSaves).toEqual({ state: 'DYING', successes: 0, failures: 1 });
|
||||
|
||||
// The party list carries it too.
|
||||
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(dmAuth)).body.characters.find((c) => c.id === heroId);
|
||||
expect(listed.deathSaves.state).toBe('DYING');
|
||||
|
||||
// Healed, then hit for max HP more than they have left.
|
||||
await strike(max);
|
||||
await strike(-max);
|
||||
const dmView = (await request(app).get(e).set(dmAuth)).body.encounter.participants[0];
|
||||
expect([dmView.hidden, dmView.characterCombatInfo.deathSaves.state]).toEqual([true, 'DEAD']);
|
||||
expect((await strike(5)).status).toBe(400);
|
||||
});
|
||||
|
||||
itIfDb('hide the fallen from players, skip their turns, and let an ally with a reviving spell bring a character back', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('fallen');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const healerId = await buildCharacter(campaign.id, playerAuth, 'Healer');
|
||||
const doomedId = await buildCharacter(campaign.id, playerAuth, 'Doomed');
|
||||
const goblin = (await request(app).post(`${c}/monsters/from-library`).set(dmAuth).send({ key: 'goblin' })).body.monster;
|
||||
const e = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Crypt' })).body.encounter.id}`;
|
||||
const add = async (body) => (await request(app).post(`${e}/participants`).set(dmAuth).send(body)).body.participant.id;
|
||||
const healer = await add({ characterId: healerId, initiativeRoll: 20 });
|
||||
const doomed = await add({ characterId: doomedId, initiativeRoll: 10 });
|
||||
const gob = await add({ monsterId: goblin.id, initiativeRoll: 1 });
|
||||
const setHp = (id, currentHp) => request(app).patch(`${e}/participants/${id}`).set(dmAuth).send({ currentHp });
|
||||
const view = async (auth) => (await request(app).get(e).set(auth)).body.encounter;
|
||||
const ids = (encounter) => encounter.participants.map((p) => p.id);
|
||||
const revive = (auth, id) => request(app).post(`${e}/participants/${id}/revive`).set(auth);
|
||||
const nextTurn = async () => (await request(app).post(`${e}/next-turn`).set(dmAuth)).body.encounter;
|
||||
|
||||
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' }); // the healer is on turn
|
||||
await request(app).put(`${e}/targets`).set(dmAuth).send({ participantIds: [gob] });
|
||||
|
||||
// A monster at 0 HP is gone from the player's list, target mark and all; the DM keeps it.
|
||||
await setHp(gob, 0);
|
||||
let seen = await view(playerAuth);
|
||||
expect([ids(seen), seen.targetParticipantIds, seen.fallen]).toEqual([[healer, doomed], [], []]);
|
||||
seen = await view(dmAuth);
|
||||
expect([ids(seen), seen.targetParticipantIds]).toEqual([[healer, doomed, gob], [gob]]);
|
||||
expect(seen.participants.map((p) => p.hidden)).toEqual([false, false, true]);
|
||||
expect((await request(app).put(`${e}/targets`).set(playerAuth).send({ participantIds: [gob] })).status).toBe(400);
|
||||
|
||||
// A dying character stays in view; a dead one is hidden, and named among the fallen.
|
||||
await setHp(doomed, 0);
|
||||
expect(ids(await view(playerAuth))).toEqual([healer, doomed]);
|
||||
await request(app).post(`/characters/${doomedId}/death-saves`).set(dmAuth).send({ action: 'set', successes: 0, failures: 3 });
|
||||
seen = await view(playerAuth);
|
||||
expect([ids(seen), seen.fallen]).toEqual([[healer], [{ participantId: doomed, characterId: doomedId, name: 'Doomed' }]]);
|
||||
|
||||
// The dead join no other scene until they are revived.
|
||||
const elsewhere = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Elsewhere' })).body.encounter.id}`;
|
||||
const join = () => request(app).post(`${elsewhere}/participants`).set(dmAuth).send({ characterId: doomedId });
|
||||
expect((await join()).status).toBe(400);
|
||||
|
||||
// The fallen have no turn: with only the healer standing, the round rolls straight over.
|
||||
seen = await nextTurn();
|
||||
expect([seen.activeParticipantId, seen.currentRound]).toEqual([healer, 2]);
|
||||
|
||||
// Reviving takes a spell that brings back the dead, at a level the caster can use.
|
||||
expect(seen.participants[0].characterCombatInfo.canRevive).toBe(false);
|
||||
expect((await revive(playerAuth, doomed)).status).toBe(400);
|
||||
const revivify = (await request(app).post(`${c}/spells/from-library`).set(dmAuth).send({ key: 'revivify' })).body.spell;
|
||||
expect(revivify.revives).toBe(true);
|
||||
await request(app).post(`/characters/${healerId}/spells`).set(dmAuth).send({ spellId: revivify.id });
|
||||
expect((await revive(playerAuth, doomed)).status).toBe(400); // level 1 can't cast a level 3 spell
|
||||
await request(app).patch(`/characters/${healerId}`).set(dmAuth).send({ level: 3 });
|
||||
expect((await view(playerAuth)).participants[0].characterCombatInfo.canRevive).toBe(true);
|
||||
expect((await revive(playerAuth, gob)).status).toBe(400); // monsters are healed, not revived
|
||||
|
||||
const revived = await revive(playerAuth, doomed);
|
||||
expect([revived.status, revived.body.revivedWith]).toEqual([200, 'Revivify']);
|
||||
expect([ids(revived.body.encounter), revived.body.encounter.fallen]).toEqual([[healer, doomed], []]);
|
||||
const back = (await request(app).get(`/characters/${doomedId}`).set(playerAuth)).body.character;
|
||||
expect([back.currentHp, back.deathSaves]).toEqual([1, { state: 'UP', successes: 0, failures: 0 }]);
|
||||
expect((await revive(playerAuth, doomed)).status).toBe(400); // not dead any more
|
||||
expect((await join()).status).toBe(201);
|
||||
|
||||
// Only on the reviver's own turn, and healing a monster brings it back into view.
|
||||
expect((await nextTurn()).activeParticipantId).toBe(doomed);
|
||||
await setHp(gob, 3);
|
||||
expect(ids(await view(playerAuth))).toEqual([healer, doomed, gob]);
|
||||
expect((await nextTurn()).activeParticipantId).toBe(gob);
|
||||
expect((await revive(playerAuth, doomed)).status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('silver and copper', () => {
|
||||
itIfDb('are kept as separate coins, exchanged at fixed worth, given and rewarded', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('coins');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
await request(app).patch(c).set(dmAuth).send({ startingGold: 5 });
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Purse');
|
||||
const friendId = await buildCharacter(campaign.id, dmAuth, 'Friend');
|
||||
const ch = `/characters/${heroId}`;
|
||||
const purse = async (id = heroId, auth = playerAuth) => {
|
||||
const { gold, silver, copper } = (await request(app).get(`/characters/${id}`).set(auth)).body.character;
|
||||
return [gold, silver, copper];
|
||||
};
|
||||
const money = (body) => request(app).post(`${ch}/gold`).set(playerAuth).send(body);
|
||||
const exchange = (body) => request(app).post(`${ch}/gold/exchange`).set(playerAuth).send(body);
|
||||
|
||||
expect(await purse()).toEqual([5, 0, 0]);
|
||||
|
||||
// Each coin has its own balance; an amount with no coin named is gold.
|
||||
expect((await money({ amount: 7, currency: 'SILVER', reason: 'Sold a pelt' })).status).toBe(201);
|
||||
expect((await money({ amount: 3, currency: 'COPPER' })).body.copper).toBe(3);
|
||||
expect((await money({ amount: 1 })).body.gold).toBe(6);
|
||||
expect(await purse()).toEqual([6, 7, 3]);
|
||||
expect((await money({ amount: 1, currency: 'PLATINUM' })).status).toBe(400);
|
||||
|
||||
// A spend the whole purse can't cover is refused and moves nothing.
|
||||
const short = await money({ amount: -1000, currency: 'SILVER' });
|
||||
expect(short.status).toBe(400);
|
||||
expect(short.body.error).toMatch(/Not enough money/);
|
||||
expect(await purse()).toEqual([6, 7, 3]);
|
||||
expect((await request(app).patch(ch).set(dmAuth).send({ silver: 99 })).status).toBe(400);
|
||||
|
||||
// 1 gold = 10 silver = 100 copper.
|
||||
expect((await exchange({ from: 'GOLD', to: 'SILVER', amount: 1 })).body.received).toBe(10);
|
||||
expect(await purse()).toEqual([5, 17, 3]);
|
||||
expect((await exchange({ from: 'GOLD', to: 'COPPER', amount: 1 })).body.received).toBe(100);
|
||||
expect((await exchange({ from: 'SILVER', to: 'COPPER', amount: 1 })).body.received).toBe(10);
|
||||
expect(await purse()).toEqual([4, 16, 113]);
|
||||
expect((await exchange({ from: 'COPPER', to: 'SILVER', amount: 20 })).body.received).toBe(2);
|
||||
expect((await exchange({ from: 'SILVER', to: 'GOLD', amount: 10 })).body.received).toBe(1);
|
||||
expect((await exchange({ from: 'COPPER', to: 'SILVER', amount: 90 })).body.received).toBe(9);
|
||||
expect(await purse()).toEqual([5, 17, 3]);
|
||||
|
||||
// It has to come to whole coins, be affordable, and be between two different coins.
|
||||
const uneven = await exchange({ from: 'SILVER', to: 'GOLD', amount: 5 });
|
||||
expect(uneven.status).toBe(400);
|
||||
expect(uneven.body.error).toMatch(/multiples of 10/);
|
||||
expect((await exchange({ from: 'COPPER', to: 'SILVER', amount: 1 })).status).toBe(400);
|
||||
expect((await exchange({ from: 'COPPER', to: 'GOLD', amount: 50 })).body.error).toMatch(/multiples of 100/);
|
||||
expect((await exchange({ from: 'SILVER', to: 'COPPER', amount: 18 })).status).toBe(400);
|
||||
expect((await exchange({ from: 'GOLD', to: 'GOLD', amount: 1 })).status).toBe(400);
|
||||
expect((await request(app).post(`/characters/${friendId}/gold/exchange`).set(playerAuth).send({ from: 'GOLD', to: 'SILVER', amount: 1 })).status).toBe(403);
|
||||
expect(await purse()).toEqual([5, 17, 3]);
|
||||
|
||||
// The ledger says which coin every entry is in.
|
||||
const ledger = (await request(app).get(`${ch}/gold`).set(playerAuth)).body;
|
||||
expect([ledger.gold, ledger.silver, ledger.copper]).toEqual([5, 17, 3]);
|
||||
expect(ledger.transactions[0]).toMatchObject({ currency: 'SILVER', amount: 9, balanceAfter: 17, reason: 'Exchanged from 90 copper' });
|
||||
expect(ledger.transactions.find((t) => t.reason === 'Sold a pelt')).toMatchObject({ currency: 'SILVER', amount: 7, balanceAfter: 7 });
|
||||
|
||||
// Giving and rewards work per coin.
|
||||
expect((await request(app).post(`${ch}/give`).set(playerAuth).send({ kind: 'gold', currency: 'SILVER', amount: 3, toCharacterId: friendId })).status).toBe(200);
|
||||
expect((await request(app).post(`${ch}/give`).set(playerAuth).send({ kind: 'gold', currency: 'COPPER', amount: 5000, toCharacterId: friendId })).status).toBe(400);
|
||||
expect(await purse()).toEqual([5, 14, 3]);
|
||||
expect(await purse(friendId, dmAuth)).toEqual([5, 3, 0]);
|
||||
|
||||
const e = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Loose change' })).body.encounter.id}`;
|
||||
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId });
|
||||
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: friendId });
|
||||
const reward = (await request(app).post(`${e}/rewards`).set(dmAuth).send({ type: 'GOLD', gold: 9, currency: 'COPPER' })).body.reward;
|
||||
expect(reward).toMatchObject({ gold: 9, currency: 'COPPER' });
|
||||
await request(app).post(`${e}/rewards/${reward.id}/award`).set(dmAuth).send({ split: true });
|
||||
const split = [(await purse())[2] - 3, (await purse(friendId, dmAuth))[2]].sort();
|
||||
expect(split).toEqual([4, 5]); // 9 copper between two
|
||||
|
||||
// Another player still sees none of it.
|
||||
const outsiderView = (await request(app).get(`/characters/${friendId}`).set(playerAuth)).body.character;
|
||||
expect([outsiderView.gold, outsiderView.silver, outsiderView.copper]).toEqual([null, null, null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('paying with change', () => {
|
||||
itIfDb('a spend or a gift is paid from the whole purse: bigger coins are broken, smaller ones counted out', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('change');
|
||||
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ startingGold: 5 });
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Spender');
|
||||
const friendId = await buildCharacter(campaign.id, dmAuth, 'Friend');
|
||||
const ch = `/characters/${heroId}`;
|
||||
const purse = async (id = heroId, auth = playerAuth) => {
|
||||
const { gold, silver, copper } = (await request(app).get(`/characters/${id}`).set(auth)).body.character;
|
||||
return [gold, silver, copper];
|
||||
};
|
||||
const money = (body) => request(app).post(`${ch}/gold`).set(playerAuth).send(body);
|
||||
const newest = async (count) =>
|
||||
(await request(app).get(`${ch}/gold`).set(playerAuth)).body.transactions.slice(0, count).map((t) => [t.currency, t.amount, t.balanceAfter, t.reason]);
|
||||
|
||||
// 3 silver from a purse of 5 gold: one gold is broken, 7 silver comes back.
|
||||
const boots = await money({ amount: -3, currency: 'SILVER', reason: 'New boots' });
|
||||
expect(boots.status).toBe(201);
|
||||
expect([boots.body.gold, boots.body.silver, boots.body.copper]).toEqual([4, 7, 0]);
|
||||
expect(await purse()).toEqual([4, 7, 0]);
|
||||
expect((await newest(2)).sort()).toEqual([
|
||||
['GOLD', -1, 4, 'New boots (paid 3 silver, with change)'],
|
||||
['SILVER', 7, 7, 'New boots (paid 3 silver, with change)'],
|
||||
]);
|
||||
|
||||
// 5 copper: the nearest bigger coin is a silver, so that is what breaks.
|
||||
expect((await money({ amount: -5, currency: 'COPPER' })).status).toBe(201);
|
||||
expect(await purse()).toEqual([4, 6, 5]);
|
||||
|
||||
// Enough of the coin named: nothing else moves, and the entry reads as usual.
|
||||
await money({ amount: -2, reason: 'Horse feed' });
|
||||
expect(await purse()).toEqual([2, 6, 5]);
|
||||
expect((await newest(1))[0]).toEqual(['GOLD', -2, 2, 'Horse feed']);
|
||||
|
||||
// More than the whole purse is worth (2.65 gold): refused, nothing moves.
|
||||
const tooMuch = await money({ amount: -3 });
|
||||
expect(tooMuch.status).toBe(400);
|
||||
expect(tooMuch.body.error).toMatch(/Not enough money/);
|
||||
expect(tooMuch.body.error).toMatch(/2\.65 gold/);
|
||||
expect(await purse()).toEqual([2, 6, 5]);
|
||||
|
||||
// No bigger coin to break: smaller ones are counted out, biggest first.
|
||||
await money({ amount: -2 });
|
||||
await money({ amount: 40, currency: 'COPPER' });
|
||||
expect(await purse()).toEqual([0, 6, 45]);
|
||||
expect((await money({ amount: -1, reason: 'Toll' })).status).toBe(201);
|
||||
expect(await purse()).toEqual([0, 0, 5]);
|
||||
|
||||
// Giving works the same way for the giver; the recipient gets the coins named.
|
||||
await money({ amount: 1 });
|
||||
expect((await request(app).post(`${ch}/give`).set(playerAuth).send({ kind: 'gold', currency: 'SILVER', amount: 4, toCharacterId: friendId })).status).toBe(200);
|
||||
expect(await purse()).toEqual([0, 6, 5]);
|
||||
expect(await purse(friendId, dmAuth)).toEqual([5, 4, 0]);
|
||||
const beyond = await request(app).post(`${ch}/give`).set(playerAuth).send({ kind: 'gold', amount: 1, toCharacterId: friendId });
|
||||
expect(beyond.status).toBe(400);
|
||||
expect(beyond.body.error).toMatch(/Not enough money/);
|
||||
|
||||
// Exchanging is the exception: it moves exactly the coins named.
|
||||
expect((await request(app).post(`${ch}/gold/exchange`).set(playerAuth).send({ from: 'SILVER', to: 'COPPER', amount: 7 })).status).toBe(400);
|
||||
expect(await purse()).toEqual([0, 6, 5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trade', () => {
|
||||
itIfDb('changes gold and cash with an NPC and sells wares, in a quest in progress only', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('trade');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
const post = (path, body, auth = dmAuth) => request(app).post(path).set(auth).send(body);
|
||||
|
||||
// An NPC's rate has two sides, and only the DM sets it.
|
||||
expect((await post(`${c}/npcs`, { name: 'Half', exchangeGold: 2 })).status).toBe(400);
|
||||
const changer = (await post(`${c}/npcs`, { name: 'Vex', exchangeGold: 2, exchangeCash: 15 })).body.npc;
|
||||
expect(changer).toMatchObject({ exchangeGold: 2, exchangeCash: 15 });
|
||||
const baker = (await post(`${c}/npcs`, { name: 'Baker' })).body.npc;
|
||||
const elsewhere = (await post(`${c}/npcs`, { name: 'Hermit' })).body.npc;
|
||||
const patchNpc = (id, body) => request(app).patch(`${c}/npcs/${id}`).set(dmAuth).send(body);
|
||||
expect((await patchNpc(baker.id, { exchangeCash: 3 })).status).toBe(400);
|
||||
|
||||
// Wares come from the campaign pools and belong to one seller.
|
||||
const bread = (await post(`${c}/items`, { name: 'Bread' })).body.item;
|
||||
const sword = (await post(`${c}/weapons/from-library`, { key: 'longsword' })).body.weapon;
|
||||
const quest = (await post(`${c}/encounters`, { name: 'Market Day', mode: 'QUEST' })).body.encounter;
|
||||
const e = `/encounters/${quest.id}`;
|
||||
expect((await post(`${c}/wares`, { npcId: baker.id, itemId: bread.id, price: 3, currency: 'SILVER' }, playerAuth)).status).toBe(403);
|
||||
expect((await post(`${c}/wares`, { itemId: bread.id, price: 3 })).status).toBe(400); // no seller
|
||||
expect((await post(`${c}/wares`, { npcId: baker.id, encounterId: quest.id, itemId: bread.id, price: 3 })).status).toBe(400); // two sellers
|
||||
expect((await post(`${c}/wares`, { npcId: baker.id, itemId: bread.id, weaponId: sword.id, price: 3 })).status).toBe(400); // two things
|
||||
expect((await post(`${c}/wares`, { npcId: baker.id, itemId: 'nope', price: 3 })).status).toBe(400);
|
||||
const loaf = (await post(`${c}/wares`, { npcId: baker.id, itemId: bread.id, price: 3, currency: 'SILVER', stock: 2 })).body.ware;
|
||||
const blade = (await post(`${c}/wares`, { encounterId: quest.id, weaponId: sword.id, price: 40, currency: 'CASH' })).body.ware;
|
||||
const relic = (await post(`${c}/wares`, { npcId: elsewhere.id, itemId: bread.id, price: 1 })).body.ware;
|
||||
expect(loaf).toMatchObject({ stock: 2, level: 1, item: { name: 'Bread' } });
|
||||
expect(blade.stock).toBeNull();
|
||||
const npcList = (await request(app).get(`${c}/npcs`).set(dmAuth)).body.npcs;
|
||||
expect(npcList.find((n) => n.id === baker.id).wares).toHaveLength(1);
|
||||
|
||||
await post(`${e}/participants`, { characterId: heroId });
|
||||
await post(`${e}/participants`, { npcId: changer.id });
|
||||
const bakerRow = (await post(`${e}/participants`, { npcId: baker.id })).body.participant;
|
||||
await post(`/characters/${heroId}/gold`, { amount: 10 });
|
||||
|
||||
// Players see the rate and everything for sale in the scene.
|
||||
const seen = (await request(app).get(e).set(playerAuth)).body.encounter;
|
||||
const seenNpc = (name) => seen.participants.find((p) => p.npc?.name === name).npc;
|
||||
expect(seenNpc('Vex')).toMatchObject({ exchangeGold: 2, exchangeCash: 15 });
|
||||
expect(seenNpc('Baker').wares.map((w) => [w.item.name, w.price, w.currency, w.stock])).toEqual([['Bread', 3, 'SILVER', 2]]);
|
||||
expect(seen.wares.map((w) => w.weapon.name)).toEqual(['Longsword']);
|
||||
expect(seenNpc('Baker').dmNotes).toBeUndefined();
|
||||
|
||||
const exchange = (body, auth = playerAuth) => post(`${e}/exchange`, { characterId: heroId, npcId: changer.id, ...body }, auth);
|
||||
const buy = (wareId, body = {}, auth = playerAuth) => post(`${e}/wares/${wareId}/buy`, { characterId: heroId, ...body }, auth);
|
||||
const purse = async () => {
|
||||
const { gold, silver, copper, cash } = (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character;
|
||||
return { gold, silver, copper, cash };
|
||||
};
|
||||
|
||||
// Nothing is traded before the quest begins, and never in a battle.
|
||||
expect((await exchange({ from: 'GOLD', amount: 2 })).status).toBe(400);
|
||||
expect((await buy(loaf.id)).status).toBe(400);
|
||||
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE', mode: 'BATTLE' });
|
||||
expect((await exchange({ from: 'GOLD', amount: 2 })).status).toBe(400);
|
||||
expect((await buy(loaf.id)).status).toBe(400);
|
||||
await request(app).patch(e).set(dmAuth).send({ mode: 'QUEST' });
|
||||
|
||||
// Gold and cash only, in whole multiples of the rate, either way.
|
||||
expect((await exchange({ from: 'SILVER', amount: 10 })).status).toBe(400);
|
||||
expect((await exchange({ from: 'GOLD', amount: 3 })).status).toBe(400);
|
||||
expect((await exchange({ from: 'GOLD', amount: 12 })).status).toBe(400); // only has 10
|
||||
expect((await exchange({ from: 'GOLD', amount: 2, npcId: baker.id })).status).toBe(400); // not a money changer
|
||||
const changed = await exchange({ from: 'GOLD', amount: 6 });
|
||||
expect(changed.status).toBe(201);
|
||||
expect(changed.body).toMatchObject({ received: 45, currency: 'CASH' });
|
||||
expect(await purse()).toEqual({ gold: 4, silver: 0, copper: 0, cash: 45 });
|
||||
expect((await exchange({ from: 'CASH', amount: 20 })).status).toBe(400);
|
||||
expect((await exchange({ from: 'CASH', amount: 15 })).body.received).toBe(2);
|
||||
expect(await purse()).toEqual({ gold: 6, silver: 0, copper: 0, cash: 30 });
|
||||
const ledger = (await request(app).get(`/characters/${heroId}/gold`).set(playerAuth)).body.transactions;
|
||||
expect(ledger.slice(0, 2).map((t) => [t.currency, t.amount, t.reason]).sort()).toEqual([
|
||||
['CASH', -15, 'Changed with Vex for 2 gold'],
|
||||
['GOLD', 2, 'Changed with Vex from $15'],
|
||||
]);
|
||||
|
||||
// Buying pays the price (coins make change), hands the thing over and uses up stock.
|
||||
expect((await buy(relic.id)).status).toBe(400); // that NPC is not here
|
||||
expect((await buy(blade.id)).status).toBe(400); // $40, has $30
|
||||
expect((await buy(loaf.id, { quantity: 3 })).status).toBe(400); // only 2 left
|
||||
const bought = await buy(loaf.id, { quantity: 2 });
|
||||
expect(bought.status).toBe(201);
|
||||
expect(await purse()).toEqual({ gold: 5, silver: 4, copper: 0, cash: 30 });
|
||||
expect((await buy(loaf.id)).body.error).toContain('sold out');
|
||||
await exchange({ from: 'GOLD', amount: 2 });
|
||||
expect((await buy(blade.id)).status).toBe(201);
|
||||
expect(await purse()).toEqual({ gold: 3, silver: 4, copper: 0, cash: 5 });
|
||||
const sheet = (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character;
|
||||
expect(sheet.inventoryItems.map((i) => [i.item.name, i.quantity])).toEqual([['Bread', 2]]);
|
||||
expect(sheet.weapons.map((w) => w.weapon.name)).toContain('Longsword');
|
||||
|
||||
// A player trades for their own character only; an NPC who is down sells nothing.
|
||||
const other = await request(app).post('/auth/register').send({ email: uniqueEmail('trade-other'), password: 'password123', displayName: 'Other' });
|
||||
cleanupUserIds.push(other.body.user.id);
|
||||
const otherAuth = { Authorization: `Bearer ${other.body.token}` };
|
||||
await request(app).post(`/campaigns/join/${campaign.inviteCode}`).set(otherAuth);
|
||||
const otherId = await buildCharacter(campaign.id, otherAuth, 'Bo');
|
||||
await post(`${e}/participants`, { characterId: otherId });
|
||||
expect((await buy(blade.id, {}, otherAuth)).status).toBe(403);
|
||||
expect((await exchange({ from: 'GOLD', amount: 2 }, dmAuth)).status).toBe(201); // the DM can, for anyone
|
||||
|
||||
// The DM can reprice and restock a ware; nobody else can.
|
||||
const patchWare = (auth, body) => request(app).patch(`${c}/wares/${loaf.id}`).set(auth).send(body);
|
||||
expect((await patchWare(playerAuth, { price: 1 })).status).toBe(403);
|
||||
expect((await patchWare(dmAuth, { price: 0 })).status).toBe(400);
|
||||
expect((await patchWare(dmAuth, { stock: 5, price: 1, currency: 'COPPER' })).body.ware).toMatchObject({ stock: 5, price: 1, currency: 'COPPER', item: { name: 'Bread' } });
|
||||
expect((await patchWare(dmAuth, { stock: null })).body.ware).toMatchObject({ stock: null, price: 1 });
|
||||
expect((await buy(loaf.id)).status).toBe(201);
|
||||
expect(await purse()).toEqual({ gold: 1, silver: 3, copper: 9, cash: 20 });
|
||||
await patchNpc(baker.id, { maxHp: 5 });
|
||||
await request(app).delete(`${e}/participants/${bakerRow.id}`).set(dmAuth);
|
||||
const downRow = (await post(`${e}/participants`, { npcId: baker.id })).body.participant;
|
||||
await request(app).patch(`${e}/participants/${downRow.id}`).set(dmAuth).send({ currentHp: 0 });
|
||||
expect((await buy(loaf.id)).status).toBe(400);
|
||||
|
||||
expect((await request(app).delete(`${c}/wares/${blade.id}`).set(playerAuth)).status).toBe(403);
|
||||
expect((await request(app).delete(`${c}/wares/${blade.id}`).set(dmAuth)).status).toBe(204);
|
||||
expect((await request(app).get(e).set(playerAuth)).body.encounter.wares).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ const { generateInviteCode } = require('../utils/inviteCode');
|
||||
const { computeCharacterSheet, MAX_DAMAGE_SCALING, MAX_GEAR_LEVEL } = require('../game/calculations');
|
||||
const { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../game/data/library');
|
||||
const { visitedLocationIds } = require('../utils/locations');
|
||||
const { deathSaveInfo } = require('../utils/hp');
|
||||
const { CURRENCIES } = require('../utils/grants');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -24,6 +26,7 @@ function campaignSummary(campaign, role) {
|
||||
startingSpellCount: campaign.startingSpellCount,
|
||||
skipMotivationRoll: campaign.skipMotivationRoll,
|
||||
showMonsterHp: campaign.showMonsterHp,
|
||||
areaAttackAcPenalty: campaign.areaAttackAcPenalty,
|
||||
ownerId: campaign.ownerId,
|
||||
archivedAt: campaign.archivedAt,
|
||||
role,
|
||||
@@ -53,6 +56,7 @@ const createCampaignSchema = z.object({
|
||||
startingSpellCount: z.number().int().min(0).max(50).optional(),
|
||||
skipMotivationRoll: z.boolean().optional(),
|
||||
showMonsterHp: z.boolean().optional(),
|
||||
areaAttackAcPenalty: z.number().int().min(0).max(10).optional(),
|
||||
});
|
||||
|
||||
// POST /campaigns — create, creator becomes DM/owner
|
||||
@@ -194,7 +198,11 @@ function characterSummary(character) {
|
||||
buildStage: character.buildStage,
|
||||
currentHp: character.currentHp,
|
||||
tempHp: character.tempHp,
|
||||
deathSaves: deathSaveInfo(character),
|
||||
gold: character.gold,
|
||||
silver: character.silver,
|
||||
copper: character.copper,
|
||||
cash: character.cash,
|
||||
maxHp: character.buildStage === 'COMPLETE' ? sheet.hp.max : null,
|
||||
armorClass: character.buildStage === 'COMPLETE' ? sheet.armorClass : null,
|
||||
currentLocationId: character.currentLocationId,
|
||||
@@ -219,7 +227,7 @@ router.get('/:id/characters', requireCampaignMember(), async (req, res, next) =>
|
||||
characters: summaries.map((c) => {
|
||||
if (c.userId === req.user.id) return c;
|
||||
const knowsPlace = c.currentLocationId && visited.has(c.currentLocationId);
|
||||
return { ...c, gold: null, ...(knowsPlace ? {} : { currentLocationId: null, currentLocationName: null }) };
|
||||
return { ...c, gold: null, silver: null, copper: null, cash: null, ...(knowsPlace ? {} : { currentLocationId: null, currentLocationName: null }) };
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -399,6 +407,7 @@ const spellSchema = z.object({
|
||||
description: z.string().max(4000).optional().nullable(),
|
||||
damageDice: z.string().max(20).optional().nullable(),
|
||||
isStartingOption: z.boolean().optional(),
|
||||
revives: z.boolean().optional(), // brings back the dead
|
||||
});
|
||||
|
||||
router.post('/:id/spells', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
@@ -518,7 +527,7 @@ const monsterSchema = z.object({
|
||||
wisScore: z.number().int().min(1).max(30).optional(),
|
||||
chaScore: z.number().int().min(1).max(30).optional(),
|
||||
attackBonus: z.number().int().min(-20).max(50).optional(),
|
||||
damageDice: z.string().max(20).optional().nullable(),
|
||||
damageDice: z.string().max(60).optional().nullable(), // may name its type: "1d6+2 necrotic"
|
||||
challengeRating: z.string().max(10).optional().nullable(),
|
||||
specialTrait: z.string().max(500).optional().nullable(),
|
||||
notes: z.string().max(2000).optional().nullable(),
|
||||
@@ -637,7 +646,11 @@ router.put('/:id/encounters/order', requireCampaignMember(), requireDM(), async
|
||||
|
||||
router.get('/:id/npcs', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const npcs = await prisma.npc.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
|
||||
const npcs = await prisma.npc.findMany({
|
||||
where: { campaignId: req.campaign.id },
|
||||
include: { wares: { include: WARE_INCLUDE, orderBy: { createdAt: 'asc' } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
res.json({ npcs });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -654,12 +667,31 @@ const npcSchema = z.object({
|
||||
maxHp: z.number().int().min(1).max(100000).optional().nullable(),
|
||||
attackBonus: z.number().int().min(-20).max(50).optional().nullable(),
|
||||
damageDice: z.string().max(20).optional().nullable(),
|
||||
// The money changer's rate: exchangeGold gold for exchangeCash cash. Both
|
||||
// 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(),
|
||||
});
|
||||
|
||||
// A rate has two sides: both are given, or both are cleared.
|
||||
function halfARate(data) {
|
||||
const sides = [data.exchangeGold, data.exchangeCash];
|
||||
return sides.some((side) => side === undefined) !== sides.every((side) => side === undefined) || (sides[0] === null) !== (sides[1] === null);
|
||||
}
|
||||
const HALF_A_RATE = 'An exchange rate needs both sides: so much gold for so much cash.';
|
||||
|
||||
const WARE_INCLUDE = { weapon: true, item: true };
|
||||
|
||||
// Open encounter pages refetch: what is for sale in a scene just changed.
|
||||
function emitTradeChanged(req) {
|
||||
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('encounter:participant-updated', {});
|
||||
}
|
||||
|
||||
router.post('/:id/npcs', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
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 });
|
||||
const npc = await prisma.npc.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
|
||||
res.status(201).json({ npc });
|
||||
} catch (err) {
|
||||
@@ -671,8 +703,10 @@ router.patch('/:id/npcs/:npcId', requireCampaignMember(), requireDM(), async (re
|
||||
try {
|
||||
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 });
|
||||
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);
|
||||
res.json({ npc });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -772,6 +806,83 @@ router.delete('/:id/locations/:locationId', requireCampaignMember(), requireDM()
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wares — weapons and items for sale, by an NPC or at an encounter itself.
|
||||
// Only the DM sets them up. Players see them, and buy, on the encounter page
|
||||
// (encounters.routes.js); an NPC's wares go wherever the NPC does.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const wareSchema = z
|
||||
.object({
|
||||
npcId: z.string().optional(),
|
||||
encounterId: z.string().optional(),
|
||||
weaponId: z.string().optional(),
|
||||
itemId: z.string().optional(),
|
||||
price: z.number().int().min(1).max(1000000),
|
||||
currency: z.enum(CURRENCIES).default('GOLD'),
|
||||
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) => 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) => {
|
||||
try {
|
||||
const parsed = wareSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
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));
|
||||
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.` });
|
||||
const level = data.level ?? entry.minLevel;
|
||||
if (level < entry.minLevel) {
|
||||
return res.status(400).json({ error: `${entry.name} starts at level ${entry.minLevel} and can't go below it.` });
|
||||
}
|
||||
|
||||
const ware = await prisma.saleOffer.create({ data: { ...data, level, campaignId: req.campaign.id }, include: WARE_INCLUDE });
|
||||
emitTradeChanged(req);
|
||||
res.status(201).json({ ware });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// The price and what is left can change; what is sold, and by whom, cannot.
|
||||
const patchWareSchema = z.object({
|
||||
price: z.number().int().min(1).max(1000000).optional(),
|
||||
currency: z.enum(CURRENCIES).optional(),
|
||||
stock: z.number().int().min(0).max(9999).nullable().optional(),
|
||||
});
|
||||
|
||||
router.patch('/:id/wares/:wareId', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = patchWareSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
if (!(await updatePoolEntry('saleOffer', req.params.wareId, req.campaign.id, parsed.data))) {
|
||||
return res.status(404).json({ error: 'Nothing like that is for sale in this campaign' });
|
||||
}
|
||||
emitTradeChanged(req);
|
||||
res.json({ ware: await prisma.saleOffer.findUnique({ where: { id: req.params.wareId }, include: WARE_INCLUDE }) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id/wares/:wareId', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
if (!(await deletePoolEntry('saleOffer', req.params.wareId, req.campaign.id))) {
|
||||
return res.status(404).json({ error: 'Nothing like that is for sale in this campaign' });
|
||||
}
|
||||
emitTradeChanged(req);
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default content library — static SRD-style weapons/spells/monsters any
|
||||
// campaign can pull from, instead of the DM hand-typing everything. See
|
||||
|
||||
@@ -29,9 +29,9 @@ const {
|
||||
} = require('../game/buildFlow');
|
||||
const characterOptions = require('../game/data/characterOptions');
|
||||
const { SKILLS } = require('../game/data/skills');
|
||||
const { InsufficientGoldError, adjustGold, giveGear } = require('../utils/grants');
|
||||
const { CURRENCIES, COINS, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, spendCoins, worthInGold, giveGear } = require('../utils/grants');
|
||||
const { recordVisits } = require('../utils/locations');
|
||||
const { absorbDamage } = require('../utils/hp');
|
||||
const { absorbDamage, NO_DEATH_SAVES, lifeState, deathSaveInfo, settleDeathSaves, changeHp, rollDeathSave } = require('../utils/hp');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -85,6 +85,7 @@ function serializeCharacter(character, isOwner, isDM = true) {
|
||||
},
|
||||
currentHp: character.currentHp,
|
||||
tempHp: character.tempHp,
|
||||
deathSaves: deathSaveInfo(character),
|
||||
armorBonus: character.armorBonus,
|
||||
specialAbilities: character.specialAbilities,
|
||||
background: character.background,
|
||||
@@ -94,6 +95,9 @@ function serializeCharacter(character, isOwner, isDM = true) {
|
||||
currentLocationId: restricted ? null : character.currentLocationId,
|
||||
currentLocation: restricted ? null : character.currentLocation || null,
|
||||
gold: restricted ? null : character.gold,
|
||||
silver: restricted ? null : character.silver,
|
||||
copper: restricted ? null : character.copper,
|
||||
cash: restricted ? null : character.cash,
|
||||
restricted,
|
||||
buildStage: character.buildStage,
|
||||
buildState: character.buildStage === 'COMPLETE' ? null : character.buildState,
|
||||
@@ -170,8 +174,8 @@ function clamp(value, min, max) {
|
||||
|
||||
router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
if (req.body && req.body.gold !== undefined) {
|
||||
return res.status(400).json({ error: 'Gold changes go through the money tracker.' });
|
||||
if (req.body && Object.values(COIN_COLUMN).some((coin) => req.body[coin] !== undefined)) {
|
||||
return res.status(400).json({ error: 'Gold, silver, copper and cash changes go through the money tracker.' });
|
||||
}
|
||||
const parsed = patchCharacterSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
@@ -182,18 +186,28 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
|
||||
|
||||
// Damage is soaked by temp HP before it reaches real HP — unless this
|
||||
// same request sets temp HP outright.
|
||||
if (parsed.data.currentHp !== undefined && parsed.data.tempHp === undefined) {
|
||||
const settingHp = parsed.data.currentHp !== undefined;
|
||||
const settingTempHp = parsed.data.tempHp !== undefined;
|
||||
if (req.character.buildStage === 'COMPLETE' && (settingHp || parsed.data.level !== undefined)) {
|
||||
// Current HP stays within 0..max, including when a level change moves max.
|
||||
const maxHp = computeCharacterSheet({ ...req.character, ...parsed.data }).hp.max;
|
||||
if (!settingHp) {
|
||||
parsed.data.currentHp = clamp(req.character.currentHp, 0, maxHp);
|
||||
} else {
|
||||
if (lifeState(req.character) === 'DEAD' && parsed.data.currentHp > 0) {
|
||||
return res.status(400).json({ error: `${req.character.name} is dead. The DM has to revive them before they can be healed.` });
|
||||
}
|
||||
// Reaching 0, being hit while there, and being healed back up all
|
||||
// move the death saves along with the HP.
|
||||
const { tempHp, ...after } = changeHp(settingTempHp ? { ...req.character, tempHp: 0 } : req.character, parsed.data.currentHp, maxHp);
|
||||
Object.assign(parsed.data, after, settingTempHp ? {} : { tempHp });
|
||||
}
|
||||
} else if (settingHp && !settingTempHp) {
|
||||
const after = absorbDamage(req.character.currentHp, req.character.tempHp, parsed.data.currentHp);
|
||||
parsed.data.currentHp = after.currentHp;
|
||||
if (after.tempHp !== req.character.tempHp) parsed.data.tempHp = after.tempHp;
|
||||
}
|
||||
|
||||
// Current HP stays within 0..max, including when a level change moves max.
|
||||
if (req.character.buildStage === 'COMPLETE' && (parsed.data.currentHp !== undefined || parsed.data.level !== undefined)) {
|
||||
const maxHp = computeCharacterSheet({ ...req.character, ...parsed.data }).hp.max;
|
||||
parsed.data.currentHp = clamp(parsed.data.currentHp ?? req.character.currentHp, 0, maxHp);
|
||||
}
|
||||
|
||||
// privateNotes is for the owning player only — a DM editing someone
|
||||
// else's character (or another player, though write access already
|
||||
// blocks that) should never be able to set it through this route.
|
||||
@@ -232,6 +246,61 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Death saves — what happens to a character at 0 HP (utils/hp.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const deathSaveTally = z.number().int().min(0).max(3);
|
||||
const deathSaveRoll = z.number().int().min(1, 'A death save is a d20: 1 to 20').max(20, 'A death save is a d20: 1 to 20');
|
||||
|
||||
const deathSaveSchema = z.discriminatedUnion('action', [
|
||||
// What the d20 showed; the server works out what it means.
|
||||
z.object({ action: z.literal('roll'), roll: deathSaveRoll }),
|
||||
// Steadied by someone else: a Medicine check, a healer's kit, a spell.
|
||||
z.object({ action: z.literal('stabilize') }),
|
||||
// The DM setting the tallies by hand: a critical hit's second failure, or
|
||||
// undoing a mistake.
|
||||
z.object({ action: z.literal('set'), successes: deathSaveTally, failures: deathSaveTally }),
|
||||
// The DM bringing back the dead, at 1 HP.
|
||||
z.object({ action: z.literal('revive') }),
|
||||
]);
|
||||
|
||||
// The owning player rolls their own death saves and can be steadied; setting
|
||||
// the tallies by hand and raising the dead are the DM's.
|
||||
router.post('/:id/death-saves', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = deathSaveSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { character } = req;
|
||||
if (character.buildStage !== 'COMPLETE') return res.status(400).json({ error: 'Character build is not finished yet.' });
|
||||
|
||||
const input = parsed.data;
|
||||
const state = lifeState(character);
|
||||
if ((input.action === 'set' || input.action === 'revive') && !req.isDM) {
|
||||
return res.status(403).json({ error: 'Only the DM can do that.' });
|
||||
}
|
||||
|
||||
let data;
|
||||
if (input.action === 'roll' || input.action === 'stabilize') {
|
||||
if (state !== 'DYING') return res.status(400).json({ error: `${character.name} is not dying, so there is no death save to make.` });
|
||||
data = input.action === 'roll' ? rollDeathSave(character, input.roll) : { ...NO_DEATH_SAVES, stable: true };
|
||||
} else if (input.action === 'set') {
|
||||
if (state === 'UP') return res.status(400).json({ error: `${character.name} is not at 0 HP.` });
|
||||
data = settleDeathSaves(input.successes, input.failures);
|
||||
} else {
|
||||
if (state !== 'DEAD') return res.status(400).json({ error: `${character.name} is not dead.` });
|
||||
data = { currentHp: 1, ...NO_DEATH_SAVES };
|
||||
}
|
||||
|
||||
await prisma.character.update({ where: { id: character.id }, data });
|
||||
const full = await loadFullCharacter(character.id);
|
||||
req.app.get('io').to(`campaign:${full.campaignId}`).emit('character:updated', { characterId: full.id });
|
||||
res.json({ character: serializeCharacter(full, req.isOwner) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', requireCharacterAccess(), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
|
||||
@@ -355,6 +424,9 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
|
||||
speedOverride: source.speedOverride,
|
||||
specialAbilities: source.specialAbilities,
|
||||
gold: source.gold,
|
||||
silver: source.silver,
|
||||
copper: source.copper,
|
||||
cash: source.cash,
|
||||
background: source.background,
|
||||
appearance: source.appearance,
|
||||
// Same privacy boundary as serializeCharacter: a DM copying a
|
||||
@@ -370,10 +442,13 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
|
||||
},
|
||||
inventoryItems: { create: [...items.values()] },
|
||||
goldTransactions: {
|
||||
create:
|
||||
source.gold > 0
|
||||
? [{ amount: source.gold, balanceAfter: source.gold, reason: 'Carried over from another campaign', createdById: req.user.id }]
|
||||
: [],
|
||||
create: CURRENCIES.filter((currency) => source[COIN_COLUMN[currency]] > 0).map((currency) => ({
|
||||
currency,
|
||||
amount: source[COIN_COLUMN[currency]],
|
||||
balanceAfter: source[COIN_COLUMN[currency]],
|
||||
reason: 'Carried over from another campaign',
|
||||
createdById: req.user.id,
|
||||
})),
|
||||
},
|
||||
weapons: { create: [...weapons.values()] },
|
||||
spells: { create: [...spells.values()] },
|
||||
@@ -837,6 +912,7 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
|
||||
currentHp: character.currentHp,
|
||||
maxHp: sheet.hp.max,
|
||||
tempHp: character.tempHp,
|
||||
deathSaves: deathSaveInfo(character),
|
||||
armorClass: sheet.armorClass,
|
||||
initiative: sheet.initiative,
|
||||
speed: sheet.speed,
|
||||
@@ -857,6 +933,21 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
|
||||
|
||||
const GOLD_TRANSACTION_INCLUDE = { createdBy: { select: { displayName: true } } };
|
||||
|
||||
// Which coin an amount is in; gold when none is given.
|
||||
const currencySchema = z.enum(CURRENCIES).default('GOLD');
|
||||
|
||||
function coinName(currency) {
|
||||
return currency.toLowerCase();
|
||||
}
|
||||
|
||||
const PURSE_SELECT = { gold: true, silver: true, copper: true, cash: true };
|
||||
|
||||
// For a payment the whole purse can't cover. Cash stands apart from the coins.
|
||||
function notEnoughMoney(character, amount, currency) {
|
||||
if (currency === 'CASH') return `Not enough cash — ${character.name} has $${character.cash}, which doesn't cover $${amount}.`;
|
||||
return `Not enough money — ${character.name} has ${character.gold} gold, ${character.silver} silver and ${character.copper} copper (worth ${worthInGold(character)} gold), which doesn't cover ${amount} ${coinName(currency)}.`;
|
||||
}
|
||||
|
||||
router.get('/:id/gold', requireCharacterAccess(), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.isDM && !req.isOwner) return res.status(403).json({ error: "Only the character's player and the DM can see its money" });
|
||||
@@ -865,7 +956,7 @@ router.get('/:id/gold', requireCharacterAccess(), async (req, res, next) => {
|
||||
include: GOLD_TRANSACTION_INCLUDE,
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
res.json({ gold: req.character.gold, transactions });
|
||||
res.json({ gold: req.character.gold, silver: req.character.silver, copper: req.character.copper, cash: req.character.cash, transactions });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -873,30 +964,76 @@ router.get('/:id/gold', requireCharacterAccess(), async (req, res, next) => {
|
||||
|
||||
// amount is signed: positive = gained, negative = spent. The owning player
|
||||
// and the DM can both record entries; each one is stamped with who made it.
|
||||
// A spend is paid from the whole purse: if there aren't enough of the coin
|
||||
// named, bigger coins are broken (or smaller ones counted out) to cover it.
|
||||
// Cash is only ever paid in cash.
|
||||
router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z
|
||||
.object({
|
||||
amount: z.number().int().min(-1000000).max(1000000).refine((n) => n !== 0, 'Amount cannot be zero'),
|
||||
currency: currencySchema,
|
||||
reason: z.string().trim().max(200).optional().nullable(),
|
||||
})
|
||||
.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { amount, reason } = parsed.data;
|
||||
const { amount, currency, reason } = parsed.data;
|
||||
|
||||
let transaction;
|
||||
let transactions;
|
||||
try {
|
||||
// Throwing inside the transaction rolls the balance change back.
|
||||
transaction = await prisma.$transaction((tx) =>
|
||||
adjustGold(tx, { characterId: req.character.id, amount, reason, userId: req.user.id, include: GOLD_TRANSACTION_INCLUDE })
|
||||
);
|
||||
transactions = await prisma.$transaction(async (tx) => {
|
||||
const entry = { characterId: req.character.id, currency, reason, userId: req.user.id, include: GOLD_TRANSACTION_INCLUDE };
|
||||
return amount > 0 ? [await adjustGold(tx, { ...entry, amount })] : spendCoins(tx, { ...entry, amount: -amount });
|
||||
});
|
||||
} catch (err) {
|
||||
if (!(err instanceof InsufficientGoldError)) throw err;
|
||||
return res.status(400).json({ error: `Not enough gold — the balance is ${req.character.gold}.` });
|
||||
return res.status(400).json({ error: notEnoughMoney(req.character, -amount, currency) });
|
||||
}
|
||||
|
||||
emitCharacterUpdated(req);
|
||||
res.status(201).json({ gold: transaction.balanceAfter, transaction });
|
||||
const purse = await prisma.character.findUnique({ where: { id: req.character.id }, select: PURSE_SELECT });
|
||||
res.status(201).json({ ...purse, transaction: transactions[0], transactions });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Changes coins of one kind into another at their fixed worth (1 gold = 10
|
||||
// silver = 100 copper). `amount` is how many `from` coins to hand over; it has
|
||||
// to come to a whole number of `to` coins. Both sides go on the ledger. Unlike
|
||||
// a spend, this moves exactly the coins named: no change is made. Cash has no
|
||||
// worth in coins, so it can't be exchanged.
|
||||
router.post('/:id/gold/exchange', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z
|
||||
.object({ from: z.enum(COINS), to: z.enum(COINS), amount: z.number().int().min(1).max(1000000) })
|
||||
.refine((data) => data.from !== data.to, { message: 'Pick two different coins' })
|
||||
.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { from, to, amount } = parsed.data;
|
||||
|
||||
const worth = amount * COIN_VALUE[from];
|
||||
if (worth % COIN_VALUE[to] !== 0) {
|
||||
const step = COIN_VALUE[to] / COIN_VALUE[from];
|
||||
return res.status(400).json({ error: `${amount} ${coinName(from)} doesn't make a whole number of ${coinName(to)} — exchange in multiples of ${step}.` });
|
||||
}
|
||||
const received = worth / COIN_VALUE[to];
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const base = { characterId: req.character.id, userId: req.user.id };
|
||||
await adjustGold(tx, { ...base, currency: from, amount: -amount, reason: `Exchanged for ${received} ${coinName(to)}` });
|
||||
await adjustGold(tx, { ...base, currency: to, amount: received, reason: `Exchanged from ${amount} ${coinName(from)}` });
|
||||
});
|
||||
} catch (err) {
|
||||
if (!(err instanceof InsufficientGoldError)) throw err;
|
||||
return res.status(400).json({ error: `Not enough ${coinName(from)} — the balance is ${req.character[COIN_COLUMN[from]]}.` });
|
||||
}
|
||||
|
||||
emitCharacterUpdated(req);
|
||||
const character = await loadFullCharacter(req.character.id);
|
||||
res.status(201).json({ character: serializeCharacter(character, req.isOwner, req.isDM), received });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -1040,7 +1177,7 @@ for (const gear of GEAR) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const giveSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('gold'), toCharacterId: z.string(), amount: z.number().int().min(1) }),
|
||||
z.object({ kind: z.literal('gold'), toCharacterId: z.string(), amount: z.number().int().min(1), currency: z.enum(CURRENCIES).default('GOLD') }),
|
||||
z.object({ kind: z.literal('weapon'), toCharacterId: z.string(), id: z.string(), quantity: z.number().int().min(1).default(1) }),
|
||||
z.object({ kind: z.literal('item'), toCharacterId: z.string(), id: z.string(), quantity: z.number().int().min(1).default(1) }),
|
||||
z.object({ kind: z.literal('spell'), toCharacterId: z.string(), id: z.string() }),
|
||||
@@ -1067,8 +1204,10 @@ router.post('/:id/give', requireCharacterAccess({ write: true }), async (req, re
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (give.kind === 'gold') {
|
||||
await adjustGold(tx, { characterId: giver.id, amount: -give.amount, reason: `Gave to ${recipient.name}`, userId: req.user.id });
|
||||
await adjustGold(tx, { characterId: recipient.id, amount: give.amount, reason: `Received from ${giver.name}`, userId: req.user.id });
|
||||
// The giver pays from their whole purse, with change; the recipient gets the coins named.
|
||||
const coins = { amount: give.amount, currency: give.currency, userId: req.user.id };
|
||||
await spendCoins(tx, { ...coins, characterId: giver.id, reason: `Gave to ${recipient.name}` });
|
||||
await adjustGold(tx, { ...coins, characterId: recipient.id, reason: `Received from ${giver.name}` });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1102,7 +1241,7 @@ router.post('/:id/give', requireCharacterAccess({ write: true }), async (req, re
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof InsufficientGoldError) {
|
||||
return res.status(400).json({ error: `Not enough gold — ${giver.name} has ${giver.gold}.` });
|
||||
return res.status(400).json({ error: notEnoughMoney(giver, give.amount, give.currency || 'GOLD') });
|
||||
}
|
||||
if (err instanceof GiveError) return res.status(400).json({ error: err.message });
|
||||
throw err;
|
||||
|
||||
@@ -4,9 +4,9 @@ const prisma = require('../db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { loadCampaignMembership } = require('../middleware/campaignAccess');
|
||||
const { computeCharacterSheet, computeWeaponStats, canUseGear, abilityModifier, MAX_GEAR_LEVEL } = require('../game/calculations');
|
||||
const { adjustGold, giveGear } = require('../utils/grants');
|
||||
const { CURRENCIES, InsufficientGoldError, adjustGold, spendCoins, giveGear } = require('../utils/grants');
|
||||
const { moveCharacters, visitedLocationIds } = require('../utils/locations');
|
||||
const { absorbDamage } = require('../utils/hp');
|
||||
const { NO_DEATH_SAVES, lifeState, deathSaveInfo, changeHp } = require('../utils/hp');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -66,6 +66,43 @@ function initiativeBonus({ character, monster }) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Out of the fight: a dead character, or a monster or NPC brought to 0 HP.
|
||||
// The fallen are hidden from the players and skipped in the turn order; the
|
||||
// DM still sees them. It is worked out from HP each time, never stored, so
|
||||
// healing a monster or reviving a character brings them straight back. Takes
|
||||
// a participant with its character loaded.
|
||||
function isFallen(p) {
|
||||
if (p.character) return lifeState(p.character) === 'DEAD';
|
||||
return p.maxHp > 0 && p.currentHp <= 0;
|
||||
}
|
||||
|
||||
// The first combatant still standing at or after `from` in the order, going
|
||||
// round to the top when the end is reached. If nobody is standing, whoever is
|
||||
// at `from`.
|
||||
function standingFrom(order, from) {
|
||||
for (let step = 0; step < order.length; step += 1) {
|
||||
const p = order[(from + step) % order.length];
|
||||
if (!isFallen(p)) return p;
|
||||
}
|
||||
return order[from % order.length] ?? null;
|
||||
}
|
||||
|
||||
// An encounter's combatants in turn order, with what isFallen needs.
|
||||
async function loadTurnOrder(encounterId) {
|
||||
const participants = await prisma.encounterParticipant.findMany({
|
||||
where: { encounterId },
|
||||
include: { character: { select: { currentHp: true, deathSaveFailures: true, stable: true } } },
|
||||
});
|
||||
return inTurnOrder(participants);
|
||||
}
|
||||
|
||||
// The spell a character would raise the dead with: one flagged as reviving
|
||||
// in the campaign's spell list, at a level they can cast. Takes a character
|
||||
// with its spells loaded.
|
||||
function revivalSpell(character) {
|
||||
return character.spells.map((cs) => cs.spell).find((spell) => spell.revives && canUseGear(character.level, spell.level));
|
||||
}
|
||||
|
||||
const initiativeRollSchema = z.number().int().min(1, 'An initiative roll is a d20: 1 to 20').max(20, 'An initiative roll is a d20: 1 to 20');
|
||||
|
||||
// What every campaign member sees about a character in an encounter: the
|
||||
@@ -103,6 +140,8 @@ function characterCombatInfo(character) {
|
||||
armorClass: sheet.armorClass,
|
||||
maxHp: sheet.hp.max,
|
||||
tempHp: character.tempHp,
|
||||
deathSaves: deathSaveInfo(character),
|
||||
canRevive: Boolean(revivalSpell(character)),
|
||||
speed: sheet.speed,
|
||||
initiativeBonus: sheet.initiative,
|
||||
passivePerception: sheet.passivePerception,
|
||||
@@ -116,9 +155,16 @@ function characterCombatInfo(character) {
|
||||
};
|
||||
}
|
||||
|
||||
// What is for sale, oldest first, with the weapon or item itself.
|
||||
const WARES = { include: { weapon: true, item: true }, orderBy: { createdAt: 'asc' } };
|
||||
|
||||
// The DM gets everything. Players get the scene as their characters see it:
|
||||
// no DM notes, no monster stat blocks, no NPC secrets or stats, and only the
|
||||
// rewards that have actually been handed out.
|
||||
// rewards that have actually been handed out. What an NPC sells and the rate
|
||||
// they change money at are for everyone: `npc.wares`, `npc.exchangeGold` and
|
||||
// `npc.exchangeCash`, and the scene's own `wares`. The fallen (isFallen) are left
|
||||
// out of a player's list altogether; of those, only the dead characters are
|
||||
// named, in `fallen`, so an ally can try to revive them.
|
||||
//
|
||||
// `viewerId` is the user the payload is for. A player is told where a scene
|
||||
// is once one of their characters has been there, and — when the campaign
|
||||
@@ -141,21 +187,27 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
},
|
||||
},
|
||||
monster: true,
|
||||
npc: true,
|
||||
npc: { include: { wares: WARES } },
|
||||
},
|
||||
},
|
||||
rewards: { include: { weapon: true, item: true }, orderBy: { createdAt: 'asc' } },
|
||||
wares: WARES,
|
||||
},
|
||||
});
|
||||
if (!encounter) return encounter;
|
||||
|
||||
const hideHp = !isDM && !encounter.campaign.showMonsterHp;
|
||||
const participants = inTurnOrder(encounter.participants).map((p) => {
|
||||
const inOrder = inTurnOrder(encounter.participants);
|
||||
const hiddenIds = new Set(inOrder.filter(isFallen).map((p) => p.id));
|
||||
const fallen = inOrder
|
||||
.filter((p) => p.character && hiddenIds.has(p.id))
|
||||
.map((p) => ({ participantId: p.id, characterId: p.character.id, name: p.displayName }));
|
||||
const participants = inOrder.filter((p) => isDM || !hiddenIds.has(p.id)).map((p) => {
|
||||
const { character, monster, npc, ...row } = p;
|
||||
// The bonus is part of a monster's stat block, so only the DM gets it for
|
||||
// those; a character's is on their sheet for all to see.
|
||||
const bonus = isDM || character ? { initiativeBonus: initiativeBonus(p) } : {};
|
||||
const out = { ...row, ...bonus, kind: npc || p.npcId ? 'NPC' : p.isMonster ? 'MONSTER' : 'CHARACTER' };
|
||||
const out = { ...row, ...bonus, kind: npc || p.npcId ? 'NPC' : p.isMonster ? 'MONSTER' : 'CHARACTER', hidden: hiddenIds.has(p.id) };
|
||||
if (character) {
|
||||
// A character combatant's HP is the character's own HP, not a
|
||||
// per-encounter copy, so the tracker and the sheet can never disagree.
|
||||
@@ -186,7 +238,10 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
locationId: knowsPlace ? scene.locationId : null,
|
||||
location: knowsPlace ? location : null,
|
||||
...(isDM ? { dmNotes } : {}),
|
||||
// A player is not told that someone they cannot see is being targeted.
|
||||
targetParticipantIds: isDM ? scene.targetParticipantIds : scene.targetParticipantIds.filter((id) => !hiddenIds.has(id)),
|
||||
participants,
|
||||
fallen,
|
||||
rewards: isDM ? rewards : rewards.filter((r) => r.awardedAt),
|
||||
};
|
||||
}
|
||||
@@ -254,13 +309,15 @@ router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) =
|
||||
if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' });
|
||||
}
|
||||
|
||||
// Starting a battle puts the first combatant in the initiative order on turn.
|
||||
// Starting a battle puts the first combatant still standing on turn.
|
||||
const mode = data.mode ?? req.encounter.mode;
|
||||
if (data.status === 'ACTIVE' && mode === 'BATTLE' && !req.encounter.activeParticipantId) {
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
|
||||
data.activeParticipantId = inTurnOrder(participants)[0]?.id ?? null;
|
||||
data.activeParticipantId = standingFrom(await loadTurnOrder(req.encounter.id), 0)?.id ?? null;
|
||||
}
|
||||
|
||||
// Targets belong to the moment; beginning, ending or reopening clears them.
|
||||
if (data.status && data.status !== req.encounter.status) data.targetParticipantIds = [];
|
||||
|
||||
await prisma.encounter.update({ where: { id: req.encounter.id }, data });
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
@@ -313,6 +370,10 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
|
||||
if (!character || character.campaignId !== req.encounter.campaignId) {
|
||||
return res.status(400).json({ error: 'That character is not part of this campaign.' });
|
||||
}
|
||||
// The dead stay where they fell: they join no new scene until revived.
|
||||
if (lifeState(character) === 'DEAD') {
|
||||
return res.status(400).json({ error: `${character.name} is dead and cannot join an encounter. Revive them first.` });
|
||||
}
|
||||
currentHp = character.currentHp;
|
||||
maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
|
||||
resolvedName = displayName || character.name;
|
||||
@@ -399,10 +460,14 @@ router.patch('/:id/participants/:participantId', requireEncounterAccess, async (
|
||||
const maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
|
||||
data.maxHp = maxHp;
|
||||
if (data.currentHp !== undefined) {
|
||||
// Temp HP soaks damage first; what is left lands on the character.
|
||||
const after = absorbDamage(character.currentHp, character.tempHp, data.currentHp);
|
||||
data.currentHp = clamp(after.currentHp, 0, maxHp);
|
||||
await prisma.character.update({ where: { id: character.id }, data: { currentHp: data.currentHp, tempHp: after.tempHp } });
|
||||
if (lifeState(character) === 'DEAD' && data.currentHp > 0) {
|
||||
return res.status(400).json({ error: `${character.name} is dead. Revive them before healing.` });
|
||||
}
|
||||
// Temp HP soaks damage first; what is left lands on the character,
|
||||
// and at 0 HP it moves their death saves too.
|
||||
const after = changeHp(character, data.currentHp, maxHp);
|
||||
data.currentHp = after.currentHp;
|
||||
await prisma.character.update({ where: { id: character.id }, data: after });
|
||||
emitCharacterUpdated(req, character.id);
|
||||
}
|
||||
} else if (data.currentHp !== undefined) {
|
||||
@@ -420,15 +485,20 @@ router.patch('/:id/participants/:participantId', requireEncounterAccess, async (
|
||||
|
||||
router.delete('/:id/participants/:participantId', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
|
||||
const order = inTurnOrder(participants);
|
||||
const order = await loadTurnOrder(req.encounter.id);
|
||||
const index = order.findIndex((p) => p.id === req.params.participantId);
|
||||
if (index === -1) return res.status(404).json({ error: 'Combatant not found in this encounter' });
|
||||
|
||||
// Removing whoever is on turn passes the turn to the next combatant.
|
||||
if (req.encounter.activeParticipantId === req.params.participantId) {
|
||||
const next = order.length > 1 ? order[(index + 1) % order.length] : null;
|
||||
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { activeParticipantId: next ? next.id : null } });
|
||||
// Removing whoever is on turn passes the turn to the next combatant still
|
||||
// standing, and their targets go with the turn. Removing a target just
|
||||
// unmarks it.
|
||||
const { activeParticipantId, targetParticipantIds } = req.encounter;
|
||||
if (activeParticipantId === req.params.participantId) {
|
||||
const next = standingFrom([...order.slice(index + 1), ...order.slice(0, index)], 0);
|
||||
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { activeParticipantId: next ? next.id : null, targetParticipantIds: [] } });
|
||||
} else if (targetParticipantIds.includes(req.params.participantId)) {
|
||||
const left = targetParticipantIds.filter((id) => id !== req.params.participantId);
|
||||
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { targetParticipantIds: left } });
|
||||
}
|
||||
await prisma.encounterParticipant.delete({ where: { id: req.params.participantId } });
|
||||
await respondWithEncounter(req, res);
|
||||
@@ -437,25 +507,118 @@ router.delete('/:id/participants/:participantId', requireEncounterAccess, requir
|
||||
}
|
||||
});
|
||||
|
||||
// A dead character can be brought back in the middle of a battle by an ally:
|
||||
// the character on turn, if they are on their feet and can cast a spell that
|
||||
// raises the dead (revivalSpell). The dead come back at 1 HP, and with that
|
||||
// are back in everyone's list. A player tries it for their own character; the
|
||||
// DM for whoever is on turn. (The DM can also revive anyone outright, through
|
||||
// the character's death saves.)
|
||||
router.post('/:id/participants/:participantId/revive', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
const { mode, status, activeParticipantId } = req.encounter;
|
||||
const onTurnId = mode === 'BATTLE' && status === 'ACTIVE' ? activeParticipantId : null;
|
||||
const onTurn = onTurnId
|
||||
? await prisma.encounterParticipant.findFirst({
|
||||
where: { id: onTurnId, encounterId: req.encounter.id },
|
||||
include: { character: { include: { spells: { include: { spell: true } } } } },
|
||||
})
|
||||
: null;
|
||||
const reviver = onTurn?.character;
|
||||
if (!req.isDM && (!reviver || reviver.userId !== req.user.id)) {
|
||||
return res.status(403).json({ error: "You can only try to revive someone on your own character's turn" });
|
||||
}
|
||||
if (!reviver) return res.status(400).json({ error: 'Only a character on turn in a battle can try to revive someone.' });
|
||||
|
||||
const target = await prisma.encounterParticipant.findUnique({ where: { id: req.params.participantId }, include: { character: true } });
|
||||
if (!target || target.encounterId !== req.encounter.id) {
|
||||
return res.status(404).json({ error: 'Combatant not found in this encounter' });
|
||||
}
|
||||
if (!target.character || lifeState(target.character) !== 'DEAD') {
|
||||
return res.status(400).json({ error: `${target.displayName} is not dead.` });
|
||||
}
|
||||
if (lifeState(reviver) !== 'UP') return res.status(400).json({ error: `${reviver.name} is in no state to revive anyone.` });
|
||||
|
||||
const spell = revivalSpell(reviver);
|
||||
if (!spell) return res.status(400).json({ error: `${reviver.name} has no spell that can bring back the dead.` });
|
||||
|
||||
await prisma.character.update({ where: { id: target.character.id }, data: { currentHp: 1, ...NO_DEATH_SAVES } });
|
||||
await prisma.encounterParticipant.update({ where: { id: target.id }, data: { currentHp: 1 } });
|
||||
emitCharacterUpdated(req, target.character.id);
|
||||
await respondWithEncounter(req, res, 200, { revivedWith: spell.name });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Targets — who the combatant on turn is going for
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The whole list, in the order picked; an empty one clears it.
|
||||
const targetsSchema = z.object({ participantIds: z.array(z.string()).max(50) });
|
||||
|
||||
// Combatants can be marked as targets, for everyone to see; the DM's screen
|
||||
// then offers to apply damage to them. One is an ordinary attack, more is an
|
||||
// area attack. The DM can mark anyone at any time. A player can while their
|
||||
// own character is on turn in a battle. Nobody can mark the combatant who is
|
||||
// on turn: you don't target yourself.
|
||||
router.put('/:id/targets', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = targetsSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const participantIds = [...new Set(parsed.data.participantIds)];
|
||||
|
||||
// Whoever is on turn, when a battle is under way.
|
||||
const { mode, status, activeParticipantId } = req.encounter;
|
||||
const onTurnId = mode === 'BATTLE' && status === 'ACTIVE' ? activeParticipantId : null;
|
||||
|
||||
if (!req.isDM) {
|
||||
const ownTurn = onTurnId
|
||||
? await prisma.encounterParticipant.findFirst({
|
||||
where: { id: onTurnId, encounterId: req.encounter.id, character: { userId: req.user.id } },
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
if (!ownTurn) return res.status(403).json({ error: "You can only pick targets on your own character's turn" });
|
||||
}
|
||||
|
||||
if (onTurnId && participantIds.includes(onTurnId)) {
|
||||
return res.status(400).json({ error: 'The combatant on turn cannot be their own target' });
|
||||
}
|
||||
|
||||
// A player can only mark who they can see: to them the fallen are not there.
|
||||
const order = await loadTurnOrder(req.encounter.id);
|
||||
const markable = new Set(order.filter((p) => req.isDM || !isFallen(p)).map((p) => p.id));
|
||||
if (!participantIds.every((id) => markable.has(id))) return res.status(400).json({ error: 'Combatant not found in this encounter' });
|
||||
|
||||
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { targetParticipantIds: participantIds } });
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Turns and rounds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Passes the turn down the initiative order; going past the last combatant
|
||||
// starts the next round.
|
||||
// Passes the turn down the initiative order to the next combatant still
|
||||
// standing (the fallen have no turn); going past the last combatant starts
|
||||
// the next round.
|
||||
router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
|
||||
const order = inTurnOrder(participants);
|
||||
const order = await loadTurnOrder(req.encounter.id);
|
||||
if (order.length === 0) return res.status(400).json({ error: 'Add combatants before taking turns.' });
|
||||
|
||||
const index = order.findIndex((p) => p.id === req.encounter.activeParticipantId);
|
||||
const wrapped = index === order.length - 1;
|
||||
const upNext = standingFrom(order, index + 1);
|
||||
const wrapped = index !== -1 && order.indexOf(upNext) <= index;
|
||||
await prisma.encounter.update({
|
||||
where: { id: req.encounter.id },
|
||||
data: {
|
||||
status: 'ACTIVE',
|
||||
activeParticipantId: order[wrapped || index === -1 ? 0 : index + 1].id,
|
||||
activeParticipantId: upNext.id,
|
||||
targetParticipantIds: [], // the next combatant picks their own
|
||||
...(wrapped ? { currentRound: { increment: 1 } } : {}),
|
||||
},
|
||||
});
|
||||
@@ -467,14 +630,15 @@ router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res
|
||||
|
||||
router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
|
||||
const order = await loadTurnOrder(req.encounter.id);
|
||||
const updated = await prisma.encounter.update({
|
||||
where: { id: req.encounter.id },
|
||||
data: {
|
||||
currentRound: { increment: 1 },
|
||||
status: 'ACTIVE',
|
||||
targetParticipantIds: [],
|
||||
// A new round starts back at the top of the initiative order.
|
||||
...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: inTurnOrder(participants)[0]?.id ?? null } : {}),
|
||||
...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: standingFrom(order, 0)?.id ?? null } : {}),
|
||||
},
|
||||
});
|
||||
await syncCharacterLocations(req);
|
||||
@@ -493,7 +657,7 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const rewardSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('GOLD'), gold: z.number().int().min(1).max(1000000), note: z.string().trim().max(200).optional().nullable() }),
|
||||
z.object({ type: z.literal('GOLD'), gold: z.number().int().min(1).max(1000000), currency: z.enum(CURRENCIES).default('GOLD'), note: z.string().trim().max(200).optional().nullable() }),
|
||||
z.object({
|
||||
type: z.literal('WEAPON'),
|
||||
weaponId: z.string(),
|
||||
@@ -570,7 +734,7 @@ router.post('/:id/rewards/:rewardId/award', requireEncounterAccess, requireDM, a
|
||||
|
||||
let recipients;
|
||||
if (parsed.data.split) {
|
||||
if (reward.type !== 'GOLD') return res.status(400).json({ error: 'Only gold can be split.' });
|
||||
if (reward.type !== 'GOLD') return res.status(400).json({ error: 'Only coins can be split.' });
|
||||
const participants = await prisma.encounterParticipant.findMany({
|
||||
where: { encounterId: req.encounter.id, characterId: { not: null } },
|
||||
include: { character: true },
|
||||
@@ -599,7 +763,7 @@ router.post('/:id/rewards/:rewardId/award', requireEncounterAccess, requireDM, a
|
||||
const remainder = reward.gold % recipients.length;
|
||||
for (const [i, character] of recipients.entries()) {
|
||||
const amount = share + (i < remainder ? 1 : 0);
|
||||
if (amount > 0) await adjustGold(tx, { characterId: character.id, amount, reason, userId: req.user.id });
|
||||
if (amount > 0) await adjustGold(tx, { characterId: character.id, amount, currency: reward.currency, reason, userId: req.user.id });
|
||||
}
|
||||
} else {
|
||||
const kind = reward.type === 'WEAPON' ? 'weapon' : 'item';
|
||||
@@ -619,4 +783,146 @@ router.post('/:id/rewards/:rewardId/award', requireEncounterAccess, requireDM, a
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trade — changing gold and cash with an NPC, and buying what is for sale.
|
||||
// The DM sets the rates and the wares up (campaigns.routes.js); here the
|
||||
// players, or the DM for them, use them.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class TradeError extends Error {
|
||||
constructor(message, status = 400) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function money(amount, currency) {
|
||||
return currency === 'CASH' ? `$${amount}` : `${amount} ${currency.toLowerCase()}`;
|
||||
}
|
||||
|
||||
// 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.
|
||||
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.');
|
||||
}
|
||||
|
||||
// The character doing the trading: in this scene, alive, and the player's
|
||||
// own (the DM can trade for anyone).
|
||||
async function loadTrader(req, characterId) {
|
||||
const row = await prisma.encounterParticipant.findFirst({ where: { encounterId: req.encounter.id, characterId }, include: { character: true } });
|
||||
if (!row) throw new TradeError('That character is not in this encounter.');
|
||||
if (!req.isDM && row.character.userId !== req.user.id) throw new TradeError('You can only trade for your own character.', 403);
|
||||
if (isFallen(row)) throw new TradeError(`${row.character.name} is dead.`);
|
||||
return row.character;
|
||||
}
|
||||
|
||||
// An NPC to trade with has to be in the scene and still standing.
|
||||
async function loadTradingNpc(req, npcId) {
|
||||
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;
|
||||
}
|
||||
|
||||
function tradeRoute(handler) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
assertTradeOpen(req.encounter);
|
||||
await handler(req, res);
|
||||
} catch (err) {
|
||||
if (err instanceof TradeError) return res.status(err.status).json({ error: err.message });
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Changes gold into cash, or cash into gold, at the NPC's own rate. Only
|
||||
// gold counts: silver and copper are not taken, and none come back. `amount`
|
||||
// is what is handed over, and has to come to a whole number the other way.
|
||||
router.post(
|
||||
'/:id/exchange',
|
||||
requireEncounterAccess,
|
||||
tradeRoute(async (req, res) => {
|
||||
const parsed = z
|
||||
.object({ characterId: z.string(), npcId: z.string(), from: z.enum(['GOLD', 'CASH']), amount: z.number().int().min(1).max(1000000) })
|
||||
.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { characterId, npcId, from, amount } = parsed.data;
|
||||
const to = from === 'GOLD' ? 'CASH' : 'GOLD';
|
||||
|
||||
const character = await loadTrader(req, characterId);
|
||||
const npc = await loadTradingNpc(req, npcId);
|
||||
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) {
|
||||
throw new TradeError(
|
||||
`${npc.name} gives ${money(rate.CASH, 'CASH')} for ${money(rate.GOLD, 'GOLD')}: hand over ${from.toLowerCase()} in multiples of ${rate[from]}.`
|
||||
);
|
||||
}
|
||||
const received = (amount / rate[from]) * rate[to];
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const base = { characterId: character.id, userId: req.user.id };
|
||||
await adjustGold(tx, { ...base, currency: from, amount: -amount, reason: `Changed with ${npc.name} for ${money(received, to)}` });
|
||||
await adjustGold(tx, { ...base, currency: to, amount: received, reason: `Changed with ${npc.name} from ${money(amount, from)}` });
|
||||
});
|
||||
} catch (err) {
|
||||
if (!(err instanceof InsufficientGoldError)) throw err;
|
||||
throw new TradeError(`${character.name} only has ${money(from === 'GOLD' ? character.gold : character.cash, from)}.`);
|
||||
}
|
||||
|
||||
emitCharacterUpdated(req, character.id);
|
||||
await respondWithEncounter(req, res, 201, { received, currency: to });
|
||||
})
|
||||
);
|
||||
|
||||
// Buys something that is for sale here: from an NPC in the scene, or from the
|
||||
// scene itself. The price is paid from the purse (coins make change; a cash
|
||||
// price takes cash), and the weapon or item joins what the character holds.
|
||||
router.post(
|
||||
'/:id/wares/:wareId/buy',
|
||||
requireEncounterAccess,
|
||||
tradeRoute(async (req, res) => {
|
||||
const parsed = z.object({ characterId: z.string(), quantity: z.number().int().min(1).max(999).default(1) }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { characterId, quantity } = parsed.data;
|
||||
|
||||
const character = await loadTrader(req, characterId);
|
||||
const ware = await prisma.saleOffer.findUnique({ where: { id: req.params.wareId }, include: { weapon: true, item: true } });
|
||||
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;
|
||||
const kind = ware.weapon ? 'weapon' : 'item';
|
||||
const entry = ware[kind];
|
||||
const total = ware.price * quantity;
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (ware.stock !== null) {
|
||||
// The atomic decrement is the stock check: two buyers can't both take the last one.
|
||||
const taken = await tx.saleOffer.updateMany({ where: { id: ware.id, stock: { gte: quantity } }, data: { stock: { decrement: quantity } } });
|
||||
if (taken.count === 0) throw new TradeError(ware.stock === 0 ? `${entry.name} is sold out.` : `Only ${ware.stock} of ${entry.name} left.`);
|
||||
}
|
||||
const what = `${quantity > 1 ? `${quantity} × ` : ''}${entry.name}`;
|
||||
await spendCoins(tx, {
|
||||
characterId: character.id,
|
||||
amount: total,
|
||||
currency: ware.currency,
|
||||
reason: seller ? `Bought ${what} from ${seller}` : `Bought ${what}`,
|
||||
userId: req.user.id,
|
||||
});
|
||||
await giveGear(tx, kind, { characterId: character.id, entry, quantity, level: ware.level });
|
||||
});
|
||||
} catch (err) {
|
||||
if (!(err instanceof InsufficientGoldError)) throw err;
|
||||
throw new TradeError(`${character.name} can't afford that: it costs ${money(total, ware.currency)}.`);
|
||||
}
|
||||
|
||||
emitCharacterUpdated(req, character.id);
|
||||
await respondWithEncounter(req, res, 201);
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
+105
-9
@@ -1,24 +1,120 @@
|
||||
// The two ways a character's belongings change: gold through the ledger, and
|
||||
// The two ways a character's belongings change: coins through the ledger, and
|
||||
// gear from the campaign pools. Shared by the character sheet routes and by
|
||||
// encounter rewards so both follow the same rules. Every function takes a
|
||||
// Prisma client or transaction client as `db`.
|
||||
|
||||
// The coins, and what each is worth counted in copper: silver is 0.1 gold and
|
||||
// copper is 0.01 gold, so 1 gold = 10 silver = 100 copper and 1 silver = 10
|
||||
// copper. Whole numbers keep exchanges exact.
|
||||
const COINS = ['GOLD', 'SILVER', 'COPPER'];
|
||||
const COIN_VALUE = { GOLD: 100, SILVER: 10, COPPER: 1 };
|
||||
// Everything a purse can hold: the coins, plus cash ($) — modern money for
|
||||
// crossover worlds. Cash has no worth in coins, so it is never exchanged for
|
||||
// them and never used to make change.
|
||||
const CURRENCIES = [...COINS, 'CASH'];
|
||||
// Each currency is its own column on the character.
|
||||
const COIN_COLUMN = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper', CASH: 'cash' };
|
||||
|
||||
class InsufficientGoldError extends Error {}
|
||||
|
||||
// amount is signed: positive = gained, negative = spent. Writes the ledger row
|
||||
// and returns it; throws InsufficientGoldError if the balance would go below 0
|
||||
// (inside a transaction, that rolls the whole thing back).
|
||||
async function adjustGold(db, { characterId, amount, reason, userId, include }) {
|
||||
// amount is signed: positive = gained, negative = spent, in `currency` coins.
|
||||
// Writes the ledger row and returns it; throws InsufficientGoldError if that
|
||||
// coin's balance would go below 0 (inside a transaction, that rolls the whole
|
||||
// thing back). This moves exactly the coins named and nothing else; to pay a
|
||||
// price out of whatever coins the purse holds, use spendCoins.
|
||||
async function adjustGold(db, { characterId, amount, currency = 'GOLD', reason, userId, include }) {
|
||||
const column = COIN_COLUMN[currency];
|
||||
// The atomic increment is the balance check: two spends racing each other
|
||||
// can't both pass a stale read.
|
||||
const updated = await db.character.update({ where: { id: characterId }, data: { gold: { increment: amount } } });
|
||||
if (updated.gold < 0) throw new InsufficientGoldError();
|
||||
const updated = await db.character.update({ where: { id: characterId }, data: { [column]: { increment: amount } } });
|
||||
if (updated[column] < 0) throw new InsufficientGoldError();
|
||||
return db.goldTransaction.create({
|
||||
data: { characterId, amount, balanceAfter: updated.gold, reason: reason || null, createdById: userId },
|
||||
data: { characterId, currency, amount, balanceAfter: updated[column], reason: reason || null, createdById: userId },
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
// Works out which coins leave (and come back as change) when a purse pays
|
||||
// `amount` coins of `currency`. Returns the change to each balance, e.g.
|
||||
// { GOLD: -1, SILVER: 7, COPPER: 0 } for 3 silver paid from a purse with only
|
||||
// gold in it — or null if the whole purse isn't worth that much.
|
||||
// 1. Coins of the kind asked for go first.
|
||||
// 2. Any shortfall is met by breaking the nearest bigger coin; the change
|
||||
// comes back in as few coins as possible.
|
||||
// 3. Only if there are no bigger coins left are smaller ones counted out.
|
||||
function planPayment(purse, currency, amount) {
|
||||
const worth = (coin) => COIN_VALUE[coin];
|
||||
const delta = { GOLD: 0, SILVER: 0, COPPER: 0 };
|
||||
const left = (coin) => purse[coin] + delta[coin];
|
||||
let owed = amount * worth(currency);
|
||||
if (COINS.reduce((sum, coin) => sum + purse[coin] * worth(coin), 0) < owed) return null;
|
||||
|
||||
const pay = (coin, count) => {
|
||||
delta[coin] -= count;
|
||||
owed -= count * worth(coin);
|
||||
};
|
||||
pay(currency, Math.min(purse[currency], amount));
|
||||
|
||||
const bigger = COINS.filter((coin) => worth(coin) > worth(currency)).reverse(); // nearest first
|
||||
for (const coin of bigger) {
|
||||
if (owed <= 0) break;
|
||||
pay(coin, Math.min(left(coin), Math.ceil(owed / worth(coin))));
|
||||
// Overpaid: hand the difference back, biggest coins first.
|
||||
let change = -owed;
|
||||
for (const small of COINS.filter((c) => worth(c) < worth(coin))) {
|
||||
if (change <= 0) break;
|
||||
const count = Math.floor(change / worth(small));
|
||||
delta[small] += count;
|
||||
change -= count * worth(small);
|
||||
}
|
||||
if (owed < 0) owed = 0;
|
||||
}
|
||||
|
||||
for (const coin of COINS.filter((c) => worth(c) < worth(currency))) {
|
||||
if (owed <= 0) break;
|
||||
pay(coin, Math.min(left(coin), Math.floor(owed / worth(coin))));
|
||||
}
|
||||
return delta;
|
||||
}
|
||||
|
||||
// Pays `amount` coins of `currency` out of a character's purse, making change
|
||||
// as needed (see planPayment). Every coin that moved gets its own ledger row;
|
||||
// the rows are returned, the one in `currency` first when there is one. Throws
|
||||
// InsufficientGoldError if the purse as a whole can't cover it. Cash is paid in
|
||||
// cash alone: coins don't cover it, and it doesn't cover coins.
|
||||
async function spendCoins(db, { characterId, amount, currency = 'GOLD', reason, userId, include }) {
|
||||
if (!COINS.includes(currency)) return [await adjustGold(db, { characterId, amount: -amount, currency, reason, userId, include })];
|
||||
const character = await db.character.findUnique({ where: { id: characterId }, select: { gold: true, silver: true, copper: true } });
|
||||
const purse = { GOLD: character.gold, SILVER: character.silver, COPPER: character.copper };
|
||||
const delta = planPayment(purse, currency, amount);
|
||||
if (!delta) throw new InsufficientGoldError();
|
||||
|
||||
const moved = COINS.filter((coin) => delta[coin] !== 0).sort((a, b) => (b === currency) - (a === currency));
|
||||
const madeChange = moved.some((coin) => coin !== currency);
|
||||
const label = reason || 'Spent';
|
||||
const rows = [];
|
||||
for (const coin of moved) {
|
||||
rows.push(
|
||||
await adjustGold(db, {
|
||||
characterId,
|
||||
amount: delta[coin],
|
||||
currency: coin,
|
||||
// Says what the payment was when the coins that moved aren't the ones asked for.
|
||||
reason: madeChange ? `${label} (paid ${amount} ${currency.toLowerCase()}, with change)` : reason,
|
||||
userId,
|
||||
include,
|
||||
})
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// A purse's coins as one figure in gold, for messages: 265 copper-worth -> 2.65.
|
||||
function worthInGold(character) {
|
||||
const copper = COINS.reduce((sum, coin) => sum + character[COIN_COLUMN[coin]] * COIN_VALUE[coin], 0);
|
||||
return copper / COIN_VALUE.GOLD;
|
||||
}
|
||||
|
||||
const HELD = {
|
||||
weapon: { model: 'characterWeapon', key: 'weaponId' },
|
||||
item: { model: 'inventoryItem', key: 'itemId' },
|
||||
@@ -37,4 +133,4 @@ async function giveGear(db, kind, { characterId, entry, quantity = 1, level, equ
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { InsufficientGoldError, adjustGold, giveGear };
|
||||
module.exports = { CURRENCIES, COINS, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, planPayment, spendCoins, worthInGold, giveGear };
|
||||
|
||||
+67
-1
@@ -9,4 +9,70 @@ function absorbDamage(currentHp, tempHp, requestedHp) {
|
||||
return { currentHp: requestedHp + absorbed, tempHp: tempHp - absorbed };
|
||||
}
|
||||
|
||||
module.exports = { absorbDamage };
|
||||
// ---------------------------------------------------------------------------
|
||||
// At 0 HP — death saves
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// A character at 0 HP is dying: they roll a d20 each turn, and three successes
|
||||
// or three failures settle it. The tallies and the "stable" flag live on the
|
||||
// Character row, next to the HP they belong to.
|
||||
|
||||
// Three successes steady a character; three failures kill them.
|
||||
const DEATH_SAVE_LIMIT = 3;
|
||||
|
||||
const NO_DEATH_SAVES = { deathSaveSuccesses: 0, deathSaveFailures: 0, stable: false };
|
||||
|
||||
// UP: on their feet. DYING: at 0 HP and rolling death saves. STABLE: at 0 HP
|
||||
// but no longer rolling. DEAD: three failures, or one blow too big to survive.
|
||||
function lifeState(character) {
|
||||
if (character.deathSaveFailures >= DEATH_SAVE_LIMIT) return 'DEAD';
|
||||
if (character.currentHp > 0) return 'UP';
|
||||
return character.stable ? 'STABLE' : 'DYING';
|
||||
}
|
||||
|
||||
// What the API sends about it, the same shape wherever a character's HP goes.
|
||||
function deathSaveInfo(character) {
|
||||
return { state: lifeState(character), successes: character.deathSaveSuccesses, failures: character.deathSaveFailures };
|
||||
}
|
||||
|
||||
// Turns a pair of tallies into what gets stored: the third failure is death,
|
||||
// and the third success makes the character stable, which clears the tallies.
|
||||
function settleDeathSaves(successes, failures) {
|
||||
if (failures >= DEATH_SAVE_LIMIT) return { deathSaveSuccesses: successes, deathSaveFailures: DEATH_SAVE_LIMIT, stable: false };
|
||||
if (successes >= DEATH_SAVE_LIMIT) return { ...NO_DEATH_SAVES, stable: true };
|
||||
return { deathSaveSuccesses: successes, deathSaveFailures: failures, stable: false };
|
||||
}
|
||||
|
||||
// Damage or healing on a finished character. `requestedHp` is the HP that was
|
||||
// asked for, which may be below zero or above max. Returns every column that
|
||||
// should be written: HP and temp HP, plus the death-save columns when the
|
||||
// change touches them.
|
||||
//
|
||||
// - Damage comes off temp HP first.
|
||||
// - Dropping to 0 starts the death saves from a clean slate.
|
||||
// - Damage taken while already at 0 is one failed save, and ends "stable".
|
||||
// - Damage that would leave as much as max HP still to take once HP is at 0
|
||||
// kills outright.
|
||||
// - Any healing from 0 puts the character back up and clears the tallies.
|
||||
function changeHp(character, requestedHp, maxHp) {
|
||||
const soaked = absorbDamage(character.currentHp, character.tempHp, requestedHp);
|
||||
const after = { currentHp: Math.min(Math.max(soaked.currentHp, 0), maxHp), tempHp: soaked.tempHp };
|
||||
if (after.currentHp > 0) return { ...after, ...NO_DEATH_SAVES };
|
||||
|
||||
const damage = character.currentHp - soaked.currentHp; // what got past temp HP
|
||||
if (damage <= 0) return after;
|
||||
if (-soaked.currentHp >= maxHp) return { ...after, ...settleDeathSaves(0, DEATH_SAVE_LIMIT) };
|
||||
if (character.currentHp > 0) return { ...after, ...NO_DEATH_SAVES };
|
||||
return { ...after, ...settleDeathSaves(character.deathSaveSuccesses, character.deathSaveFailures + 1) };
|
||||
}
|
||||
|
||||
// A dying character's death save, from what the d20 showed: 10 or more is a
|
||||
// success, less is a failure, a 1 is two failures, and a 20 puts them back on
|
||||
// their feet with 1 HP.
|
||||
function rollDeathSave(character, roll) {
|
||||
if (roll === 20) return { currentHp: 1, ...NO_DEATH_SAVES };
|
||||
if (roll >= 10) return settleDeathSaves(character.deathSaveSuccesses + 1, character.deathSaveFailures);
|
||||
return settleDeathSaves(character.deathSaveSuccesses, character.deathSaveFailures + (roll === 1 ? 2 : 1));
|
||||
}
|
||||
|
||||
module.exports = { absorbDamage, DEATH_SAVE_LIMIT, NO_DEATH_SAVES, lifeState, deathSaveInfo, settleDeathSaves, changeHp, rollDeathSave };
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
const { lifeState, settleDeathSaves, changeHp, rollDeathSave } = require('./hp');
|
||||
|
||||
const CLEAR = { deathSaveSuccesses: 0, deathSaveFailures: 0, stable: false };
|
||||
const hero = (over = {}) => ({ currentHp: 10, tempHp: 0, ...CLEAR, ...over });
|
||||
|
||||
describe('lifeState', () => {
|
||||
test('reads up, dying, stable and dead off the row', () => {
|
||||
expect(lifeState(hero())).toBe('UP');
|
||||
expect(lifeState(hero({ currentHp: 0 }))).toBe('DYING');
|
||||
expect(lifeState(hero({ currentHp: 0, stable: true }))).toBe('STABLE');
|
||||
expect(lifeState(hero({ currentHp: 0, deathSaveFailures: 3 }))).toBe('DEAD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('changeHp', () => {
|
||||
test('ordinary damage and healing leave the death saves clear', () => {
|
||||
expect(changeHp(hero(), 4, 20)).toEqual({ currentHp: 4, tempHp: 0, ...CLEAR });
|
||||
expect(changeHp(hero(), 99, 20)).toEqual({ currentHp: 20, tempHp: 0, ...CLEAR });
|
||||
});
|
||||
|
||||
test('dropping to 0 starts the saves from nothing', () => {
|
||||
expect(changeHp(hero(), -5, 20)).toEqual({ currentHp: 0, tempHp: 0, ...CLEAR });
|
||||
});
|
||||
|
||||
test('damage at 0 HP is a failed save and ends stable', () => {
|
||||
const down = hero({ currentHp: 0, deathSaveSuccesses: 2, deathSaveFailures: 1 });
|
||||
expect(changeHp(down, -3, 20)).toMatchObject({ currentHp: 0, deathSaveSuccesses: 2, deathSaveFailures: 2, stable: false });
|
||||
expect(changeHp(hero({ currentHp: 0, stable: true }), -3, 20)).toMatchObject({ deathSaveFailures: 1, stable: false });
|
||||
expect(lifeState(changeHp(hero({ currentHp: 0, deathSaveFailures: 2 }), -1, 20))).toBe('DEAD');
|
||||
});
|
||||
|
||||
test('temp HP that soaks the whole blow costs no save', () => {
|
||||
const down = hero({ currentHp: 0, tempHp: 5, deathSaveFailures: 1 });
|
||||
expect(changeHp(down, -5, 20)).toEqual({ currentHp: 0, tempHp: 0 });
|
||||
});
|
||||
|
||||
test('asking for the HP they already have at 0 changes nothing', () => {
|
||||
expect(changeHp(hero({ currentHp: 0, deathSaveFailures: 2 }), 0, 20)).toEqual({ currentHp: 0, tempHp: 0 });
|
||||
});
|
||||
|
||||
test('damage of max HP or more past zero kills outright', () => {
|
||||
expect(lifeState(changeHp(hero(), -19, 20))).toBe('DYING');
|
||||
expect(lifeState(changeHp(hero(), -20, 20))).toBe('DEAD');
|
||||
expect(lifeState(changeHp(hero({ currentHp: 0 }), -20, 20))).toBe('DEAD');
|
||||
// Temp HP counts against the blow first.
|
||||
expect(lifeState(changeHp(hero({ tempHp: 1 }), -20, 20))).toBe('DYING');
|
||||
});
|
||||
|
||||
test('healing from 0 puts them back up with a clean slate', () => {
|
||||
const down = hero({ currentHp: 0, deathSaveSuccesses: 1, deathSaveFailures: 2 });
|
||||
expect(changeHp(down, 1, 20)).toEqual({ currentHp: 1, tempHp: 0, ...CLEAR });
|
||||
expect(changeHp(hero({ currentHp: 0, stable: true }), 6, 20)).toEqual({ currentHp: 6, tempHp: 0, ...CLEAR });
|
||||
});
|
||||
});
|
||||
|
||||
describe('rollDeathSave', () => {
|
||||
const down = (successes, failures) => hero({ currentHp: 0, deathSaveSuccesses: successes, deathSaveFailures: failures });
|
||||
|
||||
test('10 or more succeeds, less fails, a 1 fails twice', () => {
|
||||
expect(rollDeathSave(down(0, 0), 10)).toEqual({ deathSaveSuccesses: 1, deathSaveFailures: 0, stable: false });
|
||||
expect(rollDeathSave(down(0, 0), 19)).toEqual({ deathSaveSuccesses: 1, deathSaveFailures: 0, stable: false });
|
||||
expect(rollDeathSave(down(0, 0), 9)).toEqual({ deathSaveSuccesses: 0, deathSaveFailures: 1, stable: false });
|
||||
expect(rollDeathSave(down(0, 0), 1)).toEqual({ deathSaveSuccesses: 0, deathSaveFailures: 2, stable: false });
|
||||
});
|
||||
|
||||
test('the third success makes them stable and clears the tallies', () => {
|
||||
expect(rollDeathSave(down(2, 2), 14)).toEqual({ ...CLEAR, stable: true });
|
||||
});
|
||||
|
||||
test('the third failure is death, and never counts past three', () => {
|
||||
expect(rollDeathSave(down(2, 2), 5)).toMatchObject({ deathSaveFailures: 3 });
|
||||
expect(rollDeathSave(down(0, 2), 1)).toMatchObject({ deathSaveFailures: 3 });
|
||||
});
|
||||
|
||||
test('a 20 is 1 HP and a clean slate', () => {
|
||||
expect(rollDeathSave(down(0, 2), 20)).toEqual({ currentHp: 1, ...CLEAR });
|
||||
});
|
||||
});
|
||||
|
||||
describe('settleDeathSaves', () => {
|
||||
test('failures win a tie at three', () => {
|
||||
expect(settleDeathSaves(3, 3)).toMatchObject({ deathSaveFailures: 3, stable: false });
|
||||
});
|
||||
});
|
||||
+97
-15
@@ -325,6 +325,50 @@ Example: a Fighter (d10) with CON 14 (+2) has 12 at level 1 and 28 at level 3.
|
||||
**Temp HP** is a tracked number shown beside HP. Damage is not taken from it
|
||||
automatically.
|
||||
|
||||
### At 0 HP: death saves
|
||||
|
||||
A character at 0 HP is in one of three states, shown on the sheet under the
|
||||
HP bar, on their encounter card, in the quick stats and in the party lists.
|
||||
|
||||
| State | Meaning | How it ends |
|
||||
|---|---|---|
|
||||
| Dying | Rolling death saves | Three successes, three failures, healing, or being stabilised |
|
||||
| Stable | At 0 HP, no longer rolling | Healing wakes them; damage makes them Dying again |
|
||||
| Dead | Three failures, or one blow too big to survive | The DM's **Revive at 1 HP**, or an ally's reviving spell in a battle |
|
||||
|
||||
**Rolling.** While Dying, type what the d20 showed and click **Death save**.
|
||||
The app does not roll it.
|
||||
|
||||
| Roll | Result |
|
||||
|---|---|
|
||||
| 1 | Two failures |
|
||||
| 2 to 9 | One failure |
|
||||
| 10 to 19 | One success |
|
||||
| 20 | Back up with 1 HP, tallies cleared |
|
||||
|
||||
- **Three successes:** Stable. The tallies are cleared.
|
||||
- **Three failures:** Dead.
|
||||
- **Stabilise** makes a Dying character Stable without a roll, for when
|
||||
someone steadies them (a Medicine check, a healer's kit, a spell).
|
||||
- **Damage at 0 HP** is one failed save, and makes a Stable character Dying
|
||||
again. Damage soaked entirely by temp HP does not count. The app cannot tell
|
||||
a critical hit; the DM adds its second failure by clicking a pip.
|
||||
- **Massive damage:** if a blow leaves as much as the character's max HP
|
||||
still to take once HP reaches 0, they are Dead at once. A character with
|
||||
12 max HP on 5 HP dies from 17 damage or more.
|
||||
- **Healing** from 0, by any amount, puts the character back up and clears
|
||||
the tallies and Stable.
|
||||
- **Dead** characters cannot be healed: Heal is refused with a message. The
|
||||
DM clicks **Revive at 1 HP**, then heals as normal. In a battle, an ally
|
||||
with a reviving spell can do the same on their own turn, and until then the
|
||||
dead are hidden from the players' encounter screen: see
|
||||
[ENCOUNTERS.md](ENCOUNTERS.md), section 6.
|
||||
- The **DM** can click a pip to set either tally by hand, to fix a mistake.
|
||||
|
||||
Dropping to 0 always starts from empty tallies. None of this changes any
|
||||
other number on the sheet, and nothing is added to the conditions in an
|
||||
encounter.
|
||||
|
||||
## 8. Other derived stats
|
||||
|
||||
| Stat | Formula |
|
||||
@@ -509,17 +553,50 @@ character already has adds to the stack. Items have no effect on any stat. An
|
||||
item can carry optional dice (a potion's healing, a bomb's damage) that grow
|
||||
with its level exactly like a weapon's (section 10).
|
||||
|
||||
**Money** is a single gold balance with a log behind it.
|
||||
**Money** is a purse of three coins, each counted on its own, with a log
|
||||
behind it.
|
||||
|
||||
- The sheet's Money card shows the balance and every change to it: the amount,
|
||||
what it was for, who recorded it, when, and the balance afterwards.
|
||||
- The **owning player and the DM** can both record a gain or a spend. Other
|
||||
members can see the log but not add to it.
|
||||
- The balance **cannot go below zero**; a spend larger than the balance is
|
||||
refused.
|
||||
- The log is the only way the balance changes. Entries cannot be edited or
|
||||
| Coin | Worth |
|
||||
|---|---|
|
||||
| Gold (gp) | 1 gold |
|
||||
| Silver (sp) | 0.1 gold — 10 to the gold |
|
||||
| Copper (cp) | 0.01 gold — 100 to the gold, 10 to the silver |
|
||||
|
||||
- The sheet's Money card shows the three balances, what the purse is worth in
|
||||
gold altogether, and every change: the amount and coin, what it was for, who
|
||||
recorded it, when, and that coin's balance afterwards.
|
||||
- The **owning player and the DM** can both record a gain or a spend, choosing
|
||||
the coin. Other players cannot see the purse or the log.
|
||||
- **Spending makes change for you.** A spend (or a gift to another character)
|
||||
is paid from the whole purse. Coins of the kind named go first; if there
|
||||
aren't enough, the nearest bigger coin is broken and the change comes back
|
||||
in as few coins as possible; if there are no bigger coins, smaller ones are
|
||||
counted out. Spending 3 silver from a purse of 5 gold leaves 4 gold and 7
|
||||
silver. Each coin that moved gets its own log entry, marked "with change".
|
||||
- A spend is refused only when the **whole purse** isn't worth enough.
|
||||
- **Exchange** turns coins of one kind into another at the worth above, and
|
||||
logs both sides. It moves exactly the coins named and has to come to whole
|
||||
coins: silver into gold goes in tens, copper into silver in tens, copper
|
||||
into gold in hundreds.
|
||||
- The log is the only way a balance changes. Entries cannot be edited or
|
||||
deleted; to correct a mistake, record the opposite entry.
|
||||
- There is one currency (gold). Silver, copper and so on are not tracked.
|
||||
- Starting gold (a campaign setting) is gold only. Encounter rewards and gifts
|
||||
between characters can be in any coin, or in cash.
|
||||
- **Cash ($)** is a fourth balance for crossover worlds, counted in whole
|
||||
dollars and shown as `$40`. It is gained, spent, given and awarded like a
|
||||
coin and has its own log entries, but it has no worth in coins: it cannot
|
||||
be exchanged on the sheet, a cash spend is paid from cash alone, and cash
|
||||
is never broken to cover a coin spend. It shows in the purse only when
|
||||
held.
|
||||
- **Gold and cash are changed for each other by an NPC**, not on the sheet.
|
||||
An NPC the DM has given an exchange rate ("1 gold = $5") changes them
|
||||
either way at that rate, in an NPC quest in progress that both the NPC and
|
||||
the character are in. Only gold and cash: silver and copper are not taken.
|
||||
Each NPC has their own rate. See [NPCS.md](NPCS.md), section 5.
|
||||
- **Buying.** In the same kind of scene a character can buy the weapons and
|
||||
items an NPC, or the encounter itself, has for sale. The price is paid from
|
||||
the purse like any other spend and logged as "Bought <thing> from <NPC>".
|
||||
See [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
|
||||
|
||||
### Giving to another character
|
||||
|
||||
@@ -569,7 +646,7 @@ character in the same campaign.
|
||||
| Party overview | One table of every character with an HP bar, AC, gold and location. |
|
||||
| Character sheet | Everything in this document. |
|
||||
| Character builder | A character that is still being built (section 2). |
|
||||
| Quick stats drawer | A combat summary: HP, AC, initiative, speed, proficiency bonus, spellcasting numbers, special abilities, and each weapon's attack and damage. |
|
||||
| Quick stats drawer (the ⚡ button: bottom right, or in the top bar on a phone) | A combat summary: HP, AC, initiative, speed, proficiency bonus, spellcasting numbers, special abilities, and each weapon's attack and damage. |
|
||||
| Encounter page | Characters taking part in a battle or quest (next section). |
|
||||
|
||||
Changes are pushed live to everyone who has the campaign open, so sheets and
|
||||
@@ -620,7 +697,9 @@ players never receive.
|
||||
- "Begin Battle" puts the highest initiative on turn. "Next Turn" passes the
|
||||
turn down the order, and going past the last combatant starts the next
|
||||
round. "Next Round" jumps straight to the top of the next round.
|
||||
- A combatant at 0 HP is marked Down but still takes its turn in the order.
|
||||
- A monster or NPC at 0 HP is marked Down; a character is Dying and makes
|
||||
death saves on its card (section 7). Either still has its place in the
|
||||
order.
|
||||
- Conditions (Prone, Poisoned, …) are picked from a list and removed with a
|
||||
click.
|
||||
|
||||
@@ -671,12 +750,15 @@ Another Campaign" card on its sheet.
|
||||
| View private notes | Yes | No | No |
|
||||
| Run the builder | Yes | Yes | No |
|
||||
| Change HP, temp HP, armor bonus | Yes | Yes | No |
|
||||
| Enter a death save, or stabilise | Yes | Yes | No |
|
||||
| Set death-save tallies by hand, revive from dead | No | Yes | No |
|
||||
| Edit special abilities, background, appearance (after the build) | No | Yes | No |
|
||||
| Tick Equipped / Prepared | Yes | Yes | No |
|
||||
| Give, remove or re-count weapons, spells and items | No | Yes | No |
|
||||
| Level a weapon or item up or down | No | Yes | No |
|
||||
| Record a gold gain or spend | Yes | Yes | No |
|
||||
| Give gold, weapons, items or spells to another character | Yes | Yes | No |
|
||||
| Change gold and cash with an NPC, or buy what is for sale, in a quest in progress | Yes | Yes | No |
|
||||
| View the money log, gold and inventory | Yes | Yes | No |
|
||||
| Change level, XP, location or speed override | No | Yes | No |
|
||||
| Post a character note | Yes | Yes | Yes |
|
||||
@@ -707,13 +789,13 @@ calculations for them.
|
||||
- Armor and shields as items (only the single armor bonus number)
|
||||
- Weapon proficiency (everyone is proficient with everything)
|
||||
- Rolling attacks, damage, saves or checks for a character (the dice roller,
|
||||
bottom left of every page, is a plain roller that knows nothing about the
|
||||
sheet)
|
||||
- Death saves, conditions on the sheet, and resting
|
||||
bottom left of every page, or the last button of the bottom bar on a phone,
|
||||
is a plain roller that knows nothing about the sheet)
|
||||
- Conditions on the sheet, and resting
|
||||
- Automatic levelling from XP
|
||||
- Alignment, languages, tool proficiencies and backgrounds-as-rules
|
||||
- Encumbrance and item weight
|
||||
- Currencies other than gold, and item prices
|
||||
- Cents, and changing silver or copper for cash
|
||||
|
||||
## 20. Everything the builder offers
|
||||
|
||||
|
||||
+218
-19
@@ -19,9 +19,10 @@ This describes the code as it stands. The rules live in
|
||||
7. [Initiative, turns and rounds](#7-initiative-turns-and-rounds)
|
||||
8. [Conditions](#8-conditions)
|
||||
9. [Rewards](#9-rewards)
|
||||
10. [What each side sees](#10-what-each-side-sees)
|
||||
11. [Permissions](#11-permissions)
|
||||
12. [What is not modelled](#12-what-is-not-modelled)
|
||||
10. [Trade](#10-trade)
|
||||
11. [What each side sees](#11-what-each-side-sees)
|
||||
12. [Permissions](#12-permissions)
|
||||
13. [What is not modelled](#13-what-is-not-modelled)
|
||||
|
||||
---
|
||||
|
||||
@@ -46,11 +47,12 @@ 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 | Three groups: "People you meet", "The party", "Creatures" |
|
||||
| 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 | Not shown |
|
||||
| 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 |
|
||||
|
||||
Everything else (hit points, conditions, rewards, the scene description, DM
|
||||
notes) works the same in both.
|
||||
@@ -86,6 +88,10 @@ goes to the top. The "Sort by" menu re-sorts the list by name, status, type or
|
||||
date instead; that choice is remembered per browser and changes nothing for
|
||||
anyone else. ▲ and ▼ only show while sorting by campaign order.
|
||||
|
||||
The list shows **ten encounters a page**, with Prev and Next beneath it, 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.
|
||||
|
||||
**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
|
||||
@@ -111,7 +117,7 @@ The DM adds people from the "Add to the scene" panel.
|
||||
|
||||
| Kind | Comes from | Can be added more than once |
|
||||
|---|---|---|
|
||||
| Character | The campaign's finished characters | No |
|
||||
| Character | The campaign's finished characters, except dead ones | No |
|
||||
| Monster | The campaign's monster list ([MONSTERS.md](MONSTERS.md)) | Yes |
|
||||
| NPC | The campaign's NPC list ([NPCS.md](NPCS.md)) | No |
|
||||
|
||||
@@ -131,6 +137,10 @@ Details start open for the DM and closed for players. Weapons and spells
|
||||
above a character's level are left out, because the character cannot use
|
||||
them.
|
||||
|
||||
A **dead character cannot be added** to any encounter: they are left out of
|
||||
the list to pick from and the server refuses them, until they are revived. A
|
||||
character who dies stays in the encounters they were already in.
|
||||
|
||||
The DM removes someone with "Remove" on their card. Removing whoever is on
|
||||
turn passes the turn to the next combatant.
|
||||
|
||||
@@ -149,18 +159,87 @@ The DM types an amount and clicks Damage or Heal on a card.
|
||||
monsters and NPCs it depends on the campaign setting "Players see the exact
|
||||
hit points of monsters and NPCs": on (the default) shows the numbers, off
|
||||
shows only the health bar. The DM always sees the numbers.
|
||||
- At 0 HP a combatant is marked **Down** and dimmed. It keeps its place in
|
||||
the order; nothing is removed automatically.
|
||||
- At 0 HP a monster or NPC is **out of the fight**: see "The fallen" below.
|
||||
- A **character** at 0 HP is **Dying** instead, and its card grows a death-save
|
||||
box: see below.
|
||||
- A character's temporary HP is shown beside its HP but is not used up
|
||||
automatically.
|
||||
|
||||
### A character at 0 HP
|
||||
|
||||
The full rules are in [CHARACTERS.md](CHARACTERS.md), section 7; the box on
|
||||
the card is the same one as on the sheet, and both change the same character.
|
||||
|
||||
- **Dying.** The box shows two tallies, Saved and Failed, three pips each.
|
||||
Type what the d20 showed and click **Death save**: 10 or more is a success,
|
||||
less is a failure, a 1 is two failures, a 20 puts the character back up at
|
||||
1 HP. The DM can do this for anyone; a player for their own character.
|
||||
- **Damage while down** is one failed save, added when the DM clicks Damage.
|
||||
For a critical hit the DM clicks the next Failed pip to add the second.
|
||||
- **Stabilise** stops the saves without a roll (a Medicine check, a spell).
|
||||
Three successes do the same. A stable character stays at 0 HP.
|
||||
- **Heal** on a dying or stable character puts them back up and clears the
|
||||
tallies.
|
||||
- **Dead** after three failures, or when one blow leaves as much as the
|
||||
character's max HP still to take after reaching 0. Healing is refused; the
|
||||
DM clicks **Revive at 1 HP**, or an ally revives them (below).
|
||||
- The DM can click any pip to set a tally by hand.
|
||||
|
||||
Nothing rolls by itself: the app does not prompt for a save when the turn
|
||||
comes round. A dying or stable character keeps its place in the order and
|
||||
still gets its turn.
|
||||
|
||||
### The fallen
|
||||
|
||||
A combatant is **out of the fight** when it is:
|
||||
|
||||
- a **monster or NPC at 0 HP** (one with hit points to track), or
|
||||
- a **dead character**. Dying and stable characters are not: they stay in
|
||||
view.
|
||||
|
||||
What that does:
|
||||
|
||||
- **Players no longer see it.** Its card is gone from their screen, along
|
||||
with any target mark on it; the server does not send it to them at all.
|
||||
- **The DM's screen leaves it out as well**, until the DM ticks **Show dead**
|
||||
above the list (the box shows how many there are, and only appears when
|
||||
there are any). Ticked, they are listed in a group of their own at the
|
||||
bottom, "Out of the fight, hidden from players", each with a "Hidden from
|
||||
players" badge, and everything on the card still works. The tick is not
|
||||
remembered: it is off again when the page is reopened.
|
||||
- **It has no turn.** Next Turn, Next Round and Begin Battle pass over it.
|
||||
- It stays in the encounter: nothing is removed, and gold is still split with
|
||||
a dead character.
|
||||
|
||||
It is worked out from hit points each time, not stored, so it undoes itself:
|
||||
heal a monster or NPC above 0, or revive a character, and they are back on
|
||||
everyone's screen and back in the turn order.
|
||||
|
||||
**Reviving a dead character.** Players see dead characters by name only, in a
|
||||
**☠ Fallen** box above the list. There are two ways back:
|
||||
|
||||
- The **DM** ticks Show dead and clicks **Revive at 1 HP** on the dead
|
||||
character's card (or does it on the sheet), at any time.
|
||||
- An **ally** tries it: in a battle in progress, the player whose character is
|
||||
**on turn** gets a **Try revive** button beside each name in the Fallen box,
|
||||
if that character is capable. Capable means on their feet (above 0 HP) and
|
||||
knowing a spell that is ticked **Revives** in the campaign's spell list
|
||||
([SPELLS.md](SPELLS.md)), at a level they can cast. The library's Revivify
|
||||
(level 3) and Raise Dead (level 5) are ticked already.
|
||||
|
||||
Either way the character comes back at 1 HP with the death saves cleared. The
|
||||
app does not use up the spell, check how long ago they died, or end the
|
||||
reviver's turn.
|
||||
|
||||
## 7. Initiative, turns and rounds
|
||||
|
||||
Battles only.
|
||||
Initiative is entered in battles and quests alike. Turns and rounds are for
|
||||
battles only.
|
||||
|
||||
- Each combatant has an **initiative roll** field. Type what the d20 showed,
|
||||
1 to 20; the field accepts nothing else. The app does not roll the die, but
|
||||
it adds the bonus for you and shows the total: a character's initiative
|
||||
it adds the bonus for you and shows the total, which can go past 20 (a 20 on
|
||||
the die with a +3 bonus is 23): a character's initiative
|
||||
bonus from their sheet, a monster's Dexterity modifier, nothing for an NPC.
|
||||
- The DM can enter the roll for anyone, when adding them or on the card. A
|
||||
player can enter the roll for **their own character** on its card.
|
||||
@@ -173,6 +252,71 @@ Battles only.
|
||||
- **Next Turn** passes the turn to the next in the order. After the last
|
||||
combatant it returns to the top and the **round** goes up by one.
|
||||
- **Next Round** skips straight to the top of the next round.
|
||||
- Anyone out of the fight (section 6, "The fallen") is passed over.
|
||||
- **The list follows the turn.** While a battle is in progress, whoever is on
|
||||
turn is at the top of "Initiative order" with those still to act this round
|
||||
beneath them. Each combatant whose turn has passed moves down to a **Next
|
||||
Round** section at the bottom, still in initiative order. When the round
|
||||
rolls over everyone is back in "Initiative order". Everyone sees the same
|
||||
arrangement. Before the battle begins, and once it is complete, it is one
|
||||
plain list.
|
||||
|
||||
### Targets
|
||||
|
||||
Combatants can be marked as **targets**: whoever is being attacked (or
|
||||
healed) right now. One target is an ordinary attack; more than one is an
|
||||
**area attack**.
|
||||
|
||||
- Click **🎯 Target** beside a combatant's name to mark them; click it again
|
||||
to unmark. Each one marked is added to the targets.
|
||||
- The **DM** can mark anyone at any time, in a battle or a quest. A **player**
|
||||
can while their own character is on turn in a battle, so they can show the
|
||||
DM who they are going for. A player cannot mark someone who is out of the
|
||||
fight, and is not shown a mark the DM leaves on one.
|
||||
- The combatant **on turn** cannot be marked: nobody targets themselves, and
|
||||
their card has no Target button until the turn passes. To heal or hurt them
|
||||
on their own turn, use the buttons on their card.
|
||||
- A target's card is outlined in red for everyone and reads "Targeted by
|
||||
<whoever is on turn>".
|
||||
- While anyone is marked the DM gets an **attack bar** above the list, which
|
||||
stays in view while scrolling. It shows who is attacking whom, each target's
|
||||
AC and HP, and the attacker's attacks: a character's equipped weapons (all
|
||||
of them if none are equipped) and damaging spells, or a monster's or NPC's
|
||||
attack and damage. The amount box is ready to type in; **Damage** (or
|
||||
Enter) and **Heal** change HP exactly as the buttons on a card do.
|
||||
- Targets stay marked after damage, for a second attack. They are cleared
|
||||
when the turn or round moves on, when the encounter begins, ends or is
|
||||
reopened; a target that is removed from the encounter is unmarked.
|
||||
|
||||
**Area attacks**
|
||||
|
||||
An attack on several targets is harder to land and its damage is shared.
|
||||
|
||||
- **Harder to hit.** Every target's AC counts as higher for each target
|
||||
beyond the first, by the campaign's **area attack AC penalty**. The DM sets
|
||||
it in the campaign settings, from 0 (no penalty) to 10; it starts at 2, which
|
||||
gives:
|
||||
|
||||
| Targets | Added to each AC |
|
||||
|---|---|
|
||||
| 1 | 0 |
|
||||
| 2 | +2 |
|
||||
| 3 | +4 |
|
||||
| 4 | +6 |
|
||||
|
||||
The attack bar shows the AC to beat with the penalty already added
|
||||
("17 (15 +2)"), and each target's card says "area attack, AC +2".
|
||||
- **Who was hit.** Each target has a tick box, ticked to begin with. Untick
|
||||
anyone the roll missed; they take nothing.
|
||||
- **Sharing the amount.** Type the total. "Split between those hit" (the
|
||||
default) shares it evenly, any remainder going one point each to the first
|
||||
targets marked: 7 between three is 3, 2 and 2. "Full amount to each" gives
|
||||
every target hit the whole amount. Each target's share is shown before it
|
||||
is applied.
|
||||
- A target with no hit points to track is left out of the sharing.
|
||||
|
||||
The app does not roll the attack or decide whether it hits: the DM compares
|
||||
the roll with the AC shown and types the damage.
|
||||
|
||||
## 8. Conditions
|
||||
|
||||
@@ -192,7 +336,7 @@ The DM lines up loot in the Rewards panel, on any encounter at any time.
|
||||
|
||||
| Reward | What the DM sets |
|
||||
|---|---|
|
||||
| Gold | An amount |
|
||||
| Coins or cash | An amount, and which: gold, silver, copper or cash ($) |
|
||||
| Weapon | A weapon from the pool, a level (not below its starting level), a quantity |
|
||||
| Item | An item from the pool, a level, a quantity |
|
||||
|
||||
@@ -203,9 +347,9 @@ Each can carry a short note ("Goblin chief's purse").
|
||||
| Button | What happens |
|
||||
|---|---|
|
||||
| Award (pick a character) | The whole reward goes to that character. It can be any finished character in the campaign, not only those in the encounter. |
|
||||
| Split (gold only) | Shared evenly between the characters in the encounter. Any remainder goes one gold each, highest initiative first: 101 gold between two is 51 and 50. |
|
||||
| Split (coins and cash only) | Shared evenly between the characters in the encounter. Any remainder goes one each, highest initiative first: 101 gold between two is 51 and 50. |
|
||||
|
||||
- **Gold** is added to the character's balance with a money-log entry reading
|
||||
- **Coins or cash** are added to the character's balance with a money-log entry reading
|
||||
"Reward: <encounter name>".
|
||||
- A **weapon or item** is added to what the character holds, at the reward's
|
||||
level, stacking with any they already have. If the character is below that
|
||||
@@ -216,7 +360,48 @@ Each can carry a short note ("Goblin chief's purse").
|
||||
Until a reward is awarded only the DM can see it. Afterwards players see what
|
||||
it was and who received it.
|
||||
|
||||
## 10. What each side sees
|
||||
## 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:
|
||||
|
||||
- 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
|
||||
the buttons are off.
|
||||
- There is **no trading in a battle**. Switching a quest to a battle hides the
|
||||
Trade card and the server refuses any trade; switching back brings it back.
|
||||
- An NPC trades only while they are **in the scene and not at 0 HP**.
|
||||
- The character trading has to be **in the encounter** and alive.
|
||||
|
||||
The "Trade" card sits beside the scene, above Rewards. It appears for players
|
||||
when there is something to trade, and always for the DM in a quest.
|
||||
|
||||
| Part of the card | What it does |
|
||||
|---|---|
|
||||
| Who is trading | Picks the character. A player chooses among their own characters in the scene; the DM among all of them. Their purse is shown beside it |
|
||||
| 💱 A money changer | One row per NPC in the scene who has an exchange rate. Type an amount, choose "gold into cash" or "cash into gold", and the button shows what comes back |
|
||||
| 🛒 "<NPC> sells" | Everything that NPC has for sale: what it is, its level and dice, the price, and how many are left. "Buy" buys one |
|
||||
| 🛒 "For sale here" | Things sold by the scene itself rather than by anyone in it |
|
||||
| Set up (DM only) | Puts weapons and items from the campaign pools up for sale at this encounter, and edits their price and stock |
|
||||
|
||||
- **Changing money** uses the NPC's own rate, the same both ways, and only
|
||||
gold and cash: no silver, no copper, no change. The amount handed over has
|
||||
to be a whole multiple of that side of the rate. The full rules and
|
||||
examples are in [NPCS.md](NPCS.md), section 5.
|
||||
- **Buying** pays the price from the character's purse (coins make change; a
|
||||
cash price takes cash only), adds the weapon or item to what the character
|
||||
holds at the level it is sold at, and uses up stock. A character below that
|
||||
level holds it but cannot use it yet.
|
||||
- Every trade is written to the character's money log.
|
||||
- **An NPC's rate and wares are set on the campaign page**, under "Trade" on
|
||||
the NPC's row, and follow the NPC into every quest they are in. **An
|
||||
encounter's own wares are set here**, in the DM's part of the card, and
|
||||
exist only in this encounter: a market stall, a vending machine, a chest
|
||||
with a price tag.
|
||||
- Removing an NPC from the scene takes their rate and wares out of it.
|
||||
Deleting the encounter deletes its own wares; what was bought stays bought.
|
||||
|
||||
## 11. What each side sees
|
||||
|
||||
| | DM | Players |
|
||||
|---|---|---|
|
||||
@@ -225,13 +410,16 @@ it was and who received it.
|
||||
| Characters | Full card | Full card |
|
||||
| Monsters | Full stat block | Name, HP, conditions |
|
||||
| NPCs | Everything | Name, role, attitude, description, HP bar |
|
||||
| Monsters and NPCs at 0 HP | With "Show dead" ticked, marked hidden | Nothing |
|
||||
| Dead characters | Full card with "Show dead" ticked, marked hidden | The name only, in the Fallen box |
|
||||
| Rewards | All | Only awarded ones |
|
||||
| Exchange rates and things for sale | Yes | Yes, for NPCs they can see and for the scene itself |
|
||||
| Controls (add, damage, turns, rewards) | Yes | None |
|
||||
|
||||
The server only sends a player what the right-hand column lists, so hidden
|
||||
information cannot be dug out of the page.
|
||||
|
||||
## 11. Permissions
|
||||
## 12. Permissions
|
||||
|
||||
| Action | DM | Players |
|
||||
|---|---|---|
|
||||
@@ -243,20 +431,31 @@ information cannot be dug out of the page.
|
||||
| Add or remove anyone | Yes | No |
|
||||
| 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 |
|
||||
| Enter a death save, or stabilise | Yes, for anyone | For their own character only |
|
||||
| Set death-save tallies by hand | Yes | No |
|
||||
| Revive a dead character outright | Yes | No |
|
||||
| Try to revive a dead character | Yes, for the character on turn | On their own character's turn, if it has a reviving spell |
|
||||
| Advance turns and rounds | Yes | No |
|
||||
| Add, remove or award rewards | Yes | No |
|
||||
| Set exchange rates, and put things up for sale | Yes | No |
|
||||
| Change money or buy, in a quest in progress | Yes, for any character in it | For their own character in it |
|
||||
|
||||
People outside the campaign cannot open its encounters.
|
||||
|
||||
## 12. What is not modelled
|
||||
## 13. What is not modelled
|
||||
|
||||
- Rolling initiative, attacks, damage or saves (use the dice roller, bottom
|
||||
left of every page)
|
||||
left of every page, or in the bottom bar on a phone)
|
||||
- Applying an attack's damage automatically
|
||||
- Death saves and stabilising
|
||||
- Death saves for monsters and NPCs, and prompting for a save on a dying
|
||||
character's turn
|
||||
- A roll, a spell slot or a time limit for reviving the dead
|
||||
- Conditions with durations or mechanical effects
|
||||
- Maps, positions, distances and areas of effect
|
||||
- Experience points and encounter difficulty
|
||||
- Spells as rewards
|
||||
- Spells as rewards, and spells for sale
|
||||
- Selling to an NPC, haggling, and buying more than one of a thing in a
|
||||
single step
|
||||
- Renaming an encounter from the screen
|
||||
- A log of what happened during the encounter
|
||||
|
||||
+13
-3
@@ -15,7 +15,7 @@ level up the same way weapons do), [SPELLS.md](SPELLS.md),
|
||||
4. [Item levels](#4-item-levels)
|
||||
5. [Dice on items](#5-dice-on-items)
|
||||
6. [Starting items](#6-starting-items)
|
||||
7. [Items as rewards](#7-items-as-rewards)
|
||||
7. [Items as rewards and for sale](#7-items-as-rewards-and-for-sale)
|
||||
8. [Permissions](#8-permissions)
|
||||
9. [What is not modelled](#9-what-is-not-modelled)
|
||||
|
||||
@@ -121,13 +121,20 @@ In the builder's equipment step a new character can pick items that are:
|
||||
Starting items and starting weapons share the campaign's "starting items"
|
||||
allowance. Each arrives at level 1, quantity 1, unequipped.
|
||||
|
||||
## 7. Items as rewards
|
||||
## 7. Items as rewards and for sale
|
||||
|
||||
On an encounter the DM can line up an item as a reward, at a chosen level and
|
||||
quantity, and later award it to a character. Awarding adds it to the
|
||||
character's inventory, stacking with any they already hold. See
|
||||
[ENCOUNTERS.md](ENCOUNTERS.md).
|
||||
|
||||
The DM can also put an item **up for sale**, at a level, a price (in gold,
|
||||
silver, copper or cash) and an optional stock. An NPC can sell it, wherever
|
||||
that NPC is met, or an encounter can sell it itself. Players see what is for
|
||||
sale and buy it in an NPC quest that is in progress: the price leaves their
|
||||
purse and the item joins their inventory. See [NPCS.md](NPCS.md), section 5,
|
||||
and [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
|
||||
|
||||
## 8. Permissions
|
||||
|
||||
| Action | DM | Owning player | Other players |
|
||||
@@ -136,6 +143,8 @@ character's inventory, stacking with any they already hold. See
|
||||
| Add, change or remove pool items | Yes | No | No |
|
||||
| Give an item from the pool, or take one away | Yes | No | No |
|
||||
| Give a held item to another character | Yes | Yes | No |
|
||||
| Put an item up for sale | Yes | No | No |
|
||||
| Buy an item that is for sale, in a quest in progress | Yes, for any character in it | Yes, if the character is in it | No |
|
||||
| Change quantity or level | Yes | No | No |
|
||||
| Equip or unequip | Yes | Yes, if the character can use it | No |
|
||||
| See a character's inventory | Yes | Yes | Yes |
|
||||
@@ -146,6 +155,7 @@ character's inventory, stacking with any they already hold. See
|
||||
- Armor and shields as items
|
||||
- Using up items automatically, charges, and attunement
|
||||
- Weight, carrying capacity and containers
|
||||
- Prices, buying and selling (record the gold change in the money log)
|
||||
- A price on the item itself: a price is set each time it is put up for sale
|
||||
- Selling items back (record the gold change in the money log)
|
||||
- Swaps as a single step (each side gives separately), and asking the
|
||||
recipient to accept
|
||||
|
||||
+19
-13
@@ -36,10 +36,10 @@ This describes the code as it stands. The built-in list is
|
||||
| Name | Up to 80 characters |
|
||||
| AC | Armor Class |
|
||||
| Max HP | Hit points each copy starts an encounter with |
|
||||
| Speed | In feet. 30 unless set otherwise |
|
||||
| Speed | Walking speed in feet. 30 unless set otherwise. Other speeds (fly, swim, climb) go in Notes |
|
||||
| Ability scores | STR, DEX, CON, INT, WIS, CHA. 10 unless set otherwise |
|
||||
| Attack bonus | Added to the monster's attack roll |
|
||||
| Damage | Dice for its main attack, e.g. `1d6+2` |
|
||||
| Damage | Dice for its main attack, e.g. `1d6+2`, with the damage type if you like: `1d6+2 necrotic` |
|
||||
| Challenge rating | A rough danger rating, e.g. `1/4`, `2` |
|
||||
| Special trait | A short note such as "Pack Tactics" |
|
||||
| Notes | Anything else: what it attacks with, how it behaves |
|
||||
@@ -52,21 +52,25 @@ from its scores for display only.
|
||||
|
||||
The list is the "Monsters" card on the campaign page (DM only).
|
||||
|
||||
- **Adding by hand:** "+ Add Monster" asks for name, AC, max HP, attack bonus
|
||||
and damage. Speed is set to 30 and every ability score to 10; challenge
|
||||
rating, trait and notes are left empty.
|
||||
- **Adding by hand:** "+ Add Monster" opens the whole stat block: name,
|
||||
challenge rating, AC, HP, speed, attack bonus, damage, the six ability
|
||||
scores, special trait and notes. Only name, AC and HP must be typed; speed
|
||||
starts at 30, attack bonus at 0 and each ability score at 10.
|
||||
- **Adding from the bestiary:** "📚 From Library" copies a complete stat block.
|
||||
- **Changing:** there is no edit form (the API accepts changes, but no screen
|
||||
offers them). Remove and re-add, or add from the library.
|
||||
- **Removing:** the ✕ button, immediately, with no confirmation. Copies
|
||||
- **Changing:** "Edit" on a monster's row opens the same form filled in.
|
||||
Saving changes the template, and copies already in encounters show the new
|
||||
stat block straight away; their current HP is left alone.
|
||||
- The list shows each monster's full stat block: the numbers in columns, and
|
||||
its ability scores, trait and notes under its name.
|
||||
- **Removing:** the ✕ button, after a confirmation. Copies
|
||||
already in encounters stay there with their name and HP, but lose their
|
||||
stat block.
|
||||
|
||||
## 4. The built-in bestiary
|
||||
|
||||
38 monsters, from harmless to dangerous. Each library row shows challenge
|
||||
rating, AC, HP, attack and damage, plus its trait and notes, before you add
|
||||
it. Adding copies the monster into your campaign; the copy is yours, and later
|
||||
38 monsters, from harmless to dangerous, every one with a complete stat
|
||||
block. Each library row shows challenge rating, AC, HP, speed, attack, damage
|
||||
and the six ability scores, plus its trait and notes, before you add it. Adding copies the monster into your campaign; the copy is yours, and later
|
||||
changes to the library do not affect it.
|
||||
|
||||
The full bestiary, weakest first. "Attack" is the bonus added to the
|
||||
@@ -146,8 +150,9 @@ and its notes.
|
||||
This is enforced by the server: stat blocks are never sent to a player's
|
||||
browser.
|
||||
|
||||
Players do see a monster's exact HP numbers. If you would rather keep those
|
||||
secret, that is not currently possible.
|
||||
Players see a monster's exact HP numbers unless the campaign setting "Players
|
||||
see the exact hit points of monsters and NPCs in encounters" is unticked.
|
||||
Then they get the health bar only.
|
||||
|
||||
## 7. Permissions
|
||||
|
||||
@@ -163,6 +168,7 @@ secret, that is not currently possible.
|
||||
## 8. What is not modelled
|
||||
|
||||
- Multiple attacks, spells, legendary actions and resistances (use Notes)
|
||||
- Trade: monsters do not sell things or change money (NPCs do)
|
||||
- Rolling a monster's attacks or damage (use the dice roller)
|
||||
- Saving throw bonuses and skills
|
||||
- Experience points for defeating monsters
|
||||
|
||||
+99
-16
@@ -1,7 +1,8 @@
|
||||
# NPCs
|
||||
|
||||
How non-player characters work in this app: what the DM records about them,
|
||||
how they are brought into a scene, and what players can and cannot see.
|
||||
how they are brought into a scene, what they sell and the rate they change
|
||||
money at, and what players can and cannot see.
|
||||
|
||||
This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
[MONSTERS.md](MONSTERS.md).
|
||||
@@ -12,10 +13,11 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
2. [What an NPC holds](#2-what-an-npc-holds)
|
||||
3. [Managing NPCs](#3-managing-npcs)
|
||||
4. [NPCs in encounters](#4-npcs-in-encounters)
|
||||
5. [What players see](#5-what-players-see)
|
||||
6. [NPC or monster?](#6-npc-or-monster)
|
||||
7. [Permissions](#7-permissions)
|
||||
8. [What is not modelled](#8-what-is-not-modelled)
|
||||
5. [Trade: changing money and selling](#5-trade-changing-money-and-selling)
|
||||
6. [What players see](#6-what-players-see)
|
||||
7. [NPC or monster?](#7-npc-or-monster)
|
||||
8. [Permissions](#8-permissions)
|
||||
9. [What is not modelled](#9-what-is-not-modelled)
|
||||
|
||||
---
|
||||
|
||||
@@ -27,6 +29,8 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
- An NPC does nothing by sitting in the list. Players meet one when the DM
|
||||
**adds it to an encounter**.
|
||||
- NPCs are mainly for talking to. Combat stats are optional.
|
||||
- An NPC can also **trade**: change gold and cash at a rate of their own, and
|
||||
sell weapons and items (section 5).
|
||||
|
||||
## 2. What an NPC holds
|
||||
|
||||
@@ -38,9 +42,11 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
| Description | What the party can see and knows | Everyone |
|
||||
| DM notes | Secrets, motives, what they are hiding | **DM only** |
|
||||
| AC | Optional | DM only |
|
||||
| HP | Optional. Without it the NPC has no health bar | See section 5 |
|
||||
| HP | Optional. Without it the NPC has no health bar | See section 6 |
|
||||
| Attack bonus | Optional | DM only |
|
||||
| Damage | Optional dice, e.g. `1d6` | DM only |
|
||||
| Exchange rate | Optional: so much gold for so much cash (section 5) | Everyone, in an encounter |
|
||||
| Wares | Optional: weapons and items for sale, each with a price (section 5) | Everyone, in an encounter |
|
||||
|
||||
A new NPC is Neutral unless you choose otherwise.
|
||||
|
||||
@@ -48,10 +54,14 @@ A new NPC is Neutral unless you choose otherwise.
|
||||
|
||||
The NPC list is the "NPCs" card on the campaign page (DM only).
|
||||
|
||||
- **Adding:** "+ Add NPC" opens a form with every field above. Only the name
|
||||
is required.
|
||||
- **Changing:** there is no edit form. To change an NPC, remove it and add it
|
||||
again (the API accepts changes, but no screen offers them).
|
||||
- **Adding:** "+ Add NPC" opens a form with every field above except the
|
||||
exchange rate and wares. Only the name is required.
|
||||
- **Trade:** the "Trade" button on an NPC's row opens their exchange rate and
|
||||
their wares (section 5). The row shows a 💱 badge with the rate and a 🛒
|
||||
badge with how many things are for sale.
|
||||
- **Changing:** there is no edit form for the other fields. To change them,
|
||||
remove the NPC and add it again (the API accepts changes, but no screen
|
||||
offers them). Removing an NPC removes their wares too.
|
||||
- **Removing:** the ✕ button, after a confirmation. This also **removes the
|
||||
NPC from every encounter** it was in.
|
||||
|
||||
@@ -73,7 +83,72 @@ An NPC's card shows its attitude as a coloured badge, its role under its
|
||||
name, and its description in full. The DM's card also shows AC, attack,
|
||||
damage and the DM notes.
|
||||
|
||||
## 5. What players see
|
||||
## 5. Trade: changing money and selling
|
||||
|
||||
Only the DM sets trade up. Players see it and use it on the encounter page,
|
||||
in the "Trade" card. The rules for when trading is open are in
|
||||
[ENCOUNTERS.md](ENCOUNTERS.md), section 10: in short, in an **NPC quest that
|
||||
is in progress**, never in a battle, and only with an NPC who is in the scene
|
||||
and not at 0 HP.
|
||||
|
||||
### Changing money
|
||||
|
||||
An NPC with an **exchange rate** changes gold into cash ($) and cash into
|
||||
gold. The rate is two whole numbers, "this much gold = this much cash", and
|
||||
each NPC has their own, so one money changer can be a better deal than
|
||||
another.
|
||||
|
||||
| Rate | Hand over | Get |
|
||||
|---|---|---|
|
||||
| 1 gold = $5 | 3 gold | $15 |
|
||||
| 1 gold = $5 | $20 | 4 gold |
|
||||
| 2 gold = $15 | 6 gold | $45 |
|
||||
| 2 gold = $15 | $30 | 4 gold |
|
||||
|
||||
- The same rate is used both ways.
|
||||
- **Only gold and cash.** Silver and copper are not taken, a gold coin is not
|
||||
broken to make up an amount, and no change is given.
|
||||
- What is handed over has to be a whole multiple of that side of the rate: at
|
||||
2 gold = $15, gold goes in twos and cash in fifteens.
|
||||
- Both sides go on the character's money log, naming the NPC: "Changed with
|
||||
Vex for $45" and "Changed with Vex from 6 gold".
|
||||
- An NPC never runs out of gold or cash to change.
|
||||
- To set a rate, type both numbers under "Trade" and press "Save rate".
|
||||
"Stop changing money" clears it. An NPC with no rate does not change money.
|
||||
|
||||
### Selling
|
||||
|
||||
An NPC's **wares** are weapons and items from the campaign's own pools, each
|
||||
put up for sale with:
|
||||
|
||||
| Field | What it is |
|
||||
|---|---|
|
||||
| What | One weapon or item from the pool |
|
||||
| Level | The gear level it is handed over at, not below its starting level |
|
||||
| Price | For one, in gold, silver, copper or cash |
|
||||
| Stock | How many can be bought. Empty means no limit |
|
||||
|
||||
- Wares belong to the NPC, not to an encounter: they are on offer in every
|
||||
quest that NPC is in.
|
||||
- Buying pays the price from the character's purse, adds the weapon or item
|
||||
to what they hold (stacking with any they already have) and lowers the
|
||||
stock. A price in coins is paid with change, exactly like any other spend;
|
||||
a price in cash takes cash only.
|
||||
- At 0 stock a ware shows as sold out and cannot be bought. It stays listed
|
||||
until the DM removes it.
|
||||
- The ✕ beside a ware stops it being sold. Things already bought stay bought.
|
||||
- "Edit" beside a ware changes its price, the coin (or cash) the price is in,
|
||||
and its stock: restock a sold-out ware, or clear the stock for no limit.
|
||||
What is sold, its level and who sells it cannot be changed; remove the ware
|
||||
and put it up again for that.
|
||||
- Removing a weapon or item from the campaign pool removes it from sale
|
||||
everywhere.
|
||||
|
||||
A stall or a machine with nobody behind it can sell things too: those wares
|
||||
belong to an encounter rather than an NPC, and are set up on the encounter
|
||||
page. See [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
|
||||
|
||||
## 6. What players see
|
||||
|
||||
Players only ever see an NPC through an encounter it is in:
|
||||
|
||||
@@ -81,6 +156,7 @@ Players only ever see an NPC through an encounter it is in:
|
||||
|---|---|
|
||||
| Name, role, attitude, description | DM notes |
|
||||
| Health bar and conditions, if the NPC has HP | AC, attack bonus, damage |
|
||||
| The exchange rate, and everything the NPC has for sale with its price and stock | The NPC list, and wares of NPCs who are not in the scene |
|
||||
|
||||
This is enforced by the server, not just hidden on screen: the hidden fields
|
||||
are never sent to a player's browser.
|
||||
@@ -88,7 +164,7 @@ are never sent to a player's browser.
|
||||
Changing an NPC's attitude changes what players see straight away, so
|
||||
flipping a "Friendly" NPC to "Hostile" is itself a reveal.
|
||||
|
||||
## 6. NPC or monster?
|
||||
## 7. NPC or monster?
|
||||
|
||||
| | NPC | Monster |
|
||||
|---|---|---|
|
||||
@@ -99,11 +175,12 @@ flipping a "Friendly" NPC to "Hostile" is itself a reveal.
|
||||
| Combat stats | Optional, basic | Full stat block |
|
||||
| Can be in an encounter more than once | No | Yes (a pack of goblins) |
|
||||
| Built-in library | No | Yes |
|
||||
| Can change money and sell things | Yes | No |
|
||||
|
||||
A named villain the party will both talk to and fight works well as an NPC
|
||||
with HP, AC, attack and damage filled in.
|
||||
|
||||
## 7. Permissions
|
||||
## 8. Permissions
|
||||
|
||||
| Action | DM | Players |
|
||||
|---|---|---|
|
||||
@@ -111,12 +188,18 @@ with HP, AC, attack and damage filled in.
|
||||
| Add or remove NPCs | Yes | No |
|
||||
| Add an NPC to an encounter | Yes | No |
|
||||
| Change an NPC's HP or conditions in an encounter | Yes | No |
|
||||
| See an NPC in an encounter | Everything | Name, role, attitude, description, health bar |
|
||||
| See an NPC in an encounter | Everything | Name, role, attitude, description, health bar, exchange rate, wares |
|
||||
| Set an NPC's exchange rate | Yes | No |
|
||||
| Put things up for sale, or take them down | Yes | No |
|
||||
| Change money with an NPC, or buy from one | Yes, for any character in the scene | For their own character in the scene |
|
||||
|
||||
## 8. What is not modelled
|
||||
## 9. What is not modelled
|
||||
|
||||
- Ability scores, saves, skills and spells for NPCs
|
||||
- NPC inventory, gold and shops
|
||||
- An NPC's own purse: they never run short of gold or cash
|
||||
- Selling things to an NPC, haggling, and different rates for buying and
|
||||
selling cash
|
||||
- Changing silver or copper for cash
|
||||
- Where an NPC is (they are not tied to locations)
|
||||
- Relationships, reputation and attitude toward individual characters
|
||||
- A record of which NPCs the party has met, outside of encounters
|
||||
|
||||
+4
-3
@@ -46,15 +46,16 @@ The list is the "Spell List" card on the campaign page (DM only).
|
||||
| What it does | One sentence. Shown wherever the spell is shown |
|
||||
| Damage | Picked from a list, or "No damage". Fixed; it does not grow |
|
||||
| Starting option | Whether a brand-new character may pick it in the builder (section 8) |
|
||||
| Revives | Whether it brings back the dead. A character who can cast one may revive a dead ally on their turn in a battle ([ENCOUNTERS.md](ENCOUNTERS.md), section 6). Ticked already on the library's Revivify and Raise Dead |
|
||||
| Casting time, range, duration | Stored for library spells but not shown on any screen |
|
||||
|
||||
A cantrip is a spell of level 0. The app always calls it a **cantrip**, never
|
||||
"level 0" or "level 1".
|
||||
|
||||
- **Adding:** by hand with the "+ Add Spell" form (name, level, school,
|
||||
damage and what it does), or from the library.
|
||||
- **Changing:** damage (the drop-down in the table) and the starting-option
|
||||
tick box. Name, level, school and description cannot be changed on screen
|
||||
damage, what it does and the two tick boxes), or from the library.
|
||||
- **Changing:** damage (the drop-down in the table) and the Revives and
|
||||
starting-option tick boxes. Name, level, school and description cannot be changed on screen
|
||||
once created (the API accepts it); remove and re-add instead.
|
||||
- **Damage choices:** the drop-down offers "No damage", then 1 to 6, 8 or 10
|
||||
dice of d4, d6, d8, d10 or d12 (1d4 up to 10d12), plus the three unusual
|
||||
|
||||
+12
-3
@@ -19,7 +19,7 @@ This describes the code as it stands. The formulas live in
|
||||
6. [How damage grows](#6-how-damage-grows)
|
||||
7. [Attack and damage numbers](#7-attack-and-damage-numbers)
|
||||
8. [Starting weapons](#8-starting-weapons)
|
||||
9. [Weapons as rewards](#9-weapons-as-rewards)
|
||||
9. [Weapons as rewards and for sale](#9-weapons-as-rewards-and-for-sale)
|
||||
10. [Permissions](#10-permissions)
|
||||
11. [What is not modelled](#11-what-is-not-modelled)
|
||||
12. [The complete weapon library](#12-the-complete-weapon-library)
|
||||
@@ -185,13 +185,20 @@ In the builder's equipment step a new character can pick weapons that are:
|
||||
Starting weapons and starting items share the campaign's "starting items"
|
||||
allowance. They arrive at level 1, unequipped.
|
||||
|
||||
## 9. Weapons as rewards
|
||||
## 9. Weapons as rewards and for sale
|
||||
|
||||
On an encounter the DM can line up a weapon as a reward, at a chosen level
|
||||
(not below its starting level) and quantity, and later award it to a
|
||||
character. Awarding adds it to what the character holds, exactly as giving it
|
||||
on the sheet would. See [ENCOUNTERS.md](ENCOUNTERS.md).
|
||||
|
||||
The DM can also put a weapon **up for sale**, at a level, a price (in gold,
|
||||
silver, copper or cash) and an optional stock. An NPC can sell it, wherever
|
||||
that NPC is met, or an encounter can sell it itself. Players see what is for
|
||||
sale and buy it in an NPC quest that is in progress: the price leaves their
|
||||
purse and the weapon joins what they hold, unequipped. See
|
||||
[NPCS.md](NPCS.md), section 5, and [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
|
||||
|
||||
## 10. Permissions
|
||||
|
||||
| Action | DM | Owning player | Other players |
|
||||
@@ -201,6 +208,8 @@ on the sheet would. See [ENCOUNTERS.md](ENCOUNTERS.md).
|
||||
| Add, change or remove pool entries | Yes | No | No |
|
||||
| Give a weapon from the pool, or take one away | Yes | No | No |
|
||||
| Give a held weapon to another character | Yes | Yes | No |
|
||||
| Put a weapon up for sale | Yes | No | No |
|
||||
| Buy a weapon that is for sale, in a quest in progress | Yes, for any character in it | Yes, if the character is in it | No |
|
||||
| Change a held weapon's level or quantity | Yes | No | No |
|
||||
| Equip or unequip | Yes | Yes, if the character can use it | No |
|
||||
| See a character's weapons | Yes | Yes | Yes |
|
||||
@@ -212,7 +221,7 @@ on the sheet would. See [ENCOUNTERS.md](ENCOUNTERS.md).
|
||||
- Range and ammunition
|
||||
- Critical hits, advantage, and rolling the attack itself (use the dice roller)
|
||||
- Magic bonuses such as +1 weapons (use a higher level or growth instead)
|
||||
- Price and weight
|
||||
- A price on the weapon itself (a price is set each time it is put up for sale), and weight
|
||||
|
||||
## 12. The complete weapon library
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.3.0",
|
||||
"version": "1.10.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+145
-5
@@ -1,4 +1,4 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from './client';
|
||||
import type {
|
||||
BuildOptions,
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CampaignMemberSummary,
|
||||
CharacterDetail,
|
||||
CharacterSummary,
|
||||
Currency,
|
||||
DiceMode,
|
||||
Encounter,
|
||||
EncounterMode,
|
||||
@@ -17,6 +18,7 @@ import type {
|
||||
Monster,
|
||||
Note,
|
||||
Npc,
|
||||
SaleOffer,
|
||||
QuickStats,
|
||||
Spell,
|
||||
Weapon,
|
||||
@@ -162,6 +164,28 @@ export function usePatchCharacter(characterId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// What can be done for a character at 0 HP: enter a death save (what the d20
|
||||
// showed), steady them, or — the DM only — set the tallies by hand or bring
|
||||
// back the dead.
|
||||
export type DeathSaveAction =
|
||||
| { action: 'roll'; roll: number }
|
||||
| { action: 'stabilize' }
|
||||
| { action: 'set'; successes: number; failures: number }
|
||||
| { action: 'revive' };
|
||||
|
||||
export function useDeathSave(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: DeathSaveAction) =>
|
||||
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/death-saves`, data)).data.character,
|
||||
onSuccess: (character) => {
|
||||
invalidateCharacter(qc, characterId); // the sheet and its quick stats
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet's HP
|
||||
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCharacter(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -172,7 +196,7 @@ export function useDeleteCharacter(campaignId: string) {
|
||||
|
||||
// What one character can hand to another. Whatever is given leaves the giver.
|
||||
export type Gift =
|
||||
| { kind: 'gold'; amount: number }
|
||||
| { kind: 'gold'; amount: number; currency?: Currency }
|
||||
| { kind: 'weapon' | 'item'; id: string; quantity: number }
|
||||
| { kind: 'spell'; id: string };
|
||||
|
||||
@@ -211,6 +235,16 @@ export function useQuickStats(characterId: string | undefined, enabled: boolean)
|
||||
});
|
||||
}
|
||||
|
||||
// Changes `amount` coins of one kind into another at their fixed worth.
|
||||
export function useExchangeCoins(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { from: Currency; to: Currency; amount: number }) =>
|
||||
(await apiClient.post<{ character: CharacterDetail; received: number }>(`/characters/${characterId}/gold/exchange`, data)).data,
|
||||
onSuccess: () => invalidateCharacter(qc, characterId),
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Character build flow
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -388,8 +422,8 @@ export function useGoldLedger(characterId: string | undefined, enabled = true) {
|
||||
export function useAdjustGold(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { amount: number; reason?: string }) =>
|
||||
(await apiClient.post<{ gold: number; transaction: GoldTransaction }>(`/characters/${characterId}/gold`, data)).data,
|
||||
mutationFn: async (data: { amount: number; currency?: Currency; reason?: string }) =>
|
||||
(await apiClient.post<{ transaction: GoldTransaction }>(`/characters/${characterId}/gold`, data)).data,
|
||||
onSuccess: () => invalidateCharacter(qc, characterId), // prefix match also refreshes the ledger
|
||||
});
|
||||
}
|
||||
@@ -430,9 +464,12 @@ export function usePatchPoolEntry(campaignId: string, pool: Pool) {
|
||||
isStartingOption?: boolean;
|
||||
damageDice?: string | null;
|
||||
damageScaling?: number;
|
||||
revives?: boolean;
|
||||
minLevel?: number;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
exchangeGold?: number | null;
|
||||
exchangeCash?: number | null;
|
||||
}) =>
|
||||
(await apiClient.patch(`/campaigns/${campaignId}/${pool}/${id}`, data)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, pool] }),
|
||||
@@ -503,6 +540,20 @@ export function useCreateMonster(campaignId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Changes a monster's stat block. Encounters read it live, so copies already
|
||||
// in a scene show the new numbers (their HP stays as it was).
|
||||
export function useUpdateMonster(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, ...data }: { id: string } & Partial<Omit<Monster, 'id' | 'campaignId'>>) =>
|
||||
(await apiClient.patch<{ monster: Monster }>(`/campaigns/${campaignId}/monsters/${id}`, data)).data.monster,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'monsters'] });
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMonster(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -717,6 +768,29 @@ export function usePatchEncounter(encounterId: string) {
|
||||
}
|
||||
type EncounterStatusInput = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
|
||||
|
||||
// Sets which combatants are marked as targets (an empty list clears them).
|
||||
// The DM at any time; a player while their own character is on turn.
|
||||
export function useSetTargets(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (participantIds: string[]) => (await apiClient.put(`/encounters/${encounterId}/targets`, { participantIds })).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||
});
|
||||
}
|
||||
|
||||
// The character on turn trying to bring a dead ally back; the server decides
|
||||
// whether they have the means.
|
||||
export function useReviveParticipant(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (participantId: string) => (await apiClient.post(`/encounters/${encounterId}/participants/${participantId}/revive`)).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
|
||||
qc.invalidateQueries({ queryKey: ['character'] }); // the revived character's sheet
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useNextTurn(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -730,7 +804,7 @@ export function useNextTurn(encounterId: string) {
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export type NewReward =
|
||||
| { type: 'GOLD'; gold: number; note?: string }
|
||||
| { type: 'GOLD'; gold: number; currency?: Currency; note?: string }
|
||||
| { type: 'WEAPON'; weaponId: string; quantity?: number; level?: number; note?: string }
|
||||
| { type: 'ITEM'; itemId: string; quantity?: number; level?: number; note?: string };
|
||||
|
||||
@@ -786,6 +860,72 @@ export function useCreateNpc(campaignId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Trade — an NPC's exchange rate and wares are set up by the DM; in a quest
|
||||
// in progress, characters in the scene change money and buy
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export type NewWare = ({ npcId: string } | { encounterId: 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.
|
||||
function invalidateWares(qc: QueryClient, campaignId: string) {
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'npcs'] });
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] });
|
||||
}
|
||||
|
||||
export function useCreateWare(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: NewWare) => (await apiClient.post<{ ware: SaleOffer }>(`/campaigns/${campaignId}/wares`, data)).data.ware,
|
||||
onSuccess: () => invalidateWares(qc, campaignId),
|
||||
});
|
||||
}
|
||||
|
||||
// The price and what is left can change; what is sold, and by whom, cannot.
|
||||
export function usePatchWare(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, ...data }: { id: string; price?: number; currency?: Currency; stock?: number | null }) =>
|
||||
(await apiClient.patch<{ ware: SaleOffer }>(`/campaigns/${campaignId}/wares/${id}`, data)).data.ware,
|
||||
onSuccess: () => invalidateWares(qc, campaignId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWare(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (wareId: string) => apiClient.delete(`/campaigns/${campaignId}/wares/${wareId}`),
|
||||
onSuccess: () => invalidateWares(qc, campaignId),
|
||||
});
|
||||
}
|
||||
|
||||
// Both change what a character holds, so the sheet and the party list refresh too.
|
||||
function invalidateAfterTrade(qc: QueryClient, encounterId: string) {
|
||||
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
|
||||
qc.invalidateQueries({ queryKey: ['character'] });
|
||||
qc.invalidateQueries({ queryKey: ['campaign'] });
|
||||
}
|
||||
|
||||
// Changes gold into cash or cash into gold with an NPC in the scene, at their rate.
|
||||
export function useExchangeWithNpc(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { characterId: string; npcId: string; from: 'GOLD' | 'CASH'; amount: number }) =>
|
||||
(await apiClient.post<{ received: number; currency: Currency }>(`/encounters/${encounterId}/exchange`, data)).data,
|
||||
onSuccess: () => invalidateAfterTrade(qc, encounterId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useBuyWare(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ wareId, ...data }: { wareId: string; characterId: string; quantity?: number }) =>
|
||||
(await apiClient.post(`/encounters/${encounterId}/wares/${wareId}/buy`, data)).data,
|
||||
onSuccess: () => invalidateAfterTrade(qc, encounterId),
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Notes
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -93,6 +93,8 @@ export interface Campaign {
|
||||
startingSpellCount: number;
|
||||
skipMotivationRoll: boolean;
|
||||
showMonsterHp: boolean; // off: players get a health bar for monsters and NPCs, no numbers
|
||||
// Added to each target's AC for every target beyond the first in an area attack; 0 is off.
|
||||
areaAttackAcPenalty: number;
|
||||
ownerId: string;
|
||||
archivedAt: string | null; // set while the campaign is archived
|
||||
role: 'DM' | 'PLAYER';
|
||||
@@ -105,6 +107,22 @@ export interface CampaignMemberSummary {
|
||||
role: 'DM' | 'PLAYER';
|
||||
}
|
||||
|
||||
// The coins a character carries. 1 gold = 10 silver = 100 copper.
|
||||
export type Coin = 'GOLD' | 'SILVER' | 'COPPER';
|
||||
// Coins, plus cash ($): modern money for crossover worlds, with no worth in coins.
|
||||
export type Currency = Coin | 'CASH';
|
||||
|
||||
// Where a character stands at 0 HP. UP: on their feet. DYING: rolling death
|
||||
// saves, three of either kind settles it. STABLE: at 0 HP but no longer
|
||||
// rolling. DEAD: three failures, or one blow too big to survive.
|
||||
export type LifeState = 'UP' | 'DYING' | 'STABLE' | 'DEAD';
|
||||
|
||||
export interface DeathSaves {
|
||||
state: LifeState;
|
||||
successes: number;
|
||||
failures: number;
|
||||
}
|
||||
|
||||
export interface CharacterSummary {
|
||||
id: string;
|
||||
campaignId: string;
|
||||
@@ -118,7 +136,12 @@ export interface CharacterSummary {
|
||||
buildStage: BuildStage;
|
||||
currentHp: number;
|
||||
tempHp: number;
|
||||
gold: number | null; // null for another player's character
|
||||
deathSaves: DeathSaves;
|
||||
// The purse; all four are null for another player's character.
|
||||
gold: number | null;
|
||||
silver: number | null;
|
||||
copper: number | null;
|
||||
cash: number | null;
|
||||
maxHp: number | null;
|
||||
armorClass: number | null;
|
||||
currentLocationId: string | null;
|
||||
@@ -195,6 +218,7 @@ export interface Spell {
|
||||
description: string | null;
|
||||
damageDice: string | null;
|
||||
isStartingOption: boolean;
|
||||
revives: boolean; // brings back the dead: lets its caster revive a dead ally in a battle
|
||||
}
|
||||
|
||||
export interface CharacterSpell {
|
||||
@@ -233,8 +257,9 @@ export interface InventoryItem {
|
||||
export interface GoldTransaction {
|
||||
id: string;
|
||||
characterId: string;
|
||||
currency: Currency; // which coin this entry is in
|
||||
amount: number; // signed: positive = gained, negative = spent
|
||||
balanceAfter: number;
|
||||
balanceAfter: number; // that coin's balance afterwards
|
||||
reason: string | null;
|
||||
createdAt: string;
|
||||
createdBy: { displayName: string } | null;
|
||||
@@ -267,6 +292,7 @@ export interface CharacterDetail {
|
||||
rolledScores: Record<Ability, number>;
|
||||
currentHp: number;
|
||||
tempHp: number;
|
||||
deathSaves: DeathSaves;
|
||||
armorBonus: number;
|
||||
specialAbilities: string | null;
|
||||
background: string | null;
|
||||
@@ -278,6 +304,9 @@ export interface CharacterDetail {
|
||||
currentLocationId: string | null;
|
||||
currentLocation: Location | null;
|
||||
gold: number | null;
|
||||
silver: number | null;
|
||||
copper: number | null;
|
||||
cash: number | null;
|
||||
// True when this is another player's character: no gold, inventory or
|
||||
// location is sent, only what the party can see across the table.
|
||||
restricted: boolean;
|
||||
@@ -315,6 +344,23 @@ export type EncounterStatus = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
|
||||
export type EncounterMode = 'BATTLE' | 'QUEST';
|
||||
export type NpcDisposition = 'FRIENDLY' | 'NEUTRAL' | 'HOSTILE';
|
||||
|
||||
// Something for sale: a weapon or item from the campaign pools, at a price.
|
||||
// It is an NPC's (on offer wherever they are met) or an encounter's own.
|
||||
export interface SaleOffer {
|
||||
id: string;
|
||||
campaignId: string;
|
||||
npcId: string | null;
|
||||
encounterId: string | null;
|
||||
weaponId: string | null;
|
||||
itemId: string | null;
|
||||
price: number; // for one, in `currency`
|
||||
currency: Currency;
|
||||
level: number; // the gear level it is handed over at
|
||||
stock: number | null; // how many are left; null is no limit
|
||||
weapon: Weapon | null;
|
||||
item: Item | null;
|
||||
}
|
||||
|
||||
// What players see of an NPC in an encounter.
|
||||
export interface NpcPublic {
|
||||
id: string;
|
||||
@@ -322,6 +368,11 @@ export interface NpcPublic {
|
||||
role: string | null;
|
||||
disposition: NpcDisposition;
|
||||
description: string | null;
|
||||
// A money changer's rate: exchangeGold gold for exchangeCash cash, either
|
||||
// way. Both null for an NPC who does not change money.
|
||||
exchangeGold: number | null;
|
||||
exchangeCash: number | null;
|
||||
wares?: SaleOffer[]; // what they have for sale
|
||||
}
|
||||
|
||||
// The DM's full record. dmNotes and the combat stats never reach a player.
|
||||
@@ -337,7 +388,8 @@ export interface EncounterReward {
|
||||
id: string;
|
||||
encounterId: string;
|
||||
type: 'GOLD' | 'WEAPON' | 'ITEM';
|
||||
gold: number | null;
|
||||
gold: number | null; // for a GOLD reward: how many coins...
|
||||
currency: Currency; // ...and which coin
|
||||
weaponId: string | null;
|
||||
itemId: string | null;
|
||||
quantity: number;
|
||||
@@ -376,6 +428,8 @@ export interface CharacterCombatInfo {
|
||||
characterClass: CharacterClass | null;
|
||||
armorClass: number;
|
||||
tempHp: number;
|
||||
deathSaves: DeathSaves;
|
||||
canRevive: boolean; // has a spell that brings back the dead, at a level they can cast
|
||||
speed: number;
|
||||
initiativeBonus: number;
|
||||
passivePerception: number;
|
||||
@@ -407,6 +461,9 @@ export interface EncounterParticipant {
|
||||
conditions: string[];
|
||||
isMonster: boolean;
|
||||
kind: 'CHARACTER' | 'MONSTER' | 'NPC';
|
||||
// Out of the fight (a dead character, or a monster or NPC at 0 HP) and so
|
||||
// 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;
|
||||
// The DM gets the whole stat block; players only the id and name.
|
||||
@@ -423,6 +480,9 @@ export interface Encounter {
|
||||
status: EncounterStatus;
|
||||
currentRound: number;
|
||||
activeParticipantId: string | null;
|
||||
// The combatants marked as targets right now, for everyone to see, in the
|
||||
// order they were picked. More than one is an area attack.
|
||||
targetParticipantIds: string[];
|
||||
description: string | null;
|
||||
dmNotes?: string | null; // DM only
|
||||
// Where the scene is. A player is only told once their character has been there.
|
||||
@@ -431,8 +491,12 @@ export interface Encounter {
|
||||
archivedAt: string | null; // archived encounters only reach the DM's list
|
||||
sortOrder: number; // place in the campaign's own order, lowest first
|
||||
createdAt: string;
|
||||
participants?: EncounterParticipant[];
|
||||
participants?: EncounterParticipant[]; // a player's list leaves out the fallen
|
||||
// The dead characters in the scene. Players do not get their cards, only
|
||||
// this, so that an ally can try to revive them.
|
||||
fallen?: { participantId: string; characterId: string; name: string }[];
|
||||
rewards?: EncounterReward[]; // players only receive the awarded ones
|
||||
wares?: SaleOffer[]; // for sale at the scene itself, not by an NPC in it
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
@@ -468,6 +532,7 @@ export interface LibrarySpell {
|
||||
duration: string | null;
|
||||
description: string | null;
|
||||
damageDice?: string; // default damage; absent for spells that deal none
|
||||
revives?: boolean; // brings back the dead
|
||||
classes: CharacterClass[];
|
||||
}
|
||||
|
||||
@@ -514,6 +579,7 @@ export interface QuickStats {
|
||||
currentHp: number;
|
||||
maxHp: number;
|
||||
tempHp: number;
|
||||
deathSaves: DeathSaves;
|
||||
armorClass: number;
|
||||
initiative: number;
|
||||
speed: number;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState } from 'react';
|
||||
import { useDeathSave } from '../api/queries';
|
||||
import { InfoTip } from './InfoTip';
|
||||
import { NumberInput } from './NumberInput';
|
||||
import type { DeathSaves as DeathSavesInfo } from '../api/types';
|
||||
|
||||
const STATE_LABELS = { DYING: 'Dying', STABLE: 'Stable', DEAD: '☠ Dead' } as const;
|
||||
|
||||
// Nothing for a character on their feet; otherwise where they stand at 0 HP.
|
||||
export function LifeBadge({ deathSaves }: { deathSaves: DeathSavesInfo }) {
|
||||
if (deathSaves.state === 'UP') return null;
|
||||
return <span className={`badge ${deathSaves.state === 'STABLE' ? '' : 'badge-hostile'}`}>{STATE_LABELS[deathSaves.state]}</span>;
|
||||
}
|
||||
|
||||
// Three pips for one tally. For the DM each is a button: clicking a pip sets
|
||||
// the tally to it, and clicking the last filled one takes it back off.
|
||||
function Pips({ kind, count, onSet }: { kind: 'success' | 'failure'; count: number; onSet?: (count: number) => void }) {
|
||||
const noun = kind === 'success' ? 'successes' : 'failures';
|
||||
return (
|
||||
<span className="death-pips" role="group" aria-label={`${count} of 3 ${noun}`}>
|
||||
{[1, 2, 3].map((n) => {
|
||||
const className = `death-pip is-${kind}${n <= count ? ' is-filled' : ''}`;
|
||||
return onSet ? (
|
||||
<button key={n} type="button" className={className} aria-label={`Set ${noun} to ${n === count ? n - 1 : n}`} onClick={() => onSet(n === count ? n - 1 : n)} />
|
||||
) : (
|
||||
<span key={n} className={className} />
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// What happens to a character at 0 HP: the death-save tallies, and the ways
|
||||
// out of them. `canAct` is the character's own player or the DM; setting the
|
||||
// tallies by hand and raising the dead from here are the DM's alone.
|
||||
export function DeathSaves({
|
||||
characterId,
|
||||
name,
|
||||
deathSaves,
|
||||
canAct,
|
||||
isDM,
|
||||
onError,
|
||||
}: {
|
||||
characterId: string;
|
||||
name: string;
|
||||
deathSaves: DeathSavesInfo;
|
||||
canAct: boolean;
|
||||
isDM: boolean;
|
||||
onError?: (err: unknown) => void;
|
||||
}) {
|
||||
const deathSave = useDeathSave(characterId);
|
||||
const [roll, setRoll] = useState('');
|
||||
const { state, successes, failures } = deathSaves;
|
||||
if (state === 'UP') return null;
|
||||
|
||||
const send = (action: Parameters<typeof deathSave.mutate>[0]) => deathSave.mutate(action, { onError });
|
||||
|
||||
function handleRoll() {
|
||||
const value = Number(roll);
|
||||
if (!roll || !Number.isInteger(value)) return;
|
||||
send({ action: 'roll', roll: value });
|
||||
setRoll('');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`death-saves is-${state.toLowerCase()}`}>
|
||||
<div className="flex-row flex-wrap">
|
||||
<LifeBadge deathSaves={deathSaves} />
|
||||
{state === 'DYING' && (
|
||||
<>
|
||||
<span className="death-tally">
|
||||
Saved <Pips kind="success" count={successes} onSet={isDM ? (n) => send({ action: 'set', successes: n, failures }) : undefined} />
|
||||
</span>
|
||||
<span className="death-tally">
|
||||
Failed <Pips kind="failure" count={failures} onSet={isDM ? (n) => send({ action: 'set', successes, failures: n }) : undefined} />
|
||||
</span>
|
||||
<InfoTip term="DEATH_SAVES" />
|
||||
</>
|
||||
)}
|
||||
{state === 'STABLE' && <span className="combatant-sub">Out cold at 0 HP, but no longer rolling. Any healing wakes them; damage starts the saves again.</span>}
|
||||
{state === 'DEAD' && <span className="combatant-sub">{isDM ? 'Healing does nothing now.' : 'The DM can bring them back, or an ally with a reviving spell on their turn in a battle.'}</span>}
|
||||
</div>
|
||||
|
||||
{state === 'DYING' && canAct && (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={20}
|
||||
placeholder="d20"
|
||||
aria-label={`Death save roll for ${name}`}
|
||||
title="Type what the d20 showed (1 to 20)."
|
||||
value={roll}
|
||||
onChange={(e) => setRoll(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRoll()}
|
||||
style={{ width: 64 }}
|
||||
/>
|
||||
<button className="btn" onClick={handleRoll} disabled={!roll || deathSave.isPending}>
|
||||
Death save
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
title="Someone steadied them: a Medicine check, a healer's kit or a spell. They stay at 0 HP but stop rolling."
|
||||
onClick={() => send({ action: 'stabilize' })}
|
||||
disabled={deathSave.isPending}
|
||||
>
|
||||
Stabilise
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'DEAD' && isDM && (
|
||||
<button className="btn mt-sm" onClick={() => send({ action: 'revive' })} disabled={deathSave.isPending}>
|
||||
Revive at 1 HP
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useId, useState } from 'react';
|
||||
import { useEffect, useId, useRef, useState, type CSSProperties } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
// Short explanations for the stat labels sprinkled across the sheet, quick
|
||||
// stats, party table, and builder — "what is this, and how is it worked
|
||||
@@ -7,6 +8,7 @@ export const STAT_HELP: Record<string, string> = {
|
||||
AC: 'Armor Class — how hard you are to hit. 10 + your Dexterity modifier + any armor bonus.',
|
||||
HP: 'Hit Points — damage you can take before falling unconscious. Max HP is set by your class hit die, Constitution modifier, and level.',
|
||||
TEMP_HP: 'Temporary Hit Points — an extra buffer that absorbs damage first and disappears when it runs out (it does not stack with more temp HP).',
|
||||
DEATH_SAVES: 'Death saves — at 0 HP you roll a d20 at the start of each of your turns. 10 or more is a success, less is a failure; a 1 counts as two failures and a 20 puts you back up with 1 HP. Three successes and you are stable; three failures and you die. Being hit while down is a failure (two if it was a critical hit). Any healing ends it.',
|
||||
INITIATIVE: 'Initiative — added to a d20 roll at the start of a fight to decide turn order. Equal to your Dexterity modifier.',
|
||||
PROFICIENCY_BONUS: 'Proficiency Bonus — added to attacks, saves, and skills you are trained in. Grows with level (+2 at level 1, up to +6 at level 20).',
|
||||
PASSIVE_PERCEPTION: 'Passive Perception — your always-on alertness score, used instead of rolling when you are not actively searching. 10 + your Perception skill bonus.',
|
||||
@@ -38,8 +40,36 @@ export const STAT_HELP: Record<string, string> = {
|
||||
// (a species, a skill, an ability) without adding it to the shared list.
|
||||
export function InfoTip({ term, text: custom, label = 'What is this?' }: { term?: keyof typeof STAT_HELP; text?: string; label?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [place, setPlace] = useState<CSSProperties | null>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const id = useId();
|
||||
const text = custom ?? (term ? STAT_HELP[term] : undefined);
|
||||
|
||||
// The bubble is drawn on the page itself, fixed to where the ⓘ is on
|
||||
// screen, rather than inside whatever holds the ⓘ: a scrolling table or a
|
||||
// drawer would otherwise cut it off. It sits above the ⓘ when there is room
|
||||
// and below when there isn't, and never runs off the sides.
|
||||
useEffect(() => {
|
||||
if (!open || !trigger.current) return;
|
||||
const rect = trigger.current.getBoundingClientRect();
|
||||
const halfWidth = Math.min(130, window.innerWidth / 2 - 8);
|
||||
const centre = Math.min(Math.max(rect.left + rect.width / 2, halfWidth + 8), window.innerWidth - halfWidth - 8);
|
||||
const above = rect.top > 160;
|
||||
setPlace({
|
||||
left: centre,
|
||||
...(above ? { bottom: window.innerHeight - rect.top + 8 } : { top: rect.bottom + 8 }),
|
||||
});
|
||||
// Its position is only right until the page moves.
|
||||
const close = () => setOpen(false);
|
||||
window.addEventListener('scroll', close, true);
|
||||
window.addEventListener('resize', close);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', close, true);
|
||||
window.removeEventListener('resize', close);
|
||||
setPlace(null); // so the next opening is measured afresh, not shown where the last one was
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
return (
|
||||
@@ -49,6 +79,7 @@ export function InfoTip({ term, text: custom, label = 'What is this?' }: { term?
|
||||
className="info-tip-trigger"
|
||||
aria-describedby={id}
|
||||
aria-label={label}
|
||||
ref={trigger}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
@@ -56,11 +87,14 @@ export function InfoTip({ term, text: custom, label = 'What is this?' }: { term?
|
||||
>
|
||||
ⓘ
|
||||
</button>
|
||||
{open && (
|
||||
<span role="tooltip" id={id} className="info-tip-bubble">
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
{open &&
|
||||
place &&
|
||||
createPortal(
|
||||
<span role="tooltip" id={id} className="info-tip-bubble" style={place}>
|
||||
{text}
|
||||
</span>,
|
||||
document.body,
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// A row of links to the sections of a long page. It finds them itself: any
|
||||
// element with a `data-jump="Label"` attribute that is on the page right now
|
||||
// gets a link, so sections only some people see need no special handling.
|
||||
export function JumpNav() {
|
||||
const [labels, setLabels] = useState<string[]>([]);
|
||||
|
||||
// Sections come and go as data loads, so the page is watched for them.
|
||||
useEffect(() => {
|
||||
const look = () => {
|
||||
const found = [...document.querySelectorAll<HTMLElement>('[data-jump]')].map((el) => el.dataset.jump ?? '');
|
||||
setLabels((current) => (current.join('|') === found.join('|') ? current : found));
|
||||
};
|
||||
const observer = new MutationObserver(look);
|
||||
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-jump'] });
|
||||
look();
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
if (labels.length < 3) return null;
|
||||
return (
|
||||
<nav className="jump-nav" aria-label="Sections of this page">
|
||||
{labels.map((label) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className="jump-link"
|
||||
onClick={() => document.querySelector(`[data-jump="${label}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,39 @@
|
||||
import { Link, Outlet } from 'react-router-dom';
|
||||
import { Link, NavLink, Outlet, useMatch } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
|
||||
import { DiceRoller } from './DiceRoller';
|
||||
|
||||
export function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
// The campaign pages say which campaign they are in their address; sheets
|
||||
// and encounters report theirs themselves (useRememberCampaign).
|
||||
const onCampaign = useMatch('/campaigns/:campaignId/*');
|
||||
useRememberCampaign(onCampaign?.params.campaignId);
|
||||
const campaignId = useLastCampaignId();
|
||||
|
||||
// The same destinations twice: along the top bar on a wide screen, and as a
|
||||
// bar along the bottom, in reach of a thumb, on a phone.
|
||||
const destinations = [
|
||||
{ to: '/campaigns', end: true, icon: '🗺', label: 'Campaigns' },
|
||||
...(campaignId
|
||||
? [
|
||||
{ to: `/campaigns/${campaignId}`, end: true, icon: '🏰', label: 'Campaign' },
|
||||
{ to: `/campaigns/${campaignId}/party`, end: true, icon: '👥', label: 'Party' },
|
||||
]
|
||||
: []),
|
||||
{ to: '/account', end: true, icon: '👤', label: 'Account' },
|
||||
];
|
||||
const links = (className: string) =>
|
||||
destinations.map((d) => (
|
||||
<NavLink key={d.to} to={d.to} end={d.end} className={({ isActive }) => `${className}${isActive ? ' is-active' : ''}`}>
|
||||
<span className="nav-icon" aria-hidden>
|
||||
{d.icon}
|
||||
</span>
|
||||
<span>{d.label}</span>
|
||||
</NavLink>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="topbar">
|
||||
@@ -14,22 +43,26 @@ export function Layout() {
|
||||
</span>
|
||||
The Sunken Reach
|
||||
</Link>
|
||||
<nav className="topbar-nav">
|
||||
{user && (
|
||||
{user && (
|
||||
<nav className="topbar-nav" aria-label="Main">
|
||||
<div className="topbar-links">{links('topbar-link')}</div>
|
||||
<div className="topbar-user">
|
||||
<Link to="/account" title="Account settings">
|
||||
{user.displayName}
|
||||
</Link>
|
||||
<span className="topbar-name">{user.displayName}</span>
|
||||
<button className="btn btn-ghost" onClick={logout}>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
<main className="page">
|
||||
<Outlet />
|
||||
</main>
|
||||
{user && (
|
||||
<nav className="bottom-nav" aria-label="Main">
|
||||
{links('bottom-nav-link')}
|
||||
</nav>
|
||||
)}
|
||||
<DiceRoller />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuickStats } from '../api/queries';
|
||||
import { Spinner, ErrorBanner } from './Spinner';
|
||||
import { fmtMod } from '../lib/format';
|
||||
import { HpBar } from './HpBar';
|
||||
import { DeathSaves } from './DeathSaves';
|
||||
|
||||
export function QuickStatsFab({ characterId }: { characterId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -52,6 +53,8 @@ export function QuickStatsDrawer({ characterId, onClose }: { characterId: string
|
||||
</div>
|
||||
</div>
|
||||
<HpBar current={data.currentHp} max={data.maxHp} temp={data.tempHp} />
|
||||
{/* Read-only here; the saves are rolled on the sheet or in the encounter. */}
|
||||
<DeathSaves characterId={characterId} name={data.name} deathSaves={data.deathSaves} canAct={false} isDM={false} />
|
||||
|
||||
{data.spellcasting && (
|
||||
<div className="card mt-md">
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useBuyWare, useExchangeWithNpc } 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, Item, SaleOffer, Weapon } from '../api/types';
|
||||
|
||||
type ExchangeSide = 'GOLD' | 'CASH';
|
||||
|
||||
// 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
|
||||
// own character, the DM for anyone. Trading is open only while the quest is in
|
||||
// progress, and there is none in a battle. The DM sets it all up: an NPC's
|
||||
// rate and wares on the campaign page, the scene's own wares here.
|
||||
export function TradePanel({
|
||||
encounter,
|
||||
isDM,
|
||||
userId,
|
||||
characters,
|
||||
weapons,
|
||||
items,
|
||||
onError,
|
||||
}: {
|
||||
encounter: Encounter;
|
||||
isDM: boolean;
|
||||
userId: string | undefined;
|
||||
characters: CharacterSummary[];
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const exchange = useExchangeWithNpc(encounter.id);
|
||||
const buy = useBuyWare(encounter.id);
|
||||
|
||||
const [traderId, setTraderId] = useState('');
|
||||
const [amounts, setAmounts] = useState<Record<string, string>>({});
|
||||
const [sides, setSides] = useState<Record<string, ExchangeSide>>({});
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
|
||||
if (encounter.mode !== 'QUEST') return null;
|
||||
|
||||
const here = (encounter.participants ?? []).filter((p) => !p.hidden);
|
||||
const npcs = here.flatMap((p) => (p.npc ? [p.npc] : []));
|
||||
const changers = npcs.filter((npc) => npc.exchangeGold && npc.exchangeCash);
|
||||
const stalls: [string, string, SaleOffer[]][] = [
|
||||
...npcs.map((npc): [string, string, SaleOffer[]] => [npc.id, `${npc.name} sells`, npc.wares ?? []]),
|
||||
['here', 'For sale here', encounter.wares ?? []],
|
||||
];
|
||||
const openStalls = stalls.filter(([, , wares]) => wares.length > 0);
|
||||
if (!isDM && changers.length === 0 && openStalls.length === 0) return null;
|
||||
|
||||
// Who can do the trading: a player's own characters in the scene, or for the DM anyone's.
|
||||
const traders = here.flatMap((p) => (p.character && (isDM || p.character.userId === userId) ? [p.character] : []));
|
||||
const trader = traders.find((c) => c.id === traderId) ?? traders[0];
|
||||
const purse = characters.find((c) => c.id === trader?.id);
|
||||
const open = encounter.status === 'ACTIVE';
|
||||
const busy = exchange.isPending || buy.isPending;
|
||||
|
||||
function handleExchange(npc: (typeof changers)[number]) {
|
||||
const from = sides[npc.id] ?? 'GOLD';
|
||||
const amount = Math.floor(Number(amounts[npc.id]));
|
||||
if (!trader || !(amount > 0)) return;
|
||||
setDone(null);
|
||||
exchange.mutate(
|
||||
{ characterId: trader.id, npcId: npc.id, from, amount },
|
||||
{
|
||||
onError,
|
||||
onSuccess: ({ received, currency }) => {
|
||||
setDone(`${trader.name} changed ${formatMoney(amount, from, true)} for ${formatMoney(received, currency, true)} with ${npc.name}.`);
|
||||
setAmounts((a) => ({ ...a, [npc.id]: '' }));
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function handleBuy(ware: SaleOffer) {
|
||||
if (!trader) return;
|
||||
setDone(null);
|
||||
buy.mutate(
|
||||
{ wareId: ware.id, characterId: trader.id },
|
||||
{ onError, onSuccess: () => setDone(`${trader.name} bought ${(ware.weapon ?? ware.item)?.name} for ${formatMoney(ware.price, ware.currency, true)}.`) }
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-title">Trade</div>
|
||||
{!open && (
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
{encounter.status === 'PLANNING' ? 'Trading opens once the quest is in progress.' : 'The quest is over; trading is closed.'}
|
||||
</p>
|
||||
)}
|
||||
{traders.length > 0 && (
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
<select aria-label="Who is trading" value={trader?.id ?? ''} onChange={(e) => setTraderId(e.target.value)} style={{ flex: 1, minWidth: 110 }}>
|
||||
{traders.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{purse && purse.gold !== null && <span className="mono">{formatPurse(purse)}</span>}
|
||||
</div>
|
||||
)}
|
||||
{traders.length === 0 && (changers.length > 0 || openStalls.length > 0) && (
|
||||
<p className="combatant-sub">{isDM ? 'Add a character to the scene to trade for them.' : 'Your character has to be in the scene to trade.'}</p>
|
||||
)}
|
||||
{done && <p className="combatant-sub">✓ {done}</p>}
|
||||
|
||||
{changers.map((npc) => {
|
||||
const from = sides[npc.id] ?? 'GOLD';
|
||||
const to = from === 'GOLD' ? 'CASH' : 'GOLD';
|
||||
const rate = { GOLD: npc.exchangeGold ?? 1, CASH: npc.exchangeCash ?? 1 };
|
||||
const amount = Math.floor(Number(amounts[npc.id]));
|
||||
const whole = amount > 0 && amount % rate[from] === 0;
|
||||
return (
|
||||
<div key={npc.id} className="reward-row">
|
||||
<strong>💱 {npc.name}</strong>
|
||||
<span className="combatant-sub">
|
||||
{' '}
|
||||
changes {formatMoney(rate.GOLD, 'GOLD', true)} for {formatMoney(rate.CASH, 'CASH')}, either way. No silver or copper.
|
||||
</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 || !trader || !whole || busy} onClick={() => handleExchange(npc)}>
|
||||
{whole ? `Get ${formatMoney((amount / rate[from]) * rate[to], to, true)}` : 'Exchange'}
|
||||
</button>
|
||||
</div>
|
||||
{amount > 0 && !whole && (
|
||||
<div className="combatant-sub">
|
||||
Hand over {from.toLowerCase()} in multiples of {rate[from]}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{openStalls.map(([key, label, wares]) => (
|
||||
<div key={key} className="mt-sm">
|
||||
<div className="section-label">🛒 {label}</div>
|
||||
{wares.map((ware) => (
|
||||
<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 || !trader || ware.stock === 0 || busy} onClick={() => handleBuy(ware)}>
|
||||
Buy · {formatMoney(ware.price, ware.currency)}
|
||||
</button>
|
||||
</div>
|
||||
{ware.item?.description && <div className="combatant-sub">{ware.item.description}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isDM && (
|
||||
<div className="mt-md">
|
||||
<div className="section-label">Set up: for sale at this encounter</div>
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
For a stall or a machine with nobody behind it. An NPC's wares and exchange rate are set under "Trade" on the{' '}
|
||||
<Link to={`/campaigns/${encounter.campaignId}`}>campaign page</Link>, and follow the NPC into every quest.
|
||||
</p>
|
||||
<WaresEditor
|
||||
campaignId={encounter.campaignId}
|
||||
seller={{ encounterId: encounter.id }}
|
||||
wares={encounter.wares ?? []}
|
||||
weapons={weapons}
|
||||
items={items}
|
||||
onError={onError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState } from 'react';
|
||||
import { useCreateWare, useDeleteWare, usePatchWare } from '../api/queries';
|
||||
import { NumberInput } from './NumberInput';
|
||||
import { COIN_LABELS, CURRENCIES, formatMoney } from '../lib/format';
|
||||
import { scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
import { stockLabel, wareName } from '../lib/wares';
|
||||
import type { Currency, Item, SaleOffer, Weapon } from '../api/types';
|
||||
|
||||
// One ware in the DM's list. "Edit" swaps the line for its price and stock.
|
||||
function WareRow({ campaignId, ware, onError }: { campaignId: string; ware: SaleOffer; onError: (err: unknown) => void }) {
|
||||
const patchWare = usePatchWare(campaignId);
|
||||
const deleteWare = useDeleteWare(campaignId);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [price, setPrice] = useState('');
|
||||
const [currency, setCurrency] = useState<Currency>(ware.currency);
|
||||
const [stock, setStock] = useState('');
|
||||
|
||||
function startEditing() {
|
||||
setPrice(String(ware.price));
|
||||
setCurrency(ware.currency);
|
||||
setStock(ware.stock === null ? '' : String(ware.stock));
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const amount = Math.floor(Number(price));
|
||||
if (!(amount > 0)) return;
|
||||
patchWare.mutate({ id: ware.id, price: amount, currency, stock: stock === '' ? null : Number(stock) }, { onError, onSuccess: () => setEditing(false) });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '0.3rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
<div className="flex-between">
|
||||
<span>
|
||||
{wareName(ware)} — <strong className="mono">{formatMoney(ware.price, ware.currency)}</strong>
|
||||
<span className="combatant-sub"> · {stockLabel(ware)}</span>
|
||||
</span>
|
||||
<span className="flex-row">
|
||||
<button className="btn btn-ghost" aria-label={`Edit the price and stock of ${wareName(ware)}`} onClick={() => (editing ? setEditing(false) : startEditing())}>
|
||||
{editing ? 'Cancel' : 'Edit'}
|
||||
</button>
|
||||
<button className="btn btn-ghost" aria-label={`Stop selling ${wareName(ware)}`} onClick={() => deleteWare.mutate(ware.id, { onError })}>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{editing && (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<NumberInput min={1} max={1000000} placeholder="Price" aria-label="Price for one" value={price} onChange={(e) => setPrice(e.target.value)} style={{ width: 90 }} />
|
||||
<select aria-label="Price is in" value={currency} onChange={(e) => setCurrency(e.target.value as Currency)}>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{COIN_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<NumberInput
|
||||
min={0}
|
||||
max={9999}
|
||||
placeholder="Stock (empty = no limit)"
|
||||
aria-label="How many are in stock"
|
||||
title="How many can be bought. Leave empty for no limit."
|
||||
value={stock}
|
||||
onChange={(e) => setStock(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 120 }}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={!price || patchWare.isPending}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
export function WaresEditor({
|
||||
campaignId,
|
||||
seller,
|
||||
wares,
|
||||
weapons,
|
||||
items,
|
||||
onError,
|
||||
}: {
|
||||
campaignId: string;
|
||||
seller: { npcId: string } | { encounterId: string };
|
||||
wares: SaleOffer[];
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const createWare = useCreateWare(campaignId);
|
||||
|
||||
const [kind, setKind] = useState<'item' | 'weapon'>('item');
|
||||
const [poolId, setPoolId] = useState('');
|
||||
const [level, setLevel] = useState('');
|
||||
const [price, setPrice] = useState('');
|
||||
const [currency, setCurrency] = useState<Currency>('GOLD');
|
||||
const [stock, setStock] = useState('');
|
||||
|
||||
const pool: (Weapon | Item)[] = kind === 'weapon' ? weapons : items;
|
||||
const picked = pool.find((entry) => entry.id === poolId);
|
||||
|
||||
function handleAdd() {
|
||||
const amount = Math.floor(Number(price));
|
||||
if (!picked || !(amount > 0)) return;
|
||||
createWare.mutate(
|
||||
{
|
||||
...seller,
|
||||
...(kind === 'weapon' ? { weaponId: picked.id } : { itemId: picked.id }),
|
||||
price: amount,
|
||||
currency,
|
||||
level: level ? Number(level) : undefined,
|
||||
stock: stock === '' ? null : Number(stock),
|
||||
},
|
||||
{
|
||||
onError,
|
||||
onSuccess: () => {
|
||||
setPoolId('');
|
||||
setLevel('');
|
||||
setPrice('');
|
||||
setStock('');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{wares.length === 0 && (
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
Nothing for sale yet.
|
||||
</p>
|
||||
)}
|
||||
{wares.map((ware) => (
|
||||
<WareRow key={ware.id} campaignId={campaignId} ware={ware} onError={onError} />
|
||||
))}
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<select
|
||||
aria-label="What kind of thing to sell"
|
||||
value={kind}
|
||||
onChange={(e) => {
|
||||
setKind(e.target.value as typeof kind);
|
||||
setPoolId('');
|
||||
setLevel('');
|
||||
}}
|
||||
>
|
||||
<option value="item">Item</option>
|
||||
<option value="weapon">Weapon</option>
|
||||
</select>
|
||||
<select
|
||||
aria-label={kind === 'weapon' ? 'Weapon to sell' : 'Item to sell'}
|
||||
value={poolId}
|
||||
onChange={(e) => {
|
||||
setPoolId(e.target.value);
|
||||
setLevel('');
|
||||
}}
|
||||
style={{ flex: 1, minWidth: 110 }}
|
||||
>
|
||||
<option value="">{pool.length ? 'Choose…' : 'The pool is empty'}</option>
|
||||
{pool.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{picked && (
|
||||
<select aria-label="Level" value={level || picked.minLevel} onChange={(e) => setLevel(e.target.value)}>
|
||||
{Array.from({ length: MAX_GEAR_LEVEL - picked.minLevel + 1 }, (_, i) => picked.minLevel + i).map((l) => (
|
||||
<option key={l} value={l}>
|
||||
Lv {l}
|
||||
{picked.damageDice ? ` · ${scaledDamageDice(picked.damageDice, l, picked.damageScaling, picked.minLevel)}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<NumberInput min={1} max={1000000} placeholder="Price" aria-label="Price for one" value={price} onChange={(e) => setPrice(e.target.value)} style={{ width: 90 }} />
|
||||
<select aria-label="Price is in" value={currency} onChange={(e) => setCurrency(e.target.value as Currency)}>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{COIN_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<NumberInput
|
||||
min={0}
|
||||
max={9999}
|
||||
placeholder="Stock (empty = no limit)"
|
||||
aria-label="How many are in stock"
|
||||
title="How many can be bought. Leave empty for no limit."
|
||||
value={stock}
|
||||
onChange={(e) => setStock(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 120 }}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleAdd} disabled={!picked || !price || createWare.isPending}>
|
||||
Put up for sale
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+522
-8
@@ -1,4 +1,6 @@
|
||||
:root {
|
||||
/* Dark throughout, so the browser draws its own controls dark too. */
|
||||
color-scheme: dark;
|
||||
--bg: #131009;
|
||||
--bg-elevated: #1b160e;
|
||||
--bg-card: #221b12;
|
||||
@@ -347,10 +349,8 @@ textarea {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='email'],
|
||||
input[type='password'],
|
||||
input[type='number'],
|
||||
/* Every text-like field, including an <input> with no type given. */
|
||||
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='file']),
|
||||
select,
|
||||
textarea {
|
||||
background: var(--bg-inset);
|
||||
@@ -631,10 +631,9 @@ input[type='radio'] {
|
||||
color: var(--accent-strong);
|
||||
outline: none;
|
||||
}
|
||||
/* Placed by InfoTip.tsx against the viewport, so no scrolling container can clip it. */
|
||||
.info-tip-bubble {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
position: fixed;
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: 260px;
|
||||
@@ -648,7 +647,7 @@ input[type='radio'] {
|
||||
letter-spacing: normal;
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-deep);
|
||||
z-index: 100;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -806,6 +805,57 @@ table.data-table tbody tr:hover {
|
||||
.combatant.is-down {
|
||||
opacity: 0.6;
|
||||
}
|
||||
/* The marked target stands out even when it is down or not on turn. */
|
||||
.combatant.is-targeted {
|
||||
opacity: 1;
|
||||
border-color: var(--danger-strong);
|
||||
box-shadow: 0 0 0 1px var(--danger-strong), 0 0 22px rgba(216, 84, 84, 0.3), var(--shadow-deep);
|
||||
}
|
||||
.target-marker {
|
||||
color: var(--danger-strong);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.btn-target {
|
||||
padding: 0.15rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-target.is-on {
|
||||
border-color: var(--danger-strong);
|
||||
color: var(--danger-strong);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
/* What players see of the dead: names, and a way to try to bring them back. */
|
||||
.fallen-bar {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
/* The DM's attack bar stays in reach while scrolling the initiative order. */
|
||||
.attack-bar {
|
||||
position: sticky;
|
||||
top: 4.5rem;
|
||||
z-index: 20;
|
||||
border-color: var(--danger-strong);
|
||||
}
|
||||
.attack-target {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.attack-target-name {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
.attack-bar-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.combatant-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.1rem;
|
||||
@@ -870,6 +920,55 @@ table.data-table tbody tr:hover {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* At 0 HP: the death-save tallies and what can be done about them. */
|
||||
.death-saves {
|
||||
margin-top: 0.6rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: 6px;
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
.death-saves.is-stable {
|
||||
border-color: var(--border);
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
.death-tally {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.death-pips {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.death-pip {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--text-dim);
|
||||
background: transparent;
|
||||
}
|
||||
button.death-pip {
|
||||
cursor: pointer;
|
||||
}
|
||||
.death-pip.is-success {
|
||||
border-color: var(--success-strong);
|
||||
}
|
||||
.death-pip.is-failure {
|
||||
border-color: var(--danger-strong);
|
||||
}
|
||||
.death-pip.is-success.is-filled {
|
||||
background: var(--success-strong);
|
||||
}
|
||||
.death-pip.is-failure.is-filled {
|
||||
background: var(--danger-strong);
|
||||
}
|
||||
|
||||
.combatant-details {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
@@ -1191,3 +1290,418 @@ table.data-table tbody tr:hover {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Navigation: links along the top bar on a wide screen, a bar along the */
|
||||
/* bottom on a phone; and a row of links to the sections of a long page */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
.topbar-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.topbar-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.topbar-link:hover {
|
||||
text-decoration: none;
|
||||
color: var(--accent-strong);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.topbar-link.is-active {
|
||||
color: var(--accent-strong);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.topbar-name {
|
||||
max-width: 14ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bottom-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.jump-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.jump-link {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-inset);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.jump-link:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
/* A section jumped to stops below the sticky top bar, not under it. */
|
||||
[data-jump] {
|
||||
scroll-margin-top: 4.5rem;
|
||||
}
|
||||
|
||||
/* A table cell that holds a sentence rather than a figure may wrap. */
|
||||
table.data-table td.cell-title,
|
||||
table.data-table td.cell-wide {
|
||||
white-space: normal;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Small screens. Nothing may be wider than the screen: rows of controls */
|
||||
/* wrap, fields shrink to fit, tables become stacked cards, and the main */
|
||||
/* destinations move to a bar along the bottom. */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
body,
|
||||
#root,
|
||||
.app-shell {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
/* A field never forces its row wider than the space there is. */
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
:root {
|
||||
--bottom-nav-height: calc(3.6rem + env(safe-area-inset-bottom, 0px));
|
||||
--dice-slot: 4.2rem;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 1rem 0.85rem calc(var(--bottom-nav-height) + 1.5rem);
|
||||
}
|
||||
.topbar {
|
||||
padding: 0.5rem 0.85rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.topbar-brand {
|
||||
font-size: 1rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.topbar-links,
|
||||
.topbar-name {
|
||||
display: none;
|
||||
}
|
||||
.topbar-nav {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 70;
|
||||
display: flex;
|
||||
height: var(--bottom-nav-height);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
background: linear-gradient(180deg, #1e1810 0%, #120e08 100%);
|
||||
border-top: 1px solid var(--border);
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.bottom-nav-link {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.1rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-top: 2px solid transparent;
|
||||
}
|
||||
.bottom-nav-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
.bottom-nav-link .nav-icon {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.bottom-nav-link.is-active {
|
||||
color: var(--accent-strong);
|
||||
border-top-color: var(--accent-strong);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* Nothing floats over the page on a phone. The dice roller takes the last
|
||||
place in the bottom bar, and quick stats a place in the top bar. */
|
||||
.bottom-nav {
|
||||
padding-right: var(--dice-slot);
|
||||
}
|
||||
.fab-dice {
|
||||
left: auto;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 71;
|
||||
width: var(--dice-slot);
|
||||
height: var(--bottom-nav-height);
|
||||
padding: 0 0 env(safe-area-inset-bottom, 0px);
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
border: none;
|
||||
border-top: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.2;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.fab-dice::after {
|
||||
content: 'Dice';
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.fab-dice:hover {
|
||||
transform: none;
|
||||
}
|
||||
.dice-panel {
|
||||
left: 0.85rem;
|
||||
bottom: calc(var(--bottom-nav-height) + 0.6rem);
|
||||
width: calc(100vw - 1.7rem);
|
||||
max-height: calc(100dvh - var(--bottom-nav-height) - 5rem);
|
||||
}
|
||||
.fab-quickstats {
|
||||
top: 0.3rem;
|
||||
bottom: auto;
|
||||
right: 6.4rem;
|
||||
z-index: 31;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 1.15rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
/* The brand makes room for it. */
|
||||
.app-shell:has(.fab-quickstats) .topbar-brand {
|
||||
max-width: calc(100vw - 11rem);
|
||||
}
|
||||
.drawer {
|
||||
width: 100vw;
|
||||
padding: 1rem 0.85rem calc(var(--bottom-nav-height) + 1rem);
|
||||
z-index: 95;
|
||||
}
|
||||
.drawer-backdrop {
|
||||
z-index: 94;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.card {
|
||||
padding: 0.9rem 0.85rem;
|
||||
}
|
||||
.card-title {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.page-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Rows of controls wrap instead of running off the side, and what is in
|
||||
them may shrink: an inline min-width never beats the screen. */
|
||||
.flex-row,
|
||||
.flex-between,
|
||||
.detail-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.flex-row > *,
|
||||
.flex-between > * {
|
||||
max-width: 100%;
|
||||
}
|
||||
.flex-row > input,
|
||||
.flex-row > select,
|
||||
.flex-row > textarea,
|
||||
.flex-row > a,
|
||||
.flex-between > a {
|
||||
min-width: min(100%, 8rem) !important;
|
||||
}
|
||||
.btn {
|
||||
white-space: normal;
|
||||
padding: 0.6rem 0.9rem;
|
||||
}
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.grid-cols-3 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 130px), 1fr));
|
||||
}
|
||||
.choice-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.btn-roll {
|
||||
font-size: 1.1rem;
|
||||
padding: 1.1rem 1.25rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
.step-footer > * {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* Things to tap are big enough to hit with a thumb. */
|
||||
input[type='checkbox'],
|
||||
input[type='radio'] {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex: none;
|
||||
}
|
||||
.death-pip {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
.btn-target {
|
||||
padding: 0.4rem 0.75rem;
|
||||
}
|
||||
.condition-chip {
|
||||
padding: 0.3rem 0.6rem;
|
||||
}
|
||||
.condition-chip button {
|
||||
padding: 0.15rem 0.35rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.info-tip-trigger {
|
||||
font-size: 1.05em;
|
||||
padding: 0.2rem;
|
||||
}
|
||||
.pick-chip {
|
||||
padding: 0.5rem 0.9rem;
|
||||
}
|
||||
.jump-link {
|
||||
padding: 0.5rem 0.8rem;
|
||||
}
|
||||
.attack-bar {
|
||||
top: 3.4rem;
|
||||
max-height: 55dvh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
[data-jump] {
|
||||
scroll-margin-top: 3.8rem;
|
||||
}
|
||||
|
||||
/* Tables: every row becomes a card. The first cell is its heading; the
|
||||
rest carry their column's name from data-label. */
|
||||
.table-wrap {
|
||||
overflow: visible;
|
||||
border: none;
|
||||
}
|
||||
table.data-table,
|
||||
table.data-table tbody {
|
||||
display: block;
|
||||
}
|
||||
table.data-table thead {
|
||||
display: none;
|
||||
}
|
||||
table.data-table tbody tr {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem 0.9rem;
|
||||
padding: 0.7rem 0.75rem;
|
||||
margin-bottom: 0.6rem;
|
||||
background: var(--bg-inset);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
table.data-table tbody tr:last-child {
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
table.data-table tbody tr:hover {
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
table.data-table td {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0;
|
||||
white-space: normal;
|
||||
min-width: 0;
|
||||
}
|
||||
table.data-table td:empty {
|
||||
display: none;
|
||||
}
|
||||
table.data-table td.cell-title,
|
||||
table.data-table td.cell-wide {
|
||||
display: block;
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
table.data-table td.cell-title {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
table.data-table td[data-label]::before {
|
||||
content: attr(data-label);
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
flex: none;
|
||||
}
|
||||
table.data-table td.cell-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* The narrowest phones: five places in the bottom bar still fit. */
|
||||
@media (max-width: 380px) {
|
||||
:root {
|
||||
--dice-slot: 3.4rem;
|
||||
}
|
||||
.bottom-nav-link,
|
||||
.fab-dice::after {
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Between a phone and a full desktop the top bar drops the name to stay on one line. */
|
||||
@media (max-width: 1080px) {
|
||||
.topbar-name {
|
||||
display: none;
|
||||
}
|
||||
.topbar-nav {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Coin, Currency } from '../api/types';
|
||||
|
||||
export const SPECIES_LABELS: Record<string, string> = {
|
||||
HUMAN: 'Human',
|
||||
HALF_ELF: 'Half-Elf',
|
||||
@@ -70,6 +72,38 @@ export function hpBarClass(current: number, max: number): string {
|
||||
return 'hp-bar-fill';
|
||||
}
|
||||
|
||||
// Coins. Silver is worth 0.1 gold and copper 0.01 gold.
|
||||
export const COINS: Coin[] = ['GOLD', 'SILVER', 'COPPER'];
|
||||
// Everything a purse can hold: the coins, plus cash ($) for crossover worlds.
|
||||
// Cash has no worth in coins, so it is never exchanged or used to make change.
|
||||
export const CURRENCIES: Currency[] = [...COINS, 'CASH'];
|
||||
export const COIN_LABELS: Record<Currency, string> = { GOLD: 'Gold', SILVER: 'Silver', COPPER: 'Copper', CASH: 'Cash ($)' };
|
||||
export const COIN_FIELD: Record<Currency, 'gold' | 'silver' | 'copper' | 'cash'> = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper', CASH: 'cash' };
|
||||
// What one coin is worth, counted in copper.
|
||||
export const COIN_VALUE: Record<Coin, number> = { GOLD: 100, SILVER: 10, COPPER: 1 };
|
||||
const COIN_SHORT: Record<Coin, string> = { GOLD: 'gp', SILVER: 'sp', COPPER: 'cp' };
|
||||
|
||||
// "12 gp", "5 sp", "$40". With `long`, coins are spelled out: "12 gold".
|
||||
export function formatMoney(amount: number, currency: Currency, long = false): string {
|
||||
if (currency === 'CASH') return `$${amount}`;
|
||||
return `${amount} ${long ? COIN_LABELS[currency].toLowerCase() : COIN_SHORT[currency]}`;
|
||||
}
|
||||
|
||||
type Purse = { gold: number | null; silver: number | null; copper: number | null; cash: number | null };
|
||||
|
||||
// "12 gp · 5 sp · 3 cp · $40". Gold always shows; the rest only when held.
|
||||
export function formatPurse(purse: Purse): string {
|
||||
return CURRENCIES.filter((c) => c === 'GOLD' || (purse[COIN_FIELD[c]] ?? 0) > 0)
|
||||
.map((c) => formatMoney(purse[COIN_FIELD[c]] ?? 0, c))
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
// The purse's coins as one figure in gold, e.g. 12.65. Cash is not counted.
|
||||
export function purseWorthInGold(purse: Purse): number {
|
||||
const copper = COINS.reduce((sum, c) => sum + (purse[COIN_FIELD[c]] ?? 0) * COIN_VALUE[c], 0);
|
||||
return copper / COIN_VALUE.GOLD;
|
||||
}
|
||||
|
||||
export function extractErrorMessage(err: unknown): string {
|
||||
const anyErr = err as { response?: { data?: { error?: unknown } } };
|
||||
const apiError = anyErr?.response?.data?.error;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useSyncExternalStore } from 'react';
|
||||
|
||||
// The campaign the user was last in, so the navigation can always offer a way
|
||||
// back to it — from a character sheet or an encounter, whose addresses don't
|
||||
// say which campaign they belong to. Kept per browser.
|
||||
const KEY = 'dnd_last_campaign';
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function read(): string | null {
|
||||
return localStorage.getItem(KEY);
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function forgetCampaign() {
|
||||
localStorage.removeItem(KEY);
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export function useLastCampaignId(): string | null {
|
||||
return useSyncExternalStore(subscribe, read);
|
||||
}
|
||||
|
||||
// Call from any page that knows which campaign it is showing.
|
||||
export function useRememberCampaign(campaignId: string | null | undefined) {
|
||||
useEffect(() => {
|
||||
if (!campaignId || read() === campaignId) return;
|
||||
localStorage.setItem(KEY, campaignId);
|
||||
listeners.forEach((listener) => listener());
|
||||
}, [campaignId]);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { scaledDamageDice } from './previewCalc';
|
||||
import type { SaleOffer } from '../api/types';
|
||||
|
||||
// "🗡 Longsword · Lv 2 · 1d10": what a ware is, for anyone looking at it.
|
||||
export function wareName(ware: SaleOffer): string {
|
||||
const entry = ware.weapon ?? ware.item;
|
||||
if (!entry) return 'Removed from the pool';
|
||||
const dice = entry.damageDice ? ` · ${scaledDamageDice(entry.damageDice, ware.level, entry.damageScaling, entry.minLevel)}` : '';
|
||||
return `${ware.weapon ? '🗡' : '🎒'} ${entry.name} · Lv ${ware.level}${dice}`;
|
||||
}
|
||||
|
||||
export function stockLabel(ware: SaleOffer): string {
|
||||
return ware.stock === null ? 'always in stock' : ware.stock === 0 ? 'sold out' : `${ware.stock} left`;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useState, type CSSProperties, type FormEvent, type ReactNode } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
useCampaign,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useDeleteEncounter,
|
||||
useCampaignMonsters,
|
||||
useCreateMonster,
|
||||
useUpdateMonster,
|
||||
useDeleteMonster,
|
||||
useCampaignWeapons,
|
||||
useCreateWeapon,
|
||||
@@ -41,11 +42,14 @@ import { useAuth } from '../auth/AuthContext';
|
||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { JumpNav } from '../components/JumpNav';
|
||||
import { LifeBadge } from '../components/DeathSaves';
|
||||
import { usePagination, Pagination } from '../components/Paginated';
|
||||
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS, extractErrorMessage, spellLevelLabel, spellRequirementLabel } from '../lib/format';
|
||||
import { WaresEditor } from '../components/Wares';
|
||||
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS, extractErrorMessage, fmtMod, formatMoney, spellLevelLabel, spellRequirementLabel } from '../lib/format';
|
||||
import { speciesIcon } from '../lib/speciesIcons';
|
||||
import { DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
import type { CharacterSummary, Encounter, EncounterMode, LibraryMonster, LibrarySpell, LibraryWeapon, NpcDisposition } from '../api/types';
|
||||
import { ABILITY_ORDER, DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
import type { CharacterSummary, Encounter, EncounterMode, Item, LibraryMonster, LibrarySpell, LibraryWeapon, Monster, Npc, NpcDisposition, Weapon } from '../api/types';
|
||||
|
||||
// How the encounter list can be ordered. "custom" is the campaign's own order,
|
||||
// which the DM arranges; the rest only change what this browser shows.
|
||||
@@ -102,7 +106,7 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
|
||||
<div className="flex-row">
|
||||
{!inProgress && (
|
||||
<div style={{ textAlign: 'right', fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||
HP {character.currentHp}/{character.maxHp} · AC {character.armorClass}
|
||||
<LifeBadge deathSaves={character.deathSaves} /> HP {character.currentHp}/{character.maxHp} · AC {character.armorClass}
|
||||
</div>
|
||||
)}
|
||||
<Link to={inProgress ? `/characters/${character.id}/build` : `/characters/${character.id}`} className="btn">
|
||||
@@ -131,7 +135,7 @@ function PoolRowControls({
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<td>
|
||||
<td data-label="Starting option">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${entry.name} is a starting option`}
|
||||
@@ -139,7 +143,7 @@ function PoolRowControls({
|
||||
onChange={(e) => onToggleStarting(e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<td className="cell-actions">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
aria-label={`Remove ${entry.name}`}
|
||||
@@ -235,6 +239,214 @@ function SpellDamageSelect({
|
||||
);
|
||||
}
|
||||
|
||||
type MonsterInput = Omit<Monster, 'id' | 'campaignId'>;
|
||||
type AbilityScores = Pick<Monster, 'strScore' | 'dexScore' | 'conScore' | 'intScore' | 'wisScore' | 'chaScore'>;
|
||||
const scoreField = (ability: string) => `${ability.toLowerCase()}Score` as keyof AbilityScores;
|
||||
|
||||
// "STR 8 · DEX 14 · …", in stat-block order.
|
||||
function abilityLine(scores: AbilityScores): string {
|
||||
return ABILITY_ORDER.map((a) => `${a} ${scores[scoreField(a)]}`).join(' · ');
|
||||
}
|
||||
|
||||
// The whole stat block, field for field as a printed one gives it. Adds a
|
||||
// monster, or edits one when `monster` is passed (its values fill the form).
|
||||
function MonsterForm({
|
||||
monster,
|
||||
saving,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
monster?: Monster;
|
||||
saving: boolean;
|
||||
// Rejects if the save failed, so the form keeps what was typed.
|
||||
onSave: (data: MonsterInput) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const field = (name: string, label: string, input: ReactNode, style?: CSSProperties) => (
|
||||
<div className="field" style={{ marginBottom: 0, ...style }}>
|
||||
<label htmlFor={`monster-${name}`}>{label}</label>
|
||||
{input}
|
||||
</div>
|
||||
);
|
||||
const number = (name: keyof MonsterInput, label: string, min: number, max: number, fallback?: number) =>
|
||||
field(
|
||||
name,
|
||||
label,
|
||||
<NumberInput
|
||||
id={`monster-${name}`}
|
||||
name={name}
|
||||
min={min}
|
||||
max={max}
|
||||
defaultValue={(monster?.[name] as number | undefined) ?? fallback}
|
||||
keepValue={fallback !== undefined}
|
||||
required
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target as HTMLFormElement;
|
||||
const fd = new FormData(form);
|
||||
const text = (key: string) => String(fd.get(key) || '').trim() || null;
|
||||
const num = (key: string) => Number(fd.get(key));
|
||||
try {
|
||||
await onSave({
|
||||
name: String(fd.get('name')).trim(),
|
||||
challengeRating: text('challengeRating'),
|
||||
ac: num('ac'),
|
||||
maxHp: num('maxHp'),
|
||||
speed: num('speed'),
|
||||
attackBonus: num('attackBonus'),
|
||||
damageDice: text('damageDice'),
|
||||
strScore: num('strScore'),
|
||||
dexScore: num('dexScore'),
|
||||
conScore: num('conScore'),
|
||||
intScore: num('intScore'),
|
||||
wisScore: num('wisScore'),
|
||||
chaScore: num('chaScore'),
|
||||
specialTrait: text('specialTrait'),
|
||||
notes: text('notes'),
|
||||
});
|
||||
if (!monster) form.reset();
|
||||
} catch {
|
||||
// The page shows the error; what was typed stays.
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-3">
|
||||
{field('name', 'Name', <input id="monster-name" name="name" defaultValue={monster?.name} required maxLength={80} />)}
|
||||
{field(
|
||||
'challengeRating',
|
||||
'CR (Challenge Rating)',
|
||||
<input id="monster-challengeRating" name="challengeRating" defaultValue={monster?.challengeRating ?? ''} placeholder="e.g. 1/4 or 3" maxLength={10} />
|
||||
)}
|
||||
{number('ac', 'AC (Armor Class)', 0, 50)}
|
||||
{number('maxHp', 'HP (Hit Points)', 1, 100000)}
|
||||
{number('speed', 'Speed (feet)', 0, 1000, 30)}
|
||||
{number('attackBonus', 'Attack bonus', -20, 50, 0)}
|
||||
{field(
|
||||
'damageDice',
|
||||
'Damage',
|
||||
<input id="monster-damageDice" name="damageDice" defaultValue={monster?.damageDice ?? ''} placeholder="e.g. 1d6+2 necrotic" maxLength={60} />
|
||||
)}
|
||||
</div>
|
||||
<div className="grid mt-sm" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))' }}>
|
||||
{ABILITY_ORDER.map((a) => number(scoreField(a), a, 1, 30, 10))}
|
||||
</div>
|
||||
{field(
|
||||
'specialTrait',
|
||||
'Special trait',
|
||||
<input
|
||||
id="monster-specialTrait"
|
||||
name="specialTrait"
|
||||
defaultValue={monster?.specialTrait ?? ''}
|
||||
placeholder="The one thing that makes it different"
|
||||
maxLength={500}
|
||||
/>,
|
||||
{ marginTop: '0.5rem' }
|
||||
)}
|
||||
{field(
|
||||
'notes',
|
||||
'Notes',
|
||||
<textarea
|
||||
id="monster-notes"
|
||||
name="notes"
|
||||
rows={3}
|
||||
className="textarea-block"
|
||||
defaultValue={monster?.notes ?? ''}
|
||||
placeholder="Everything else: other speeds (fly 30, swim 40), extra attacks, spells, tactics, resistances, senses, what it drops"
|
||||
maxLength={2000}
|
||||
/>,
|
||||
{ marginTop: '0.5rem' }
|
||||
)}
|
||||
<div className="flex-row mt-sm">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{monster ? 'Save Changes' : 'Add Monster'}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button type="button" className="btn btn-ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// "1 gp = $5": the rate an NPC changes gold and cash at, or null if they don't.
|
||||
function rateLabel(npc: Pick<Npc, 'exchangeGold' | 'exchangeCash'>): string | null {
|
||||
if (!npc.exchangeGold || !npc.exchangeCash) return null;
|
||||
return `${formatMoney(npc.exchangeGold, 'GOLD')} = ${formatMoney(npc.exchangeCash, 'CASH')}`;
|
||||
}
|
||||
|
||||
// What an NPC trades: the rate they change gold and cash at, and their wares.
|
||||
// Players meet both on the encounter page, in a quest the NPC is part of.
|
||||
function NpcTrade({
|
||||
campaignId,
|
||||
npc,
|
||||
weapons,
|
||||
items,
|
||||
onError,
|
||||
}: {
|
||||
campaignId: string;
|
||||
npc: Npc;
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const patchNpc = usePatchPoolEntry(campaignId, 'npcs');
|
||||
const [gold, setGold] = useState(npc.exchangeGold ? String(npc.exchangeGold) : '');
|
||||
const [cash, setCash] = useState(npc.exchangeCash ? String(npc.exchangeCash) : '');
|
||||
const changesMoney = Boolean(rateLabel(npc));
|
||||
|
||||
return (
|
||||
<div className="card mt-sm" style={{ background: 'var(--bg-inset)' }}>
|
||||
<div className="section-label">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
|
||||
taken. Leave it empty for someone who does not change money.
|
||||
</p>
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
<NumberInput min={1} max={1000000} aria-label="Gold side of the rate" placeholder="Gold" value={gold} onChange={(e) => setGold(e.target.value)} style={{ width: 90 }} />
|
||||
<span>gold =</span>
|
||||
<span>$</span>
|
||||
<NumberInput min={1} max={1000000} aria-label="Cash side of the rate" placeholder="Cash" value={cash} onChange={(e) => setCash(e.target.value)} style={{ width: 90 }} />
|
||||
<button
|
||||
className="btn"
|
||||
disabled={!gold || !cash || patchNpc.isPending}
|
||||
onClick={() => patchNpc.mutate({ id: npc.id, exchangeGold: Number(gold), exchangeCash: Number(cash) }, { onError })}
|
||||
>
|
||||
Save rate
|
||||
</button>
|
||||
{changesMoney && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
disabled={patchNpc.isPending}
|
||||
onClick={() =>
|
||||
patchNpc.mutate(
|
||||
{ id: npc.id, exchangeGold: null, exchangeCash: null },
|
||||
{
|
||||
onError,
|
||||
onSuccess: () => {
|
||||
setGold('');
|
||||
setCash('');
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
Stop changing money
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="section-label mt-md">For sale</div>
|
||||
<WaresEditor campaignId={campaignId} seller={{ npcId: npc.id }} wares={npc.wares ?? []} weapons={weapons} items={items} onError={onError} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryPicker<T extends { key: string; name: string }>({
|
||||
items,
|
||||
onAdd,
|
||||
@@ -303,6 +515,7 @@ export function CampaignHomePage() {
|
||||
const setEncounterArchived = useSetEncounterArchived(campaignId);
|
||||
const deleteEncounter = useDeleteEncounter(campaignId);
|
||||
const createMonster = useCreateMonster(campaignId);
|
||||
const updateMonster = useUpdateMonster(campaignId);
|
||||
const deleteMonster = useDeleteMonster(campaignId);
|
||||
const createWeapon = useCreateWeapon(campaignId);
|
||||
const createSpell = useCreateSpell(campaignId);
|
||||
@@ -332,6 +545,7 @@ export function CampaignHomePage() {
|
||||
const [encounterSort, setEncounterSort] = useState<EncounterSort>(storedEncounterSort);
|
||||
const [showArchivedEncounters, setShowArchivedEncounters] = useState(false);
|
||||
const [showNpcForm, setShowNpcForm] = useState(false);
|
||||
const [tradeNpcId, setTradeNpcId] = useState<string | null>(null);
|
||||
const [noteBody, setNoteBody] = useState('');
|
||||
const [dmNoteBody, setDmNoteBody] = useState('');
|
||||
const [newLocationName, setNewLocationName] = useState('');
|
||||
@@ -340,6 +554,7 @@ export function CampaignHomePage() {
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [deleteConfirmName, setDeleteConfirmName] = useState('');
|
||||
const [showMonsterForm, setShowMonsterForm] = useState(false);
|
||||
const [editingMonsterId, setEditingMonsterId] = useState<string | null>(null);
|
||||
const [showWeaponForm, setShowWeaponForm] = useState(false);
|
||||
const [showSpellForm, setShowSpellForm] = useState(false);
|
||||
const [showItemForm, setShowItemForm] = useState(false);
|
||||
@@ -352,9 +567,12 @@ export function CampaignHomePage() {
|
||||
const itemsPage = usePagination(items, 8);
|
||||
const npcsPage = usePagination(npcs, 8);
|
||||
const monstersPage = usePagination(monsters, 8);
|
||||
const editingMonster = monsters?.find((m) => m.id === editingMonsterId);
|
||||
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
|
||||
const archivedEncounters = encounters?.filter((e) => e.archivedAt) || [];
|
||||
const shownEncounters = sortEncounters(liveEncounters, encounterSort);
|
||||
const encountersPage = usePagination(shownEncounters, 10);
|
||||
const archivedEncountersPage = usePagination(archivedEncounters, 10);
|
||||
const sharedNotes = notes?.filter((n) => n.visibility === 'SHARED');
|
||||
const dmNotes = notes?.filter((n) => n.visibility === 'DM_ONLY');
|
||||
const sharedNotesPage = usePagination(sharedNotes, 6);
|
||||
@@ -461,8 +679,10 @@ export function CampaignHomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JumpNav />
|
||||
|
||||
{isDM && (
|
||||
<div className="card">
|
||||
<div data-jump="Settings" className="card">
|
||||
<div className="card-title">
|
||||
<span>Campaign Settings</span>
|
||||
<button className="btn btn-ghost" onClick={() => setShowSettings((s) => !s)}>
|
||||
@@ -488,6 +708,7 @@ export function CampaignHomePage() {
|
||||
startingSpellCount: Number(fd.get('startingSpellCount')),
|
||||
skipMotivationRoll: fd.get('skipMotivationRoll') === 'on',
|
||||
showMonsterHp: fd.get('showMonsterHp') === 'on',
|
||||
areaAttackAcPenalty: Number(fd.get('areaAttackAcPenalty')),
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -513,6 +734,19 @@ export function CampaignHomePage() {
|
||||
<input type="checkbox" name="showMonsterHp" defaultChecked={campaign.showMonsterHp} />
|
||||
Players see the exact hit points of monsters and NPCs in encounters (untick for a health bar only)
|
||||
</label>
|
||||
<label className="flex-row flex-wrap gap-sm mt-sm">
|
||||
Area attack AC penalty
|
||||
<NumberInput
|
||||
name="areaAttackAcPenalty"
|
||||
min={0}
|
||||
max={10}
|
||||
keepValue
|
||||
defaultValue={campaign.areaAttackAcPenalty}
|
||||
style={{ width: 70 }}
|
||||
title="An attack on several targets is harder to land: each target's AC counts as this much higher for every target beyond the first. 0 turns the penalty off."
|
||||
/>
|
||||
<span className="combatant-sub">added to each target's AC for every target beyond the first (0 for none)</span>
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary mt-md" disabled={updateCampaign.isPending}>
|
||||
Save Settings
|
||||
</button>
|
||||
@@ -581,7 +815,7 @@ export function CampaignHomePage() {
|
||||
{formError && <div className="mt-md"><ErrorBanner message={formError} /></div>}
|
||||
|
||||
{!isDM && myCharacters.length > 0 && (
|
||||
<div className="mt-lg">
|
||||
<div data-jump="My characters" className="mt-lg">
|
||||
<h2 style={{ fontSize: '1.1rem' }}>Your Character{myCharacters.length > 1 ? 's' : ''}</h2>
|
||||
{myCharacters.map((c) => (
|
||||
<CharacterRow key={c.id} character={c} isDM={false} onDelete={() => {}} />
|
||||
@@ -589,7 +823,7 @@ export function CampaignHomePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-lg">
|
||||
<div data-jump={isDM ? 'Characters' : 'Party'} className="mt-lg">
|
||||
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
|
||||
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>{isDM ? 'Characters' : 'Rest of the Party'}</h2>
|
||||
</div>
|
||||
@@ -617,7 +851,7 @@ export function CampaignHomePage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-lg">
|
||||
<div data-jump="Encounters" className="mt-lg">
|
||||
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
|
||||
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>Encounters</h2>
|
||||
{liveEncounters.length > 1 && (
|
||||
@@ -659,13 +893,17 @@ export function CampaignHomePage() {
|
||||
)}
|
||||
{isDM && encounterSort === 'custom' && liveEncounters.length > 1 && (
|
||||
<p className="subtitle" style={{ marginTop: 0 }}>
|
||||
This is the campaign's own order, which everyone sees by default. Use ▲ and ▼ to rearrange it.
|
||||
This is the campaign's own order, which everyone sees by default. Use ▲ and ▼ to rearrange it; moving past the
|
||||
top or bottom of a page carries an encounter onto the next one.
|
||||
</p>
|
||||
)}
|
||||
{encounters && liveEncounters.length === 0 && (
|
||||
<div className="empty-state">{archivedEncounters.length > 0 ? 'Every encounter is archived.' : 'No encounters yet.'}</div>
|
||||
)}
|
||||
{shownEncounters.map((enc, index) => (
|
||||
{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);
|
||||
return (
|
||||
<div key={enc.id} className="card flex-between flex-wrap">
|
||||
<Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}>
|
||||
<span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span>
|
||||
@@ -710,7 +948,9 @@ export function CampaignHomePage() {
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
<Pagination page={encountersPage.page} pageCount={encountersPage.pageCount} onPrev={encountersPage.prevPage} onNext={encountersPage.nextPage} />
|
||||
|
||||
{isDM && archivedEncounters.length > 0 && (
|
||||
<div className="mt-md">
|
||||
@@ -723,7 +963,7 @@ export function CampaignHomePage() {
|
||||
{showArchivedEncounters && (
|
||||
<>
|
||||
<p className="subtitle" style={{ marginTop: 0 }}>Players don't see these in their list.</p>
|
||||
{archivedEncounters.map((enc) => (
|
||||
{archivedEncountersPage.pageItems.map((enc) => (
|
||||
<div key={enc.id} className="card flex-between flex-wrap">
|
||||
<Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}>
|
||||
<span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span>
|
||||
@@ -750,6 +990,12 @@ export function CampaignHomePage() {
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<Pagination
|
||||
page={archivedEncountersPage.page}
|
||||
pageCount={archivedEncountersPage.pageCount}
|
||||
onPrev={archivedEncountersPage.prevPage}
|
||||
onNext={archivedEncountersPage.nextPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -757,7 +1003,7 @@ export function CampaignHomePage() {
|
||||
</div>
|
||||
|
||||
{!isDM && locations && locations.length > 0 && (
|
||||
<div className="mt-lg card">
|
||||
<div data-jump="Places" className="mt-lg card">
|
||||
<div className="card-title">Places You've Been</div>
|
||||
{locations.map((loc) => (
|
||||
<div key={loc.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
@@ -769,7 +1015,7 @@ export function CampaignHomePage() {
|
||||
)}
|
||||
|
||||
{isDM && (
|
||||
<div className="mt-lg card">
|
||||
<div data-jump="NPCs" className="mt-lg card">
|
||||
<div className="card-title">
|
||||
<span>NPCs</span>
|
||||
<button className="btn btn-ghost" onClick={() => setShowNpcForm((s) => !s)}>
|
||||
@@ -778,7 +1024,8 @@ export function CampaignHomePage() {
|
||||
</div>
|
||||
<p className="subtitle" style={{ marginTop: 0 }}>
|
||||
The people your party meets. Add them to an encounter to bring them into a scene. Players never see your notes or an
|
||||
NPC's stats.
|
||||
NPC's stats. "Trade" sets what an NPC sells and the rate they change gold and cash at; players see both when they
|
||||
meet the NPC in a quest.
|
||||
</p>
|
||||
{showNpcForm && (
|
||||
<form
|
||||
@@ -829,21 +1076,39 @@ export function CampaignHomePage() {
|
||||
<div className="mt-sm">
|
||||
{npcs?.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No NPCs yet.</p>}
|
||||
{npcsPage.pageItems.map((npc) => (
|
||||
<div key={npc.id} className="flex-between" style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
<div>
|
||||
<strong>{npc.name}</strong>
|
||||
{npc.role && <span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>{npc.role}</span>}
|
||||
<span className={`badge badge-${npc.disposition.toLowerCase()}`} style={{ marginLeft: 8 }}>{npc.disposition}</span>
|
||||
{npc.description && <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{npc.description}</div>}
|
||||
{npc.dmNotes && <div style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>🔒 {npc.dmNotes}</div>}
|
||||
<div key={npc.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
<div className="flex-between">
|
||||
<div>
|
||||
<strong>{npc.name}</strong>
|
||||
{npc.role && <span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>{npc.role}</span>}
|
||||
<span className={`badge badge-${npc.disposition.toLowerCase()}`} style={{ marginLeft: 8 }}>{npc.disposition}</span>
|
||||
{rateLabel(npc) && <span className="badge" style={{ marginLeft: 8 }}>💱 {rateLabel(npc)}</span>}
|
||||
{(npc.wares?.length ?? 0) > 0 && <span className="badge" style={{ marginLeft: 8 }}>🛒 {npc.wares?.length} for sale</span>}
|
||||
{npc.description && <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{npc.description}</div>}
|
||||
{npc.dmNotes && <div style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>🔒 {npc.dmNotes}</div>}
|
||||
</div>
|
||||
<div className="flex-row">
|
||||
<button className="btn btn-ghost" onClick={() => setTradeNpcId(tradeNpcId === npc.id ? null : npc.id)}>
|
||||
{tradeNpcId === npc.id ? 'Close' : 'Trade'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
aria-label={`Remove ${npc.name}`}
|
||||
onClick={() => window.confirm(`Remove ${npc.name}?`) && deleteNpc.mutate(npc.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
aria-label={`Remove ${npc.name}`}
|
||||
onClick={() => window.confirm(`Remove ${npc.name}?`) && deleteNpc.mutate(npc.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{tradeNpcId === npc.id && (
|
||||
<NpcTrade
|
||||
campaignId={campaignId}
|
||||
npc={npc}
|
||||
weapons={weapons ?? []}
|
||||
items={items ?? []}
|
||||
onError={(err) => setFormError(extractErrorMessage(err))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -852,7 +1117,7 @@ export function CampaignHomePage() {
|
||||
)}
|
||||
|
||||
{isDM && (
|
||||
<div className="mt-lg card">
|
||||
<div data-jump="Locations" className="mt-lg card">
|
||||
<div className="card-title">
|
||||
<span>Locations</span>
|
||||
</div>
|
||||
@@ -896,7 +1161,7 @@ export function CampaignHomePage() {
|
||||
must be at least a weapon's or item's level to use it, and at least a spell's level to cast it.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-sm card">
|
||||
<div data-jump="Weapons" className="mt-sm card">
|
||||
<div className="card-title">
|
||||
<span>Weapons Pool</span>
|
||||
<div className="flex-row gap-sm">
|
||||
@@ -970,12 +1235,12 @@ export function CampaignHomePage() {
|
||||
<tbody>
|
||||
{weaponsPage.pageItems.map((w) => (
|
||||
<tr key={w.id}>
|
||||
<td>{w.name}</td>
|
||||
<td>{w.damageDice} {w.damageType}</td>
|
||||
<td>
|
||||
<td className="cell-title">{w.name}</td>
|
||||
<td data-label="Base damage">{w.damageDice} {w.damageType}</td>
|
||||
<td data-label="Level">
|
||||
<StartLevelSelect value={w.minLevel} onChange={(minLevel) => patchWeapon.mutate({ id: w.id, minLevel })} />
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Growth">
|
||||
<ScalingSelect value={w.damageScaling} onChange={(damageScaling) => patchWeapon.mutate({ id: w.id, damageScaling })} />
|
||||
</td>
|
||||
<PoolRowControls
|
||||
@@ -991,7 +1256,7 @@ export function CampaignHomePage() {
|
||||
<Pagination page={weaponsPage.page} pageCount={weaponsPage.pageCount} onPrev={weaponsPage.prevPage} onNext={weaponsPage.nextPage} />
|
||||
</div>
|
||||
|
||||
<div className="mt-md card">
|
||||
<div data-jump="Items" className="mt-md card">
|
||||
<div className="card-title">
|
||||
<span>Items Pool</span>
|
||||
<button className="btn btn-ghost" onClick={() => setShowItemForm((s) => !s)}>
|
||||
@@ -1046,13 +1311,13 @@ export function CampaignHomePage() {
|
||||
<tbody>
|
||||
{itemsPage.pageItems.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>{item.name}</td>
|
||||
<td>{item.description}</td>
|
||||
<td>{item.damageDice || '—'}</td>
|
||||
<td>
|
||||
<td className="cell-title">{item.name}</td>
|
||||
<td className="cell-wide">{item.description}</td>
|
||||
<td data-label="Base dice">{item.damageDice || '—'}</td>
|
||||
<td data-label="Level">
|
||||
<StartLevelSelect value={item.minLevel} onChange={(minLevel) => patchItem.mutate({ id: item.id, minLevel })} />
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Growth">
|
||||
{item.damageDice ? (
|
||||
<ScalingSelect value={item.damageScaling} onChange={(damageScaling) => patchItem.mutate({ id: item.id, damageScaling })} />
|
||||
) : (
|
||||
@@ -1073,7 +1338,7 @@ export function CampaignHomePage() {
|
||||
<Pagination page={itemsPage.page} pageCount={itemsPage.pageCount} onPrev={itemsPage.prevPage} onNext={itemsPage.nextPage} />
|
||||
</div>
|
||||
|
||||
<div className="mt-md card">
|
||||
<div data-jump="Spells" className="mt-md card">
|
||||
<div className="card-title">
|
||||
<span>Spell List</span>
|
||||
<div className="flex-row gap-sm">
|
||||
@@ -1114,6 +1379,7 @@ export function CampaignHomePage() {
|
||||
description: String(fd.get('description') || '').trim() || null,
|
||||
damageDice: String(fd.get('damageDice') || '') || null,
|
||||
isStartingOption: fd.get('isStartingOption') === 'on',
|
||||
revives: fd.get('revives') === 'on',
|
||||
});
|
||||
(e.target as HTMLFormElement).reset();
|
||||
}}
|
||||
@@ -1134,6 +1400,10 @@ export function CampaignHomePage() {
|
||||
<input type="checkbox" name="isStartingOption" />
|
||||
Starting option
|
||||
</label>
|
||||
<label className="checkbox-row" title="A character who can cast it may revive a dead ally on their turn in a battle">
|
||||
<input type="checkbox" name="revives" />
|
||||
Revives the dead
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary">Add</button>
|
||||
</form>
|
||||
)}
|
||||
@@ -1144,6 +1414,7 @@ export function CampaignHomePage() {
|
||||
<th>Spell</th>
|
||||
<th>Level</th>
|
||||
<th>Damage</th>
|
||||
<th title="A character who can cast it may revive a dead ally on their turn in a battle">Revives</th>
|
||||
<th>Starting</th>
|
||||
<th />
|
||||
</tr>
|
||||
@@ -1151,14 +1422,22 @@ export function CampaignHomePage() {
|
||||
<tbody>
|
||||
{spellsPage.pageItems.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>
|
||||
<td className="cell-title">
|
||||
<strong>{s.name}</strong>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{s.description || 'No description.'}</div>
|
||||
</td>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>{spellLevelLabel(s.level)}</td>
|
||||
<td>
|
||||
<td data-label="Level" style={{ whiteSpace: 'nowrap' }}>{spellLevelLabel(s.level)}</td>
|
||||
<td data-label="Damage">
|
||||
<SpellDamageSelect spellName={s.name} value={s.damageDice} onChange={(damageDice) => patchSpell.mutate({ id: s.id, damageDice })} />
|
||||
</td>
|
||||
<td data-label="Revives">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${s.name} revives the dead`}
|
||||
checked={s.revives}
|
||||
onChange={(e) => patchSpell.mutate({ id: s.id, revives: e.target.checked })}
|
||||
/>
|
||||
</td>
|
||||
<PoolRowControls
|
||||
entry={s}
|
||||
onToggleStarting={(isStartingOption) => patchSpell.mutate({ id: s.id, isStartingOption })}
|
||||
@@ -1172,15 +1451,21 @@ export function CampaignHomePage() {
|
||||
<Pagination page={spellsPage.page} pageCount={spellsPage.pageCount} onPrev={spellsPage.prevPage} onNext={spellsPage.nextPage} />
|
||||
</div>
|
||||
|
||||
<div className="mt-md card">
|
||||
<div data-jump="Monsters" className="mt-md card">
|
||||
<div className="card-title">
|
||||
<span>Monsters</span>
|
||||
<div className="flex-row gap-sm">
|
||||
<button className="btn btn-ghost" onClick={() => setShowMonsterLibrary((s) => !s)}>
|
||||
{showMonsterLibrary ? 'Close Library' : '📚 From Library'}
|
||||
</button>
|
||||
<button className="btn btn-ghost" onClick={() => setShowMonsterForm((s) => !s)}>
|
||||
{showMonsterForm ? 'Close' : '+ Add Monster'}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
setShowMonsterForm(!(showMonsterForm || editingMonster));
|
||||
setEditingMonsterId(null);
|
||||
}}
|
||||
>
|
||||
{showMonsterForm || editingMonster ? 'Close' : '+ Add Monster'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1190,62 +1475,93 @@ export function CampaignHomePage() {
|
||||
adding={addMonsterFromLibrary.isPending}
|
||||
onAdd={(key) => addMonsterFromLibrary.mutate(key)}
|
||||
placeholder="Search the monster library…"
|
||||
renderMeta={(m) => `CR ${m.challengeRating} · AC ${m.ac} · HP ${m.maxHp} · attack +${m.attackBonus}${m.damageDice ? ` for ${m.damageDice}` : ''}`}
|
||||
renderMeta={(m) =>
|
||||
`CR ${m.challengeRating ?? '—'} · AC ${m.ac} · HP ${m.maxHp} · speed ${m.speed} · attack ${fmtMod(m.attackBonus)}${m.damageDice ? ` for ${m.damageDice}` : ''} · ${abilityLine(m)}`
|
||||
}
|
||||
renderDetail={(m) => [m.specialTrait, m.notes].filter(Boolean).join(' — ') || null}
|
||||
/>
|
||||
)}
|
||||
{showMonsterForm && (
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target as HTMLFormElement);
|
||||
await createMonster.mutateAsync({
|
||||
name: String(fd.get('name')),
|
||||
ac: Number(fd.get('ac')),
|
||||
maxHp: Number(fd.get('maxHp')),
|
||||
speed: 30,
|
||||
strScore: 10,
|
||||
dexScore: 10,
|
||||
conScore: 10,
|
||||
intScore: 10,
|
||||
wisScore: 10,
|
||||
chaScore: 10,
|
||||
attackBonus: Number(fd.get('attackBonus') || 0),
|
||||
damageDice: String(fd.get('damageDice') || '') || null,
|
||||
challengeRating: null,
|
||||
specialTrait: null,
|
||||
notes: null,
|
||||
});
|
||||
(e.target as HTMLFormElement).reset();
|
||||
}}
|
||||
className="grid grid-cols-3"
|
||||
>
|
||||
<input name="name" placeholder="Name" required />
|
||||
<NumberInput name="ac" min={0} max={50} placeholder="AC" required />
|
||||
<NumberInput name="maxHp" min={1} max={100000} placeholder="Max HP" required />
|
||||
<NumberInput name="attackBonus" min={-20} max={50} placeholder="Attack bonus" />
|
||||
<input name="damageDice" placeholder="Damage dice" />
|
||||
<button type="submit" className="btn btn-primary">Add</button>
|
||||
</form>
|
||||
{(showMonsterForm || editingMonster) && (
|
||||
<div className="card mt-sm" style={{ background: 'var(--bg-inset)' }}>
|
||||
{editingMonster && <div className="section-label">Editing {editingMonster.name}</div>}
|
||||
<MonsterForm
|
||||
key={editingMonster?.id ?? 'new'}
|
||||
monster={editingMonster}
|
||||
saving={createMonster.isPending || updateMonster.isPending}
|
||||
onCancel={editingMonster ? () => setEditingMonsterId(null) : undefined}
|
||||
onSave={async (stats) => {
|
||||
setFormError(null);
|
||||
try {
|
||||
if (editingMonster) {
|
||||
await updateMonster.mutateAsync({ id: editingMonster.id, ...stats });
|
||||
setEditingMonsterId(null);
|
||||
} else {
|
||||
await createMonster.mutateAsync(stats);
|
||||
}
|
||||
} catch (err) {
|
||||
setFormError(extractErrorMessage(err));
|
||||
throw err;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="table-wrap mt-sm">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>CR</th>
|
||||
<th>AC</th>
|
||||
<th>HP</th>
|
||||
<th>Speed</th>
|
||||
<th>Attack</th>
|
||||
<th>Damage</th>
|
||||
<th />
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{monstersPage.pageItems.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td>{m.name}</td>
|
||||
<td>{m.ac}</td>
|
||||
<td>{m.maxHp}</td>
|
||||
<td className="cell-title">
|
||||
<strong>{m.name}</strong>
|
||||
<div className="mono" style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{abilityLine(m)}</div>
|
||||
{m.specialTrait && (
|
||||
<div style={{ fontSize: '0.9rem' }}>
|
||||
<strong>Trait:</strong> {m.specialTrait}
|
||||
</div>
|
||||
)}
|
||||
{m.notes && <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem', whiteSpace: 'pre-wrap' }}>{m.notes}</div>}
|
||||
</td>
|
||||
<td data-label="CR">{m.challengeRating || '—'}</td>
|
||||
<td data-label="AC">{m.ac}</td>
|
||||
<td data-label="HP">{m.maxHp}</td>
|
||||
<td data-label="Speed">{m.speed}</td>
|
||||
<td data-label="Attack">{fmtMod(m.attackBonus)}</td>
|
||||
<td data-label="Damage">{m.damageDice || '—'}</td>
|
||||
<td className="cell-actions">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
aria-label={`Edit ${m.name}`}
|
||||
onClick={() => {
|
||||
setEditingMonsterId(m.id);
|
||||
setShowMonsterForm(false);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-ghost" onClick={() => deleteMonster.mutate(m.id)}>✕</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
aria-label={`Remove ${m.name}`}
|
||||
onClick={() =>
|
||||
window.confirm(`Remove ${m.name}? Copies already in encounters stay, but lose their stat block.`) && deleteMonster.mutate(m.id)
|
||||
}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -1257,7 +1573,7 @@ export function CampaignHomePage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-lg card">
|
||||
<div data-jump="Notes" className="mt-lg card">
|
||||
<div className="card-title">Shared Notes</div>
|
||||
<form onSubmit={handleCreateNote} className="flex-row flex-wrap">
|
||||
<textarea
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useRememberCampaign } from '../lib/lastCampaign';
|
||||
import { useBuildBack, useBuildChoose, useBuildFinalize, useBuildOptions, useBuildRoll, useCampaign, useCampaignItems, useCampaignSpells, useCampaignWeapons, useCharacter, usePatchDiceMode } from '../api/queries';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
@@ -95,6 +96,7 @@ export function CharacterBuilderPage() {
|
||||
const { characterId = '' } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { data: character, isLoading, error } = useCharacter(characterId);
|
||||
useRememberCampaign(character?.campaignId);
|
||||
const roll = useBuildRoll(characterId);
|
||||
const choose = useBuildChoose(characterId);
|
||||
const finalize = useBuildFinalize(characterId);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
useRemoveCharacterSpell,
|
||||
useGoldLedger,
|
||||
useAdjustGold,
|
||||
useExchangeCoins,
|
||||
useCampaignCharacters,
|
||||
useGive,
|
||||
type Gift,
|
||||
@@ -31,15 +32,34 @@ import { useAuth } from '../auth/AuthContext';
|
||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { JumpNav } from '../components/JumpNav';
|
||||
import { useRememberCampaign } from '../lib/lastCampaign';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
import { DeathSaves } from '../components/DeathSaves';
|
||||
import { QuickStatsFab } from '../components/QuickStatsDrawer';
|
||||
import { InfoTip } from '../components/InfoTip';
|
||||
import { usePagination, Pagination } from '../components/Paginated';
|
||||
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, fmtMod, extractErrorMessage, spellLevelLabel } from '../lib/format';
|
||||
import {
|
||||
ABILITY_LABELS,
|
||||
CLASS_LABELS,
|
||||
COINS,
|
||||
COIN_FIELD,
|
||||
COIN_LABELS,
|
||||
COIN_VALUE,
|
||||
CURRENCIES,
|
||||
SKILL_LABELS,
|
||||
SPECIES_LABELS,
|
||||
fmtMod,
|
||||
extractErrorMessage,
|
||||
formatMoney,
|
||||
formatPurse,
|
||||
purseWorthInGold,
|
||||
spellLevelLabel,
|
||||
} from '../lib/format';
|
||||
import { speciesIcon } from '../lib/speciesIcons';
|
||||
import { hpMax as previewHpMax, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
import { CLASS_HIT_DIE } from '../lib/classData';
|
||||
import type { Ability, CharacterDetail } from '../api/types';
|
||||
import type { Ability, CharacterDetail, Coin, Currency } from '../api/types';
|
||||
|
||||
// The DM's level picker for one held weapon or item, from the level the gear
|
||||
// starts at up to the maximum. Each option shows what the dice become at that
|
||||
@@ -89,20 +109,26 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
|
||||
const others = characters?.filter((c) => c.id !== character.id && c.buildStage === 'COMPLETE') ?? [];
|
||||
if (others.length === 0) return null;
|
||||
|
||||
// "kind:id" identifies the thing picked; gold has no id.
|
||||
// "kind:id" identifies the thing picked; for coins the id is which coin.
|
||||
const [kind, id] = what.split(':');
|
||||
const coin = (kind === 'gold' ? id : 'GOLD') as Currency;
|
||||
const heldWeapon = character.weapons.find((w) => w.weaponId === id);
|
||||
const heldItem = character.inventoryItems.find((i) => i.itemId === id);
|
||||
const gold = character.gold ?? 0;
|
||||
const max = kind === 'gold' ? gold : kind === 'weapon' ? (heldWeapon?.quantity ?? 0) : kind === 'item' ? (heldItem?.quantity ?? 0) : 1;
|
||||
// A gift of coins is paid from the whole purse, with change, so any coin
|
||||
// the purse is worth at least one of can be given. Cash is only paid in cash.
|
||||
const held = (c: Currency) => character[COIN_FIELD[c]] ?? 0;
|
||||
const purseInCopper = COINS.reduce((sum, c) => sum + held(c) * COIN_VALUE[c], 0);
|
||||
const affordable = (c: Currency) => (c === 'CASH' ? held(c) : Math.floor(purseInCopper / COIN_VALUE[c]));
|
||||
const coinsHeld = CURRENCIES.filter((c) => affordable(c) > 0);
|
||||
const max = kind === 'gold' ? affordable(coin) : kind === 'weapon' ? (heldWeapon?.quantity ?? 0) : kind === 'item' ? (heldItem?.quantity ?? 0) : 1;
|
||||
const count = Math.floor(Number(amount));
|
||||
const countOk = kind === 'spell' || (count >= 1 && count <= max);
|
||||
const nothingToGive = gold === 0 && character.weapons.length + character.inventoryItems.length + character.spells.length === 0;
|
||||
const nothingToGive = coinsHeld.length === 0 && character.weapons.length + character.inventoryItems.length + character.spells.length === 0;
|
||||
|
||||
function handleGive() {
|
||||
const gift: Gift | null =
|
||||
kind === 'gold'
|
||||
? { kind, amount: count }
|
||||
? { kind, amount: count, currency: coin }
|
||||
: kind === 'weapon' || kind === 'item'
|
||||
? { kind, id, quantity: count }
|
||||
: kind === 'spell'
|
||||
@@ -145,7 +171,11 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
|
||||
style={{ flex: 2, minWidth: 160 }}
|
||||
>
|
||||
<option value="">What to give…</option>
|
||||
{gold > 0 && <option value="gold:">Gold ({gold} gp)</option>}
|
||||
{coinsHeld.map((c) => (
|
||||
<option key={c} value={`gold:${c}`}>
|
||||
{COIN_LABELS[c]} ({formatMoney(held(c), c)} held)
|
||||
</option>
|
||||
))}
|
||||
{character.weapons.length > 0 && (
|
||||
<optgroup label="Weapons">
|
||||
{character.weapons.map((w) => (
|
||||
@@ -178,8 +208,8 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={max}
|
||||
aria-label={kind === 'gold' ? 'How much gold' : 'How many'}
|
||||
title={kind === 'gold' ? 'How much gold' : `How many (up to ${max})`}
|
||||
aria-label={kind === 'gold' ? 'How many coins' : 'How many'}
|
||||
title={`How many (up to ${max})`}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
style={{ width: 90 }}
|
||||
@@ -207,6 +237,7 @@ export function CharacterSheetPage() {
|
||||
const { characterId = '' } = useParams();
|
||||
const { user } = useAuth();
|
||||
const { data: character, isLoading, error } = useCharacter(characterId);
|
||||
useRememberCampaign(character?.campaignId);
|
||||
const patchCharacter = usePatchCharacter(characterId);
|
||||
useCampaignSocket(character?.campaignId);
|
||||
|
||||
@@ -228,6 +259,7 @@ export function CharacterSheetPage() {
|
||||
const patchSpell = usePatchCharacterSpell(characterId);
|
||||
const removeSpell = useRemoveCharacterSpell(characterId);
|
||||
const adjustGold = useAdjustGold(characterId);
|
||||
const exchangeCoins = useExchangeCoins(characterId);
|
||||
const createNote = useCreateNote();
|
||||
const { data: campaigns } = useCampaigns();
|
||||
const copyCharacter = useCopyCharacter(characterId);
|
||||
@@ -237,6 +269,10 @@ export function CharacterSheetPage() {
|
||||
const [pickedItemId, setPickedItemId] = useState('');
|
||||
const [goldAmount, setGoldAmount] = useState('');
|
||||
const [goldReason, setGoldReason] = useState('');
|
||||
const [goldCurrency, setGoldCurrency] = useState<Currency>('GOLD');
|
||||
const [exchangeAmount, setExchangeAmount] = useState('');
|
||||
const [exchangeFrom, setExchangeFrom] = useState<Coin>('GOLD');
|
||||
const [exchangeTo, setExchangeTo] = useState<Coin>('SILVER');
|
||||
const [noteBody, setNoteBody] = useState('');
|
||||
const [pickedWeaponId, setPickedWeaponId] = useState('');
|
||||
const [pickedSpellId, setPickedSpellId] = useState('');
|
||||
@@ -273,7 +309,9 @@ export function CharacterSheetPage() {
|
||||
const amount = Number(hpDelta);
|
||||
if (!amount) return;
|
||||
const next = character!.currentHp + sign * amount;
|
||||
patchCharacter.mutate({ currentHp: next });
|
||||
setFormError(null);
|
||||
// Healing is refused for a character who has died.
|
||||
patchCharacter.mutate({ currentHp: next }, { onError: (err) => setFormError(extractErrorMessage(err)) });
|
||||
setHpDelta('');
|
||||
}
|
||||
|
||||
@@ -300,7 +338,7 @@ export function CharacterSheetPage() {
|
||||
if (!(amount > 0)) return;
|
||||
setFormError(null);
|
||||
try {
|
||||
await adjustGold.mutateAsync({ amount: sign * amount, reason: goldReason.trim() || undefined });
|
||||
await adjustGold.mutateAsync({ amount: sign * amount, currency: goldCurrency, reason: goldReason.trim() || undefined });
|
||||
setGoldAmount('');
|
||||
setGoldReason('');
|
||||
} catch (err) {
|
||||
@@ -308,6 +346,18 @@ export function CharacterSheetPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExchange() {
|
||||
const amount = Math.floor(Number(exchangeAmount));
|
||||
if (!(amount > 0) || exchangeFrom === exchangeTo) return;
|
||||
setFormError(null);
|
||||
try {
|
||||
await exchangeCoins.mutateAsync({ from: exchangeFrom, to: exchangeTo, amount });
|
||||
setExchangeAmount('');
|
||||
} catch (err) {
|
||||
setFormError(extractErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddNote(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!noteBody.trim()) return;
|
||||
@@ -415,6 +465,14 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
|
||||
<HpBar current={character.currentHp} max={sheet.hp.max} temp={character.tempHp} />
|
||||
<DeathSaves
|
||||
characterId={characterId}
|
||||
name={character.name}
|
||||
deathSaves={character.deathSaves}
|
||||
canAct={canEdit}
|
||||
isDM={isDM}
|
||||
onError={(err) => setFormError(extractErrorMessage(err))}
|
||||
/>
|
||||
|
||||
{canEdit && (
|
||||
<div className="flex-row mt-sm flex-wrap">
|
||||
@@ -497,7 +555,9 @@ export function CharacterSheetPage() {
|
||||
<p className="subtitle mt-sm">📍 Currently at: <strong>{character.currentLocation.name}</strong></p>
|
||||
)}
|
||||
|
||||
<div className="card mt-md">
|
||||
<JumpNav />
|
||||
|
||||
<div data-jump="Abilities" className="card mt-md">
|
||||
<div className="card-title">Ability Scores</div>
|
||||
<div className="grid grid-cols-3">
|
||||
{(Object.keys(sheet.abilities) as Ability[]).map((ability) => (
|
||||
@@ -513,7 +573,7 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 mt-md">
|
||||
<div className="card">
|
||||
<div data-jump="Saves" className="card">
|
||||
<div className="card-title">
|
||||
Saving Throws <InfoTip term="SAVING_THROW" />
|
||||
</div>
|
||||
@@ -526,7 +586,7 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="card">
|
||||
<div data-jump="Skills" className="card">
|
||||
<div className="card-title">
|
||||
Skills <InfoTip term="SKILL_BONUS" />
|
||||
</div>
|
||||
@@ -560,7 +620,7 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card mt-md">
|
||||
<div data-jump="Weapons" className="card mt-md">
|
||||
<div className="card-title">
|
||||
Weapons <InfoTip term="ATTACK_BONUS" />
|
||||
</div>
|
||||
@@ -623,7 +683,7 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
|
||||
{campaignSpells && campaignSpells.length > 0 && (
|
||||
<div className="card mt-md">
|
||||
<div data-jump="Spells" className="card mt-md">
|
||||
<div className="card-title">Spells Known</div>
|
||||
{spellsPage.pageItems.map((cs) => (
|
||||
<div key={cs.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
@@ -687,7 +747,7 @@ export function CharacterSheetPage() {
|
||||
|
||||
{!character.restricted && (
|
||||
<>
|
||||
<div className="card mt-md">
|
||||
<div data-jump="Inventory" className="card mt-md">
|
||||
<div className="card-title">Inventory</div>
|
||||
{itemsPage.pageItems.map((held) => (
|
||||
<div key={held.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
@@ -767,22 +827,34 @@ export function CharacterSheetPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card mt-md">
|
||||
<div data-jump="Money" className="card mt-md">
|
||||
<div className="card-title">
|
||||
<span>Money</span>
|
||||
<span className="mono">{character.gold} gp</span>
|
||||
<span className="mono">{formatPurse(character)}</span>
|
||||
</div>
|
||||
<p className="subtitle" style={{ marginTop: 0 }}>
|
||||
1 gold = 10 silver = 100 copper. Worth {purseWorthInGold(character)} gold in all. Spending and giving make
|
||||
change for you: if you are short of a coin, a bigger one is broken. Cash ($) is kept apart: it is never
|
||||
exchanged for coins.
|
||||
</p>
|
||||
{canEdit && (
|
||||
<div className="flex-row flex-wrap">
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={1000000}
|
||||
placeholder="Amount"
|
||||
aria-label="Gold amount"
|
||||
aria-label="Coin amount"
|
||||
value={goldAmount}
|
||||
onChange={(e) => setGoldAmount(e.target.value)}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<select aria-label="Coin" value={goldCurrency} onChange={(e) => setGoldCurrency(e.target.value as Currency)}>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{COIN_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
placeholder="What for? (optional)"
|
||||
aria-label="Reason"
|
||||
@@ -799,19 +871,56 @@ export function CharacterSheetPage() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{canEdit && (
|
||||
<div className="flex-row flex-wrap mt-sm">
|
||||
<span className="subtitle">Exchange</span>
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={1000000}
|
||||
placeholder="Amount"
|
||||
aria-label="How many coins to exchange"
|
||||
value={exchangeAmount}
|
||||
onChange={(e) => setExchangeAmount(e.target.value)}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<select aria-label="Exchange from" value={exchangeFrom} onChange={(e) => setExchangeFrom(e.target.value as Coin)}>
|
||||
{COINS.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{COIN_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="subtitle">into</span>
|
||||
<select aria-label="Exchange into" value={exchangeTo} onChange={(e) => setExchangeTo(e.target.value as Coin)}>
|
||||
{COINS.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{COIN_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn" onClick={handleExchange} disabled={!exchangeAmount || exchangeFrom === exchangeTo || exchangeCoins.isPending}>
|
||||
Exchange
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-sm">
|
||||
{goldLedger?.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No transactions yet.</p>}
|
||||
{ledgerPage.pageItems.map((t) => (
|
||||
<div key={t.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
<div>
|
||||
<strong className="mono">{t.amount > 0 ? `+${t.amount}` : `−${Math.abs(t.amount)}`}</strong>
|
||||
<strong className="mono">
|
||||
{t.amount > 0 ? '+' : '−'}
|
||||
{formatMoney(Math.abs(t.amount), t.currency ?? 'GOLD')}
|
||||
</strong>
|
||||
<span style={{ marginLeft: 8 }}>{t.reason || (t.amount > 0 ? 'Gained' : 'Spent')}</span>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>
|
||||
{t.createdBy ? `${t.createdBy.displayName} · ` : ''}
|
||||
{new Date(t.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<span className="mono" style={{ color: 'var(--text-muted)' }}>{t.balanceAfter} gp</span>
|
||||
<span className="mono" style={{ color: 'var(--text-muted)' }}>
|
||||
{formatMoney(t.balanceAfter, t.currency ?? 'GOLD')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -895,7 +1004,7 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card mt-md">
|
||||
<div data-jump="Notes" className="card mt-md">
|
||||
<div className="card-title">Notes</div>
|
||||
<form onSubmit={handleAddNote} className="flex-row flex-wrap">
|
||||
<textarea
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
useRemoveParticipant,
|
||||
useAdvanceRound,
|
||||
useNextTurn,
|
||||
useSetTargets,
|
||||
useReviveParticipant,
|
||||
usePatchEncounter,
|
||||
useSetEncounterArchived,
|
||||
useDeleteEncounter,
|
||||
@@ -27,11 +29,15 @@ import { useAuth } from '../auth/AuthContext';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
import { ABILITY_LABELS, CLASS_LABELS, SPECIES_LABELS, extractErrorMessage, fmtMod, spellLevelLabel } from '../lib/format';
|
||||
import { DeathSaves } from '../components/DeathSaves';
|
||||
import { TradePanel } from '../components/TradePanel';
|
||||
import { useRememberCampaign } from '../lib/lastCampaign';
|
||||
import { ABILITY_LABELS, CLASS_LABELS, COIN_LABELS, CURRENCIES, SPECIES_LABELS, extractErrorMessage, fmtMod, formatMoney, spellLevelLabel } from '../lib/format';
|
||||
import { abilityModifier, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
import type {
|
||||
Ability,
|
||||
CharacterSummary,
|
||||
Currency,
|
||||
Encounter,
|
||||
EncounterMode,
|
||||
EncounterParticipant,
|
||||
@@ -60,6 +66,11 @@ const CONDITIONS = [
|
||||
'Concentrating',
|
||||
];
|
||||
|
||||
// An attack on more than one target is an area attack, and harder to land:
|
||||
// every target's AC counts as `step` higher for each target beyond the first.
|
||||
// The step is the campaign's "area attack AC penalty" setting.
|
||||
const areaPenalty = (targetCount: number, step: number) => Math.max(0, targetCount - 1) * step;
|
||||
|
||||
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' };
|
||||
@@ -94,20 +105,32 @@ function subline(p: EncounterParticipant): string {
|
||||
function CombatantCard({
|
||||
participant: p,
|
||||
isDM,
|
||||
mode,
|
||||
isActive,
|
||||
onPatch,
|
||||
canRollInitiative,
|
||||
targetedBy,
|
||||
acPenalty,
|
||||
onTarget,
|
||||
onRemove,
|
||||
onError,
|
||||
}: {
|
||||
participant: EncounterParticipant;
|
||||
isDM: boolean;
|
||||
mode: EncounterMode;
|
||||
isActive: boolean;
|
||||
onPatch: (data: Partial<EncounterParticipant>) => void;
|
||||
// The DM for anyone; a player for their own character.
|
||||
// The DM for anyone; a player for their own character. The same goes for
|
||||
// that character's death saves.
|
||||
canRollInitiative: boolean;
|
||||
// Set while this combatant is a target: who is going for them, or '' when
|
||||
// nobody is on turn.
|
||||
targetedBy?: string;
|
||||
// What an area attack adds to this target's AC; 0 when they are the only one.
|
||||
acPenalty: number;
|
||||
// Marks this combatant as a target, or unmarks them. Absent for someone who
|
||||
// may not pick targets right now, and on the card of whoever is on turn.
|
||||
onTarget?: () => void;
|
||||
onRemove: () => void;
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const [hpDelta, setHpDelta] = useState('');
|
||||
// Details start open for the DM (who runs the scene from this screen) and
|
||||
@@ -126,6 +149,9 @@ function CombatantCard({
|
||||
const tracksHp = barOnly || maxHp > 0;
|
||||
const pct = barOnly ? (p.hpPercent ?? 0) : tracksHp ? Math.max(0, Math.min(100, (currentHp / maxHp) * 100)) : 0;
|
||||
const isDown = tracksHp && pct <= 0;
|
||||
// A dying character still has something to do each turn, so its card stays lit.
|
||||
const isDying = info?.deathSaves.state === 'DYING';
|
||||
const isTargeted = targetedBy !== undefined;
|
||||
// Players get a monster's name and health bar only; the stat block is the DM's.
|
||||
const monsterStats = monster && monster.ac !== undefined ? monster : null;
|
||||
const hasDetails = Boolean(info || monsterStats || npc?.dmNotes);
|
||||
@@ -144,10 +170,16 @@ function CombatantCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`card combatant combatant-${p.kind.toLowerCase()}${isActive ? ' is-active' : ''}${isDown ? ' is-down' : ''}`}>
|
||||
<div className={`card combatant combatant-${p.kind.toLowerCase()}${isActive ? ' is-active' : ''}${isDown && !isDying ? ' is-down' : ''}${isTargeted ? ' is-targeted' : ''}`}>
|
||||
<div className="flex-between flex-wrap">
|
||||
<div>
|
||||
{isActive && <div className="turn-marker">▶ On turn</div>}
|
||||
{isTargeted && (
|
||||
<div className="target-marker">
|
||||
🎯 Targeted{targetedBy && ` by ${targetedBy}`}
|
||||
{acPenalty > 0 && ` · area attack, AC +${acPenalty}`}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-row flex-wrap">
|
||||
<span className={`badge ${KIND_BADGES[p.kind]}`}>{KIND_LABELS[p.kind]}</span>
|
||||
{p.character ? (
|
||||
@@ -158,12 +190,27 @@ function CombatantCard({
|
||||
<span className="combatant-name">{p.displayName}</span>
|
||||
)}
|
||||
{npc && <span className={`badge badge-${npc.disposition.toLowerCase()}`}>{npc.disposition}</span>}
|
||||
{isDown && <span className="badge badge-hostile">Down</span>}
|
||||
{isDown && !info && <span className="badge badge-hostile">Down</span>}
|
||||
{p.hidden && (
|
||||
<span className="badge" title="Out of the fight. Players no longer see this card, and it has no turn.">
|
||||
Hidden from players
|
||||
</span>
|
||||
)}
|
||||
{onTarget && (
|
||||
<button
|
||||
className={`btn btn-target${isTargeted ? ' is-on' : ''}`}
|
||||
aria-pressed={isTargeted}
|
||||
title={isTargeted ? 'Stop targeting' : `Mark ${p.displayName} as a target. Marking more than one makes it an area attack.`}
|
||||
onClick={onTarget}
|
||||
>
|
||||
🎯 {isTargeted ? 'Targeted' : 'Target'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="combatant-sub">{subline(p)}</div>
|
||||
</div>
|
||||
{mode === 'BATTLE' &&
|
||||
(canRollInitiative ? (
|
||||
{/* Battles and quests both take initiative: who speaks or acts first matters in either. */}
|
||||
{canRollInitiative ? (
|
||||
<label className="flex-row gap-sm" title="Type what the d20 showed (1 to 20). The initiative bonus is added for you.">
|
||||
Initiative roll
|
||||
<NumberInput
|
||||
@@ -186,7 +233,7 @@ function CombatantCard({
|
||||
</label>
|
||||
) : (
|
||||
<StatChip label="Initiative">{p.initiativeRoll == null ? '—' : p.initiative}</StatChip>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="stat-chips mt-sm">
|
||||
@@ -244,6 +291,16 @@ function CombatantCard({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{info && p.character && (
|
||||
<DeathSaves
|
||||
characterId={p.character.id}
|
||||
name={p.displayName}
|
||||
deathSaves={info.deathSaves}
|
||||
canAct={canRollInitiative}
|
||||
isDM={isDM}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -380,8 +437,178 @@ function CombatantCard({
|
||||
);
|
||||
}
|
||||
|
||||
// What a combatant hits with, one line per attack: a character's weapons
|
||||
// (the equipped ones, when any are) and damaging spells, or the single attack
|
||||
// of a monster or NPC.
|
||||
function attackLines(p: EncounterParticipant): string[] {
|
||||
const info = p.characterCombatInfo;
|
||||
if (info) {
|
||||
const equipped = info.weapons.filter((w) => w.equipped);
|
||||
const weapons = (equipped.length ? equipped : info.weapons).map(
|
||||
(w) => `${w.name}: ${fmtMod(w.attackBonus)} to hit · ${w.damageDice} ${fmtMod(w.damageBonus)} ${w.damageType}`
|
||||
);
|
||||
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}` : ''}`);
|
||||
return [...weapons, ...spells];
|
||||
}
|
||||
const stats = p.monster ?? p.npc;
|
||||
if (!stats || (stats.attackBonus == null && !stats.damageDice)) return [];
|
||||
return [[stats.attackBonus != null && `${fmtMod(stats.attackBonus)} to hit`, stats.damageDice].filter(Boolean).join(' · ')];
|
||||
}
|
||||
|
||||
type AreaMode = 'SPLIT' | 'EACH';
|
||||
|
||||
// How `amount` lands on `count` targets: all of it on each, or shared out
|
||||
// evenly, any remainder going one point each to the first targets picked
|
||||
// (7 between three is 3, 2 and 2).
|
||||
function shares(amount: number, count: number, mode: AreaMode): number[] {
|
||||
if (mode === 'EACH') return Array.from({ length: count }, () => amount);
|
||||
const base = Math.floor(amount / count);
|
||||
return Array.from({ length: count }, (_, i) => base + (i < amount % count ? 1 : 0));
|
||||
}
|
||||
|
||||
// The DM's shortcut once targets are marked: who is attacking whom, the
|
||||
// numbers needed to call the hit, and a box to apply the damage without
|
||||
// hunting for each target's card. With more than one target it is an area
|
||||
// attack: each AC is shown with the penalty added, the DM ticks who was hit,
|
||||
// and the amount is split between them or dealt to each in full.
|
||||
function AttackBar({
|
||||
attacker,
|
||||
targets,
|
||||
acStep,
|
||||
onApply,
|
||||
onRemove,
|
||||
onClear,
|
||||
}: {
|
||||
attacker: EncounterParticipant | undefined;
|
||||
targets: EncounterParticipant[];
|
||||
acStep: number; // the campaign's area attack AC penalty
|
||||
onApply: (changes: { participantId: string; currentHp: number }[]) => void;
|
||||
onRemove: (participantId: string) => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const [amount, setAmount] = useState('');
|
||||
const [mode, setMode] = useState<AreaMode>('SPLIT');
|
||||
const [missed, setMissed] = useState<string[]>([]);
|
||||
|
||||
const isArea = targets.length > 1;
|
||||
const penalty = areaPenalty(targets.length, acStep);
|
||||
const tracksHp = (t: EncounterParticipant) => (t.maxHp ?? 0) > 0;
|
||||
const hit = targets.filter((t) => tracksHp(t) && !missed.includes(t.id));
|
||||
const value = Number(amount) || 0;
|
||||
const portions = shares(value, hit.length, isArea ? mode : 'EACH');
|
||||
const attacks = attacker ? attackLines(attacker) : [];
|
||||
|
||||
function apply(sign: 1 | -1) {
|
||||
if (!value || hit.length === 0) return;
|
||||
onApply(hit.map((t, i) => ({ participantId: t.id, currentHp: (t.currentHp ?? 0) + sign * portions[i] })));
|
||||
setAmount('');
|
||||
setMissed([]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card attack-bar">
|
||||
<div className="flex-between flex-wrap">
|
||||
<div className="attack-bar-title">
|
||||
{attacker && <>{attacker.displayName} → </>}🎯 {isArea ? `${targets.length} targets` : targets[0].displayName}
|
||||
</div>
|
||||
<button className="btn btn-ghost" onClick={onClear}>
|
||||
✕ Clear {isArea ? 'targets' : 'target'}
|
||||
</button>
|
||||
</div>
|
||||
{isArea && (
|
||||
<p className="combatant-sub" style={{ margin: '0.25rem 0 0' }}>
|
||||
Area attack{penalty > 0 && `: every target's AC counts as ${penalty} higher (+${acStep} for each target beyond the first)`}. Untick anyone the
|
||||
roll missed.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{targets.map((t) => {
|
||||
const info = t.characterCombatInfo;
|
||||
const ac = info?.armorClass ?? t.monster?.ac ?? t.npc?.ac;
|
||||
const portion = portions[hit.indexOf(t)];
|
||||
return (
|
||||
<div key={t.id} className="attack-target">
|
||||
{isArea && (
|
||||
<label className="flex-row gap-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${t.displayName} was hit`}
|
||||
disabled={!tracksHp(t)}
|
||||
checked={hit.includes(t)}
|
||||
onChange={(e) => setMissed(e.target.checked ? missed.filter((id) => id !== t.id) : [...missed, t.id])}
|
||||
/>
|
||||
<span className="attack-target-name">{t.displayName}</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="stat-chips">
|
||||
{ac != null && (
|
||||
<StatChip label={isArea ? 'AC to beat' : 'Target AC'}>
|
||||
{ac + penalty}
|
||||
{penalty > 0 && ` (${ac} +${penalty})`}
|
||||
</StatChip>
|
||||
)}
|
||||
{tracksHp(t) ? (
|
||||
<StatChip label={isArea ? 'HP' : 'Target HP'}>
|
||||
{t.currentHp ?? 0}/{t.maxHp}
|
||||
{info && info.tempHp > 0 && ` +${info.tempHp}`}
|
||||
</StatChip>
|
||||
) : (
|
||||
<span className="combatant-sub">No hit points to track.</span>
|
||||
)}
|
||||
{isArea && value > 0 && portion !== undefined && <StatChip label="Share">{portion}</StatChip>}
|
||||
</div>
|
||||
{isArea && (
|
||||
<button className="btn btn-ghost" aria-label={`Stop targeting ${t.displayName}`} onClick={() => onRemove(t.id)}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{attacks.map((line) => (
|
||||
<div key={line} className="combatant-sub mt-sm">
|
||||
⚔ {line}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{targets.some(tracksHp) && (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
{/* Remounted when the targets change, so the cursor lands here as soon as one is marked. */}
|
||||
<NumberInput
|
||||
key={targets.map((t) => t.id).join()}
|
||||
autoFocus
|
||||
min={1}
|
||||
max={99999}
|
||||
placeholder={isArea ? 'Total' : 'Amount'}
|
||||
aria-label={isArea ? 'HP change for the targets hit' : `HP change for ${targets[0].displayName}`}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && apply(-1)}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
{isArea && (
|
||||
<select aria-label="How the amount is shared" value={mode} onChange={(e) => setMode(e.target.value as AreaMode)} style={{ width: 'auto' }}>
|
||||
<option value="SPLIT">Split between those hit</option>
|
||||
<option value="EACH">Full amount to each</option>
|
||||
</select>
|
||||
)}
|
||||
<button className="btn btn-danger" onClick={() => apply(-1)} disabled={!value || hit.length === 0}>
|
||||
− Damage
|
||||
</button>
|
||||
<button className="btn" onClick={() => apply(1)} disabled={!value || hit.length === 0}>
|
||||
+ Heal
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function rewardLabel(r: EncounterReward): string {
|
||||
if (r.type === 'GOLD') return `${r.gold} gold`;
|
||||
if (r.type === 'GOLD') return formatMoney(r.gold ?? 0, r.currency ?? 'GOLD', true);
|
||||
const entry = r.type === 'WEAPON' ? r.weapon : r.item;
|
||||
return `${entry?.name ?? 'Removed from the pool'}${r.quantity > 1 ? ` ×${r.quantity}` : ''} · Lv ${r.level}`;
|
||||
}
|
||||
@@ -407,6 +634,7 @@ function RewardsPanel({
|
||||
|
||||
const [type, setType] = useState<NewReward['type']>('GOLD');
|
||||
const [gold, setGold] = useState('');
|
||||
const [currency, setCurrency] = useState<Currency>('GOLD');
|
||||
const [poolId, setPoolId] = useState('');
|
||||
const [quantity, setQuantity] = useState('1');
|
||||
const [level, setLevel] = useState('');
|
||||
@@ -426,7 +654,7 @@ function RewardsPanel({
|
||||
if (type === 'GOLD') {
|
||||
const amount = Math.floor(Number(gold));
|
||||
if (!(amount > 0)) return;
|
||||
reward = { type, gold: amount, ...shared };
|
||||
reward = { type, gold: amount, currency, ...shared };
|
||||
} else {
|
||||
if (!picked) return;
|
||||
const gear = { quantity: Math.max(1, Math.floor(Number(quantity)) || 1), level: level ? Number(level) : undefined, ...shared };
|
||||
@@ -449,7 +677,7 @@ function RewardsPanel({
|
||||
<div className="card-title">Rewards</div>
|
||||
{isDM && rewards.length === 0 && (
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
Line up gold, weapons and items here. Players only see a reward once you award it.
|
||||
Line up coins, weapons and items here. Players only see a reward once you award it.
|
||||
</p>
|
||||
)}
|
||||
{rewards.map((r) => (
|
||||
@@ -519,20 +747,29 @@ function RewardsPanel({
|
||||
setLevel('');
|
||||
}}
|
||||
>
|
||||
<option value="GOLD">Gold</option>
|
||||
<option value="GOLD">Coins</option>
|
||||
<option value="WEAPON">Weapon</option>
|
||||
<option value="ITEM">Item</option>
|
||||
</select>
|
||||
{type === 'GOLD' ? (
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={1000000}
|
||||
placeholder="Amount"
|
||||
aria-label="Gold amount"
|
||||
value={gold}
|
||||
onChange={(e) => setGold(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 90 }}
|
||||
/>
|
||||
<>
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={1000000}
|
||||
placeholder="Amount"
|
||||
aria-label="Coin amount"
|
||||
value={gold}
|
||||
onChange={(e) => setGold(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 90 }}
|
||||
/>
|
||||
<select aria-label="Coin" value={currency} onChange={(e) => setCurrency(e.target.value as Currency)}>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{COIN_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
@@ -599,6 +836,7 @@ export function EncounterPage() {
|
||||
const { user } = useAuth();
|
||||
const { data: encounter, isLoading, error } = useEncounter(encounterId);
|
||||
useCampaignSocket(encounter?.campaignId);
|
||||
useRememberCampaign(encounter?.campaignId);
|
||||
|
||||
const { data: campaignData } = useCampaign(encounter?.campaignId);
|
||||
const isDM = campaignData?.campaign.role === 'DM';
|
||||
@@ -614,6 +852,8 @@ export function EncounterPage() {
|
||||
const removeParticipant = useRemoveParticipant(encounterId);
|
||||
const advanceRound = useAdvanceRound(encounterId);
|
||||
const nextTurn = useNextTurn(encounterId);
|
||||
const setTargets = useSetTargets(encounterId);
|
||||
const revive = useReviveParticipant(encounterId);
|
||||
const patchEncounter = usePatchEncounter(encounterId);
|
||||
const setArchived = useSetEncounterArchived(encounter?.campaignId ?? '');
|
||||
const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? '');
|
||||
@@ -623,20 +863,39 @@ export function EncounterPage() {
|
||||
const [pickId, setPickId] = useState('');
|
||||
const [initiative, setInitiative] = useState('');
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
// The DM's own screen leaves the fallen out too, until asked for.
|
||||
const [showDead, setShowDead] = useState(false);
|
||||
|
||||
if (isLoading) return <Spinner label="Setting the scene…" />;
|
||||
if (error || !encounter) return <ErrorBanner message="Could not load this encounter." />;
|
||||
|
||||
const isBattle = encounter.mode === 'BATTLE';
|
||||
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.
|
||||
const standing = participants.filter((p) => !p.hidden);
|
||||
const outOfTheFight = participants.filter((p) => p.hidden);
|
||||
const fallen = encounter.fallen ?? [];
|
||||
const active = isBattle && encounter.status === 'ACTIVE' ? participants.find((p) => p.id === encounter.activeParticipantId) : undefined;
|
||||
const targetIds = encounter.targetParticipantIds ?? [];
|
||||
const targets = targetIds.map((id) => participants.find((p) => p.id === id)).filter((p): p is EncounterParticipant => p !== undefined);
|
||||
// The DM marks targets whenever they like; a player while their own character is on turn.
|
||||
const acStep = campaignData?.campaign.areaAttackAcPenalty ?? 0;
|
||||
const canTarget = isDM || (active?.character != null && active.character.userId === user?.id);
|
||||
// A player whose own character is on turn, on their feet and able to raise the dead.
|
||||
const canTryRevive =
|
||||
active?.character != null &&
|
||||
active.character.userId === user?.id &&
|
||||
active.characterCombatInfo?.deathSaves.state === 'UP' &&
|
||||
active.characterCombatInfo.canRevive;
|
||||
const builtCharacters = characters?.filter((c) => c.buildStage === 'COMPLETE') ?? [];
|
||||
const onError = (err: unknown) => setFormError(extractErrorMessage(err));
|
||||
const fail = { onError };
|
||||
|
||||
const pickOptions: { id: string; name: string }[] =
|
||||
pickType === 'character'
|
||||
? builtCharacters.filter((c) => !participants.some((p) => p.characterId === c.id))
|
||||
? // The dead join no new scene until they are revived.
|
||||
builtCharacters.filter((c) => c.deathSaves.state !== 'DEAD' && !participants.some((p) => p.characterId === c.id))
|
||||
: pickType === 'monster'
|
||||
? (monsters ?? [])
|
||||
: (npcs ?? []).filter((n) => !participants.some((p) => p.npcId === n.id));
|
||||
@@ -655,22 +914,42 @@ export function EncounterPage() {
|
||||
key={p.id}
|
||||
participant={p}
|
||||
isDM={isDM}
|
||||
mode={encounter.mode}
|
||||
isActive={p.id === active?.id}
|
||||
onPatch={(data) => patchParticipant.mutate({ participantId: p.id, ...data }, fail)}
|
||||
canRollInitiative={isDM || (p.character != null && p.character.userId === user?.id)}
|
||||
targetedBy={targetIds.includes(p.id) ? (active?.displayName ?? '') : undefined}
|
||||
acPenalty={areaPenalty(targets.length, acStep)}
|
||||
onTarget={
|
||||
// Nobody targets themselves: the combatant on turn has no button.
|
||||
canTarget && p.id !== active?.id
|
||||
? () => setTargets.mutate(targetIds.includes(p.id) ? targetIds.filter((id) => id !== p.id) : [...targetIds, p.id], fail)
|
||||
: undefined
|
||||
}
|
||||
onRemove={() => removeParticipant.mutate(p.id, fail)}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
|
||||
// A quest scene is grouped by who is there rather than by initiative.
|
||||
// In a battle under way the turn passes down the initiative order, so
|
||||
// everyone above whoever is on turn has had their go this round. They drop
|
||||
// to "Next Round" at the bottom, which leaves the combatant on turn at the
|
||||
// top with those still to act beneath them. A new round puts everyone back.
|
||||
const turnIndex = active ? participants.indexOf(active) : -1;
|
||||
const hasActed = (p: EncounterParticipant) => participants.indexOf(p) < turnIndex;
|
||||
|
||||
// A quest scene is grouped by who is there; within each group the order is
|
||||
// still initiative, highest first.
|
||||
const groups: [string, EncounterParticipant[]][] = isBattle
|
||||
? [['Initiative order', participants]]
|
||||
? [
|
||||
['Initiative order', standing.filter((p) => !hasActed(p))],
|
||||
['Next Round', standing.filter(hasActed)],
|
||||
]
|
||||
: [
|
||||
['People you meet', participants.filter((p) => p.kind === 'NPC')],
|
||||
['The party', participants.filter((p) => p.kind === 'CHARACTER')],
|
||||
['Creatures', participants.filter((p) => p.kind === 'MONSTER')],
|
||||
['People you meet', standing.filter((p) => p.kind === 'NPC')],
|
||||
['The party', standing.filter((p) => p.kind === 'CHARACTER')],
|
||||
['Creatures', standing.filter((p) => p.kind === 'MONSTER')],
|
||||
];
|
||||
if (showDead) groups.push(['Out of the fight, hidden from players', outOfTheFight]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -793,7 +1072,51 @@ export function EncounterPage() {
|
||||
|
||||
<div className="encounter-layout mt-md">
|
||||
<div>
|
||||
{participants.length === 0 && (
|
||||
{isDM && targets.length > 0 && (
|
||||
<AttackBar
|
||||
attacker={active}
|
||||
targets={targets}
|
||||
acStep={acStep}
|
||||
onApply={(changes) => Promise.all(changes.map((change) => patchParticipant.mutateAsync(change))).catch(onError)}
|
||||
onRemove={(id) => setTargets.mutate(targetIds.filter((x) => x !== id), fail)}
|
||||
onClear={() => setTargets.mutate([], fail)}
|
||||
/>
|
||||
)}
|
||||
{!isDM && fallen.length > 0 && (
|
||||
<div className="card fallen-bar">
|
||||
<div className="card-title">☠ Fallen</div>
|
||||
{fallen.map((f) => (
|
||||
<div key={f.participantId} className="flex-between flex-wrap">
|
||||
<span className="combatant-name">{f.name}</span>
|
||||
{canTryRevive && (
|
||||
<button
|
||||
className="btn"
|
||||
title={`${active?.displayName} tries to bring ${f.name} back, at 1 HP.`}
|
||||
disabled={revive.isPending}
|
||||
onClick={() => {
|
||||
setFormError(null);
|
||||
revive.mutate(f.participantId, fail);
|
||||
}}
|
||||
>
|
||||
Try revive
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<p className="combatant-sub" style={{ margin: '0.4rem 0 0' }}>
|
||||
{canTryRevive
|
||||
? `${active?.displayName} has a spell that can bring back the dead.`
|
||||
: 'A character with a spell that brings back the dead can try to revive them on their own turn.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isDM && outOfTheFight.length > 0 && (
|
||||
<label className="checkbox-row mt-md" title="Dead characters, and monsters and NPCs at 0 HP. Players never see them.">
|
||||
<input type="checkbox" checked={showDead} onChange={(e) => setShowDead(e.target.checked)} />
|
||||
Show dead ({outOfTheFight.length})
|
||||
</label>
|
||||
)}
|
||||
{participants.length === 0 && fallen.length === 0 && (
|
||||
<div className="empty-state card">
|
||||
{isDM
|
||||
? 'Nobody here yet. Add characters, monsters or NPCs from the panel alongside.'
|
||||
@@ -839,18 +1162,16 @@ export function EncounterPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-row gap-sm mt-sm">
|
||||
{isBattle && (
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={20}
|
||||
placeholder="Initiative roll (d20)"
|
||||
title="What the d20 showed, 1 to 20. Leave empty to roll later; the bonus is added for you."
|
||||
aria-label="Initiative roll"
|
||||
value={initiative}
|
||||
onChange={(e) => setInitiative(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
)}
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={20}
|
||||
placeholder="Initiative roll (d20)"
|
||||
title="What the d20 showed, 1 to 20. Leave empty to roll later; the bonus is added for you."
|
||||
aria-label="Initiative roll"
|
||||
value={initiative}
|
||||
onChange={(e) => setInitiative(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleAdd} disabled={!pickId || addParticipant.isPending}>
|
||||
Add
|
||||
</button>
|
||||
@@ -863,6 +1184,16 @@ export function EncounterPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TradePanel
|
||||
encounter={encounter}
|
||||
isDM={isDM}
|
||||
userId={user?.id}
|
||||
characters={builtCharacters}
|
||||
weapons={weapons ?? []}
|
||||
items={items ?? []}
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
<RewardsPanel
|
||||
encounter={encounter}
|
||||
isDM={isDM}
|
||||
|
||||
@@ -5,8 +5,9 @@ import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { usePagination, Pagination } from '../components/Paginated';
|
||||
import { InfoTip } from '../components/InfoTip';
|
||||
import { CLASS_LABELS, SPECIES_LABELS } from '../lib/format';
|
||||
import { CLASS_LABELS, SPECIES_LABELS, formatPurse } from '../lib/format';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
import { LifeBadge } from '../components/DeathSaves';
|
||||
import { speciesIcon } from '../lib/speciesIcons';
|
||||
|
||||
// A DM's one-screen view of every character's stats at a glance; a player
|
||||
@@ -56,7 +57,7 @@ export function PartyOverviewPage() {
|
||||
<th>
|
||||
AC <InfoTip term="AC" />
|
||||
</th>
|
||||
<th>Gold</th>
|
||||
<th>Coins</th>
|
||||
<th>Location</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -66,31 +67,32 @@ export function PartyOverviewPage() {
|
||||
const isMine = c.userId === user?.id;
|
||||
return (
|
||||
<tr key={c.id}>
|
||||
<td>
|
||||
<td className="cell-title">
|
||||
<Link to={inProgress ? `/characters/${c.id}/build` : `/characters/${c.id}`}>
|
||||
<span aria-hidden="true">{speciesIcon(c.species)}</span> {c.name}
|
||||
</Link>
|
||||
{isMine && <span className="badge" style={{ marginLeft: 6 }}>you</span>}
|
||||
</td>
|
||||
<td>{c.species ? SPECIES_LABELS[c.species] : '—'}</td>
|
||||
<td>{c.characterClass ? CLASS_LABELS[c.characterClass] : inProgress ? 'Building…' : '—'}</td>
|
||||
<td>{c.level}</td>
|
||||
<td>
|
||||
<td data-label="Species">{c.species ? SPECIES_LABELS[c.species] : '—'}</td>
|
||||
<td data-label="Class">{c.characterClass ? CLASS_LABELS[c.characterClass] : inProgress ? 'Building…' : '—'}</td>
|
||||
<td data-label="Level">{c.level}</td>
|
||||
<td data-label="HP">
|
||||
{c.maxHp != null ? (
|
||||
<div style={{ minWidth: 90 }}>
|
||||
<span className="mono">
|
||||
{c.currentHp}/{c.maxHp}
|
||||
{c.tempHp > 0 && ` +${c.tempHp}`}
|
||||
</span>
|
||||
</span>{' '}
|
||||
<LifeBadge deathSaves={c.deathSaves} />
|
||||
<HpBar current={c.currentHp} max={c.maxHp} temp={c.tempHp} height={6} />
|
||||
</div>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
<td>{c.armorClass ?? '—'}</td>
|
||||
<td className="mono">{c.gold ?? '—'}</td>
|
||||
<td>{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
|
||||
<td data-label="AC">{c.armorClass ?? '—'}</td>
|
||||
<td data-label="Coins" className="mono">{c.gold == null ? '—' : formatPurse(c)}</td>
|
||||
<td data-label="Location">{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
Generated
+4
-4
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.3.0",
|
||||
"version": "1.10.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.3.0",
|
||||
"version": "1.10.1",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"version": "1.3.0",
|
||||
"version": "1.10.1",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.20.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
@@ -31,7 +31,7 @@
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"version": "1.3.0",
|
||||
"version": "1.10.1",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"axios": "^1.20.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.3.0",
|
||||
"version": "1.10.1",
|
||||
"private": true,
|
||||
"description": "D&D 5e campaign manager — monorepo root",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user