feat(conditions): report the current value behind each condition

This commit is contained in:
Bastian Wagner
2026-08-22 18:07:57 +02:00
parent c295bae63a
commit 9bd28b5e2c
2 changed files with 86 additions and 23 deletions

View File

@@ -280,4 +280,38 @@ describe('GameConditionService', () => {
expect(outcomes.map((outcome) => outcome.met)).toEqual([true, false]); expect(outcomes.map((outcome) => outcome.met)).toEqual([true, false]);
}); });
it('reports the current value behind each requirement', async () => {
const service = createService({ renown: 1, reputation: 14 });
const outcomes = await service.describe({ characterId: CHARACTER_ID }, [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 25,
},
{
type: GameConditionType.WORLD_RENOWN,
operator: ComparisonOperator.GTE,
value: 3,
},
]);
// "Current: 14" against "Requires 25" is the whole point of showing a
// locked offer rather than hiding it (slice §5).
expect(outcomes[0]).toMatchObject({ met: false, actual: 14 });
expect(outcomes[1]).toMatchObject({ met: false, actual: 1 });
});
it('reports a null current value for a condition with no scale', async () => {
const service = createService({ renown: 1, reputation: 100 });
const outcomes = await service.describe(
{ characterId: CHARACTER_ID, npcId: NPC_ID },
[{ type: GameConditionType.FLAG_SET, key: 'met', value: true }],
);
expect(outcomes[0].actual).toBeNull();
});
}); });

View File

