diff --git a/apps/api/src/quests/quest-state.spec.ts b/apps/api/src/quests/quest-state.spec.ts new file mode 100644 index 0000000..de8dabc --- /dev/null +++ b/apps/api/src/quests/quest-state.spec.ts @@ -0,0 +1,194 @@ +import { QuestObjectiveType } from './quest.types'; +import { + isObjectiveSatisfied, + ObjectiveSnapshot, + resolveCurrentObjectiveIndex, +} from './quest-state'; + +function collect(over: Partial = {}): ObjectiveSnapshot { + return { + type: QuestObjectiveType.COLLECT_ITEM, + requiredQuantity: 5, + advanceWhenBlocked: false, + current: 0, + blocked: false, + ...over, + }; +} + +function talk(over: Partial = {}): 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, + ); + }); +}); diff --git a/apps/api/src/quests/quest-state.ts b/apps/api/src/quests/quest-state.ts new file mode 100644 index 0000000..1626099 --- /dev/null +++ b/apps/api/src/quests/quest-state.ts @@ -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; +}