From c852ecb2e4487a14aff9fd30399b5461b52e78ee Mon Sep 17 00:00:00 2001 From: Joshua Oosthuizen Date: Wed, 7 Oct 2026 13:45:40 +0200 Subject: [PATCH] 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 --- README.md | 6 +- backend/package.json | 2 +- .../20261008100000_trade/migration.sql | 28 +++ backend/prisma/schema.prisma | 36 ++++ backend/src/routes/api.integration.test.js | 131 ++++++++++- backend/src/routes/campaigns.routes.js | 105 ++++++++- backend/src/routes/encounters.routes.js | 154 ++++++++++++- docs/CHARACTERS.md | 17 +- docs/ENCOUNTERS.md | 62 +++++- docs/ITEMS.md | 16 +- docs/MONSTERS.md | 1 + docs/NPCS.md | 115 ++++++++-- docs/WEAPONS.md | 15 +- frontend/package.json | 2 +- frontend/src/api/queries.ts | 71 +++++- frontend/src/api/types.ts | 23 ++ frontend/src/components/TradePanel.tsx | 196 +++++++++++++++++ frontend/src/components/Wares.tsx | 204 ++++++++++++++++++ frontend/src/lib/wares.ts | 14 ++ frontend/src/pages/CampaignHomePage.tsx | 127 +++++++++-- frontend/src/pages/EncounterPage.tsx | 11 + package-lock.json | 8 +- package.json | 2 +- 23 files changed, 1281 insertions(+), 65 deletions(-) create mode 100644 backend/prisma/migrations/20261008100000_trade/migration.sql create mode 100644 frontend/src/components/TradePanel.tsx create mode 100644 frontend/src/components/Wares.tsx create mode 100644 frontend/src/lib/wares.ts diff --git a/README.md b/README.md index c1db16f..81ca19d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/package.json b/backend/package.json index 454ea48..8b74b01 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.9.0", + "version": "1.10.0", "private": true, "type": "commonjs", "main": "src/index.js", diff --git a/backend/prisma/migrations/20261008100000_trade/migration.sql b/backend/prisma/migrations/20261008100000_trade/migration.sql new file mode 100644 index 0000000..e9b01fe --- /dev/null +++ b/backend/prisma/migrations/20261008100000_trade/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b72dfaa..35c67ba 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -188,6 +188,7 @@ model Campaign { monsters Monster[] npcs Npc[] encounters Encounter[] + saleOffers SaleOffer[] notes Note[] locations Location[] @@ -372,6 +373,7 @@ model Weapon { campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade) assigned CharacterWeapon[] rewards EncounterReward[] + offers SaleOffer[] @@map("weapons") } @@ -406,6 +408,7 @@ model Item { campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade) held InventoryItem[] rewards EncounterReward[] + offers SaleOffer[] @@map("items") } @@ -527,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") } @@ -565,6 +573,7 @@ model Encounter { location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull) participants EncounterParticipant[] rewards EncounterReward[] + wares SaleOffer[] @@map("encounters") } @@ -594,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 diff --git a/backend/src/routes/api.integration.test.js b/backend/src/routes/api.integration.test.js index 6838cb1..6ab21e0 100644 --- a/backend/src/routes/api.integration.test.js +++ b/backend/src/routes/api.integration.test.js @@ -989,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); @@ -2401,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([]); + }); +}); diff --git a/backend/src/routes/campaigns.routes.js b/backend/src/routes/campaigns.routes.js index eeac586..6e837c0 100644 --- a/backend/src/routes/campaigns.routes.js +++ b/backend/src/routes/campaigns.routes.js @@ -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(); @@ -645,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); @@ -662,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) { @@ -679,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); @@ -780,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 diff --git a/backend/src/routes/encounters.routes.js b/backend/src/routes/encounters.routes.js index f6531ff..1fc1ca8 100644 --- a/backend/src/routes/encounters.routes.js +++ b/backend/src/routes/encounters.routes.js @@ -4,7 +4,7 @@ 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 { NO_DEATH_SAVES, lifeState, deathSaveInfo, changeHp } = require('../utils/hp'); @@ -155,9 +155,14 @@ 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. The fallen (isFallen) are left +// 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. // @@ -182,10 +187,11 @@ 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; @@ -777,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; diff --git a/docs/CHARACTERS.md b/docs/CHARACTERS.md index bcae457..ea2ca61 100644 --- a/docs/CHARACTERS.md +++ b/docs/CHARACTERS.md @@ -585,8 +585,18 @@ behind it. - **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, 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. + 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 from ". + See [ENCOUNTERS.md](ENCOUNTERS.md), section 10. ### Giving to another character @@ -748,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 | @@ -784,7 +795,7 @@ calculations for them. - Automatic levelling from XP - Alignment, languages, tool proficiencies and backgrounds-as-rules - Encumbrance and item weight -- Item prices, cents, and an exchange rate between cash and coins +- Cents, and changing silver or copper for cash ## 20. Everything the builder offers diff --git a/docs/ENCOUNTERS.md b/docs/ENCOUNTERS.md index c90cdd3..e3564fd 100644 --- a/docs/ENCOUNTERS.md +++ b/docs/ENCOUNTERS.md @@ -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) --- @@ -51,6 +52,7 @@ time from the top of the page. | 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. @@ -354,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 | +| 🛒 " 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 | |---|---|---| @@ -366,12 +409,13 @@ it was and who received it. | 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 | |---|---|---| @@ -390,10 +434,12 @@ information cannot be dug out of the page. | 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) @@ -404,6 +450,8 @@ People outside the campaign cannot open its encounters. - 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 diff --git a/docs/ITEMS.md b/docs/ITEMS.md index 23b6c56..6dfa7a1 100644 --- a/docs/ITEMS.md +++ b/docs/ITEMS.md @@ -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 diff --git a/docs/MONSTERS.md b/docs/MONSTERS.md index eb9c857..14f8ef8 100644 --- a/docs/MONSTERS.md +++ b/docs/MONSTERS.md @@ -168,6 +168,7 @@ Then they get the health bar only. ## 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 diff --git a/docs/NPCS.md b/docs/NPCS.md index 5b47b68..540f7f8 100644 --- a/docs/NPCS.md +++ b/docs/NPCS.md @@ -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 diff --git a/docs/WEAPONS.md b/docs/WEAPONS.md index bc4e2c1..071ca98 100644 --- a/docs/WEAPONS.md +++ b/docs/WEAPONS.md @@ -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 diff --git a/frontend/package.json b/frontend/package.json index 5485832..73703db 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.9.0", + "version": "1.10.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/queries.ts b/frontend/src/api/queries.ts index a3140a4..5eee5b1 100644 --- a/frontend/src/api/queries.ts +++ b/frontend/src/api/queries.ts @@ -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, @@ -467,6 +468,8 @@ export function usePatchPoolEntry(campaignId: string, pool: Pool) { 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] }), @@ -857,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 // -------------------------------------------------------------------------- diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 4eed6fd..026805c 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -344,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; @@ -351,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. @@ -474,6 +496,7 @@ export interface Encounter { // 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 { diff --git a/frontend/src/components/TradePanel.tsx b/frontend/src/components/TradePanel.tsx new file mode 100644 index 0000000..694cdd7 --- /dev/null +++ b/frontend/src/components/TradePanel.tsx @@ -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>({}); + const [sides, setSides] = useState>({}); + const [done, setDone] = useState(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 ( +
+
Trade
+ {!open && ( +

+ {encounter.status === 'PLANNING' ? 'Trading opens once the quest is in progress.' : 'The quest is over; trading is closed.'} +

+ )} + {traders.length > 0 && ( +
+ + {purse && purse.gold !== null && {formatPurse(purse)}} +
+ )} + {traders.length === 0 && (changers.length > 0 || openStalls.length > 0) && ( +

{isDM ? 'Add a character to the scene to trade for them.' : 'Your character has to be in the scene to trade.'}

+ )} + {done &&

✓ {done}

} + + {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 ( +
+ 💱 {npc.name} + + {' '} + changes {formatMoney(rate.GOLD, 'GOLD', true)} for {formatMoney(rate.CASH, 'CASH')}, either way. No silver or copper. + +
+ setAmounts((a) => ({ ...a, [npc.id]: e.target.value }))} + style={{ width: 90 }} + /> + + +
+ {amount > 0 && !whole && ( +
+ Hand over {from.toLowerCase()} in multiples of {rate[from]}. +
+ )} +
+ ); + })} + + {openStalls.map(([key, label, wares]) => ( +
+
🛒 {label}
+ {wares.map((ware) => ( +
+
+ + {wareName(ware)} + · {stockLabel(ware)} + + +
+ {ware.item?.description &&
{ware.item.description}
} +
+ ))} +
+ ))} + + {isDM && ( +
+
Set up: for sale at this encounter
+

+ For a stall or a machine with nobody behind it. An NPC's wares and exchange rate are set under "Trade" on the{' '} + campaign page, and follow the NPC into every quest. +

+ +
+ )} +
+ ); +} diff --git a/frontend/src/components/Wares.tsx b/frontend/src/components/Wares.tsx new file mode 100644 index 0000000..890ed7d --- /dev/null +++ b/frontend/src/components/Wares.tsx @@ -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(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 ( +
+
+ + {wareName(ware)} — {formatMoney(ware.price, ware.currency)} + · {stockLabel(ware)} + + + + + +
+ {editing && ( +
+ setPrice(e.target.value)} style={{ width: 90 }} /> + + setStock(e.target.value)} + style={{ flex: 1, minWidth: 120 }} + /> + +
+ )} +
+ ); +} + +// 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('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 ( +
+ {wares.length === 0 && ( +

+ Nothing for sale yet. +

+ )} + {wares.map((ware) => ( + + ))} +
+ + + {picked && ( + + )} +
+
+ setPrice(e.target.value)} style={{ width: 90 }} /> + + setStock(e.target.value)} + style={{ flex: 1, minWidth: 120 }} + /> + +
+
+ ); +} diff --git a/frontend/src/lib/wares.ts b/frontend/src/lib/wares.ts new file mode 100644 index 0000000..5079ecf --- /dev/null +++ b/frontend/src/lib/wares.ts @@ -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`; +} diff --git a/frontend/src/pages/CampaignHomePage.tsx b/frontend/src/pages/CampaignHomePage.tsx index 53b24f8..213836b 100644 --- a/frontend/src/pages/CampaignHomePage.tsx +++ b/frontend/src/pages/CampaignHomePage.tsx @@ -44,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, fmtMod, 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 { ABILITY_ORDER, DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../lib/previewCalc'; -import type { CharacterSummary, Encounter, EncounterMode, LibraryMonster, LibrarySpell, LibraryWeapon, Monster, NpcDisposition } from '../api/types'; +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. @@ -373,6 +374,78 @@ function MonsterForm({ ); } +// "1 gp = $5": the rate an NPC changes gold and cash at, or null if they don't. +function rateLabel(npc: Pick): 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 ( +
+
Changes money
+

+ 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. +

+
+ setGold(e.target.value)} style={{ width: 90 }} /> + gold = + $ + setCash(e.target.value)} style={{ width: 90 }} /> + + {changesMoney && ( + + )} +
+
For sale
+ +
+ ); +} + function LibraryPicker({ items, onAdd, @@ -471,6 +544,7 @@ export function CampaignHomePage() { const [encounterSort, setEncounterSort] = useState(storedEncounterSort); const [showArchivedEncounters, setShowArchivedEncounters] = useState(false); const [showNpcForm, setShowNpcForm] = useState(false); + const [tradeNpcId, setTradeNpcId] = useState(null); const [noteBody, setNoteBody] = useState(''); const [dmNoteBody, setDmNoteBody] = useState(''); const [newLocationName, setNewLocationName] = useState(''); @@ -933,7 +1007,8 @@ export function CampaignHomePage() {

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.

{showNpcForm && (
{npcs?.length === 0 &&

No NPCs yet.

} {npcsPage.pageItems.map((npc) => ( -
-
- {npc.name} - {npc.role && {npc.role}} - {npc.disposition} - {npc.description &&
{npc.description}
} - {npc.dmNotes &&
🔒 {npc.dmNotes}
} +
+
+
+ {npc.name} + {npc.role && {npc.role}} + {npc.disposition} + {rateLabel(npc) && 💱 {rateLabel(npc)}} + {(npc.wares?.length ?? 0) > 0 && 🛒 {npc.wares?.length} for sale} + {npc.description &&
{npc.description}
} + {npc.dmNotes &&
🔒 {npc.dmNotes}
} +
+
+ + +
- + {tradeNpcId === npc.id && ( + setFormError(extractErrorMessage(err))} + /> + )}
))}
diff --git a/frontend/src/pages/EncounterPage.tsx b/frontend/src/pages/EncounterPage.tsx index 0a270fe..9896668 100644 --- a/frontend/src/pages/EncounterPage.tsx +++ b/frontend/src/pages/EncounterPage.tsx @@ -30,6 +30,7 @@ import { Spinner, ErrorBanner } from '../components/Spinner'; import { NumberInput } from '../components/NumberInput'; import { HpBar } from '../components/HpBar'; import { DeathSaves } from '../components/DeathSaves'; +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 { @@ -1181,6 +1182,16 @@ export function EncounterPage() {
)} + +