diff --git a/backend/package.json b/backend/package.json index 8f0628d..b8a4365 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.12.0", + "version": "1.12.1", "private": true, "type": "commonjs", "main": "src/index.js", diff --git a/backend/src/routes/api.integration.test.js b/backend/src/routes/api.integration.test.js index 68c000e..44ef92d 100644 --- a/backend/src/routes/api.integration.test.js +++ b/backend/src/routes/api.integration.test.js @@ -1454,6 +1454,12 @@ describe('default content library', () => { expect(addRes.body.weapon.name).toBe('Longsword'); expect(addRes.body.weapon.campaignId).toBe(campaign.id); + // Each library entry goes in once, until its copy is renamed or deleted. + const addLongsword = () => request(app).post(`/campaigns/${campaign.id}/weapons/from-library`).set(dmAuth).send({ key: 'longsword' }); + expect((await addLongsword()).status).toBe(409); + await request(app).delete(`/campaigns/${campaign.id}/weapons/${addRes.body.weapon.id}`).set(dmAuth); + expect((await addLongsword()).status).toBe(201); + // Library spells show their level and default damage before they are added, // and carry both into the campaign. const byKey = Object.fromEntries(listRes.body.spells.map((s) => [s.key, s])); diff --git a/backend/src/routes/campaigns.routes.js b/backend/src/routes/campaigns.routes.js index cc78042..ecff411 100644 --- a/backend/src/routes/campaigns.routes.js +++ b/backend/src/routes/campaigns.routes.js @@ -948,6 +948,14 @@ router.delete('/:id/wares/:wareId', requireCampaignMember(), requireDM(), async // backend/src/game/data/{weapons,spells,monsters}Library.js for the sets. // --------------------------------------------------------------------------- +// A library entry goes into a campaign once: it is taken for as long as the +// pool holds a row by its name, and free again when that row is renamed or +// deleted. +async function inPool(model, campaignId, name) { + const row = await prisma[model].findFirst({ where: { campaignId, name: { equals: name, mode: 'insensitive' } }, select: { id: true } }); + return Boolean(row); +} + router.get('/:id/library', requireCampaignMember(), requireDM(), async (req, res, next) => { try { res.json({ weapons: WEAPON_LIBRARY, items: ITEM_LIBRARY, spells: SPELL_LIBRARY, monsters: MONSTER_LIBRARY }); @@ -962,6 +970,9 @@ router.post('/:id/weapons/from-library', requireCampaignMember(), requireDM(), a if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); const template = WEAPON_LIBRARY.find((w) => w.key === parsed.data.key); if (!template) return res.status(404).json({ error: 'Unknown library weapon.' }); + if (await inPool('weapon', req.campaign.id, template.name)) { + return res.status(409).json({ error: `${template.name} is already in this campaign.` }); + } const { key, category, ...data } = template; const weapon = await prisma.weapon.create({ data: { ...data, campaignId: req.campaign.id } }); res.status(201).json({ weapon }); @@ -976,6 +987,9 @@ router.post('/:id/items/from-library', requireCampaignMember(), requireDM(), asy if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); const template = ITEM_LIBRARY.find((i) => i.key === parsed.data.key); if (!template) return res.status(404).json({ error: 'Unknown library item.' }); + if (await inPool('item', req.campaign.id, template.name)) { + return res.status(409).json({ error: `${template.name} is already in this campaign.` }); + } const { key, category, ...data } = template; const item = await prisma.item.create({ data: { ...data, campaignId: req.campaign.id } }); res.status(201).json({ item }); @@ -990,6 +1004,9 @@ router.post('/:id/spells/from-library', requireCampaignMember(), requireDM(), as if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); const template = SPELL_LIBRARY.find((s) => s.key === parsed.data.key); if (!template) return res.status(404).json({ error: 'Unknown library spell.' }); + if (await inPool('spell', req.campaign.id, template.name)) { + return res.status(409).json({ error: `${template.name} is already in this campaign.` }); + } const { key, classes, ...data } = template; const spell = await prisma.spell.create({ data: { ...data, campaignId: req.campaign.id } }); res.status(201).json({ spell }); @@ -1004,6 +1021,9 @@ router.post('/:id/monsters/from-library', requireCampaignMember(), requireDM(), if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); const template = MONSTER_LIBRARY.find((m) => m.key === parsed.data.key); if (!template) return res.status(404).json({ error: 'Unknown library monster.' }); + if (await inPool('monster', req.campaign.id, template.name)) { + return res.status(409).json({ error: `${template.name} is already in this campaign.` }); + } const { key, ...data } = template; const monster = await prisma.monster.create({ data: { ...data, campaignId: req.campaign.id } }); res.status(201).json({ monster }); diff --git a/docs/ITEMS.md b/docs/ITEMS.md index 8b0c5cd..c485227 100644 --- a/docs/ITEMS.md +++ b/docs/ITEMS.md @@ -53,7 +53,9 @@ The pool is the "Items Pool" card on the campaign page (DM only). worn gear such as a Ring of Protection, and common supplies. Each is copied into the pool with its effects already set (Chain Mail: +6 AC, −10 Speed, and a note about Stealth), at starting level 1 and not ticked as a starting - option. The copy is yours to change. The full list is in section 10. + option. The copy is yours to change. The full list is in section 10. Each + can be added once: an item the pool already has by that name is left out of + the library list, and comes back if the pool's item is renamed or deleted. - **Effects:** the "Effects" button on an item's row. The row shows what the item does under its name. - **Changing:** starting level, growth (when the item has dice) and the diff --git a/docs/MONSTERS.md b/docs/MONSTERS.md index 14f8ef8..2d7c30b 100644 --- a/docs/MONSTERS.md +++ b/docs/MONSTERS.md @@ -57,6 +57,9 @@ The list is the "Monsters" card on the campaign page (DM only). scores, special trait and notes. Only name, AC and HP must be typed; speed starts at 30, attack bonus at 0 and each ability score at 10. - **Adding from the bestiary:** "📚 From Library" copies a complete stat block. + Each monster can be added once: one the campaign already has by that name is + left out of the list, and comes back if the campaign's monster is renamed or + deleted. - **Changing:** "Edit" on a monster's row opens the same form filled in. Saving changes the template, and copies already in encounters show the new stat block straight away; their current HP is left alone. diff --git a/docs/SPELLS.md b/docs/SPELLS.md index 68a961d..c587a0e 100644 --- a/docs/SPELLS.md +++ b/docs/SPELLS.md @@ -107,6 +107,10 @@ one shown. A spell added from the library is **not** a starting option until ticked. The library has nothing above level 5; add higher spells by hand. +Each library spell can be added once. A spell the list already has by that +name is left out of the library list, and comes back if the campaign's spell +is renamed or deleted. + ## 4. Granting spells to characters On a character's sheet, the DM picks a spell from the list and clicks Grant. diff --git a/docs/WEAPONS.md b/docs/WEAPONS.md index 1efb3da..44246ae 100644 --- a/docs/WEAPONS.md +++ b/docs/WEAPONS.md @@ -86,6 +86,10 @@ growth, and **not** ticked as a starting option. Change those in the table afterwards. Every library weapon is listed in [section 12](#12-the-complete-weapon-library). +Each library weapon can be added once. A weapon the pool already has by that +name is left out of the library list, and comes back if the pool's weapon is +renamed or deleted. + ## 4. Giving weapons to characters On a character's sheet, the DM picks a weapon from the pool and clicks Give. diff --git a/frontend/package.json b/frontend/package.json index 76e086b..c1d26f3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.12.0", + "version": "1.12.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/pages/CampaignHomePage.tsx b/frontend/src/pages/CampaignHomePage.tsx index 10448da..acb244d 100644 --- a/frontend/src/pages/CampaignHomePage.tsx +++ b/frontend/src/pages/CampaignHomePage.tsx @@ -503,6 +503,7 @@ function NpcTrade({ function LibraryPicker({ items, + taken, onAdd, adding, renderMeta, @@ -510,6 +511,9 @@ function LibraryPicker({ placeholder, }: { items: T[]; + // What the campaign already holds: a library entry by one of these names is + // left out, since each can be added once. + taken: { name: string }[] | undefined; onAdd: (key: string) => void; adding: boolean; renderMeta: (item: T) => string; @@ -518,7 +522,9 @@ function LibraryPicker({ placeholder: string; }) { const [query, setQuery] = useState(''); - const filtered = items.filter((i) => i.name.toLowerCase().includes(query.toLowerCase())); + const takenNames = new Set((taken ?? []).map((t) => t.name.toLowerCase())); + const available = items.filter((i) => !takenNames.has(i.name.toLowerCase())); + const filtered = available.filter((i) => i.name.toLowerCase().includes(query.toLowerCase())); const { pageItems, page, pageCount, prevPage, nextPage } = usePagination(filtered, 6); return ( @@ -537,7 +543,11 @@ function LibraryPicker({ ))} - {filtered.length === 0 &&

