This commit is contained in:
Bastian Wagner
2026-08-22 16:41:47 +02:00
parent dfa62fd152
commit 081c9f83f9
137 changed files with 11594 additions and 1302 deletions

View File

@@ -0,0 +1,217 @@
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'>;
export interface ConditionOutcome {
condition: GameCondition;
met: boolean;
}
/**
* 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) {
if (!(await this.evaluateOne(context, condition, scope))) {
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) {
outcomes.push({
condition,
met: await this.evaluateOne(context, condition, scope),
});
}
return outcomes;
}
private async evaluateOne(
context: ConditionContext,
condition: GameCondition,
scope: RepositoryScope,
): Promise<boolean> {
if (!SUPPORTED_CONDITION_TYPES.has(condition.type)) {
return false;
}
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 false;
}
}
private async evaluateRegionReputation(
context: ConditionContext,
condition: GameCondition,
scope: RepositoryScope,
): Promise<boolean> {
if (!condition.key) {
return false;
}
const faction = await scope
.getRepository(ReputationFaction)
.findOneBy({ key: condition.key, enabled: true });
if (!faction) {
return false;
}
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.
return this.compareNumeric(row?.reputation ?? 0, condition);
}
private async evaluateWorldRenown(
context: ConditionContext,
condition: GameCondition,
scope: RepositoryScope,
): Promise<boolean> {
const character = await scope
.getRepository(Character)
.findOneBy({ id: context.characterId });
if (!character) {
return false;
}
return this.compareNumeric(character.renown, condition);
}
private async evaluateFlag(
context: ConditionContext,
condition: GameCondition,
scope: RepositoryScope,
): Promise<boolean> {
// 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 false;
}
const state = await scope.getRepository(CharacterNpcState).findOneBy({
characterId: context.characterId,
npcId: context.npcId,
});
const expected = condition.value ?? true;
return (state?.flags?.[condition.key] ?? false) === expected;
}
private async evaluateHasItem(
context: ConditionContext,
condition: GameCondition,
scope: RepositoryScope,
): Promise<boolean> {
if (!condition.key) {
return false;
}
const definition = await scope
.getRepository(ItemDefinition)
.findOneBy({ key: condition.key });
if (!definition) {
return false;
}
const owned = await scope.getRepository(CharacterItem).findOneBy({
characterId: context.characterId,
itemDefinitionId: definition.id,
});
// "Has item" without an explicit comparison means "at least one".
return this.compareNumeric(owned?.quantity ?? 0, {
...condition,
operator: condition.operator ?? ComparisonOperator.GTE,
value: condition.value ?? 1,
});
}
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,
);
}
}