Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
555765916f |
@@ -99,6 +99,12 @@ including a character sheet or an encounter) and **Account**.
|
||||
checklist at level 2 or 3, and shown beside the class everywhere; the DM can
|
||||
change it from the sheet, and switch a 2014 path to its 2024 update. Every
|
||||
sheet shows **the road ahead**: what each level from 1 to 20 gives.
|
||||
- **Free roam**, a third kind of encounter beside battles and quests: the DM
|
||||
ticks the nearby locations, the whole party is in it from the start, and
|
||||
each player sends their own character to a place to meet who is found there
|
||||
and shop (a place's own shop, or an NPC's wares) where they stand.
|
||||
- **Completed encounters are hidden** from the campaign's list until "Show
|
||||
completed" is ticked.
|
||||
- **Faster battles.** A colour-coded **turn-order track** (PCs blue, NPCs
|
||||
green, monsters red) shows this round, a 🏁 flag where it ends, and the
|
||||
next. Click a name (or press 1–9) to **target**. Whoever is on turn picks a
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Free roam: a kind of encounter where the party wanders between places the
|
||||
-- DM has laid out. A location can have NPCs found there and things for sale.
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "EncounterMode" ADD VALUE 'FREE_ROAM';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "npcs" ADD COLUMN "locationId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "sale_offers" ADD COLUMN "locationId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "encounter_locations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"encounterId" TEXT NOT NULL,
|
||||
"locationId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "encounter_locations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "encounter_locations_encounterId_locationId_key" ON "encounter_locations"("encounterId", "locationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sale_offers_locationId_idx" ON "sale_offers"("locationId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "npcs" ADD CONSTRAINT "npcs_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_locations" ADD CONSTRAINT "encounter_locations_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_locations" ADD CONSTRAINT "encounter_locations_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -134,11 +134,13 @@ enum EncounterStatus {
|
||||
COMPLETE
|
||||
}
|
||||
|
||||
// What kind of scene an encounter is: a fight run in initiative order, or a
|
||||
// quest scene built around NPCs and an objective.
|
||||
// What kind of scene an encounter is: a fight run in initiative order, a
|
||||
// quest scene built around NPCs and an objective, or free roam, where the
|
||||
// party wanders between places the DM has laid out (to shop, to meet people).
|
||||
enum EncounterMode {
|
||||
BATTLE
|
||||
QUEST
|
||||
FREE_ROAM
|
||||
}
|
||||
|
||||
enum NpcDisposition {
|
||||
@@ -335,6 +337,10 @@ model Location {
|
||||
characters Character[]
|
||||
encounters Encounter[]
|
||||
visits CharacterLocationVisit[]
|
||||
// Who is found here, what is for sale here, and the free-roam scenes it is a stop in.
|
||||
npcs Npc[]
|
||||
wares SaleOffer[]
|
||||
freeRoams EncounterLocation[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("locations")
|
||||
@@ -737,9 +743,13 @@ model Npc {
|
||||
// for exchangeCash cash, either way. Both null: this NPC does not exchange.
|
||||
exchangeGold Int?
|
||||
exchangeCash Int?
|
||||
// Where this NPC is usually found. In a free-roam scene they are met (and
|
||||
// traded with) by whoever goes there.
|
||||
locationId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||
encounterAppearances EncounterParticipant[]
|
||||
wares SaleOffer[]
|
||||
|
||||
@@ -782,11 +792,28 @@ model Encounter {
|
||||
rewards EncounterReward[]
|
||||
wares SaleOffer[]
|
||||
actions EncounterAction[]
|
||||
// Free roam: the places the party can go to in this scene.
|
||||
places EncounterLocation[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("encounters")
|
||||
}
|
||||
|
||||
// A place that is a stop in a free-roam scene: one of the campaign's
|
||||
// locations, picked by the DM as somewhere the party can go right now.
|
||||
model EncounterLocation {
|
||||
id String @id @default(cuid())
|
||||
encounterId String
|
||||
locationId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||||
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([encounterId, locationId])
|
||||
@@map("encounter_locations")
|
||||
}
|
||||
|
||||
// An attack or a spell in a battle, from the moment it is declared to the
|
||||
// moment its damage lands (game/combat.js). A player declares their own on
|
||||
// their turn; the DM declares anyone's; and with the campaign's auto-roll
|
||||
@@ -851,15 +878,17 @@ model EncounterReward {
|
||||
}
|
||||
|
||||
// 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).
|
||||
// It belongs to an NPC (and is on offer wherever that NPC is met), to an
|
||||
// encounter itself (a stall, a vending machine), or to a location (a shop
|
||||
// that is always there, open to whoever goes to it in a free-roam scene).
|
||||
// Only the DM sets these up; players see them, and buy, in a quest or
|
||||
// free-roam encounter in progress (encounters.routes.js).
|
||||
model SaleOffer {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
npcId String? // exactly one of npcId / encounterId is set
|
||||
npcId String? // exactly one of npcId / encounterId / locationId is set
|
||||
encounterId String?
|
||||
locationId String?
|
||||
weaponId String? // exactly one of weaponId / itemId is set
|
||||
itemId String?
|
||||
price Int // for one, in `currency`
|
||||
@@ -871,12 +900,14 @@ model SaleOffer {
|
||||
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)
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||
weapon Weapon? @relation(fields: [weaponId], references: [id], onDelete: Cascade)
|
||||
item Item? @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([campaignId])
|
||||
@@index([npcId])
|
||||
@@index([encounterId])
|
||||
@@index([locationId])
|
||||
@@map("sale_offers")
|
||||
}
|
||||
|
||||
|
||||
@@ -4047,3 +4047,88 @@ describe('battle actions', () => {
|
||||
expect(rolled.damage.dice).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('free roam', () => {
|
||||
itIfDb('the party is in it from the start, wanders between its places, and shops where it stands', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('roam');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
const allyId = await buildCharacter(campaign.id, dmAuth, 'Bo');
|
||||
await request(app).post(`/characters/${heroId}/gold`).set(dmAuth).send({ amount: 50, reason: 'Purse' });
|
||||
const place = async (name) => (await request(app).post(`${c}/locations`).set(dmAuth).send({ name, description: `${name}.` })).body.location;
|
||||
const [market, inn, docks] = [await place('Market'), await place('Inn'), await place('Docks')];
|
||||
const rope = (await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Rope' })).body.item;
|
||||
const ale = (await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Ale' })).body.item;
|
||||
|
||||
// A shop at the market, and an innkeeper found at the inn who sells ale.
|
||||
expect((await request(app).post(`${c}/wares`).set(dmAuth).send({ locationId: market.id, itemId: rope.id, price: 2 })).status).toBe(201);
|
||||
expect((await request(app).post(`${c}/wares`).set(dmAuth).send({ locationId: market.id, npcId: 'x', itemId: rope.id, price: 2 })).status).toBe(400);
|
||||
const keeper = (await request(app).post(`${c}/npcs`).set(dmAuth).send({ name: 'Mara', role: 'Innkeeper', dmNotes: 'Waters the ale.', locationId: inn.id })).body.npc;
|
||||
expect((await request(app).post(`${c}/npcs`).set(dmAuth).send({ name: 'Nobody', locationId: 'nowhere' })).status).toBe(400);
|
||||
await request(app).post(`${c}/wares`).set(dmAuth).send({ npcId: keeper.id, itemId: ale.id, price: 1 });
|
||||
|
||||
const created = await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'A day in town', mode: 'FREE_ROAM', placeIds: [market.id, inn.id] });
|
||||
expect(created.status).toBe(201);
|
||||
const e = `/encounters/${created.body.encounter.id}`;
|
||||
const view = async (auth) => (await request(app).get(e).set(auth)).body.encounter;
|
||||
|
||||
// Everyone alive is in it without being added, so the player can open it.
|
||||
let scene = await view(playerAuth);
|
||||
expect(scene.mode).toBe('FREE_ROAM');
|
||||
expect(scene.participants.map((p) => p.displayName).sort()).toEqual(['Aria', 'Bo']);
|
||||
expect(scene.places.map((p) => p.name)).toEqual(['Market', 'Inn']);
|
||||
expect(scene.places[0].wares[0].item.name).toBe('Rope');
|
||||
expect(scene.places[1].npcs[0]).toMatchObject({ name: 'Mara', role: 'Innkeeper' });
|
||||
expect(scene.places[1].npcs[0].dmNotes).toBeUndefined(); // the DM's alone
|
||||
expect(scene.places[1].npcs[0].wares[0].item.name).toBe('Ale');
|
||||
expect((await view(dmAuth)).places[1].npcs[0].dmNotes).toBe('Waters the ale.');
|
||||
|
||||
const visit = (auth, characterId, locationId) => request(app).post(`${e}/visit`).set(auth).send({ characterId, locationId });
|
||||
const buy = (auth, wareId, characterId) => request(app).post(`${e}/wares/${wareId}/buy`).set(auth).send({ characterId });
|
||||
const ropeWare = scene.places[0].wares[0].id;
|
||||
const aleWare = scene.places[1].npcs[0].wares[0].id;
|
||||
|
||||
// Nothing moves, and nothing is sold, before the scene begins.
|
||||
expect((await visit(playerAuth, heroId, market.id)).status).toBe(400);
|
||||
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' });
|
||||
expect((await buy(playerAuth, ropeWare, heroId)).status).toBe(400); // not at the market yet
|
||||
expect((await visit(playerAuth, heroId, docks.id)).status).toBe(400); // not one of the scene's places
|
||||
expect((await visit(playerAuth, allyId, market.id)).status).toBe(403); // not their character
|
||||
|
||||
scene = (await visit(playerAuth, heroId, market.id)).body.encounter;
|
||||
expect(scene.places[0].here.map((h) => h.name)).toEqual(['Aria']);
|
||||
expect((await buy(playerAuth, ropeWare, heroId)).status).toBe(201);
|
||||
// The innkeeper is at the inn: go there to buy her ale.
|
||||
const tooFar = await buy(playerAuth, aleWare, heroId);
|
||||
expect(tooFar.status).toBe(400);
|
||||
expect(tooFar.body.error).toMatch(/go to Inn first/);
|
||||
await visit(playerAuth, heroId, inn.id);
|
||||
expect((await buy(playerAuth, aleWare, heroId)).status).toBe(201);
|
||||
const hero = (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character;
|
||||
expect(hero.inventoryItems.map((i) => i.item.name).sort()).toEqual(['Ale', 'Rope']);
|
||||
expect(hero.currentLocation.name).toBe('Inn');
|
||||
// The DM can send anyone, and opens another place while the party roams.
|
||||
expect((await visit(dmAuth, allyId, market.id)).status).toBe(200);
|
||||
expect((await request(app).put(`${e}/places`).set(playerAuth).send({ locationIds: [] })).status).toBe(403);
|
||||
const more = (await request(app).put(`${e}/places`).set(dmAuth).send({ locationIds: [market.id, inn.id, docks.id] })).body.encounter;
|
||||
expect(more.places.map((p) => p.name)).toEqual(['Market', 'Inn', 'Docks']);
|
||||
// Having been there, the player now knows the place for good.
|
||||
const known = (await request(app).get(`${c}/locations`).set(playerAuth)).body.locations.map((l) => l.name).sort();
|
||||
expect(known).toEqual(['Inn', 'Market']);
|
||||
});
|
||||
|
||||
itIfDb('a shop at a place does not sell in a quest, and only a free-roam scene has places', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('roamnot');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
const market = (await request(app).post(`${c}/locations`).set(dmAuth).send({ name: 'Market' })).body.location;
|
||||
const rope = (await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Rope' })).body.item;
|
||||
const ware = (await request(app).post(`${c}/wares`).set(dmAuth).send({ locationId: market.id, itemId: rope.id, price: 1 })).body.ware;
|
||||
const quest = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Errand', mode: 'QUEST' })).body.encounter.id}`;
|
||||
await request(app).post(`${quest}/participants`).set(dmAuth).send({ characterId: heroId });
|
||||
await request(app).patch(quest).set(dmAuth).send({ status: 'ACTIVE' });
|
||||
expect((await request(app).post(`${quest}/wares/${ware.id}/buy`).set(playerAuth).send({ characterId: heroId })).status).toBe(404);
|
||||
expect((await request(app).post(`${quest}/visit`).set(playerAuth).send({ characterId: heroId, locationId: market.id })).status).toBe(400);
|
||||
expect((await request(app).get(quest).set(playerAuth)).body.encounter.places).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -853,9 +853,11 @@ router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) =>
|
||||
|
||||
const createEncounterSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
mode: z.enum(['BATTLE', 'QUEST']).optional(),
|
||||
mode: z.enum(['BATTLE', 'QUEST', 'FREE_ROAM']).optional(),
|
||||
description: z.string().max(4000).optional().nullable(),
|
||||
locationId: z.string().optional().nullable(),
|
||||
// Free roam: the places the party can go to, from the campaign's locations.
|
||||
placeIds: z.array(z.string()).max(50).optional(),
|
||||
// Where it goes in the campaign's order: the top, the bottom, or straight
|
||||
// after another encounter (which wins over `position`). The bottom if
|
||||
// neither is given.
|
||||
@@ -867,11 +869,15 @@ router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req,
|
||||
try {
|
||||
const parsed = createEncounterSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { position, afterEncounterId, ...fields } = parsed.data;
|
||||
const { position, afterEncounterId, placeIds = [], ...fields } = parsed.data;
|
||||
if (fields.locationId) {
|
||||
const location = await prisma.location.findFirst({ where: { id: fields.locationId, campaignId: req.campaign.id } });
|
||||
if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' });
|
||||
}
|
||||
const places = [...new Set(placeIds)];
|
||||
if (places.length > 0 && (await prisma.location.count({ where: { id: { in: places }, campaignId: req.campaign.id } })) !== places.length) {
|
||||
return res.status(400).json({ error: 'Pick places from this campaign\'s locations.' });
|
||||
}
|
||||
|
||||
const order = await prisma.encounter.findMany({
|
||||
where: { campaignId: req.campaign.id },
|
||||
@@ -899,6 +905,26 @@ router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req,
|
||||
}
|
||||
return created;
|
||||
});
|
||||
|
||||
// Free roam is for the whole party: its places are set, and everyone
|
||||
// alive and finished is in it from the start (so their players see it).
|
||||
if (encounter.mode === 'FREE_ROAM') {
|
||||
if (places.length > 0) await prisma.encounterLocation.createMany({ data: places.map((locationId) => ({ encounterId: encounter.id, locationId })) });
|
||||
const party = await prisma.character.findMany({ where: { campaignId: req.campaign.id, buildStage: 'COMPLETE' }, include: SHEET_INCLUDE, orderBy: { createdAt: 'asc' } });
|
||||
const present = party.filter((character) => lifeState(character) !== 'DEAD');
|
||||
if (present.length > 0) {
|
||||
await prisma.encounterParticipant.createMany({
|
||||
data: present.map((character) => ({
|
||||
encounterId: encounter.id,
|
||||
characterId: character.id,
|
||||
displayName: character.name,
|
||||
currentHp: character.currentHp,
|
||||
maxHp: sheetOf(character).hp.max,
|
||||
isMonster: false,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
res.status(201).json({ encounter });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -961,8 +987,16 @@ const npcSchema = z.object({
|
||||
// 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(),
|
||||
// Where they are usually found: met there in a free-roam scene.
|
||||
locationId: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
// Whether a location named for an NPC, a ware or a scene is one of this campaign's.
|
||||
async function locationInCampaign(locationId, campaignId) {
|
||||
return !locationId || Boolean(await prisma.location.findFirst({ where: { id: locationId, campaignId }, select: { id: true } }));
|
||||
}
|
||||
const NOT_A_PLACE = 'That location is not part of this campaign.';
|
||||
|
||||
// A rate has two sides: both are given, or both are cleared.
|
||||
function halfARate(data) {
|
||||
const sides = [data.exchangeGold, data.exchangeCash];
|
||||
@@ -982,6 +1016,7 @@ router.post('/:id/npcs', requireCampaignMember(), requireDM(), async (req, res,
|
||||
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 });
|
||||
if (!(await locationInCampaign(parsed.data.locationId, req.campaign.id))) return res.status(400).json({ error: NOT_A_PLACE });
|
||||
const npc = await prisma.npc.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
|
||||
res.status(201).json({ npc });
|
||||
} catch (err) {
|
||||
@@ -994,6 +1029,7 @@ router.patch('/:id/npcs/:npcId', requireCampaignMember(), requireDM(), async (re
|
||||
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 });
|
||||
if (!(await locationInCampaign(parsed.data.locationId, req.campaign.id))) return res.status(400).json({ error: NOT_A_PLACE });
|
||||
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);
|
||||
@@ -1106,6 +1142,7 @@ const wareSchema = z
|
||||
.object({
|
||||
npcId: z.string().optional(),
|
||||
encounterId: z.string().optional(),
|
||||
locationId: z.string().optional(), // a shop that is always at this place
|
||||
weaponId: z.string().optional(),
|
||||
itemId: z.string().optional(),
|
||||
price: z.number().int().min(1).max(1000000),
|
||||
@@ -1113,7 +1150,7 @@ const wareSchema = z
|
||||
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) => [data.npcId, data.encounterId, data.locationId].filter(Boolean).length === 1, { message: 'Say where it is sold: by an NPC, at an encounter, or at a location (one of them)' })
|
||||
.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) => {
|
||||
@@ -1123,7 +1160,7 @@ router.post('/:id/wares', requireCampaignMember(), requireDM(), async (req, res,
|
||||
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));
|
||||
const seller = (await inCampaign('npc', data.npcId)) || (await inCampaign('encounter', data.encounterId)) || (await inCampaign('location', data.locationId));
|
||||
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.` });
|
||||
|
||||
@@ -296,13 +296,16 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
// Likewise their conditions: the character's own, which outlast the scene.
|
||||
out.conditions = character.conditions;
|
||||
out.character = { id: character.id, name: character.name, userId: character.userId };
|
||||
// Where each character is, is part of a free-roam scene; elsewhere it stays on their sheet.
|
||||
if (encounter.mode === 'FREE_ROAM') out.character.currentLocationId = character.currentLocationId;
|
||||
out.characterCombatInfo = combatInfo;
|
||||
}
|
||||
out.conditionRounds = roundsFor(p.conditionRounds, out.conditions);
|
||||
if (monster) out.monster = isDM ? monster : { id: monster.id, name: monster.name };
|
||||
if (npc) {
|
||||
const { dmNotes, ac, maxHp, attackBonus, damageDice, campaignId, createdAt, ...publicNpc } = npc;
|
||||
out.npc = isDM ? { ...publicNpc, dmNotes, ac, maxHp, attackBonus, damageDice } : publicNpc;
|
||||
// Where they are usually found is the DM's to know, like their notes and stats.
|
||||
const { dmNotes, ac, maxHp, attackBonus, damageDice, locationId, campaignId, createdAt, ...publicNpc } = npc;
|
||||
out.npc = isDM ? { ...publicNpc, dmNotes, ac, maxHp, attackBonus, damageDice, locationId } : publicNpc;
|
||||
}
|
||||
if (hideHp && !character) {
|
||||
// Rounded up, so anything still standing shows a sliver of bar.
|
||||
@@ -337,6 +340,31 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
.slice(0, 6)
|
||||
.map(showAction);
|
||||
|
||||
// Free roam: the places the party can go to, each with who is found there,
|
||||
// what is for sale there, and which of the characters in the scene are
|
||||
// there now. Everyone in the scene sees every place in it. An NPC's secrets
|
||||
// and stats stay with the DM, as anywhere.
|
||||
let places;
|
||||
if (encounter.mode === 'FREE_ROAM') {
|
||||
const rows = await prisma.encounterLocation.findMany({
|
||||
where: { encounterId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: { location: { include: { npcs: { include: { wares: WARES }, orderBy: { name: 'asc' } }, wares: WARES } } },
|
||||
});
|
||||
places = rows.map(({ location: place }) => ({
|
||||
id: place.id,
|
||||
name: place.name,
|
||||
description: place.description,
|
||||
wares: place.wares,
|
||||
npcs: place.npcs.map(({ dmNotes: secrets, ac, maxHp, attackBonus, damageDice, locationId, campaignId, createdAt, ...publicNpc }) =>
|
||||
isDM ? { ...publicNpc, dmNotes: secrets, ac, maxHp, attackBonus, damageDice, locationId } : publicNpc
|
||||
),
|
||||
here: encounter.participants
|
||||
.filter((p) => p.character && p.character.currentLocationId === place.id)
|
||||
.map((p) => ({ characterId: p.character.id, name: p.displayName, userId: p.character.userId })),
|
||||
}));
|
||||
}
|
||||
|
||||
const { dmNotes, rewards, campaign, location, ...scene } = encounter;
|
||||
const knowsPlace = isDM || (location && (await visitedLocationIds(prisma, viewerId, encounter.campaignId)).has(location.id));
|
||||
return {
|
||||
@@ -350,6 +378,7 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
fallen,
|
||||
actions,
|
||||
recentActions,
|
||||
...(places ? { places } : {}),
|
||||
rewards: isDM ? rewards : rewards.filter((r) => r.awardedAt),
|
||||
};
|
||||
}
|
||||
@@ -372,7 +401,8 @@ async function syncCharacterLocations(req) {
|
||||
where: { id: req.encounter.id },
|
||||
include: { participants: { where: { characterId: { not: null } }, include: { character: { select: { id: true, currentLocationId: true } } } } },
|
||||
});
|
||||
if (!encounter || encounter.status !== 'ACTIVE' || !encounter.locationId) return;
|
||||
// In free roam each character goes where their player sends them.
|
||||
if (!encounter || encounter.status !== 'ACTIVE' || !encounter.locationId || encounter.mode === 'FREE_ROAM') return;
|
||||
|
||||
const elsewhere = encounter.participants.map((p) => p.character).filter((c) => c.currentLocationId !== encounter.locationId);
|
||||
await moveCharacters(prisma, elsewhere.map((c) => c.id), encounter.locationId);
|
||||
@@ -396,7 +426,7 @@ router.get('/:id', requireEncounterAccess, async (req, res, next) => {
|
||||
|
||||
const patchEncounterSchema = z.object({
|
||||
status: z.enum(['PLANNING', 'ACTIVE', 'COMPLETE']).optional(),
|
||||
mode: z.enum(['BATTLE', 'QUEST']).optional(),
|
||||
mode: z.enum(['BATTLE', 'QUEST', 'FREE_ROAM']).optional(),
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
description: z.string().max(4000).nullable().optional(),
|
||||
dmNotes: z.string().max(4000).nullable().optional(),
|
||||
@@ -1143,6 +1173,58 @@ router.post('/:id/actions/:actionId/apply', requireEncounterAccess, requireDM, a
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Free roam — the party wanders between the places the DM has laid out for
|
||||
// the scene: to shop, to meet whoever is found there. The DM sets the places;
|
||||
// each player sends their own character to one (the DM can send anyone).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The whole list of places; an empty one clears it.
|
||||
router.put('/:id/places', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ locationIds: z.array(z.string()).max(50) }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const locationIds = [...new Set(parsed.data.locationIds)];
|
||||
if ((await prisma.location.count({ where: { id: { in: locationIds }, campaignId: req.encounter.campaignId } })) !== locationIds.length) {
|
||||
return res.status(400).json({ error: "Pick places from this campaign's locations." });
|
||||
}
|
||||
const had = await prisma.encounterLocation.findMany({ where: { encounterId: req.encounter.id }, select: { locationId: true } });
|
||||
const keep = new Set(had.map((row) => row.locationId));
|
||||
await prisma.$transaction([
|
||||
prisma.encounterLocation.deleteMany({ where: { encounterId: req.encounter.id, locationId: { notIn: locationIds } } }),
|
||||
// Added one at a time, in the order given, so the list keeps that order.
|
||||
...locationIds.filter((id) => !keep.has(id)).map((locationId) => prisma.encounterLocation.create({ data: { encounterId: req.encounter.id, locationId } })),
|
||||
]);
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// A character goes to one of the scene's places: their own player sends them, or the DM.
|
||||
router.post('/:id/visit', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ characterId: z.string(), locationId: z.string() }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { characterId, locationId } = parsed.data;
|
||||
if (req.encounter.mode !== 'FREE_ROAM') return res.status(400).json({ error: 'Only a free-roam scene has places to go to.' });
|
||||
if (req.encounter.status !== 'ACTIVE') return res.status(400).json({ error: 'The scene has not begun yet.' });
|
||||
|
||||
const row = await prisma.encounterParticipant.findFirst({ where: { encounterId: req.encounter.id, characterId }, include: { character: true } });
|
||||
if (!row) return res.status(400).json({ error: 'That character is not in this scene.' });
|
||||
if (!req.isDM && row.character.userId !== req.user.id) return res.status(403).json({ error: 'You can only move your own character.' });
|
||||
if (isFallen(row)) return res.status(400).json({ error: `${row.character.name} is dead.` });
|
||||
const place = await prisma.encounterLocation.findFirst({ where: { encounterId: req.encounter.id, locationId } });
|
||||
if (!place) return res.status(400).json({ error: 'That is not one of the places in this scene.' });
|
||||
|
||||
await moveCharacters(prisma, [characterId], locationId);
|
||||
emitCharacterUpdated(req, characterId);
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Experience — awarded once, shared evenly between the characters in the scene
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1333,10 +1415,18 @@ function money(amount, currency) {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// in a quest or a free-roam scene 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.');
|
||||
if (encounter.mode === 'BATTLE') throw new TradeError('Trading happens in a quest or while roaming, not in a battle.');
|
||||
if (encounter.status !== 'ACTIVE') throw new TradeError(`Trading opens once the ${encounter.mode === 'QUEST' ? 'quest' : 'scene'} is in progress.`);
|
||||
}
|
||||
|
||||
// In a free-roam scene a character trades where they are: the place has to
|
||||
// be one of the scene's, and they have to have gone there.
|
||||
async function assertAtPlace(req, character, locationId, what) {
|
||||
const place = locationId ? await prisma.encounterLocation.findFirst({ where: { encounterId: req.encounter.id, locationId }, include: { location: true } }) : null;
|
||||
if (!place) throw new TradeError(`${what} is not here to trade with.`);
|
||||
if (character.currentLocationId !== locationId) throw new TradeError(`${character.name} has to go to ${place.location.name} first.`);
|
||||
}
|
||||
|
||||
// The character doing the trading: in this scene, alive, and the player's
|
||||
@@ -1349,8 +1439,15 @@ async function loadTrader(req, characterId) {
|
||||
return row.character;
|
||||
}
|
||||
|
||||
// An NPC to trade with has to be in the scene and still standing.
|
||||
async function loadTradingNpc(req, npcId) {
|
||||
// An NPC to trade with has to be in the scene and still standing; in a
|
||||
// free-roam scene, at one of its places, with the character there too.
|
||||
async function loadTradingNpc(req, npcId, character) {
|
||||
if (req.encounter.mode === 'FREE_ROAM') {
|
||||
const npc = await prisma.npc.findFirst({ where: { id: npcId, campaignId: req.encounter.campaignId } });
|
||||
if (!npc) throw new TradeError('That NPC is not here to trade with.');
|
||||
await assertAtPlace(req, character, npc.locationId, npc.name);
|
||||
return npc;
|
||||
}
|
||||
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;
|
||||
@@ -1383,7 +1480,7 @@ router.post(
|
||||
const to = from === 'GOLD' ? 'CASH' : 'GOLD';
|
||||
|
||||
const character = await loadTrader(req, characterId);
|
||||
const npc = await loadTradingNpc(req, npcId);
|
||||
const npc = await loadTradingNpc(req, npcId, character);
|
||||
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) {
|
||||
@@ -1425,7 +1522,12 @@ router.post(
|
||||
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;
|
||||
// A shop at a place sells to whoever has gone there, while that place is part of the scene.
|
||||
if (ware.locationId) {
|
||||
if (req.encounter.mode !== 'FREE_ROAM') throw new TradeError('That is not for sale here.', 404);
|
||||
await assertAtPlace(req, character, ware.locationId, 'That shop');
|
||||
}
|
||||
const seller = ware.npcId ? (await loadTradingNpc(req, ware.npcId, character)).name : null;
|
||||
const kind = ware.weapon ? 'weapon' : 'item';
|
||||
const entry = ware[kind];
|
||||
const total = ware.price * quantity;
|
||||
|
||||
+59
-18
@@ -11,7 +11,7 @@ This describes the code as it stands. The rules live in
|
||||
## Contents
|
||||
|
||||
1. [The basics](#1-the-basics)
|
||||
2. [Battle or quest](#2-battle-or-quest)
|
||||
2. [Battle, quest or free roam](#2-battle-quest-or-free-roam)
|
||||
3. [Status](#3-status)
|
||||
4. [The scene and DM notes](#4-the-scene-and-dm-notes)
|
||||
5. [Who is in the scene](#5-who-is-in-the-scene)
|
||||
@@ -28,8 +28,8 @@ This describes the code as it stands. The rules live in
|
||||
|
||||
## 1. The basics
|
||||
|
||||
- An encounter is one **scene** the DM runs: a fight, or a meeting with
|
||||
people who want something.
|
||||
- An encounter is one **scene** the DM runs: a fight, a meeting with people
|
||||
who want something, or a stretch of free roaming between places.
|
||||
- Encounters belong to a campaign and are listed on the campaign page. The DM
|
||||
creates them and sees all of them.
|
||||
- A player only sees an encounter that **one of their own characters is in**.
|
||||
@@ -39,31 +39,62 @@ This describes the code as it stands. The rules live in
|
||||
- The encounter page updates live for everyone who has it open.
|
||||
- The DM runs everything. Players watch, and see less than the DM does.
|
||||
|
||||
## 2. Battle or quest
|
||||
## 2. Battle, quest or free roam
|
||||
|
||||
Every encounter has a mode, chosen when it is created and switchable at any
|
||||
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; 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 |
|
||||
| | Battle | NPC quest | Free roam |
|
||||
|---|---|---|---|
|
||||
| For | A fight | A scene built around people and an objective | The party wandering between places: shopping, meeting people |
|
||||
| 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" | A card per place, then the party |
|
||||
| Rounds and turns | Yes | No | No |
|
||||
| Initiative | The d20 roll, typed in per combatant; the bonus is added | The same; each group lists highest first | Not needed |
|
||||
| Start button | Begin Battle | Begin Quest | Begin Roaming |
|
||||
| End button | End Battle | Complete Quest | End Free Roam |
|
||||
| Trade (section 10) | No | Yes, while in progress, with the NPCs in the scene | Yes, while in progress, at the place a character has gone to |
|
||||
| Who is in it | Whoever the DM adds | Whoever the DM adds | The whole party, from the start |
|
||||
|
||||
Everything else (hit points, conditions, rewards, the scene description, DM
|
||||
notes) works the same in both.
|
||||
notes) works the same in all three.
|
||||
|
||||
### Free roam
|
||||
|
||||
A free-roam encounter lays out **places the party can go to**: some of the
|
||||
campaign's locations, picked by the DM as the ones nearby.
|
||||
|
||||
- **Creating one** puts every finished, living character in it straight away,
|
||||
so every player sees it in their list without being added.
|
||||
- **Places.** On its page the DM ticks which of the campaign's locations are
|
||||
part of the scene ("Places in this scene"), and can tick more, or untick
|
||||
some, while it is in progress. Locations are made on the campaign page.
|
||||
- **Each place is a card** showing its description, who of the party is there
|
||||
now, the **NPCs found there** (name, role, description, what they sell and
|
||||
the rate they change money at), and the place's **own shop**. Everyone in
|
||||
the scene sees every place in it, whether or not they have been there.
|
||||
- **Going somewhere.** Once the DM presses Begin Roaming, a player picks their
|
||||
character and presses **Go here** on a place: the character is now there
|
||||
(it shows on their sheet too, and the place joins the ones they know). The
|
||||
DM can send anyone.
|
||||
- **Shopping.** A character buys from a place's shop, and buys from or changes
|
||||
money with an NPC found there, only while they are **at that place**. The
|
||||
Buy button says so otherwise.
|
||||
- **Setting it up (DM).** A place's own shop is stocked from its card ("Set up
|
||||
this place's own shop"): weapons and items from the campaign's pools, with a
|
||||
price and stock, exactly as an NPC's wares are. It belongs to the location,
|
||||
so it is there in every free-roam scene that includes the place. An NPC is
|
||||
put at a place with **Found at** on the NPC's pop-out
|
||||
([NPCS.md](NPCS.md)); what they sell is set there too.
|
||||
- An encounter's own location (the picker at the top) does not pull the party
|
||||
together in free roam: each character is where they were sent.
|
||||
|
||||
## 3. Status
|
||||
|
||||
| Status | Meaning | How to get there |
|
||||
|---|---|---|
|
||||
| Planning | Being set up | Every new encounter starts here |
|
||||
| In progress | Being played | Begin Battle / Begin Quest, or Reopen |
|
||||
| Complete | Finished | End Battle / Complete Quest |
|
||||
| In progress | Being played | Begin Battle / Begin Quest / Begin Roaming, or Reopen |
|
||||
| Complete | Finished | End Battle / Complete Quest / End Free Roam |
|
||||
|
||||
Status is a label. Nothing is locked when an encounter is complete: the DM
|
||||
can still change HP, add people and award rewards. Encounters cannot be
|
||||
@@ -96,6 +127,13 @@ with three pages or more, a menu to jump to any page), 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.
|
||||
|
||||
**Completed encounters are hidden.** An encounter that is **Complete** is
|
||||
left off the list, for the DM and the players alike, so the list is what is
|
||||
still to play. A **Show completed (N)** tick box above the list brings them
|
||||
back; the choice is remembered per browser. ▲ and ▼ move an encounter past its
|
||||
neighbour on the list as shown. Opening a completed encounter from a link
|
||||
still works, and nothing about it changes.
|
||||
|
||||
**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
|
||||
@@ -624,8 +662,9 @@ characters' sheets. XP never levels anyone up by itself
|
||||
|
||||
## 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:
|
||||
In an NPC quest the party can **change money** and **buy things**. (In free
|
||||
roam they do the same at the place each character has gone to: section 2,
|
||||
"Free roam".) 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
|
||||
@@ -700,6 +739,8 @@ information cannot be dug out of the page.
|
||||
| Change HP or conditions | Yes | No |
|
||||
| Enter an initiative roll | Yes, for anyone | For their own character only |
|
||||
| Mark targets | Yes, any time | On their own character's turn |
|
||||
| Pick a free-roam scene's places, stock a place's shop | Yes | No |
|
||||
| Send a character to a place in free roam | Yes, anyone | Their own character |
|
||||
| Declare an attack or a spell | Yes, for anyone, and anything spelled out | For their own character, on its turn, with its weapons and spells |
|
||||
| Enter or roll an attack's d20 and damage | Yes | For their own character's attack |
|
||||
| Enter or roll a saving throw | Yes, for anyone | For their own character |
|
||||
|
||||
@@ -50,6 +50,13 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
|
||||
A new NPC is Neutral unless you choose otherwise.
|
||||
|
||||
**Found at.** An NPC can be given one of the campaign's locations as the place
|
||||
they are usually found, on their pop-out. In a **free-roam** encounter that
|
||||
includes that place, the NPC appears on its card, and a character who goes
|
||||
there can buy from them and change money with them
|
||||
([ENCOUNTERS.md](ENCOUNTERS.md), section 2, "Free roam"). It changes nothing in
|
||||
a quest or a battle, where an NPC takes part because the DM added them.
|
||||
|
||||
## 3. Managing NPCs
|
||||
|
||||
The NPC list is the "NPCs" part of the campaign page (DM only). Each NPC is a
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from './client';
|
||||
import type {
|
||||
Ability,
|
||||
ActionPatch,
|
||||
ActionSource,
|
||||
BuildOptions,
|
||||
@@ -646,6 +647,11 @@ export function usePatchPoolEntry(campaignId: string, pool: Pool) {
|
||||
exchangeCash?: number | null;
|
||||
effects?: GearEffect[];
|
||||
effectNote?: string | null;
|
||||
// A spell: how it lands in a battle. An NPC: where they are found.
|
||||
attackRoll?: boolean;
|
||||
saveAbility?: Ability | null;
|
||||
halfOnSave?: boolean;
|
||||
locationId?: string | null;
|
||||
}) =>
|
||||
(await apiClient.patch(`/campaigns/${campaignId}/${pool}/${id}`, data)).data,
|
||||
onSuccess: () => {
|
||||
@@ -840,7 +846,7 @@ export function useCreateEncounter(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
// Where it goes in the campaign's order: the top, the bottom (the default), or straight after another.
|
||||
mutationFn: async (data: { name: string; mode?: EncounterMode; position?: 'TOP' | 'BOTTOM'; afterEncounterId?: string }) =>
|
||||
mutationFn: async (data: { name: string; mode?: EncounterMode; position?: 'TOP' | 'BOTTOM'; afterEncounterId?: string; placeIds?: string[] }) =>
|
||||
(await apiClient.post<{ encounter: Encounter }>(`/campaigns/${campaignId}/encounters`, data)).data.encounter,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] }),
|
||||
});
|
||||
@@ -986,6 +992,28 @@ export function useSetTargets(encounterId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Free roam: the DM sets the places the party can go to in the scene.
|
||||
export function useSetPlaces(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (locationIds: string[]) => (await apiClient.put(`/encounters/${encounterId}/places`, { locationIds })).data,
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
// Free roam: a character goes to one of the scene's places.
|
||||
export function useVisitPlace(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { characterId: string; locationId: string }) => (await apiClient.post(`/encounters/${encounterId}/visit`, data)).data,
|
||||
onSuccess: (data, { characterId }) => {
|
||||
keepEncounter(qc, encounterId, data);
|
||||
qc.invalidateQueries({ queryKey: ['character', characterId] });
|
||||
qc.invalidateQueries({ queryKey: ['campaign'] }); // the places a player knows, and where the party is
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Declares an attack or a spell: by whoever is on turn unless an actor is
|
||||
// named, at the targets marked unless some are given.
|
||||
export function useDeclareAction(encounterId: string) {
|
||||
@@ -1121,7 +1149,7 @@ export function useCreateNpc(campaignId: string) {
|
||||
// in progress, characters in the scene change money and buy
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export type NewWare = ({ npcId: string } | { encounterId: string }) &
|
||||
export type NewWare = ({ npcId: string } | { encounterId: string } | { locationId: 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.
|
||||
|
||||
@@ -584,7 +584,8 @@ export interface MonsterAttack {
|
||||
}
|
||||
|
||||
export type EncounterStatus = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
|
||||
export type EncounterMode = 'BATTLE' | 'QUEST';
|
||||
// A fight in initiative order, a quest scene around NPCs, or free roam between places the DM has laid out.
|
||||
export type EncounterMode = 'BATTLE' | 'QUEST' | 'FREE_ROAM';
|
||||
export type NpcDisposition = 'FRIENDLY' | 'NEUTRAL' | 'HOSTILE';
|
||||
|
||||
// Something for sale: a weapon or item from the campaign pools, at a price.
|
||||
@@ -594,6 +595,7 @@ export interface SaleOffer {
|
||||
campaignId: string;
|
||||
npcId: string | null;
|
||||
encounterId: string | null;
|
||||
locationId: string | null; // a shop that is always at this place
|
||||
weaponId: string | null;
|
||||
itemId: string | null;
|
||||
price: number; // for one, in `currency`
|
||||
@@ -616,6 +618,18 @@ export interface NpcPublic {
|
||||
exchangeGold: number | null;
|
||||
exchangeCash: number | null;
|
||||
wares?: SaleOffer[]; // what they have for sale
|
||||
locationId?: string | null; // where they are usually found: met there in a free-roam scene
|
||||
}
|
||||
|
||||
// A place the party can go to in a free-roam scene: who is found there, what
|
||||
// is for sale there, and which of the characters in the scene are there now.
|
||||
export interface FreeRoamPlace {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
wares: SaleOffer[];
|
||||
npcs: (NpcPublic & Partial<Npc>)[];
|
||||
here: { characterId: string; name: string; userId: string | null }[];
|
||||
}
|
||||
|
||||
// The DM's full record. dmNotes and the combat stats never reach a player.
|
||||
@@ -802,7 +816,8 @@ export interface EncounterParticipant {
|
||||
// hidden from the players. Only the DM is ever sent one of these.
|
||||
hidden: boolean;
|
||||
npcId: string | null;
|
||||
character?: { id: string; name: string; userId: string | null } | null;
|
||||
// currentLocationId is sent in a free-roam scene only: where they have gone.
|
||||
character?: { id: string; name: string; userId: string | null; currentLocationId?: string | null } | null;
|
||||
// The DM gets the whole stat block; players only the id and name.
|
||||
monster?: ({ id: string; name: string } & Partial<Monster>) | null;
|
||||
npc?: (NpcPublic & Partial<Npc>) | null;
|
||||
@@ -838,6 +853,7 @@ export interface Encounter {
|
||||
// hidden ones), and the last few that landed.
|
||||
actions?: EncounterAction[];
|
||||
recentActions?: EncounterAction[];
|
||||
places?: FreeRoamPlace[]; // a free-roam scene's places; absent for any other kind
|
||||
rewards?: EncounterReward[]; // players only receive the awarded ones
|
||||
wares?: SaleOffer[]; // for sale at the scene itself, not by an NPC in it
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { CharacterSummary, Encounter, Item, SaleOffer, Weapon } from '../ap
|
||||
|
||||
type ExchangeSide = 'GOLD' | 'CASH';
|
||||
|
||||
// (A free-roam scene has its own panel: components/encounter/FreeRoam.tsx.)
|
||||
// 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
|
||||
|
||||
@@ -73,8 +73,8 @@ function WareRow({ campaignId, ware, onError }: { campaignId: string; ware: Sale
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// The DM's list of what one seller has for sale — an NPC, an encounter
|
||||
// itself, or a location (a shop that is always there) — with a form to put something from the campaign pools up for sale.
|
||||
export function WaresEditor({
|
||||
campaignId,
|
||||
seller,
|
||||
@@ -84,7 +84,7 @@ export function WaresEditor({
|
||||
onError,
|
||||
}: {
|
||||
campaignId: string;
|
||||
seller: { npcId: string } | { encounterId: string };
|
||||
seller: { npcId: string } | { encounterId: string } | { locationId: string };
|
||||
wares: SaleOffer[];
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useDeleteMonster, useDeletePoolEntry, usePatchPoolEntry, useUpdateMonster } from '../../api/queries';
|
||||
import { useCampaignLocations, useDeleteMonster, useDeletePoolEntry, usePatchPoolEntry, useUpdateMonster } from '../../api/queries';
|
||||
import { Modal } from '../Modal';
|
||||
import { NumberInput } from '../NumberInput';
|
||||
import { ErrorBanner } from '../Spinner';
|
||||
@@ -230,6 +230,7 @@ function MonsterDetail({ campaignId, monster, added, onClose }: { added: ReactNo
|
||||
function NpcDetail({ campaignId, npc, weapons, items, added, onClose }: { added: ReactNode; campaignId: string; npc: Npc; weapons: Weapon[]; items: Item[]; onClose: () => void }) {
|
||||
const patch = usePatchPoolEntry(campaignId, 'npcs');
|
||||
const remove = useDeletePoolEntry(campaignId, 'npcs');
|
||||
const { data: locations } = useCampaignLocations(campaignId);
|
||||
const [gold, setGold] = useState(npc.exchangeGold ? String(npc.exchangeGold) : '');
|
||||
const [cash, setCash] = useState(npc.exchangeCash ? String(npc.exchangeCash) : '');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -263,6 +264,26 @@ function NpcDetail({ campaignId, npc, weapons, items, added, onClose }: { added:
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="section-label mt-md">Found at</div>
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
Where the party meets {npc.name} in a free-roam scene: whoever goes to that place can talk and trade with them there. (In a quest, they
|
||||
are met when you add them to it.)
|
||||
</p>
|
||||
<select
|
||||
aria-label={`Where ${npc.name} is found`}
|
||||
value={npc.locationId ?? ''}
|
||||
disabled={patch.isPending}
|
||||
onChange={(e) => patch.mutate({ id: npc.id, locationId: e.target.value || null }, { onError })}
|
||||
style={{ maxWidth: 320 }}
|
||||
>
|
||||
<option value="">Nowhere in particular</option>
|
||||
{(locations ?? []).map((loc) => (
|
||||
<option key={loc.id} value={loc.id}>
|
||||
📍 {loc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="section-label mt-md">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
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useBuyWare, useExchangeWithNpc, useSetPlaces, useVisitPlace } 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, FreeRoamPlace, Item, Location, SaleOffer, Weapon } from '../../api/types';
|
||||
|
||||
type ExchangeSide = 'GOLD' | 'CASH';
|
||||
|
||||
// A free-roam scene: the places the DM has laid out, each with who is found
|
||||
// there and what is for sale, and who of the party is there now. A player
|
||||
// sends their own character to a place and shops where they stand; the DM
|
||||
// can send anyone, picks which of the campaign's locations are part of the
|
||||
// scene, and stocks each place's own shop here. (What an NPC sells, and where
|
||||
// they are found, is set on the NPC, on the campaign page.)
|
||||
export function FreeRoamPanel({
|
||||
encounter,
|
||||
isDM,
|
||||
userId,
|
||||
characters,
|
||||
locations,
|
||||
weapons,
|
||||
items,
|
||||
onError,
|
||||
}: {
|
||||
encounter: Encounter;
|
||||
isDM: boolean;
|
||||
userId: string | undefined;
|
||||
characters: CharacterSummary[];
|
||||
locations: Location[]; // the campaign's, for the DM to pick places from
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const setPlaces = useSetPlaces(encounter.id);
|
||||
const visit = useVisitPlace(encounter.id);
|
||||
const buy = useBuyWare(encounter.id);
|
||||
const exchange = useExchangeWithNpc(encounter.id);
|
||||
const [roamerId, setRoamerId] = useState('');
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
const [amounts, setAmounts] = useState<Record<string, string>>({});
|
||||
const [sides, setSides] = useState<Record<string, ExchangeSide>>({});
|
||||
|
||||
const places = encounter.places ?? [];
|
||||
const open = encounter.status === 'ACTIVE';
|
||||
// Who can be moved and can shop: a player's own characters in the scene; for the DM, anyone's.
|
||||
const roamers = (encounter.participants ?? []).flatMap((p) => (p.character && !p.hidden && (isDM || p.character.userId === userId) ? [p.character] : []));
|
||||
const roamer = roamers.find((c) => c.id === roamerId) ?? roamers[0];
|
||||
const purse = characters.find((c) => c.id === roamer?.id);
|
||||
const whereabouts = places.find((place) => place.here.some((h) => h.characterId === roamer?.id));
|
||||
const busy = visit.isPending || buy.isPending || exchange.isPending;
|
||||
const placeIds = places.map((p) => p.id);
|
||||
|
||||
function go(place: FreeRoamPlace) {
|
||||
if (!roamer) return;
|
||||
setDone(null);
|
||||
visit.mutate({ characterId: roamer.id, locationId: place.id }, { onError, onSuccess: () => setDone(`${roamer.name} goes to ${place.name}.`) });
|
||||
}
|
||||
|
||||
function handleBuy(ware: SaleOffer) {
|
||||
if (!roamer) return;
|
||||
setDone(null);
|
||||
buy.mutate(
|
||||
{ wareId: ware.id, characterId: roamer.id },
|
||||
{ onError, onSuccess: () => setDone(`${roamer.name} bought ${(ware.weapon ?? ware.item)?.name} for ${formatMoney(ware.price, ware.currency, true)}.`) }
|
||||
);
|
||||
}
|
||||
|
||||
const wareRow = (ware: SaleOffer, atPlace: boolean) => (
|
||||
<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 || !roamer || !atPlace || ware.stock === 0 || busy}
|
||||
title={!open ? 'The scene has not begun' : !atPlace ? `${roamer?.name ?? 'Your character'} has to go here first` : undefined}
|
||||
onClick={() => handleBuy(ware)}
|
||||
>
|
||||
Buy · {formatMoney(ware.price, ware.currency)}
|
||||
</button>
|
||||
</div>
|
||||
{ware.item?.description && <div className="combatant-sub">{ware.item.description}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<div className="card-title">
|
||||
<span>🧭 Places to go</span>
|
||||
{!open && <span className="badge">{encounter.status === 'PLANNING' ? 'Not begun yet' : 'Over'}</span>}
|
||||
</div>
|
||||
{roamers.length > 0 ? (
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
<label className="flex-row gap-sm" style={{ flex: 1, minWidth: 180 }}>
|
||||
{roamers.length > 1 ? 'Moving' : 'You are'}
|
||||
<select aria-label="Which character is moving and shopping" value={roamer?.id ?? ''} onChange={(e) => setRoamerId(e.target.value)} style={{ flex: 1 }}>
|
||||
{roamers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="combatant-sub">📍 {whereabouts ? whereabouts.name : 'not at any of these places yet'}</span>
|
||||
{purse && purse.gold !== null && <span className="mono">{formatPurse(purse)}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<p className="combatant-sub" style={{ margin: 0 }}>
|
||||
{isDM ? 'No characters are in this scene yet: add them from "Add to the scene".' : 'Your character has to be in the scene to go anywhere.'}
|
||||
</p>
|
||||
)}
|
||||
{done && <p className="combatant-sub" style={{ marginBottom: 0 }}>✓ {done}</p>}
|
||||
{places.length === 0 && (
|
||||
<p className="combatant-sub" style={{ marginBottom: 0 }}>
|
||||
{isDM ? 'No places yet. Tick the locations the party can go to, below.' : 'The DM has not opened any places yet.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isDM && (
|
||||
<details className="mt-sm" open={places.length === 0}>
|
||||
<summary className="section-label" style={{ cursor: 'pointer' }}>
|
||||
Places in this scene ({places.length} of {locations.length} locations)
|
||||
</summary>
|
||||
{locations.length === 0 ? (
|
||||
<p className="combatant-sub">
|
||||
This campaign has no locations yet. Add some under Locations on the <Link to={`/campaigns/${encounter.campaignId}`}>campaign page</Link>.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
{locations.map((loc) => {
|
||||
const included = placeIds.includes(loc.id);
|
||||
return (
|
||||
<label key={loc.id} className="checkbox-row" title={loc.description ?? undefined}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={included}
|
||||
disabled={setPlaces.isPending}
|
||||
onChange={() => setPlaces.mutate(included ? placeIds.filter((id) => id !== loc.id) : [...placeIds, loc.id], { onError })}
|
||||
/>
|
||||
📍 {loc.name}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="place-grid">
|
||||
{places.map((place) => {
|
||||
const atPlace = Boolean(roamer) && place.here.some((h) => h.characterId === roamer?.id);
|
||||
return (
|
||||
<div key={place.id} className={`card place-card${atPlace ? ' is-here' : ''}`}>
|
||||
<div className="card-title">
|
||||
<span>📍 {place.name}</span>
|
||||
{atPlace ? (
|
||||
<span className="badge badge-live">{roamer?.name} is here</span>
|
||||
) : (
|
||||
<button className="btn btn-primary" disabled={!open || !roamer || busy} onClick={() => go(place)}>
|
||||
Go here
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{place.description && <p style={{ margin: '0 0 0.4rem', whiteSpace: 'pre-wrap' }}>{place.description}</p>}
|
||||
<div className="combatant-sub">{place.here.length > 0 ? `Here now: ${place.here.map((h) => h.name).join(', ')}` : 'Nobody from the party is here.'}</div>
|
||||
|
||||
{place.npcs.map((npc) => {
|
||||
const rate = { GOLD: npc.exchangeGold ?? 0, CASH: npc.exchangeCash ?? 0 };
|
||||
const changes = rate.GOLD > 0 && rate.CASH > 0;
|
||||
const from = sides[npc.id] ?? 'GOLD';
|
||||
const to = from === 'GOLD' ? 'CASH' : 'GOLD';
|
||||
const amount = Math.floor(Number(amounts[npc.id]));
|
||||
const whole = changes && amount > 0 && amount % rate[from] === 0;
|
||||
return (
|
||||
<div key={npc.id} className="mt-sm">
|
||||
<div className="section-label">
|
||||
🧑 {npc.name}
|
||||
{npc.role ? `, ${npc.role}` : ''}
|
||||
</div>
|
||||
{npc.description && <p className="combatant-sub" style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{npc.description}</p>}
|
||||
{npc.dmNotes && (
|
||||
<p className="combatant-sub" style={{ margin: 0 }} title="Only you can see this">
|
||||
🔒 {npc.dmNotes}
|
||||
</p>
|
||||
)}
|
||||
{(npc.wares ?? []).map((ware) => wareRow(ware, atPlace))}
|
||||
{changes && (
|
||||
<div className="reward-row">
|
||||
<span className="combatant-sub">
|
||||
💱 Changes {formatMoney(rate.GOLD, 'GOLD', true)} for {formatMoney(rate.CASH, 'CASH')}, either way.
|
||||
</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 || !roamer || !atPlace || !whole || busy}
|
||||
onClick={() => {
|
||||
if (!roamer) return;
|
||||
setDone(null);
|
||||
exchange.mutate(
|
||||
{ characterId: roamer.id, npcId: npc.id, from, amount },
|
||||
{
|
||||
onError,
|
||||
onSuccess: ({ received, currency }) => {
|
||||
setDone(`${roamer.name} changed ${formatMoney(amount, from, true)} for ${formatMoney(received, currency, true)} with ${npc.name}.`);
|
||||
setAmounts((a) => ({ ...a, [npc.id]: '' }));
|
||||
},
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
{whole ? `Get ${formatMoney((amount / rate[from]) * rate[to], to, true)}` : 'Exchange'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(npc.wares ?? []).length === 0 && !changes && <div className="combatant-sub">Nothing to sell: someone to talk to.</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{place.wares.length > 0 && (
|
||||
<div className="mt-sm">
|
||||
<div className="section-label">🛒 For sale here</div>
|
||||
{place.wares.map((ware) => wareRow(ware, atPlace))}
|
||||
</div>
|
||||
)}
|
||||
{place.npcs.length === 0 && place.wares.length === 0 && !isDM && <div className="combatant-sub mt-sm">Nothing to buy here.</div>}
|
||||
|
||||
{isDM && (
|
||||
<details className="mt-sm">
|
||||
<summary className="section-label" style={{ cursor: 'pointer' }}>
|
||||
Set up this place’s own shop
|
||||
</summary>
|
||||
<p className="combatant-sub" style={{ marginTop: '0.3rem' }}>
|
||||
Sold here whenever this place is part of a free-roam scene. To have an NPC found here, set their location on the NPC (campaign page).
|
||||
</p>
|
||||
<WaresEditor campaignId={encounter.campaignId} seller={{ locationId: place.id }} wares={place.wares} weapons={weapons} items={items} onError={onError} />
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -978,6 +978,22 @@ input[type='radio'] {
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
/* Free roam: a card per place the party can go to. */
|
||||
.place-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 0.9rem;
|
||||
margin-top: 0.9rem;
|
||||
align-items: start;
|
||||
}
|
||||
.place-card {
|
||||
margin: 0;
|
||||
}
|
||||
.place-card.is-here {
|
||||
border-color: var(--accent-strong);
|
||||
box-shadow: 0 0 14px rgba(201, 162, 39, 0.2);
|
||||
}
|
||||
|
||||
/* What the scene is waiting on, in a sentence. */
|
||||
.cue-card {
|
||||
display: flex;
|
||||
|
||||
@@ -68,6 +68,17 @@ function storedEncounterSort(): EncounterSort {
|
||||
const ENCOUNTER_PLACE_KEY = 'dnd_encounter_place';
|
||||
const AFTER = 'after:';
|
||||
|
||||
// Finished encounters are left off the list until asked for; the choice is remembered.
|
||||
const SHOW_COMPLETED_KEY = 'dnd_show_completed_encounters';
|
||||
|
||||
function storedShowCompleted(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(SHOW_COMPLETED_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function storedEncounterPlace(): string {
|
||||
return localStorage.getItem(ENCOUNTER_PLACE_KEY) === 'TOP' ? 'TOP' : 'BOTTOM';
|
||||
}
|
||||
@@ -80,7 +91,7 @@ function sortEncounters(encounters: Encounter[], sort: EncounterSort): Encounter
|
||||
custom: () => 0,
|
||||
name: (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
|
||||
status: (a, b) => STATUS_RANK[a.status] - STATUS_RANK[b.status],
|
||||
type: (a, b) => Number(a.mode === 'QUEST') - Number(b.mode === 'QUEST'),
|
||||
type: (a, b) => MODE_RANK[a.mode] - MODE_RANK[b.mode],
|
||||
newest: (a, b) => time(b) - time(a),
|
||||
oldest: (a, b) => time(a) - time(b),
|
||||
};
|
||||
@@ -135,7 +146,8 @@ function CharacterRow({ character, isDM, mine = false, onDelete }: { character:
|
||||
);
|
||||
}
|
||||
|
||||
const MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest' };
|
||||
const MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest', FREE_ROAM: '🧭 Free roam' };
|
||||
const MODE_RANK: Record<EncounterMode, number> = { BATTLE: 0, QUEST: 1, FREE_ROAM: 2 };
|
||||
|
||||
// A long note, cut down to a line for a search result.
|
||||
const snippet = (text: string) => (text.length > 110 ? `${text.slice(0, 110).trimEnd()}…` : text);
|
||||
@@ -193,13 +205,16 @@ export function CampaignHomePage() {
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [resting, setResting] = useState(false);
|
||||
const [levelling, setLevelling] = useState(false);
|
||||
const [showCompleted, setShowCompleted] = useState(storedShowCompleted);
|
||||
const [deleteConfirmName, setDeleteConfirmName] = useState('');
|
||||
|
||||
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
|
||||
// An encounter picked to go after may have been archived or deleted since.
|
||||
const encounterPlace = newEncounterPlace.startsWith(AFTER) && !liveEncounters.some((e) => AFTER + e.id === newEncounterPlace) ? 'BOTTOM' : newEncounterPlace;
|
||||
const archivedEncounters = encounters?.filter((e) => e.archivedAt) || [];
|
||||
const shownEncounters = sortEncounters(liveEncounters, encounterSort);
|
||||
// Complete encounters are hidden unless "Show completed" is ticked.
|
||||
const completedCount = liveEncounters.filter((e) => e.status === 'COMPLETE').length;
|
||||
const shownEncounters = sortEncounters(showCompleted ? liveEncounters : liveEncounters.filter((e) => e.status !== 'COMPLETE'), encounterSort);
|
||||
const encountersPage = usePagination(shownEncounters, 10);
|
||||
const archivedEncountersPage = usePagination(archivedEncounters, 10);
|
||||
const sharedNotes = notes?.filter((n) => n.visibility === 'SHARED');
|
||||
@@ -243,11 +258,15 @@ export function CampaignHomePage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Swaps an encounter with its neighbour in the campaign's own order.
|
||||
function moveEncounter(index: number, step: -1 | 1) {
|
||||
// Swaps an encounter with its neighbour on the list as shown (which may
|
||||
// leave the completed ones out) in the campaign's own order.
|
||||
function moveEncounter(shownIndex: number, step: -1 | 1) {
|
||||
const neighbour = shownEncounters[shownIndex + step];
|
||||
const moving = shownEncounters[shownIndex];
|
||||
if (!neighbour || !moving) return;
|
||||
const ids = liveEncounters.map((e) => e.id);
|
||||
const target = index + step;
|
||||
if (target < 0 || target >= ids.length) return;
|
||||
const index = ids.indexOf(moving.id);
|
||||
const target = ids.indexOf(neighbour.id);
|
||||
[ids[index], ids[target]] = [ids[target], ids[index]];
|
||||
reorderEncounters.mutate([...ids, ...archivedEncounters.map((e) => e.id)], {
|
||||
onError: (err) => setFormError(extractErrorMessage(err)),
|
||||
@@ -511,6 +530,23 @@ export function CampaignHomePage() {
|
||||
<div className="section-block">
|
||||
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
|
||||
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>Encounters</h2>
|
||||
{completedCount > 0 && (
|
||||
<label className="checkbox-row subtitle" title="Encounters that are complete are left off the list until this is ticked">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showCompleted}
|
||||
onChange={(e) => {
|
||||
setShowCompleted(e.target.checked);
|
||||
try {
|
||||
localStorage.setItem(SHOW_COMPLETED_KEY, e.target.checked ? '1' : '0');
|
||||
} catch {
|
||||
// Private browsing: the choice lasts as long as the page.
|
||||
}
|
||||
}}
|
||||
/>
|
||||
Show completed ({completedCount})
|
||||
</label>
|
||||
)}
|
||||
{liveEncounters.length > 1 && (
|
||||
<label className="flex-row subtitle">
|
||||
Sort by
|
||||
@@ -542,6 +578,7 @@ export function CampaignHomePage() {
|
||||
<select aria-label="Encounter type" value={newEncounterMode} onChange={(e) => setNewEncounterMode(e.target.value as EncounterMode)}>
|
||||
<option value="BATTLE">Battle</option>
|
||||
<option value="QUEST">NPC quest</option>
|
||||
<option value="FREE_ROAM">Free roam</option>
|
||||
</select>
|
||||
{liveEncounters.length > 0 && (
|
||||
<select
|
||||
@@ -566,7 +603,7 @@ export function CampaignHomePage() {
|
||||
</select>
|
||||
)}
|
||||
<button type="submit" className="btn btn-primary" disabled={createEncounter.isPending}>
|
||||
{newEncounterMode === 'QUEST' ? '📜 Start Quest' : '⚔ Start Encounter'}
|
||||
{newEncounterMode === 'QUEST' ? '📜 Start Quest' : newEncounterMode === 'FREE_ROAM' ? '🧭 Start Free Roam' : '⚔ Start Encounter'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
@@ -579,6 +616,9 @@ export function CampaignHomePage() {
|
||||
{encounters && liveEncounters.length === 0 && (
|
||||
<div className="empty-state">{archivedEncounters.length > 0 ? 'Every encounter is archived.' : 'No encounters yet.'}</div>
|
||||
)}
|
||||
{liveEncounters.length > 0 && shownEncounters.length === 0 && (
|
||||
<div className="empty-state">Every encounter here is complete. Tick “Show completed” to see them.</div>
|
||||
)}
|
||||
{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);
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useAuth } from '../auth/useAuth';
|
||||
import { RollFeed } from '../components/RollFeed';
|
||||
import { InitiativeTrack } from '../components/encounter/InitiativeTrack';
|
||||
import { ActionCard, ActionComposer, RecentActions } from '../components/encounter/ActionPanel';
|
||||
import { FreeRoamPanel } from '../components/encounter/FreeRoam';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
@@ -72,7 +73,7 @@ const areaPenalty = (targetCount: number, step: number) => Math.max(0, targetCou
|
||||
|
||||
const KIND_LABELS = { CHARACTER: 'PC', MONSTER: 'Monster', NPC: 'NPC' } as const;
|
||||
const KIND_BADGES = { CHARACTER: 'badge-player', MONSTER: 'badge-hostile', NPC: 'badge-friendly' } as const;
|
||||
const MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest' };
|
||||
const MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest', FREE_ROAM: '🧭 Free roam' };
|
||||
const STATUS_LABELS = { PLANNING: 'Planning', ACTIVE: 'In progress', COMPLETE: 'Complete' } as const;
|
||||
|
||||
function StatChip({ label, children }: { label: string; children: ReactNode }) {
|
||||
@@ -1380,6 +1381,7 @@ export function EncounterPage() {
|
||||
if (error || !encounter) return <ErrorBanner message="Could not load this encounter." />;
|
||||
|
||||
const isBattle = encounter.mode === 'BATTLE';
|
||||
const isRoam = encounter.mode === 'FREE_ROAM';
|
||||
const participants = encounter.participants ?? []; // already in initiative order
|
||||
// The fallen: dead characters, and monsters and NPCs at 0 HP. Only the DM is
|
||||
// sent their cards, and they sit apart from those still in the fight.
|
||||
@@ -1507,7 +1509,7 @@ export function EncounterPage() {
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>
|
||||
{isBattle ? '⚔' : '📜'} {encounter.name}
|
||||
{isBattle ? '⚔' : isRoam ? '🧭' : '📜'} {encounter.name}
|
||||
</h1>
|
||||
<div className="flex-row flex-wrap">
|
||||
<span className="badge badge-dm">{MODE_LABELS[encounter.mode]}</span>
|
||||
@@ -1539,6 +1541,7 @@ export function EncounterPage() {
|
||||
>
|
||||
<option value="BATTLE">Battle</option>
|
||||
<option value="QUEST">NPC quest</option>
|
||||
<option value="FREE_ROAM">Free roam</option>
|
||||
</select>
|
||||
<select
|
||||
aria-label="Location"
|
||||
@@ -1560,7 +1563,7 @@ export function EncounterPage() {
|
||||
)}
|
||||
{encounter.status === 'PLANNING' && (
|
||||
<button className="btn btn-primary" onClick={() => patchEncounter.mutate({ status: 'ACTIVE' }, fail)}>
|
||||
{isBattle ? 'Begin Battle' : 'Begin Quest'}
|
||||
{isBattle ? 'Begin Battle' : isRoam ? 'Begin Roaming' : 'Begin Quest'}
|
||||
</button>
|
||||
)}
|
||||
{encounter.status === 'ACTIVE' && isBattle && (
|
||||
@@ -1580,7 +1583,7 @@ export function EncounterPage() {
|
||||
)}
|
||||
{encounter.status === 'ACTIVE' && (
|
||||
<button className="btn btn-danger" onClick={() => patchEncounter.mutate({ status: 'COMPLETE' }, fail)}>
|
||||
{isBattle ? 'End Battle' : 'Complete Quest'}
|
||||
{isBattle ? 'End Battle' : isRoam ? 'End Free Roam' : 'Complete Quest'}
|
||||
</button>
|
||||
)}
|
||||
{encounter.status === 'COMPLETE' && (
|
||||
@@ -1654,7 +1657,7 @@ export function EncounterPage() {
|
||||
|
||||
{(isDM || encounter.description) && (
|
||||
<div className="card">
|
||||
<div className="card-title">{isBattle ? 'The Scene' : 'Quest'}</div>
|
||||
<div className="card-title">{isBattle ? 'The Scene' : isRoam ? 'Where the party is' : 'Quest'}</div>
|
||||
{isDM ? (
|
||||
<textarea
|
||||
key={`description:${encounter.description ?? ''}`}
|
||||
@@ -1665,7 +1668,9 @@ export function EncounterPage() {
|
||||
placeholder={
|
||||
isBattle
|
||||
? 'Describe the battlefield: terrain, cover, hazards… (shown to players)'
|
||||
: 'What is being asked of the party, and by whom? (shown to players)'
|
||||
: isRoam
|
||||
? 'Where the party finds itself, and what is around to visit… (shown to players)'
|
||||
: 'What is being asked of the party, and by whom? (shown to players)'
|
||||
}
|
||||
onBlur={(e) => {
|
||||
const description = e.target.value.trim() || null;
|
||||
@@ -1678,6 +1683,19 @@ export function EncounterPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRoam && (
|
||||
<FreeRoamPanel
|
||||
encounter={encounter}
|
||||
isDM={isDM}
|
||||
userId={user?.id}
|
||||
characters={builtCharacters}
|
||||
locations={locations ?? []}
|
||||
weapons={weapons ?? []}
|
||||
items={items ?? []}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="encounter-layout mt-md">
|
||||
<div>
|
||||
{actions.map((action) => (
|
||||
|
||||
Generated
+4
-4
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.0",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.0",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@prisma/client": "^7.10.0",
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"axios": "^1.20.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.0",
|
||||
"private": true,
|
||||
"description": "D&D 5e campaign manager — monorepo root",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user