No matches.

} + {filtered.length === 0 && ( +

+ {available.length === 0 ? 'Everything in the library is already in this campaign.' : 'No matches.'} +

+ )} @@ -1262,6 +1272,7 @@ export function CampaignHomePage() { {showWeaponLibrary && library && ( items={library.weapons} + taken={weapons} adding={addWeaponFromLibrary.isPending} onAdd={(key) => addWeaponFromLibrary.mutate(key)} placeholder="Search the weapon library…" @@ -1378,6 +1389,7 @@ export function CampaignHomePage() { {showItemLibrary && library && ( items={library.items} + taken={items} adding={addItemFromLibrary.isPending} onAdd={(key) => addItemFromLibrary.mutate(key)} placeholder="Search the item library: armor, shields, worn gear, supplies…" @@ -1496,6 +1508,7 @@ export function CampaignHomePage() { {showSpellLibrary && library && ( items={library.spells} + taken={spells} adding={addSpellFromLibrary.isPending} onAdd={(key) => addSpellFromLibrary.mutate(key)} placeholder="Search the spell library…" @@ -1615,6 +1628,7 @@ export function CampaignHomePage() { {showMonsterLibrary && library && ( items={library.monsters} + taken={monsters} adding={addMonsterFromLibrary.isPending} onAdd={(key) => addMonsterFromLibrary.mutate(key)} placeholder="Search the monster library…" diff --git a/package-lock.json b/package-lock.json index d2979d2..a1c30a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "dnd-campaign-manager", - "version": "1.12.0", + "version": "1.12.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dnd-campaign-manager", - "version": "1.12.0", + "version": "1.12.1", "workspaces": [ "backend", "frontend" ] }, "backend": { - "version": "1.12.0", + "version": "1.12.1", "dependencies": { "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "^7.10.0", @@ -32,7 +32,7 @@ } }, "frontend": { - "version": "1.12.0", + "version": "1.12.1", "dependencies": { "@tanstack/react-query": "^5.103.2", "axios": "^1.20.0", diff --git a/package.json b/package.json index e117cf2..ffb7b16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dnd-campaign-manager", - "version": "1.12.0", + "version": "1.12.1", "private": true, "description": "D&D 5e campaign manager — monorepo root", "workspaces": [