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>
This commit is contained in:
2026-10-12 09:30:16 +02:00
co-authored by Claude Opus 5.5
parent 9b3637576d
commit 555765916f
21 changed files with 823 additions and 69 deletions
+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;