From 9bd28b5e2c654d203f6240abc90c333fb7d50bd2 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 18:07:57 +0200 Subject: [PATCH] feat(conditions): report the current value behind each condition --- .../conditions/game-condition.service.spec.ts | 34 +++++++++ .../src/conditions/game-condition.service.ts | 75 +++++++++++++------ 2 files changed, 86 insertions(+), 23 deletions(-) diff --git a/apps/api/src/conditions/game-condition.service.spec.ts b/apps/api/src/conditions/game-condition.service.spec.ts index 8cf3375..e083ad7 100644 --- a/apps/api/src/conditions/game-condition.service.spec.ts +++ b/apps/api/src/conditions/game-condition.service.spec.ts @@ -280,4 +280,38 @@ describe('GameConditionService', () => { 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(); + }); }); diff --git a/apps/api/src/conditions/game-condition.service.ts b/apps/api/src/conditions/game-condition.service.ts index f338e77..af5ebaa 100644 --- a/apps/api/src/conditions/game-condition.service.ts +++ b/apps/api/src/conditions/game-condition.service.ts @@ -27,9 +27,21 @@ export interface ConditionContext { 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; } /** @@ -59,7 +71,8 @@ export class GameConditionService { const scope: RepositoryScope = manager ?? this.dataSource; 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; } } @@ -83,9 +96,11 @@ export class GameConditionService { 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: await this.evaluateOne(context, condition, scope), + met: evaluation.met, + actual: evaluation.actual, }); } return outcomes; @@ -95,9 +110,9 @@ export class GameConditionService { context: ConditionContext, condition: GameCondition, scope: RepositoryScope, - ): Promise { + ): Promise { if (!SUPPORTED_CONDITION_TYPES.has(condition.type)) { - return false; + return { met: false, actual: null }; } switch (condition.type) { @@ -110,7 +125,7 @@ export class GameConditionService { case GameConditionType.HAS_ITEM: return this.evaluateHasItem(context, condition, scope); default: - return false; + return { met: false, actual: null }; } } @@ -118,16 +133,16 @@ export class GameConditionService { context: ConditionContext, condition: GameCondition, scope: RepositoryScope, - ): Promise { + ): Promise { if (!condition.key) { - return false; + return { met: false, actual: null }; } const faction = await scope .getRepository(ReputationFaction) .findOneBy({ key: condition.key, enabled: true }); if (!faction) { - return false; + return { met: false, actual: null }; } 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 // -- 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( context: ConditionContext, condition: GameCondition, scope: RepositoryScope, - ): Promise { + ): Promise { const character = await scope .getRepository(Character) .findOneBy({ id: context.characterId }); 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( context: ConditionContext, condition: GameCondition, scope: RepositoryScope, - ): Promise { + ): 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 false; + return { met: false, actual: null }; } const state = await scope.getRepository(CharacterNpcState).findOneBy({ @@ -171,23 +193,26 @@ export class GameConditionService { }); 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( context: ConditionContext, condition: GameCondition, scope: RepositoryScope, - ): Promise { + ): Promise { if (!condition.key) { - return false; + return { met: false, actual: null }; } const definition = await scope .getRepository(ItemDefinition) .findOneBy({ key: condition.key }); if (!definition) { - return false; + return { met: false, actual: null }; } const owned = await scope.getRepository(CharacterItem).findOneBy({ @@ -196,11 +221,15 @@ export class GameConditionService { }); // "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, - }); + 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 {