feat: add production campaigns, gameplay labs, and review documentation
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readTutorialProgress, tutorialLaunch, tutorialProgressKey, writeTutorialProgress } from '../src/tutorial-progress.js';
|
||||
|
||||
function storage(entries = {}) {
|
||||
const items = new Map(Object.entries(entries));
|
||||
const writes = [];
|
||||
return {
|
||||
items,
|
||||
writes,
|
||||
getItem: key => items.get(key) ?? null,
|
||||
setItem(key, value) { writes.push(key); items.set(key, value); },
|
||||
};
|
||||
}
|
||||
|
||||
const campaignId = 'v0.5';
|
||||
const fresh = {current: 0, completed: []};
|
||||
const key = tutorialProgressKey(campaignId);
|
||||
|
||||
test('new visitors start with tutorial 1 without writing any progress', () => {
|
||||
const saved = storage();
|
||||
const progress = readTutorialProgress(saved, campaignId, 3);
|
||||
assert.deepEqual(progress, fresh);
|
||||
assert.equal(tutorialLaunch('', 3, progress, fresh), 0);
|
||||
assert.equal(tutorialLaunch('?campaign=v0.5', 3, progress, fresh), 0);
|
||||
assert.deepEqual(saved.writes, []);
|
||||
assert.equal(key, 'hex-trails-tutorial-v1-v0.5');
|
||||
});
|
||||
|
||||
test('tutorial saves retain valid indices and discard malformed or duplicate entries', () => {
|
||||
const saved = storage({[key]: JSON.stringify({current: 2, completed: [-1, 0, 0, 2, 3, 1.5, '1', null]})});
|
||||
assert.deepEqual(readTutorialProgress(saved, campaignId, 3), {current: 2, completed: [0, 2]});
|
||||
for (const current of [-1, 3, 1.5, '2', null]) {
|
||||
saved.items.set(key, JSON.stringify({current, completed: {0: 1}}));
|
||||
assert.deepEqual(readTutorialProgress(saved, campaignId, 3), fresh);
|
||||
}
|
||||
assert.deepEqual(saved.writes, []);
|
||||
});
|
||||
|
||||
test('bad JSON and non-object saves are harmless and never rewritten during reading', () => {
|
||||
for (const raw of ['{', 'null', '[]', 'true', '"text"', '42']) {
|
||||
const saved = storage({[key]: raw});
|
||||
assert.deepEqual(readTutorialProgress(saved, campaignId, 3), fresh);
|
||||
assert.equal(saved.getItem(key), raw);
|
||||
assert.deepEqual(saved.writes, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('tutorial writes stay separate from campaign progress, historical saves and version selection', () => {
|
||||
const original = {
|
||||
'hex-trails-campaign-v0.5': JSON.stringify({current: 999, completed: [0, 998, 999]}),
|
||||
'hex-trails-observe-v3-r2': JSON.stringify({current: 6, completed: [6]}),
|
||||
'hex-trails-campaign-version': 'v0.4',
|
||||
};
|
||||
const saved = storage(original);
|
||||
assert.equal(writeTutorialProgress(saved, campaignId, 3, {current: 2, completed: [0, 0, 1, 8]}), true);
|
||||
assert.deepEqual(readTutorialProgress(saved, campaignId, 3), {current: 2, completed: [0, 1]});
|
||||
assert.deepEqual(saved.writes, [key]);
|
||||
for (const [name, value] of Object.entries(original)) assert.equal(saved.getItem(name), value);
|
||||
assert.deepEqual(readTutorialProgress(saved, 'v0.4', 3), fresh);
|
||||
});
|
||||
|
||||
test('unavailable storage never blocks loading or playing tutorials', () => {
|
||||
const denied = {getItem() { throw new Error('denied'); }, setItem() { throw new Error('denied'); }};
|
||||
for (const saved of [undefined, null, {}, denied]) {
|
||||
assert.deepEqual(readTutorialProgress(saved, campaignId, 3), fresh);
|
||||
assert.equal(writeTutorialProgress(saved, campaignId, 3, {current: 1, completed: [0]}), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit tutorial links can replay completed lessons and take priority over level links', () => {
|
||||
const done = {current: 2, completed: [0, 1, 2]};
|
||||
const experienced = {current: 999, completed: [0, 999]};
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
assert.equal(tutorialLaunch(`?tutorial=${index + 1}&level=1000`, 3, done, experienced), index);
|
||||
}
|
||||
assert.equal(tutorialLaunch(new URLSearchParams('campaign=v0.5&tutorial=2'), 3, fresh, fresh), 1);
|
||||
});
|
||||
|
||||
test('every explicit campaign level parameter bypasses automatic tutorials', () => {
|
||||
for (const search of ['?level=1', '?level=1000', '?level=', '?level=bad', '?tutorial=9&level=1']) {
|
||||
assert.equal(tutorialLaunch(search, 3, fresh, fresh), null, search);
|
||||
}
|
||||
});
|
||||
|
||||
test('existing campaign players skip automatic tutorials but may request them explicitly', () => {
|
||||
for (const campaignProgress of [{current: 1, completed: []}, {current: 0, completed: [0]}, {current: 999, completed: [999]}]) {
|
||||
assert.equal(tutorialLaunch('', 3, fresh, campaignProgress), null);
|
||||
assert.equal(tutorialLaunch('?tutorial=1', 3, fresh, campaignProgress), 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('unfinished tutorials resume at the saved lesson or the next incomplete lesson', () => {
|
||||
assert.equal(tutorialLaunch('', 3, {current: 1, completed: [0]}, fresh), 1);
|
||||
assert.equal(tutorialLaunch('', 3, {current: 2, completed: [0]}, fresh), 2);
|
||||
assert.equal(tutorialLaunch('', 3, {current: 0, completed: [0]}, fresh), 1);
|
||||
assert.equal(tutorialLaunch('', 3, {current: 2, completed: [0, 2]}, fresh), 1);
|
||||
assert.equal(tutorialLaunch('', 3, {current: 99, completed: [0, 0, -1, 4]}, fresh), 1);
|
||||
assert.equal(tutorialLaunch('', 3, {current: 2, completed: [0, 1, 2]}, fresh), null);
|
||||
});
|
||||
|
||||
test('invalid tutorial parameters follow the normal launch rules', () => {
|
||||
for (const value of ['', '0', '4', '-1', '1.5', '1e0', 'Infinity', 'abc']) {
|
||||
assert.equal(tutorialLaunch(`?tutorial=${value}`, 3, {current: 1, completed: [0]}, fresh), 1, value);
|
||||
assert.equal(tutorialLaunch(`?tutorial=${value}`, 3, fresh, {current: 2, completed: []}), null, value);
|
||||
}
|
||||
});
|
||||
|
||||
test('campaigns without tutorials do not read, write or launch tutorial state', () => {
|
||||
const unreachable = {
|
||||
getItem() { assert.fail('should not read a tutorial slot'); },
|
||||
setItem() { assert.fail('should not write a tutorial slot'); },
|
||||
};
|
||||
for (const count of [0, -1, 1.5, undefined, NaN]) {
|
||||
assert.deepEqual(readTutorialProgress(unreachable, campaignId, count), fresh);
|
||||
assert.equal(writeTutorialProgress(unreachable, campaignId, count, fresh), false);
|
||||
assert.equal(tutorialLaunch('?tutorial=1', count, fresh, fresh), null);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user