feat: seed demo character and first locations
This commit is contained in:
@@ -19,7 +19,7 @@
|
|||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"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",
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
"typeorm": "typeorm-ts-node-commonjs",
|
"typeorm": "typeorm-ts-node-commonjs",
|
||||||
"seed": "ts-node src/database/seeds/seed.ts"
|
"seed": "ts-node src/database/seeds/run-seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
|
|||||||
17
apps/api/src/database/seeds/run-seed.ts
Normal file
17
apps/api/src/database/seeds/run-seed.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { AppDataSource } from '../data-source';
|
||||||
|
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
|
||||||
|
|
||||||
|
async function runSeed(): Promise<void> {
|
||||||
|
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;
|
||||||
|
});
|
||||||
2
apps/api/src/database/seeds/vertical-slice.constants.ts
Normal file
2
apps/api/src/database/seeds/vertical-slice.constants.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001';
|
||||||
|
export const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
|
||||||
90
apps/api/src/database/seeds/vertical-slice.seed.spec.ts
Normal file
90
apps/api/src/database/seeds/vertical-slice.seed.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
85
apps/api/src/database/seeds/vertical-slice.seed.ts
Normal file
85
apps/api/src/database/seeds/vertical-slice.seed.ts
Normal file
@@ -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<void> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/api/src/demo/demo-character.constants.ts
Normal file
1
apps/api/src/demo/demo-character.constants.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||||
Reference in New Issue
Block a user