Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ddf9846a2 |
@@ -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]));
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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…"
|
||||
|
||||
Generated
+4
-4
@@ -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
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user