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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
+14
-3
@@ -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 <thing> from <NPC>".
|
||||
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
|
||||
|
||||
|
||||
+55
-7
@@ -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 |
|
||||
| 🛒 "<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 |
|
||||
|---|---|---|
|
||||
@@ -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
|
||||
|
||||
+13
-3
@@ -15,7 +15,7 @@ level up the same way weapons do), [SPELLS.md](SPELLS.md),
|
||||
4. [Item levels](#4-item-levels)
|
||||
5. [Dice on items](#5-dice-on-items)
|
||||
6. [Starting items](#6-starting-items)
|
||||
7. [Items as rewards](#7-items-as-rewards)
|
||||
7. [Items as rewards and for sale](#7-items-as-rewards-and-for-sale)
|
||||
8. [Permissions](#8-permissions)
|
||||
9. [What is not modelled](#9-what-is-not-modelled)
|
||||
|
||||
@@ -121,13 +121,20 @@ In the builder's equipment step a new character can pick items that are:
|
||||
Starting items and starting weapons share the campaign's "starting items"
|
||||
allowance. Each arrives at level 1, quantity 1, unequipped.
|
||||
|
||||
## 7. Items as rewards
|
||||
## 7. Items as rewards and for sale
|
||||
|
||||
On an encounter the DM can line up an item as a reward, at a chosen level and
|
||||
quantity, and later award it to a character. Awarding adds it to the
|
||||
character's inventory, stacking with any they already hold. See
|
||||
[ENCOUNTERS.md](ENCOUNTERS.md).
|
||||
|
||||
The DM can also put an item **up for sale**, at a level, a price (in gold,
|
||||
silver, copper or cash) and an optional stock. An NPC can sell it, wherever
|
||||
that NPC is met, or an encounter can sell it itself. Players see what is for
|
||||
sale and buy it in an NPC quest that is in progress: the price leaves their
|
||||
purse and the item joins their inventory. See [NPCS.md](NPCS.md), section 5,
|
||||
and [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
|
||||
|
||||
## 8. Permissions
|
||||
|
||||
| Action | DM | Owning player | Other players |
|
||||
@@ -136,6 +143,8 @@ character's inventory, stacking with any they already hold. See
|
||||
| Add, change or remove pool items | Yes | No | No |
|
||||
| Give an item from the pool, or take one away | Yes | No | No |
|
||||
| Give a held item to another character | Yes | Yes | No |
|
||||
| Put an item up for sale | Yes | No | No |
|
||||
| Buy an item that is for sale, in a quest in progress | Yes, for any character in it | Yes, if the character is in it | No |
|
||||
| Change quantity or level | Yes | No | No |
|
||||
| Equip or unequip | Yes | Yes, if the character can use it | No |
|
||||
| See a character's inventory | Yes | Yes | Yes |
|
||||
@@ -146,6 +155,7 @@ character's inventory, stacking with any they already hold. See
|
||||
- Armor and shields as items
|
||||
- Using up items automatically, charges, and attunement
|
||||
- Weight, carrying capacity and containers
|
||||
- Prices, buying and selling (record the gold change in the money log)
|
||||
- A price on the item itself: a price is set each time it is put up for sale
|
||||
- Selling items back (record the gold change in the money log)
|
||||
- Swaps as a single step (each side gives separately), and asking the
|
||||
recipient to accept
|
||||
|
||||
@@ -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
|
||||
|
||||
+99
-16
@@ -1,7 +1,8 @@
|
||||
# NPCs
|
||||
|
||||
How non-player characters work in this app: what the DM records about them,
|
||||
how they are brought into a scene, and what players can and cannot see.
|
||||
how they are brought into a scene, what they sell and the rate they change
|
||||
money at, and what players can and cannot see.
|
||||
|
||||
This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
[MONSTERS.md](MONSTERS.md).
|
||||
@@ -12,10 +13,11 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
2. [What an NPC holds](#2-what-an-npc-holds)
|
||||
3. [Managing NPCs](#3-managing-npcs)
|
||||
4. [NPCs in encounters](#4-npcs-in-encounters)
|
||||
5. [What players see](#5-what-players-see)
|
||||
6. [NPC or monster?](#6-npc-or-monster)
|
||||
7. [Permissions](#7-permissions)
|
||||
8. [What is not modelled](#8-what-is-not-modelled)
|
||||
5. [Trade: changing money and selling](#5-trade-changing-money-and-selling)
|
||||
6. [What players see](#6-what-players-see)
|
||||
7. [NPC or monster?](#7-npc-or-monster)
|
||||
8. [Permissions](#8-permissions)
|
||||
9. [What is not modelled](#9-what-is-not-modelled)
|
||||
|
||||
---
|
||||
|
||||
@@ -27,6 +29,8 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
- An NPC does nothing by sitting in the list. Players meet one when the DM
|
||||
**adds it to an encounter**.
|
||||
- NPCs are mainly for talking to. Combat stats are optional.
|
||||
- An NPC can also **trade**: change gold and cash at a rate of their own, and
|
||||
sell weapons and items (section 5).
|
||||
|
||||
## 2. What an NPC holds
|
||||
|
||||
@@ -38,9 +42,11 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
| Description | What the party can see and knows | Everyone |
|
||||
| DM notes | Secrets, motives, what they are hiding | **DM only** |
|
||||
| AC | Optional | DM only |
|
||||
| HP | Optional. Without it the NPC has no health bar | See section 5 |
|
||||
| HP | Optional. Without it the NPC has no health bar | See section 6 |
|
||||
| Attack bonus | Optional | DM only |
|
||||
| Damage | Optional dice, e.g. `1d6` | DM only |
|
||||
| Exchange rate | Optional: so much gold for so much cash (section 5) | Everyone, in an encounter |
|
||||
| Wares | Optional: weapons and items for sale, each with a price (section 5) | Everyone, in an encounter |
|
||||
|
||||
A new NPC is Neutral unless you choose otherwise.
|
||||
|
||||
@@ -48,10 +54,14 @@ A new NPC is Neutral unless you choose otherwise.
|
||||
|
||||
The NPC list is the "NPCs" card on the campaign page (DM only).
|
||||
|
||||
- **Adding:** "+ Add NPC" opens a form with every field above. Only the name
|
||||
is required.
|
||||
- **Changing:** there is no edit form. To change an NPC, remove it and add it
|
||||
again (the API accepts changes, but no screen offers them).
|
||||
- **Adding:** "+ Add NPC" opens a form with every field above except the
|
||||
exchange rate and wares. Only the name is required.
|
||||
- **Trade:** the "Trade" button on an NPC's row opens their exchange rate and
|
||||
their wares (section 5). The row shows a 💱 badge with the rate and a 🛒
|
||||
badge with how many things are for sale.
|
||||
- **Changing:** there is no edit form for the other fields. To change them,
|
||||
remove the NPC and add it again (the API accepts changes, but no screen
|
||||
offers them). Removing an NPC removes their wares too.
|
||||
- **Removing:** the ✕ button, after a confirmation. This also **removes the
|
||||
NPC from every encounter** it was in.
|
||||
|
||||
@@ -73,7 +83,72 @@ An NPC's card shows its attitude as a coloured badge, its role under its
|
||||
name, and its description in full. The DM's card also shows AC, attack,
|
||||
damage and the DM notes.
|
||||
|
||||
## 5. What players see
|
||||
## 5. Trade: changing money and selling
|
||||
|
||||
Only the DM sets trade up. Players see it and use it on the encounter page,
|
||||
in the "Trade" card. The rules for when trading is open are in
|
||||
[ENCOUNTERS.md](ENCOUNTERS.md), section 10: in short, in an **NPC quest that
|
||||
is in progress**, never in a battle, and only with an NPC who is in the scene
|
||||
and not at 0 HP.
|
||||
|
||||
### Changing money
|
||||
|
||||
An NPC with an **exchange rate** changes gold into cash ($) and cash into
|
||||
gold. The rate is two whole numbers, "this much gold = this much cash", and
|
||||
each NPC has their own, so one money changer can be a better deal than
|
||||
another.
|
||||
|
||||
| Rate | Hand over | Get |
|
||||
|---|---|---|
|
||||
| 1 gold = $5 | 3 gold | $15 |
|
||||
| 1 gold = $5 | $20 | 4 gold |
|
||||
| 2 gold = $15 | 6 gold | $45 |
|
||||
| 2 gold = $15 | $30 | 4 gold |
|
||||
|
||||
- The same rate is used both ways.
|
||||
- **Only gold and cash.** Silver and copper are not taken, a gold coin is not
|
||||
broken to make up an amount, and no change is given.
|
||||
- What is handed over has to be a whole multiple of that side of the rate: at
|
||||
2 gold = $15, gold goes in twos and cash in fifteens.
|
||||
- Both sides go on the character's money log, naming the NPC: "Changed with
|
||||
Vex for $45" and "Changed with Vex from 6 gold".
|
||||
- An NPC never runs out of gold or cash to change.
|
||||
- To set a rate, type both numbers under "Trade" and press "Save rate".
|
||||
"Stop changing money" clears it. An NPC with no rate does not change money.
|
||||
|
||||
### Selling
|
||||
|
||||
An NPC's **wares** are weapons and items from the campaign's own pools, each
|
||||
put up for sale with:
|
||||
|
||||
| Field | What it is |
|
||||
|---|---|
|
||||
| What | One weapon or item from the pool |
|
||||
| Level | The gear level it is handed over at, not below its starting level |
|
||||
| Price | For one, in gold, silver, copper or cash |
|
||||
| Stock | How many can be bought. Empty means no limit |
|
||||
|
||||
- Wares belong to the NPC, not to an encounter: they are on offer in every
|
||||
quest that NPC is in.
|
||||
- Buying pays the price from the character's purse, adds the weapon or item
|
||||
to what they hold (stacking with any they already have) and lowers the
|
||||
stock. A price in coins is paid with change, exactly like any other spend;
|
||||
a price in cash takes cash only.
|
||||
- At 0 stock a ware shows as sold out and cannot be bought. It stays listed
|
||||
until the DM removes it.
|
||||
- The ✕ beside a ware stops it being sold. Things already bought stay bought.
|
||||
- "Edit" beside a ware changes its price, the coin (or cash) the price is in,
|
||||
and its stock: restock a sold-out ware, or clear the stock for no limit.
|
||||
What is sold, its level and who sells it cannot be changed; remove the ware
|
||||
and put it up again for that.
|
||||
- Removing a weapon or item from the campaign pool removes it from sale
|
||||
everywhere.
|
||||
|
||||
A stall or a machine with nobody behind it can sell things too: those wares
|
||||
belong to an encounter rather than an NPC, and are set up on the encounter
|
||||
page. See [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
|
||||
|
||||
## 6. What players see
|
||||
|
||||
Players only ever see an NPC through an encounter it is in:
|
||||
|
||||
@@ -81,6 +156,7 @@ Players only ever see an NPC through an encounter it is in:
|
||||
|---|---|
|
||||
| Name, role, attitude, description | DM notes |
|
||||
| Health bar and conditions, if the NPC has HP | AC, attack bonus, damage |
|
||||
| The exchange rate, and everything the NPC has for sale with its price and stock | The NPC list, and wares of NPCs who are not in the scene |
|
||||
|
||||
This is enforced by the server, not just hidden on screen: the hidden fields
|
||||
are never sent to a player's browser.
|
||||
@@ -88,7 +164,7 @@ are never sent to a player's browser.
|
||||
Changing an NPC's attitude changes what players see straight away, so
|
||||
flipping a "Friendly" NPC to "Hostile" is itself a reveal.
|
||||
|
||||
## 6. NPC or monster?
|
||||
## 7. NPC or monster?
|
||||
|
||||
| | NPC | Monster |
|
||||
|---|---|---|
|
||||
@@ -99,11 +175,12 @@ flipping a "Friendly" NPC to "Hostile" is itself a reveal.
|
||||
| Combat stats | Optional, basic | Full stat block |
|
||||
| Can be in an encounter more than once | No | Yes (a pack of goblins) |
|
||||
| Built-in library | No | Yes |
|
||||
| Can change money and sell things | Yes | No |
|
||||
|
||||
A named villain the party will both talk to and fight works well as an NPC
|
||||
with HP, AC, attack and damage filled in.
|
||||
|
||||
## 7. Permissions
|
||||
## 8. Permissions
|
||||
|
||||
| Action | DM | Players |
|
||||
|---|---|---|
|
||||
@@ -111,12 +188,18 @@ with HP, AC, attack and damage filled in.
|
||||
| Add or remove NPCs | Yes | No |
|
||||
| Add an NPC to an encounter | Yes | No |
|
||||
| Change an NPC's HP or conditions in an encounter | Yes | No |
|
||||
| See an NPC in an encounter | Everything | Name, role, attitude, description, health bar |
|
||||
| See an NPC in an encounter | Everything | Name, role, attitude, description, health bar, exchange rate, wares |
|
||||
| Set an NPC's exchange rate | Yes | No |
|
||||
| Put things up for sale, or take them down | Yes | No |
|
||||
| Change money with an NPC, or buy from one | Yes, for any character in the scene | For their own character in the scene |
|
||||
|
||||
## 8. What is not modelled
|
||||
## 9. What is not modelled
|
||||
|
||||
- Ability scores, saves, skills and spells for NPCs
|
||||
- NPC inventory, gold and shops
|
||||
- An NPC's own purse: they never run short of gold or cash
|
||||
- Selling things to an NPC, haggling, and different rates for buying and
|
||||
selling cash
|
||||
- Changing silver or copper for cash
|
||||
- Where an NPC is (they are not tied to locations)
|
||||
- Relationships, reputation and attitude toward individual characters
|
||||
- A record of which NPCs the party has met, outside of encounters
|
||||
|
||||
+12
-3
@@ -19,7 +19,7 @@ This describes the code as it stands. The formulas live in
|
||||
6. [How damage grows](#6-how-damage-grows)
|
||||
7. [Attack and damage numbers](#7-attack-and-damage-numbers)
|
||||
8. [Starting weapons](#8-starting-weapons)
|
||||
9. [Weapons as rewards](#9-weapons-as-rewards)
|
||||
9. [Weapons as rewards and for sale](#9-weapons-as-rewards-and-for-sale)
|
||||
10. [Permissions](#10-permissions)
|
||||
11. [What is not modelled](#11-what-is-not-modelled)
|
||||
12. [The complete weapon library](#12-the-complete-weapon-library)
|
||||
@@ -185,13 +185,20 @@ In the builder's equipment step a new character can pick weapons that are:
|
||||
Starting weapons and starting items share the campaign's "starting items"
|
||||
allowance. They arrive at level 1, unequipped.
|
||||
|
||||
## 9. Weapons as rewards
|
||||
## 9. Weapons as rewards and for sale
|
||||
|
||||
On an encounter the DM can line up a weapon as a reward, at a chosen level
|
||||
(not below its starting level) and quantity, and later award it to a
|
||||
character. Awarding adds it to what the character holds, exactly as giving it
|
||||
on the sheet would. See [ENCOUNTERS.md](ENCOUNTERS.md).
|
||||
|
||||
The DM can also put a weapon **up for sale**, at a level, a price (in gold,
|
||||
silver, copper or cash) and an optional stock. An NPC can sell it, wherever
|
||||
that NPC is met, or an encounter can sell it itself. Players see what is for
|
||||
sale and buy it in an NPC quest that is in progress: the price leaves their
|
||||
purse and the weapon joins what they hold, unequipped. See
|
||||
[NPCS.md](NPCS.md), section 5, and [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
|
||||
|
||||
## 10. Permissions
|
||||
|
||||
| Action | DM | Owning player | Other players |
|
||||
@@ -201,6 +208,8 @@ on the sheet would. See [ENCOUNTERS.md](ENCOUNTERS.md).
|
||||
| Add, change or remove pool entries | Yes | No | No |
|
||||
| Give a weapon from the pool, or take one away | Yes | No | No |
|
||||
| Give a held weapon to another character | Yes | Yes | No |
|
||||
| Put a weapon up for sale | Yes | No | No |
|
||||
| Buy a weapon that is for sale, in a quest in progress | Yes, for any character in it | Yes, if the character is in it | No |
|
||||
| Change a held weapon's level or quantity | Yes | No | No |
|
||||
| Equip or unequip | Yes | Yes, if the character can use it | No |
|
||||
| See a character's weapons | Yes | Yes | Yes |
|
||||
@@ -212,7 +221,7 @@ on the sheet would. See [ENCOUNTERS.md](ENCOUNTERS.md).
|
||||
- Range and ammunition
|
||||
- Critical hits, advantage, and rolling the attack itself (use the dice roller)
|
||||
- Magic bonuses such as +1 weapons (use a higher level or growth instead)
|
||||
- Price and weight
|
||||
- A price on the weapon itself (a price is set each time it is put up for sale), and weight
|
||||
|
||||
## 12. The complete weapon library
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useBuyWare, useExchangeWithNpc } from '../api/queries';
|
||||
import { NumberInput } from './NumberInput';
|
||||
import { WaresEditor } from './Wares';
|
||||
import { stockLabel, wareName } from '../lib/wares';
|
||||
import { formatMoney, formatPurse } from '../lib/format';
|
||||
import type { CharacterSummary, Encounter, Item, SaleOffer, Weapon } from '../api/types';
|
||||
|
||||
type ExchangeSide = 'GOLD' | 'CASH';
|
||||
|
||||
// Trade in a quest: changing gold and cash with an NPC who does that, at
|
||||
// their own rate, and buying what the NPCs in the scene — or the scene itself
|
||||
// — have for sale. Everyone sees what is on offer; a player trades for their
|
||||
// own character, the DM for anyone. Trading is open only while the quest is in
|
||||
// progress, and there is none in a battle. The DM sets it all up: an NPC's
|
||||
// rate and wares on the campaign page, the scene's own wares here.
|
||||
export function TradePanel({
|
||||
encounter,
|
||||
isDM,
|
||||
userId,
|
||||
characters,
|
||||
weapons,
|
||||
items,
|
||||
onError,
|
||||
}: {
|
||||
encounter: Encounter;
|
||||
isDM: boolean;
|
||||
userId: string | undefined;
|
||||
characters: CharacterSummary[];
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const exchange = useExchangeWithNpc(encounter.id);
|
||||
const buy = useBuyWare(encounter.id);
|
||||
|
||||
const [traderId, setTraderId] = useState('');
|
||||
const [amounts, setAmounts] = useState<Record<string, string>>({});
|
||||
const [sides, setSides] = useState<Record<string, ExchangeSide>>({});
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
|
||||
if (encounter.mode !== 'QUEST') return null;
|
||||
|
||||
const here = (encounter.participants ?? []).filter((p) => !p.hidden);
|
||||
const npcs = here.flatMap((p) => (p.npc ? [p.npc] : []));
|
||||
const changers = npcs.filter((npc) => npc.exchangeGold && npc.exchangeCash);
|
||||
const stalls: [string, string, SaleOffer[]][] = [
|
||||
...npcs.map((npc): [string, string, SaleOffer[]] => [npc.id, `${npc.name} sells`, npc.wares ?? []]),
|
||||
['here', 'For sale here', encounter.wares ?? []],
|
||||
];
|
||||
const openStalls = stalls.filter(([, , wares]) => wares.length > 0);
|
||||
if (!isDM && changers.length === 0 && openStalls.length === 0) return null;
|
||||
|
||||
// Who can do the trading: a player's own characters in the scene, or for the DM anyone's.
|
||||
const traders = here.flatMap((p) => (p.character && (isDM || p.character.userId === userId) ? [p.character] : []));
|
||||
const trader = traders.find((c) => c.id === traderId) ?? traders[0];
|
||||
const purse = characters.find((c) => c.id === trader?.id);
|
||||
const open = encounter.status === 'ACTIVE';
|
||||
const busy = exchange.isPending || buy.isPending;
|
||||
|
||||
function handleExchange(npc: (typeof changers)[number]) {
|
||||
const from = sides[npc.id] ?? 'GOLD';
|
||||
const amount = Math.floor(Number(amounts[npc.id]));
|
||||
if (!trader || !(amount > 0)) return;
|
||||
setDone(null);
|
||||
exchange.mutate(
|
||||
{ characterId: trader.id, npcId: npc.id, from, amount },
|
||||
{
|
||||
onError,
|
||||
onSuccess: ({ received, currency }) => {
|
||||
setDone(`${trader.name} changed ${formatMoney(amount, from, true)} for ${formatMoney(received, currency, true)} with ${npc.name}.`);
|
||||
setAmounts((a) => ({ ...a, [npc.id]: '' }));
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function handleBuy(ware: SaleOffer) {
|
||||
if (!trader) return;
|
||||
setDone(null);
|
||||
buy.mutate(
|
||||
{ wareId: ware.id, characterId: trader.id },
|
||||
{ onError, onSuccess: () => setDone(`${trader.name} bought ${(ware.weapon ?? ware.item)?.name} for ${formatMoney(ware.price, ware.currency, true)}.`) }
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-title">Trade</div>
|
||||
{!open && (
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
{encounter.status === 'PLANNING' ? 'Trading opens once the quest is in progress.' : 'The quest is over; trading is closed.'}
|
||||
</p>
|
||||
)}
|
||||
{traders.length > 0 && (
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
<select aria-label="Who is trading" value={trader?.id ?? ''} onChange={(e) => setTraderId(e.target.value)} style={{ flex: 1, minWidth: 110 }}>
|
||||
{traders.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{purse && purse.gold !== null && <span className="mono">{formatPurse(purse)}</span>}
|
||||
</div>
|
||||
)}
|
||||
{traders.length === 0 && (changers.length > 0 || openStalls.length > 0) && (
|
||||
<p className="combatant-sub">{isDM ? 'Add a character to the scene to trade for them.' : 'Your character has to be in the scene to trade.'}</p>
|
||||
)}
|
||||
{done && <p className="combatant-sub">✓ {done}</p>}
|
||||
|
||||
{changers.map((npc) => {
|
||||
const from = sides[npc.id] ?? 'GOLD';
|
||||
const to = from === 'GOLD' ? 'CASH' : 'GOLD';
|
||||
const rate = { GOLD: npc.exchangeGold ?? 1, CASH: npc.exchangeCash ?? 1 };
|
||||
const amount = Math.floor(Number(amounts[npc.id]));
|
||||
const whole = amount > 0 && amount % rate[from] === 0;
|
||||
return (
|
||||
<div key={npc.id} className="reward-row">
|
||||
<strong>💱 {npc.name}</strong>
|
||||
<span className="combatant-sub">
|
||||
{' '}
|
||||
changes {formatMoney(rate.GOLD, 'GOLD', true)} for {formatMoney(rate.CASH, 'CASH')}, either way. No silver or copper.
|
||||
</span>
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={1000000}
|
||||
placeholder="Amount"
|
||||
aria-label={`How much to change with ${npc.name}`}
|
||||
value={amounts[npc.id] ?? ''}
|
||||
onChange={(e) => setAmounts((a) => ({ ...a, [npc.id]: e.target.value }))}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<select
|
||||
aria-label={`What to hand ${npc.name}`}
|
||||
value={from}
|
||||
onChange={(e) => setSides((s) => ({ ...s, [npc.id]: e.target.value as ExchangeSide }))}
|
||||
>
|
||||
<option value="GOLD">gold into cash</option>
|
||||
<option value="CASH">cash into gold</option>
|
||||
</select>
|
||||
<button className="btn" disabled={!open || !trader || !whole || busy} onClick={() => handleExchange(npc)}>
|
||||
{whole ? `Get ${formatMoney((amount / rate[from]) * rate[to], to, true)}` : 'Exchange'}
|
||||
</button>
|
||||
</div>
|
||||
{amount > 0 && !whole && (
|
||||
<div className="combatant-sub">
|
||||
Hand over {from.toLowerCase()} in multiples of {rate[from]}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{openStalls.map(([key, label, wares]) => (
|
||||
<div key={key} className="mt-sm">
|
||||
<div className="section-label">🛒 {label}</div>
|
||||
{wares.map((ware) => (
|
||||
<div key={ware.id} className="reward-row">
|
||||
<div className="flex-between">
|
||||
<span>
|
||||
<strong>{wareName(ware)}</strong>
|
||||
<span className="combatant-sub"> · {stockLabel(ware)}</span>
|
||||
</span>
|
||||
<button className="btn" disabled={!open || !trader || ware.stock === 0 || busy} onClick={() => handleBuy(ware)}>
|
||||
Buy · {formatMoney(ware.price, ware.currency)}
|
||||
</button>
|
||||
</div>
|
||||
{ware.item?.description && <div className="combatant-sub">{ware.item.description}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isDM && (
|
||||
<div className="mt-md">
|
||||
<div className="section-label">Set up: for sale at this encounter</div>
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
For a stall or a machine with nobody behind it. An NPC's wares and exchange rate are set under "Trade" on the{' '}
|
||||
<Link to={`/campaigns/${encounter.campaignId}`}>campaign page</Link>, and follow the NPC into every quest.
|
||||
</p>
|
||||
<WaresEditor
|
||||
campaignId={encounter.campaignId}
|
||||
seller={{ encounterId: encounter.id }}
|
||||
wares={encounter.wares ?? []}
|
||||
weapons={weapons}
|
||||
items={items}
|
||||
onError={onError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState } from 'react';
|
||||
import { useCreateWare, useDeleteWare, usePatchWare } from '../api/queries';
|
||||
import { NumberInput } from './NumberInput';
|
||||
import { COIN_LABELS, CURRENCIES, formatMoney } from '../lib/format';
|
||||
import { scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
import { stockLabel, wareName } from '../lib/wares';
|
||||
import type { Currency, Item, SaleOffer, Weapon } from '../api/types';
|
||||
|
||||
// One ware in the DM's list. "Edit" swaps the line for its price and stock.
|
||||
function WareRow({ campaignId, ware, onError }: { campaignId: string; ware: SaleOffer; onError: (err: unknown) => void }) {
|
||||
const patchWare = usePatchWare(campaignId);
|
||||
const deleteWare = useDeleteWare(campaignId);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [price, setPrice] = useState('');
|
||||
const [currency, setCurrency] = useState<Currency>(ware.currency);
|
||||
const [stock, setStock] = useState('');
|
||||
|
||||
function startEditing() {
|
||||
setPrice(String(ware.price));
|
||||
setCurrency(ware.currency);
|
||||
setStock(ware.stock === null ? '' : String(ware.stock));
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const amount = Math.floor(Number(price));
|
||||
if (!(amount > 0)) return;
|
||||
patchWare.mutate({ id: ware.id, price: amount, currency, stock: stock === '' ? null : Number(stock) }, { onError, onSuccess: () => setEditing(false) });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '0.3rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
<div className="flex-between">
|
||||
<span>
|
||||
{wareName(ware)} — <strong className="mono">{formatMoney(ware.price, ware.currency)}</strong>
|
||||
<span className="combatant-sub"> · {stockLabel(ware)}</span>
|
||||
</span>
|
||||
<span className="flex-row">
|
||||
<button className="btn btn-ghost" aria-label={`Edit the price and stock of ${wareName(ware)}`} onClick={() => (editing ? setEditing(false) : startEditing())}>
|
||||
{editing ? 'Cancel' : 'Edit'}
|
||||
</button>
|
||||
<button className="btn btn-ghost" aria-label={`Stop selling ${wareName(ware)}`} onClick={() => deleteWare.mutate(ware.id, { onError })}>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{editing && (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<NumberInput min={1} max={1000000} placeholder="Price" aria-label="Price for one" value={price} onChange={(e) => setPrice(e.target.value)} style={{ width: 90 }} />
|
||||
<select aria-label="Price is in" value={currency} onChange={(e) => setCurrency(e.target.value as Currency)}>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{COIN_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<NumberInput
|
||||
min={0}
|
||||
max={9999}
|
||||
placeholder="Stock (empty = no limit)"
|
||||
aria-label="How many are in stock"
|
||||
title="How many can be bought. Leave empty for no limit."
|
||||
value={stock}
|
||||
onChange={(e) => setStock(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 120 }}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={!price || patchWare.isPending}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The DM's list of what one seller has for sale — an NPC, or an encounter
|
||||
// itself — with a form to put something from the campaign pools up for sale.
|
||||
export function WaresEditor({
|
||||
campaignId,
|
||||
seller,
|
||||
wares,
|
||||
weapons,
|
||||
items,
|
||||
onError,
|
||||
}: {
|
||||
campaignId: string;
|
||||
seller: { npcId: string } | { encounterId: string };
|
||||
wares: SaleOffer[];
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const createWare = useCreateWare(campaignId);
|
||||
|
||||
const [kind, setKind] = useState<'item' | 'weapon'>('item');
|
||||
const [poolId, setPoolId] = useState('');
|
||||
const [level, setLevel] = useState('');
|
||||
const [price, setPrice] = useState('');
|
||||
const [currency, setCurrency] = useState<Currency>('GOLD');
|
||||
const [stock, setStock] = useState('');
|
||||
|
||||
const pool: (Weapon | Item)[] = kind === 'weapon' ? weapons : items;
|
||||
const picked = pool.find((entry) => entry.id === poolId);
|
||||
|
||||
function handleAdd() {
|
||||
const amount = Math.floor(Number(price));
|
||||
if (!picked || !(amount > 0)) return;
|
||||
createWare.mutate(
|
||||
{
|
||||
...seller,
|
||||
...(kind === 'weapon' ? { weaponId: picked.id } : { itemId: picked.id }),
|
||||
price: amount,
|
||||
currency,
|
||||
level: level ? Number(level) : undefined,
|
||||
stock: stock === '' ? null : Number(stock),
|
||||
},
|
||||
{
|
||||
onError,
|
||||
onSuccess: () => {
|
||||
setPoolId('');
|
||||
setLevel('');
|
||||
setPrice('');
|
||||
setStock('');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{wares.length === 0 && (
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
Nothing for sale yet.
|
||||
</p>
|
||||
)}
|
||||
{wares.map((ware) => (
|
||||
<WareRow key={ware.id} campaignId={campaignId} ware={ware} onError={onError} />
|
||||
))}
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<select
|
||||
aria-label="What kind of thing to sell"
|
||||
value={kind}
|
||||
onChange={(e) => {
|
||||
setKind(e.target.value as typeof kind);
|
||||
setPoolId('');
|
||||
setLevel('');
|
||||
}}
|
||||
>
|
||||
<option value="item">Item</option>
|
||||
<option value="weapon">Weapon</option>
|
||||
</select>
|
||||
<select
|
||||
aria-label={kind === 'weapon' ? 'Weapon to sell' : 'Item to sell'}
|
||||
value={poolId}
|
||||
onChange={(e) => {
|
||||
setPoolId(e.target.value);
|
||||
setLevel('');
|
||||
}}
|
||||
style={{ flex: 1, minWidth: 110 }}
|
||||
>
|
||||
<option value="">{pool.length ? 'Choose…' : 'The pool is empty'}</option>
|
||||
{pool.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{picked && (
|
||||
<select aria-label="Level" value={level || picked.minLevel} onChange={(e) => setLevel(e.target.value)}>
|
||||
{Array.from({ length: MAX_GEAR_LEVEL - picked.minLevel + 1 }, (_, i) => picked.minLevel + i).map((l) => (
|
||||
<option key={l} value={l}>
|
||||
Lv {l}
|
||||
{picked.damageDice ? ` · ${scaledDamageDice(picked.damageDice, l, picked.damageScaling, picked.minLevel)}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<NumberInput min={1} max={1000000} placeholder="Price" aria-label="Price for one" value={price} onChange={(e) => setPrice(e.target.value)} style={{ width: 90 }} />
|
||||
<select aria-label="Price is in" value={currency} onChange={(e) => setCurrency(e.target.value as Currency)}>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{COIN_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<NumberInput
|
||||
min={0}
|
||||
max={9999}
|
||||
placeholder="Stock (empty = no limit)"
|
||||
aria-label="How many are in stock"
|
||||
title="How many can be bought. Leave empty for no limit."
|
||||
value={stock}
|
||||
onChange={(e) => setStock(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 120 }}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleAdd} disabled={!picked || !price || createWare.isPending}>
|
||||
Put up for sale
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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<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,
|
||||
@@ -471,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('');
|
||||
@@ -933,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
|
||||
@@ -984,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>
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TradePanel
|
||||
encounter={encounter}
|
||||
isDM={isDM}
|
||||
userId={user?.id}
|
||||
characters={builtCharacters}
|
||||
weapons={weapons ?? []}
|
||||
items={items ?? []}
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
<RewardsPanel
|
||||
encounter={encounter}
|
||||
isDM={isDM}
|
||||
|
||||
Generated
+4
-4
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.20.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
@@ -31,7 +31,7 @@
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"axios": "^1.20.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"private": true,
|
||||
"description": "D&D 5e campaign manager — monorepo root",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user