feat(api): track which locations a character has discovered

This commit is contained in:
Bastian Wagner
2026-08-23 10:52:45 +02:00
parent 420205a254
commit 604581be34
5 changed files with 317 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { LocationDefinition } from '../entities/location-definition.entity';
/**
* A place this character knows about (Playable Slice 0.10 §9).
*
* Player state, not content: which routes are gated at all lives on the
* connection. A row here is written once and never updated, so the unique
* pair is the whole concurrency story (AGENTS.md §30).
*/
@Entity({ name: 'character_location_discoveries' })
@Index(
'IDX_character_location_discoveries_pair',
['characterId', 'locationId'],
{ unique: true },
)
export class CharacterLocationDiscovery {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'character_id', type: 'uuid' })
characterId!: string;
@Column({ name: 'location_id', type: 'uuid' })
locationId!: string;
@CreateDateColumn({ name: 'discovered_at', type: 'timestamptz' })
discoveredAt!: Date;
@ManyToOne(() => Character, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'character_id' })
character!: Character;
@ManyToOne(() => LocationDefinition, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'location_id' })
location!: LocationDefinition;
}

View File

@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LocationDefinition } from '../entities/location-definition.entity';
import { CharacterLocationDiscovery } from './character-location-discovery.entity';
import { WorldDiscoveryService } from './world-discovery.service';
/**
* A leaf module on purpose. `WorldModule` already imports `TravelModule`, and
* both need this service; giving it its own module is what keeps that from
* becoming a circular import.
*/
@Module({
imports: [
TypeOrmModule.forFeature([CharacterLocationDiscovery, LocationDefinition]),
],
providers: [WorldDiscoveryService],
exports: [WorldDiscoveryService],
})
export class WorldDiscoveryModule {}

View File

