feat(industry): add invention validation pipeline
Validates LLM-generated invention JSON: checks inputs exist in ItemRegistry, rejects name collisions (case-insensitive), validates categories, and generates snake_case itemIds from names. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user