From 067134b684b62fda520e423582b94ef789822aac Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 18 Aug 2026 19:30:31 +0200 Subject: [PATCH] feat: seed demo character and first locations --- apps/api/package.json | 2 +- apps/api/src/database/seeds/run-seed.ts | 17 ++++ .../seeds/vertical-slice.constants.ts | 2 + .../seeds/vertical-slice.seed.spec.ts | 90 +++++++++++++++++++ .../src/database/seeds/vertical-slice.seed.ts | 85 ++++++++++++++++++ apps/api/src/demo/demo-character.constants.ts | 1 + 6 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/database/seeds/run-seed.ts create mode 100644 apps/api/src/database/seeds/vertical-slice.constants.ts create mode 100644 apps/api/src/database/seeds/vertical-slice.seed.spec.ts create mode 100644 apps/api/src/database/seeds/vertical-slice.seed.ts create mode 100644 apps/api/src/demo/demo-character.constants.ts diff --git a/apps/api/package.json b/apps/api/package.json index fe99d4d..825fd3c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -19,7 +19,7 @@ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", "typeorm": "typeorm-ts-node-commonjs", - "seed": "ts-node src/database/seeds/seed.ts" + "seed": "ts-node src/database/seeds/run-seed.ts" }, "dependencies": { "@nestjs/common": "^11.0.1", diff --git a/apps/api/src/database/seeds/run-seed.ts b/apps/api/src/database/seeds/run-seed.ts new file mode 100644 index 0000000..bae4ce2 --- /dev/null +++ b/apps/api/src/database/seeds/run-seed.ts @@ -0,0 +1,17 @@ +import { AppDataSource } from '../data-source'; +import { seedVisibleVerticalSlice } from './vertical-slice.seed'; + +async function runSeed(): Promise { + await AppDataSource.initialize(); + + try { + await seedVisibleVerticalSlice(AppDataSource); + } finally { + await AppDataSource.destroy(); + } +} + +void runSeed().catch((error: unknown) => { + console.error('Demo seed failed:', error); + process.exitCode = 1; +}); diff --git a/apps/api/src/database/seeds/vertical-slice.constants.ts b/apps/api/src/database/seeds/vertical-slice.constants.ts new file mode 100644 index 0000000..9587882 --- /dev/null +++ b/apps/api/src/database/seeds/vertical-slice.constants.ts @@ -0,0 +1,2 @@ +export const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001'; +export const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002'; diff --git a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts new file mode 100644 index 0000000..0773eca --- /dev/null +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -0,0 +1,90 @@ +import { DataSource } from 'typeorm'; +import { seedVisibleVerticalSlice } from './vertical-slice.seed'; + +type Row = Record; + +const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; +const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001'; +const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002'; + +class InMemoryRepository { + readonly rows: Row[] = []; + readonly upsert = jest.fn( + async ( + values: Row | Row[], + conflictPaths: string[] | { conflictPaths: string[] }, + ) => { + const conflictKeys = Array.isArray(conflictPaths) + ? conflictPaths + : conflictPaths.conflictPaths; + + for (const value of Array.isArray(values) ? values : [values]) { + const existing = this.rows.find((row) => + conflictKeys.every((key) => row[key] === value[key]), + ); + + if (existing) { + Object.assign(existing, value); + } else { + this.rows.push({ ...value }); + } + } + }, + ); + readonly findOneBy = jest.fn(async (criteria: Row) => + this.rows.find((row) => + Object.entries(criteria).every(([key, value]) => row[key] === value), + ), + ); + readonly insert = jest.fn(async (value: Row) => { + this.rows.push({ ...value }); + }); +} + +describe('seedVisibleVerticalSlice', () => { + it('upserts the two locations and directed connections while inserting one stable demo character', async () => { + const locationRepository = new InMemoryRepository(); + const connectionRepository = new InMemoryRepository(); + const characterRepository = new InMemoryRepository(); + const dataSource = { + getRepository: jest + .fn() + .mockReturnValueOnce(locationRepository) + .mockReturnValueOnce(connectionRepository) + .mockReturnValueOnce(characterRepository) + .mockReturnValueOnce(locationRepository) + .mockReturnValueOnce(connectionRepository) + .mockReturnValueOnce(characterRepository), + } as unknown as DataSource; + + await seedVisibleVerticalSlice(dataSource); + await seedVisibleVerticalSlice(dataSource); + + expect(locationRepository.upsert).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ id: SOUTH_GATE_ID, key: 'south-gate' }), + expect.objectContaining({ id: BURNED_ROAD_ID, key: 'burned-road' }), + ]), + ['key'], + ); + expect(connectionRepository.upsert).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + fromLocationId: SOUTH_GATE_ID, + toLocationId: BURNED_ROAD_ID, + }), + expect.objectContaining({ + fromLocationId: BURNED_ROAD_ID, + toLocationId: SOUTH_GATE_ID, + }), + ]), + ['fromLocationId', 'toLocationId'], + ); + expect(characterRepository.insert).toHaveBeenCalledWith( + expect.objectContaining({ id: DEMO_CHARACTER_ID }), + ); + expect(locationRepository.rows).toHaveLength(2); + expect(connectionRepository.rows).toHaveLength(2); + expect(characterRepository.rows).toHaveLength(1); + }); +}); diff --git a/apps/api/src/database/seeds/vertical-slice.seed.ts b/apps/api/src/database/seeds/vertical-slice.seed.ts new file mode 100644 index 0000000..9b54917 --- /dev/null +++ b/apps/api/src/database/seeds/vertical-slice.seed.ts @@ -0,0 +1,85 @@ +import { DataSource } from 'typeorm'; +import { DEMO_CHARACTER_ID } from '../../demo/demo-character.constants'; +import { Character } from '../../characters/entities/character.entity'; +import { LocationConnection } from '../../world/entities/location-connection.entity'; +import { LocationDefinition } from '../../world/entities/location-definition.entity'; +import { BURNED_ROAD_ID, SOUTH_GATE_ID } from './vertical-slice.constants'; + +export async function seedVisibleVerticalSlice( + dataSource: DataSource, +): Promise { + const locationRepository = dataSource.getRepository(LocationDefinition); + const connectionRepository = dataSource.getRepository(LocationConnection); + const characterRepository = dataSource.getRepository(Character); + + await locationRepository.upsert( + [ + { + id: SOUTH_GATE_ID, + key: 'south-gate', + name: 'Südtor von Graufurt', + description: + 'Am schwarzen Südtor endet der Schutz Graufurts. Hinter den Wachtfeuern beginnt die stille Weite der Aschenfelder.', + regionKey: 'ashen-fields', + minRecommendedLevel: 1, + maxRecommendedLevel: 1, + dangerLevel: 0, + isSafe: true, + huntingEnabled: false, + artworkPath: '/assets/locations/south-gate.webp', + }, + { + id: BURNED_ROAD_ID, + key: 'burned-road', + name: 'Verbrannte Straße', + description: + 'Die alte Handelsstraße führt durch verkohlte Felder. Zwischen Asche und zerbrochenen Wagen warten die ersten Gefahren.', + regionKey: 'ashen-fields', + minRecommendedLevel: 1, + maxRecommendedLevel: 2, + dangerLevel: 1, + isSafe: false, + huntingEnabled: true, + artworkPath: '/assets/locations/burned-road.webp', + }, + ], + ['key'], + ); + + await connectionRepository.upsert( + [ + { + fromLocationId: SOUTH_GATE_ID, + toLocationId: BURNED_ROAD_ID, + travelDurationSeconds: 10, + ambushChance: '0.0500', + enabled: true, + }, + { + fromLocationId: BURNED_ROAD_ID, + toLocationId: SOUTH_GATE_ID, + travelDurationSeconds: 10, + ambushChance: '0.0500', + enabled: true, + }, + ], + ['fromLocationId', 'toLocationId'], + ); + + const existing = await characterRepository.findOneBy({ + id: DEMO_CHARACTER_ID, + }); + + if (!existing) { + await characterRepository.insert({ + id: DEMO_CHARACTER_ID, + name: 'Aric Duskwalker', + level: 1, + experience: 0, + baseHp: 100, + baseAttack: 6, + currentHp: 100, + currentLocationId: SOUTH_GATE_ID, + }); + } +} diff --git a/apps/api/src/demo/demo-character.constants.ts b/apps/api/src/demo/demo-character.constants.ts new file mode 100644 index 0000000..e79635b --- /dev/null +++ b/apps/api/src/demo/demo-character.constants.ts @@ -0,0 +1 @@ +export const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001';