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, key: TROUBLE_BEYOND_THE_GATE_KEY,
title: 'Trouble Beyond the Gate', title: 'Trouble Beyond the Gate',
description: 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', rewardFactionKey: 'border-guard',
rewardReputation: 10, rewardReputation: 10,
rewardSilver: 0, rewardSilver: 0,
@@ -92,11 +92,11 @@ export const QUEST_OBJECTIVES: SeedQuestObjective[] = [
orderIndex: 0, orderIndex: 0,
type: QuestObjectiveType.COLLECT_ITEM, type: QuestObjectiveType.COLLECT_ITEM,
targetKey: 'ash-pelt', targetKey: 'ash-pelt',
requiredQuantity: 5, requiredQuantity: 1,
description: 'Collect Ashen Pelts', description: 'Collect Ashen Pelts',
npcLine: null, npcLine: null,
hintText: 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, advanceWhenBlocked: true,
consumeOnComplete: false, consumeOnComplete: false,
grantsLootBagKey: null, grantsLootBagKey: null,
@@ -114,7 +114,7 @@ export const QUEST_OBJECTIVES: SeedQuestObjective[] = [
requiredQuantity: 1, requiredQuantity: 1,
description: 'Return to the South Gate Warden', description: 'Return to the South Gate Warden',
npcLine: 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, hintText: null,
advanceWhenBlocked: false, advanceWhenBlocked: false,
consumeOnComplete: false, consumeOnComplete: false,
@@ -137,7 +137,7 @@ export const QUEST_OBJECTIVES: SeedQuestObjective[] = [
description: 'Speak with Borin in Graufurt', description: 'Speak with Borin in Graufurt',
npcLine: npcLine:
'But the South Gate Warden sent you. Fine. Take this. Bring it back full and make it worth my trouble.', '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, advanceWhenBlocked: false,
consumeOnComplete: false, consumeOnComplete: false,
// Slice 0.9 decision D1. §6 has Borin say "Take this" while also pointing // 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>, } as unknown as Repository<Character>,
{ find: jest.fn() } as unknown as Repository<LocationConnection>, { find: jest.fn() } as unknown as Repository<LocationConnection>,
{ find: jest.fn() } as unknown as Repository<LocationDefinition>,
{ find: jest.fn() } as unknown as Repository<LocationMonster>, { find: jest.fn() } as unknown as Repository<LocationMonster>,
{ {
isTravelAllowed: () => Promise.resolve(true), isTravelAllowed: () => Promise.resolve(true),
@@ -133,6 +134,7 @@ function buildService(options: {
}), }),
} as unknown as Repository<Character>, } as unknown as Repository<Character>,
{ find: jest.fn() } as unknown as Repository<LocationConnection>, { find: jest.fn() } as unknown as Repository<LocationConnection>,
{ find: jest.fn() } as unknown as Repository<LocationDefinition>,
{ find: jest.fn() } as unknown as Repository<LocationMonster>, { find: jest.fn() } as unknown as Repository<LocationMonster>,
worldDiscovery, worldDiscovery,
); );

View File

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

View File