@@ -0,0 +1,153 @@
import { DataSource, Repository } from 'typeorm';
import { CharacterLocationDiscovery } from './character-location-discovery.entity';
import { LocationConnection } from '../entities/location-connection.entity';
import { LocationDefinition } from '../entities/location-definition.entity';
import { WorldDiscoveryService } from './world-discovery.service';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const WATCHPOST_ID = '20000000-0000-4000-8000-000000000003';
const ASH_PIT_ID = '20000000-0000-4000-8000-000000000004';
interface InsertCall {
values: Record<string, unknown>;
orIgnore: boolean;
}
function buildService(options: {
discoveries?: Array<{ locationId: string }>;
locations?: Array<Partial<LocationDefinition>>;
insertCalls?: InsertCall[];
}): WorldDiscoveryService {
const discoveries = options.discoveries ?? [];
const locations = options.locations ?? [];
const insertCalls = options.insertCalls ?? [];
const discoveryRepository = {
find: jest.fn().mockResolvedValue(discoveries),
createQueryBuilder: jest.fn(() => {
const builder = {
insert: () => builder,
into: () => builder,
values: (values: Record<string, unknown>) => {
insertCalls.push({ values, orIgnore: false });
return builder;
},
orIgnore: () => {
insertCalls[insertCalls.length - 1].orIgnore = true;
return builder;
},
execute: jest
.fn()
.mockResolvedValue({ identifiers: [{ id: 'new-row' }] }),
};
return builder;
}),
} as unknown as Repository<CharacterLocationDiscovery>;
const locationRepository = {
findOneBy: jest.fn(({ key }: { key: string }) =>
Promise.resolve(locations.find((location) => location.key === key) ?? null),
),
} as unknown as Repository<LocationDefinition>;
const dataSource = {
getRepository: (target: unknown) =>
target === CharacterLocationDiscovery
? discoveryRepository
: locationRepository,
} as unknown as DataSource;
return new WorldDiscoveryService(dataSource);
}
describe('WorldDiscoveryService', () => {
it('returns the ids the character has already discovered', async () => {
const service = buildService({
discoveries: [{ locationId: ASH_PIT_ID }],
});
const discovered = await service.getDiscoveredLocationIds(CHARACTER_ID);
expect(discovered.has(ASH_PIT_ID)).toBe(true);
expect(discovered.has(WATCHPOST_ID)).toBe(false);
});
it('allows travel down a route that carries no gate', async () => {
const service = buildService({});
const connection = {
toLocationId: WATCHPOST_ID,
requiresDiscovery: false,
} as LocationConnection;
await expect(
service.isTravelAllowed(CHARACTER_ID, connection),
).resolves.toBe(true);
});
it('refuses a gated route the character has not discovered', async () => {
const service = buildService({});
const connection = {
toLocationId: ASH_PIT_ID,
requiresDiscovery: true,
} as LocationConnection;
await expect(
service.isTravelAllowed(CHARACTER_ID, connection),
).resolves.toBe(false);
});
it('allows a gated route once it has been discovered', async () => {
const service = buildService({ discoveries: [{ locationId: ASH_PIT_ID }] });
const connection = {
toLocationId: ASH_PIT_ID,
requiresDiscovery: true,
} as LocationConnection;
await expect(
service.isTravelAllowed(CHARACTER_ID, connection),
).resolves.toBe(true);
});
it('returns the location the first time it is discovered', async () => {
const service = buildService({
locations: [{ id: ASH_PIT_ID, key: 'ash-pit', name: 'Ash Pit' }],
});
await expect(service.discover(CHARACTER_ID, 'ash-pit')).resolves.toEqual({
key: 'ash-pit',
name: 'Ash Pit',
});
});
it('returns null when the location was already known', async () => {
const service = buildService({
discoveries: [{ locationId: ASH_PIT_ID }],
locations: [{ id: ASH_PIT_ID, key: 'ash-pit', name: 'Ash Pit' }],
});
await expect(service.discover(CHARACTER_ID, 'ash-pit')).resolves.toBeNull();
});
it('writes the row so a duplicate is ignored rather than thrown', async () => {
const insertCalls: InsertCall[] = [];
const service = buildService({
locations: [{ id: ASH_PIT_ID, key: 'ash-pit', name: 'Ash Pit' }],
insertCalls,
});
await service.discover(CHARACTER_ID, 'ash-pit');
expect(insertCalls).toHaveLength(1);
expect(insertCalls[0].orIgnore).toBe(true);
expect(insertCalls[0].values).toEqual({
characterId: CHARACTER_ID,
locationId: ASH_PIT_ID,
});
});
it('ignores an unknown location key', async () => {
const service = buildService({ locations: [] });
await expect(service.discover(CHARACTER_ID, 'nowhere')).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,91 @@
import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { LocationConnection } from '../entities/location-connection.entity';
import { LocationDefinition } from '../entities/location-definition.entity';
import { CharacterLocationDiscovery } from './character-location-discovery.entity';
export interface DiscoveredLocation {
key: string;
name: string;
}
/**
* Which places a character knows about, and whether a gated route is open to
* them yet (Playable Slice 0.10 §9).
*
* One service rather than a check inlined in `WorldService` and
* `TravelService`: the map must hide exactly what travel refuses, and two
* copies of that rule would drift the moment Slice 0.11 adds a second gate.
*/
@Injectable()
export class WorldDiscoveryService {
constructor(private readonly dataSource: DataSource) {}
async getDiscoveredLocationIds(
characterId: string,
manager?: EntityManager,
): Promise<Set<string>> {
const repository = this.discoveries(manager);
const rows = await repository.find({
where: { characterId },
select: { locationId: true },
});
return new Set(rows.map((row) => row.locationId));
}
/**
* Records that the character now knows this place.
*
* Returns the location the first time and `null` afterwards, so a caller can
* tell a fresh reveal from a repeated click without a second query. The
* insert ignores a conflict rather than throwing: the same interaction run
* twice is a normal thing for a player to do (AGENTS.md §30).
*/
async discover(
characterId: string,
locationKey: string,
manager?: EntityManager,
): Promise<DiscoveredLocation | null> {
const locations = manager
? manager.getRepository(LocationDefinition)
: this.dataSource.getRepository(LocationDefinition);
const location = await locations.findOneBy({ key: locationKey });
if (!location) {
return null;
}
const known = await this.getDiscoveredLocationIds(characterId, manager);
if (known.has(location.id)) {
return null;
}
await this.discoveries(manager)
.createQueryBuilder()
.insert()
.into(CharacterLocationDiscovery)
.values({ characterId, locationId: location.id })
.orIgnore()
.execute();
return { key: location.key, name: location.name };
}
async isTravelAllowed(
characterId: string,
connection: Pick<LocationConnection, 'toLocationId' | 'requiresDiscovery'>,
manager?: EntityManager,
): Promise<boolean> {
if (!connection.requiresDiscovery) {
return true;
}
const known = await this.getDiscoveredLocationIds(characterId, manager);
return known.has(connection.toLocationId);
}
private discoveries(manager?: EntityManager) {
return manager
? manager.getRepository(CharacterLocationDiscovery)
: this.dataSource.getRepository(CharacterLocationDiscovery);
}
}

View File

@@ -35,6 +35,14 @@ export class LocationConnection {
@Column({ name: 'enabled', type: 'boolean' })
enabled!: boolean;
/**
* When true this route only exists for a character who has discovered its
* target (Playable Slice 0.10 §9). Default false: every route that existed
* before this slice stays open.
*/
@Column({ name: 'requires_discovery', type: 'boolean', default: false })
requiresDiscovery!: boolean;
@ManyToOne(
() => LocationDefinition,
(location) => location.outgoingConnections,