diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 209b437..fff7a2a 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { CharactersModule } from './characters/characters.module'; import { DatabaseModule } from './database/database.module'; import { HealthModule } from './health/health.module'; +import { HuntingModule } from './hunting/hunting.module'; import { TravelModule } from './travel/travel.module'; import { WorldModule } from './world/world.module'; @@ -12,6 +13,7 @@ import { WorldModule } from './world/world.module'; CharactersModule, TravelModule, WorldModule, + HuntingModule, ], }) export class AppModule {} diff --git a/apps/api/src/hunting/hunting.controller.spec.ts b/apps/api/src/hunting/hunting.controller.spec.ts new file mode 100644 index 0000000..7cef911 --- /dev/null +++ b/apps/api/src/hunting/hunting.controller.spec.ts @@ -0,0 +1,50 @@ +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { configureApplication } from '../app.config'; +import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; +import { HuntingController } from './hunting.controller'; +import { HuntingService } from './hunting.service'; + +describe('HuntingController', () => { + let app: INestApplication; + const startHunt = jest.fn(); + + beforeEach(async () => { + startHunt.mockReset(); + const module = await Test.createTestingModule({ + controllers: [HuntingController], + providers: [ + { + provide: HuntingService, + useValue: { startHunt }, + }, + ], + }).compile(); + + app = module.createNestApplication(); + configureApplication(app); + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('delegates to huntingService.startHunt with the demo character id and returns its result', async () => { + const huntResult = { + id: 'hunt-1', + location: { id: 'loc-1', key: 'burned-road', name: 'Verbrannte Strasse' }, + encounters: [], + }; + startHunt.mockResolvedValue(huntResult); + + const response = await request(app.getHttpServer()) + .post('/api/hunts') + .expect(201); + + expect(startHunt).toHaveBeenCalledWith(DEMO_CHARACTER_ID); + expect(response.body).toEqual(huntResult); + }); +}); diff --git a/apps/api/src/hunting/hunting.controller.ts b/apps/api/src/hunting/hunting.controller.ts new file mode 100644 index 0000000..d23a863 --- /dev/null +++ b/apps/api/src/hunting/hunting.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Post } from '@nestjs/common'; +import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; +import { HuntResultDto, HuntingService } from './hunting.service'; + +@Controller('hunts') +export class HuntingController { + constructor(private readonly huntingService: HuntingService) {} + + @Post() + startHunt(): Promise { + return this.huntingService.startHunt(DEMO_CHARACTER_ID); + } +} diff --git a/apps/api/src/hunting/hunting.module.ts b/apps/api/src/hunting/hunting.module.ts new file mode 100644 index 0000000..e963735 --- /dev/null +++ b/apps/api/src/hunting/hunting.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Character } from '../characters/entities/character.entity'; +import { LocationMonster } from '../monsters/entities/location-monster.entity'; +import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; +import { TravelModule } from '../travel/travel.module'; +import { Hunt } from './entities/hunt.entity'; +import { HuntEncounter } from './entities/hunt-encounter.entity'; +import { HuntingController } from './hunting.controller'; +import { HuntingService } from './hunting.service'; +import { RANDOM_SOURCE, systemRandomSource } from './random-source'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + MonsterDefinition, + LocationMonster, + Hunt, + HuntEncounter, + Character, + ]), + TravelModule, + ], + controllers: [HuntingController], + providers: [ + HuntingService, + { provide: RANDOM_SOURCE, useValue: systemRandomSource }, + ], +}) +export class HuntingModule {} diff --git a/apps/api/src/world/world.module.ts b/apps/api/src/world/world.module.ts index c83a42a..f89e016 100644 --- a/apps/api/src/world/world.module.ts +++ b/apps/api/src/world/world.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Character } from '../characters/entities/character.entity'; +import { LocationMonster } from '../monsters/entities/location-monster.entity'; +import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; import { TravelModule } from '../travel/travel.module'; import { LocationConnection } from './entities/location-connection.entity'; import { WorldController } from './world.controller'; @@ -8,7 +10,12 @@ import { WorldService } from './world.service'; @Module({ imports: [ - TypeOrmModule.forFeature([Character, LocationConnection]), + TypeOrmModule.forFeature([ + Character, + LocationConnection, + LocationMonster, + MonsterDefinition, + ]), TravelModule, ], controllers: [WorldController], diff --git a/apps/api/src/world/world.service.spec.ts b/apps/api/src/world/world.service.spec.ts index dfe4f02..edb37d1 100644 --- a/apps/api/src/world/world.service.spec.ts +++ b/apps/api/src/world/world.service.spec.ts @@ -5,6 +5,7 @@ import { BURNED_ROAD_ID, SOUTH_GATE_ID, } from '../database/seeds/vertical-slice.constants'; +import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { TravelService } from '../travel/travel.service'; import { LocationConnection } from './entities/location-connection.entity'; import { LocationDefinition } from './entities/location-definition.entity'; @@ -102,7 +103,16 @@ describe('WorldService', () => { const connections = { find: findConnections, } as unknown as Repository; - const service = new WorldService(travelService, characters, connections); + const findLocationMonsters = jest.fn(); + const locationMonsters = { + find: findLocationMonsters, + } as unknown as Repository; + const service = new WorldService( + travelService, + characters, + connections, + locationMonsters, + ); const result = await service.getCurrentLocation(CHARACTER_ID); @@ -131,6 +141,7 @@ describe('WorldService', () => { danger: 'LOW', }, ], + possibleMonsters: [], }); expect(findCharacter).toHaveBeenCalledWith({ where: { id: CHARACTER_ID }, @@ -140,6 +151,51 @@ describe('WorldService', () => { where: { fromLocationId: SOUTH_GATE_ID, enabled: true }, relations: { toLocation: true }, }); + expect(findLocationMonsters).not.toHaveBeenCalled(); + }); + + it('returns the enabled monster pool by name, ordered by weight descending, when hunting is enabled', async () => { + const location = burnedRoad(); + const travelService = { + completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }), + } as unknown as TravelService; + const characters = { + findOne: jest.fn().mockResolvedValue({ + id: CHARACTER_ID, + currentLocationId: BURNED_ROAD_ID, + currentLocation: location, + }), + } as unknown as Repository; + const connections = { + find: jest.fn().mockResolvedValue([]), + } as unknown as Repository; + const findLocationMonsters = jest + .fn() + .mockResolvedValue([ + { monster: { name: 'Aschenratte' } }, + { monster: { name: 'Stra\u00dfenr\u00e4uber' } }, + ]); + const locationMonsters = { + find: findLocationMonsters, + } as unknown as Repository; + const service = new WorldService( + travelService, + characters, + connections, + locationMonsters, + ); + + const result = await service.getCurrentLocation(CHARACTER_ID); + + expect(result.possibleMonsters).toEqual([ + 'Aschenratte', + 'Stra\u00dfenr\u00e4uber', + ]); + expect(findLocationMonsters).toHaveBeenCalledWith({ + where: { locationId: BURNED_ROAD_ID, enabled: true }, + relations: { monster: true }, + order: { weight: 'DESC' }, + }); }); it('reports a missing character after travel completion', async () => { @@ -157,11 +213,21 @@ describe('WorldService', () => { const connections = { find: findConnections, } as unknown as Repository; - const service = new WorldService(travelService, characters, connections); + const findLocationMonsters = jest.fn(); + const locationMonsters = { + find: findLocationMonsters, + } as unknown as Repository; + const service = new WorldService( + travelService, + characters, + connections, + locationMonsters, + ); await expect( service.getCurrentLocation(CHARACTER_ID), ).rejects.toBeInstanceOf(NotFoundException); expect(findConnections).not.toHaveBeenCalled(); + expect(findLocationMonsters).not.toHaveBeenCalled(); }); }); diff --git a/apps/api/src/world/world.service.ts b/apps/api/src/world/world.service.ts index e41d662..703b4fe 100644 --- a/apps/api/src/world/world.service.ts +++ b/apps/api/src/world/world.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Character } from '../characters/entities/character.entity'; +import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { TravelService } from '../travel/travel.service'; import { LocationConnection } from './entities/location-connection.entity'; @@ -30,6 +31,7 @@ export interface CurrentLocationResponse { huntingEnabled: boolean; artworkPath: string; connections: CurrentLocationConnection[]; + possibleMonsters: string[]; } @Injectable() @@ -40,6 +42,8 @@ export class WorldService { private readonly characters: Repository, @InjectRepository(LocationConnection) private readonly connections: Repository, + @InjectRepository(LocationMonster) + private readonly locationMonsters: Repository, ) {} async getCurrentLocation( @@ -61,6 +65,10 @@ export class WorldService { }); const location = character.currentLocation; + const possibleMonsters = location.huntingEnabled + ? await this.getPossibleMonsters(location.id) + : []; + return { id: location.id, key: location.key, @@ -84,9 +92,19 @@ export class WorldService { travelDurationSeconds: connection.travelDurationSeconds, danger: this.toDangerRating(connection.ambushChance), })), + possibleMonsters, }; } + private async getPossibleMonsters(locationId: string): Promise { + const pool = await this.locationMonsters.find({ + where: { locationId, enabled: true }, + relations: { monster: true }, + order: { weight: 'DESC' }, + }); + return pool.map((entry) => entry.monster.name); + } + private toDangerRating(ambushChance: string): 'LOW' | 'HIGH' { return Number(ambushChance) <= 0.05 ? 'LOW' : 'HIGH'; } diff --git a/apps/api/test/app.e2e-spec.ts b/apps/api/test/app.e2e-spec.ts index 04ff4f6..d7f1fd8 100644 --- a/apps/api/test/app.e2e-spec.ts +++ b/apps/api/test/app.e2e-spec.ts @@ -6,6 +6,7 @@ import { AppModule } from './../src/app.module'; import { CharactersModule } from './../src/characters/characters.module'; import { DatabaseModule } from './../src/database/database.module'; import { configureApplication } from './../src/app.config'; +import { HuntingModule } from './../src/hunting/hunting.module'; import { TravelModule } from './../src/travel/travel.module'; import { WorldModule } from './../src/world/world.module'; @@ -21,6 +22,9 @@ class TestTravelModule {} @Module({}) class TestWorldModule {} +@Module({}) +class TestHuntingModule {} + describe('API (e2e)', () => { let app: INestApplication; @@ -36,6 +40,8 @@ describe('API (e2e)', () => { .useModule(TestTravelModule) .overrideModule(WorldModule) .useModule(TestWorldModule) + .overrideModule(HuntingModule) + .useModule(TestHuntingModule) .compile(); app = moduleFixture.createNestApplication(); diff --git a/apps/api/test/visible-slice.e2e-spec.ts b/apps/api/test/visible-slice.e2e-spec.ts index 28a4473..f5b9263 100644 --- a/apps/api/test/visible-slice.e2e-spec.ts +++ b/apps/api/test/visible-slice.e2e-spec.ts @@ -8,6 +8,7 @@ import { AppModule } from './../src/app.module'; import { CharactersModule } from './../src/characters/characters.module'; import { DatabaseModule } from './../src/database/database.module'; import { configureApplication } from './../src/app.config'; +import { HuntingModule } from './../src/hunting/hunting.module'; import { TravelModule } from './../src/travel/travel.module'; import { WorldModule } from './../src/world/world.module'; import { DEMO_CHARACTER_ID } from './../src/demo/demo-character.constants'; @@ -31,6 +32,9 @@ class TestTravelModule {} @Module({}) class TestWorldModule {} +@Module({}) +class TestHuntingModule {} + describe('Visible vertical slice smoke (e2e)', () => { describe('without a developer database', () => { let app: INestApplication; @@ -47,6 +51,8 @@ describe('Visible vertical slice smoke (e2e)', () => { .useModule(TestTravelModule) .overrideModule(WorldModule) .useModule(TestWorldModule) + .overrideModule(HuntingModule) + .useModule(TestHuntingModule) .compile(); app = moduleFixture.createNestApplication(); @@ -153,57 +159,151 @@ describe('Visible vertical slice smoke (e2e)', () => { .expect(400); }); - it( - 'POST /api/travel starts a travel, rejects a concurrent start, and completes into the moved character (full happy path)', - async () => { - // `GET current-location` lazily completes any overdue travel left - // behind by a previous run before we read the starting point, so - // this test is safe to re-run without a fresh seed. - const origin = await request(app.getHttpServer()) - .get('/api/world/current-location') - .expect(200); - const originLocationId: string = origin.body.id; - const outbound = origin.body.connections[0]; - const targetLocationId: string = outbound.targetLocation.id; - const travelDurationSeconds: number = outbound.travelDurationSeconds; + it('POST /api/travel starts a travel, rejects a concurrent start, and completes into the moved character (full happy path)', async () => { + // `GET current-location` lazily completes any overdue travel left + // behind by a previous run before we read the starting point, so + // this test is safe to re-run without a fresh seed. + const origin = await request(app.getHttpServer()) + .get('/api/world/current-location') + .expect(200); + const originLocationId: string = origin.body.id; + const outbound = origin.body.connections[0]; + const targetLocationId: string = outbound.targetLocation.id; + const travelDurationSeconds: number = outbound.travelDurationSeconds; - const started = await request(app.getHttpServer()) + const started = await request(app.getHttpServer()) + .post('/api/travel') + .send({ targetLocationId }) + .expect(201); + + expect(started.body).toMatchObject({ + status: 'TRAVELLING', + targetLocation: { id: targetLocationId }, + }); + expect(typeof started.body.arrivesAt).toBe('string'); + expect(Number.isNaN(Date.parse(started.body.arrivesAt))).toBe(false); + + const concurrentStart = await request(app.getHttpServer()) + .post('/api/travel') + .send({ targetLocationId }) + .expect(409); + expect(concurrentStart.body).toMatchObject({ + code: 'TRAVEL_ALREADY_ACTIVE', + }); + + const arrivedTravel = await pollUntilTravelCompletes( + app, + travelDurationSeconds, + ); + expect(arrivedTravel).toMatchObject({ + status: 'COMPLETED', + targetLocation: { id: targetLocationId }, + }); + + const arrivedLocation = await request(app.getHttpServer()) + .get('/api/world/current-location') + .expect(200); + expect(arrivedLocation.body.id).toBe(targetLocationId); + + // Restore the demo character to its original location so the suite + // (and this test) stays safely re-runnable. + const returnConnection = arrivedLocation.body.connections.find( + (connection: { targetLocation: { id: string } }) => + connection.targetLocation.id === originLocationId, + ); + expect(returnConnection).toBeDefined(); + + await request(app.getHttpServer()) + .post('/api/travel') + .send({ targetLocationId: originLocationId }) + .expect(201); + + const returnedTravel = await pollUntilTravelCompletes( + app, + returnConnection.travelDurationSeconds, + ); + expect(returnedTravel).toMatchObject({ + status: 'COMPLETED', + targetLocation: { id: originLocationId }, + }); + + const restoredLocation = await request(app.getHttpServer()) + .get('/api/world/current-location') + .expect(200); + expect(restoredLocation.body.id).toBe(originLocationId); + }, 30_000); + + it('POST /api/hunts starts a hunt at burned-road, and a second call supersedes the first', async () => { + // Get to burned-road (the only hunting-enabled seeded location), + // remembering the origin so we can restore it afterwards and keep + // this test safely re-runnable. + const origin = await request(app.getHttpServer()) + .get('/api/world/current-location') + .expect(200); + const originLocationId: string = origin.body.id; + const originLocationKey: string = origin.body.key; + + let atBurnedRoad = origin.body; + if (originLocationKey !== 'burned-road') { + const toBurnedRoad = origin.body.connections.find( + (connection: { targetLocation: { key: string } }) => + connection.targetLocation.key === 'burned-road', + ); + expect(toBurnedRoad).toBeDefined(); + + await request(app.getHttpServer()) .post('/api/travel') - .send({ targetLocationId }) + .send({ targetLocationId: toBurnedRoad.targetLocation.id }) .expect(201); - expect(started.body).toMatchObject({ - status: 'TRAVELLING', - targetLocation: { id: targetLocationId }, - }); - expect(typeof started.body.arrivesAt).toBe('string'); - expect(Number.isNaN(Date.parse(started.body.arrivesAt))).toBe(false); + await pollUntilTravelCompletes(app, toBurnedRoad.travelDurationSeconds); - const concurrentStart = await request(app.getHttpServer()) - .post('/api/travel') - .send({ targetLocationId }) - .expect(409); - expect(concurrentStart.body).toMatchObject({ - code: 'TRAVEL_ALREADY_ACTIVE', - }); - - const arrivedTravel = await pollUntilTravelCompletes( - app, - travelDurationSeconds, - ); - expect(arrivedTravel).toMatchObject({ - status: 'COMPLETED', - targetLocation: { id: targetLocationId }, - }); - - const arrivedLocation = await request(app.getHttpServer()) + const arrived = await request(app.getHttpServer()) .get('/api/world/current-location') .expect(200); - expect(arrivedLocation.body.id).toBe(targetLocationId); + atBurnedRoad = arrived.body; + } - // Restore the demo character to its original location so the suite - // (and this test) stays safely re-runnable. - const returnConnection = arrivedLocation.body.connections.find( + expect(atBurnedRoad.key).toBe('burned-road'); + expect(atBurnedRoad.possibleMonsters).toEqual([ + 'Aschenratte', + 'Straßenräuber', + ]); + + const firstHunt = await request(app.getHttpServer()) + .post('/api/hunts') + .expect(201); + + expect(typeof firstHunt.body.id).toBe('string'); + expect(firstHunt.body.location).toMatchObject({ key: 'burned-road' }); + expect(firstHunt.body.encounters).toHaveLength(3); + for (const encounter of firstHunt.body.encounters as Array<{ + id: string; + monster: { key: string }; + dangerRating: string; + }>) { + expect(typeof encounter.id).toBe('string'); + expect(['ash-rat', 'road-bandit']).toContain(encounter.monster.key); + expect([ + 'WEAK', + 'MATCH', + 'STRONG', + 'VERY_DANGEROUS', + 'DEADLY', + ]).toContain(encounter.dangerRating); + } + + const secondHunt = await request(app.getHttpServer()) + .post('/api/hunts') + .expect(201); + + expect(typeof secondHunt.body.id).toBe('string'); + expect(secondHunt.body.id).not.toBe(firstHunt.body.id); + + // Restore the demo character to its original location so the suite + // stays safely re-runnable. + if (originLocationKey !== 'burned-road') { + const returnConnection = atBurnedRoad.connections.find( (connection: { targetLocation: { id: string } }) => connection.targetLocation.id === originLocationId, ); @@ -214,22 +314,12 @@ describe('Visible vertical slice smoke (e2e)', () => { .send({ targetLocationId: originLocationId }) .expect(201); - const returnedTravel = await pollUntilTravelCompletes( + await pollUntilTravelCompletes( app, returnConnection.travelDurationSeconds, ); - expect(returnedTravel).toMatchObject({ - status: 'COMPLETED', - targetLocation: { id: originLocationId }, - }); - - const restoredLocation = await request(app.getHttpServer()) - .get('/api/world/current-location') - .expect(200); - expect(restoredLocation.body.id).toBe(originLocationId); - }, - 30_000, - ); + } + }, 30_000); async function pollUntilTravelCompletes( application: INestApplication,