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:
root
2026-03-08 23:46:06 +00:00
parent b6a08c8bd8
commit 57f3832b0b
2 changed files with 189 additions and 0 deletions
@@ -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);
});
});
+78
View File
@@ -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,
};
}