From c641168c7dfe721aed16180fdf4166fa7a403be2 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 18 Aug 2026 20:57:30 +0200 Subject: [PATCH] feat: expose current world location --- .../task-6-report.md | 83 +++++++++ apps/api/src/app.module.ts | 9 +- apps/api/src/world/world.controller.ts | 13 ++ apps/api/src/world/world.module.ts | 17 ++ apps/api/src/world/world.service.spec.ts | 167 ++++++++++++++++++ apps/api/src/world/world.service.ts | 93 ++++++++++ apps/api/test/app.e2e-spec.ts | 6 + 7 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 .superpowers/sdd/2026-08-18-first-visible-vertical-slice/task-6-report.md create mode 100644 apps/api/src/world/world.controller.ts create mode 100644 apps/api/src/world/world.module.ts create mode 100644 apps/api/src/world/world.service.spec.ts create mode 100644 apps/api/src/world/world.service.ts diff --git a/.superpowers/sdd/2026-08-18-first-visible-vertical-slice/task-6-report.md b/.superpowers/sdd/2026-08-18-first-visible-vertical-slice/task-6-report.md new file mode 100644 index 0000000..2ed8589 --- /dev/null +++ b/.superpowers/sdd/2026-08-18-first-visible-vertical-slice/task-6-report.md @@ -0,0 +1,83 @@ +# Task 6 — Current-location World API report + +## Scope delivered + +- Added `WorldService.getCurrentLocation(characterId)`. +- Added `GET /api/world/current-location` using the stable demo character. +- Added `WorldModule`, including TypeORM repositories for `Character` and + `LocationConnection`, and imported it through `AppModule`. +- `WorldService` completes due travel before loading the character, returns the + complete current-location DTO, returns only enabled outgoing connections, and + exposes a target summary, duration, and textual danger rating only. The raw + `ambushChance` is not part of the public DTO; `0.0500` maps to `LOW`. +- Kept the existing database-free health E2E harness valid by overriding the + newly imported `WorldModule`, just as it already overrides the database, + character, and travel modules. + +## Required preflight + +- Read the complete Task-6 brief and the binding project documentation, + including the visual asset style guide. +- Inspected `world-travel-screen.png`, `combat-screen.png`, and + `hunting-screen.png` in `docs/references/`. +- Preserved the user-owned untracked `apps/web/public/images/` directory and + `docs/references/Ashen_Realms_Visual_Asset_Style_Guide_V1.md` unchanged. + +## TDD evidence + +### RED + +Command: + +```powershell +npm test --workspace=@ashen-realms/api -- world.service.spec.ts --runInBand +``` + +Initial result: failed because `./world.service` did not exist. After the +minimal initial implementation, the test exposed that a disabled connection +could still be returned by an unexpected repository result. The service now +defensively filters disabled connections in addition to querying with +`enabled: true`. + +### GREEN + +```powershell +npm test --workspace=@ashen-realms/api -- world.service.spec.ts --runInBand +``` + +Result: 1 suite passed, 2 tests passed. + +## Verification evidence + +```powershell +npm test --workspace=@ashen-realms/api -- --runInBand +# 8 suites passed, 20 tests passed + +npm run test:e2e --workspace=@ashen-realms/api -- --runInBand +# 1 suite passed, 2 tests passed + +npm run build:api +# Nest build completed successfully + +# From apps/api: +.\node_modules\.bin\prettier.cmd --check +npx eslint +# Prettier: all matched files use Prettier code style; ESLint exit 0 + +git diff --check -- apps/api/src/app.module.ts apps/api/src/world apps/api/test/app.e2e-spec.ts +# exit 0 +``` + +The initial E2E run was reproducibly red because the new `WorldModule` caused +TypeORM repository construction after the test had intentionally replaced the +database module. Adding its corresponding empty module override restored the +existing database-free health test without changing product behavior. + +## Deliberate limits and concerns + +- The approved first slice only needs the seeded 5% route, so the public + connection danger mapping currently distinguishes `LOW` (at or below 5%) and + `HIGH` (above 5%). Future routes may require finer danger tiers when their + product thresholds are specified. +- This task adds no database-backed route E2E assertion; the dedicated + migration/seed route smoke test is scheduled for Task 10. diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 9c646e3..209b437 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -3,8 +3,15 @@ import { CharactersModule } from './characters/characters.module'; import { DatabaseModule } from './database/database.module'; import { HealthModule } from './health/health.module'; import { TravelModule } from './travel/travel.module'; +import { WorldModule } from './world/world.module'; @Module({ - imports: [DatabaseModule, HealthModule, CharactersModule, TravelModule], + imports: [ + DatabaseModule, + HealthModule, + CharactersModule, + TravelModule, + WorldModule, + ], }) export class AppModule {} diff --git a/apps/api/src/world/world.controller.ts b/apps/api/src/world/world.controller.ts new file mode 100644 index 0000000..6c28032 --- /dev/null +++ b/apps/api/src/world/world.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; +import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; +import { WorldService } from './world.service'; + +@Controller('world') +export class WorldController { + constructor(private readonly worldService: WorldService) {} + + @Get('current-location') + getCurrentLocation() { + return this.worldService.getCurrentLocation(DEMO_CHARACTER_ID); + } +} diff --git a/apps/api/src/world/world.module.ts b/apps/api/src/world/world.module.ts new file mode 100644 index 0000000..c83a42a --- /dev/null +++ b/apps/api/src/world/world.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Character } from '../characters/entities/character.entity'; +import { TravelModule } from '../travel/travel.module'; +import { LocationConnection } from './entities/location-connection.entity'; +import { WorldController } from './world.controller'; +import { WorldService } from './world.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Character, LocationConnection]), + TravelModule, + ], + controllers: [WorldController], + providers: [WorldService], +}) +export class WorldModule {} diff --git a/apps/api/src/world/world.service.spec.ts b/apps/api/src/world/world.service.spec.ts new file mode 100644 index 0000000..dfe4f02 --- /dev/null +++ b/apps/api/src/world/world.service.spec.ts @@ -0,0 +1,167 @@ +import { NotFoundException } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { Character } from '../characters/entities/character.entity'; +import { + BURNED_ROAD_ID, + SOUTH_GATE_ID, +} from '../database/seeds/vertical-slice.constants'; +import { TravelService } from '../travel/travel.service'; +import { LocationConnection } from './entities/location-connection.entity'; +import { LocationDefinition } from './entities/location-definition.entity'; +import { WorldService } from './world.service'; + +const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; + +function currentLocation(): LocationDefinition { + return { + id: SOUTH_GATE_ID, + key: 'south-gate', + name: 'S\u00fcdtor von Graufurt', + description: + 'Das S\u00fcdtor ist der sichere Ausgangspunkt nach S\u00fcden.', + regionKey: 'ashen-fields', + minRecommendedLevel: 1, + maxRecommendedLevel: 1, + dangerLevel: 0, + isSafe: true, + huntingEnabled: false, + artworkPath: '/assets/locations/south-gate.webp', + createdAt: new Date('2026-08-18T09:00:00.000Z'), + updatedAt: new Date('2026-08-18T09:00:00.000Z'), + characters: [], + outgoingConnections: [], + incomingConnections: [], + }; +} + +function burnedRoad(): LocationDefinition { + return { + id: BURNED_ROAD_ID, + key: 'burned-road', + name: 'Verbrannte Stra\u00dfe', + description: 'Eine verbrannte Handelsroute durch die Aschenfelder.', + regionKey: 'ashen-fields', + minRecommendedLevel: 1, + maxRecommendedLevel: 2, + dangerLevel: 1, + isSafe: false, + huntingEnabled: true, + artworkPath: '/assets/locations/burned-road.webp', + createdAt: new Date('2026-08-18T09:00:00.000Z'), + updatedAt: new Date('2026-08-18T09:00:00.000Z'), + characters: [], + outgoingConnections: [], + incomingConnections: [], + }; +} + +describe('WorldService', () => { + it('returns the authoritative current location and only enabled public connections', async () => { + const callOrder: string[] = []; + const location = currentLocation(); + const completeTravelIfDue = jest.fn().mockImplementation(() => { + callOrder.push('completeTravelIfDue'); + return Promise.resolve({ status: 'IDLE' }); + }); + const travelService = { + completeTravelIfDue, + } as unknown as TravelService; + const findCharacter = jest.fn().mockImplementation(() => { + callOrder.push('findCharacter'); + return Promise.resolve({ + id: CHARACTER_ID, + currentLocationId: SOUTH_GATE_ID, + currentLocation: location, + }); + }); + const characters = { + findOne: findCharacter, + } as unknown as Repository; + const findConnections = jest.fn().mockResolvedValue([ + { + id: '30000000-0000-4000-8000-000000000001', + fromLocationId: SOUTH_GATE_ID, + toLocationId: BURNED_ROAD_ID, + travelDurationSeconds: 10, + ambushChance: '0.0500', + enabled: true, + fromLocation: location, + toLocation: burnedRoad(), + }, + { + id: '30000000-0000-4000-8000-000000000002', + fromLocationId: SOUTH_GATE_ID, + toLocationId: '20000000-0000-4000-8000-000000000003', + travelDurationSeconds: 30, + ambushChance: '0.9000', + enabled: false, + fromLocation: location, + toLocation: burnedRoad(), + }, + ]); + const connections = { + find: findConnections, + } as unknown as Repository; + const service = new WorldService(travelService, characters, connections); + + const result = await service.getCurrentLocation(CHARACTER_ID); + + expect(callOrder).toEqual(['completeTravelIfDue', 'findCharacter']); + expect(result).toEqual({ + id: SOUTH_GATE_ID, + key: 'south-gate', + name: 'S\u00fcdtor von Graufurt', + description: + 'Das S\u00fcdtor ist der sichere Ausgangspunkt nach S\u00fcden.', + regionKey: 'ashen-fields', + minRecommendedLevel: 1, + maxRecommendedLevel: 1, + dangerLevel: 0, + isSafe: true, + huntingEnabled: false, + artworkPath: '/assets/locations/south-gate.webp', + connections: [ + { + targetLocation: { + id: BURNED_ROAD_ID, + key: 'burned-road', + name: 'Verbrannte Stra\u00dfe', + }, + travelDurationSeconds: 10, + danger: 'LOW', + }, + ], + }); + expect(findCharacter).toHaveBeenCalledWith({ + where: { id: CHARACTER_ID }, + relations: { currentLocation: true }, + }); + expect(findConnections).toHaveBeenCalledWith({ + where: { fromLocationId: SOUTH_GATE_ID, enabled: true }, + relations: { toLocation: true }, + }); + }); + + it('reports a missing character after travel completion', async () => { + const completeTravelIfDue = jest.fn().mockResolvedValue({ + status: 'IDLE', + }); + const travelService = { + completeTravelIfDue, + } as unknown as TravelService; + const findCharacter = jest.fn().mockResolvedValue(null); + const characters = { + findOne: findCharacter, + } as unknown as Repository; + const findConnections = jest.fn(); + const connections = { + find: findConnections, + } as unknown as Repository; + const service = new WorldService(travelService, characters, connections); + + await expect( + service.getCurrentLocation(CHARACTER_ID), + ).rejects.toBeInstanceOf(NotFoundException); + expect(findConnections).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/world/world.service.ts b/apps/api/src/world/world.service.ts new file mode 100644 index 0000000..e41d662 --- /dev/null +++ b/apps/api/src/world/world.service.ts @@ -0,0 +1,93 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Character } from '../characters/entities/character.entity'; +import { TravelService } from '../travel/travel.service'; +import { LocationConnection } from './entities/location-connection.entity'; + +export interface LocationSummary { + id: string; + key: string; + name: string; +} + +export interface CurrentLocationConnection { + targetLocation: LocationSummary; + travelDurationSeconds: number; + danger: 'LOW' | 'HIGH'; +} + +export interface CurrentLocationResponse { + id: string; + key: string; + name: string; + description: string; + regionKey: string; + minRecommendedLevel: number; + maxRecommendedLevel: number; + dangerLevel: number; + isSafe: boolean; + huntingEnabled: boolean; + artworkPath: string; + connections: CurrentLocationConnection[]; +} + +@Injectable() +export class WorldService { + constructor( + private readonly travelService: TravelService, + @InjectRepository(Character) + private readonly characters: Repository, + @InjectRepository(LocationConnection) + private readonly connections: Repository, + ) {} + + async getCurrentLocation( + characterId: string, + ): Promise { + await this.travelService.completeTravelIfDue(characterId); + + const character = await this.characters.findOne({ + where: { id: characterId }, + relations: { currentLocation: true }, + }); + if (!character) { + throw new NotFoundException('Character not found.'); + } + + const connections = await this.connections.find({ + where: { fromLocationId: character.currentLocationId, enabled: true }, + relations: { toLocation: true }, + }); + const location = character.currentLocation; + + return { + id: location.id, + key: location.key, + name: location.name, + description: location.description, + regionKey: location.regionKey, + minRecommendedLevel: location.minRecommendedLevel, + maxRecommendedLevel: location.maxRecommendedLevel, + dangerLevel: location.dangerLevel, + isSafe: location.isSafe, + huntingEnabled: location.huntingEnabled, + artworkPath: location.artworkPath, + connections: connections + .filter((connection) => connection.enabled) + .map((connection) => ({ + targetLocation: { + id: connection.toLocation.id, + key: connection.toLocation.key, + name: connection.toLocation.name, + }, + travelDurationSeconds: connection.travelDurationSeconds, + danger: this.toDangerRating(connection.ambushChance), + })), + }; + } + + 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 ea77d0b..04ff4f6 100644 --- a/apps/api/test/app.e2e-spec.ts +++ b/apps/api/test/app.e2e-spec.ts @@ -7,6 +7,7 @@ import { CharactersModule } from './../src/characters/characters.module'; import { DatabaseModule } from './../src/database/database.module'; import { configureApplication } from './../src/app.config'; import { TravelModule } from './../src/travel/travel.module'; +import { WorldModule } from './../src/world/world.module'; @Module({}) class TestDatabaseModule {} @@ -17,6 +18,9 @@ class TestCharactersModule {} @Module({}) class TestTravelModule {} +@Module({}) +class TestWorldModule {} + describe('API (e2e)', () => { let app: INestApplication; @@ -30,6 +34,8 @@ describe('API (e2e)', () => { .useModule(TestCharactersModule) .overrideModule(TravelModule) .useModule(TestTravelModule) + .overrideModule(WorldModule) + .useModule(TestWorldModule) .compile(); app = moduleFixture.createNestApplication();