import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { Character } from '../characters/entities/character.entity'; import { GameConditionService } from '../conditions/game-condition.service'; import { ExchangeRule } from '../exchanges/entities/exchange-rule.entity'; import { NpcExchangeProfile } from '../exchanges/entities/npc-exchange-profile.entity'; import { NpcShop } from '../shops/entities/npc-shop.entity'; import { CharacterNpcState } from './entities/character-npc-state.entity'; import { DialogueNode } from './entities/dialogue-node.entity'; import { NpcDefinition } from './entities/npc-definition.entity'; import { npcNotFound, npcUnavailable } from './npc.errors'; import { DialogueNodeDto, DialogueResponseDto, NpcActionDto, NpcInteractionDto, NpcMarker, NpcSummaryDto, } from './npc.types'; /** * Loads NPCs and works out what they currently offer (NPC spec §30). * * Deliberately not responsible for purchases, exchanges, reputation or item * transfer -- those live in their own services (spec §30). This one answers * "who is here, what do they say, and what can I do with them". */ @Injectable() export class NpcService { constructor( private readonly dataSource: DataSource, private readonly conditions: GameConditionService, ) {} /** Every enabled NPC at a location, for the local view (spec §22, §24). */ async getNpcsAtLocation(locationId: string): Promise { const npcs = await this.dataSource.getRepository(NpcDefinition).find({ where: { locationId, enabled: true }, order: { key: 'ASC' }, }); const summaries: NpcSummaryDto[] = []; for (const npc of npcs) { summaries.push({ id: npc.id, key: npc.key, name: npc.name, title: npc.title, portraitPath: npc.portraitPath, markers: await this.resolveMarkers(npc), }); } return summaries; } /** * Everything the client needs to render one NPC screen (spec §23). * * The character's own location decides reachability, never the request, so * a client cannot talk to a merchant in a town it has not travelled to * (the same rule `WorldService.runLocalInteraction` applies). */ async getInteraction( characterId: string, npcKey: string, ): Promise { const npc = await this.requireReachableNpc(characterId, npcKey); // Dialogue is resolved *before* the visit is recorded. `touchNpcState` // sets the `met` flag, so a greeting node conditioned on `met = false` // would never fire if the order were reversed. const dialogue = await this.resolveDialogue(characterId, npc); await this.touchNpcState(characterId, npc.id); return { npc: { id: npc.id, key: npc.key, name: npc.name, title: npc.title, description: npc.description, portraitPath: npc.portraitPath, artworkPath: npc.artworkPath, capabilities: npc.capabilities ?? [], }, dialogue, availableActions: await this.resolveActions(npc), }; } /** Loads an enabled NPC the character is currently standing with. */ async requireReachableNpc( characterId: string, npcKey: string, ): Promise { const npc = await this.dataSource .getRepository(NpcDefinition) .findOneBy({ key: npcKey, enabled: true }); if (!npc) { throw npcNotFound(); } const character = await this.dataSource .getRepository(Character) .findOneBy({ id: characterId }); if (!character) { throw npcNotFound(); } if (character.currentLocationId !== npc.locationId) { throw npcUnavailable(); } return npc; } /** * Picks the highest-priority dialogue whose conditions hold (spec §11). * * Ties break on key so a content mistake produces the same line every time * rather than whatever the database happened to return first. */ private async resolveDialogue( characterId: string, npc: NpcDefinition, ): Promise { const nodes = await this.dataSource.getRepository(DialogueNode).find({ where: { npcId: npc.id, enabled: true }, order: { priority: 'DESC', key: 'ASC' }, }); for (const node of nodes) { const met = await this.conditions.evaluate( { characterId, npcId: npc.id }, node.conditions, ); if (!met) { continue; } const responses: DialogueResponseDto[] = []; for (const response of node.responses ?? []) { const allowed = await this.conditions.evaluate( { characterId, npcId: npc.id }, response.conditions, ); if (allowed) { responses.push({ key: response.key, text: response.text, targetNodeKey: response.targetNodeKey ?? null, }); } } return { key: node.key, text: node.text, responses }; } return null; } /** * The actions the server is willing to honour right now (spec §23). * * Driven by whether the backing data exists and is enabled, not by the * declared capability list -- an NPC that claims MERCHANT but has no * enabled shop offers no shop button (spec §5). */ private async resolveActions(npc: NpcDefinition): Promise { const actions: NpcActionDto[] = [ { type: 'TALK', label: 'Talk', key: null }, ]; const shop = await this.dataSource .getRepository(NpcShop) .findOneBy({ npcId: npc.id, enabled: true }); if (shop) { actions.push({ type: 'OPEN_SHOP', label: 'Browse Wares', key: shop.key }); } const profile = await this.findUsableExchangeProfile(npc.id); if (profile) { actions.push({ type: 'OPEN_EXCHANGE', label: 'Trade In Goods', key: profile.key, }); } return actions; } /** Markers for the local view. Only backed interactions get one (spec §24). */ private async resolveMarkers(npc: NpcDefinition): Promise { const markers: NpcMarker[] = []; const shop = await this.dataSource .getRepository(NpcShop) .findOneBy({ npcId: npc.id, enabled: true }); if (shop) { markers.push('MERCHANT'); } if (await this.findUsableExchangeProfile(npc.id)) { markers.push('EXCHANGE'); } return markers; } /** * An enabled exchange profile that actually has an enabled rule. * * An empty profile would otherwise advertise a trade-in screen with nothing * on it. */ private async findUsableExchangeProfile( npcId: string, ): Promise { const profile = await this.dataSource .getRepository(NpcExchangeProfile) .findOneBy({ npcId, enabled: true }); if (!profile) { return null; } const ruleCount = await this.dataSource .getRepository(ExchangeRule) .countBy({ profileId: profile.id, enabled: true }); return ruleCount > 0 ? profile : null; } /** * Records that this character has now spoken to this NPC (spec §7). * * `firstMetAt` is written once and never overwritten. The `met` flag it * sets alongside is what a greeting node conditions on, which keeps "have * we met before" in the same FLAG_SET vocabulary as every other gate rather * than inventing a second mechanism for one line of dialogue. */ private async touchNpcState( characterId: string, npcId: string, ): Promise { const states = this.dataSource.getRepository(CharacterNpcState); const existing = await states.findOneBy({ characterId, npcId }); const now = new Date(); if (existing) { existing.lastInteractionAt = now; existing.flags = { ...existing.flags, met: true }; await states.save(existing); return; } await states.save( states.create({ characterId, npcId, firstMetAt: now, lastInteractionAt: now, flags: { met: true }, }), ); } }