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

View File

@@ -3,6 +3,9 @@ import { Character } from '../characters/entities/character.entity';
import { CharacterItem } from '../items/entities/character-item.entity'; import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity'; import { ItemDefinition } from '../items/entities/item-definition.entity';
import { CharacterNpcState } from '../npcs/entities/character-npc-state.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 { CharacterReputation } from '../reputation/entities/character-reputation.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity'; import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { GameConditionService } from './game-condition.service'; import { GameConditionService } from './game-condition.service';
@@ -11,6 +14,8 @@ import { ComparisonOperator, GameConditionType } from './game-condition.types';
const CHARACTER_ID = 'character-1'; const CHARACTER_ID = 'character-1';
const NPC_ID = 'npc-1'; const NPC_ID = 'npc-1';
const FACTION_ID = 'faction-1'; const FACTION_ID = 'faction-1';
const QUEST_ID = 'quest-1';
const QUEST_KEY = 'trouble-beyond-the-gate';
interface Fixture { interface Fixture {
renown?: number; renown?: number;
@@ -18,6 +23,9 @@ interface Fixture {
factionEnabled?: boolean; factionEnabled?: boolean;
flags?: Record<string, boolean | string | number> | null; flags?: Record<string, boolean | string | number> | null;
itemQuantity?: number; itemQuantity?: number;
questEnabled?: boolean;
/** Undefined means the character never took the quest on. */
questStatus?: CharacterQuestStatus;
} }
function createService(fixture: Fixture = {}): GameConditionService { 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'); throw new Error('Unexpected repository');
}, },
} as unknown as DataSource; } as unknown as DataSource;
@@ -314,4 +343,93 @@ describe('GameConditionService', () => {
expect(outcomes[0].actual).toBeNull(); 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 { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity'; import { ItemDefinition } from '../items/entities/item-definition.entity';
import { CharacterNpcState } from '../npcs/entities/character-npc-state.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 { CharacterReputation } from '../reputation/entities/character-reputation.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity'; import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { import {
@@ -124,11 +127,63 @@ export class GameConditionService {
return this.evaluateFlag(context, condition, scope); return this.evaluateFlag(context, condition, scope);
case GameConditionType.HAS_ITEM: case GameConditionType.HAS_ITEM:
return this.evaluateHasItem(context, condition, scope); 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: default:
return { met: false, actual: null }; 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( private async evaluateRegionReputation(
context: ConditionContext, context: ConditionContext,
condition: GameCondition, condition: GameCondition,

View File

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