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 { 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,
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user