Compare commits

...
1 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 0ddf9846a2 V1.12.1: each library entry goes into a campaign once
- A weapon, item, spell or monster from the library can be added to a
  campaign once. Adding it again is refused: "Longsword is already in
  this campaign."
- The library list leaves out what the campaign already has, so what is
  left is what can still be added. When nothing is left it says so.
- An entry counts as added while the campaign has one by that name,
  whatever its capitals. Rename or delete the campaign's copy and the
  library offers it again. This also covers what a world's seed put in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:09:23 +02:00
11 changed files with 63 additions and 10 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.12.0",
"version": "1.12.1",
"private": true,
"type": "commonjs",
"main": "src/index.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]));
+20
View File
@@ -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 });
+3 -1
View File
@@ -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
+3
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.12.0",
"version": "1.12.1",
"type": "module",
"scripts": {
"dev": "vite",
+16 -2
View File
@@ -503,6 +503,7 @@ function NpcTrade({
function LibraryPicker<T extends { key: string; name: string }>({
items,
taken,
onAdd,
adding,
renderMeta,
@@ -510,6 +511,9 @@ function LibraryPicker<T extends { key: string; name: string }>({
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<T extends { key: string; name: string }>({
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<T extends { key: string; name: string }>({
</button>
</div>
))}
{filtered.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No matches.</p>}
{filtered.length === 0 && (
<p style={{ color: 'var(--text-muted)' }}>
{available.length === 0 ? 'Everything in the library is already in this campaign.' : 'No matches.'}
</p>
)}
</div>
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} />
</div>
@@ -1262,6 +1272,7 @@ export function CampaignHomePage() {
{showWeaponLibrary && library && (
<LibraryPicker<LibraryWeapon>
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 && (
<LibraryPicker<LibraryItem>
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 && (
<LibraryPicker<LibrarySpell>
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 && (
<LibraryPicker<LibraryMonster>
items={library.monsters}
taken={monsters}
adding={addMonsterFromLibrary.isPending}
onAdd={(key) => addMonsterFromLibrary.mutate(key)}
placeholder="Search the monster library…"
+4 -4
View File
@@ -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",
+1 -1
View File
@@ -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": [