Compare commits

...
1 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 555765916f V1.28.0: free roam, and completed encounters out of the way
- Free roam: a third kind of encounter. 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
- A place shows who is found there and what is for sale; a character shops
  (a place's own shop, or an NPC's wares and exchange) only where they stand
- Locations can have a shop of their own; an NPC can be "found at" a location
- Completed encounters are hidden from the campaign's list until "Show
  completed" is ticked; moving an encounter works on the list as shown

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:30:16 +02:00
21 changed files with 823 additions and 69 deletions
+6
View File
@@ -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 -1
View File
@@ -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;
+38 -7
View File
@@ -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();
});
});
+41 -4
View File
@@ -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.` });
+113 -11
View File
@@ -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
View File
@@ -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 |
+7
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.27.0",
"version": "1.28.0",
"type": "module",
"scripts": {
"dev": "vite",
+30 -2
View File
@@ -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.
+18 -2
View File
@@ -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
}
+1
View File
@@ -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
+3 -3
View File
@@ -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>
);
}
+16
View File
@@ -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;
+48 -8
View File
@@ -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);
+24 -6
View File
@@ -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) => (
+4 -4
View File
@@ -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
View File
@@ -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": [