Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dcb41ae4a | ||
|
|
c852ecb2e4 | ||
|
|
bdce3399ee | ||
|
|
f764878960 |
@@ -14,10 +14,10 @@ monsters, and a real-time encounter/combat tracker.
|
||||
| `docs/CHARACTERS.md` | Building a character, every number on the sheet, levels, money, permissions |
|
||||
| `docs/WEAPONS.md` | The weapon pool, weapon levels and how damage grows |
|
||||
| `docs/SPELLS.md` | The spell list, who can use which spells, the spell library |
|
||||
| `docs/ITEMS.md` | The item pool and character inventory |
|
||||
| `docs/ENCOUNTERS.md` | Battles and NPC quests, turns, rewards, what players see |
|
||||
| `docs/ITEMS.md` | The item pool, character inventory, items for sale |
|
||||
| `docs/ENCOUNTERS.md` | Battles and NPC quests, turns, rewards, trade, what players see |
|
||||
| `docs/MONSTERS.md` | The monster list and built-in bestiary |
|
||||
| `docs/NPCS.md` | Non-player characters |
|
||||
| `docs/NPCS.md` | Non-player characters, what they sell and the rates they change money at |
|
||||
|
||||
`CLAUDE_BUILD_PROMPT.md`, `docs/GAME_RULES_REFERENCE.md` and
|
||||
`docs/ROLL_YOUR_CHARACTER.md` are the original specifications the first
|
||||
@@ -25,6 +25,25 @@ version was built from. The app has since moved on in places (gear levels,
|
||||
the builder's story steps, picking a motivation); the documents above are the
|
||||
current behaviour.
|
||||
|
||||
## Getting around
|
||||
|
||||
The same four destinations are always on screen: **Campaigns** (the list),
|
||||
**Campaign** and **Party** (for the campaign you were last in, from any page,
|
||||
including a character sheet or an encounter) and **Account**.
|
||||
|
||||
- On a wide screen they are links along the top bar, and the dice roller
|
||||
floats at the bottom left.
|
||||
- On a phone or small tablet (900px wide or less) they are a bar along the
|
||||
bottom of the screen, with the **dice roller** as its last button. Nothing
|
||||
floats over the page: on a character sheet the ⚡ quick stats button sits in
|
||||
the top bar.
|
||||
- The campaign page and the character sheet are long, so each starts with a
|
||||
row of **section links** (Characters, Encounters, NPCs, Monsters...) that
|
||||
jump straight to that part of the page.
|
||||
- On a small screen every table (weapons, items, spells, monsters, the party
|
||||
overview) is shown as a stack of cards, one per row, with each figure
|
||||
labelled, and rows of controls wrap rather than run off the side.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Backend:** Node.js + Express, JavaScript, Prisma ORM, PostgreSQL, Socket.io, JWT auth (bcrypt + jsonwebtoken), zod validation.
|
||||
@@ -85,7 +104,7 @@ and then restart the backend. It applies new migrations **and** regenerates
|
||||
the Prisma client; without the second half the server keeps running with the
|
||||
old data model and pages that use anything new fail to load (for example
|
||||
"Could not load this encounter"). Change your password from the account page
|
||||
(click your name in the top bar).
|
||||
("Account" in the navigation).
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.8.0",
|
||||
"version": "1.10.1",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
@@ -10,7 +10,7 @@
|
||||
"scripts": {
|
||||
"dev": "node --watch src/index.js",
|
||||
"start": "node src/index.js",
|
||||
"test": "node --experimental-vm-modules node_modules/.bin/jest",
|
||||
"test": "jest",
|
||||
"docs:lists": "node scripts/generate-doc-lists.js",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:generate": "prisma generate",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterEnum: cash ($), a modern currency for crossover worlds. It has no fixed
|
||||
-- worth in gold, so it is never exchanged or used to make change.
|
||||
ALTER TYPE "Currency" ADD VALUE 'CASH';
|
||||
|
||||
-- AlterTable: cash is counted on its own, like each coin
|
||||
ALTER TABLE "characters" ADD COLUMN "cash" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- AlterTable: an NPC who changes money does it at their own rate, this many
|
||||
-- gold for that much cash. Both are null for an NPC who does not.
|
||||
ALTER TABLE "npcs" ADD COLUMN "exchangeGold" INTEGER;
|
||||
ALTER TABLE "npcs" ADD COLUMN "exchangeCash" INTEGER;
|
||||
|
||||
-- CreateTable: something for sale, by an NPC or at an encounter itself
|
||||
CREATE TABLE "sale_offers" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"npcId" TEXT,
|
||||
"encounterId" TEXT,
|
||||
"weaponId" TEXT,
|
||||
"itemId" TEXT,
|
||||
"price" INTEGER NOT NULL,
|
||||
"currency" "Currency" NOT NULL DEFAULT 'GOLD',
|
||||
"level" INTEGER NOT NULL DEFAULT 1,
|
||||
"stock" INTEGER,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "sale_offers_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_npcId_fkey" FOREIGN KEY ("npcId") REFERENCES "npcs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_weaponId_fkey" FOREIGN KEY ("weaponId") REFERENCES "weapons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -118,12 +118,14 @@ enum RewardType {
|
||||
ITEM
|
||||
}
|
||||
|
||||
// The coins a character carries. Their worth is fixed in utils/grants.js:
|
||||
// 1 gold = 10 silver = 100 copper.
|
||||
// The money a character carries. The coins' worth is fixed in utils/grants.js:
|
||||
// 1 gold = 10 silver = 100 copper. Cash ($) is modern money for crossover
|
||||
// worlds; it has no worth in coins and is never exchanged for them.
|
||||
enum Currency {
|
||||
GOLD
|
||||
SILVER
|
||||
COPPER
|
||||
CASH
|
||||
}
|
||||
|
||||
enum NoteVisibility {
|
||||
@@ -186,6 +188,7 @@ model Campaign {
|
||||
monsters Monster[]
|
||||
npcs Npc[]
|
||||
encounters Encounter[]
|
||||
saleOffers SaleOffer[]
|
||||
notes Note[]
|
||||
locations Location[]
|
||||
|
||||
@@ -278,11 +281,12 @@ model Character {
|
||||
// The next three are composed by the builder from the player's picks
|
||||
// (game/data/characterOptions.js). After that only the DM can edit them.
|
||||
specialAbilities String? // shown in the quick-stats panel
|
||||
// The purse: each coin is counted on its own and only changes through the
|
||||
// The purse: each coin (and cash) is counted on its own and only changes through the
|
||||
// money ledger (GoldTransaction).
|
||||
gold Int @default(0)
|
||||
silver Int @default(0)
|
||||
copper Int @default(0)
|
||||
cash Int @default(0) // whole dollars
|
||||
|
||||
background String?
|
||||
appearance String?
|
||||
@@ -369,6 +373,7 @@ model Weapon {
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
assigned CharacterWeapon[]
|
||||
rewards EncounterReward[]
|
||||
offers SaleOffer[]
|
||||
|
||||
@@map("weapons")
|
||||
}
|
||||
@@ -403,6 +408,7 @@ model Item {
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
held InventoryItem[]
|
||||
rewards EncounterReward[]
|
||||
offers SaleOffer[]
|
||||
|
||||
@@map("items")
|
||||
}
|
||||
@@ -524,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")
|
||||
}
|
||||
@@ -562,6 +573,7 @@ model Encounter {
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||
participants EncounterParticipant[]
|
||||
rewards EncounterReward[]
|
||||
wares SaleOffer[]
|
||||
|
||||
@@map("encounters")
|
||||
}
|
||||
@@ -591,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
|
||||
|
||||
@@ -674,6 +674,39 @@ describe('money tracker', () => {
|
||||
[15, 15, 'Starting gold', 'Player'],
|
||||
]);
|
||||
});
|
||||
|
||||
itIfDb('keeps cash apart from the coins', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('cash');
|
||||
const create = (name) => request(app).post(`/campaigns/${campaign.id}/characters`).set(playerAuth).send({ name });
|
||||
const characterId = (await create('Drifter')).body.character.id;
|
||||
const money = (body) => request(app).post(`/characters/${characterId}/gold`).set(playerAuth).send(body);
|
||||
|
||||
await money({ amount: 5 });
|
||||
const paid = await money({ amount: 40, currency: 'CASH', reason: 'Odd job' });
|
||||
expect(paid.status).toBe(201);
|
||||
expect(paid.body).toMatchObject({ gold: 5, cash: 40 });
|
||||
expect(paid.body.transaction).toMatchObject({ currency: 'CASH', amount: 40, balanceAfter: 40 });
|
||||
|
||||
// Cash is paid in cash alone: gold doesn't cover it, and it doesn't cover gold.
|
||||
const overspend = await money({ amount: -41, currency: 'CASH' });
|
||||
expect(overspend.status).toBe(400);
|
||||
expect(overspend.body.error).toContain('$40');
|
||||
expect((await money({ amount: -6 })).status).toBe(400);
|
||||
const spent = await money({ amount: -15, currency: 'CASH', reason: 'Diner' });
|
||||
expect(spent.body).toMatchObject({ gold: 5, silver: 0, copper: 0, cash: 25 });
|
||||
expect(spent.body.transactions).toHaveLength(1);
|
||||
|
||||
// It has no worth in coins, so it can't be exchanged, and it only moves through the ledger.
|
||||
const exchange = (body) => request(app).post(`/characters/${characterId}/gold/exchange`).set(playerAuth).send(body);
|
||||
expect((await exchange({ from: 'CASH', to: 'GOLD', amount: 10 })).status).toBe(400);
|
||||
expect((await exchange({ from: 'GOLD', to: 'CASH', amount: 1 })).status).toBe(400);
|
||||
expect((await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ cash: 999 })).status).toBe(400);
|
||||
|
||||
const sheet = (await request(app).get(`/characters/${characterId}`).set(playerAuth)).body.character;
|
||||
expect(sheet.cash).toBe(25);
|
||||
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(dmAuth)).body.characters;
|
||||
expect(listed.find((c) => c.id === characterId).cash).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe('starting options', () => {
|
||||
@@ -956,7 +989,11 @@ describe('encounters', () => {
|
||||
|
||||
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId });
|
||||
const playerView = byName((await request(app).get(e).set(playerAuth)).body.encounter);
|
||||
expect(playerView['Mayor Orla'].npc).toEqual({ id: mayor.id, name: 'Mayor Orla', role: 'Mayor', disposition: 'HOSTILE', description: 'Tired but kind.' });
|
||||
// What a player gets is exactly this: the public face, plus what the NPC trades (nothing, here).
|
||||
expect(playerView['Mayor Orla'].npc).toEqual({
|
||||
id: mayor.id, name: 'Mayor Orla', role: 'Mayor', disposition: 'HOSTILE', description: 'Tired but kind.',
|
||||
exchangeGold: null, exchangeCash: null, wares: [],
|
||||
});
|
||||
|
||||
// Deleting an NPC takes it out of the scenes it was in.
|
||||
expect((await request(app).delete(`${c}/npcs/${hermit.id}`).set(dmAuth)).status).toBe(204);
|
||||
@@ -1418,6 +1455,35 @@ describe('default content library', () => {
|
||||
expect((await request(app).get(`/campaigns/${campaign.id}/monsters`).set(dmAuth)).body.monsters).toHaveLength(1);
|
||||
expect((await request(app).get(`/campaigns/${campaign.id}/monsters`).set(playerAuth)).status).toBe(403);
|
||||
|
||||
// Every library monster carries the whole stat block.
|
||||
for (const m of listRes.body.monsters) {
|
||||
expect(m).toMatchObject({
|
||||
challengeRating: expect.any(String),
|
||||
ac: expect.any(Number),
|
||||
maxHp: expect.any(Number),
|
||||
speed: expect.any(Number),
|
||||
attackBonus: expect.any(Number),
|
||||
damageDice: expect.any(String),
|
||||
notes: expect.any(String),
|
||||
});
|
||||
for (const a of ['str', 'dex', 'con', 'int', 'wis', 'cha']) expect(m[`${a}Score`]).toEqual(expect.any(Number));
|
||||
}
|
||||
|
||||
// A hand-made monster takes the whole stat block, and every field can be changed afterwards.
|
||||
const block = {
|
||||
name: 'Gloamling', challengeRating: '1/2', ac: 13, maxHp: 18, speed: 40, attackBonus: 4, damageDice: '1d6+2 necrotic',
|
||||
strScore: 8, dexScore: 16, conScore: 12, intScore: 6, wisScore: 11, chaScore: 5,
|
||||
specialTrait: 'Shadow Step', notes: 'Fly 30. Resists necrotic.',
|
||||
};
|
||||
const made = await request(app).post(`/campaigns/${campaign.id}/monsters`).set(dmAuth).send(block);
|
||||
expect(made.status).toBe(201);
|
||||
expect(made.body.monster).toMatchObject(block);
|
||||
const patchMonster = (auth, body) => request(app).patch(`/campaigns/${campaign.id}/monsters/${made.body.monster.id}`).set(auth).send(body);
|
||||
const edited = await patchMonster(dmAuth, { speed: 20, wisScore: 14, challengeRating: '1', specialTrait: null });
|
||||
expect(edited.status).toBe(200);
|
||||
expect(edited.body.monster).toMatchObject({ ...block, speed: 20, wisScore: 14, challengeRating: '1', specialTrait: null });
|
||||
expect((await patchMonster(playerAuth, { ac: 1 })).status).toBe(403);
|
||||
|
||||
const playerAddRes = await request(app)
|
||||
.post(`/campaigns/${campaign.id}/monsters/from-library`)
|
||||
.set(playerAuth)
|
||||
@@ -2339,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();
|
||||
|
||||
@@ -201,6 +202,7 @@ function characterSummary(character) {
|
||||
gold: character.gold,
|
||||
silver: character.silver,
|
||||
copper: character.copper,
|
||||
cash: character.cash,
|
||||
maxHp: character.buildStage === 'COMPLETE' ? sheet.hp.max : null,
|
||||
armorClass: character.buildStage === 'COMPLETE' ? sheet.armorClass : null,
|
||||
currentLocationId: character.currentLocationId,
|
||||
@@ -225,7 +227,7 @@ router.get('/:id/characters', requireCampaignMember(), async (req, res, next) =>
|
||||
characters: summaries.map((c) => {
|
||||
if (c.userId === req.user.id) return c;
|
||||
const knowsPlace = c.currentLocationId && visited.has(c.currentLocationId);
|
||||
return { ...c, gold: null, silver: null, copper: null, ...(knowsPlace ? {} : { currentLocationId: null, currentLocationName: null }) };
|
||||
return { ...c, gold: null, silver: null, copper: null, cash: null, ...(knowsPlace ? {} : { currentLocationId: null, currentLocationName: null }) };
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -525,7 +527,7 @@ const monsterSchema = z.object({
|
||||
wisScore: z.number().int().min(1).max(30).optional(),
|
||||
chaScore: z.number().int().min(1).max(30).optional(),
|
||||
attackBonus: z.number().int().min(-20).max(50).optional(),
|
||||
damageDice: z.string().max(20).optional().nullable(),
|
||||
damageDice: z.string().max(60).optional().nullable(), // may name its type: "1d6+2 necrotic"
|
||||
challengeRating: z.string().max(10).optional().nullable(),
|
||||
specialTrait: z.string().max(500).optional().nullable(),
|
||||
notes: z.string().max(2000).optional().nullable(),
|
||||
@@ -644,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);
|
||||
@@ -661,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) {
|
||||
@@ -678,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);
|
||||
@@ -779,6 +806,83 @@ router.delete('/:id/locations/:locationId', requireCampaignMember(), requireDM()
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wares — weapons and items for sale, by an NPC or at an encounter itself.
|
||||
// Only the DM sets them up. Players see them, and buy, on the encounter page
|
||||
// (encounters.routes.js); an NPC's wares go wherever the NPC does.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const wareSchema = z
|
||||
.object({
|
||||
npcId: z.string().optional(),
|
||||
encounterId: z.string().optional(),
|
||||
weaponId: z.string().optional(),
|
||||
itemId: z.string().optional(),
|
||||
price: z.number().int().min(1).max(1000000),
|
||||
currency: z.enum(CURRENCIES).default('GOLD'),
|
||||
level: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
|
||||
stock: z.number().int().min(0).max(9999).optional().nullable(), // null: no limit
|
||||
})
|
||||
.refine((data) => Boolean(data.npcId) !== Boolean(data.encounterId), { message: 'Say who sells it: an NPC or an encounter, not both' })
|
||||
.refine((data) => Boolean(data.weaponId) !== Boolean(data.itemId), { message: 'Pick one weapon or one item to sell' });
|
||||
|
||||
router.post('/:id/wares', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = wareSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const data = parsed.data;
|
||||
const inCampaign = async (model, id) => (id ? prisma[model].findFirst({ where: { id, campaignId: req.campaign.id } }) : null);
|
||||
|
||||
const seller = (await inCampaign('npc', data.npcId)) || (await inCampaign('encounter', data.encounterId));
|
||||
if (!seller) return res.status(400).json({ error: 'That seller is not part of this campaign.' });
|
||||
const entry = (await inCampaign('weapon', data.weaponId)) || (await inCampaign('item', data.itemId));
|
||||
if (!entry) return res.status(400).json({ error: `That ${data.weaponId ? 'weapon' : 'item'} is not part of this campaign.` });
|
||||
const level = data.level ?? entry.minLevel;
|
||||
if (level < entry.minLevel) {
|
||||
return res.status(400).json({ error: `${entry.name} starts at level ${entry.minLevel} and can't go below it.` });
|
||||
}
|
||||
|
||||
const ware = await prisma.saleOffer.create({ data: { ...data, level, campaignId: req.campaign.id }, include: WARE_INCLUDE });
|
||||
emitTradeChanged(req);
|
||||
res.status(201).json({ ware });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// The price and what is left can change; what is sold, and by whom, cannot.
|
||||
const patchWareSchema = z.object({
|
||||
price: z.number().int().min(1).max(1000000).optional(),
|
||||
currency: z.enum(CURRENCIES).optional(),
|
||||
stock: z.number().int().min(0).max(9999).nullable().optional(),
|
||||
});
|
||||
|
||||
router.patch('/:id/wares/:wareId', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = patchWareSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
if (!(await updatePoolEntry('saleOffer', req.params.wareId, req.campaign.id, parsed.data))) {
|
||||
return res.status(404).json({ error: 'Nothing like that is for sale in this campaign' });
|
||||
}
|
||||
emitTradeChanged(req);
|
||||
res.json({ ware: await prisma.saleOffer.findUnique({ where: { id: req.params.wareId }, include: WARE_INCLUDE }) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id/wares/:wareId', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
if (!(await deletePoolEntry('saleOffer', req.params.wareId, req.campaign.id))) {
|
||||
return res.status(404).json({ error: 'Nothing like that is for sale in this campaign' });
|
||||
}
|
||||
emitTradeChanged(req);
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default content library — static SRD-style weapons/spells/monsters any
|
||||
// campaign can pull from, instead of the DM hand-typing everything. See
|
||||
|
||||
@@ -29,7 +29,7 @@ const {
|
||||
} = require('../game/buildFlow');
|
||||
const characterOptions = require('../game/data/characterOptions');
|
||||
const { SKILLS } = require('../game/data/skills');
|
||||
const { CURRENCIES, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, spendCoins, worthInGold, giveGear } = require('../utils/grants');
|
||||
const { CURRENCIES, COINS, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, spendCoins, worthInGold, giveGear } = require('../utils/grants');
|
||||
const { recordVisits } = require('../utils/locations');
|
||||
const { absorbDamage, NO_DEATH_SAVES, lifeState, deathSaveInfo, settleDeathSaves, changeHp, rollDeathSave } = require('../utils/hp');
|
||||
|
||||
@@ -97,6 +97,7 @@ function serializeCharacter(character, isOwner, isDM = true) {
|
||||
gold: restricted ? null : character.gold,
|
||||
silver: restricted ? null : character.silver,
|
||||
copper: restricted ? null : character.copper,
|
||||
cash: restricted ? null : character.cash,
|
||||
restricted,
|
||||
buildStage: character.buildStage,
|
||||
buildState: character.buildStage === 'COMPLETE' ? null : character.buildState,
|
||||
@@ -173,8 +174,8 @@ function clamp(value, min, max) {
|
||||
|
||||
router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
if (req.body && ['gold', 'silver', 'copper'].some((coin) => req.body[coin] !== undefined)) {
|
||||
return res.status(400).json({ error: 'Gold, silver and copper changes go through the money tracker.' });
|
||||
if (req.body && Object.values(COIN_COLUMN).some((coin) => req.body[coin] !== undefined)) {
|
||||
return res.status(400).json({ error: 'Gold, silver, copper and cash changes go through the money tracker.' });
|
||||
}
|
||||
const parsed = patchCharacterSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
@@ -425,6 +426,7 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
|
||||
gold: source.gold,
|
||||
silver: source.silver,
|
||||
copper: source.copper,
|
||||
cash: source.cash,
|
||||
background: source.background,
|
||||
appearance: source.appearance,
|
||||
// Same privacy boundary as serializeCharacter: a DM copying a
|
||||
@@ -938,8 +940,11 @@ function coinName(currency) {
|
||||
return currency.toLowerCase();
|
||||
}
|
||||
|
||||
// For a payment the whole purse can't cover.
|
||||
const PURSE_SELECT = { gold: true, silver: true, copper: true, cash: true };
|
||||
|
||||
// For a payment the whole purse can't cover. Cash stands apart from the coins.
|
||||
function notEnoughMoney(character, amount, currency) {
|
||||
if (currency === 'CASH') return `Not enough cash — ${character.name} has $${character.cash}, which doesn't cover $${amount}.`;
|
||||
return `Not enough money — ${character.name} has ${character.gold} gold, ${character.silver} silver and ${character.copper} copper (worth ${worthInGold(character)} gold), which doesn't cover ${amount} ${coinName(currency)}.`;
|
||||
}
|
||||
|
||||
@@ -951,7 +956,7 @@ router.get('/:id/gold', requireCharacterAccess(), async (req, res, next) => {
|
||||
include: GOLD_TRANSACTION_INCLUDE,
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
res.json({ gold: req.character.gold, silver: req.character.silver, copper: req.character.copper, transactions });
|
||||
res.json({ gold: req.character.gold, silver: req.character.silver, copper: req.character.copper, cash: req.character.cash, transactions });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -961,6 +966,7 @@ router.get('/:id/gold', requireCharacterAccess(), async (req, res, next) => {
|
||||
// and the DM can both record entries; each one is stamped with who made it.
|
||||
// A spend is paid from the whole purse: if there aren't enough of the coin
|
||||
// named, bigger coins are broken (or smaller ones counted out) to cover it.
|
||||
// Cash is only ever paid in cash.
|
||||
router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z
|
||||
@@ -986,7 +992,7 @@ router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, re
|
||||
}
|
||||
|
||||
emitCharacterUpdated(req);
|
||||
const purse = await prisma.character.findUnique({ where: { id: req.character.id }, select: { gold: true, silver: true, copper: true } });
|
||||
const purse = await prisma.character.findUnique({ where: { id: req.character.id }, select: PURSE_SELECT });
|
||||
res.status(201).json({ ...purse, transaction: transactions[0], transactions });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -996,11 +1002,12 @@ router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, re
|
||||
// Changes coins of one kind into another at their fixed worth (1 gold = 10
|
||||
// silver = 100 copper). `amount` is how many `from` coins to hand over; it has
|
||||
// to come to a whole number of `to` coins. Both sides go on the ledger. Unlike
|
||||
// a spend, this moves exactly the coins named: no change is made.
|
||||
// a spend, this moves exactly the coins named: no change is made. Cash has no
|
||||
// worth in coins, so it can't be exchanged.
|
||||
router.post('/:id/gold/exchange', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z
|
||||
.object({ from: z.enum(CURRENCIES), to: z.enum(CURRENCIES), amount: z.number().int().min(1).max(1000000) })
|
||||
.object({ from: z.enum(COINS), to: z.enum(COINS), amount: z.number().int().min(1).max(1000000) })
|
||||
.refine((data) => data.from !== data.to, { message: 'Pick two different coins' })
|
||||
.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
@@ -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;
|
||||
|
||||
+18
-12
@@ -6,10 +6,14 @@
|
||||
// The coins, and what each is worth counted in copper: silver is 0.1 gold and
|
||||
// copper is 0.01 gold, so 1 gold = 10 silver = 100 copper and 1 silver = 10
|
||||
// copper. Whole numbers keep exchanges exact.
|
||||
const CURRENCIES = ['GOLD', 'SILVER', 'COPPER'];
|
||||
const COINS = ['GOLD', 'SILVER', 'COPPER'];
|
||||
const COIN_VALUE = { GOLD: 100, SILVER: 10, COPPER: 1 };
|
||||
// Each coin is its own column on the character.
|
||||
const COIN_COLUMN = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper' };
|
||||
// Everything a purse can hold: the coins, plus cash ($) — modern money for
|
||||
// crossover worlds. Cash has no worth in coins, so it is never exchanged for
|
||||
// them and never used to make change.
|
||||
const CURRENCIES = [...COINS, 'CASH'];
|
||||
// Each currency is its own column on the character.
|
||||
const COIN_COLUMN = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper', CASH: 'cash' };
|
||||
|
||||
class InsufficientGoldError extends Error {}
|
||||
|
||||
@@ -43,7 +47,7 @@ function planPayment(purse, currency, amount) {
|
||||
const delta = { GOLD: 0, SILVER: 0, COPPER: 0 };
|
||||
const left = (coin) => purse[coin] + delta[coin];
|
||||
let owed = amount * worth(currency);
|
||||
if (CURRENCIES.reduce((sum, coin) => sum + purse[coin] * worth(coin), 0) < owed) return null;
|
||||
if (COINS.reduce((sum, coin) => sum + purse[coin] * worth(coin), 0) < owed) return null;
|
||||
|
||||
const pay = (coin, count) => {
|
||||
delta[coin] -= count;
|
||||
@@ -51,13 +55,13 @@ function planPayment(purse, currency, amount) {
|
||||
};
|
||||
pay(currency, Math.min(purse[currency], amount));
|
||||
|
||||
const bigger = CURRENCIES.filter((coin) => worth(coin) > worth(currency)).reverse(); // nearest first
|
||||
const bigger = COINS.filter((coin) => worth(coin) > worth(currency)).reverse(); // nearest first
|
||||
for (const coin of bigger) {
|
||||
if (owed <= 0) break;
|
||||
pay(coin, Math.min(left(coin), Math.ceil(owed / worth(coin))));
|
||||
// Overpaid: hand the difference back, biggest coins first.
|
||||
let change = -owed;
|
||||
for (const small of CURRENCIES.filter((c) => worth(c) < worth(coin))) {
|
||||
for (const small of COINS.filter((c) => worth(c) < worth(coin))) {
|
||||
if (change <= 0) break;
|
||||
const count = Math.floor(change / worth(small));
|
||||
delta[small] += count;
|
||||
@@ -66,7 +70,7 @@ function planPayment(purse, currency, amount) {
|
||||
if (owed < 0) owed = 0;
|
||||
}
|
||||
|
||||
for (const coin of CURRENCIES.filter((c) => worth(c) < worth(currency))) {
|
||||
for (const coin of COINS.filter((c) => worth(c) < worth(currency))) {
|
||||
if (owed <= 0) break;
|
||||
pay(coin, Math.min(left(coin), Math.floor(owed / worth(coin))));
|
||||
}
|
||||
@@ -76,14 +80,16 @@ function planPayment(purse, currency, amount) {
|
||||
// Pays `amount` coins of `currency` out of a character's purse, making change
|
||||
// as needed (see planPayment). Every coin that moved gets its own ledger row;
|
||||
// the rows are returned, the one in `currency` first when there is one. Throws
|
||||
// InsufficientGoldError if the purse as a whole can't cover it.
|
||||
// InsufficientGoldError if the purse as a whole can't cover it. Cash is paid in
|
||||
// cash alone: coins don't cover it, and it doesn't cover coins.
|
||||
async function spendCoins(db, { characterId, amount, currency = 'GOLD', reason, userId, include }) {
|
||||
if (!COINS.includes(currency)) return [await adjustGold(db, { characterId, amount: -amount, currency, reason, userId, include })];
|
||||
const character = await db.character.findUnique({ where: { id: characterId }, select: { gold: true, silver: true, copper: true } });
|
||||
const purse = { GOLD: character.gold, SILVER: character.silver, COPPER: character.copper };
|
||||
const delta = planPayment(purse, currency, amount);
|
||||
if (!delta) throw new InsufficientGoldError();
|
||||
|
||||
const moved = CURRENCIES.filter((coin) => delta[coin] !== 0).sort((a, b) => (b === currency) - (a === currency));
|
||||
const moved = COINS.filter((coin) => delta[coin] !== 0).sort((a, b) => (b === currency) - (a === currency));
|
||||
const madeChange = moved.some((coin) => coin !== currency);
|
||||
const label = reason || 'Spent';
|
||||
const rows = [];
|
||||
@@ -103,9 +109,9 @@ async function spendCoins(db, { characterId, amount, currency = 'GOLD', reason,
|
||||
return rows;
|
||||
}
|
||||
|
||||
// A purse's whole worth as one figure in gold, for messages: 265 copper-worth -> 2.65.
|
||||
// A purse's coins as one figure in gold, for messages: 265 copper-worth -> 2.65.
|
||||
function worthInGold(character) {
|
||||
const copper = CURRENCIES.reduce((sum, coin) => sum + character[COIN_COLUMN[coin]] * COIN_VALUE[coin], 0);
|
||||
const copper = COINS.reduce((sum, coin) => sum + character[COIN_COLUMN[coin]] * COIN_VALUE[coin], 0);
|
||||
return copper / COIN_VALUE.GOLD;
|
||||
}
|
||||
|
||||
@@ -127,4 +133,4 @@ async function giveGear(db, kind, { characterId, entry, quantity = 1, level, equ
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { CURRENCIES, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, planPayment, spendCoins, worthInGold, giveGear };
|
||||
module.exports = { CURRENCIES, COINS, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, planPayment, spendCoins, worthInGold, giveGear };
|
||||
|
||||
+21
-5
@@ -581,7 +581,22 @@ behind it.
|
||||
- The log is the only way a balance changes. Entries cannot be edited or
|
||||
deleted; to correct a mistake, record the opposite entry.
|
||||
- Starting gold (a campaign setting) is gold only. Encounter rewards and gifts
|
||||
between characters can be in any coin.
|
||||
between characters can be in any coin, or in cash.
|
||||
- **Cash ($)** is a fourth balance for crossover worlds, counted in whole
|
||||
dollars and shown as `$40`. It is gained, spent, given and awarded like a
|
||||
coin and has its own log entries, but it has no worth in coins: it cannot
|
||||
be exchanged on the sheet, a cash spend is paid from cash alone, and cash
|
||||
is never broken to cover a coin spend. It shows in the purse only when
|
||||
held.
|
||||
- **Gold and cash are changed for each other by an NPC**, not on the sheet.
|
||||
An NPC the DM has given an exchange rate ("1 gold = $5") changes them
|
||||
either way at that rate, in an NPC quest in progress that both the NPC and
|
||||
the character are in. Only gold and cash: silver and copper are not taken.
|
||||
Each NPC has their own rate. See [NPCS.md](NPCS.md), section 5.
|
||||
- **Buying.** In the same kind of scene a character can buy the weapons and
|
||||
items an NPC, or the encounter itself, has for sale. The price is paid from
|
||||
the purse like any other spend and logged as "Bought <thing> from <NPC>".
|
||||
See [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
|
||||
|
||||
### Giving to another character
|
||||
|
||||
@@ -631,7 +646,7 @@ character in the same campaign.
|
||||
| Party overview | One table of every character with an HP bar, AC, gold and location. |
|
||||
| Character sheet | Everything in this document. |
|
||||
| Character builder | A character that is still being built (section 2). |
|
||||
| Quick stats drawer | A combat summary: HP, AC, initiative, speed, proficiency bonus, spellcasting numbers, special abilities, and each weapon's attack and damage. |
|
||||
| Quick stats drawer (the ⚡ button: bottom right, or in the top bar on a phone) | A combat summary: HP, AC, initiative, speed, proficiency bonus, spellcasting numbers, special abilities, and each weapon's attack and damage. |
|
||||
| Encounter page | Characters taking part in a battle or quest (next section). |
|
||||
|
||||
Changes are pushed live to everyone who has the campaign open, so sheets and
|
||||
@@ -743,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 |
|
||||
@@ -773,13 +789,13 @@ calculations for them.
|
||||
- Armor and shields as items (only the single armor bonus number)
|
||||
- Weapon proficiency (everyone is proficient with everything)
|
||||
- Rolling attacks, damage, saves or checks for a character (the dice roller,
|
||||
bottom left of every page, is a plain roller that knows nothing about the
|
||||
sheet)
|
||||
bottom left of every page, or the last button of the bottom bar on a phone,
|
||||
is a plain roller that knows nothing about the sheet)
|
||||
- Conditions on the sheet, and resting
|
||||
- Automatic levelling from XP
|
||||
- Alignment, languages, tool proficiencies and backgrounds-as-rules
|
||||
- Encumbrance and item weight
|
||||
- Currencies other than gold, and item prices
|
||||
- Cents, and changing silver or copper for cash
|
||||
|
||||
## 20. Everything the builder offers
|
||||
|
||||
|
||||
+71
-12
@@ -19,9 +19,10 @@ This describes the code as it stands. The rules live in
|
||||
7. [Initiative, turns and rounds](#7-initiative-turns-and-rounds)
|
||||
8. [Conditions](#8-conditions)
|
||||
9. [Rewards](#9-rewards)
|
||||
10. [What each side sees](#10-what-each-side-sees)
|
||||
11. [Permissions](#11-permissions)
|
||||
12. [What is not modelled](#12-what-is-not-modelled)
|
||||
10. [Trade](#10-trade)
|
||||
11. [What each side sees](#11-what-each-side-sees)
|
||||
12. [Permissions](#12-permissions)
|
||||
13. [What is not modelled](#13-what-is-not-modelled)
|
||||
|
||||
---
|
||||
|
||||
@@ -46,11 +47,12 @@ time from the top of the page.
|
||||
| | Battle | NPC quest |
|
||||
|---|---|---|
|
||||
| For | A fight | A scene built around people and an objective |
|
||||
| Layout | Everyone in one list, in initiative order | Three groups: "People you meet", "The party", "Creatures" |
|
||||
| Layout | Everyone in one list, in initiative order; once in progress, those who have had their turn move to "Next Round" at the bottom | Three groups: "People you meet", "The party", "Creatures" |
|
||||
| Rounds and turns | Yes | No |
|
||||
| Initiative | The d20 roll, typed in per combatant; the bonus is added | The same; each group lists highest first |
|
||||
| Start button | Begin Battle | Begin Quest |
|
||||
| End button | End Battle | Complete Quest |
|
||||
| Trade (section 10) | No | Yes, while in progress |
|
||||
|
||||
Everything else (hit points, conditions, rewards, the scene description, DM
|
||||
notes) works the same in both.
|
||||
@@ -86,6 +88,10 @@ goes to the top. The "Sort by" menu re-sorts the list by name, status, type or
|
||||
date instead; that choice is remembered per browser and changes nothing for
|
||||
anyone else. ▲ and ▼ only show while sorting by campaign order.
|
||||
|
||||
The list shows **ten encounters a page**, with Prev and Next beneath it, and
|
||||
the DM's archived list is paged the same way. The order runs on across pages:
|
||||
moving the last encounter on a page down carries it to the top of the next.
|
||||
|
||||
**Archive.** "Archive" (on the list or the encounter page) puts an encounter
|
||||
away without changing it. It moves to the DM's "Archived" section and
|
||||
disappears from the players' list; a player whose character is in it can still
|
||||
@@ -247,6 +253,13 @@ battles only.
|
||||
combatant it returns to the top and the **round** goes up by one.
|
||||
- **Next Round** skips straight to the top of the next round.
|
||||
- Anyone out of the fight (section 6, "The fallen") is passed over.
|
||||
- **The list follows the turn.** While a battle is in progress, whoever is on
|
||||
turn is at the top of "Initiative order" with those still to act this round
|
||||
beneath them. Each combatant whose turn has passed moves down to a **Next
|
||||
Round** section at the bottom, still in initiative order. When the round
|
||||
rolls over everyone is back in "Initiative order". Everyone sees the same
|
||||
arrangement. Before the battle begins, and once it is complete, it is one
|
||||
plain list.
|
||||
|
||||
### Targets
|
||||
|
||||
@@ -323,7 +336,7 @@ The DM lines up loot in the Rewards panel, on any encounter at any time.
|
||||
|
||||
| Reward | What the DM sets |
|
||||
|---|---|
|
||||
| Gold | An amount |
|
||||
| Coins or cash | An amount, and which: gold, silver, copper or cash ($) |
|
||||
| Weapon | A weapon from the pool, a level (not below its starting level), a quantity |
|
||||
| Item | An item from the pool, a level, a quantity |
|
||||
|
||||
@@ -334,9 +347,9 @@ Each can carry a short note ("Goblin chief's purse").
|
||||
| Button | What happens |
|
||||
|---|---|
|
||||
| Award (pick a character) | The whole reward goes to that character. It can be any finished character in the campaign, not only those in the encounter. |
|
||||
| Split (gold only) | Shared evenly between the characters in the encounter. Any remainder goes one gold each, highest initiative first: 101 gold between two is 51 and 50. |
|
||||
| Split (coins and cash only) | Shared evenly between the characters in the encounter. Any remainder goes one each, highest initiative first: 101 gold between two is 51 and 50. |
|
||||
|
||||
- **Gold** is added to the character's balance with a money-log entry reading
|
||||
- **Coins or cash** are added to the character's balance with a money-log entry reading
|
||||
"Reward: <encounter name>".
|
||||
- A **weapon or item** is added to what the character holds, at the reward's
|
||||
level, stacking with any they already have. If the character is below that
|
||||
@@ -347,7 +360,48 @@ Each can carry a short note ("Goblin chief's purse").
|
||||
Until a reward is awarded only the DM can see it. Afterwards players see what
|
||||
it was and who received it.
|
||||
|
||||
## 10. What each side sees
|
||||
## 10. Trade
|
||||
|
||||
In an NPC quest the party can **change money** and **buy things**. Both take
|
||||
someone to deal with and the time to do it, so:
|
||||
|
||||
- Trading is open only in an **NPC quest that is in progress**. Before "Begin
|
||||
Quest" and after "Complete Quest" everything on offer is still shown, but
|
||||
the buttons are off.
|
||||
- There is **no trading in a battle**. Switching a quest to a battle hides the
|
||||
Trade card and the server refuses any trade; switching back brings it back.
|
||||
- An NPC trades only while they are **in the scene and not at 0 HP**.
|
||||
- The character trading has to be **in the encounter** and alive.
|
||||
|
||||
The "Trade" card sits beside the scene, above Rewards. It appears for players
|
||||
when there is something to trade, and always for the DM in a quest.
|
||||
|
||||
| Part of the card | What it does |
|
||||
|---|---|
|
||||
| Who is trading | Picks the character. A player chooses among their own characters in the scene; the DM among all of them. Their purse is shown beside it |
|
||||
| 💱 A money changer | One row per NPC in the scene who has an exchange rate. Type an amount, choose "gold into cash" or "cash into gold", and the button shows what comes back |
|
||||
| 🛒 "<NPC> sells" | Everything that NPC has for sale: what it is, its level and dice, the price, and how many are left. "Buy" buys one |
|
||||
| 🛒 "For sale here" | Things sold by the scene itself rather than by anyone in it |
|
||||
| Set up (DM only) | Puts weapons and items from the campaign pools up for sale at this encounter, and edits their price and stock |
|
||||
|
||||
- **Changing money** uses the NPC's own rate, the same both ways, and only
|
||||
gold and cash: no silver, no copper, no change. The amount handed over has
|
||||
to be a whole multiple of that side of the rate. The full rules and
|
||||
examples are in [NPCS.md](NPCS.md), section 5.
|
||||
- **Buying** pays the price from the character's purse (coins make change; a
|
||||
cash price takes cash only), adds the weapon or item to what the character
|
||||
holds at the level it is sold at, and uses up stock. A character below that
|
||||
level holds it but cannot use it yet.
|
||||
- Every trade is written to the character's money log.
|
||||
- **An NPC's rate and wares are set on the campaign page**, under "Trade" on
|
||||
the NPC's row, and follow the NPC into every quest they are in. **An
|
||||
encounter's own wares are set here**, in the DM's part of the card, and
|
||||
exist only in this encounter: a market stall, a vending machine, a chest
|
||||
with a price tag.
|
||||
- Removing an NPC from the scene takes their rate and wares out of it.
|
||||
Deleting the encounter deletes its own wares; what was bought stays bought.
|
||||
|
||||
## 11. What each side sees
|
||||
|
||||
| | DM | Players |
|
||||
|---|---|---|
|
||||
@@ -359,12 +413,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 |
|
||||
|---|---|---|
|
||||
@@ -383,13 +438,15 @@ 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)
|
||||
left of every page, or in the bottom bar on a phone)
|
||||
- Applying an attack's damage automatically
|
||||
- Death saves for monsters and NPCs, and prompting for a save on a dying
|
||||
character's turn
|
||||
@@ -397,6 +454,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
|
||||
|
||||
+19
-13
@@ -36,10 +36,10 @@ This describes the code as it stands. The built-in list is
|
||||
| Name | Up to 80 characters |
|
||||
| AC | Armor Class |
|
||||
| Max HP | Hit points each copy starts an encounter with |
|
||||
| Speed | In feet. 30 unless set otherwise |
|
||||
| Speed | Walking speed in feet. 30 unless set otherwise. Other speeds (fly, swim, climb) go in Notes |
|
||||
| Ability scores | STR, DEX, CON, INT, WIS, CHA. 10 unless set otherwise |
|
||||
| Attack bonus | Added to the monster's attack roll |
|
||||
| Damage | Dice for its main attack, e.g. `1d6+2` |
|
||||
| Damage | Dice for its main attack, e.g. `1d6+2`, with the damage type if you like: `1d6+2 necrotic` |
|
||||
| Challenge rating | A rough danger rating, e.g. `1/4`, `2` |
|
||||
| Special trait | A short note such as "Pack Tactics" |
|
||||
| Notes | Anything else: what it attacks with, how it behaves |
|
||||
@@ -52,21 +52,25 @@ from its scores for display only.
|
||||
|
||||
The list is the "Monsters" card on the campaign page (DM only).
|
||||
|
||||
- **Adding by hand:** "+ Add Monster" asks for name, AC, max HP, attack bonus
|
||||
and damage. Speed is set to 30 and every ability score to 10; challenge
|
||||
rating, trait and notes are left empty.
|
||||
- **Adding by hand:** "+ Add Monster" opens the whole stat block: name,
|
||||
challenge rating, AC, HP, speed, attack bonus, damage, the six ability
|
||||
scores, special trait and notes. Only name, AC and HP must be typed; speed
|
||||
starts at 30, attack bonus at 0 and each ability score at 10.
|
||||
- **Adding from the bestiary:** "📚 From Library" copies a complete stat block.
|
||||
- **Changing:** there is no edit form (the API accepts changes, but no screen
|
||||
offers them). Remove and re-add, or add from the library.
|
||||
- **Removing:** the ✕ button, immediately, with no confirmation. Copies
|
||||
- **Changing:** "Edit" on a monster's row opens the same form filled in.
|
||||
Saving changes the template, and copies already in encounters show the new
|
||||
stat block straight away; their current HP is left alone.
|
||||
- The list shows each monster's full stat block: the numbers in columns, and
|
||||
its ability scores, trait and notes under its name.
|
||||
- **Removing:** the ✕ button, after a confirmation. Copies
|
||||
already in encounters stay there with their name and HP, but lose their
|
||||
stat block.
|
||||
|
||||
## 4. The built-in bestiary
|
||||
|
||||
38 monsters, from harmless to dangerous. Each library row shows challenge
|
||||
rating, AC, HP, attack and damage, plus its trait and notes, before you add
|
||||
it. Adding copies the monster into your campaign; the copy is yours, and later
|
||||
38 monsters, from harmless to dangerous, every one with a complete stat
|
||||
block. Each library row shows challenge rating, AC, HP, speed, attack, damage
|
||||
and the six ability scores, plus its trait and notes, before you add it. Adding copies the monster into your campaign; the copy is yours, and later
|
||||
changes to the library do not affect it.
|
||||
|
||||
The full bestiary, weakest first. "Attack" is the bonus added to the
|
||||
@@ -146,8 +150,9 @@ and its notes.
|
||||
This is enforced by the server: stat blocks are never sent to a player's
|
||||
browser.
|
||||
|
||||
Players do see a monster's exact HP numbers. If you would rather keep those
|
||||
secret, that is not currently possible.
|
||||
Players see a monster's exact HP numbers unless the campaign setting "Players
|
||||
see the exact hit points of monsters and NPCs in encounters" is unticked.
|
||||
Then they get the health bar only.
|
||||
|
||||
## 7. Permissions
|
||||
|
||||
@@ -163,6 +168,7 @@ secret, that is not currently possible.
|
||||
## 8. What is not modelled
|
||||
|
||||
- Multiple attacks, spells, legendary actions and resistances (use Notes)
|
||||
- Trade: monsters do not sell things or change money (NPCs do)
|
||||
- Rolling a monster's attacks or damage (use the dice roller)
|
||||
- Saving throw bonuses and skills
|
||||
- Experience points for defeating monsters
|
||||
|
||||
+99
-16
@@ -1,7 +1,8 @@
|
||||
# NPCs
|
||||
|
||||
How non-player characters work in this app: what the DM records about them,
|
||||
how they are brought into a scene, and what players can and cannot see.
|
||||
how they are brought into a scene, what they sell and the rate they change
|
||||
money at, and what players can and cannot see.
|
||||
|
||||
This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
[MONSTERS.md](MONSTERS.md).
|
||||
@@ -12,10 +13,11 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
2. [What an NPC holds](#2-what-an-npc-holds)
|
||||
3. [Managing NPCs](#3-managing-npcs)
|
||||
4. [NPCs in encounters](#4-npcs-in-encounters)
|
||||
5. [What players see](#5-what-players-see)
|
||||
6. [NPC or monster?](#6-npc-or-monster)
|
||||
7. [Permissions](#7-permissions)
|
||||
8. [What is not modelled](#8-what-is-not-modelled)
|
||||
5. [Trade: changing money and selling](#5-trade-changing-money-and-selling)
|
||||
6. [What players see](#6-what-players-see)
|
||||
7. [NPC or monster?](#7-npc-or-monster)
|
||||
8. [Permissions](#8-permissions)
|
||||
9. [What is not modelled](#9-what-is-not-modelled)
|
||||
|
||||
---
|
||||
|
||||
@@ -27,6 +29,8 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
- An NPC does nothing by sitting in the list. Players meet one when the DM
|
||||
**adds it to an encounter**.
|
||||
- NPCs are mainly for talking to. Combat stats are optional.
|
||||
- An NPC can also **trade**: change gold and cash at a rate of their own, and
|
||||
sell weapons and items (section 5).
|
||||
|
||||
## 2. What an NPC holds
|
||||
|
||||
@@ -38,9 +42,11 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
| Description | What the party can see and knows | Everyone |
|
||||
| DM notes | Secrets, motives, what they are hiding | **DM only** |
|
||||
| AC | Optional | DM only |
|
||||
| HP | Optional. Without it the NPC has no health bar | See section 5 |
|
||||
| HP | Optional. Without it the NPC has no health bar | See section 6 |
|
||||
| Attack bonus | Optional | DM only |
|
||||
| Damage | Optional dice, e.g. `1d6` | DM only |
|
||||
| Exchange rate | Optional: so much gold for so much cash (section 5) | Everyone, in an encounter |
|
||||
| Wares | Optional: weapons and items for sale, each with a price (section 5) | Everyone, in an encounter |
|
||||
|
||||
A new NPC is Neutral unless you choose otherwise.
|
||||
|
||||
@@ -48,10 +54,14 @@ A new NPC is Neutral unless you choose otherwise.
|
||||
|
||||
The NPC list is the "NPCs" card on the campaign page (DM only).
|
||||
|
||||
- **Adding:** "+ Add NPC" opens a form with every field above. Only the name
|
||||
is required.
|
||||
- **Changing:** there is no edit form. To change an NPC, remove it and add it
|
||||
again (the API accepts changes, but no screen offers them).
|
||||
- **Adding:** "+ Add NPC" opens a form with every field above except the
|
||||
exchange rate and wares. Only the name is required.
|
||||
- **Trade:** the "Trade" button on an NPC's row opens their exchange rate and
|
||||
their wares (section 5). The row shows a 💱 badge with the rate and a 🛒
|
||||
badge with how many things are for sale.
|
||||
- **Changing:** there is no edit form for the other fields. To change them,
|
||||
remove the NPC and add it again (the API accepts changes, but no screen
|
||||
offers them). Removing an NPC removes their wares too.
|
||||
- **Removing:** the ✕ button, after a confirmation. This also **removes the
|
||||
NPC from every encounter** it was in.
|
||||
|
||||
@@ -73,7 +83,72 @@ An NPC's card shows its attitude as a coloured badge, its role under its
|
||||
name, and its description in full. The DM's card also shows AC, attack,
|
||||
damage and the DM notes.
|
||||
|
||||
## 5. What players see
|
||||
## 5. Trade: changing money and selling
|
||||
|
||||
Only the DM sets trade up. Players see it and use it on the encounter page,
|
||||
in the "Trade" card. The rules for when trading is open are in
|
||||
[ENCOUNTERS.md](ENCOUNTERS.md), section 10: in short, in an **NPC quest that
|
||||
is in progress**, never in a battle, and only with an NPC who is in the scene
|
||||
and not at 0 HP.
|
||||
|
||||
### Changing money
|
||||
|
||||
An NPC with an **exchange rate** changes gold into cash ($) and cash into
|
||||
gold. The rate is two whole numbers, "this much gold = this much cash", and
|
||||
each NPC has their own, so one money changer can be a better deal than
|
||||
another.
|
||||
|
||||
| Rate | Hand over | Get |
|
||||
|---|---|---|
|
||||
| 1 gold = $5 | 3 gold | $15 |
|
||||
| 1 gold = $5 | $20 | 4 gold |
|
||||
| 2 gold = $15 | 6 gold | $45 |
|
||||
| 2 gold = $15 | $30 | 4 gold |
|
||||
|
||||
- The same rate is used both ways.
|
||||
- **Only gold and cash.** Silver and copper are not taken, a gold coin is not
|
||||
broken to make up an amount, and no change is given.
|
||||
- What is handed over has to be a whole multiple of that side of the rate: at
|
||||
2 gold = $15, gold goes in twos and cash in fifteens.
|
||||
- Both sides go on the character's money log, naming the NPC: "Changed with
|
||||
Vex for $45" and "Changed with Vex from 6 gold".
|
||||
- An NPC never runs out of gold or cash to change.
|
||||
- To set a rate, type both numbers under "Trade" and press "Save rate".
|
||||
"Stop changing money" clears it. An NPC with no rate does not change money.
|
||||
|
||||
### Selling
|
||||
|
||||
An NPC's **wares** are weapons and items from the campaign's own pools, each
|
||||
put up for sale with:
|
||||
|
||||
| Field | What it is |
|
||||
|---|---|
|
||||
| What | One weapon or item from the pool |
|
||||
| Level | The gear level it is handed over at, not below its starting level |
|
||||
| Price | For one, in gold, silver, copper or cash |
|
||||
| Stock | How many can be bought. Empty means no limit |
|
||||
|
||||
- Wares belong to the NPC, not to an encounter: they are on offer in every
|
||||
quest that NPC is in.
|
||||
- Buying pays the price from the character's purse, adds the weapon or item
|
||||
to what they hold (stacking with any they already have) and lowers the
|
||||
stock. A price in coins is paid with change, exactly like any other spend;
|
||||
a price in cash takes cash only.
|
||||
- At 0 stock a ware shows as sold out and cannot be bought. It stays listed
|
||||
until the DM removes it.
|
||||
- The ✕ beside a ware stops it being sold. Things already bought stay bought.
|
||||
- "Edit" beside a ware changes its price, the coin (or cash) the price is in,
|
||||
and its stock: restock a sold-out ware, or clear the stock for no limit.
|
||||
What is sold, its level and who sells it cannot be changed; remove the ware
|
||||
and put it up again for that.
|
||||
- Removing a weapon or item from the campaign pool removes it from sale
|
||||
everywhere.
|
||||
|
||||
A stall or a machine with nobody behind it can sell things too: those wares
|
||||
belong to an encounter rather than an NPC, and are set up on the encounter
|
||||
page. See [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
|
||||
|
||||
## 6. What players see
|
||||
|
||||
Players only ever see an NPC through an encounter it is in:
|
||||
|
||||
@@ -81,6 +156,7 @@ Players only ever see an NPC through an encounter it is in:
|
||||
|---|---|
|
||||
| Name, role, attitude, description | DM notes |
|
||||
| Health bar and conditions, if the NPC has HP | AC, attack bonus, damage |
|
||||
| The exchange rate, and everything the NPC has for sale with its price and stock | The NPC list, and wares of NPCs who are not in the scene |
|
||||
|
||||
This is enforced by the server, not just hidden on screen: the hidden fields
|
||||
are never sent to a player's browser.
|
||||
@@ -88,7 +164,7 @@ are never sent to a player's browser.
|
||||
Changing an NPC's attitude changes what players see straight away, so
|
||||
flipping a "Friendly" NPC to "Hostile" is itself a reveal.
|
||||
|
||||
## 6. NPC or monster?
|
||||
## 7. NPC or monster?
|
||||
|
||||
| | NPC | Monster |
|
||||
|---|---|---|
|
||||
@@ -99,11 +175,12 @@ flipping a "Friendly" NPC to "Hostile" is itself a reveal.
|
||||
| Combat stats | Optional, basic | Full stat block |
|
||||
| Can be in an encounter more than once | No | Yes (a pack of goblins) |
|
||||
| Built-in library | No | Yes |
|
||||
| Can change money and sell things | Yes | No |
|
||||
|
||||
A named villain the party will both talk to and fight works well as an NPC
|
||||
with HP, AC, attack and damage filled in.
|
||||
|
||||
## 7. Permissions
|
||||
## 8. Permissions
|
||||
|
||||
| Action | DM | Players |
|
||||
|---|---|---|
|
||||
@@ -111,12 +188,18 @@ with HP, AC, attack and damage filled in.
|
||||
| Add or remove NPCs | Yes | No |
|
||||
| Add an NPC to an encounter | Yes | No |
|
||||
| Change an NPC's HP or conditions in an encounter | Yes | No |
|
||||
| See an NPC in an encounter | Everything | Name, role, attitude, description, health bar |
|
||||
| See an NPC in an encounter | Everything | Name, role, attitude, description, health bar, exchange rate, wares |
|
||||
| Set an NPC's exchange rate | Yes | No |
|
||||
| Put things up for sale, or take them down | Yes | No |
|
||||
| Change money with an NPC, or buy from one | Yes, for any character in the scene | For their own character in the scene |
|
||||
|
||||
## 8. What is not modelled
|
||||
## 9. What is not modelled
|
||||
|
||||
- Ability scores, saves, skills and spells for NPCs
|
||||
- NPC inventory, gold and shops
|
||||
- An NPC's own purse: they never run short of gold or cash
|
||||
- Selling things to an NPC, haggling, and different rates for buying and
|
||||
selling cash
|
||||
- Changing silver or copper for cash
|
||||
- Where an NPC is (they are not tied to locations)
|
||||
- Relationships, reputation and attitude toward individual characters
|
||||
- A record of which NPCs the party has met, outside of encounters
|
||||
|
||||
+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.8.0",
|
||||
"version": "1.10.1",
|
||||
"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] }),
|
||||
@@ -537,6 +540,20 @@ export function useCreateMonster(campaignId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Changes a monster's stat block. Encounters read it live, so copies already
|
||||
// in a scene show the new numbers (their HP stays as it was).
|
||||
export function useUpdateMonster(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, ...data }: { id: string } & Partial<Omit<Monster, 'id' | 'campaignId'>>) =>
|
||||
(await apiClient.patch<{ monster: Monster }>(`/campaigns/${campaignId}/monsters/${id}`, data)).data.monster,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'monsters'] });
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMonster(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -843,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
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -108,7 +108,9 @@ export interface CampaignMemberSummary {
|
||||
}
|
||||
|
||||
// The coins a character carries. 1 gold = 10 silver = 100 copper.
|
||||
export type Currency = 'GOLD' | 'SILVER' | 'COPPER';
|
||||
export type Coin = 'GOLD' | 'SILVER' | 'COPPER';
|
||||
// Coins, plus cash ($): modern money for crossover worlds, with no worth in coins.
|
||||
export type Currency = Coin | 'CASH';
|
||||
|
||||
// Where a character stands at 0 HP. UP: on their feet. DYING: rolling death
|
||||
// saves, three of either kind settles it. STABLE: at 0 HP but no longer
|
||||
@@ -135,10 +137,11 @@ export interface CharacterSummary {
|
||||
currentHp: number;
|
||||
tempHp: number;
|
||||
deathSaves: DeathSaves;
|
||||
// The purse; all three are null for another player's character.
|
||||
// The purse; all four are null for another player's character.
|
||||
gold: number | null;
|
||||
silver: number | null;
|
||||
copper: number | null;
|
||||
cash: number | null;
|
||||
maxHp: number | null;
|
||||
armorClass: number | null;
|
||||
currentLocationId: string | null;
|
||||
@@ -303,6 +306,7 @@ export interface CharacterDetail {
|
||||
gold: number | null;
|
||||
silver: number | null;
|
||||
copper: number | null;
|
||||
cash: number | null;
|
||||
// True when this is another player's character: no gold, inventory or
|
||||
// location is sent, only what the party can see across the table.
|
||||
restricted: boolean;
|
||||
@@ -340,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;
|
||||
@@ -347,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.
|
||||
@@ -470,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,36 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// A row of links to the sections of a long page. It finds them itself: any
|
||||
// element with a `data-jump="Label"` attribute that is on the page right now
|
||||
// gets a link, so sections only some people see need no special handling.
|
||||
export function JumpNav() {
|
||||
const [labels, setLabels] = useState<string[]>([]);
|
||||
|
||||
// Sections come and go as data loads, so the page is watched for them.
|
||||
useEffect(() => {
|
||||
const look = () => {
|
||||
const found = [...document.querySelectorAll<HTMLElement>('[data-jump]')].map((el) => el.dataset.jump ?? '');
|
||||
setLabels((current) => (current.join('|') === found.join('|') ? current : found));
|
||||
};
|
||||
const observer = new MutationObserver(look);
|
||||
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-jump'] });
|
||||
look();
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
if (labels.length < 3) return null;
|
||||
return (
|
||||
<nav className="jump-nav" aria-label="Sections of this page">
|
||||
{labels.map((label) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className="jump-link"
|
||||
onClick={() => document.querySelector(`[data-jump="${label}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,39 @@
|
||||
import { Link, Outlet } from 'react-router-dom';
|
||||
import { Link, NavLink, Outlet, useMatch } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
|
||||
import { DiceRoller } from './DiceRoller';
|
||||
|
||||
export function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
// The campaign pages say which campaign they are in their address; sheets
|
||||
// and encounters report theirs themselves (useRememberCampaign).
|
||||
const onCampaign = useMatch('/campaigns/:campaignId/*');
|
||||
useRememberCampaign(onCampaign?.params.campaignId);
|
||||
const campaignId = useLastCampaignId();
|
||||
|
||||
// The same destinations twice: along the top bar on a wide screen, and as a
|
||||
// bar along the bottom, in reach of a thumb, on a phone.
|
||||
const destinations = [
|
||||
{ to: '/campaigns', end: true, icon: '🗺', label: 'Campaigns' },
|
||||
...(campaignId
|
||||
? [
|
||||
{ to: `/campaigns/${campaignId}`, end: true, icon: '🏰', label: 'Campaign' },
|
||||
{ to: `/campaigns/${campaignId}/party`, end: true, icon: '👥', label: 'Party' },
|
||||
]
|
||||
: []),
|
||||
{ to: '/account', end: true, icon: '👤', label: 'Account' },
|
||||
];
|
||||
const links = (className: string) =>
|
||||
destinations.map((d) => (
|
||||
<NavLink key={d.to} to={d.to} end={d.end} className={({ isActive }) => `${className}${isActive ? ' is-active' : ''}`}>
|
||||
<span className="nav-icon" aria-hidden>
|
||||
{d.icon}
|
||||
</span>
|
||||
<span>{d.label}</span>
|
||||
</NavLink>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="topbar">
|
||||
@@ -14,22 +43,26 @@ export function Layout() {
|
||||
</span>
|
||||
The Sunken Reach
|
||||
</Link>
|
||||
<nav className="topbar-nav">
|
||||
{user && (
|
||||
{user && (
|
||||
<nav className="topbar-nav" aria-label="Main">
|
||||
<div className="topbar-links">{links('topbar-link')}</div>
|
||||
<div className="topbar-user">
|
||||
<Link to="/account" title="Account settings">
|
||||
{user.displayName}
|
||||
</Link>
|
||||
<span className="topbar-name">{user.displayName}</span>
|
||||
<button className="btn btn-ghost" onClick={logout}>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
<main className="page">
|
||||
<Outlet />
|
||||
</main>
|
||||
{user && (
|
||||
<nav className="bottom-nav" aria-label="Main">
|
||||
{links('bottom-nav-link')}
|
||||
</nav>
|
||||
)}
|
||||
<DiceRoller />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+419
-4
@@ -1,4 +1,6 @@
|
||||
:root {
|
||||
/* Dark throughout, so the browser draws its own controls dark too. */
|
||||
color-scheme: dark;
|
||||
--bg: #131009;
|
||||
--bg-elevated: #1b160e;
|
||||
--bg-card: #221b12;
|
||||
@@ -347,10 +349,8 @@ textarea {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='email'],
|
||||
input[type='password'],
|
||||
input[type='number'],
|
||||
/* Every text-like field, including an <input> with no type given. */
|
||||
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='file']),
|
||||
select,
|
||||
textarea {
|
||||
background: var(--bg-inset);
|
||||
@@ -1290,3 +1290,418 @@ button.death-pip {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Navigation: links along the top bar on a wide screen, a bar along the */
|
||||
/* bottom on a phone; and a row of links to the sections of a long page */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
.topbar-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.topbar-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.topbar-link:hover {
|
||||
text-decoration: none;
|
||||
color: var(--accent-strong);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.topbar-link.is-active {
|
||||
color: var(--accent-strong);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.topbar-name {
|
||||
max-width: 14ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bottom-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.jump-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.jump-link {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-inset);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.jump-link:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
/* A section jumped to stops below the sticky top bar, not under it. */
|
||||
[data-jump] {
|
||||
scroll-margin-top: 4.5rem;
|
||||
}
|
||||
|
||||
/* A table cell that holds a sentence rather than a figure may wrap. */
|
||||
table.data-table td.cell-title,
|
||||
table.data-table td.cell-wide {
|
||||
white-space: normal;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Small screens. Nothing may be wider than the screen: rows of controls */
|
||||
/* wrap, fields shrink to fit, tables become stacked cards, and the main */
|
||||
/* destinations move to a bar along the bottom. */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
body,
|
||||
#root,
|
||||
.app-shell {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
/* A field never forces its row wider than the space there is. */
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
:root {
|
||||
--bottom-nav-height: calc(3.6rem + env(safe-area-inset-bottom, 0px));
|
||||
--dice-slot: 4.2rem;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 1rem 0.85rem calc(var(--bottom-nav-height) + 1.5rem);
|
||||
}
|
||||
.topbar {
|
||||
padding: 0.5rem 0.85rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.topbar-brand {
|
||||
font-size: 1rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.topbar-links,
|
||||
.topbar-name {
|
||||
display: none;
|
||||
}
|
||||
.topbar-nav {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 70;
|
||||
display: flex;
|
||||
height: var(--bottom-nav-height);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
background: linear-gradient(180deg, #1e1810 0%, #120e08 100%);
|
||||
border-top: 1px solid var(--border);
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.bottom-nav-link {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.1rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-top: 2px solid transparent;
|
||||
}
|
||||
.bottom-nav-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
.bottom-nav-link .nav-icon {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.bottom-nav-link.is-active {
|
||||
color: var(--accent-strong);
|
||||
border-top-color: var(--accent-strong);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* Nothing floats over the page on a phone. The dice roller takes the last
|
||||
place in the bottom bar, and quick stats a place in the top bar. */
|
||||
.bottom-nav {
|
||||
padding-right: var(--dice-slot);
|
||||
}
|
||||
.fab-dice {
|
||||
left: auto;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 71;
|
||||
width: var(--dice-slot);
|
||||
height: var(--bottom-nav-height);
|
||||
padding: 0 0 env(safe-area-inset-bottom, 0px);
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
border: none;
|
||||
border-top: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.2;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.fab-dice::after {
|
||||
content: 'Dice';
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.fab-dice:hover {
|
||||
transform: none;
|
||||
}
|
||||
.dice-panel {
|
||||
left: 0.85rem;
|
||||
bottom: calc(var(--bottom-nav-height) + 0.6rem);
|
||||
width: calc(100vw - 1.7rem);
|
||||
max-height: calc(100dvh - var(--bottom-nav-height) - 5rem);
|
||||
}
|
||||
.fab-quickstats {
|
||||
top: 0.3rem;
|
||||
bottom: auto;
|
||||
right: 6.4rem;
|
||||
z-index: 31;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 1.15rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
/* The brand makes room for it. */
|
||||
.app-shell:has(.fab-quickstats) .topbar-brand {
|
||||
max-width: calc(100vw - 11rem);
|
||||
}
|
||||
.drawer {
|
||||
width: 100vw;
|
||||
padding: 1rem 0.85rem calc(var(--bottom-nav-height) + 1rem);
|
||||
z-index: 95;
|
||||
}
|
||||
.drawer-backdrop {
|
||||
z-index: 94;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.card {
|
||||
padding: 0.9rem 0.85rem;
|
||||
}
|
||||
.card-title {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.page-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Rows of controls wrap instead of running off the side, and what is in
|
||||
them may shrink: an inline min-width never beats the screen. */
|
||||
.flex-row,
|
||||
.flex-between,
|
||||
.detail-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.flex-row > *,
|
||||
.flex-between > * {
|
||||
max-width: 100%;
|
||||
}
|
||||
.flex-row > input,
|
||||
.flex-row > select,
|
||||
.flex-row > textarea,
|
||||
.flex-row > a,
|
||||
.flex-between > a {
|
||||
min-width: min(100%, 8rem) !important;
|
||||
}
|
||||
.btn {
|
||||
white-space: normal;
|
||||
padding: 0.6rem 0.9rem;
|
||||
}
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.grid-cols-3 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 130px), 1fr));
|
||||
}
|
||||
.choice-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.btn-roll {
|
||||
font-size: 1.1rem;
|
||||
padding: 1.1rem 1.25rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
.step-footer > * {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* Things to tap are big enough to hit with a thumb. */
|
||||
input[type='checkbox'],
|
||||
input[type='radio'] {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex: none;
|
||||
}
|
||||
.death-pip {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
.btn-target {
|
||||
padding: 0.4rem 0.75rem;
|
||||
}
|
||||
.condition-chip {
|
||||
padding: 0.3rem 0.6rem;
|
||||
}
|
||||
.condition-chip button {
|
||||
padding: 0.15rem 0.35rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.info-tip-trigger {
|
||||
font-size: 1.05em;
|
||||
padding: 0.2rem;
|
||||
}
|
||||
.pick-chip {
|
||||
padding: 0.5rem 0.9rem;
|
||||
}
|
||||
.jump-link {
|
||||
padding: 0.5rem 0.8rem;
|
||||
}
|
||||
.attack-bar {
|
||||
top: 3.4rem;
|
||||
max-height: 55dvh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
[data-jump] {
|
||||
scroll-margin-top: 3.8rem;
|
||||
}
|
||||
|
||||
/* Tables: every row becomes a card. The first cell is its heading; the
|
||||
rest carry their column's name from data-label. */
|
||||
.table-wrap {
|
||||
overflow: visible;
|
||||
border: none;
|
||||
}
|
||||
table.data-table,
|
||||
table.data-table tbody {
|
||||
display: block;
|
||||
}
|
||||
table.data-table thead {
|
||||
display: none;
|
||||
}
|
||||
table.data-table tbody tr {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem 0.9rem;
|
||||
padding: 0.7rem 0.75rem;
|
||||
margin-bottom: 0.6rem;
|
||||
background: var(--bg-inset);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
table.data-table tbody tr:last-child {
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
table.data-table tbody tr:hover {
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
table.data-table td {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0;
|
||||
white-space: normal;
|
||||
min-width: 0;
|
||||
}
|
||||
table.data-table td:empty {
|
||||
display: none;
|
||||
}
|
||||
table.data-table td.cell-title,
|
||||
table.data-table td.cell-wide {
|
||||
display: block;
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
table.data-table td.cell-title {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
table.data-table td[data-label]::before {
|
||||
content: attr(data-label);
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
flex: none;
|
||||
}
|
||||
table.data-table td.cell-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* The narrowest phones: five places in the bottom bar still fit. */
|
||||
@media (max-width: 380px) {
|
||||
:root {
|
||||
--dice-slot: 3.4rem;
|
||||
}
|
||||
.bottom-nav-link,
|
||||
.fab-dice::after {
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Between a phone and a full desktop the top bar drops the name to stay on one line. */
|
||||
@media (max-width: 1080px) {
|
||||
.topbar-name {
|
||||
display: none;
|
||||
}
|
||||
.topbar-nav {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
+20
-11
@@ -1,4 +1,4 @@
|
||||
import type { Currency } from '../api/types';
|
||||
import type { Coin, Currency } from '../api/types';
|
||||
|
||||
export const SPECIES_LABELS: Record<string, string> = {
|
||||
HUMAN: 'Human',
|
||||
@@ -73,25 +73,34 @@ export function hpBarClass(current: number, max: number): string {
|
||||
}
|
||||
|
||||
// Coins. Silver is worth 0.1 gold and copper 0.01 gold.
|
||||
export const CURRENCIES: Currency[] = ['GOLD', 'SILVER', 'COPPER'];
|
||||
export const COIN_LABELS: Record<Currency, string> = { GOLD: 'Gold', SILVER: 'Silver', COPPER: 'Copper' };
|
||||
export const COIN_SHORT: Record<Currency, string> = { GOLD: 'gp', SILVER: 'sp', COPPER: 'cp' };
|
||||
export const COIN_FIELD: Record<Currency, 'gold' | 'silver' | 'copper'> = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper' };
|
||||
export const COINS: Coin[] = ['GOLD', 'SILVER', 'COPPER'];
|
||||
// Everything a purse can hold: the coins, plus cash ($) for crossover worlds.
|
||||
// Cash has no worth in coins, so it is never exchanged or used to make change.
|
||||
export const CURRENCIES: Currency[] = [...COINS, 'CASH'];
|
||||
export const COIN_LABELS: Record<Currency, string> = { GOLD: 'Gold', SILVER: 'Silver', COPPER: 'Copper', CASH: 'Cash ($)' };
|
||||
export const COIN_FIELD: Record<Currency, 'gold' | 'silver' | 'copper' | 'cash'> = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper', CASH: 'cash' };
|
||||
// What one coin is worth, counted in copper.
|
||||
export const COIN_VALUE: Record<Currency, number> = { GOLD: 100, SILVER: 10, COPPER: 1 };
|
||||
export const COIN_VALUE: Record<Coin, number> = { GOLD: 100, SILVER: 10, COPPER: 1 };
|
||||
const COIN_SHORT: Record<Coin, string> = { GOLD: 'gp', SILVER: 'sp', COPPER: 'cp' };
|
||||
|
||||
type Purse = { gold: number | null; silver: number | null; copper: number | null };
|
||||
// "12 gp", "5 sp", "$40". With `long`, coins are spelled out: "12 gold".
|
||||
export function formatMoney(amount: number, currency: Currency, long = false): string {
|
||||
if (currency === 'CASH') return `$${amount}`;
|
||||
return `${amount} ${long ? COIN_LABELS[currency].toLowerCase() : COIN_SHORT[currency]}`;
|
||||
}
|
||||
|
||||
// "12 gp · 5 sp · 3 cp". Gold always shows; silver and copper only when held.
|
||||
type Purse = { gold: number | null; silver: number | null; copper: number | null; cash: number | null };
|
||||
|
||||
// "12 gp · 5 sp · 3 cp · $40". Gold always shows; the rest only when held.
|
||||
export function formatPurse(purse: Purse): string {
|
||||
return CURRENCIES.filter((c) => c === 'GOLD' || (purse[COIN_FIELD[c]] ?? 0) > 0)
|
||||
.map((c) => `${purse[COIN_FIELD[c]] ?? 0} ${COIN_SHORT[c]}`)
|
||||
.map((c) => formatMoney(purse[COIN_FIELD[c]] ?? 0, c))
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
// The whole purse as one figure in gold, e.g. 12.65.
|
||||
// The purse's coins as one figure in gold, e.g. 12.65. Cash is not counted.
|
||||
export function purseWorthInGold(purse: Purse): number {
|
||||
const copper = CURRENCIES.reduce((sum, c) => sum + (purse[COIN_FIELD[c]] ?? 0) * COIN_VALUE[c], 0);
|
||||
const copper = COINS.reduce((sum, c) => sum + (purse[COIN_FIELD[c]] ?? 0) * COIN_VALUE[c], 0);
|
||||
return copper / COIN_VALUE.GOLD;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useSyncExternalStore } from 'react';
|
||||
|
||||
// The campaign the user was last in, so the navigation can always offer a way
|
||||
// back to it — from a character sheet or an encounter, whose addresses don't
|
||||
// say which campaign they belong to. Kept per browser.
|
||||
const KEY = 'dnd_last_campaign';
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function read(): string | null {
|
||||
return localStorage.getItem(KEY);
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function forgetCampaign() {
|
||||
localStorage.removeItem(KEY);
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export function useLastCampaignId(): string | null {
|
||||
return useSyncExternalStore(subscribe, read);
|
||||
}
|
||||
|
||||
// Call from any page that knows which campaign it is showing.
|
||||
export function useRememberCampaign(campaignId: string | null | undefined) {
|
||||
useEffect(() => {
|
||||
if (!campaignId || read() === campaignId) return;
|
||||
localStorage.setItem(KEY, campaignId);
|
||||
listeners.forEach((listener) => listener());
|
||||
}, [campaignId]);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { scaledDamageDice } from './previewCalc';
|
||||
import type { SaleOffer } from '../api/types';
|
||||
|
||||
// "🗡 Longsword · Lv 2 · 1d10": what a ware is, for anyone looking at it.
|
||||
export function wareName(ware: SaleOffer): string {
|
||||
const entry = ware.weapon ?? ware.item;
|
||||
if (!entry) return 'Removed from the pool';
|
||||
const dice = entry.damageDice ? ` · ${scaledDamageDice(entry.damageDice, ware.level, entry.damageScaling, entry.minLevel)}` : '';
|
||||
return `${ware.weapon ? '🗡' : '🎒'} ${entry.name} · Lv ${ware.level}${dice}`;
|
||||
}
|
||||
|
||||
export function stockLabel(ware: SaleOffer): string {
|
||||
return ware.stock === null ? 'always in stock' : ware.stock === 0 ? 'sold out' : `${ware.stock} left`;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useState, type CSSProperties, type FormEvent, type ReactNode } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
useCampaign,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useDeleteEncounter,
|
||||
useCampaignMonsters,
|
||||
useCreateMonster,
|
||||
useUpdateMonster,
|
||||
useDeleteMonster,
|
||||
useCampaignWeapons,
|
||||
useCreateWeapon,
|
||||
@@ -41,12 +42,14 @@ import { useAuth } from '../auth/AuthContext';
|
||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { JumpNav } from '../components/JumpNav';
|
||||
import { LifeBadge } from '../components/DeathSaves';
|
||||
import { usePagination, Pagination } from '../components/Paginated';
|
||||
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS, extractErrorMessage, spellLevelLabel, spellRequirementLabel } from '../lib/format';
|
||||
import { WaresEditor } from '../components/Wares';
|
||||
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS, extractErrorMessage, fmtMod, formatMoney, spellLevelLabel, spellRequirementLabel } from '../lib/format';
|
||||
import { speciesIcon } from '../lib/speciesIcons';
|
||||
import { DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
import type { CharacterSummary, Encounter, EncounterMode, LibraryMonster, LibrarySpell, LibraryWeapon, NpcDisposition } from '../api/types';
|
||||
import { ABILITY_ORDER, DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
import type { CharacterSummary, Encounter, EncounterMode, Item, LibraryMonster, LibrarySpell, LibraryWeapon, Monster, Npc, NpcDisposition, Weapon } from '../api/types';
|
||||
|
||||
// How the encounter list can be ordered. "custom" is the campaign's own order,
|
||||
// which the DM arranges; the rest only change what this browser shows.
|
||||
@@ -132,7 +135,7 @@ function PoolRowControls({
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<td>
|
||||
<td data-label="Starting option">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${entry.name} is a starting option`}
|
||||
@@ -140,7 +143,7 @@ function PoolRowControls({
|
||||
onChange={(e) => onToggleStarting(e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<td className="cell-actions">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
aria-label={`Remove ${entry.name}`}
|
||||
@@ -236,6 +239,214 @@ function SpellDamageSelect({
|
||||
);
|
||||
}
|
||||
|
||||
type MonsterInput = Omit<Monster, 'id' | 'campaignId'>;
|
||||
type AbilityScores = Pick<Monster, 'strScore' | 'dexScore' | 'conScore' | 'intScore' | 'wisScore' | 'chaScore'>;
|
||||
const scoreField = (ability: string) => `${ability.toLowerCase()}Score` as keyof AbilityScores;
|
||||
|
||||
// "STR 8 · DEX 14 · …", in stat-block order.
|
||||
function abilityLine(scores: AbilityScores): string {
|
||||
return ABILITY_ORDER.map((a) => `${a} ${scores[scoreField(a)]}`).join(' · ');
|
||||
}
|
||||
|
||||
// The whole stat block, field for field as a printed one gives it. Adds a
|
||||
// monster, or edits one when `monster` is passed (its values fill the form).
|
||||
function MonsterForm({
|
||||
monster,
|
||||
saving,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
monster?: Monster;
|
||||
saving: boolean;
|
||||
// Rejects if the save failed, so the form keeps what was typed.
|
||||
onSave: (data: MonsterInput) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const field = (name: string, label: string, input: ReactNode, style?: CSSProperties) => (
|
||||
<div className="field" style={{ marginBottom: 0, ...style }}>
|
||||
<label htmlFor={`monster-${name}`}>{label}</label>
|
||||
{input}
|
||||
</div>
|
||||
);
|
||||
const number = (name: keyof MonsterInput, label: string, min: number, max: number, fallback?: number) =>
|
||||
field(
|
||||
name,
|
||||
label,
|
||||
<NumberInput
|
||||
id={`monster-${name}`}
|
||||
name={name}
|
||||
min={min}
|
||||
max={max}
|
||||
defaultValue={(monster?.[name] as number | undefined) ?? fallback}
|
||||
keepValue={fallback !== undefined}
|
||||
required
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target as HTMLFormElement;
|
||||
const fd = new FormData(form);
|
||||
const text = (key: string) => String(fd.get(key) || '').trim() || null;
|
||||
const num = (key: string) => Number(fd.get(key));
|
||||
try {
|
||||
await onSave({
|
||||
name: String(fd.get('name')).trim(),
|
||||
challengeRating: text('challengeRating'),
|
||||
ac: num('ac'),
|
||||
maxHp: num('maxHp'),
|
||||
speed: num('speed'),
|
||||
attackBonus: num('attackBonus'),
|
||||
damageDice: text('damageDice'),
|
||||
strScore: num('strScore'),
|
||||
dexScore: num('dexScore'),
|
||||
conScore: num('conScore'),
|
||||
intScore: num('intScore'),
|
||||
wisScore: num('wisScore'),
|
||||
chaScore: num('chaScore'),
|
||||
specialTrait: text('specialTrait'),
|
||||
notes: text('notes'),
|
||||
});
|
||||
if (!monster) form.reset();
|
||||
} catch {
|
||||
// The page shows the error; what was typed stays.
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-3">
|
||||
{field('name', 'Name', <input id="monster-name" name="name" defaultValue={monster?.name} required maxLength={80} />)}
|
||||
{field(
|
||||
'challengeRating',
|
||||
'CR (Challenge Rating)',
|
||||
<input id="monster-challengeRating" name="challengeRating" defaultValue={monster?.challengeRating ?? ''} placeholder="e.g. 1/4 or 3" maxLength={10} />
|
||||
)}
|
||||
{number('ac', 'AC (Armor Class)', 0, 50)}
|
||||
{number('maxHp', 'HP (Hit Points)', 1, 100000)}
|
||||
{number('speed', 'Speed (feet)', 0, 1000, 30)}
|
||||
{number('attackBonus', 'Attack bonus', -20, 50, 0)}
|
||||
{field(
|
||||
'damageDice',
|
||||
'Damage',
|
||||
<input id="monster-damageDice" name="damageDice" defaultValue={monster?.damageDice ?? ''} placeholder="e.g. 1d6+2 necrotic" maxLength={60} />
|
||||
)}
|
||||
</div>
|
||||
<div className="grid mt-sm" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))' }}>
|
||||
{ABILITY_ORDER.map((a) => number(scoreField(a), a, 1, 30, 10))}
|
||||
</div>
|
||||
{field(
|
||||
'specialTrait',
|
||||
'Special trait',
|
||||
<input
|
||||
id="monster-specialTrait"
|
||||
name="specialTrait"
|
||||
defaultValue={monster?.specialTrait ?? ''}
|
||||
placeholder="The one thing that makes it different"
|
||||
maxLength={500}
|
||||
/>,
|
||||
{ marginTop: '0.5rem' }
|
||||
)}
|
||||
{field(
|
||||
'notes',
|
||||
'Notes',
|
||||
<textarea
|
||||
id="monster-notes"
|
||||
name="notes"
|
||||
rows={3}
|
||||
className="textarea-block"
|
||||
defaultValue={monster?.notes ?? ''}
|
||||
placeholder="Everything else: other speeds (fly 30, swim 40), extra attacks, spells, tactics, resistances, senses, what it drops"
|
||||
maxLength={2000}
|
||||
/>,
|
||||
{ marginTop: '0.5rem' }
|
||||
)}
|
||||
<div className="flex-row mt-sm">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{monster ? 'Save Changes' : 'Add Monster'}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button type="button" className="btn btn-ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// "1 gp = $5": the rate an NPC changes gold and cash at, or null if they don't.
|
||||
function rateLabel(npc: Pick<Npc, 'exchangeGold' | 'exchangeCash'>): string | null {
|
||||
if (!npc.exchangeGold || !npc.exchangeCash) return null;
|
||||
return `${formatMoney(npc.exchangeGold, 'GOLD')} = ${formatMoney(npc.exchangeCash, 'CASH')}`;
|
||||
}
|
||||
|
||||
// What an NPC trades: the rate they change gold and cash at, and their wares.
|
||||
// Players meet both on the encounter page, in a quest the NPC is part of.
|
||||
function NpcTrade({
|
||||
campaignId,
|
||||
npc,
|
||||
weapons,
|
||||
items,
|
||||
onError,
|
||||
}: {
|
||||
campaignId: string;
|
||||
npc: Npc;
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const patchNpc = usePatchPoolEntry(campaignId, 'npcs');
|
||||
const [gold, setGold] = useState(npc.exchangeGold ? String(npc.exchangeGold) : '');
|
||||
const [cash, setCash] = useState(npc.exchangeCash ? String(npc.exchangeCash) : '');
|
||||
const changesMoney = Boolean(rateLabel(npc));
|
||||
|
||||
return (
|
||||
<div className="card mt-sm" style={{ background: 'var(--bg-inset)' }}>
|
||||
<div className="section-label">Changes money</div>
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
Set a rate and the party can change gold into cash, or cash into gold, with {npc.name}. Silver and copper are not
|
||||
taken. Leave it empty for someone who does not change money.
|
||||
</p>
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
<NumberInput min={1} max={1000000} aria-label="Gold side of the rate" placeholder="Gold" value={gold} onChange={(e) => setGold(e.target.value)} style={{ width: 90 }} />
|
||||
<span>gold =</span>
|
||||
<span>$</span>
|
||||
<NumberInput min={1} max={1000000} aria-label="Cash side of the rate" placeholder="Cash" value={cash} onChange={(e) => setCash(e.target.value)} style={{ width: 90 }} />
|
||||
<button
|
||||
className="btn"
|
||||
disabled={!gold || !cash || patchNpc.isPending}
|
||||
onClick={() => patchNpc.mutate({ id: npc.id, exchangeGold: Number(gold), exchangeCash: Number(cash) }, { onError })}
|
||||
>
|
||||
Save rate
|
||||
</button>
|
||||
{changesMoney && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
disabled={patchNpc.isPending}
|
||||
onClick={() =>
|
||||
patchNpc.mutate(
|
||||
{ id: npc.id, exchangeGold: null, exchangeCash: null },
|
||||
{
|
||||
onError,
|
||||
onSuccess: () => {
|
||||
setGold('');
|
||||
setCash('');
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
Stop changing money
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="section-label mt-md">For sale</div>
|
||||
<WaresEditor campaignId={campaignId} seller={{ npcId: npc.id }} wares={npc.wares ?? []} weapons={weapons} items={items} onError={onError} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryPicker<T extends { key: string; name: string }>({
|
||||
items,
|
||||
onAdd,
|
||||
@@ -304,6 +515,7 @@ export function CampaignHomePage() {
|
||||
const setEncounterArchived = useSetEncounterArchived(campaignId);
|
||||
const deleteEncounter = useDeleteEncounter(campaignId);
|
||||
const createMonster = useCreateMonster(campaignId);
|
||||
const updateMonster = useUpdateMonster(campaignId);
|
||||
const deleteMonster = useDeleteMonster(campaignId);
|
||||
const createWeapon = useCreateWeapon(campaignId);
|
||||
const createSpell = useCreateSpell(campaignId);
|
||||
@@ -333,6 +545,7 @@ export function CampaignHomePage() {
|
||||
const [encounterSort, setEncounterSort] = useState<EncounterSort>(storedEncounterSort);
|
||||
const [showArchivedEncounters, setShowArchivedEncounters] = useState(false);
|
||||
const [showNpcForm, setShowNpcForm] = useState(false);
|
||||
const [tradeNpcId, setTradeNpcId] = useState<string | null>(null);
|
||||
const [noteBody, setNoteBody] = useState('');
|
||||
const [dmNoteBody, setDmNoteBody] = useState('');
|
||||
const [newLocationName, setNewLocationName] = useState('');
|
||||
@@ -341,6 +554,7 @@ export function CampaignHomePage() {
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [deleteConfirmName, setDeleteConfirmName] = useState('');
|
||||
const [showMonsterForm, setShowMonsterForm] = useState(false);
|
||||
const [editingMonsterId, setEditingMonsterId] = useState<string | null>(null);
|
||||
const [showWeaponForm, setShowWeaponForm] = useState(false);
|
||||
const [showSpellForm, setShowSpellForm] = useState(false);
|
||||
const [showItemForm, setShowItemForm] = useState(false);
|
||||
@@ -353,9 +567,12 @@ export function CampaignHomePage() {
|
||||
const itemsPage = usePagination(items, 8);
|
||||
const npcsPage = usePagination(npcs, 8);
|
||||
const monstersPage = usePagination(monsters, 8);
|
||||
const editingMonster = monsters?.find((m) => m.id === editingMonsterId);
|
||||
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
|
||||
const archivedEncounters = encounters?.filter((e) => e.archivedAt) || [];
|
||||
const shownEncounters = sortEncounters(liveEncounters, encounterSort);
|
||||
const encountersPage = usePagination(shownEncounters, 10);
|
||||
const archivedEncountersPage = usePagination(archivedEncounters, 10);
|
||||
const sharedNotes = notes?.filter((n) => n.visibility === 'SHARED');
|
||||
const dmNotes = notes?.filter((n) => n.visibility === 'DM_ONLY');
|
||||
const sharedNotesPage = usePagination(sharedNotes, 6);
|
||||
@@ -462,8 +679,10 @@ export function CampaignHomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JumpNav />
|
||||
|
||||
{isDM && (
|
||||
<div className="card">
|
||||
<div data-jump="Settings" className="card">
|
||||
<div className="card-title">
|
||||
<span>Campaign Settings</span>
|
||||
<button className="btn btn-ghost" onClick={() => setShowSettings((s) => !s)}>
|
||||
@@ -596,7 +815,7 @@ export function CampaignHomePage() {
|
||||
{formError && <div className="mt-md"><ErrorBanner message={formError} /></div>}
|
||||
|
||||
{!isDM && myCharacters.length > 0 && (
|
||||
<div className="mt-lg">
|
||||
<div data-jump="My characters" className="mt-lg">
|
||||
<h2 style={{ fontSize: '1.1rem' }}>Your Character{myCharacters.length > 1 ? 's' : ''}</h2>
|
||||
{myCharacters.map((c) => (
|
||||
<CharacterRow key={c.id} character={c} isDM={false} onDelete={() => {}} />
|
||||
@@ -604,7 +823,7 @@ export function CampaignHomePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-lg">
|
||||
<div data-jump={isDM ? 'Characters' : 'Party'} className="mt-lg">
|
||||
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
|
||||
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>{isDM ? 'Characters' : 'Rest of the Party'}</h2>
|
||||
</div>
|
||||
@@ -632,7 +851,7 @@ export function CampaignHomePage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-lg">
|
||||
<div data-jump="Encounters" className="mt-lg">
|
||||
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
|
||||
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>Encounters</h2>
|
||||
{liveEncounters.length > 1 && (
|
||||
@@ -674,13 +893,17 @@ export function CampaignHomePage() {
|
||||
)}
|
||||
{isDM && encounterSort === 'custom' && liveEncounters.length > 1 && (
|
||||
<p className="subtitle" style={{ marginTop: 0 }}>
|
||||
This is the campaign's own order, which everyone sees by default. Use ▲ and ▼ to rearrange it.
|
||||
This is the campaign's own order, which everyone sees by default. Use ▲ and ▼ to rearrange it; moving past the
|
||||
top or bottom of a page carries an encounter onto the next one.
|
||||
</p>
|
||||
)}
|
||||
{encounters && liveEncounters.length === 0 && (
|
||||
<div className="empty-state">{archivedEncounters.length > 0 ? 'Every encounter is archived.' : 'No encounters yet.'}</div>
|
||||
)}
|
||||
{shownEncounters.map((enc, index) => (
|
||||
{encountersPage.pageItems.map((enc) => {
|
||||
// Its place in the whole list, not on this page: that is what moving it changes.
|
||||
const index = shownEncounters.indexOf(enc);
|
||||
return (
|
||||
<div key={enc.id} className="card flex-between flex-wrap">
|
||||
<Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}>
|
||||
<span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span>
|
||||
@@ -725,7 +948,9 @@ export function CampaignHomePage() {
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
<Pagination page={encountersPage.page} pageCount={encountersPage.pageCount} onPrev={encountersPage.prevPage} onNext={encountersPage.nextPage} />
|
||||
|
||||
{isDM && archivedEncounters.length > 0 && (
|
||||
<div className="mt-md">
|
||||
@@ -738,7 +963,7 @@ export function CampaignHomePage() {
|
||||
{showArchivedEncounters && (
|
||||
<>
|
||||
<p className="subtitle" style={{ marginTop: 0 }}>Players don't see these in their list.</p>
|
||||
{archivedEncounters.map((enc) => (
|
||||
{archivedEncountersPage.pageItems.map((enc) => (
|
||||
<div key={enc.id} className="card flex-between flex-wrap">
|
||||
<Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}>
|
||||
<span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span>
|
||||
@@ -765,6 +990,12 @@ export function CampaignHomePage() {
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<Pagination
|
||||
page={archivedEncountersPage.page}
|
||||
pageCount={archivedEncountersPage.pageCount}
|
||||
onPrev={archivedEncountersPage.prevPage}
|
||||
onNext={archivedEncountersPage.nextPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -772,7 +1003,7 @@ export function CampaignHomePage() {
|
||||
</div>
|
||||
|
||||
{!isDM && locations && locations.length > 0 && (
|
||||
<div className="mt-lg card">
|
||||
<div data-jump="Places" className="mt-lg card">
|
||||
<div className="card-title">Places You've Been</div>
|
||||
{locations.map((loc) => (
|
||||
<div key={loc.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
@@ -784,7 +1015,7 @@ export function CampaignHomePage() {
|
||||
)}
|
||||
|
||||
{isDM && (
|
||||
<div className="mt-lg card">
|
||||
<div data-jump="NPCs" className="mt-lg card">
|
||||
<div className="card-title">
|
||||
<span>NPCs</span>
|
||||
<button className="btn btn-ghost" onClick={() => setShowNpcForm((s) => !s)}>
|
||||
@@ -793,7 +1024,8 @@ export function CampaignHomePage() {
|
||||
</div>
|
||||
<p className="subtitle" style={{ marginTop: 0 }}>
|
||||
The people your party meets. Add them to an encounter to bring them into a scene. Players never see your notes or an
|
||||
NPC's stats.
|
||||
NPC's stats. "Trade" sets what an NPC sells and the rate they change gold and cash at; players see both when they
|
||||
meet the NPC in a quest.
|
||||
</p>
|
||||
{showNpcForm && (
|
||||
<form
|
||||
@@ -844,21 +1076,39 @@ export function CampaignHomePage() {
|
||||
<div className="mt-sm">
|
||||
{npcs?.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No NPCs yet.</p>}
|
||||
{npcsPage.pageItems.map((npc) => (
|
||||
<div key={npc.id} className="flex-between" style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
<div>
|
||||
<strong>{npc.name}</strong>
|
||||
{npc.role && <span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>{npc.role}</span>}
|
||||
<span className={`badge badge-${npc.disposition.toLowerCase()}`} style={{ marginLeft: 8 }}>{npc.disposition}</span>
|
||||
{npc.description && <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{npc.description}</div>}
|
||||
{npc.dmNotes && <div style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>🔒 {npc.dmNotes}</div>}
|
||||
<div key={npc.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
<div className="flex-between">
|
||||
<div>
|
||||
<strong>{npc.name}</strong>
|
||||
{npc.role && <span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>{npc.role}</span>}
|
||||
<span className={`badge badge-${npc.disposition.toLowerCase()}`} style={{ marginLeft: 8 }}>{npc.disposition}</span>
|
||||
{rateLabel(npc) && <span className="badge" style={{ marginLeft: 8 }}>💱 {rateLabel(npc)}</span>}
|
||||
{(npc.wares?.length ?? 0) > 0 && <span className="badge" style={{ marginLeft: 8 }}>🛒 {npc.wares?.length} for sale</span>}
|
||||
{npc.description && <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{npc.description}</div>}
|
||||
{npc.dmNotes && <div style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>🔒 {npc.dmNotes}</div>}
|
||||
</div>
|
||||
<div className="flex-row">
|
||||
<button className="btn btn-ghost" onClick={() => setTradeNpcId(tradeNpcId === npc.id ? null : npc.id)}>
|
||||
{tradeNpcId === npc.id ? 'Close' : 'Trade'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
aria-label={`Remove ${npc.name}`}
|
||||
onClick={() => window.confirm(`Remove ${npc.name}?`) && deleteNpc.mutate(npc.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
aria-label={`Remove ${npc.name}`}
|
||||
onClick={() => window.confirm(`Remove ${npc.name}?`) && deleteNpc.mutate(npc.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{tradeNpcId === npc.id && (
|
||||
<NpcTrade
|
||||
campaignId={campaignId}
|
||||
npc={npc}
|
||||
weapons={weapons ?? []}
|
||||
items={items ?? []}
|
||||
onError={(err) => setFormError(extractErrorMessage(err))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -867,7 +1117,7 @@ export function CampaignHomePage() {
|
||||
)}
|
||||
|
||||
{isDM && (
|
||||
<div className="mt-lg card">
|
||||
<div data-jump="Locations" className="mt-lg card">
|
||||
<div className="card-title">
|
||||
<span>Locations</span>
|
||||
</div>
|
||||
@@ -911,7 +1161,7 @@ export function CampaignHomePage() {
|
||||
must be at least a weapon's or item's level to use it, and at least a spell's level to cast it.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-sm card">
|
||||
<div data-jump="Weapons" className="mt-sm card">
|
||||
<div className="card-title">
|
||||
<span>Weapons Pool</span>
|
||||
<div className="flex-row gap-sm">
|
||||
@@ -985,12 +1235,12 @@ export function CampaignHomePage() {
|
||||
<tbody>
|
||||
{weaponsPage.pageItems.map((w) => (
|
||||
<tr key={w.id}>
|
||||
<td>{w.name}</td>
|
||||
<td>{w.damageDice} {w.damageType}</td>
|
||||
<td>
|
||||
<td className="cell-title">{w.name}</td>
|
||||
<td data-label="Base damage">{w.damageDice} {w.damageType}</td>
|
||||
<td data-label="Level">
|
||||
<StartLevelSelect value={w.minLevel} onChange={(minLevel) => patchWeapon.mutate({ id: w.id, minLevel })} />
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Growth">
|
||||
<ScalingSelect value={w.damageScaling} onChange={(damageScaling) => patchWeapon.mutate({ id: w.id, damageScaling })} />
|
||||
</td>
|
||||
<PoolRowControls
|
||||
@@ -1006,7 +1256,7 @@ export function CampaignHomePage() {
|
||||
<Pagination page={weaponsPage.page} pageCount={weaponsPage.pageCount} onPrev={weaponsPage.prevPage} onNext={weaponsPage.nextPage} />
|
||||
</div>
|
||||
|
||||
<div className="mt-md card">
|
||||
<div data-jump="Items" className="mt-md card">
|
||||
<div className="card-title">
|
||||
<span>Items Pool</span>
|
||||
<button className="btn btn-ghost" onClick={() => setShowItemForm((s) => !s)}>
|
||||
@@ -1061,13 +1311,13 @@ export function CampaignHomePage() {
|
||||
<tbody>
|
||||
{itemsPage.pageItems.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>{item.name}</td>
|
||||
<td>{item.description}</td>
|
||||
<td>{item.damageDice || '—'}</td>
|
||||
<td>
|
||||
<td className="cell-title">{item.name}</td>
|
||||
<td className="cell-wide">{item.description}</td>
|
||||
<td data-label="Base dice">{item.damageDice || '—'}</td>
|
||||
<td data-label="Level">
|
||||
<StartLevelSelect value={item.minLevel} onChange={(minLevel) => patchItem.mutate({ id: item.id, minLevel })} />
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Growth">
|
||||
{item.damageDice ? (
|
||||
<ScalingSelect value={item.damageScaling} onChange={(damageScaling) => patchItem.mutate({ id: item.id, damageScaling })} />
|
||||
) : (
|
||||
@@ -1088,7 +1338,7 @@ export function CampaignHomePage() {
|
||||
<Pagination page={itemsPage.page} pageCount={itemsPage.pageCount} onPrev={itemsPage.prevPage} onNext={itemsPage.nextPage} />
|
||||
</div>
|
||||
|
||||
<div className="mt-md card">
|
||||
<div data-jump="Spells" className="mt-md card">
|
||||
<div className="card-title">
|
||||
<span>Spell List</span>
|
||||
<div className="flex-row gap-sm">
|
||||
@@ -1172,15 +1422,15 @@ export function CampaignHomePage() {
|
||||
<tbody>
|
||||
{spellsPage.pageItems.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>
|
||||
<td className="cell-title">
|
||||
<strong>{s.name}</strong>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{s.description || 'No description.'}</div>
|
||||
</td>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>{spellLevelLabel(s.level)}</td>
|
||||
<td>
|
||||
<td data-label="Level" style={{ whiteSpace: 'nowrap' }}>{spellLevelLabel(s.level)}</td>
|
||||
<td data-label="Damage">
|
||||
<SpellDamageSelect spellName={s.name} value={s.damageDice} onChange={(damageDice) => patchSpell.mutate({ id: s.id, damageDice })} />
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Revives">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${s.name} revives the dead`}
|
||||
@@ -1201,15 +1451,21 @@ export function CampaignHomePage() {
|
||||
<Pagination page={spellsPage.page} pageCount={spellsPage.pageCount} onPrev={spellsPage.prevPage} onNext={spellsPage.nextPage} />
|
||||
</div>
|
||||
|
||||
<div className="mt-md card">
|
||||
<div data-jump="Monsters" className="mt-md card">
|
||||
<div className="card-title">
|
||||
<span>Monsters</span>
|
||||
<div className="flex-row gap-sm">
|
||||
<button className="btn btn-ghost" onClick={() => setShowMonsterLibrary((s) => !s)}>
|
||||
{showMonsterLibrary ? 'Close Library' : '📚 From Library'}
|
||||
</button>
|
||||
<button className="btn btn-ghost" onClick={() => setShowMonsterForm((s) => !s)}>
|
||||
{showMonsterForm ? 'Close' : '+ Add Monster'}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
setShowMonsterForm(!(showMonsterForm || editingMonster));
|
||||
setEditingMonsterId(null);
|
||||
}}
|
||||
>
|
||||
{showMonsterForm || editingMonster ? 'Close' : '+ Add Monster'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1219,62 +1475,93 @@ export function CampaignHomePage() {
|
||||
adding={addMonsterFromLibrary.isPending}
|
||||
onAdd={(key) => addMonsterFromLibrary.mutate(key)}
|
||||
placeholder="Search the monster library…"
|
||||
renderMeta={(m) => `CR ${m.challengeRating} · AC ${m.ac} · HP ${m.maxHp} · attack +${m.attackBonus}${m.damageDice ? ` for ${m.damageDice}` : ''}`}
|
||||
renderMeta={(m) =>
|
||||
`CR ${m.challengeRating ?? '—'} · AC ${m.ac} · HP ${m.maxHp} · speed ${m.speed} · attack ${fmtMod(m.attackBonus)}${m.damageDice ? ` for ${m.damageDice}` : ''} · ${abilityLine(m)}`
|
||||
}
|
||||
renderDetail={(m) => [m.specialTrait, m.notes].filter(Boolean).join(' — ') || null}
|
||||
/>
|
||||
)}
|
||||
{showMonsterForm && (
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target as HTMLFormElement);
|
||||
await createMonster.mutateAsync({
|
||||
name: String(fd.get('name')),
|
||||
ac: Number(fd.get('ac')),
|
||||
maxHp: Number(fd.get('maxHp')),
|
||||
speed: 30,
|
||||
strScore: 10,
|
||||
dexScore: 10,
|
||||
conScore: 10,
|
||||
intScore: 10,
|
||||
wisScore: 10,
|
||||
chaScore: 10,
|
||||
attackBonus: Number(fd.get('attackBonus') || 0),
|
||||
damageDice: String(fd.get('damageDice') || '') || null,
|
||||
challengeRating: null,
|
||||
specialTrait: null,
|
||||
notes: null,
|
||||
});
|
||||
(e.target as HTMLFormElement).reset();
|
||||
}}
|
||||
className="grid grid-cols-3"
|
||||
>
|
||||
<input name="name" placeholder="Name" required />
|
||||
<NumberInput name="ac" min={0} max={50} placeholder="AC" required />
|
||||
<NumberInput name="maxHp" min={1} max={100000} placeholder="Max HP" required />
|
||||
<NumberInput name="attackBonus" min={-20} max={50} placeholder="Attack bonus" />
|
||||
<input name="damageDice" placeholder="Damage dice" />
|
||||
<button type="submit" className="btn btn-primary">Add</button>
|
||||
</form>
|
||||
{(showMonsterForm || editingMonster) && (
|
||||
<div className="card mt-sm" style={{ background: 'var(--bg-inset)' }}>
|
||||
{editingMonster && <div className="section-label">Editing {editingMonster.name}</div>}
|
||||
<MonsterForm
|
||||
key={editingMonster?.id ?? 'new'}
|
||||
monster={editingMonster}
|
||||
saving={createMonster.isPending || updateMonster.isPending}
|
||||
onCancel={editingMonster ? () => setEditingMonsterId(null) : undefined}
|
||||
onSave={async (stats) => {
|
||||
setFormError(null);
|
||||
try {
|
||||
if (editingMonster) {
|
||||
await updateMonster.mutateAsync({ id: editingMonster.id, ...stats });
|
||||
setEditingMonsterId(null);
|
||||
} else {
|
||||
await createMonster.mutateAsync(stats);
|
||||
}
|
||||
} catch (err) {
|
||||
setFormError(extractErrorMessage(err));
|
||||
throw err;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="table-wrap mt-sm">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>CR</th>
|
||||
<th>AC</th>
|
||||
<th>HP</th>
|
||||
<th>Speed</th>
|
||||
<th>Attack</th>
|
||||
<th>Damage</th>
|
||||
<th />
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{monstersPage.pageItems.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td>{m.name}</td>
|
||||
<td>{m.ac}</td>
|
||||
<td>{m.maxHp}</td>
|
||||
<td className="cell-title">
|
||||
<strong>{m.name}</strong>
|
||||
<div className="mono" style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{abilityLine(m)}</div>
|
||||
{m.specialTrait && (
|
||||
<div style={{ fontSize: '0.9rem' }}>
|
||||
<strong>Trait:</strong> {m.specialTrait}
|
||||
</div>
|
||||
)}
|
||||
{m.notes && <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem', whiteSpace: 'pre-wrap' }}>{m.notes}</div>}
|
||||
</td>
|
||||
<td data-label="CR">{m.challengeRating || '—'}</td>
|
||||
<td data-label="AC">{m.ac}</td>
|
||||
<td data-label="HP">{m.maxHp}</td>
|
||||
<td data-label="Speed">{m.speed}</td>
|
||||
<td data-label="Attack">{fmtMod(m.attackBonus)}</td>
|
||||
<td data-label="Damage">{m.damageDice || '—'}</td>
|
||||
<td className="cell-actions">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
aria-label={`Edit ${m.name}`}
|
||||
onClick={() => {
|
||||
setEditingMonsterId(m.id);
|
||||
setShowMonsterForm(false);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-ghost" onClick={() => deleteMonster.mutate(m.id)}>✕</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
aria-label={`Remove ${m.name}`}
|
||||
onClick={() =>
|
||||
window.confirm(`Remove ${m.name}? Copies already in encounters stay, but lose their stat block.`) && deleteMonster.mutate(m.id)
|
||||
}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -1286,7 +1573,7 @@ export function CampaignHomePage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-lg card">
|
||||
<div data-jump="Notes" className="mt-lg card">
|
||||
<div className="card-title">Shared Notes</div>
|
||||
<form onSubmit={handleCreateNote} className="flex-row flex-wrap">
|
||||
<textarea
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useRememberCampaign } from '../lib/lastCampaign';
|
||||
import { useBuildBack, useBuildChoose, useBuildFinalize, useBuildOptions, useBuildRoll, useCampaign, useCampaignItems, useCampaignSpells, useCampaignWeapons, useCharacter, usePatchDiceMode } from '../api/queries';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
@@ -95,6 +96,7 @@ export function CharacterBuilderPage() {
|
||||
const { characterId = '' } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { data: character, isLoading, error } = useCharacter(characterId);
|
||||
useRememberCampaign(character?.campaignId);
|
||||
const roll = useBuildRoll(characterId);
|
||||
const choose = useBuildChoose(characterId);
|
||||
const finalize = useBuildFinalize(characterId);
|
||||
|
||||
@@ -32,6 +32,8 @@ import { useAuth } from '../auth/AuthContext';
|
||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { JumpNav } from '../components/JumpNav';
|
||||
import { useRememberCampaign } from '../lib/lastCampaign';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
import { DeathSaves } from '../components/DeathSaves';
|
||||
import { QuickStatsFab } from '../components/QuickStatsDrawer';
|
||||
@@ -40,15 +42,16 @@ import { usePagination, Pagination } from '../components/Paginated';
|
||||
import {
|
||||
ABILITY_LABELS,
|
||||
CLASS_LABELS,
|
||||
COINS,
|
||||
COIN_FIELD,
|
||||
COIN_LABELS,
|
||||
COIN_SHORT,
|
||||
COIN_VALUE,
|
||||
CURRENCIES,
|
||||
SKILL_LABELS,
|
||||
SPECIES_LABELS,
|
||||
fmtMod,
|
||||
extractErrorMessage,
|
||||
formatMoney,
|
||||
formatPurse,
|
||||
purseWorthInGold,
|
||||
spellLevelLabel,
|
||||
@@ -56,7 +59,7 @@ import {
|
||||
import { speciesIcon } from '../lib/speciesIcons';
|
||||
import { hpMax as previewHpMax, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
import { CLASS_HIT_DIE } from '../lib/classData';
|
||||
import type { Ability, CharacterDetail, Currency } from '../api/types';
|
||||
import type { Ability, CharacterDetail, Coin, Currency } from '../api/types';
|
||||
|
||||
// The DM's level picker for one held weapon or item, from the level the gear
|
||||
// starts at up to the maximum. Each option shows what the dice become at that
|
||||
@@ -112,10 +115,10 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
|
||||
const heldWeapon = character.weapons.find((w) => w.weaponId === id);
|
||||
const heldItem = character.inventoryItems.find((i) => i.itemId === id);
|
||||
// A gift of coins is paid from the whole purse, with change, so any coin
|
||||
// the purse is worth at least one of can be given.
|
||||
// the purse is worth at least one of can be given. Cash is only paid in cash.
|
||||
const held = (c: Currency) => character[COIN_FIELD[c]] ?? 0;
|
||||
const purseInCopper = CURRENCIES.reduce((sum, c) => sum + held(c) * COIN_VALUE[c], 0);
|
||||
const affordable = (c: Currency) => Math.floor(purseInCopper / COIN_VALUE[c]);
|
||||
const purseInCopper = COINS.reduce((sum, c) => sum + held(c) * COIN_VALUE[c], 0);
|
||||
const affordable = (c: Currency) => (c === 'CASH' ? held(c) : Math.floor(purseInCopper / COIN_VALUE[c]));
|
||||
const coinsHeld = CURRENCIES.filter((c) => affordable(c) > 0);
|
||||
const max = kind === 'gold' ? affordable(coin) : kind === 'weapon' ? (heldWeapon?.quantity ?? 0) : kind === 'item' ? (heldItem?.quantity ?? 0) : 1;
|
||||
const count = Math.floor(Number(amount));
|
||||
@@ -170,7 +173,7 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
|
||||
<option value="">What to give…</option>
|
||||
{coinsHeld.map((c) => (
|
||||
<option key={c} value={`gold:${c}`}>
|
||||
{COIN_LABELS[c]} ({held(c)} {COIN_SHORT[c]} held)
|
||||
{COIN_LABELS[c]} ({formatMoney(held(c), c)} held)
|
||||
</option>
|
||||
))}
|
||||
{character.weapons.length > 0 && (
|
||||
@@ -234,6 +237,7 @@ export function CharacterSheetPage() {
|
||||
const { characterId = '' } = useParams();
|
||||
const { user } = useAuth();
|
||||
const { data: character, isLoading, error } = useCharacter(characterId);
|
||||
useRememberCampaign(character?.campaignId);
|
||||
const patchCharacter = usePatchCharacter(characterId);
|
||||
useCampaignSocket(character?.campaignId);
|
||||
|
||||
@@ -267,8 +271,8 @@ export function CharacterSheetPage() {
|
||||
const [goldReason, setGoldReason] = useState('');
|
||||
const [goldCurrency, setGoldCurrency] = useState<Currency>('GOLD');
|
||||
const [exchangeAmount, setExchangeAmount] = useState('');
|
||||
const [exchangeFrom, setExchangeFrom] = useState<Currency>('GOLD');
|
||||
const [exchangeTo, setExchangeTo] = useState<Currency>('SILVER');
|
||||
const [exchangeFrom, setExchangeFrom] = useState<Coin>('GOLD');
|
||||
const [exchangeTo, setExchangeTo] = useState<Coin>('SILVER');
|
||||
const [noteBody, setNoteBody] = useState('');
|
||||
const [pickedWeaponId, setPickedWeaponId] = useState('');
|
||||
const [pickedSpellId, setPickedSpellId] = useState('');
|
||||
@@ -551,7 +555,9 @@ export function CharacterSheetPage() {
|
||||
<p className="subtitle mt-sm">📍 Currently at: <strong>{character.currentLocation.name}</strong></p>
|
||||
)}
|
||||
|
||||
<div className="card mt-md">
|
||||
<JumpNav />
|
||||
|
||||
<div data-jump="Abilities" className="card mt-md">
|
||||
<div className="card-title">Ability Scores</div>
|
||||
<div className="grid grid-cols-3">
|
||||
{(Object.keys(sheet.abilities) as Ability[]).map((ability) => (
|
||||
@@ -567,7 +573,7 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 mt-md">
|
||||
<div className="card">
|
||||
<div data-jump="Saves" className="card">
|
||||
<div className="card-title">
|
||||
Saving Throws <InfoTip term="SAVING_THROW" />
|
||||
</div>
|
||||
@@ -580,7 +586,7 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="card">
|
||||
<div data-jump="Skills" className="card">
|
||||
<div className="card-title">
|
||||
Skills <InfoTip term="SKILL_BONUS" />
|
||||
</div>
|
||||
@@ -614,7 +620,7 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card mt-md">
|
||||
<div data-jump="Weapons" className="card mt-md">
|
||||
<div className="card-title">
|
||||
Weapons <InfoTip term="ATTACK_BONUS" />
|
||||
</div>
|
||||
@@ -677,7 +683,7 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
|
||||
{campaignSpells && campaignSpells.length > 0 && (
|
||||
<div className="card mt-md">
|
||||
<div data-jump="Spells" className="card mt-md">
|
||||
<div className="card-title">Spells Known</div>
|
||||
{spellsPage.pageItems.map((cs) => (
|
||||
<div key={cs.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
@@ -741,7 +747,7 @@ export function CharacterSheetPage() {
|
||||
|
||||
{!character.restricted && (
|
||||
<>
|
||||
<div className="card mt-md">
|
||||
<div data-jump="Inventory" className="card mt-md">
|
||||
<div className="card-title">Inventory</div>
|
||||
{itemsPage.pageItems.map((held) => (
|
||||
<div key={held.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
@@ -821,14 +827,15 @@ export function CharacterSheetPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card mt-md">
|
||||
<div data-jump="Money" className="card mt-md">
|
||||
<div className="card-title">
|
||||
<span>Money</span>
|
||||
<span className="mono">{formatPurse(character)}</span>
|
||||
</div>
|
||||
<p className="subtitle" style={{ marginTop: 0 }}>
|
||||
1 gold = 10 silver = 100 copper. Worth {purseWorthInGold(character)} gold in all. Spending and giving make
|
||||
change for you: if you are short of a coin, a bigger one is broken.
|
||||
change for you: if you are short of a coin, a bigger one is broken. Cash ($) is kept apart: it is never
|
||||
exchanged for coins.
|
||||
</p>
|
||||
{canEdit && (
|
||||
<div className="flex-row flex-wrap">
|
||||
@@ -876,16 +883,16 @@ export function CharacterSheetPage() {
|
||||
onChange={(e) => setExchangeAmount(e.target.value)}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<select aria-label="Exchange from" value={exchangeFrom} onChange={(e) => setExchangeFrom(e.target.value as Currency)}>
|
||||
{CURRENCIES.map((c) => (
|
||||
<select aria-label="Exchange from" value={exchangeFrom} onChange={(e) => setExchangeFrom(e.target.value as Coin)}>
|
||||
{COINS.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{COIN_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="subtitle">into</span>
|
||||
<select aria-label="Exchange into" value={exchangeTo} onChange={(e) => setExchangeTo(e.target.value as Currency)}>
|
||||
{CURRENCIES.map((c) => (
|
||||
<select aria-label="Exchange into" value={exchangeTo} onChange={(e) => setExchangeTo(e.target.value as Coin)}>
|
||||
{COINS.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{COIN_LABELS[c]}
|
||||
</option>
|
||||
@@ -902,7 +909,8 @@ export function CharacterSheetPage() {
|
||||
<div key={t.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
<div>
|
||||
<strong className="mono">
|
||||
{t.amount > 0 ? `+${t.amount}` : `−${Math.abs(t.amount)}`} {COIN_SHORT[t.currency ?? 'GOLD']}
|
||||
{t.amount > 0 ? '+' : '−'}
|
||||
{formatMoney(Math.abs(t.amount), t.currency ?? 'GOLD')}
|
||||
</strong>
|
||||
<span style={{ marginLeft: 8 }}>{t.reason || (t.amount > 0 ? 'Gained' : 'Spent')}</span>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>
|
||||
@@ -911,7 +919,7 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
</div>
|
||||
<span className="mono" style={{ color: 'var(--text-muted)' }}>
|
||||
{t.balanceAfter} {COIN_SHORT[t.currency ?? 'GOLD']}
|
||||
{formatMoney(t.balanceAfter, t.currency ?? 'GOLD')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -996,7 +1004,7 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card mt-md">
|
||||
<div data-jump="Notes" className="card mt-md">
|
||||
<div className="card-title">Notes</div>
|
||||
<form onSubmit={handleAddNote} className="flex-row flex-wrap">
|
||||
<textarea
|
||||
|
||||
@@ -30,7 +30,9 @@ import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
import { DeathSaves } from '../components/DeathSaves';
|
||||
import { ABILITY_LABELS, CLASS_LABELS, COIN_LABELS, CURRENCIES, SPECIES_LABELS, extractErrorMessage, fmtMod, spellLevelLabel } from '../lib/format';
|
||||
import { TradePanel } from '../components/TradePanel';
|
||||
import { useRememberCampaign } from '../lib/lastCampaign';
|
||||
import { ABILITY_LABELS, CLASS_LABELS, COIN_LABELS, CURRENCIES, SPECIES_LABELS, extractErrorMessage, fmtMod, formatMoney, spellLevelLabel } from '../lib/format';
|
||||
import { abilityModifier, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
import type {
|
||||
Ability,
|
||||
@@ -606,7 +608,7 @@ function AttackBar({
|
||||
}
|
||||
|
||||
function rewardLabel(r: EncounterReward): string {
|
||||
if (r.type === 'GOLD') return `${r.gold} ${COIN_LABELS[r.currency ?? 'GOLD'].toLowerCase()}`;
|
||||
if (r.type === 'GOLD') return formatMoney(r.gold ?? 0, r.currency ?? 'GOLD', true);
|
||||
const entry = r.type === 'WEAPON' ? r.weapon : r.item;
|
||||
return `${entry?.name ?? 'Removed from the pool'}${r.quantity > 1 ? ` ×${r.quantity}` : ''} · Lv ${r.level}`;
|
||||
}
|
||||
@@ -834,6 +836,7 @@ export function EncounterPage() {
|
||||
const { user } = useAuth();
|
||||
const { data: encounter, isLoading, error } = useEncounter(encounterId);
|
||||
useCampaignSocket(encounter?.campaignId);
|
||||
useRememberCampaign(encounter?.campaignId);
|
||||
|
||||
const { data: campaignData } = useCampaign(encounter?.campaignId);
|
||||
const isDM = campaignData?.campaign.role === 'DM';
|
||||
@@ -927,10 +930,20 @@ export function EncounterPage() {
|
||||
/>
|
||||
);
|
||||
|
||||
// In a battle under way the turn passes down the initiative order, so
|
||||
// everyone above whoever is on turn has had their go this round. They drop
|
||||
// to "Next Round" at the bottom, which leaves the combatant on turn at the
|
||||
// top with those still to act beneath them. A new round puts everyone back.
|
||||
const turnIndex = active ? participants.indexOf(active) : -1;
|
||||
const hasActed = (p: EncounterParticipant) => participants.indexOf(p) < turnIndex;
|
||||
|
||||
// A quest scene is grouped by who is there; within each group the order is
|
||||
// still initiative, highest first.
|
||||
const groups: [string, EncounterParticipant[]][] = isBattle
|
||||
? [['Initiative order', standing]]
|
||||
? [
|
||||
['Initiative order', standing.filter((p) => !hasActed(p))],
|
||||
['Next Round', standing.filter(hasActed)],
|
||||
]
|
||||
: [
|
||||
['People you meet', standing.filter((p) => p.kind === 'NPC')],
|
||||
['The party', standing.filter((p) => p.kind === 'CHARACTER')],
|
||||
@@ -1171,6 +1184,16 @@ export function EncounterPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TradePanel
|
||||
encounter={encounter}
|
||||
isDM={isDM}
|
||||
userId={user?.id}
|
||||
characters={builtCharacters}
|
||||
weapons={weapons ?? []}
|
||||
items={items ?? []}
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
<RewardsPanel
|
||||
encounter={encounter}
|
||||
isDM={isDM}
|
||||
|
||||
@@ -67,16 +67,16 @@ export function PartyOverviewPage() {
|
||||
const isMine = c.userId === user?.id;
|
||||
return (
|
||||
<tr key={c.id}>
|
||||
<td>
|
||||
<td className="cell-title">
|
||||
<Link to={inProgress ? `/characters/${c.id}/build` : `/characters/${c.id}`}>
|
||||
<span aria-hidden="true">{speciesIcon(c.species)}</span> {c.name}
|
||||
</Link>
|
||||
{isMine && <span className="badge" style={{ marginLeft: 6 }}>you</span>}
|
||||
</td>
|
||||
<td>{c.species ? SPECIES_LABELS[c.species] : '—'}</td>
|
||||
<td>{c.characterClass ? CLASS_LABELS[c.characterClass] : inProgress ? 'Building…' : '—'}</td>
|
||||
<td>{c.level}</td>
|
||||
<td>
|
||||
<td data-label="Species">{c.species ? SPECIES_LABELS[c.species] : '—'}</td>
|
||||
<td data-label="Class">{c.characterClass ? CLASS_LABELS[c.characterClass] : inProgress ? 'Building…' : '—'}</td>
|
||||
<td data-label="Level">{c.level}</td>
|
||||
<td data-label="HP">
|
||||
{c.maxHp != null ? (
|
||||
<div style={{ minWidth: 90 }}>
|
||||
<span className="mono">
|
||||
@@ -90,9 +90,9 @@ export function PartyOverviewPage() {
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
<td>{c.armorClass ?? '—'}</td>
|
||||
<td className="mono">{c.gold == null ? '—' : formatPurse(c)}</td>
|
||||
<td>{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
|
||||
<td data-label="AC">{c.armorClass ?? '—'}</td>
|
||||
<td data-label="Coins" className="mono">{c.gold == null ? '—' : formatPurse(c)}</td>
|
||||
<td data-label="Location">{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
Generated
+4
-4
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.8.0",
|
||||
"version": "1.10.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.8.0",
|
||||
"version": "1.10.1",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"version": "1.8.0",
|
||||
"version": "1.10.1",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.20.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
@@ -31,7 +31,7 @@
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"version": "1.8.0",
|
||||
"version": "1.10.1",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"axios": "^1.20.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.8.0",
|
||||
"version": "1.10.1",
|
||||
"private": true,
|
||||
"description": "D&D 5e campaign manager — monorepo root",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user