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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Free roam: a kind of encounter where the party wanders between places the
|
||||
-- DM has laid out. A location can have NPCs found there and things for sale.
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "EncounterMode" ADD VALUE 'FREE_ROAM';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "npcs" ADD COLUMN "locationId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "sale_offers" ADD COLUMN "locationId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "encounter_locations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"encounterId" TEXT NOT NULL,
|
||||
"locationId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "encounter_locations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "encounter_locations_encounterId_locationId_key" ON "encounter_locations"("encounterId", "locationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sale_offers_locationId_idx" ON "sale_offers"("locationId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "npcs" ADD CONSTRAINT "npcs_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_locations" ADD CONSTRAINT "encounter_locations_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_locations" ADD CONSTRAINT "encounter_locations_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -134,11 +134,13 @@ enum EncounterStatus {
|
||||
COMPLETE
|
||||
}
|
||||
|
||||
// What kind of scene an encounter is: a fight run in initiative order, or a
|
||||
// quest scene built around NPCs and an objective.
|
||||
// What kind of scene an encounter is: a fight run in initiative order, a
|
||||
// quest scene built around NPCs and an objective, or free roam, where the
|
||||
// party wanders between places the DM has laid out (to shop, to meet people).
|
||||
enum EncounterMode {
|
||||
BATTLE
|
||||
QUEST
|
||||
FREE_ROAM
|
||||
}
|
||||
|
||||
enum NpcDisposition {
|
||||
@@ -335,6 +337,10 @@ model Location {
|
||||
characters Character[]
|
||||
encounters Encounter[]
|
||||
visits CharacterLocationVisit[]
|
||||
// Who is found here, what is for sale here, and the free-roam scenes it is a stop in.
|
||||
npcs Npc[]
|
||||
wares SaleOffer[]
|
||||
freeRoams EncounterLocation[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("locations")
|
||||
@@ -737,9 +743,13 @@ model Npc {
|
||||
// for exchangeCash cash, either way. Both null: this NPC does not exchange.
|
||||
exchangeGold Int?
|
||||
exchangeCash Int?
|
||||
// Where this NPC is usually found. In a free-roam scene they are met (and
|
||||
// traded with) by whoever goes there.
|
||||
locationId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||
encounterAppearances EncounterParticipant[]
|
||||
wares SaleOffer[]
|
||||
|
||||
@@ -782,11 +792,28 @@ model Encounter {
|
||||
rewards EncounterReward[]
|
||||
wares SaleOffer[]
|
||||
actions EncounterAction[]
|
||||
// Free roam: the places the party can go to in this scene.
|
||||
places EncounterLocation[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("encounters")
|
||||
}
|
||||
|
||||
// A place that is a stop in a free-roam scene: one of the campaign's
|
||||
// locations, picked by the DM as somewhere the party can go right now.
|
||||
model EncounterLocation {
|
||||
id String @id @default(cuid())
|
||||
encounterId String
|
||||
locationId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||||
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([encounterId, locationId])
|
||||
@@map("encounter_locations")
|
||||
}
|
||||
|
||||
// An attack or a spell in a battle, from the moment it is declared to the
|
||||
// moment its damage lands (game/combat.js). A player declares their own on
|
||||
// their turn; the DM declares anyone's; and with the campaign's auto-roll
|
||||
@@ -851,15 +878,17 @@ model EncounterReward {
|
||||
}
|
||||
|
||||
// Something for sale: a weapon or item from the campaign pools, at a price.
|
||||
// It belongs to an NPC (and is on offer wherever that NPC is met) or to an
|
||||
// encounter itself (a stall, a vending machine). Only the DM sets these up;
|
||||
// players see them, and buy, in a quest encounter in progress
|
||||
// (encounters.routes.js).
|
||||
// It belongs to an NPC (and is on offer wherever that NPC is met), to an
|
||||
// encounter itself (a stall, a vending machine), or to a location (a shop
|
||||
// that is always there, open to whoever goes to it in a free-roam scene).
|
||||
// Only the DM sets these up; players see them, and buy, in a quest or
|
||||
// free-roam encounter in progress (encounters.routes.js).
|
||||
model SaleOffer {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
npcId String? // exactly one of npcId / encounterId is set
|
||||
npcId String? // exactly one of npcId / encounterId / locationId is set
|
||||
encounterId String?
|
||||
locationId String?
|
||||
weaponId String? // exactly one of weaponId / itemId is set
|
||||
itemId String?
|
||||
price Int // for one, in `currency`
|
||||
@@ -871,12 +900,14 @@ model SaleOffer {
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
npc Npc? @relation(fields: [npcId], references: [id], onDelete: Cascade)
|
||||
encounter Encounter? @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||
weapon Weapon? @relation(fields: [weaponId], references: [id], onDelete: Cascade)
|
||||
item Item? @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([campaignId])
|
||||
@@index([npcId])
|
||||
@@index([encounterId])
|
||||
@@index([locationId])
|
||||
@@map("sale_offers")
|
||||
}
|
||||
|
||||
|
||||
@@ -4047,3 +4047,88 @@ describe('battle actions', () => {
|
||||
expect(rolled.damage.dice).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('free roam', () => {
|
||||
itIfDb('the party is in it from the start, wanders between its places, and shops where it stands', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('roam');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
const allyId = await buildCharacter(campaign.id, dmAuth, 'Bo');
|
||||
await request(app).post(`/characters/${heroId}/gold`).set(dmAuth).send({ amount: 50, reason: 'Purse' });
|
||||
const place = async (name) => (await request(app).post(`${c}/locations`).set(dmAuth).send({ name, description: `${name}.` })).body.location;
|
||||
const [market, inn, docks] = [await place('Market'), await place('Inn'), await place('Docks')];
|
||||
const rope = (await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Rope' })).body.item;
|
||||
const ale = (await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Ale' })).body.item;
|
||||
|
||||
// A shop at the market, and an innkeeper found at the inn who sells ale.
|
||||
expect((await request(app).post(`${c}/wares`).set(dmAuth).send({ locationId: market.id, itemId: rope.id, price: 2 })).status).toBe(201);
|
||||
expect((await request(app).post(`${c}/wares`).set(dmAuth).send({ locationId: market.id, npcId: 'x', itemId: rope.id, price: 2 })).status).toBe(400);
|
||||
const keeper = (await request(app).post(`${c}/npcs`).set(dmAuth).send({ name: 'Mara', role: 'Innkeeper', dmNotes: 'Waters the ale.', locationId: inn.id })).body.npc;
|
||||
expect((await request(app).post(`${c}/npcs`).set(dmAuth).send({ name: 'Nobody', locationId: 'nowhere' })).status).toBe(400);
|
||||
await request(app).post(`${c}/wares`).set(dmAuth).send({ npcId: keeper.id, itemId: ale.id, price: 1 });
|
||||
|
||||
const created = await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'A day in town', mode: 'FREE_ROAM', placeIds: [market.id, inn.id] });
|
||||
expect(created.status).toBe(201);
|
||||
const e = `/encounters/${created.body.encounter.id}`;
|
||||
const view = async (auth) => (await request(app).get(e).set(auth)).body.encounter;
|
||||
|
||||
// Everyone alive is in it without being added, so the player can open it.
|
||||
let scene = await view(playerAuth);
|
||||
expect(scene.mode).toBe('FREE_ROAM');
|
||||
expect(scene.participants.map((p) => p.displayName).sort()).toEqual(['Aria', 'Bo']);
|
||||
expect(scene.places.map((p) => p.name)).toEqual(['Market', 'Inn']);
|
||||
expect(scene.places[0].wares[0].item.name).toBe('Rope');
|
||||
expect(scene.places[1].npcs[0]).toMatchObject({ name: 'Mara', role: 'Innkeeper' });
|
||||
expect(scene.places[1].npcs[0].dmNotes).toBeUndefined(); // the DM's alone
|
||||
expect(scene.places[1].npcs[0].wares[0].item.name).toBe('Ale');
|
||||
expect((await view(dmAuth)).places[1].npcs[0].dmNotes).toBe('Waters the ale.');
|
||||
|
||||
const visit = (auth, characterId, locationId) => request(app).post(`${e}/visit`).set(auth).send({ characterId, locationId });
|
||||
const buy = (auth, wareId, characterId) => request(app).post(`${e}/wares/${wareId}/buy`).set(auth).send({ characterId });
|
||||
const ropeWare = scene.places[0].wares[0].id;
|
||||
const aleWare = scene.places[1].npcs[0].wares[0].id;
|
||||
|
||||
// Nothing moves, and nothing is sold, before the scene begins.
|
||||
expect((await visit(playerAuth, heroId, market.id)).status).toBe(400);
|
||||
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' });
|
||||
expect((await buy(playerAuth, ropeWare, heroId)).status).toBe(400); // not at the market yet
|
||||
expect((await visit(playerAuth, heroId, docks.id)).status).toBe(400); // not one of the scene's places
|
||||
expect((await visit(playerAuth, allyId, market.id)).status).toBe(403); // not their character
|
||||
|
||||
scene = (await visit(playerAuth, heroId, market.id)).body.encounter;
|
||||
expect(scene.places[0].here.map((h) => h.name)).toEqual(['Aria']);
|
||||
expect((await buy(playerAuth, ropeWare, heroId)).status).toBe(201);
|
||||
// The innkeeper is at the inn: go there to buy her ale.
|
||||
const tooFar = await buy(playerAuth, aleWare, heroId);
|
||||
expect(tooFar.status).toBe(400);
|
||||
expect(tooFar.body.error).toMatch(/go to Inn first/);
|
||||
await visit(playerAuth, heroId, inn.id);
|
||||
expect((await buy(playerAuth, aleWare, heroId)).status).toBe(201);
|
||||
const hero = (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character;
|
||||
expect(hero.inventoryItems.map((i) => i.item.name).sort()).toEqual(['Ale', 'Rope']);
|
||||
expect(hero.currentLocation.name).toBe('Inn');
|
||||
// The DM can send anyone, and opens another place while the party roams.
|
||||
expect((await visit(dmAuth, allyId, market.id)).status).toBe(200);
|
||||
expect((await request(app).put(`${e}/places`).set(playerAuth).send({ locationIds: [] })).status).toBe(403);
|
||||
const more = (await request(app).put(`${e}/places`).set(dmAuth).send({ locationIds: [market.id, inn.id, docks.id] })).body.encounter;
|
||||
expect(more.places.map((p) => p.name)).toEqual(['Market', 'Inn', 'Docks']);
|
||||
// Having been there, the player now knows the place for good.
|
||||
const known = (await request(app).get(`${c}/locations`).set(playerAuth)).body.locations.map((l) => l.name).sort();
|
||||
expect(known).toEqual(['Inn', 'Market']);
|
||||
});
|
||||
|
||||
itIfDb('a shop at a place does not sell in a quest, and only a free-roam scene has places', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('roamnot');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
const market = (await request(app).post(`${c}/locations`).set(dmAuth).send({ name: 'Market' })).body.location;
|
||||
const rope = (await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Rope' })).body.item;
|
||||
const ware = (await request(app).post(`${c}/wares`).set(dmAuth).send({ locationId: market.id, itemId: rope.id, price: 1 })).body.ware;
|
||||
const quest = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Errand', mode: 'QUEST' })).body.encounter.id}`;
|
||||
await request(app).post(`${quest}/participants`).set(dmAuth).send({ characterId: heroId });
|
||||
await request(app).patch(quest).set(dmAuth).send({ status: 'ACTIVE' });
|
||||
expect((await request(app).post(`${quest}/wares/${ware.id}/buy`).set(playerAuth).send({ characterId: heroId })).status).toBe(404);
|
||||
expect((await request(app).post(`${quest}/visit`).set(playerAuth).send({ characterId: heroId, locationId: market.id })).status).toBe(400);
|
||||
expect((await request(app).get(quest).set(playerAuth)).body.encounter.places).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -853,9 +853,11 @@ router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) =>
|
||||
|
||||
const createEncounterSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
mode: z.enum(['BATTLE', 'QUEST']).optional(),
|
||||
mode: z.enum(['BATTLE', 'QUEST', 'FREE_ROAM']).optional(),
|
||||
description: z.string().max(4000).optional().nullable(),
|
||||
locationId: z.string().optional().nullable(),
|
||||
// Free roam: the places the party can go to, from the campaign's locations.
|
||||
placeIds: z.array(z.string()).max(50).optional(),
|
||||
// Where it goes in the campaign's order: the top, the bottom, or straight
|
||||
// after another encounter (which wins over `position`). The bottom if
|
||||
// neither is given.
|
||||
@@ -867,11 +869,15 @@ router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req,
|
||||
try {
|
||||
const parsed = createEncounterSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { position, afterEncounterId, ...fields } = parsed.data;
|
||||
const { position, afterEncounterId, placeIds = [], ...fields } = parsed.data;
|
||||
if (fields.locationId) {
|
||||
const location = await prisma.location.findFirst({ where: { id: fields.locationId, campaignId: req.campaign.id } });
|
||||
if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' });
|
||||
}
|
||||
const places = [...new Set(placeIds)];
|
||||
if (places.length > 0 && (await prisma.location.count({ where: { id: { in: places }, campaignId: req.campaign.id } })) !== places.length) {
|
||||
return res.status(400).json({ error: 'Pick places from this campaign\'s locations.' });
|
||||
}
|
||||
|
||||
const order = await prisma.encounter.findMany({
|
||||
where: { campaignId: req.campaign.id },
|
||||
@@ -899,6 +905,26 @@ router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req,
|
||||
}
|
||||
return created;
|
||||
});
|
||||
|
||||
// Free roam is for the whole party: its places are set, and everyone
|
||||
// alive and finished is in it from the start (so their players see it).
|
||||
if (encounter.mode === 'FREE_ROAM') {
|
||||
if (places.length > 0) await prisma.encounterLocation.createMany({ data: places.map((locationId) => ({ encounterId: encounter.id, locationId })) });
|
||||
const party = await prisma.character.findMany({ where: { campaignId: req.campaign.id, buildStage: 'COMPLETE' }, include: SHEET_INCLUDE, orderBy: { createdAt: 'asc' } });
|
||||
const present = party.filter((character) => lifeState(character) !== 'DEAD');
|
||||
if (present.length > 0) {
|
||||
await prisma.encounterParticipant.createMany({
|
||||
data: present.map((character) => ({
|
||||
encounterId: encounter.id,
|
||||
characterId: character.id,
|
||||
displayName: character.name,
|
||||
currentHp: character.currentHp,
|
||||
maxHp: sheetOf(character).hp.max,
|
||||
isMonster: false,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
res.status(201).json({ encounter });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -961,8 +987,16 @@ const npcSchema = z.object({
|
||||
// null for an NPC who does not change money.
|
||||
exchangeGold: z.number().int().min(1).max(1000000).optional().nullable(),
|
||||
exchangeCash: z.number().int().min(1).max(1000000).optional().nullable(),
|
||||
// Where they are usually found: met there in a free-roam scene.
|
||||
locationId: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
// Whether a location named for an NPC, a ware or a scene is one of this campaign's.
|
||||
async function locationInCampaign(locationId, campaignId) {
|
||||
return !locationId || Boolean(await prisma.location.findFirst({ where: { id: locationId, campaignId }, select: { id: true } }));
|
||||
}
|
||||
const NOT_A_PLACE = 'That location is not part of this campaign.';
|
||||
|
||||
// A rate has two sides: both are given, or both are cleared.
|
||||
function halfARate(data) {
|
||||
const sides = [data.exchangeGold, data.exchangeCash];
|
||||
@@ -982,6 +1016,7 @@ router.post('/:id/npcs', requireCampaignMember(), requireDM(), async (req, res,
|
||||
const parsed = npcSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
if (halfARate(parsed.data)) return res.status(400).json({ error: HALF_A_RATE });
|
||||
if (!(await locationInCampaign(parsed.data.locationId, req.campaign.id))) return res.status(400).json({ error: NOT_A_PLACE });
|
||||
const npc = await prisma.npc.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
|
||||
res.status(201).json({ npc });
|
||||
} catch (err) {
|
||||
@@ -994,6 +1029,7 @@ router.patch('/:id/npcs/:npcId', requireCampaignMember(), requireDM(), async (re
|
||||
const parsed = npcSchema.partial().safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
if (halfARate(parsed.data)) return res.status(400).json({ error: HALF_A_RATE });
|
||||
if (!(await locationInCampaign(parsed.data.locationId, req.campaign.id))) return res.status(400).json({ error: NOT_A_PLACE });
|
||||
const npc = await updatePoolEntry('npc', req.params.npcId, req.campaign.id, parsed.data);
|
||||
if (!npc) return res.status(404).json({ error: 'NPC not found in this campaign' });
|
||||
emitTradeChanged(req);
|
||||
@@ -1106,6 +1142,7 @@ const wareSchema = z
|
||||
.object({
|
||||
npcId: z.string().optional(),
|
||||
encounterId: z.string().optional(),
|
||||
locationId: z.string().optional(), // a shop that is always at this place
|
||||
weaponId: z.string().optional(),
|
||||
itemId: z.string().optional(),
|
||||
price: z.number().int().min(1).max(1000000),
|
||||
@@ -1113,7 +1150,7 @@ const wareSchema = z
|
||||
level: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
|
||||
stock: z.number().int().min(0).max(9999).optional().nullable(), // null: no limit
|
||||
})
|
||||
.refine((data) => Boolean(data.npcId) !== Boolean(data.encounterId), { message: 'Say who sells it: an NPC or an encounter, not both' })
|
||||
.refine((data) => [data.npcId, data.encounterId, data.locationId].filter(Boolean).length === 1, { message: 'Say where it is sold: by an NPC, at an encounter, or at a location (one of them)' })
|
||||
.refine((data) => Boolean(data.weaponId) !== Boolean(data.itemId), { message: 'Pick one weapon or one item to sell' });
|
||||
|
||||
router.post('/:id/wares', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
@@ -1123,7 +1160,7 @@ router.post('/:id/wares', requireCampaignMember(), requireDM(), async (req, res,
|
||||
const data = parsed.data;
|
||||
const inCampaign = async (model, id) => (id ? prisma[model].findFirst({ where: { id, campaignId: req.campaign.id } }) : null);
|
||||
|
||||
const seller = (await inCampaign('npc', data.npcId)) || (await inCampaign('encounter', data.encounterId));
|
||||
const seller = (await inCampaign('npc', data.npcId)) || (await inCampaign('encounter', data.encounterId)) || (await inCampaign('location', data.locationId));
|
||||
if (!seller) return res.status(400).json({ error: 'That seller is not part of this campaign.' });
|
||||
const entry = (await inCampaign('weapon', data.weaponId)) || (await inCampaign('item', data.itemId));
|
||||
if (!entry) return res.status(400).json({ error: `That ${data.weaponId ? 'weapon' : 'item'} is not part of this campaign.` });
|
||||
|
||||
@@ -296,13 +296,16 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
// Likewise their conditions: the character's own, which outlast the scene.
|
||||
out.conditions = character.conditions;
|
||||
out.character = { id: character.id, name: character.name, userId: character.userId };
|
||||
// Where each character is, is part of a free-roam scene; elsewhere it stays on their sheet.
|
||||
if (encounter.mode === 'FREE_ROAM') out.character.currentLocationId = character.currentLocationId;
|
||||
out.characterCombatInfo = combatInfo;
|
||||
}
|
||||
out.conditionRounds = roundsFor(p.conditionRounds, out.conditions);
|
||||
if (monster) out.monster = isDM ? monster : { id: monster.id, name: monster.name };
|
||||
if (npc) {
|
||||
const { dmNotes, ac, maxHp, attackBonus, damageDice, campaignId, createdAt, ...publicNpc } = npc;
|
||||
out.npc = isDM ? { ...publicNpc, dmNotes, ac, maxHp, attackBonus, damageDice } : publicNpc;
|
||||
// Where they are usually found is the DM's to know, like their notes and stats.
|
||||
const { dmNotes, ac, maxHp, attackBonus, damageDice, locationId, campaignId, createdAt, ...publicNpc } = npc;
|
||||
out.npc = isDM ? { ...publicNpc, dmNotes, ac, maxHp, attackBonus, damageDice, locationId } : publicNpc;
|
||||
}
|
||||
if (hideHp && !character) {
|
||||
// Rounded up, so anything still standing shows a sliver of bar.
|
||||
@@ -337,6 +340,31 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
.slice(0, 6)
|
||||
.map(showAction);
|
||||
|
||||
// Free roam: the places the party can go to, each with who is found there,
|
||||
// what is for sale there, and which of the characters in the scene are
|
||||
// there now. Everyone in the scene sees every place in it. An NPC's secrets
|
||||
// and stats stay with the DM, as anywhere.
|
||||
let places;
|
||||
if (encounter.mode === 'FREE_ROAM') {
|
||||
const rows = await prisma.encounterLocation.findMany({
|
||||
where: { encounterId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: { location: { include: { npcs: { include: { wares: WARES }, orderBy: { name: 'asc' } }, wares: WARES } } },
|
||||
});
|
||||
places = rows.map(({ location: place }) => ({
|
||||
id: place.id,
|
||||
name: place.name,
|
||||
description: place.description,
|
||||
wares: place.wares,
|
||||
npcs: place.npcs.map(({ dmNotes: secrets, ac, maxHp, attackBonus, damageDice, locationId, campaignId, createdAt, ...publicNpc }) =>
|
||||
isDM ? { ...publicNpc, dmNotes: secrets, ac, maxHp, attackBonus, damageDice, locationId } : publicNpc
|
||||
),
|
||||
here: encounter.participants
|
||||
.filter((p) => p.character && p.character.currentLocationId === place.id)
|
||||
.map((p) => ({ characterId: p.character.id, name: p.displayName, userId: p.character.userId })),
|
||||
}));
|
||||
}
|
||||
|
||||
const { dmNotes, rewards, campaign, location, ...scene } = encounter;
|
||||
const knowsPlace = isDM || (location && (await visitedLocationIds(prisma, viewerId, encounter.campaignId)).has(location.id));
|
||||
return {
|
||||
@@ -350,6 +378,7 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
fallen,
|
||||
actions,
|
||||
recentActions,
|
||||
...(places ? { places } : {}),
|
||||
rewards: isDM ? rewards : rewards.filter((r) => r.awardedAt),
|
||||
};
|
||||
}
|
||||
@@ -372,7 +401,8 @@ async function syncCharacterLocations(req) {
|
||||
where: { id: req.encounter.id },
|
||||
include: { participants: { where: { characterId: { not: null } }, include: { character: { select: { id: true, currentLocationId: true } } } } },
|
||||
});
|
||||
if (!encounter || encounter.status !== 'ACTIVE' || !encounter.locationId) return;
|
||||
// In free roam each character goes where their player sends them.
|
||||
if (!encounter || encounter.status !== 'ACTIVE' || !encounter.locationId || encounter.mode === 'FREE_ROAM') return;
|
||||
|
||||
const elsewhere = encounter.participants.map((p) => p.character).filter((c) => c.currentLocationId !== encounter.locationId);
|
||||
await moveCharacters(prisma, elsewhere.map((c) => c.id), encounter.locationId);
|
||||
@@ -396,7 +426,7 @@ router.get('/:id', requireEncounterAccess, async (req, res, next) => {
|
||||
|
||||
const patchEncounterSchema = z.object({
|
||||
status: z.enum(['PLANNING', 'ACTIVE', 'COMPLETE']).optional(),
|
||||
mode: z.enum(['BATTLE', 'QUEST']).optional(),
|
||||
mode: z.enum(['BATTLE', 'QUEST', 'FREE_ROAM']).optional(),
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
description: z.string().max(4000).nullable().optional(),
|
||||
dmNotes: z.string().max(4000).nullable().optional(),
|
||||
@@ -1143,6 +1173,58 @@ router.post('/:id/actions/:actionId/apply', requireEncounterAccess, requireDM, a
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Free roam — the party wanders between the places the DM has laid out for
|
||||
// the scene: to shop, to meet whoever is found there. The DM sets the places;
|
||||
// each player sends their own character to one (the DM can send anyone).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The whole list of places; an empty one clears it.
|
||||
router.put('/:id/places', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ locationIds: z.array(z.string()).max(50) }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const locationIds = [...new Set(parsed.data.locationIds)];
|
||||
if ((await prisma.location.count({ where: { id: { in: locationIds }, campaignId: req.encounter.campaignId } })) !== locationIds.length) {
|
||||
return res.status(400).json({ error: "Pick places from this campaign's locations." });
|
||||
}
|
||||
const had = await prisma.encounterLocation.findMany({ where: { encounterId: req.encounter.id }, select: { locationId: true } });
|
||||
const keep = new Set(had.map((row) => row.locationId));
|
||||
await prisma.$transaction([
|
||||
prisma.encounterLocation.deleteMany({ where: { encounterId: req.encounter.id, locationId: { notIn: locationIds } } }),
|
||||
// Added one at a time, in the order given, so the list keeps that order.
|
||||
...locationIds.filter((id) => !keep.has(id)).map((locationId) => prisma.encounterLocation.create({ data: { encounterId: req.encounter.id, locationId } })),
|
||||
]);
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// A character goes to one of the scene's places: their own player sends them, or the DM.
|
||||
router.post('/:id/visit', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ characterId: z.string(), locationId: z.string() }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { characterId, locationId } = parsed.data;
|
||||
if (req.encounter.mode !== 'FREE_ROAM') return res.status(400).json({ error: 'Only a free-roam scene has places to go to.' });
|
||||
if (req.encounter.status !== 'ACTIVE') return res.status(400).json({ error: 'The scene has not begun yet.' });
|
||||
|
||||
const row = await prisma.encounterParticipant.findFirst({ where: { encounterId: req.encounter.id, characterId }, include: { character: true } });
|
||||
if (!row) return res.status(400).json({ error: 'That character is not in this scene.' });
|
||||
if (!req.isDM && row.character.userId !== req.user.id) return res.status(403).json({ error: 'You can only move your own character.' });
|
||||
if (isFallen(row)) return res.status(400).json({ error: `${row.character.name} is dead.` });
|
||||
const place = await prisma.encounterLocation.findFirst({ where: { encounterId: req.encounter.id, locationId } });
|
||||
if (!place) return res.status(400).json({ error: 'That is not one of the places in this scene.' });
|
||||
|
||||
await moveCharacters(prisma, [characterId], locationId);
|
||||
emitCharacterUpdated(req, characterId);
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Experience — awarded once, shared evenly between the characters in the scene
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1333,10 +1415,18 @@ function money(amount, currency) {
|
||||
}
|
||||
|
||||
// Trading takes someone to trade with and the time to do it, so it happens
|
||||
// in a quest in progress and never in a battle.
|
||||
// in a quest or a free-roam scene in progress, and never in a battle.
|
||||
function assertTradeOpen(encounter) {
|
||||
if (encounter.mode !== 'QUEST') throw new TradeError('Trading happens in a quest, not in a battle.');
|
||||
if (encounter.status !== 'ACTIVE') throw new TradeError('Trading opens once the quest is in progress.');
|
||||
if (encounter.mode === 'BATTLE') throw new TradeError('Trading happens in a quest or while roaming, not in a battle.');
|
||||
if (encounter.status !== 'ACTIVE') throw new TradeError(`Trading opens once the ${encounter.mode === 'QUEST' ? 'quest' : 'scene'} is in progress.`);
|
||||
}
|
||||
|
||||
// In a free-roam scene a character trades where they are: the place has to
|
||||
// be one of the scene's, and they have to have gone there.
|
||||
async function assertAtPlace(req, character, locationId, what) {
|
||||
const place = locationId ? await prisma.encounterLocation.findFirst({ where: { encounterId: req.encounter.id, locationId }, include: { location: true } }) : null;
|
||||
if (!place) throw new TradeError(`${what} is not here to trade with.`);
|
||||
if (character.currentLocationId !== locationId) throw new TradeError(`${character.name} has to go to ${place.location.name} first.`);
|
||||
}
|
||||
|
||||
// The character doing the trading: in this scene, alive, and the player's
|
||||
@@ -1349,8 +1439,15 @@ async function loadTrader(req, characterId) {
|
||||
return row.character;
|
||||
}
|
||||
|
||||
// An NPC to trade with has to be in the scene and still standing.
|
||||
async function loadTradingNpc(req, npcId) {
|
||||
// An NPC to trade with has to be in the scene and still standing; in a
|
||||
// free-roam scene, at one of its places, with the character there too.
|
||||
async function loadTradingNpc(req, npcId, character) {
|
||||
if (req.encounter.mode === 'FREE_ROAM') {
|
||||
const npc = await prisma.npc.findFirst({ where: { id: npcId, campaignId: req.encounter.campaignId } });
|
||||
if (!npc) throw new TradeError('That NPC is not here to trade with.');
|
||||
await assertAtPlace(req, character, npc.locationId, npc.name);
|
||||
return npc;
|
||||
}
|
||||
const row = await prisma.encounterParticipant.findFirst({ where: { encounterId: req.encounter.id, npcId }, include: { npc: true } });
|
||||
if (!row || isFallen(row)) throw new TradeError('That NPC is not here to trade with.');
|
||||
return row.npc;
|
||||
@@ -1383,7 +1480,7 @@ router.post(
|
||||
const to = from === 'GOLD' ? 'CASH' : 'GOLD';
|
||||
|
||||
const character = await loadTrader(req, characterId);
|
||||
const npc = await loadTradingNpc(req, npcId);
|
||||
const npc = await loadTradingNpc(req, npcId, character);
|
||||
if (!npc.exchangeGold || !npc.exchangeCash) throw new TradeError(`${npc.name} does not change money.`);
|
||||
const rate = { GOLD: npc.exchangeGold, CASH: npc.exchangeCash };
|
||||
if (amount % rate[from] !== 0) {
|
||||
@@ -1425,7 +1522,12 @@ router.post(
|
||||
if (!ware || ware.campaignId !== req.encounter.campaignId || (ware.encounterId && ware.encounterId !== req.encounter.id)) {
|
||||
throw new TradeError('That is not for sale here.', 404);
|
||||
}
|
||||
const seller = ware.npcId ? (await loadTradingNpc(req, ware.npcId)).name : null;
|
||||
// A shop at a place sells to whoever has gone there, while that place is part of the scene.
|
||||
if (ware.locationId) {
|
||||
if (req.encounter.mode !== 'FREE_ROAM') throw new TradeError('That is not for sale here.', 404);
|
||||
await assertAtPlace(req, character, ware.locationId, 'That shop');
|
||||
}
|
||||
const seller = ware.npcId ? (await loadTradingNpc(req, ware.npcId, character)).name : null;
|
||||
const kind = ware.weapon ? 'weapon' : 'item';
|
||||
const entry = ware[kind];
|
||||
const total = ware.price * quantity;
|
||||
|
||||
Reference in New Issue
Block a user