diff --git a/server/src/industry/__tests__/inventionValidator.test.ts b/server/src/industry/__tests__/inventionValidator.test.ts new file mode 100644 index 0000000..f5a357a --- /dev/null +++ b/server/src/industry/__tests__/inventionValidator.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest'; +import { validateInvention, type RawInvention } from '../inventionValidator.js'; +import { ItemRegistry } from '../itemRegistry.js'; + +function createTestRegistry(): ItemRegistry { + return ItemRegistry.createDefault(); // has log, stone, water, wooden_axe, hammer +} + +describe('validateInvention', () => { + it('accepts a valid crafted item', () => { + const registry = createTestRegistry(); + const raw: RawInvention = { + name: 'rope', + description: 'Twisted plant fibers', + category: 'material', + inputs: [{ itemId: 'log', quantity: 2 }], + }; + const result = validateInvention(raw, registry); + expect(result.valid).toBe(true); + expect(result.itemId).toBe('rope'); + }); + + it('rejects when input itemId does not exist', () => { + const registry = createTestRegistry(); + const raw: RawInvention = { + name: 'magic wand', + description: 'A wand', + category: 'tool', + inputs: [{ itemId: 'unicorn_horn', quantity: 1 }], + }; + const result = validateInvention(raw, registry); + expect(result.valid).toBe(false); + expect(result.error).toContain('unicorn_horn'); + }); + + it('rejects duplicate item name (case-insensitive)', () => { + const registry = createTestRegistry(); + const raw: RawInvention = { + name: 'Log', + description: 'Another log', + category: 'resource', + inputs: [], + }; + const result = validateInvention(raw, registry); + expect(result.valid).toBe(false); + expect(result.error).toContain('already exists'); + }); + + it('rejects invalid category', () => { + const registry = createTestRegistry(); + const raw: RawInvention = { + name: 'potion', + description: 'A potion', + category: 'magic' as any, + inputs: [{ itemId: 'water', quantity: 1 }], + }; + const result = validateInvention(raw, registry); + expect(result.valid).toBe(false); + expect(result.error).toContain('category'); + }); + + it('rejects missing name', () => { + const registry = createTestRegistry(); + const raw: RawInvention = { + name: '', + description: 'No name', + category: 'material', + inputs: [{ itemId: 'log', quantity: 1 }], + }; + const result = validateInvention(raw, registry); + expect(result.valid).toBe(false); + }); + + it('generates a snake_case itemId from name', () => { + const registry = createTestRegistry(); + const raw: RawInvention = { + name: 'Fishing Rod', + description: 'A rod for catching fish', + category: 'tool', + inputs: [{ itemId: 'log', quantity: 2 }], + }; + const result = validateInvention(raw, registry); + expect(result.valid).toBe(true); + expect(result.itemId).toBe('fishing_rod'); + }); + + it('accepts a structure invention with workshopType', () => { + const registry = createTestRegistry(); + const raw: RawInvention = { + name: 'kiln', + description: 'A stone furnace', + category: 'structure', + inputs: [{ itemId: 'stone', quantity: 5 }], + workshopType: 'kiln', + }; + const result = validateInvention(raw, registry); + expect(result.valid).toBe(true); + expect(result.workshopType).toBe('kiln'); + }); + + it('rejects if inputs array is missing', () => { + const registry = createTestRegistry(); + const raw = { + name: 'thing', + description: 'A thing', + category: 'material', + } as any; + const result = validateInvention(raw, registry); + expect(result.valid).toBe(false); + }); +}); diff --git a/server/src/industry/inventionValidator.ts b/server/src/industry/inventionValidator.ts new file mode 100644 index 0000000..2fac6e2 --- /dev/null +++ b/server/src/industry/inventionValidator.ts @@ -0,0 +1,78 @@ +import type { ItemRegistry } from './itemRegistry.js'; + +export interface RawInvention { + name: string; + description: string; + category: string; + inputs: { itemId: string; quantity: number }[]; + workshopType?: string | null; + toolRequired?: string | null; +} + +export interface ValidationResult { + valid: boolean; + error?: string; + itemId?: string; + workshopType?: string; +} + +const VALID_CATEGORIES = ['resource', 'tool', 'material', 'structure']; + +function toSnakeCase(name: string): string { + return name.toLowerCase().trim().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, ''); +} + +export function validateInvention(raw: RawInvention, registry: ItemRegistry): ValidationResult { + // Check name exists + if (!raw.name || typeof raw.name !== 'string' || raw.name.trim().length === 0) { + return { valid: false, error: 'Missing or empty name' }; + } + + // Check description exists + if (!raw.description || typeof raw.description !== 'string') { + return { valid: false, error: 'Missing description' }; + } + + // Check category is valid + if (!VALID_CATEGORIES.includes(raw.category)) { + return { valid: false, error: `Invalid category '${raw.category}'; must be one of: ${VALID_CATEGORIES.join(', ')}` }; + } + + // Check inputs is array + if (!Array.isArray(raw.inputs)) { + return { valid: false, error: 'Missing or invalid inputs array' }; + } + + // Check all input itemIds exist in registry + for (const input of raw.inputs) { + if (!registry.get(input.itemId)) { + return { valid: false, error: `Unknown input item '${input.itemId}'` }; + } + if (typeof input.quantity !== 'number' || input.quantity < 1) { + return { valid: false, error: `Invalid quantity for '${input.itemId}'` }; + } + } + + // Generate itemId + const itemId = toSnakeCase(raw.name); + if (!itemId) { + return { valid: false, error: 'Name produces empty itemId' }; + } + + // Check for name collision (case-insensitive via itemId and existing names) + const existingItems = registry.getAll(); + if (existingItems.some(item => item.id === itemId || item.name.toLowerCase() === raw.name.toLowerCase().trim())) { + return { valid: false, error: `Item '${raw.name}' already exists` }; + } + + // Check toolRequired exists if specified + if (raw.toolRequired && !registry.get(raw.toolRequired)) { + return { valid: false, error: `Unknown toolRequired '${raw.toolRequired}'` }; + } + + return { + valid: true, + itemId, + workshopType: raw.workshopType ?? undefined, + }; +}