@@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Character } from '../characters/entities/character.entity'; import { Character } from '../characters/entities/character.entity';
import { import {
ABANDONED_WATCHPOST_ID,
BURNED_ROAD_ID, BURNED_ROAD_ID,
SOUTH_GATE_ID, SOUTH_GATE_ID,
} from '../database/seeds/vertical-slice.constants'; } from '../database/seeds/vertical-slice.constants';
@@ -218,10 +219,22 @@ const ashPitLocation: LocationDefinition = {
* `WorldDiscoveryService` that mirrors the real gate: a connection with * `WorldDiscoveryService` that mirrors the real gate: a connection with
* `requiresDiscovery` is only allowed once its target is in `discovered`. * `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( function buildService(
options: { options: {
connections?: LocationConnection[]; connections?: LocationConnection[];
otherConnections?: LocationConnection[];
discovered?: string[]; discovered?: string[];
regionLocations?: LocationDefinition[];
} = {}, } = {},
) { ) {
const location = burnedRoad(); const location = burnedRoad();
@@ -232,8 +245,19 @@ function buildService(
findOne: jest.fn().mockResolvedValue(character(BURNED_ROAD_ID, location)), findOne: jest.fn().mockResolvedValue(character(BURNED_ROAD_ID, location)),
} as unknown as Repository<Character>; } as unknown as Repository<Character>;
const connections = { 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>; } as unknown as Repository<LocationConnection>;
const locations = {
find: jest.fn().mockResolvedValue(options.regionLocations ?? []),
} as unknown as Repository<LocationDefinition>;
const locationMonsters = { const locationMonsters = {
find: jest.fn().mockResolvedValue([]), find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<LocationMonster>; } as unknown as Repository<LocationMonster>;
@@ -254,6 +278,7 @@ function buildService(
travelService, travelService,
characters, characters,
connections, connections,
locations,
locationMonsters, locationMonsters,
worldDiscovery, worldDiscovery,
); );
@@ -304,6 +329,10 @@ describe('WorldService', () => {
const connections = { const connections = {
find: findConnections, find: findConnections,
} as unknown as Repository<LocationConnection>; } as unknown as Repository<LocationConnection>;
const findLocations = jest.fn().mockResolvedValue([]);
const locations = {
find: findLocations,
} as unknown as Repository<LocationDefinition>;
const findLocationMonsters = jest.fn(); const findLocationMonsters = jest.fn();
const locationMonsters = { const locationMonsters = {
find: findLocationMonsters, find: findLocationMonsters,
@@ -320,6 +349,7 @@ describe('WorldService', () => {
travelService, travelService,
characters, characters,
connections, connections,
locations,
locationMonsters, locationMonsters,
worldDiscovery, worldDiscovery,
); );
@@ -372,6 +402,7 @@ describe('WorldService', () => {
danger: 'LOW', danger: 'LOW',
}, },
], ],
otherLocations: [],
possibleMonsters: [], possibleMonsters: [],
}); });
expect(findCharacter).toHaveBeenCalledWith({ expect(findCharacter).toHaveBeenCalledWith({
@@ -396,6 +427,9 @@ describe('WorldService', () => {
const connections = { const connections = {
find: jest.fn().mockResolvedValue([]), find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<LocationConnection>; } 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 findLocationMonsters = jest.fn().mockResolvedValue(BURNED_ROAD_POOL);
const locationMonsters = { const locationMonsters = {
find: findLocationMonsters, find: findLocationMonsters,
@@ -412,6 +446,7 @@ describe('WorldService', () => {
travelService, travelService,
characters, characters,
connections, connections,
locations,
locationMonsters, locationMonsters,
worldDiscovery, worldDiscovery,
); );
@@ -441,6 +476,9 @@ describe('WorldService', () => {
const connections = { const connections = {
find: findConnections, find: findConnections,
} as unknown as Repository<LocationConnection>; } as unknown as Repository<LocationConnection>;
const locations = {
find: jest.fn(),
} as unknown as Repository<LocationDefinition>;
const findLocationMonsters = jest.fn(); const findLocationMonsters = jest.fn();
const locationMonsters = { const locationMonsters = {
find: findLocationMonsters, find: findLocationMonsters,
@@ -457,6 +495,7 @@ describe('WorldService', () => {
travelService, travelService,
characters, characters,
connections, connections,
locations,
locationMonsters, locationMonsters,
worldDiscovery, worldDiscovery,
); );
@@ -607,6 +646,83 @@ describe('WorldService', () => {
expect(location.connections).toHaveLength(1); expect(location.connections).toHaveLength(1);
expect(location.connections[0].targetLocation.key).toBe('ash-pit'); 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() { async function loadBurnedRoad() {
@@ -632,6 +748,9 @@ async function loadLocation(
{ {
find: jest.fn().mockResolvedValue([]), find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<LocationConnection>, } as unknown as Repository<LocationConnection>,
{
find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<LocationDefinition>,
{ {
find: jest.fn().mockResolvedValue(pool), find: jest.fn().mockResolvedValue(pool),
} as unknown as Repository<LocationMonster>, } as unknown as Repository<LocationMonster>,

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { In, Repository } from 'typeorm';
import { Character } from '../characters/entities/character.entity'; import { Character } from '../characters/entities/character.entity';
import { calculateDangerRating, DangerRating } from '../hunting/danger-rating'; import { calculateDangerRating, DangerRating } from '../hunting/danger-rating';
import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { LocationMonster } from '../monsters/entities/location-monster.entity';
@@ -56,6 +56,13 @@ export interface CurrentLocationResponse {
encounterPreview: EncounterPreviewDto[]; encounterPreview: EncounterPreviewDto[];
rewardPreview: RewardPreviewDto[]; rewardPreview: RewardPreviewDto[];
connections: CurrentLocationConnection[]; 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[]; possibleMonsters: string[];
} }
@@ -67,6 +74,8 @@ export class WorldService {
private readonly characters: Repository<Character>, private readonly characters: Repository<Character>,
@InjectRepository(LocationConnection) @InjectRepository(LocationConnection)
private readonly connections: Repository<LocationConnection>, private readonly connections: Repository<LocationConnection>,
@InjectRepository(LocationDefinition)
private readonly locations: Repository<LocationDefinition>,
@InjectRepository(LocationMonster) @InjectRepository(LocationMonster)
private readonly locationMonsters: Repository<LocationMonster>, private readonly locationMonsters: Repository<LocationMonster>,
private readonly worldDiscovery: WorldDiscoveryService, private readonly worldDiscovery: WorldDiscoveryService,
@@ -97,6 +106,14 @@ export class WorldService {
connection.enabled && connection.enabled &&
this.worldDiscovery.isRouteOpen(discoveredLocationIds, connection), this.worldDiscovery.isRouteOpen(discoveredLocationIds, connection),
); );
const directlyReachableIds = new Set(
visibleConnections.map((connection) => connection.toLocationId),
);
const otherLocations = await this.loadOtherRegionLocations(
location,
directlyReachableIds,
discoveredLocationIds,
);
return { return {
id: location.id, id: location.id,
@@ -136,10 +153,51 @@ export class WorldService {
travelDurationSeconds: connection.travelDurationSeconds, travelDurationSeconds: connection.travelDurationSeconds,
danger: this.toDangerRating(connection.ambushChance), danger: this.toDangerRating(connection.ambushChance),
})), })),
otherLocations,
possibleMonsters: pool.map((entry) => entry.monster.name), 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 * Runs a short local interaction (investigate, search, talk) and reveals its
* authored result. * authored result.

View File

@@ -123,6 +123,8 @@ export interface CurrentLocationResponse {
encounterPreview: EncounterPreview[]; encounterPreview: EncounterPreview[];
rewardPreview: RewardPreview[]; rewardPreview: RewardPreview[];
connections: CurrentLocationConnection[]; connections: CurrentLocationConnection[];
/** Known region locations that aren't a direct connection from here. */
otherLocations: LocationSummary[];
possibleMonsters: string[]; possibleMonsters: string[];
} }

View File

@@ -131,6 +131,7 @@ export function southGateFixture(
danger: 'LOW', danger: 'LOW',
}, },
], ],
otherLocations: [],
possibleMonsters: [], possibleMonsters: [],
...overrides, ...overrides,
}; };

View File

@@ -3,7 +3,8 @@
class="location-node" class="location-node"
[class.location-node--current]="current" [class.location-node--current]="current"
[class.location-node--selected]="selected" [class.location-node--selected]="selected"
[disabled]="disabled" [class.location-node--unreachable]="!reachable"
[disabled]="disabled || !reachable"
[attr.aria-current]="current ? 'location' : null" [attr.aria-current]="current ? 'location' : null"
[attr.aria-pressed]="current ? null : selected" [attr.aria-pressed]="current ? null : selected"
[attr.data-location-key]="location.key" [attr.data-location-key]="location.key"
@@ -12,6 +13,12 @@
<span class="location-node__marker" aria-hidden="true"></span> <span class="location-node__marker" aria-hidden="true"></span>
<span class="location-node__name">{{ location.name }}</span> <span class="location-node__name">{{ location.name }}</span>
<span class="location-node__state">{{ <span class="location-node__state">{{
current ? 'Current Location' : selected ? 'Selected Destination' : 'Reachable' !reachable
? 'Not Directly Reachable'
: current
? 'Current Location'
: selected
? 'Selected Destination'
: 'Reachable'
}}</span> }}</span>
</button> </button>

View File

@@ -54,6 +54,19 @@
color: var(--ar-success); color: var(--ar-success);
} }
.location-node--unreachable {
opacity: 0.55;
}
.location-node--unreachable .location-node__marker {
border-color: var(--ar-border);
box-shadow: 0 0 0 0.15rem rgb(6 8 9 / 0.75);
}
.location-node--unreachable .location-node__state {
color: var(--ar-text-muted);
}
.location-node--selected .location-node__marker, .location-node--selected .location-node__marker,
.location-node:not(:disabled):hover .location-node__marker { .location-node:not(:disabled):hover .location-node__marker {
border-color: #e1bd72; border-color: #e1bd72;

View File

@@ -11,5 +11,6 @@ export class LocationNodeComponent {
@Input() current = false; @Input() current = false;
@Input() selected = false; @Input() selected = false;
@Input() disabled = false; @Input() disabled = false;
@Input() reachable = true;
@Output() readonly choose = new EventEmitter<void>(); @Output() readonly choose = new EventEmitter<void>();
} }

View File

@@ -31,6 +31,14 @@
(choose)="selectConnection(connection)" (choose)="selectConnection(connection)"
/> />
} }
@for (otherLocation of location.otherLocations; track otherLocation.id) {
<app-location-node
[class]="'world-page__node world-page__node--' + otherLocation.key"
[location]="otherLocation"
[reachable]="false"
/>
}
</section> </section>
<app-travel-panel <app-travel-panel

View File

@@ -89,6 +89,35 @@ describe('WorldPageComponent', () => {
expect(element.textContent).toContain('Low'); expect(element.textContent).toContain('Low');
}); });
it('shows known region locations that are not directly reachable as distinct, non-interactive nodes', () => {
store.currentLocation.set(
southGateFixture({
connections: [burnedRoadConnection],
otherLocations: [
{
id: 'abandoned-watchpost-id',
key: 'abandoned-watchpost',
name: 'Abandoned Watchpost',
},
],
}),
);
const fixture = TestBed.createComponent(WorldPageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const node = element.querySelector<HTMLButtonElement>(
'[data-location-key="abandoned-watchpost"]',
);
expect(node).not.toBeNull();
expect(node?.disabled).toBe(true);
expect(node?.textContent).toContain('Not Directly Reachable');
node?.click();
expect(store.selectConnection).not.toHaveBeenCalled();
});
it('keeps each location at its geographic position when the authoritative current location swaps', () => { it('keeps each location at its geographic position when the authoritative current location swaps', () => {
const fixture = TestBed.createComponent(WorldPageComponent); const fixture = TestBed.createComponent(WorldPageComponent);
fixture.detectChanges(); fixture.detectChanges();