traveling
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export function southGateFixture(
|
|||||||
danger: 'LOW',
|
danger: 'LOW',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
otherLocations: [],
|
||||||
possibleMonsters: [],
|
possibleMonsters: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user