feat(conditions): report the current value behind each condition
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user