traveling

This commit is contained in:
Bastian Wagner
2026-08-23 18:47:11 +02:00
parent 5fa344dbe2
commit bbd5dbbd1f
12 changed files with 251 additions and 9 deletions

View File

@@ -43,7 +43,7 @@ export const QUEST_DEFINITIONS: SeedQuestDefinition[] = [
key: TROUBLE_BEYOND_THE_GATE_KEY,
title: 'Trouble Beyond the Gate',
description:
'The warden at the South Gate wants to know what the ash is doing to the creatures on the road. Five Ashen Pelts is how you show them.',
'The warden at the South Gate wants to know what the ash is doing to the creatures on the road.',
rewardFactionKey: 'border-guard',
rewardReputation: 10,
rewardSilver: 0,
@@ -92,11 +92,11 @@ export const QUEST_OBJECTIVES: SeedQuestObjective[] = [
orderIndex: 0,
type: QuestObjectiveType.COLLECT_ITEM,
targetKey: 'ash-pelt',
requiredQuantity: 5,
requiredQuantity: 1,
description: 'Collect Ashen Pelts',
npcLine: null,
hintText:
'You cannot carry enough pelts. Return to the South Gate Warden.',
'You defeatet a burned creature. Return to the South Gate Warden and report your finding.',
advanceWhenBlocked: true,
consumeOnComplete: false,
grantsLootBagKey: null,
@@ -114,7 +114,7 @@ export const QUEST_OBJECTIVES: SeedQuestObjective[] = [
requiredQuantity: 1,
description: 'Return to the South Gate Warden',
npcLine:
"Right. You're not equipped for hauling spoils yet. Go see Borin in Graufurt. Tell him I sent you. He'll complain, but he'll give you something useful.",
"We need to investigate this further, collect more pelts and bring it to Borin. Wait, you're not equipped for hauling spoils yet. Go see Borin in Graufurt. Tell him I sent you. He'll complain, but he'll give you something useful.",
hintText: null,
advanceWhenBlocked: false,
consumeOnComplete: false,
@@ -137,7 +137,7 @@ export const QUEST_OBJECTIVES: SeedQuestObjective[] = [
description: 'Speak with Borin in Graufurt',
npcLine:
'But the South Gate Warden sent you. Fine. Take this. Bring it back full and make it worth my trouble.',
hintText: null,
hintText: 'Borin gave you a bag for animal spoils. It fits 5 pelts or similar.',
advanceWhenBlocked: false,
consumeOnComplete: false,
// Slice 0.9 decision D1. §6 has Borin say "Take this" while also pointing

View File

@@ -92,6 +92,7 @@ function createService(
}),
} as unknown as Repository<Character>,
{ find: jest.fn() } as unknown as Repository<LocationConnection>,
{ find: jest.fn() } as unknown as Repository<LocationDefinition>,
{ find: jest.fn() } as unknown as Repository<LocationMonster>,
{
isTravelAllowed: () => Promise.resolve(true),
@@ -133,6 +134,7 @@ function buildService(options: {
}),
} as unknown as Repository<Character>,
{ find: jest.fn() } as unknown as Repository<LocationConnection>,
{ find: jest.fn() } as unknown as Repository<LocationDefinition>,
{ find: jest.fn() } as unknown as Repository<LocationMonster>,
worldDiscovery,
);

View File

@@ -6,6 +6,7 @@ import { MonsterDefinition } from '../monsters/entities/monster-definition.entit
import { TravelModule } from '../travel/travel.module';
import { WorldDiscoveryModule } from './discovery/world-discovery.module';
import { LocationConnection } from './entities/location-connection.entity';
import { LocationDefinition } from './entities/location-definition.entity';
import { WorldController } from './world.controller';
import { WorldService } from './world.service';
@@ -14,6 +15,7 @@ import { WorldService } from './world.service';
TypeOrmModule.forFeature([
Character,
LocationConnection,
LocationDefinition,
LocationMonster,
MonsterDefinition,
]),

View File

@@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import {
ABANDONED_WATCHPOST_ID,
BURNED_ROAD_ID,
SOUTH_GATE_ID,
} from '../database/seeds/vertical-slice.constants';
@@ -218,10 +219,22 @@ const ashPitLocation: LocationDefinition = {
* `WorldDiscoveryService` that mirrors the real gate: a connection with
* `requiresDiscovery` is only allowed once its target is in `discovered`.
*/
/**
* Builds a `WorldService` sitting at the Burned Road, with a stub
* `WorldDiscoveryService` that mirrors the real gate: a connection with
* `requiresDiscovery` is only allowed once its target is in `discovered`.
*
* `connections` mocks the direct-from-current-location lookup;
* `otherConnections` mocks the region-wide incoming-connections lookup used
* to decide which known-but-distant locations to show (distinguished by
* which `where` clause the query used, since both hit the same repository).
*/
function buildService(
options: {
connections?: LocationConnection[];
otherConnections?: LocationConnection[];
discovered?: string[];
regionLocations?: LocationDefinition[];
} = {},
) {
const location = burnedRoad();
@@ -232,8 +245,19 @@ function buildService(
findOne: jest.fn().mockResolvedValue(character(BURNED_ROAD_ID, location)),
} as unknown as Repository<Character>;
const connections = {
find: jest.fn().mockResolvedValue(options.connections ?? []),
find: jest
.fn()
.mockImplementation((query: { where: Record<string, unknown> }) =>
Promise.resolve(
'fromLocationId' in query.where
? (options.connections ?? [])
: (options.otherConnections ?? []),
),
),
} as unknown as Repository<LocationConnection>;
const locations = {
find: jest.fn().mockResolvedValue(options.regionLocations ?? []),
} as unknown as Repository<LocationDefinition>;
const locationMonsters = {
find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<LocationMonster>;
@@ -254,6 +278,7 @@ function buildService(
travelService,
characters,
connections,
locations,
locationMonsters,
worldDiscovery,
);
@@ -304,6 +329,10 @@ describe('WorldService', () => {
const connections = {
find: findConnections,
} as unknown as Repository<LocationConnection>;
const findLocations = jest.fn().mockResolvedValue([]);
const locations = {
find: findLocations,
} as unknown as Repository<LocationDefinition>;
const findLocationMonsters = jest.fn();
const locationMonsters = {
find: findLocationMonsters,
@@ -320,6 +349,7 @@ describe('WorldService', () => {
travelService,
characters,
connections,
locations,
locationMonsters,
worldDiscovery,
);
@@ -372,6 +402,7 @@ describe('WorldService', () => {
danger: 'LOW',
},
],
otherLocations: [],
possibleMonsters: [],
});
expect(findCharacter).toHaveBeenCalledWith({
@@ -396,6 +427,9 @@ describe('WorldService', () => {
const connections = {
find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<LocationConnection>;
const locations = {
find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<LocationDefinition>;
const findLocationMonsters = jest.fn().mockResolvedValue(BURNED_ROAD_POOL);
const locationMonsters = {
find: findLocationMonsters,
@@ -412,6 +446,7 @@ describe('WorldService', () => {
travelService,
characters,
connections,
locations,
locationMonsters,
worldDiscovery,
);
@@ -441,6 +476,9 @@ describe('WorldService', () => {
const connections = {
find: findConnections,
} as unknown as Repository<LocationConnection>;
const locations = {
find: jest.fn(),
} as unknown as Repository<LocationDefinition>;
const findLocationMonsters = jest.fn();
const locationMonsters = {
find: findLocationMonsters,
@@ -457,6 +495,7 @@ describe('WorldService', () => {
travelService,
characters,
connections,
locations,
locationMonsters,
worldDiscovery,
);
@@ -607,6 +646,83 @@ describe('WorldService', () => {
expect(location.connections).toHaveLength(1);
expect(location.connections[0].targetLocation.key).toBe('ash-pit');
});
it('lists other known region locations that are not directly reachable', async () => {
const watchpost: LocationDefinition = {
...ashPitLocation,
id: ABANDONED_WATCHPOST_ID,
key: 'abandoned-watchpost',
name: 'Abandoned Watchpost',
};
const { service } = buildService({
connections: [
{
fromLocationId: BURNED_ROAD_ID,
toLocationId: ABANDONED_WATCHPOST_ID,
enabled: true,
requiresDiscovery: false,
toLocation: watchpost,
} as unknown as LocationConnection,
],
regionLocations: [currentLocation(), burnedRoad(), watchpost],
otherConnections: [
{
fromLocationId: BURNED_ROAD_ID,
toLocationId: SOUTH_GATE_ID,
enabled: true,
requiresDiscovery: false,
} as unknown as LocationConnection,
],
});
const result = await service.getCurrentLocation(CHARACTER_ID);
// South Gate: known but not directly reachable from here -> listed.
// Abandoned Watchpost: directly reachable -> excluded (it's in `connections`).
// Burned Road: the current location -> excluded.
expect(result.otherLocations).toEqual([
{ id: SOUTH_GATE_ID, key: 'south-gate', name: 'Graufurt South Gate' },
]);
});
it('hides a region location gated behind an undiscovered connection', async () => {
const { service } = buildService({
regionLocations: [currentLocation(), burnedRoad(), ashPitLocation],
otherConnections: [
{
fromLocationId: ABANDONED_WATCHPOST_ID,
toLocationId: ASH_PIT_ID,
enabled: true,
requiresDiscovery: true,
} as unknown as LocationConnection,
],
});
const result = await service.getCurrentLocation(CHARACTER_ID);
expect(result.otherLocations).toEqual([]);
});
it('shows a region location once its gated connection has been discovered', async () => {
const { service } = buildService({
discovered: [ASH_PIT_ID],
regionLocations: [currentLocation(), burnedRoad(), ashPitLocation],
otherConnections: [
{
fromLocationId: ABANDONED_WATCHPOST_ID,
toLocationId: ASH_PIT_ID,
enabled: true,
requiresDiscovery: true,
} as unknown as LocationConnection,
],
});
const result = await service.getCurrentLocation(CHARACTER_ID);
expect(result.otherLocations).toEqual([
{ id: ASH_PIT_ID, key: 'ash-pit', name: 'Ash Pit' },
]);
});
});
async function loadBurnedRoad() {
@@ -632,6 +748,9 @@ async function loadLocation(
{
find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<LocationConnection>,
{
find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<LocationDefinition>,
{
find: jest.fn().mockResolvedValue(pool),
} as unknown as Repository<LocationMonster>,

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { calculateDangerRating, DangerRating } from '../hunting/danger-rating';
import { LocationMonster } from '../monsters/entities/location-monster.entity';
@@ -56,6 +56,13 @@ export interface CurrentLocationResponse {
encounterPreview: EncounterPreviewDto[];
rewardPreview: RewardPreviewDto[];
connections: CurrentLocationConnection[];
/**
* Other locations in the same region the character already knows about,
* but that aren't a direct connection from here -- reaching them takes
* more than one hop. The map shows these too, just not as travel targets
* (Playable Slice: full-area map visibility).
*/
otherLocations: LocationSummary[];
possibleMonsters: string[];
}
@@ -67,6 +74,8 @@ export class WorldService {
private readonly characters: Repository<Character>,
@InjectRepository(LocationConnection)
private readonly connections: Repository<LocationConnection>,
@InjectRepository(LocationDefinition)
private readonly locations: Repository<LocationDefinition>,
@InjectRepository(LocationMonster)
private readonly locationMonsters: Repository<LocationMonster>,
private readonly worldDiscovery: WorldDiscoveryService,
@@ -97,6 +106,14 @@ export class WorldService {
connection.enabled &&
this.worldDiscovery.isRouteOpen(discoveredLocationIds, connection),
);
const directlyReachableIds = new Set(
visibleConnections.map((connection) => connection.toLocationId),
);
const otherLocations = await this.loadOtherRegionLocations(
location,
directlyReachableIds,
discoveredLocationIds,
);
return {
id: location.id,
@@ -136,10 +153,51 @@ export class WorldService {
travelDurationSeconds: connection.travelDurationSeconds,
danger: this.toDangerRating(connection.ambushChance),
})),
otherLocations,
possibleMonsters: pool.map((entry) => entry.monster.name),
};
}
/**
* The rest of the region: locations the character already knows about
* (spec: the map must hide exactly what travel refuses -- same discovery
* gate as `connections`) but can't reach in a single hop from here.
*/
private async loadOtherRegionLocations(
location: LocationDefinition,
directlyReachableIds: ReadonlySet<string>,
discoveredLocationIds: ReadonlySet<string>,
): Promise<LocationSummary[]> {
const regionLocations = await this.locations.find({
where: { regionKey: location.regionKey },
});
const candidateIds = regionLocations
.map((candidate) => candidate.id)
.filter((id) => id !== location.id && !directlyReachableIds.has(id));
if (candidateIds.length === 0) {
return [];
}
const incomingConnections = await this.connections.find({
where: { enabled: true, toLocationId: In(candidateIds) },
});
const knownIds = new Set(
incomingConnections
.filter((connection) =>
this.worldDiscovery.isRouteOpen(discoveredLocationIds, connection),
)
.map((connection) => connection.toLocationId),
);
return regionLocations
.filter((candidate) => knownIds.has(candidate.id))
.map((candidate) => ({
id: candidate.id,
key: candidate.key,
name: candidate.name,
}));
}
/**
* Runs a short local interaction (investigate, search, talk) and reveals its
* authored result.