Compare commits

..
4 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 c852ecb2e4 V1.10.0: NPCs change money and sell things
- An NPC can change gold and cash at a rate of their own ("2 gold = $15"),
  the same both ways. Only gold and cash are taken: no silver, no copper
  and no change, and the amount has to be a whole multiple of the rate.
- An NPC can sell weapons and items from the campaign pools, each with a
  price in any coin or in cash, a level and an optional stock. The wares
  follow the NPC into every quest. An encounter can sell things itself
  too, for a stall with nobody behind it.
- Trading is open only in an NPC quest in progress, never in a battle,
  with an NPC who is in the scene and standing, for a character who is in
  it. Players see every rate and ware and trade for their own character
  in a new Trade card; the DM can trade for anyone.
- Only the DM sets trade up: "Trade" on an NPC's row on the campaign page,
  and the DM's part of the Trade card for an encounter's own wares. A
  ware's price and stock can be edited afterwards.
- Every exchange and purchase is written to the character's money log.
- All packages at version 1.10.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:45:40 +02:00
joshuaandClaude Opus 5.5 bdce3399ee V1.9.0: full monster stat blocks, and cash ($)
- "+ Add Monster" takes the whole stat block: challenge rating, speed, the
  six ability scores, special trait and notes, as well as name, AC, HP,
  attack and damage. Damage can name its type ("1d6+2 necrotic").
- Monsters can be edited: "Edit" on a row opens the same form filled in.
  Copies already in encounters show the new stat block.
- The monster list shows each full stat block, the library picker shows
  speed and ability scores too, and removing a monster asks first.
- Characters have a fourth balance, cash ($), for crossover worlds. It is
  gained, spent, given and awarded like a coin, but it has no worth in
  coins: it is never exchanged or used to make change.
- `npm test` in the backend runs again (the script pointed at a jest path
  that does not exist in the workspace).
- All packages at version 1.9.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:15:55 +02:00
joshuaandClaude Opus 5.5 f764878960 V1.8.1: combatants who have had their turn move to "Next Round"
- In a battle in progress, whoever is on turn is at the top of the list
  with those still to act beneath them. Each combatant whose turn has
  passed drops to a "Next Round" section at the bottom, and a new round
  puts everyone back.
- All packages at version 1.8.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:49:44 +02:00
joshuaandClaude Opus 5.5 6b466d497e V1.8.0: the fallen are hidden, and allies can revive the dead
- A dead character, or a monster or NPC at 0 HP, is out of the fight: the
  server no longer sends it to players, target marks included, and it has
  no turn. Healing or reviving brings it straight back.
- The DM's screen leaves the fallen out too until "Show dead" is ticked,
  which lists them in a group of their own, marked hidden.
- Players see dead characters by name in a Fallen box. The player whose
  character is on turn in a battle gets "Try revive" there if that
  character is on their feet and knows a spell that revives, at a level
  they can cast. The dead come back at 1 HP.
- Spells have a "Revives" tick in the campaign's spell list. Revivify and
  Raise Dead have it, in the library and in existing campaigns.
