feat(conditions): evaluate QUEST_ACTIVE and QUEST_COMPLETED

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-22 22:48:02 +02:00
parent e13f6533e5
commit d1cca086c6
4 changed files with 185 additions and 4 deletions

View File

@@ -4,6 +4,8 @@ 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 { CharacterQuest } from '../quests/entities/character-quest.entity';
import { QuestDefinition } from '../quests/entities/quest-definition.entity';
import { CharacterReputation } from '../reputation/entities/character-reputation.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { GameConditionService } from './game-condition.service';
@@ -22,7 +24,9 @@ import { GameConditionService } from './game-condition.service';
CharacterItem,
ItemDefinition,
CharacterNpcState,
CharacterQuest,
CharacterReputation,
QuestDefinition,
ReputationFaction,
]),
],

View File

@@ -3,6 +3,9 @@ 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 { CharacterQuest } from '../quests/entities/character-quest.entity';
import { QuestDefinition } from '../quests/entities/quest-definition.entity';
import { CharacterQuestStatus } from '../quests/quest.types';
import { CharacterReputation } from '../reputation/entities/character-reputation.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { GameConditionService } from './game-condition.service';
@@ -11,6 +14,8 @@ import { ComparisonOperator, GameConditionType } from './game-condition.types';
const CHARACTER_ID = 'character-1';
const NPC_ID = 'npc-1';
const FACTION_ID = 'faction-1';
const QUEST_ID = 'quest-1';
const QUEST_KEY = 'trouble-beyond-the-gate';
interface Fixture {
renown?: number;
@@ -18,6 +23,9 @@ interface Fixture {
factionEnabled?: boolean;
flags?: Record<string, boolean | string | number> | null;
itemQuantity?: number;
questEnabled?: boolean;
/** Undefined means the character never took the quest on. */
questStatus?: CharacterQuestStatus;
}
function createService(fixture: Fixture = {}): GameConditionService {
@@ -81,6 +89,27 @@ function createService(fixture: Fixture = {}): GameConditionService {
),
};
}
if (entity === QuestDefinition) {
return {
findOneBy: (criteria: { key: string; enabled: boolean }) =>
Promise.resolve(
criteria.key === QUEST_KEY &&
(fixture.questEnabled ?? true) === criteria.enabled
? { id: QUEST_ID, key: QUEST_KEY }
: null,
),
};
}
if (entity === CharacterQuest) {
return {
findOneBy: () =>
Promise.resolve(
fixture.questStatus === undefined
? null
: { questId: QUEST_ID, status: fixture.questStatus },
),
};
}
throw new Error('Unexpected repository');
},
} as unknown as DataSource;
@@ -314,4 +343,93 @@ describe('GameConditionService', () => {
expect(outcomes[0].actual).toBeNull();
});
it('treats a quest in progress as active and not completed', async () => {
const service = createService({ questStatus: CharacterQuestStatus.ACTIVE });
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{ type: GameConditionType.QUEST_ACTIVE, key: QUEST_KEY },
]),
).resolves.toBe(true);
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{ type: GameConditionType.QUEST_COMPLETED, key: QUEST_KEY },
]),
).resolves.toBe(false);
});
it('treats a finished quest as completed and no longer active', async () => {
const service = createService({
questStatus: CharacterQuestStatus.COMPLETED,
});
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{ type: GameConditionType.QUEST_COMPLETED, key: QUEST_KEY },
]),
).resolves.toBe(true);
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{ type: GameConditionType.QUEST_ACTIVE, key: QUEST_KEY },
]),
).resolves.toBe(false);
});
it('lets content ask for the negative with value false', async () => {
// How the warden's offer line asks for "not started and not finished"
// without a NOT operator in the vocabulary -- the same shape FLAG_SET uses.
const service = createService({ questStatus: undefined });
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{ type: GameConditionType.QUEST_ACTIVE, key: QUEST_KEY, value: false },
{
type: GameConditionType.QUEST_COMPLETED,
key: QUEST_KEY,
value: false,
},
]),
).resolves.toBe(true);
});
it('closes a quest gate for an unknown or disabled quest', async () => {
// Fail-closed: a gate must never open because the content behind it is
// missing.
await expect(
createService({ questStatus: CharacterQuestStatus.ACTIVE }).evaluate(
{ characterId: CHARACTER_ID },
[{ type: GameConditionType.QUEST_ACTIVE, key: 'no-such-quest' }],
),
).resolves.toBe(false);
await expect(
createService({
questStatus: CharacterQuestStatus.ACTIVE,
questEnabled: false,
}).evaluate({ characterId: CHARACTER_ID }, [
{ type: GameConditionType.QUEST_ACTIVE, key: QUEST_KEY },
]),
).resolves.toBe(false);
});
it('closes a quest gate that names no quest at all', async () => {
await expect(
createService({ questStatus: CharacterQuestStatus.ACTIVE }).evaluate(
{ characterId: CHARACTER_ID },
[{ type: GameConditionType.QUEST_ACTIVE }],
),
).resolves.toBe(false);
});
it('reports no measured value for a quest condition', async () => {
// A quest is active or it is not. "Current: 0" would be a lie about a
// boolean, and the shop view renders these as player-facing requirements.
const service = createService({ questStatus: undefined });
const outcomes = await service.describe({ characterId: CHARACTER_ID }, [
{ type: GameConditionType.QUEST_COMPLETED, key: QUEST_KEY },
]);
expect(outcomes[0]).toMatchObject({ met: false, actual: null });
});
});

