traveling
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user