From bbd5dbbd1fd35da9e60a76bbe126a8e45f6d56af Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sun, 23 Aug 2026 18:47:11 +0200 Subject: [PATCH] traveling --- apps/api/src/database/seeds/quest-content.ts | 10 +- .../world/local-location-interaction.spec.ts | 2 + apps/api/src/world/world.module.ts | 2 + apps/api/src/world/world.service.spec.ts | 121 +++++++++++++++++- apps/api/src/world/world.service.ts | 60 ++++++++- apps/web/src/app/core/api/game-api.models.ts | 2 + .../world/current-location.fixture.ts | 1 + .../world/location-node.component.html | 11 +- .../world/location-node.component.scss | 13 ++ .../features/world/location-node.component.ts | 1 + .../features/world/world-page.component.html | 8 ++ .../world/world-page.component.spec.ts | 29 +++++ 12 files changed, 251 insertions(+), 9 deletions(-) diff --git a/apps/api/src/database/seeds/quest-content.ts b/apps/api/src/database/seeds/quest-content.ts index 4e35fc9..25ae878 100644 --- a/apps/api/src/database/seeds/quest-content.ts +++ b/apps/api/src/database/seeds/quest-content.ts @@ -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 diff --git a/apps/api/src/world/local-location-interaction.spec.ts b/apps/api/src/world/local-location-interaction.spec.ts index d40b23a..f837b3e 100644 --- a/apps/api/src/world/local-location-interaction.spec.ts +++ b/apps/api/src/world/local-location-interaction.spec.ts @@ -92,6 +92,7 @@ function createService( }), } as unknown as Repository, { find: jest.fn() } as unknown as Repository, + { find: jest.fn() } as unknown as Repository, { find: jest.fn() } as unknown as Repository, { isTravelAllowed: () => Promise.resolve(true), @@ -133,6 +134,7 @@ function buildService(options: { }), } as unknown as Repository, { find: jest.fn() } as unknown as Repository, + { find: jest.fn() } as unknown as Repository, { find: jest.fn() } as unknown as Repository, worldDiscovery, ); diff --git a/apps/api/src/world/world.module.ts b/apps/api/src/world/world.module.ts index 008b1a9..8bd78c0 100644 --- a/apps/api/src/world/world.module.ts +++ b/apps/api/src/world/world.module.ts @@ -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, ]), diff --git a/apps/api/src/world/world.service.spec.ts b/apps/api/src/world/world.service.spec.ts index 329537c..a59d55e 100644 --- a/apps/api/src/world/world.service.spec.ts +++ b/apps/api/src/world/world.service.spec.ts @@ -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; const connections = { - find: jest.fn().mockResolvedValue(options.connections ?? []), + find: jest + .fn() + .mockImplementation((query: { where: Record }) => + Promise.resolve( + 'fromLocationId' in query.where + ? (options.connections ?? []) + : (options.otherConnections ?? []), + ), + ), } as unknown as Repository; + const locations = { + find: jest.fn().mockResolvedValue(options.regionLocations ?? []), + } as unknown as Repository; const locationMonsters = { find: jest.fn().mockResolvedValue([]), } as unknown as Repository; @@ -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; + const findLocations = jest.fn().mockResolvedValue([]); + const locations = { + find: findLocations, + } as unknown as Repository; 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; + const locations = { + find: jest.fn().mockResolvedValue([]), + } as unknown as Repository; 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; + const locations = { + find: jest.fn(), + } as unknown as Repository; 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, + { + find: jest.fn().mockResolvedValue([]), + } as unknown as Repository, { find: jest.fn().mockResolvedValue(pool), } as unknown as Repository, diff --git a/apps/api/src/world/world.service.ts b/apps/api/src/world/world.service.ts index 9b94c68..558df4f 100644 --- a/apps/api/src/world/world.service.ts +++ b/apps/api/src/world/world.service.ts @@ -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, @InjectRepository(LocationConnection) private readonly connections: Repository, + @InjectRepository(LocationDefinition) + private readonly locations: Repository, @InjectRepository(LocationMonster) private readonly locationMonsters: Repository, 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, + discoveredLocationIds: ReadonlySet, + ): Promise { + 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. diff --git a/apps/web/src/app/core/api/game-api.models.ts b/apps/web/src/app/core/api/game-api.models.ts index 23e3661..b273949 100644 --- a/apps/web/src/app/core/api/game-api.models.ts +++ b/apps/web/src/app/core/api/game-api.models.ts @@ -123,6 +123,8 @@ export interface CurrentLocationResponse { encounterPreview: EncounterPreview[]; rewardPreview: RewardPreview[]; connections: CurrentLocationConnection[]; + /** Known region locations that aren't a direct connection from here. */ + otherLocations: LocationSummary[]; possibleMonsters: string[]; } diff --git a/apps/web/src/app/features/world/current-location.fixture.ts b/apps/web/src/app/features/world/current-location.fixture.ts index 8a40ef5..03ecc77 100644 --- a/apps/web/src/app/features/world/current-location.fixture.ts +++ b/apps/web/src/app/features/world/current-location.fixture.ts @@ -131,6 +131,7 @@ export function southGateFixture( danger: 'LOW', }, ], + otherLocations: [], possibleMonsters: [], ...overrides, }; diff --git a/apps/web/src/app/features/world/location-node.component.html b/apps/web/src/app/features/world/location-node.component.html index f09f468..3136939 100644 --- a/apps/web/src/app/features/world/location-node.component.html +++ b/apps/web/src/app/features/world/location-node.component.html @@ -3,7 +3,8 @@ class="location-node" [class.location-node--current]="current" [class.location-node--selected]="selected" - [disabled]="disabled" + [class.location-node--unreachable]="!reachable" + [disabled]="disabled || !reachable" [attr.aria-current]="current ? 'location' : null" [attr.aria-pressed]="current ? null : selected" [attr.data-location-key]="location.key" @@ -12,6 +13,12 @@ {{ location.name }} {{ - current ? 'Current Location' : selected ? 'Selected Destination' : 'Reachable' + !reachable + ? 'Not Directly Reachable' + : current + ? 'Current Location' + : selected + ? 'Selected Destination' + : 'Reachable' }} diff --git a/apps/web/src/app/features/world/location-node.component.scss b/apps/web/src/app/features/world/location-node.component.scss index ad0c079..a1582a0 100644 --- a/apps/web/src/app/features/world/location-node.component.scss +++ b/apps/web/src/app/features/world/location-node.component.scss @@ -54,6 +54,19 @@ 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:not(:disabled):hover .location-node__marker { border-color: #e1bd72; diff --git a/apps/web/src/app/features/world/location-node.component.ts b/apps/web/src/app/features/world/location-node.component.ts index 49af051..296337e 100644 --- a/apps/web/src/app/features/world/location-node.component.ts +++ b/apps/web/src/app/features/world/location-node.component.ts @@ -11,5 +11,6 @@ export class LocationNodeComponent { @Input() current = false; @Input() selected = false; @Input() disabled = false; + @Input() reachable = true; @Output() readonly choose = new EventEmitter(); } diff --git a/apps/web/src/app/features/world/world-page.component.html b/apps/web/src/app/features/world/world-page.component.html index 484c03e..565ad21 100644 --- a/apps/web/src/app/features/world/world-page.component.html +++ b/apps/web/src/app/features/world/world-page.component.html @@ -31,6 +31,14 @@ (choose)="selectConnection(connection)" /> } + + @for (otherLocation of location.otherLocations; track otherLocation.id) { + + } { 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( + '[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', () => { const fixture = TestBed.createComponent(WorldPageComponent); fixture.detectChanges();