feat(conditions): evaluate QUEST_ACTIVE and QUEST_COMPLETED
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
]),
|
||||
],
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user