View File

@@ -4,6 +4,9 @@ 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 { CharacterQuest } from '../quests/entities/character-quest.entity';
import { QuestDefinition } from '../quests/entities/quest-definition.entity';
import { CharacterQuestStatus } from '../quests/quest.types';
import { CharacterReputation } from '../reputation/entities/character-reputation.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import {
@@ -124,11 +127,63 @@ export class GameConditionService {
return this.evaluateFlag(context, condition, scope);
case GameConditionType.HAS_ITEM:
return this.evaluateHasItem(context, condition, scope);
case GameConditionType.QUEST_ACTIVE:
return this.evaluateQuestStatus(
context,
condition,
scope,
CharacterQuestStatus.ACTIVE,
);
case GameConditionType.QUEST_COMPLETED:
return this.evaluateQuestStatus(
context,
condition,
scope,
CharacterQuestStatus.COMPLETED,
);
default:
return { met: false, actual: null };
}
}
/**
* Whether the character stands in a given place with a given quest (§19).
*
* Boolean-shaped like `FLAG_SET` rather than numeric: a quest is active or it
* is not, so `actual` stays null -- reporting "Current: 0" for it would be a
* lie about a boolean, and the shop view renders these outcomes as
* player-facing requirements.
*
* `value: false` is how content asks for the negative. The warden's offer
* line needs "not active and not completed", and this keeps that expressible
* without adding a NOT to the condition vocabulary.
*/
private async evaluateQuestStatus(
context: ConditionContext,
condition: GameCondition,
scope: RepositoryScope,
status: CharacterQuestStatus,
): Promise<ConditionEvaluation> {
if (!condition.key) {
return { met: false, actual: null };
}
const quest = await scope
.getRepository(QuestDefinition)
.findOneBy({ key: condition.key, enabled: true });
if (!quest) {
return { met: false, actual: null };
}
const row = await scope.getRepository(CharacterQuest).findOneBy({
characterId: context.characterId,
questId: quest.id,
});
const expected = condition.value ?? true;
return { met: (row?.status === status) === expected, actual: null };
}
private async evaluateRegionReputation(
context: ConditionContext,
condition: GameCondition,

View File

@@ -41,10 +41,12 @@ export interface GameCondition {
* Condition types this build can actually answer.
*
* The remaining types are part of the V1 vocabulary (spec §19) but have no
* backing system yet: quests arrive in Slice 0.9, bosses in 0.11, and location
* discovery is not tracked per character at all. They are listed in the enum
* so content and migrations do not need rewriting later, and rejected at
* evaluation time so an unbacked gate can never silently read as "passed".
* backing system yet: bosses arrive in Slice 0.11, and location discovery is
* not tracked per character at all. They are listed in the enum so content and
* migrations do not need rewriting later, and rejected at evaluation time so an
* unbacked gate can never silently read as "passed".
*
* The two quest types joined the list in Slice 0.9, when quests became real.
*/
export const SUPPORTED_CONDITION_TYPES: ReadonlySet<GameConditionType> =
new Set([
@@ -52,6 +54,8 @@ export const SUPPORTED_CONDITION_TYPES: ReadonlySet<GameConditionType> =
GameConditionType.WORLD_RENOWN,
GameConditionType.FLAG_SET,
GameConditionType.HAS_ITEM,
GameConditionType.QUEST_ACTIVE,
GameConditionType.QUEST_COMPLETED,
]);
export function compare(