feat: expose current world location
This commit is contained in:
@@ -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 <Task-6 TypeScript files>
|
||||||
|
npx eslint <Task-6 TypeScript files>
|
||||||
|
# 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.
|
||||||
@@ -3,8 +3,15 @@ import { CharactersModule } from './characters/characters.module';
|
|||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { TravelModule } from './travel/travel.module';
|
import { TravelModule } from './travel/travel.module';
|
||||||
|
import { WorldModule } from './world/world.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, HealthModule, CharactersModule, TravelModule],
|
imports: [
|
||||||
|
DatabaseModule,
|
||||||
|
HealthModule,
|
||||||
|
CharactersModule,
|
||||||
|
TravelModule,
|
||||||
|
WorldModule,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
13
apps/api/src/world/world.controller.ts
Normal file
13
apps/api/src/world/world.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/api/src/world/world.module.ts
Normal file
17
apps/api/src/world/world.module.ts
Normal file
@@ -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 {}
|
||||||
167
apps/api/src/world/world.service.spec.ts
Normal file
167
apps/api/src/world/world.service.spec.ts
Normal file
@@ -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<Character>;
|
||||||
|
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<LocationConnection>;
|
||||||
|
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<Character>;
|
||||||
|
const findConnections = jest.fn();
|
||||||
|
const connections = {
|
||||||
|
find: findConnections,
|
||||||
|
} as unknown as Repository<LocationConnection>;
|
||||||
|
const service = new WorldService(travelService, characters, connections);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.getCurrentLocation(CHARACTER_ID),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(findConnections).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
93
apps/api/src/world/world.service.ts
Normal file
93
apps/api/src/world/world.service.ts
Normal file
@@ -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<Character>,
|
||||||
|
@InjectRepository(LocationConnection)
|
||||||
|
private readonly connections: Repository<LocationConnection>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getCurrentLocation(
|
||||||
|
characterId: string,
|
||||||
|
): Promise<CurrentLocationResponse> {
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { CharactersModule } from './../src/characters/characters.module';
|
|||||||
import { DatabaseModule } from './../src/database/database.module';
|
import { DatabaseModule } from './../src/database/database.module';
|
||||||
import { configureApplication } from './../src/app.config';
|
import { configureApplication } from './../src/app.config';
|
||||||
import { TravelModule } from './../src/travel/travel.module';
|
import { TravelModule } from './../src/travel/travel.module';
|
||||||
|
import { WorldModule } from './../src/world/world.module';
|
||||||
|
|
||||||
@Module({})
|
@Module({})
|
||||||
class TestDatabaseModule {}
|
class TestDatabaseModule {}
|
||||||
@@ -17,6 +18,9 @@ class TestCharactersModule {}
|
|||||||
@Module({})
|
@Module({})
|
||||||
class TestTravelModule {}
|
class TestTravelModule {}
|
||||||
|
|
||||||
|
@Module({})
|
||||||
|
class TestWorldModule {}
|
||||||
|
|
||||||
describe('API (e2e)', () => {
|
describe('API (e2e)', () => {
|
||||||
let app: INestApplication<App>;
|
let app: INestApplication<App>;
|
||||||
|
|
||||||
@@ -30,6 +34,8 @@ describe('API (e2e)', () => {
|
|||||||
.useModule(TestCharactersModule)
|
.useModule(TestCharactersModule)
|
||||||
.overrideModule(TravelModule)
|
.overrideModule(TravelModule)
|
||||||
.useModule(TestTravelModule)
|
.useModule(TestTravelModule)
|
||||||
|
.overrideModule(WorldModule)
|
||||||
|
.useModule(TestWorldModule)
|
||||||
.compile();
|
.compile();
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
|
|||||||
Reference in New Issue
Block a user