import { Injectable } from '@nestjs/common'; import { DataSource, EntityManager } from 'typeorm'; import { Character } from '../characters/entities/character.entity'; import { CharacterItem } from '../items/entities/character-item.entity'; import { ItemDefinition } from '../items/entities/item-definition.entity'; import { CharacterNpcState } from '../npcs/entities/character-npc-state.entity'; import { CharacterReputation } from '../reputation/entities/character-reputation.entity'; import { ReputationFaction } from '../reputation/entities/reputation-faction.entity'; import { ComparisonOperator, compare, GameCondition, GameConditionType, SUPPORTED_CONDITION_TYPES, } from './game-condition.types'; /** * What a condition is being evaluated *about*. * * `npcId` scopes FLAG_SET, because a dialogue flag is per-NPC player state * (spec §7) rather than a global switch. */ export interface ConditionContext { characterId: string; npcId?: string; } type RepositoryScope = Pick; /** * One condition's result, plus the number it was measured against. * * `actual` is null for conditions with no scale -- a flag is set or it is not, * and "Current: 0" would be a lie about a boolean. */ interface ConditionEvaluation { met: boolean; actual: number | null; } export interface ConditionOutcome { condition: GameCondition; met: boolean; actual: number | null; } /** * Evaluates content-defined conditions server-side (NPC spec §21). * * Reused by dialogue, shop offers and exchange rules so unlock logic exists * once. The client never evaluates a condition (spec §37.9); it is only ever * told the outcome. * * Everything fails closed. An unknown type, a missing key, a condition whose * backing system does not exist yet -- all read as "not met". For a gate, the * safe direction of a bug is locked, never opened. */ @Injectable() export class GameConditionService { constructor(private readonly dataSource: DataSource) {} /** True only when every condition holds. An empty list is always true. */ async evaluate( context: ConditionContext, conditions: GameCondition[] | null | undefined, manager?: EntityManager, ): Promise { if (!conditions || conditions.length === 0) { return true; } const scope: RepositoryScope = manager ?? this.dataSource; for (const condition of conditions) { const evaluation = await this.evaluateOne(context, condition, scope); if (!evaluation.met) { return false; } } return true; } /** * Reports each condition individually, so a caller can say *why* something * is locked instead of only hiding it. Slice 0.8.5 builds its locked-offer * presentation on this. */ async describe( context: ConditionContext, conditions: GameCondition[] | null | undefined, manager?: EntityManager, ): Promise { if (!conditions || conditions.length === 0) { return []; } const scope: RepositoryScope = manager ?? this.dataSource; const outcomes: ConditionOutcome[] = []; for (const condition of conditions) { const evaluation = await this.evaluateOne(context, condition, scope); outcomes.push({ condition, met: evaluation.met, actual: evaluation.actual, }); } return outcomes; } private async evaluateOne( context: ConditionContext, condition: GameCondition, scope: RepositoryScope, ): Promise { if (!SUPPORTED_CONDITION_TYPES.has(condition.type)) { return { met: false, actual: null }; } switch (condition.type) { case GameConditionType.REGION_REPUTATION: return this.evaluateRegionReputation(context, condition, scope); case GameConditionType.WORLD_RENOWN: return this.evaluateWorldRenown(context, condition, scope); case GameConditionType.FLAG_SET: return this.evaluateFlag(context, condition, scope); case GameConditionType.HAS_ITEM: return this.evaluateHasItem(context, condition, scope); default: return { met: false, actual: null }; } } private async evaluateRegionReputation( context: ConditionContext, condition: GameCondition, scope: RepositoryScope, ): Promise { if (!condition.key) { return { met: false, actual: null }; } const faction = await scope .getRepository(ReputationFaction) .findOneBy({ key: condition.key, enabled: true }); if (!faction) { return { met: false, actual: null }; } const row = await scope.getRepository(CharacterReputation).findOneBy({ characterId: context.characterId, factionId: faction.id, }); // A faction the character never interacted with reads as 0, not as absent // -- the same rule ReputationService.getCharacterReputation applies. const reputation = row?.reputation ?? 0; return { met: this.compareNumeric(reputation, condition), actual: reputation, }; } private async evaluateWorldRenown( context: ConditionContext, condition: GameCondition, scope: RepositoryScope, ): Promise { const character = await scope .getRepository(Character) .findOneBy({ id: context.characterId }); if (!character) { return { met: false, actual: null }; } return { met: this.compareNumeric(character.renown, condition), actual: character.renown, }; } private async evaluateFlag( context: ConditionContext, condition: GameCondition, scope: RepositoryScope, ): Promise { // Flags are per-NPC player state (spec §7). Without an NPC in context // there is nothing to read, so the gate stays shut. if (!condition.key || !context.npcId) { return { met: false, actual: null }; } const state = await scope.getRepository(CharacterNpcState).findOneBy({ characterId: context.characterId, npcId: context.npcId, }); const expected = condition.value ?? true; return { met: (state?.flags?.[condition.key] ?? false) === expected, actual: null, }; } private async evaluateHasItem( context: ConditionContext, condition: GameCondition, scope: RepositoryScope, ): Promise { if (!condition.key) { return { met: false, actual: null }; } const definition = await scope .getRepository(ItemDefinition) .findOneBy({ key: condition.key }); if (!definition) { return { met: false, actual: null }; } const owned = await scope.getRepository(CharacterItem).findOneBy({ characterId: context.characterId, itemDefinitionId: definition.id, }); // "Has item" without an explicit comparison means "at least one". const quantity = owned?.quantity ?? 0; return { met: this.compareNumeric(quantity, { ...condition, operator: condition.operator ?? ComparisonOperator.GTE, value: condition.value ?? 1, }), actual: quantity, }; } private compareNumeric(actual: number, condition: GameCondition): boolean { const expected = Number(condition.value); if (!Number.isFinite(expected)) { return false; } return compare( actual, condition.operator ?? ComparisonOperator.GTE, expected, ); } }