diff --git a/apps/api/src/conditions/conditions.module.ts b/apps/api/src/conditions/conditions.module.ts index 60cce83..ef46a04 100644 --- a/apps/api/src/conditions/conditions.module.ts +++ b/apps/api/src/conditions/conditions.module.ts @@ -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, ]), ], diff --git a/apps/api/src/conditions/game-condition.service.spec.ts b/apps/api/src/conditions/game-condition.service.spec.ts index e083ad7..4154cf3 100644 --- a/apps/api/src/conditions/game-condition.service.spec.ts +++ b/apps/api/src/conditions/game-condition.service.spec.ts @@ -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 | 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 }); + }); }); diff --git a/apps/api/src/conditions/game-condition.service.ts b/apps/api/src/conditions/game-condition.service.ts index af5ebaa..62c8739 100644 --- a/apps/api/src/conditions/game-condition.service.ts +++ b/apps/api/src/conditions/game-condition.service.ts @@ -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 { + 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, diff --git a/apps/api/src/conditions/game-condition.types.ts b/apps/api/src/conditions/game-condition.types.ts index d4ad473..afb2f72 100644 --- a/apps/api/src/conditions/game-condition.types.ts +++ b/apps/api/src/conditions/game-condition.types.ts @@ -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 = new Set([ @@ -52,6 +54,8 @@ export const SUPPORTED_CONDITION_TYPES: ReadonlySet = GameConditionType.WORLD_RENOWN, GameConditionType.FLAG_SET, GameConditionType.HAS_ITEM, + GameConditionType.QUEST_ACTIVE, + GameConditionType.QUEST_COMPLETED, ]); export function compare(