feat(quests): derive the active objective from owned quantity

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-22 22:40:47 +02:00
parent 242c0935fa
commit 3a67dca86c
2 changed files with 266 additions and 0 deletions

View 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,
);
});
});

View 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;
}