diff --git a/apps/api/src/world/discovery/character-location-discovery.entity.ts b/apps/api/src/world/discovery/character-location-discovery.entity.ts new file mode 100644 index 0000000..2d26ee9 --- /dev/null +++ b/apps/api/src/world/discovery/character-location-discovery.entity.ts @@ -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; +} diff --git a/apps/api/src/world/discovery/world-discovery.module.ts b/apps/api/src/world/discovery/world-discovery.module.ts new file mode 100644 index 0000000..d87074d --- /dev/null +++ b/apps/api/src/world/discovery/world-discovery.module.ts @@ -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 {} diff --git a/apps/api/src/world/discovery/world-discovery.service.spec.ts b/apps/api/src/world/discovery/world-discovery.service.spec.ts new file mode 100644 index 0000000..6526441 --- /dev/null +++ b/apps/api/src/world/discovery/world-discovery.service.spec.ts @@ -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; + orIgnore: boolean; +} + +function buildService(options: { + discoveries?: Array<{ locationId: string }>; + locations?: Array>; + 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) => { + 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; + + const locationRepository = { + findOneBy: jest.fn(({ key }: { key: string }) => + Promise.resolve(locations.find((location) => location.key === key) ?? null), + ), + } as unknown as Repository; + + 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(); + }); +}); diff --git a/apps/api/src/world/discovery/world-discovery.service.ts b/apps/api/src/world/discovery/world-discovery.service.ts new file mode 100644 index 0000000..562c77d --- /dev/null +++ b/apps/api/src/world/discovery/world-discovery.service.ts @@ -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> { + 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 { + 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, + manager?: EntityManager, + ): Promise { + 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); + } +} diff --git a/apps/api/src/world/entities/location-connection.entity.ts b/apps/api/src/world/entities/location-connection.entity.ts index 2effd01..01708fe 100644 --- a/apps/api/src/world/entities/location-connection.entity.ts +++ b/apps/api/src/world/entities/location-connection.entity.ts @@ -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,