@@ -27,9 +27,21 @@ export interface ConditionContext {
type RepositoryScope = Pick<DataSource, 'getRepository'>; 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 { export interface ConditionOutcome {
condition: GameCondition; condition: GameCondition;
met: boolean; met: boolean;
actual: number | null;
} }
/** /**
@@ -59,7 +71,8 @@ export class GameConditionService {
const scope: RepositoryScope = manager ?? this.dataSource; const scope: RepositoryScope = manager ?? this.dataSource;
for (const condition of conditions) { for (const condition of conditions) {
if (!(await this.evaluateOne(context, condition, scope))) { const evaluation = await this.evaluateOne(context, condition, scope);
if (!evaluation.met) {
return false; return false;
} }
} }
@@ -83,9 +96,11 @@ export class GameConditionService {
const scope: RepositoryScope = manager ?? this.dataSource; const scope: RepositoryScope = manager ?? this.dataSource;
const outcomes: ConditionOutcome[] = []; const outcomes: ConditionOutcome[] = [];
for (const condition of conditions) { for (const condition of conditions) {
const evaluation = await this.evaluateOne(context, condition, scope);
outcomes.push({ outcomes.push({
condition, condition,
met: await this.evaluateOne(context, condition, scope), met: evaluation.met,
actual: evaluation.actual,
}); });
} }
return outcomes; return outcomes;
@@ -95,9 +110,9 @@ export class GameConditionService {
context: ConditionContext, context: ConditionContext,
condition: GameCondition, condition: GameCondition,
scope: RepositoryScope, scope: RepositoryScope,
): Promise<boolean> { ): Promise<ConditionEvaluation> {
if (!SUPPORTED_CONDITION_TYPES.has(condition.type)) { if (!SUPPORTED_CONDITION_TYPES.has(condition.type)) {
return false; return { met: false, actual: null };
} }
switch (condition.type) { switch (condition.type) {
@@ -110,7 +125,7 @@ export class GameConditionService {
case GameConditionType.HAS_ITEM: case GameConditionType.HAS_ITEM:
return this.evaluateHasItem(context, condition, scope); return this.evaluateHasItem(context, condition, scope);
default: default:
return false; return { met: false, actual: null };
} }
} }
@@ -118,16 +133,16 @@ export class GameConditionService {
context: ConditionContext, context: ConditionContext,
condition: GameCondition, condition: GameCondition,
scope: RepositoryScope, scope: RepositoryScope,
): Promise<boolean> { ): Promise<ConditionEvaluation> {
if (!condition.key) { if (!condition.key) {
return false; return { met: false, actual: null };
} }
const faction = await scope const faction = await scope
.getRepository(ReputationFaction) .getRepository(ReputationFaction)
.findOneBy({ key: condition.key, enabled: true }); .findOneBy({ key: condition.key, enabled: true });
if (!faction) { if (!faction) {
return false; return { met: false, actual: null };
} }
const row = await scope.getRepository(CharacterReputation).findOneBy({ const row = await scope.getRepository(CharacterReputation).findOneBy({
@@ -137,32 +152,39 @@ export class GameConditionService {
// A faction the character never interacted with reads as 0, not as absent // A faction the character never interacted with reads as 0, not as absent
// -- the same rule ReputationService.getCharacterReputation applies. // -- the same rule ReputationService.getCharacterReputation applies.
return this.compareNumeric(row?.reputation ?? 0, condition); const reputation = row?.reputation ?? 0;
return {
met: this.compareNumeric(reputation, condition),
actual: reputation,
};
} }
private async evaluateWorldRenown( private async evaluateWorldRenown(
context: ConditionContext, context: ConditionContext,
condition: GameCondition, condition: GameCondition,
scope: RepositoryScope, scope: RepositoryScope,
): Promise<boolean> { ): Promise<ConditionEvaluation> {
const character = await scope const character = await scope
.getRepository(Character) .getRepository(Character)
.findOneBy({ id: context.characterId }); .findOneBy({ id: context.characterId });
if (!character) { if (!character) {
return false; return { met: false, actual: null };
} }
return this.compareNumeric(character.renown, condition); return {
met: this.compareNumeric(character.renown, condition),
actual: character.renown,
};
} }
private async evaluateFlag( private async evaluateFlag(
context: ConditionContext, context: ConditionContext,
condition: GameCondition, condition: GameCondition,
scope: RepositoryScope, scope: RepositoryScope,
): Promise<boolean> { ): Promise<ConditionEvaluation> {
// Flags are per-NPC player state (spec §7). Without an NPC in context // Flags are per-NPC player state (spec §7). Without an NPC in context
// there is nothing to read, so the gate stays shut. // there is nothing to read, so the gate stays shut.
if (!condition.key || !context.npcId) { if (!condition.key || !context.npcId) {
return false; return { met: false, actual: null };
} }
const state = await scope.getRepository(CharacterNpcState).findOneBy({ const state = await scope.getRepository(CharacterNpcState).findOneBy({
@@ -171,23 +193,26 @@ export class GameConditionService {
}); });
const expected = condition.value ?? true; const expected = condition.value ?? true;
return (state?.flags?.[condition.key] ?? false) === expected; return {
met: (state?.flags?.[condition.key] ?? false) === expected,
actual: null,
};
} }
private async evaluateHasItem( private async evaluateHasItem(
context: ConditionContext, context: ConditionContext,
condition: GameCondition, condition: GameCondition,
scope: RepositoryScope, scope: RepositoryScope,
): Promise<boolean> { ): Promise<ConditionEvaluation> {
if (!condition.key) { if (!condition.key) {
return false; return { met: false, actual: null };
} }
const definition = await scope const definition = await scope
.getRepository(ItemDefinition) .getRepository(ItemDefinition)
.findOneBy({ key: condition.key }); .findOneBy({ key: condition.key });
if (!definition) { if (!definition) {
return false; return { met: false, actual: null };
} }
const owned = await scope.getRepository(CharacterItem).findOneBy({ const owned = await scope.getRepository(CharacterItem).findOneBy({
@@ -196,11 +221,15 @@ export class GameConditionService {
}); });
// "Has item" without an explicit comparison means "at least one". // "Has item" without an explicit comparison means "at least one".
return this.compareNumeric(owned?.quantity ?? 0, { const quantity = owned?.quantity ?? 0;
...condition, return {
operator: condition.operator ?? ComparisonOperator.GTE, met: this.compareNumeric(quantity, {
value: condition.value ?? 1, ...condition,
}); operator: condition.operator ?? ComparisonOperator.GTE,
value: condition.value ?? 1,
}),
actual: quantity,
};
} }
private compareNumeric(actual: number, condition: GameCondition): boolean { private compareNumeric(actual: number, condition: GameCondition): boolean {