- A dead character cannot be added to an encounter until revived.
- All packages at version 1.8.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:25:46 +02:00
33 changed files with 2096 additions and 226 deletions
+3 -3
View File
@@ -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
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.7.2",
"version": "1.10.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
@@ -10,7 +10,7 @@
"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",
@@ -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;
+45 -3
View File
@@ -118,12 +118,14 @@ enum RewardType {
ITEM
}
// The coins a character carries. Their worth is fixed in utils/grants.js:
// 1 gold = 10 silver = 100 copper.
// 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 {
@@ -186,6 +188,7 @@ model Campaign {
monsters Monster[]
npcs Npc[]
encounters Encounter[]
saleOffers SaleOffer[]
notes Note[]
locations Location[]
@@ -278,11 +281,12 @@ model Character {
// 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 is counted on its own and only changes through the
// 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?
@@ -369,6 +373,7 @@ model Weapon {
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
assigned CharacterWeapon[]
rewards EncounterReward[]
offers SaleOffer[]
@@map("weapons")
}
@@ -403,6 +408,7 @@ model Item {
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
held InventoryItem[]
rewards EncounterReward[]
offers SaleOffer[]
@@map("items")
}
@@ -458,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[]
@@ -521,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")
}
@@ -559,6 +573,7 @@ model Encounter {
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
participants EncounterParticipant[]
rewards EncounterReward[]
wares SaleOffer[]
@@map("encounters")
}
@@ -588,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
+2 -2
View File
@@ -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'] },
];
+270 -4
View File
@@ -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 });
});
});
@@ -2117,9 +2185,82 @@ describe('death saves', () => {
// Healed, then hit for max HP more than they have left.
await strike(max);
await strike(-max);
expect((await combatant()).characterCombatInfo.deathSaves.state).toBe('DEAD');
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', () => {
@@ -2264,3 +2405,128 @@ describe('paying with change', () => {
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([]);
});
});
+108 -3
View File
@@ -8,6 +8,7 @@ const { computeCharacterSheet, MAX_DAMAGE_SCALING, MAX_GEAR_LEVEL } = require('.
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();
@@ -201,6 +202,7 @@ function characterSummary(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,
@@ -225,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, silver: null, copper: null, ...(knowsPlace ? {} : { currentLocationId: null, currentLocationName: null }) };
return { ...c, gold: null, silver: null, copper: null, cash: null, ...(knowsPlace ? {} : { currentLocationId: null, currentLocationName: null }) };
}),
});
} catch (err) {
@@ -405,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) => {
@@ -524,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(),
@@ -643,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);
@@ -660,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) {
@@ -677,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);
@@ -778,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
+15 -8
View File
@@ -29,7 +29,7 @@ const {
} = require('../game/buildFlow');
const characterOptions = require('../game/data/characterOptions');
const { SKILLS } = require('../game/data/skills');
const { CURRENCIES, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, spendCoins, worthInGold, 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, NO_DEATH_SAVES, lifeState, deathSaveInfo, settleDeathSaves, changeHp, rollDeathSave } = require('../utils/hp');
@@ -97,6 +97,7 @@ function serializeCharacter(character, isOwner, isDM = true) {
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,
@@ -173,8 +174,8 @@ function clamp(value, min, max) {
router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
if (req.body && ['gold', 'silver', 'copper'].some((coin) => req.body[coin] !== undefined)) {
return res.status(400).json({ error: 'Gold, silver and copper 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() });
@@ -425,6 +426,7 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
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
@@ -938,8 +940,11 @@ function coinName(currency) {
return currency.toLowerCase();
}
// For a payment the whole purse can't cover.
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)}.`;
}
@@ -951,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, silver: req.character.silver, copper: req.character.copper, transactions });
res.json({ gold: req.character.gold, silver: req.character.silver, copper: req.character.copper, cash: req.character.cash, transactions });
} catch (err) {
next(err);
}
@@ -961,6 +966,7 @@ router.get('/:id/gold', requireCharacterAccess(), async (req, res, next) => {
// 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
@@ -986,7 +992,7 @@ router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, re
}
emitCharacterUpdated(req);
const purse = await prisma.character.findUnique({ where: { id: req.character.id }, select: { gold: true, silver: true, copper: true } });
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);
@@ -996,11 +1002,12 @@ router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, re
// 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.
// 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(CURRENCIES), to: z.enum(CURRENCIES), amount: z.number().int().min(1).max(1000000) })
.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() });
+269 -24
View File
@@ -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 { CURRENCIES, adjustGold, giveGear } = require('../utils/grants');
const { CURRENCIES, InsufficientGoldError, adjustGold, spendCoins, giveGear } = require('../utils/grants');
const { moveCharacters, visitedLocationIds } = require('../utils/locations');
const { lifeState, deathSaveInfo, changeHp } = 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
@@ -104,6 +141,7 @@ function characterCombatInfo(character) {
maxHp: sheet.hp.max,
tempHp: character.tempHp,
deathSaves: deathSaveInfo(character),
canRevive: Boolean(revivalSpell(character)),
speed: sheet.speed,
initiativeBonus: sheet.initiative,
passivePerception: sheet.passivePerception,
@@ -117,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
@@ -142,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.
@@ -187,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),
};
}
@@ -255,11 +309,10 @@ 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.
@@ -317,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;
@@ -428,16 +485,16 @@ 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, and
// their targets go with the turn. Removing a target just unmarks it.
// 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 = order.length > 1 ? order[(index + 1) % order.length] : null;
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);
@@ -450,6 +507,49 @@ 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
// ---------------------------------------------------------------------------
@@ -486,8 +586,10 @@ router.put('/:id/targets', requireEncounterAccess, async (req, res, next) => {
return res.status(400).json({ error: 'The combatant on turn cannot be their own target' });
}
const found = await prisma.encounterParticipant.count({ where: { id: { in: participantIds }, encounterId: req.encounter.id } });
if (found !== participantIds.length) return res.status(400).json({ error: 'Combatant not found in this encounter' });
// 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);
@@ -500,21 +602,22 @@ router.put('/:id/targets', requireEncounterAccess, async (req, res, next) => {
// 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 } } : {}),
},
@@ -527,7 +630,7 @@ 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: {
@@ -535,7 +638,7 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
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);
@@ -680,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;
+18 -12
View File
@@ -6,10 +6,14 @@
// 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 CURRENCIES = ['GOLD', 'SILVER', 'COPPER'];
const COINS = ['GOLD', 'SILVER', 'COPPER'];
const COIN_VALUE = { GOLD: 100, SILVER: 10, COPPER: 1 };
// Each coin is its own column on the character.
const COIN_COLUMN = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper' };
// 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 {}
@@ -43,7 +47,7 @@ function planPayment(purse, currency, amount) {
const delta = { GOLD: 0, SILVER: 0, COPPER: 0 };
const left = (coin) => purse[coin] + delta[coin];
let owed = amount * worth(currency);
if (CURRENCIES.reduce((sum, coin) => sum + purse[coin] * worth(coin), 0) < owed) return null;
if (COINS.reduce((sum, coin) => sum + purse[coin] * worth(coin), 0) < owed) return null;
const pay = (coin, count) => {
delta[coin] -= count;
@@ -51,13 +55,13 @@ function planPayment(purse, currency, amount) {
};
pay(currency, Math.min(purse[currency], amount));
const bigger = CURRENCIES.filter((coin) => worth(coin) > worth(currency)).reverse(); // nearest first
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 CURRENCIES.filter((c) => worth(c) < worth(coin))) {
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;
@@ -66,7 +70,7 @@ function planPayment(purse, currency, amount) {
if (owed < 0) owed = 0;
}
for (const coin of CURRENCIES.filter((c) => worth(c) < worth(currency))) {
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))));
}
@@ -76,14 +80,16 @@ function planPayment(purse, currency, amount) {
// 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.
// 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 = CURRENCIES.filter((coin) => delta[coin] !== 0).sort((a, b) => (b === currency) - (a === currency));
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 = [];
@@ -103,9 +109,9 @@ async function spendCoins(db, { characterId, amount, currency = 'GOLD', reason,
return rows;
}
// A purse's whole worth as one figure in gold, for messages: 265 copper-worth -> 2.65.
// A purse's coins as one figure in gold, for messages: 265 copper-worth -> 2.65.
function worthInGold(character) {
const copper = CURRENCIES.reduce((sum, coin) => sum + character[COIN_COLUMN[coin]] * COIN_VALUE[coin], 0);
const copper = COINS.reduce((sum, coin) => sum + character[COIN_COLUMN[coin]] * COIN_VALUE[coin], 0);
return copper / COIN_VALUE.GOLD;
}
@@ -127,4 +133,4 @@ async function giveGear(db, kind, { characterId, entry, quantity = 1, level, equ
});
}
module.exports = { CURRENCIES, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, planPayment, spendCoins, worthInGold, giveGear };
module.exports = { CURRENCIES, COINS, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, planPayment, spendCoins, worthInGold, giveGear };
+23 -4
View File
@@ -334,7 +334,7 @@ HP bar, on their encounter card, in the quick stats and in the party lists.
|---|---|---|
| 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 | Only the DM's **Revive at 1 HP** |
| 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.
@@ -359,7 +359,10 @@ The app does not roll it.
- **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.
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
@@ -578,7 +581,22 @@ behind it.
- The log is the only way a balance changes. Entries cannot be edited or
deleted; to correct a mistake, record the opposite entry.
- Starting gold (a campaign setting) is gold only. Encounter rewards and gifts
between characters can be in any coin.
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
@@ -740,6 +758,7 @@ Another Campaign" card on its sheet.
| 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 |
@@ -776,7 +795,7 @@ calculations for them.
- 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
+127 -20
View File
@@ -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 | 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.
@@ -111,7 +113,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 +133,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,8 +155,7 @@ 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 monster or NPC 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
@@ -173,12 +178,54 @@ the card is the same one as on the sheet, and both change the same character.
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**.
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 or advances by itself: the app does not prompt for a save when
the turn comes round, and a dying, stable or dead character keeps its place
in the order.
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
@@ -201,6 +248,14 @@ 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
@@ -212,7 +267,8 @@ healed) right now. One target is an ordinary attack; more than one is an
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.
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.
@@ -276,7 +332,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 |
@@ -287,9 +343,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
@@ -300,7 +356,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 |
|---|---|---|
@@ -309,13 +406,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 |
|---|---|---|
@@ -329,22 +429,29 @@ information cannot be dug out of the page.
| 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, revive the dead | Yes | No |
| 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)
- Applying an attack's damage automatically
- 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.7.2",
"version": "1.10.0",
"type": "module",
"scripts": {
"dev": "vite",
+98 -1
View File
@@ -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,
@@ -18,6 +18,7 @@ import type {
Monster,
Note,
Npc,
SaleOffer,
QuickStats,
Spell,
Weapon,
@@ -463,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] }),
@@ -536,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({
@@ -760,6 +778,19 @@ export function useSetTargets(encounterId: string) {
});
}
// 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({
@@ -829,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
// --------------------------------------------------------------------------
+39 -3
View File
@@ -108,7 +108,9 @@ export interface CampaignMemberSummary {
}
// The coins a character carries. 1 gold = 10 silver = 100 copper.
export type Currency = 'GOLD' | 'SILVER' | '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
@@ -135,10 +137,11 @@ export interface CharacterSummary {
currentHp: number;
tempHp: number;
deathSaves: DeathSaves;
// The purse; all three are null for another player's character.
// 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;
@@ -215,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 {
@@ -302,6 +306,7 @@ export interface CharacterDetail {
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;
@@ -339,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;
@@ -346,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.
@@ -402,6 +429,7 @@ export interface CharacterCombatInfo {
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;
@@ -433,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.
@@ -460,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 {
@@ -497,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[];
}
+2 -2
View File
@@ -32,7 +32,7 @@ function Pips({ kind, count, onSet }: { kind: 'success' | 'failure'; count: numb
// 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 are the DM's alone.
// tallies by hand and raising the dead from here are the DM's alone.
export function DeathSaves({
characterId,
name,
@@ -78,7 +78,7 @@ export function DeathSaves({
</>
)}
{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.' : 'Only the DM can bring them back.'}</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 && (
+196
View File
@@ -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>
);
}
+204
View File
@@ -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>
);
}
+5
View File
@@ -828,6 +828,11 @@ table.data-table tbody tr:hover {
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;
+20 -11
View File
@@ -1,4 +1,4 @@
import type { Currency } from '../api/types';
import type { Coin, Currency } from '../api/types';
export const SPECIES_LABELS: Record<string, string> = {
HUMAN: 'Human',
@@ -73,25 +73,34 @@ export function hpBarClass(current: number, max: number): string {
}
// Coins. Silver is worth 0.1 gold and copper 0.01 gold.
export const CURRENCIES: Currency[] = ['GOLD', 'SILVER', 'COPPER'];
export const COIN_LABELS: Record<Currency, string> = { GOLD: 'Gold', SILVER: 'Silver', COPPER: 'Copper' };
export const COIN_SHORT: Record<Currency, string> = { GOLD: 'gp', SILVER: 'sp', COPPER: 'cp' };
export const COIN_FIELD: Record<Currency, 'gold' | 'silver' | 'copper'> = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper' };
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<Currency, number> = { GOLD: 100, SILVER: 10, COPPER: 1 };
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' };
type Purse = { gold: number | null; silver: number | null; copper: number | null };
// "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]}`;
}
// "12 gp · 5 sp · 3 cp". Gold always shows; silver and copper only when held.
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) => `${purse[COIN_FIELD[c]] ?? 0} ${COIN_SHORT[c]}`)
.map((c) => formatMoney(purse[COIN_FIELD[c]] ?? 0, c))
.join(' · ');
}
// The whole purse as one figure in gold, e.g. 12.65.
// 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 = CURRENCIES.reduce((sum, c) => sum + (purse[COIN_FIELD[c]] ?? 0) * COIN_VALUE[c], 0);
const copper = COINS.reduce((sum, c) => sum + (purse[COIN_FIELD[c]] ?? 0) * COIN_VALUE[c], 0);
return copper / COIN_VALUE.GOLD;
}
+14
View File
@@ -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`;
}
+341 -57
View File
@@ -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,
@@ -43,10 +44,11 @@ import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
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.
@@ -236,6 +238,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,
@@ -304,6 +514,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);
@@ -333,6 +544,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('');
@@ -341,6 +553,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);
@@ -353,6 +566,7 @@ 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);
@@ -793,7 +1007,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
@@ -844,21 +1059,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>
@@ -1129,6 +1362,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();
}}
@@ -1149,6 +1383,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>
)}
@@ -1159,6 +1397,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>
@@ -1174,6 +1413,14 @@ export function CampaignHomePage() {
<td>
<SpellDamageSelect spellName={s.name} value={s.damageDice} onChange={(damageDice) => patchSpell.mutate({ id: s.id, damageDice })} />
</td>
<td>
<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 })}
@@ -1194,8 +1441,14 @@ export function CampaignHomePage() {
<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>
@@ -1205,62 +1458,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>
<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>{m.challengeRating || '—'}</td>
<td>{m.ac}</td>
<td>{m.maxHp}</td>
<td>{m.speed}</td>
<td>{fmtMod(m.attackBonus)}</td>
<td>{m.damageDice || '—'}</td>
<td>
<button className="btn btn-ghost" onClick={() => deleteMonster.mutate(m.id)}>✕</button>
<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"
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>
))}
+18 -15
View File
@@ -40,15 +40,16 @@ import { usePagination, Pagination } from '../components/Paginated';
import {
ABILITY_LABELS,
CLASS_LABELS,
COINS,
COIN_FIELD,
COIN_LABELS,
COIN_SHORT,
COIN_VALUE,
CURRENCIES,
SKILL_LABELS,
SPECIES_LABELS,
fmtMod,
extractErrorMessage,
formatMoney,
formatPurse,
purseWorthInGold,
spellLevelLabel,
@@ -56,7 +57,7 @@ import {
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, Currency } 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
@@ -112,10 +113,10 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
const heldWeapon = character.weapons.find((w) => w.weaponId === id);
const heldItem = character.inventoryItems.find((i) => i.itemId === id);
// 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.
// 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 = CURRENCIES.reduce((sum, c) => sum + held(c) * COIN_VALUE[c], 0);
const affordable = (c: Currency) => Math.floor(purseInCopper / COIN_VALUE[c]);
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));
@@ -170,7 +171,7 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
<option value="">What to give…</option>
{coinsHeld.map((c) => (
<option key={c} value={`gold:${c}`}>
{COIN_LABELS[c]} ({held(c)} {COIN_SHORT[c]} held)
{COIN_LABELS[c]} ({formatMoney(held(c), c)} held)
</option>
))}
{character.weapons.length > 0 && (
@@ -267,8 +268,8 @@ export function CharacterSheetPage() {
const [goldReason, setGoldReason] = useState('');
const [goldCurrency, setGoldCurrency] = useState<Currency>('GOLD');
const [exchangeAmount, setExchangeAmount] = useState('');
const [exchangeFrom, setExchangeFrom] = useState<Currency>('GOLD');
const [exchangeTo, setExchangeTo] = useState<Currency>('SILVER');
const [exchangeFrom, setExchangeFrom] = useState<Coin>('GOLD');
const [exchangeTo, setExchangeTo] = useState<Coin>('SILVER');
const [noteBody, setNoteBody] = useState('');
const [pickedWeaponId, setPickedWeaponId] = useState('');
const [pickedSpellId, setPickedSpellId] = useState('');
@@ -828,7 +829,8 @@ export function CharacterSheetPage() {
</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.
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">
@@ -876,16 +878,16 @@ export function CharacterSheetPage() {
onChange={(e) => setExchangeAmount(e.target.value)}
style={{ width: 100 }}
/>
<select aria-label="Exchange from" value={exchangeFrom} onChange={(e) => setExchangeFrom(e.target.value as Currency)}>
{CURRENCIES.map((c) => (
<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 Currency)}>
{CURRENCIES.map((c) => (
<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>
@@ -902,7 +904,8 @@ export function CharacterSheetPage() {
<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)}`} {COIN_SHORT[t.currency ?? 'GOLD']}
{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)' }}>
@@ -911,7 +914,7 @@ export function CharacterSheetPage() {
</div>
</div>
<span className="mono" style={{ color: 'var(--text-muted)' }}>
{t.balanceAfter} {COIN_SHORT[t.currency ?? 'GOLD']}
{formatMoney(t.balanceAfter, t.currency ?? 'GOLD')}
</span>
</div>
))}
+85 -8
View File
@@ -15,6 +15,7 @@ import {
useAdvanceRound,
useNextTurn,
useSetTargets,
useReviveParticipant,
usePatchEncounter,
useSetEncounterArchived,
useDeleteEncounter,
@@ -29,7 +30,8 @@ import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { HpBar } from '../components/HpBar';
import { DeathSaves } from '../components/DeathSaves';
import { ABILITY_LABELS, CLASS_LABELS, COIN_LABELS, CURRENCIES, SPECIES_LABELS, extractErrorMessage, fmtMod, spellLevelLabel } from '../lib/format';
import { TradePanel } from '../components/TradePanel';
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,
@@ -188,6 +190,11 @@ function CombatantCard({
)}
{npc && <span className={`badge badge-${npc.disposition.toLowerCase()}`}>{npc.disposition}</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' : ''}`}
@@ -600,7 +607,7 @@ function AttackBar({
}
function rewardLabel(r: EncounterReward): string {
if (r.type === 'GOLD') return `${r.gold} ${COIN_LABELS[r.currency ?? 'GOLD'].toLowerCase()}`;
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}`;
}
@@ -844,6 +851,7 @@ export function EncounterPage() {
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 ?? '');
@@ -853,25 +861,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));
@@ -906,15 +928,26 @@ export function EncounterPage() {
/>
);
// 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>
@@ -1047,7 +1080,41 @@ export function EncounterPage() {
onClear={() => setTargets.mutate([], fail)}
/>
)}
{participants.length === 0 && (
{!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.'
@@ -1115,6 +1182,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}
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.7.2",
"version": "1.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.7.2",
"version": "1.10.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.7.2",
"version": "1.10.0",
"dependencies": {
"@prisma/client": "^5.20.0",
"bcrypt": "^5.1.1",
@@ -31,7 +31,7 @@
}
},
"frontend": {
"version": "1.7.2",
"version": "1.10.0",
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.7.2",
"version": "1.10.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [