feat(api): track which locations a character has discovered
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
19
apps/api/src/world/discovery/world-discovery.module.ts
Normal file
19
apps/api/src/world/discovery/world-discovery.module.ts
Normal 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 {}
|
||||||
153
apps/api/src/world/discovery/world-discovery.service.spec.ts
Normal file
153
apps/api/src/world/discovery/world-discovery.service.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
91
apps/api/src/world/discovery/world-discovery.service.ts
Normal file
91
apps/api/src/world/discovery/world-discovery.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,14 @@ export class LocationConnection {
|
|||||||
@Column({ name: 'enabled', type: 'boolean' })
|
@Column({ name: 'enabled', type: 'boolean' })
|
||||||
enabled!: 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(
|
@ManyToOne(
|
||||||
() => LocationDefinition,
|
() => LocationDefinition,
|
||||||
(location) => location.outgoingConnections,
|
(location) => location.outgoingConnections,
|
||||||
|
|||||||
Reference in New Issue
Block a user