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