247 lines
7.3 KiB
TypeScript
247 lines
7.3 KiB
TypeScript
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<DataSource, 'getRepository'>;
|
|
|
|
/**
|
|
* 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<boolean> {
|
|
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<ConditionOutcome[]> {
|
|
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<ConditionEvaluation> {
|
|
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<ConditionEvaluation> {
|
|
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<ConditionEvaluation> {
|
|
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<ConditionEvaluation> {
|
|
// 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<ConditionEvaluation> {
|
|
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,
|
|
);
|
|
}
|
|
}
|