feat(quests): derive the active objective from owned quantity
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
194
apps/api/src/quests/quest-state.spec.ts
Normal file
194
apps/api/src/quests/quest-state.spec.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { QuestObjectiveType } from './quest.types';
|
||||
import {
|
||||
isObjectiveSatisfied,
|
||||
ObjectiveSnapshot,
|
||||
resolveCurrentObjectiveIndex,
|
||||
} from './quest-state';
|
||||
|
||||
function collect(over: Partial<ObjectiveSnapshot> = {}): ObjectiveSnapshot {
|
||||
return {
|
||||
type: QuestObjectiveType.COLLECT_ITEM,
|
||||
requiredQuantity: 5,
|
||||
advanceWhenBlocked: false,
|
||||
current: 0,
|
||||
blocked: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function talk(over: Partial<ObjectiveSnapshot> = {}): ObjectiveSnapshot {
|
||||
return {
|
||||
type: QuestObjectiveType.TALK_TO_NPC,
|
||||
requiredQuantity: 1,
|
||||
advanceWhenBlocked: false,
|
||||
current: 0,
|
||||
blocked: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** The real shape of Trouble Beyond the Gate (Slice 0.9 §3–§8). */
|
||||
function troubleBeyondTheGate(
|
||||
peltsCarried: number,
|
||||
hideCapacityReached: boolean,
|
||||
): ObjectiveSnapshot[] {
|
||||
return [
|
||||
collect({
|
||||
current: peltsCarried,
|
||||
blocked: hideCapacityReached,
|
||||
advanceWhenBlocked: true,
|
||||
}),
|
||||
talk(),
|
||||
talk(),
|
||||
collect({ current: peltsCarried, blocked: hideCapacityReached }),
|
||||
talk(),
|
||||
];
|
||||
}
|
||||
|
||||
describe('isObjectiveSatisfied', () => {
|
||||
it('needs the full required quantity for a collect step', () => {
|
||||
expect(isObjectiveSatisfied(collect({ current: 4 }))).toBe(false);
|
||||
expect(isObjectiveSatisfied(collect({ current: 5 }))).toBe(true);
|
||||
expect(isObjectiveSatisfied(collect({ current: 6 }))).toBe(true);
|
||||
});
|
||||
|
||||
it('gives up on a blocked collect step only when content allows it', () => {
|
||||
// The capacity lesson (slice §4): the first pelt hunt is meant to fail at
|
||||
// 1 / 5, the second one is not.
|
||||
expect(
|
||||
isObjectiveSatisfied(
|
||||
collect({ current: 1, blocked: true, advanceWhenBlocked: true }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isObjectiveSatisfied(
|
||||
collect({ current: 1, blocked: true, advanceWhenBlocked: false }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('never satisfies a talk step on its own', () => {
|
||||
// A talk step is performed, not observed -- nothing about the character's
|
||||
// inventory can complete it.
|
||||
expect(isObjectiveSatisfied(talk())).toBe(false);
|
||||
expect(isObjectiveSatisfied(talk({ current: 99, blocked: true }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCurrentObjectiveIndex', () => {
|
||||
it('stays on an unmet collect step', () => {
|
||||
expect(resolveCurrentObjectiveIndex([collect(), talk()], 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('walks past a collect step whose items are already owned', () => {
|
||||
// Spec §11: a player who owned pelts before accepting does not have to
|
||||
// throw them away and start again.
|
||||
expect(
|
||||
resolveCurrentObjectiveIndex([collect({ current: 5 }), talk()], 0),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('walks past a blocked collect step when content says to', () => {
|
||||
const objectives = [
|
||||
collect({ current: 1, blocked: true, advanceWhenBlocked: true }),
|
||||
talk(),
|
||||
];
|
||||
|
||||
expect(resolveCurrentObjectiveIndex(objectives, 0)).toBe(1);
|
||||
});
|
||||
|
||||
it('holds a blocked collect step when content does not', () => {
|
||||
const objectives = [
|
||||
collect({ current: 1, blocked: true, advanceWhenBlocked: false }),
|
||||
talk(),
|
||||
];
|
||||
|
||||
expect(resolveCurrentObjectiveIndex(objectives, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('never walks past a talk step', () => {
|
||||
expect(resolveCurrentObjectiveIndex([talk(), talk()], 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('never walks behind the stored floor', () => {
|
||||
// Talk steps are irreversible: once the warden has sent you to Borin, an
|
||||
// empty bag does not un-send you.
|
||||
expect(
|
||||
resolveCurrentObjectiveIndex([collect(), talk(), collect()], 2),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('walks several satisfied steps in one go', () => {
|
||||
const objectives = [
|
||||
collect({ current: 5 }),
|
||||
collect({ current: 5 }),
|
||||
talk(),
|
||||
];
|
||||
|
||||
expect(resolveCurrentObjectiveIndex(objectives, 0)).toBe(2);
|
||||
});
|
||||
|
||||
it('reports the end of the list when nothing is left', () => {
|
||||
expect(resolveCurrentObjectiveIndex([collect({ current: 5 })], 0)).toBe(1);
|
||||
});
|
||||
|
||||
it('clamps a stored index past the end of the list', () => {
|
||||
// Content shortened between deploys must read as "done", never crash.
|
||||
expect(resolveCurrentObjectiveIndex([collect(), talk()], 9)).toBe(2);
|
||||
});
|
||||
|
||||
it('clamps a negative stored index', () => {
|
||||
expect(resolveCurrentObjectiveIndex([collect(), talk()], -1)).toBe(0);
|
||||
});
|
||||
|
||||
it('treats a quest with no objectives as finished', () => {
|
||||
expect(resolveCurrentObjectiveIndex([], 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('walks the whole first-quest chain state by state', () => {
|
||||
// Fresh, no pelts, no bag: the hunt is the step.
|
||||
expect(resolveCurrentObjectiveIndex(troubleBeyondTheGate(0, false), 0)).toBe(
|
||||
0,
|
||||
);
|
||||
|
||||
// One pelt and the bagless HIDE limit reached (slice §4): the game sends
|
||||
// the player back to the warden instead of leaving them at 1 / 5.
|
||||
expect(resolveCurrentObjectiveIndex(troubleBeyondTheGate(1, true), 0)).toBe(
|
||||
1,
|
||||
);
|
||||
|
||||
// Warden talked to; the floor moved to 2 and Borin is the step.
|
||||
expect(resolveCurrentObjectiveIndex(troubleBeyondTheGate(1, false), 2)).toBe(
|
||||
2,
|
||||
);
|
||||
|
||||
// Bag in hand: back to the hunt, and the pelt already carried still counts
|
||||
// (slice §7) -- four more, not five.
|
||||
expect(resolveCurrentObjectiveIndex(troubleBeyondTheGate(1, false), 3)).toBe(
|
||||
3,
|
||||
);
|
||||
|
||||
// Five pelts: the turn-in opens.
|
||||
expect(resolveCurrentObjectiveIndex(troubleBeyondTheGate(5, false), 3)).toBe(
|
||||
4,
|
||||
);
|
||||
|
||||
// Turned in; the floor is past the last step.
|
||||
expect(resolveCurrentObjectiveIndex(troubleBeyondTheGate(0, false), 5)).toBe(
|
||||
5,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the hunt when the player sold the pelts again', () => {
|
||||
// Spec §11: trading quest goods away mid-quest must not softlock. Progress
|
||||
// is read from what is owned now, so the objective simply reopens.
|
||||
const objectives = troubleBeyondTheGate(5, false);
|
||||
expect(resolveCurrentObjectiveIndex(objectives, 3)).toBe(4);
|
||||
|
||||
expect(resolveCurrentObjectiveIndex(troubleBeyondTheGate(0, false), 3)).toBe(
|
||||
3,
|
||||
);
|
||||
});
|
||||
});
|
||||
72
apps/api/src/quests/quest-state.ts
Normal file
72
apps/api/src/quests/quest-state.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { QuestObjectiveType } from './quest.types';
|
||||
|
||||
/**
|
||||
* One objective reduced to what deciding "are we past this yet" needs.
|
||||
*
|
||||
* Deliberately not the entity: keeping this a plain value is what lets every
|
||||
* softlock case in Slice 0.9 §11 be a table-driven unit test with no database
|
||||
* anywhere near it.
|
||||
*/
|
||||
export interface ObjectiveSnapshot {
|
||||
type: QuestObjectiveType;
|
||||
requiredQuantity: number;
|
||||
advanceWhenBlocked: boolean;
|
||||
/** Owned quantity for a COLLECT_ITEM step; ignored for a TALK_TO_NPC step. */
|
||||
current: number;
|
||||
/** True when the character cannot carry more of this step's target. */
|
||||
blocked: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this step needs anything further from the player.
|
||||
*
|
||||
* A talk step is never satisfied by observation -- it is *performed*, and the
|
||||
* quest endpoint is the only thing that can move past it. A collect step is
|
||||
* satisfied by owning enough, or, where content asks for it, by having run into
|
||||
* the carrying limit: that is the whole capacity lesson of Slice 0.9 §4, and it
|
||||
* is what turns "Ashen Pelts 1 / 5, hide capacity reached" from a dead end into
|
||||
* the next objective.
|
||||
*/
|
||||
export function isObjectiveSatisfied(objective: ObjectiveSnapshot): boolean {
|
||||
if (objective.type !== QuestObjectiveType.COLLECT_ITEM) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (objective.current >= objective.requiredQuantity) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return objective.advanceWhenBlocked && objective.blocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* The step the player is actually on.
|
||||
*
|
||||
* Walks forward from the persisted floor past every collect step that is
|
||||
* already satisfied. Talk steps stop the walk, which is what makes the floor
|
||||
* meaningful: it only ever moves when one of them is performed, and those are
|
||||
* irreversible.
|
||||
*
|
||||
* Everything else is re-derived from what the character owns right now
|
||||
* (spec §11). That is not a shortcut -- it is what makes the §11 cases fall out
|
||||
* instead of being special-cased: pelts owned before accepting already count,
|
||||
* pelts sold mid-quest reopen the objective, and a bag obtained some other way
|
||||
* simply lets the first hunt finish on its own.
|
||||
*
|
||||
* Returns `objectives.length` when every step is behind the player.
|
||||
*/
|
||||
export function resolveCurrentObjectiveIndex(
|
||||
objectives: ObjectiveSnapshot[],
|
||||
storedIndex: number,
|
||||
): number {
|
||||
// A stored index outside the list means the content changed underneath a
|
||||
// character. Clamping beats throwing: a shortened quest reads as finished,
|
||||
// and a nonsensical negative reads as "start at the beginning".
|
||||
let index = Math.min(Math.max(storedIndex, 0), objectives.length);
|
||||
|
||||
while (index < objectives.length && isObjectiveSatisfied(objectives[index])) {
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
Reference in New Issue
Block a user