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' })
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user