Implement complete inventory system with equipment database
Features: - HP Control component with damage/heal/direct modes (mobile-optimized) - Conditions system with PF2e condition database - Equipment database with 5,482 items from PF2e (weapons, armor, equipment) - AddItemModal with search, category filters, and pagination - Bulk tracking with encumbered/overburdened status display - Item management (add, remove, toggle equipped) Backend: - Equipment module with search/filter endpoints - Prisma migration for equipment detail fields - Equipment seed script importing from JSON data files - Extended Equipment model (damage, hands, AC, etc.) Frontend: - New components: HpControl, AddConditionModal, AddItemModal - Improved character sheet with tabbed interface - API methods for equipment search and item management Documentation: - CLAUDE.md with project philosophy and architecture decisions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2240
server/prisma/data/armor.json
Normal file
2240
server/prisma/data/armor.json
Normal file
File diff suppressed because it is too large
Load Diff
44780
server/prisma/data/equipment.json
Normal file
44780
server/prisma/data/equipment.json
Normal file
File diff suppressed because it is too large
Load Diff
8786
server/prisma/data/weapons.json
Normal file
8786
server/prisma/data/weapons.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Equipment" ADD COLUMN "ac" INTEGER,
|
||||
ADD COLUMN "armorCategory" TEXT,
|
||||
ADD COLUMN "armorGroup" TEXT,
|
||||
ADD COLUMN "checkPenalty" INTEGER,
|
||||
ADD COLUMN "damageType" TEXT,
|
||||
ADD COLUMN "dexCap" INTEGER,
|
||||
ADD COLUMN "duration" TEXT,
|
||||
ADD COLUMN "reload" TEXT,
|
||||
ADD COLUMN "shieldBt" INTEGER,
|
||||
ADD COLUMN "shieldHardness" INTEGER,
|
||||
ADD COLUMN "shieldHp" INTEGER,
|
||||
ADD COLUMN "speedPenalty" INTEGER,
|
||||
ADD COLUMN "strength" INTEGER,
|
||||
ADD COLUMN "usage" TEXT,
|
||||
ADD COLUMN "weaponGroup" TEXT;
|
||||
@@ -482,18 +482,41 @@ model Equipment {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
traits String[]
|
||||
itemCategory String
|
||||
itemCategory String // "Weapons", "Armor", "Consumables", "Shields", etc.
|
||||
itemSubcategory String?
|
||||
bulk String?
|
||||
bulk String? // "L" for light, "1", "2", etc.
|
||||
url String?
|
||||
summary String?
|
||||
activation String?
|
||||
hands String?
|
||||
damage String?
|
||||
range String?
|
||||
weaponCategory String?
|
||||
price Int? // In CP
|
||||
level Int?
|
||||
price Int? // In CP
|
||||
|
||||
// Weapon-specific fields
|
||||
hands String?
|
||||
damage String? // "1d8 S", "1d6 P", etc.
|
||||
damageType String? // "S", "P", "B" (Slashing, Piercing, Bludgeoning)
|
||||
range String?
|
||||
reload String?
|
||||
weaponCategory String? // "Simple", "Martial", "Advanced", "Ammunition"
|
||||
weaponGroup String? // "Sword", "Axe", "Bow", etc.
|
||||
|
||||
// Armor-specific fields
|
||||
ac Int?
|
||||
dexCap Int?
|
||||
checkPenalty Int?
|
||||
speedPenalty Int?
|
||||
strength Int? // Strength requirement
|
||||
armorCategory String? // "Unarmored", "Light", "Medium", "Heavy"
|
||||
armorGroup String? // "Leather", "Chain", "Plate", etc.
|
||||
|
||||
// Shield-specific fields
|
||||
shieldHp Int?
|
||||
shieldHardness Int?
|
||||
shieldBt Int? // Broken Threshold
|
||||
|
||||
// Consumable/Equipment-specific fields
|
||||
activation String? // "Cast A Spell", "[one-action]", etc.
|
||||
duration String?
|
||||
usage String?
|
||||
|
||||
characterItems CharacterItem[]
|
||||
}
|
||||
|
||||
254
server/prisma/seed-equipment.ts
Normal file
254
server/prisma/seed-equipment.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import 'dotenv/config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { PrismaClient } from '../src/generated/prisma/client.js';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
|
||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
interface WeaponJson {
|
||||
name: string;
|
||||
trait: string;
|
||||
item_category: string;
|
||||
item_subcategory: string;
|
||||
bulk: string;
|
||||
url: string;
|
||||
summary: string;
|
||||
hands?: string;
|
||||
damage?: string;
|
||||
range?: string;
|
||||
weapon_category?: string;
|
||||
}
|
||||
|
||||
interface ArmorJson {
|
||||
name: string;
|
||||
trait: string;
|
||||
item_category: string;
|
||||
item_subcategory: string;
|
||||
bulk: string;
|
||||
url: string;
|
||||
summary: string;
|
||||
ac?: string;
|
||||
dex_cap?: string;
|
||||
}
|
||||
|
||||
interface EquipmentJson {
|
||||
name: string;
|
||||
trait: string;
|
||||
item_category: string;
|
||||
item_subcategory: string;
|
||||
bulk: string;
|
||||
url: string;
|
||||
summary: string;
|
||||
activation?: string;
|
||||
}
|
||||
|
||||
function parseTraits(traitString: string): string[] {
|
||||
if (!traitString || traitString.trim() === '') return [];
|
||||
return traitString.split(',').map(t => t.trim()).filter(t => t.length > 0);
|
||||
}
|
||||
|
||||
function parseDamage(damageStr: string): { damage: string | null; damageType: string | null } {
|
||||
if (!damageStr || damageStr.trim() === '') return { damage: null, damageType: null };
|
||||
|
||||
// Parse strings like "1d8 S", "1d6 P", "2d6 B"
|
||||
const match = damageStr.match(/^(.+?)\s+([SPB])$/i);
|
||||
if (match) {
|
||||
return { damage: match[1].trim(), damageType: match[2].toUpperCase() };
|
||||
}
|
||||
return { damage: damageStr, damageType: null };
|
||||
}
|
||||
|
||||
function parseNumber(str: string | undefined): number | null {
|
||||
if (!str || str.trim() === '') return null;
|
||||
const num = parseInt(str, 10);
|
||||
return isNaN(num) ? null : num;
|
||||
}
|
||||
|
||||
async function seedWeapons() {
|
||||
const dataPath = path.join(__dirname, 'data', 'weapons.json');
|
||||
const data: WeaponJson[] = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
|
||||
|
||||
console.log(`⚔️ Importing ${data.length} weapons...`);
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const item of data) {
|
||||
try {
|
||||
const { damage, damageType } = parseDamage(item.damage || '');
|
||||
|
||||
// Check if exists first
|
||||
const existing = await prisma.equipment.findUnique({ where: { name: item.name } });
|
||||
|
||||
if (existing) {
|
||||
// Update with weapon-specific fields
|
||||
await prisma.equipment.update({
|
||||
where: { name: item.name },
|
||||
data: {
|
||||
hands: item.hands || existing.hands,
|
||||
damage: damage || existing.damage,
|
||||
damageType: damageType || existing.damageType,
|
||||
range: item.range || existing.range,
|
||||
weaponCategory: item.weapon_category || existing.weaponCategory,
|
||||
// Don't overwrite traits/summary if already set
|
||||
traits: existing.traits.length > 0 ? existing.traits : parseTraits(item.trait),
|
||||
summary: existing.summary || item.summary || null,
|
||||
},
|
||||
});
|
||||
updated++;
|
||||
} else {
|
||||
await prisma.equipment.create({
|
||||
data: {
|
||||
name: item.name,
|
||||
traits: parseTraits(item.trait),
|
||||
itemCategory: item.item_category || 'Weapons',
|
||||
itemSubcategory: item.item_subcategory || null,
|
||||
bulk: item.bulk || null,
|
||||
url: item.url || null,
|
||||
summary: item.summary || null,
|
||||
hands: item.hands || null,
|
||||
damage,
|
||||
damageType,
|
||||
range: item.range || null,
|
||||
weaponCategory: item.weapon_category || null,
|
||||
},
|
||||
});
|
||||
created++;
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (errors === 0) {
|
||||
// Print full error for first failure only
|
||||
console.log(` ⚠️ First error for "${item.name}":`);
|
||||
console.log(error.message);
|
||||
}
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` ✅ Created: ${created}, Updated: ${updated}, Errors: ${errors}`);
|
||||
}
|
||||
|
||||
async function seedArmor() {
|
||||
const dataPath = path.join(__dirname, 'data', 'armor.json');
|
||||
const data: ArmorJson[] = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
|
||||
|
||||
console.log(`🛡️ Importing ${data.length} armor items...`);
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const item of data) {
|
||||
try {
|
||||
const existing = await prisma.equipment.findUnique({ where: { name: item.name } });
|
||||
|
||||
if (existing) {
|
||||
// Update with armor-specific fields
|
||||
await prisma.equipment.update({
|
||||
where: { name: item.name },
|
||||
data: {
|
||||
ac: parseNumber(item.ac) ?? existing.ac,
|
||||
dexCap: parseNumber(item.dex_cap) ?? existing.dexCap,
|
||||
traits: existing.traits.length > 0 ? existing.traits : parseTraits(item.trait),
|
||||
summary: existing.summary || item.summary || null,
|
||||
},
|
||||
});
|
||||
updated++;
|
||||
} else {
|
||||
await prisma.equipment.create({
|
||||
data: {
|
||||
name: item.name,
|
||||
traits: parseTraits(item.trait),
|
||||
itemCategory: item.item_category || 'Armor',
|
||||
itemSubcategory: item.item_subcategory || null,
|
||||
bulk: item.bulk || null,
|
||||
url: item.url || null,
|
||||
summary: item.summary || null,
|
||||
ac: parseNumber(item.ac),
|
||||
dexCap: parseNumber(item.dex_cap),
|
||||
},
|
||||
});
|
||||
created++;
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (errors < 3) {
|
||||
console.log(` ⚠️ Error for "${item.name}": ${error.message?.slice(0, 100)}`);
|
||||
}
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` ✅ Created: ${created}, Updated: ${updated}, Errors: ${errors}`);
|
||||
}
|
||||
|
||||
async function seedEquipment() {
|
||||
const dataPath = path.join(__dirname, 'data', 'equipment.json');
|
||||
const data: EquipmentJson[] = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
|
||||
|
||||
console.log(`📦 Importing ${data.length} equipment items...`);
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const item of data) {
|
||||
try {
|
||||
await prisma.equipment.upsert({
|
||||
where: { name: item.name },
|
||||
update: {
|
||||
traits: parseTraits(item.trait),
|
||||
itemCategory: item.item_category || 'Equipment',
|
||||
itemSubcategory: item.item_subcategory || null,
|
||||
bulk: item.bulk || null,
|
||||
url: item.url || null,
|
||||
summary: item.summary || null,
|
||||
activation: item.activation || null,
|
||||
},
|
||||
create: {
|
||||
name: item.name,
|
||||
traits: parseTraits(item.trait),
|
||||
itemCategory: item.item_category || 'Equipment',
|
||||
itemSubcategory: item.item_subcategory || null,
|
||||
bulk: item.bulk || null,
|
||||
url: item.url || null,
|
||||
summary: item.summary || null,
|
||||
activation: item.activation || null,
|
||||
},
|
||||
});
|
||||
created++;
|
||||
} catch (error) {
|
||||
// Item with same name already exists - count as update attempt
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` ✅ Created: ${created}, Duplicates: ${updated}, Errors: ${errors}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🗃️ Seeding Pathfinder 2e Equipment Database...\n');
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// WICHTIG: Equipment zuerst, dann Waffen/Rüstung um spezifische Felder zu ergänzen
|
||||
await seedEquipment();
|
||||
await seedWeapons(); // Ergänzt damage, hands, weapon_category etc.
|
||||
await seedArmor(); // Ergänzt ac, dex_cap etc.
|
||||
|
||||
const totalCount = await prisma.equipment.count();
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
|
||||
console.log(`\n✅ Equipment database seeded successfully!`);
|
||||
console.log(` Total items in database: ${totalCount}`);
|
||||
console.log(` Duration: ${duration}s`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('Equipment seeding failed:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -7,9 +7,13 @@ const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
async function main() {
|
||||
const passwordHash = await bcrypt.hash('admin123', 10);
|
||||
console.log('Seeding database...\n');
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
// Create password hash
|
||||
const passwordHash = await bcrypt.hash('password123', 10);
|
||||
|
||||
// Create Admin User
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { email: 'admin@dimension47.local' },
|
||||
update: {},
|
||||
create: {
|
||||
@@ -19,10 +23,208 @@ async function main() {
|
||||
role: 'ADMIN',
|
||||
},
|
||||
});
|
||||
console.log('Created Admin:', admin.username);
|
||||
|
||||
console.log('Admin user created:', user.username, user.email);
|
||||
// Create GM User
|
||||
const gm = await prisma.user.upsert({
|
||||
where: { email: 'gm@dimension47.local' },
|
||||
update: {},
|
||||
create: {
|
||||
username: 'gamemaster',
|
||||
email: 'gm@dimension47.local',
|
||||
passwordHash,
|
||||
role: 'GM',
|
||||
},
|
||||
});
|
||||
console.log('Created GM:', gm.username);
|
||||
|
||||
// Create Player Users
|
||||
const player1 = await prisma.user.upsert({
|
||||
where: { email: 'player1@dimension47.local' },
|
||||
update: {},
|
||||
create: {
|
||||
username: 'spieler1',
|
||||
email: 'player1@dimension47.local',
|
||||
passwordHash,
|
||||
role: 'PLAYER',
|
||||
},
|
||||
});
|
||||
console.log('Created Player:', player1.username);
|
||||
|
||||
const player2 = await prisma.user.upsert({
|
||||
where: { email: 'player2@dimension47.local' },
|
||||
update: {},
|
||||
create: {
|
||||
username: 'spieler2',
|
||||
email: 'player2@dimension47.local',
|
||||
passwordHash,
|
||||
role: 'PLAYER',
|
||||
},
|
||||
});
|
||||
console.log('Created Player:', player2.username);
|
||||
|
||||
// Create Test Campaign
|
||||
const campaign = await prisma.campaign.upsert({
|
||||
where: { id: '00000000-0000-0000-0000-000000000001' },
|
||||
update: {},
|
||||
create: {
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
name: 'Abendliche Schatten',
|
||||
description: 'Eine spannende Kampagne in der Welt von Golarion. Die Helden erkunden uralte Ruinen und stellen sich finsteren Mächten.',
|
||||
gmId: gm.id,
|
||||
},
|
||||
});
|
||||
console.log('Created Campaign:', campaign.name);
|
||||
|
||||
// Add members to campaign
|
||||
await prisma.campaignMember.upsert({
|
||||
where: { campaignId_userId: { campaignId: campaign.id, userId: gm.id } },
|
||||
update: {},
|
||||
create: { campaignId: campaign.id, userId: gm.id },
|
||||
});
|
||||
|
||||
await prisma.campaignMember.upsert({
|
||||
where: { campaignId_userId: { campaignId: campaign.id, userId: player1.id } },
|
||||
update: {},
|
||||
create: { campaignId: campaign.id, userId: player1.id },
|
||||
});
|
||||
|
||||
await prisma.campaignMember.upsert({
|
||||
where: { campaignId_userId: { campaignId: campaign.id, userId: player2.id } },
|
||||
update: {},
|
||||
create: { campaignId: campaign.id, userId: player2.id },
|
||||
});
|
||||
console.log('Added members to campaign');
|
||||
|
||||
// Create Test Characters
|
||||
const character1 = await prisma.character.upsert({
|
||||
where: { id: '00000000-0000-0000-0000-000000000101' },
|
||||
update: {},
|
||||
create: {
|
||||
id: '00000000-0000-0000-0000-000000000101',
|
||||
campaignId: campaign.id,
|
||||
ownerId: player1.id,
|
||||
name: 'Thorin Eisenschild',
|
||||
type: 'PC',
|
||||
level: 3,
|
||||
hpCurrent: 38,
|
||||
hpMax: 42,
|
||||
hpTemp: 0,
|
||||
ancestryId: 'dwarf',
|
||||
classId: 'fighter',
|
||||
backgroundId: 'warrior',
|
||||
experiencePoints: 1200,
|
||||
},
|
||||
});
|
||||
console.log('Created Character:', character1.name);
|
||||
|
||||
// Add abilities for character1
|
||||
const abilities1 = [
|
||||
{ ability: 'STR' as const, score: 18 },
|
||||
{ ability: 'DEX' as const, score: 12 },
|
||||
{ ability: 'CON' as const, score: 16 },
|
||||
{ ability: 'INT' as const, score: 10 },
|
||||
{ ability: 'WIS' as const, score: 14 },
|
||||
{ ability: 'CHA' as const, score: 8 },
|
||||
];
|
||||
|
||||
for (const ab of abilities1) {
|
||||
await prisma.characterAbility.upsert({
|
||||
where: { characterId_ability: { characterId: character1.id, ability: ab.ability } },
|
||||
update: { score: ab.score },
|
||||
create: { characterId: character1.id, ability: ab.ability, score: ab.score },
|
||||
});
|
||||
}
|
||||
console.log('Added abilities to', character1.name);
|
||||
|
||||
const character2 = await prisma.character.upsert({
|
||||
where: { id: '00000000-0000-0000-0000-000000000102' },
|
||||
update: {},
|
||||
create: {
|
||||
id: '00000000-0000-0000-0000-000000000102',
|
||||
campaignId: campaign.id,
|
||||
ownerId: player2.id,
|
||||
name: 'Elara Sternenlicht',
|
||||
type: 'PC',
|
||||
level: 3,
|
||||
hpCurrent: 24,
|
||||
hpMax: 28,
|
||||
hpTemp: 0,
|
||||
ancestryId: 'elf',
|
||||
classId: 'wizard',
|
||||
backgroundId: 'scholar',
|
||||
experiencePoints: 1200,
|
||||
},
|
||||
});
|
||||
console.log('Created Character:', character2.name);
|
||||
|
||||
// Add abilities for character2
|
||||
const abilities2 = [
|
||||
{ ability: 'STR' as const, score: 8 },
|
||||
{ ability: 'DEX' as const, score: 14 },
|
||||
{ ability: 'CON' as const, score: 12 },
|
||||
{ ability: 'INT' as const, score: 18 },
|
||||
{ ability: 'WIS' as const, score: 14 },
|
||||
{ ability: 'CHA' as const, score: 12 },
|
||||
];
|
||||
|
||||
for (const ab of abilities2) {
|
||||
await prisma.characterAbility.upsert({
|
||||
where: { characterId_ability: { characterId: character2.id, ability: ab.ability } },
|
||||
update: { score: ab.score },
|
||||
create: { characterId: character2.id, ability: ab.ability, score: ab.score },
|
||||
});
|
||||
}
|
||||
console.log('Added abilities to', character2.name);
|
||||
|
||||
// Create an NPC
|
||||
const npc = await prisma.character.upsert({
|
||||
where: { id: '00000000-0000-0000-0000-000000000201' },
|
||||
update: {},
|
||||
create: {
|
||||
id: '00000000-0000-0000-0000-000000000201',
|
||||
campaignId: campaign.id,
|
||||
ownerId: null,
|
||||
name: 'Meister Aldric',
|
||||
type: 'NPC',
|
||||
level: 5,
|
||||
hpCurrent: 55,
|
||||
hpMax: 55,
|
||||
hpTemp: 0,
|
||||
},
|
||||
});
|
||||
console.log('Created NPC:', npc.name);
|
||||
|
||||
// Create a second campaign
|
||||
const campaign2 = await prisma.campaign.upsert({
|
||||
where: { id: '00000000-0000-0000-0000-000000000002' },
|
||||
update: {},
|
||||
create: {
|
||||
id: '00000000-0000-0000-0000-000000000002',
|
||||
name: 'Die verlorene Stadt',
|
||||
description: 'Eine Expedition in die legendäre verlorene Stadt Xin-Shalast.',
|
||||
gmId: gm.id,
|
||||
},
|
||||
});
|
||||
console.log('Created Campaign:', campaign2.name);
|
||||
|
||||
await prisma.campaignMember.upsert({
|
||||
where: { campaignId_userId: { campaignId: campaign2.id, userId: gm.id } },
|
||||
update: {},
|
||||
create: { campaignId: campaign2.id, userId: gm.id },
|
||||
});
|
||||
|
||||
console.log('\n✅ Database seeded successfully!');
|
||||
console.log('\n📋 Test Accounts:');
|
||||
console.log(' Admin: admin@dimension47.local / password123');
|
||||
console.log(' GM: gm@dimension47.local / password123');
|
||||
console.log(' Player 1: player1@dimension47.local / password123');
|
||||
console.log(' Player 2: player2@dimension47.local / password123');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.catch((e) => {
|
||||
console.error('Seeding failed:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
|
||||
Reference in New Issue
Block a user