Merge branch 'worktree-agent-abadd094'

This commit is contained in:
root
2026-03-09 14:18:47 +00:00
3 changed files with 553 additions and 0 deletions
+7
View File
@@ -14,6 +14,13 @@ export class World {
return id;
}
createEntityWithId(id: EntityId): void {
this.entities.add(id);
if (id >= this.nextId) {
this.nextId = id + 1;
}
}
removeEntity(entity: EntityId): void {
this.entities.delete(entity);
for (const store of this.components.values()) {
@@ -0,0 +1,287 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { World } from '../../ecs/World.js';
import { openDatabase, closeDatabase } from '../database.js';
import { saveEntities, loadEntities } from '../entitySerializer.js';
import type {
Position, Needs, Stats, Movement, NPCBrain, SocialState,
StatModifiers, Appearance, RelationshipData, EntityId,
} from '@dflike/shared';
import type { StructureData } from '../../systems/buildingSystem.js';
import type { BondRegistry } from '../../systems/bondRegistry.js';
import { createBondRegistry } from '../../systems/bondRegistry.js';
import fs from 'fs';
import path from 'path';
import os from 'os';
function tempDbPath(): string {
return path.join(os.tmpdir(), `dflike-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
}
describe('entitySerializer', () => {
const dbPaths: string[] = [];
let world: World;
let dbPath: string;
function createTempDb(): string {
const p = tempDbPath();
dbPaths.push(p);
return p;
}
beforeEach(() => {
dbPath = createTempDb();
openDatabase(dbPath);
world = new World();
});
afterEach(() => {
try { closeDatabase(); } catch { /* ignore */ }
for (const p of dbPaths) {
try { fs.unlinkSync(p); } catch { /* ignore */ }
try { fs.unlinkSync(p + '-wal'); } catch { /* ignore */ }
try { fs.unlinkSync(p + '-shm'); } catch { /* ignore */ }
}
dbPaths.length = 0;
});
it('round-trips an NPC with all components', () => {
const e = world.createEntity();
world.addComponent<Position>(e, 'position', { x: 10.5, y: 20.5 });
world.addComponent<string>(e, 'name', 'Gwendolyn');
world.addComponent<Needs>(e, 'needs', { hunger: 75, energy: 60, productivity: 40 });
world.addComponent<Stats>(e, 'stats', {
strength: 12, dexterity: 14, constitution: 10, intelligence: 8, perception: 11,
sociability: 15, courage: 9, curiosity: 13, empathy: 16, temperament: 7,
});
world.addComponent<Map<string, number>>(e, 'inventory', new Map([['log', 3], ['stone', 1]]));
world.addComponent<Appearance>(e, 'appearance', {
skinId: 'shape00_skin02',
accessories: { torso: 'shirt01' },
portraitFeatures: { eyes: 'eyes01' },
});
world.addComponent<Movement>(e, 'movement', {
state: 'walking', target: { x: 5, y: 5 }, path: [{ x: 5, y: 5 }], direction: 2, moveProgress: 0.5,
});
world.addComponent<NPCBrain>(e, 'npcBrain', {
currentGoal: 'gather', goalQueue: ['wander'],
gatherTarget: { x: 3, y: 4, resourceType: 'tree' },
});
world.addComponent<SocialState>(e, 'socialState', {
phase: 'facing', partnerId: 99, phaseTimer: 5,
outcome: 'positive', globalCooldown: 10,
pairCooldowns: new Map([[2, 50], [3, 30]]),
lastOutcome: { partnerId: 99, outcome: 'positive', tick: 100 },
proposalCooldown: 0,
pendingProposal: { targetId: 99, type: 'partner' },
isProposalInteraction: true,
});
world.addComponent<StatModifiers>(e, 'statModifiers', {
modifiers: [{ stat: 'strength', value: 2, remaining: 10 }],
});
world.addComponent<string>(e, 'backstory', 'A brave adventurer from the north.');
saveEntities(world);
// Load into a fresh world
const world2 = new World();
const maxId = loadEntities(world2);
const entities = world2.getAllEntities();
expect(entities).toHaveLength(1);
expect(entities[0]).toBe(e);
// Position
const pos = world2.getComponent<Position>(e, 'position')!;
expect(pos.x).toBe(10.5);
expect(pos.y).toBe(20.5);
// Name
expect(world2.getComponent<string>(e, 'name')).toBe('Gwendolyn');
// Needs
const needs = world2.getComponent<Needs>(e, 'needs')!;
expect(needs.hunger).toBe(75);
expect(needs.energy).toBe(60);
// Stats
const stats = world2.getComponent<Stats>(e, 'stats')!;
expect(stats.strength).toBe(12);
expect(stats.empathy).toBe(16);
// Inventory (Map)
const inv = world2.getComponent<Map<string, number>>(e, 'inventory')!;
expect(inv).toBeInstanceOf(Map);
expect(inv.get('log')).toBe(3);
expect(inv.get('stone')).toBe(1);
// Appearance
const app = world2.getComponent<Appearance>(e, 'appearance')!;
expect(app.skinId).toBe('shape00_skin02');
expect(app.accessories.torso).toBe('shirt01');
// Movement (reset on load)
const mov = world2.getComponent<Movement>(e, 'movement')!;
expect(mov.state).toBe('idle');
expect(mov.target).toBeNull();
expect(mov.path).toEqual([]);
expect(mov.moveProgress).toBe(0);
// NPCBrain (reset on load)
const brain = world2.getComponent<NPCBrain>(e, 'npcBrain')!;
expect(brain.currentGoal).toBeNull();
expect(brain.goalQueue).toEqual([]);
// SocialState (reset on load)
const social = world2.getComponent<SocialState>(e, 'socialState')!;
expect(social.phase).toBe('none');
expect(social.partnerId).toBeNull();
expect(social.phaseTimer).toBe(0);
expect(social.outcome).toBeNull();
expect(social.lastOutcome).toBeNull();
expect(social.pendingProposal).toBeNull();
expect(social.isProposalInteraction).toBe(false);
// pairCooldowns should be preserved
expect(social.pairCooldowns).toBeInstanceOf(Map);
expect(social.pairCooldowns.get(2)).toBe(50);
// globalCooldown preserved
expect(social.globalCooldown).toBe(10);
// StatModifiers
const mods = world2.getComponent<StatModifiers>(e, 'statModifiers')!;
expect(mods.modifiers).toHaveLength(1);
expect(mods.modifiers[0].stat).toBe('strength');
// Backstory
expect(world2.getComponent<string>(e, 'backstory')).toBe('A brave adventurer from the north.');
expect(maxId).toBe(e);
});
it('round-trips a structure entity', () => {
const e = world.createEntity();
world.addComponent<Position>(e, 'position', { x: 5, y: 10 });
world.addComponent<string>(e, 'name', 'Stockpile');
world.addComponent<StructureData>(e, 'structure', {
type: 'stockpile',
subtype: 'general',
inventory: new Map([['log', 10], ['stone', 5]]),
buildProgress: undefined,
});
saveEntities(world);
const world2 = new World();
loadEntities(world2);
const entities = world2.getAllEntities();
expect(entities).toHaveLength(1);
const struct = world2.getComponent<StructureData>(e, 'structure')!;
expect(struct.type).toBe('stockpile');
expect(struct.subtype).toBe('general');
expect(struct.inventory).toBeInstanceOf(Map);
expect(struct.inventory.get('log')).toBe(10);
expect(struct.inventory.get('stone')).toBe(5);
});
it('round-trips relationships between entities', () => {
const e1 = world.createEntity();
const e2 = world.createEntity();
world.addComponent<Position>(e1, 'position', { x: 0, y: 0 });
world.addComponent<Position>(e2, 'position', { x: 1, y: 1 });
world.addComponent<string>(e1, 'name', 'Alice');
world.addComponent<string>(e2, 'name', 'Bob');
const rels1 = new Map<EntityId, RelationshipData>();
rels1.set(e2, { value: 50, interactions: 10, lastInteractionTick: 100, status: 'active' });
world.addComponent(e1, 'relationships', rels1);
const rels2 = new Map<EntityId, RelationshipData>();
rels2.set(e1, { value: 30, interactions: 10, lastInteractionTick: 100, status: 'active' });
world.addComponent(e2, 'relationships', rels2);
saveEntities(world);
const world2 = new World();
loadEntities(world2);
const loadedRels1 = world2.getComponent<Map<EntityId, RelationshipData>>(e1, 'relationships')!;
expect(loadedRels1).toBeInstanceOf(Map);
expect(loadedRels1.get(e2)!.value).toBe(50);
expect(loadedRels1.get(e2)!.interactions).toBe(10);
expect(loadedRels1.get(e2)!.status).toBe('active');
const loadedRels2 = world2.getComponent<Map<EntityId, RelationshipData>>(e2, 'relationships')!;
expect(loadedRels2.get(e1)!.value).toBe(30);
});
it('round-trips bond registry', () => {
const e1 = world.createEntity();
const e2 = world.createEntity();
world.addComponent<Position>(e1, 'position', { x: 0, y: 0 });
world.addComponent<Position>(e2, 'position', { x: 1, y: 1 });
const registry = createBondRegistry();
registry.set(`${e1}:${e2}`, [
{ type: 'partner', formedAtTick: 50, status: 'active' },
{ type: 'friend', formedAtTick: 20, status: 'former' },
]);
world.setSingleton('bondRegistry', registry);
saveEntities(world);
const world2 = new World();
loadEntities(world2);
const loadedRegistry = world2.getSingleton<BondRegistry>('bondRegistry')!;
expect(loadedRegistry).toBeInstanceOf(Map);
const bonds = loadedRegistry.get(`${e1}:${e2}`)!;
expect(bonds).toHaveLength(2);
expect(bonds[0].type).toBe('partner');
expect(bonds[0].formedAtTick).toBe(50);
expect(bonds[0].status).toBe('active');
expect(bonds[1].type).toBe('friend');
expect(bonds[1].status).toBe('former');
});
it('skips playerControlled entities', () => {
const npc = world.createEntity();
world.addComponent<Position>(npc, 'position', { x: 0, y: 0 });
world.addComponent<string>(npc, 'name', 'NPC');
const player = world.createEntity();
world.addComponent<Position>(player, 'position', { x: 5, y: 5 });
world.addComponent(player, 'playerControlled', { playerId: 'p1', mode: 'avatar' });
world.addComponent<string>(player, 'name', 'Player');
saveEntities(world);
const world2 = new World();
loadEntities(world2);
const entities = world2.getAllEntities();
expect(entities).toHaveLength(1);
expect(entities[0]).toBe(npc);
expect(world2.getComponent<string>(npc, 'name')).toBe('NPC');
});
it('sets maxId correctly after loading', () => {
const e1 = world.createEntity(); // 1
const e2 = world.createEntity(); // 2
const e3 = world.createEntity(); // 3
world.addComponent<Position>(e1, 'position', { x: 0, y: 0 });
world.addComponent<Position>(e2, 'position', { x: 1, y: 1 });
world.addComponent<Position>(e3, 'position', { x: 2, y: 2 });
saveEntities(world);
const world2 = new World();
const maxId = loadEntities(world2);
expect(maxId).toBe(3);
// New entity should get id 4
const newEntity = world2.createEntity();
expect(newEntity).toBe(4);
});
});
+259
View File
@@ -0,0 +1,259 @@
import type { EntityId, RelationshipData } from '@dflike/shared';
import type { World } from '../ecs/World.js';
import type { Bond, BondRegistry } from '../systems/bondRegistry.js';
import { createBondRegistry } from '../systems/bondRegistry.js';
import { getDatabase } from './database.js';
/** Components to serialize (excluding name, relationships, position which are handled separately) */
const SERIALIZABLE_COMPONENTS = [
'needs', 'stats', 'appearance', 'npcBrain', 'socialState',
'statModifiers', 'backstory', 'inventory', 'structure', 'movement',
] as const;
function serializeComponent(name: string, data: unknown): string {
switch (name) {
case 'inventory': {
// Map<string, number> -> plain object
const map = data as Map<string, number>;
return JSON.stringify(Object.fromEntries(map));
}
case 'socialState': {
const social = data as Record<string, unknown>;
// Convert pairCooldowns Map, reset transient fields
const serialized = {
...social,
phase: 'none',
partnerId: null,
phaseTimer: 0,
outcome: null,
lastOutcome: null,
pendingProposal: null,
isProposalInteraction: false,
pairCooldowns: Object.fromEntries(
(social.pairCooldowns as Map<EntityId, number>) ?? new Map(),
),
};
return JSON.stringify(serialized);
}
case 'structure': {
const struct = data as Record<string, unknown>;
const serialized = {
...struct,
inventory: Object.fromEntries(
(struct.inventory as Map<string, number>) ?? new Map(),
),
};
return JSON.stringify(serialized);
}
case 'movement': {
const mov = data as Record<string, unknown>;
const serialized = {
...mov,
path: [],
state: 'idle',
target: null,
moveProgress: 0,
};
return JSON.stringify(serialized);
}
case 'npcBrain': {
const brain = data as Record<string, unknown>;
const serialized = {
...brain,
currentGoal: null,
goalQueue: [],
};
return JSON.stringify(serialized);
}
case 'backstory':
// backstory is a string, wrap directly
return JSON.stringify(data);
default:
return JSON.stringify(data);
}
}
function deserializeComponent(name: string, json: string): unknown {
const parsed = JSON.parse(json);
switch (name) {
case 'inventory': {
return new Map(Object.entries(parsed));
}
case 'socialState': {
return {
...parsed,
pairCooldowns: new Map(
Object.entries(parsed.pairCooldowns ?? {}).map(
([k, v]) => [Number(k), v] as [number, number],
),
),
};
}
case 'structure': {
return {
...parsed,
inventory: new Map(Object.entries(parsed.inventory ?? {})),
};
}
case 'backstory':
case 'name':
// These are strings directly
return parsed;
default:
return parsed;
}
}
export function saveEntities(world: World): void {
const db = getDatabase();
const transaction = db.transaction(() => {
// Clear existing entity data
db.exec('DELETE FROM entities');
db.exec('DELETE FROM components');
db.exec('DELETE FROM relationships');
db.exec('DELETE FROM bonds');
const insertEntity = db.prepare(
'INSERT INTO entities (id, type, name, x, y) VALUES (?, ?, ?, ?, ?)',
);
const insertComponent = db.prepare(
'INSERT INTO components (entity_id, component_name, data) VALUES (?, ?, ?)',
);
const insertRelationship = db.prepare(
'INSERT INTO relationships (entity_id, target_id, value, interactions, last_interaction_tick, status) VALUES (?, ?, ?, ?, ?, ?)',
);
const insertBond = db.prepare(
'INSERT INTO bonds (entity_a, entity_b, type, formed_tick, status) VALUES (?, ?, ?, ?, ?)',
);
for (const entityId of world.getAllEntities()) {
// Skip player-controlled entities
if (world.getComponent(entityId, 'playerControlled')) continue;
const position = world.getComponent<{ x: number; y: number }>(entityId, 'position');
if (!position) continue;
// Determine entity type
const hasStructure = !!world.getComponent(entityId, 'structure');
const entityType = hasStructure ? 'structure' : 'npc';
const name = world.getComponent<string>(entityId, 'name') ?? null;
insertEntity.run(entityId, entityType, name, position.x, position.y);
// Serialize components
for (const compName of SERIALIZABLE_COMPONENTS) {
const compData = world.getComponent(entityId, compName);
if (compData !== undefined) {
insertComponent.run(entityId, compName, serializeComponent(compName, compData));
}
}
// Relationships go to dedicated table
const relationships = world.getComponent<Map<EntityId, RelationshipData>>(
entityId, 'relationships',
);
if (relationships) {
for (const [targetId, relData] of relationships) {
insertRelationship.run(
entityId, targetId, relData.value,
relData.interactions, relData.lastInteractionTick, relData.status,
);
}
}
}
// Save bond registry
const bondRegistry = world.getSingleton<BondRegistry>('bondRegistry');
if (bondRegistry) {
for (const [key, bonds] of bondRegistry) {
const [a, b] = key.split(':').map(Number);
for (const bond of bonds) {
insertBond.run(a, b, bond.type, bond.formedAtTick, bond.status);
}
}
}
});
transaction();
}
export function loadEntities(world: World): number {
const db = getDatabase();
let maxId = 0;
// Load entities
const entityRows = db.prepare('SELECT id, type, name, x, y FROM entities ORDER BY id').all() as Array<{
id: number; type: string; name: string | null; x: number; y: number;
}>;
for (const row of entityRows) {
world.createEntityWithId(row.id);
world.addComponent(row.id, 'position', { x: row.x, y: row.y });
if (row.name !== null) {
world.addComponent(row.id, 'name', row.name);
}
if (row.id > maxId) maxId = row.id;
}
// Load components
const componentRows = db.prepare('SELECT entity_id, component_name, data FROM components').all() as Array<{
entity_id: number; component_name: string; data: string;
}>;
for (const row of componentRows) {
const data = deserializeComponent(row.component_name, row.data);
world.addComponent(row.entity_id, row.component_name, data);
}
// Load relationships, grouped by entity_id
const relRows = db.prepare(
'SELECT entity_id, target_id, value, interactions, last_interaction_tick, status FROM relationships',
).all() as Array<{
entity_id: number; target_id: number; value: number;
interactions: number; last_interaction_tick: number; status: string;
}>;
const relMap = new Map<EntityId, Map<EntityId, RelationshipData>>();
for (const row of relRows) {
if (!relMap.has(row.entity_id)) {
relMap.set(row.entity_id, new Map());
}
relMap.get(row.entity_id)!.set(row.target_id, {
value: row.value,
interactions: row.interactions,
lastInteractionTick: row.last_interaction_tick,
status: row.status as 'active' | 'memory',
});
}
for (const [entityId, rels] of relMap) {
world.addComponent(entityId, 'relationships', rels);
}
// Load bonds
const bondRows = db.prepare(
'SELECT entity_a, entity_b, type, formed_tick, status FROM bonds',
).all() as Array<{
entity_a: number; entity_b: number; type: string;
formed_tick: number; status: string;
}>;
const bondRegistry: BondRegistry = createBondRegistry();
for (const row of bondRows) {
const key = `${row.entity_a}:${row.entity_b}`;
if (!bondRegistry.has(key)) {
bondRegistry.set(key, []);
}
bondRegistry.get(key)!.push({
type: row.type,
formedAtTick: row.formed_tick,
status: row.status as 'active' | 'former',
});
}
if (bondRows.length > 0) {
world.setSingleton('bondRegistry', bondRegistry);
}
return maxId;
}