443 lines
11 KiB
JavaScript
443 lines
11 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { createServer } from 'vite';
|
|
|
|
const storage = createStorage();
|
|
globalThis.window = { localStorage: storage };
|
|
|
|
const server = await createServer({
|
|
logLevel: 'error',
|
|
server: {
|
|
middlewareMode: true,
|
|
hmr: false
|
|
},
|
|
appType: 'custom'
|
|
});
|
|
|
|
try {
|
|
const {
|
|
campaignSaveStorageKey,
|
|
campaignStorageKey,
|
|
createInitialCampaignState,
|
|
setCampaignState
|
|
} = await server.ssrLoadModule(
|
|
'/src/game/state/campaignState.ts'
|
|
);
|
|
const {
|
|
manualSaveSlotCount,
|
|
manualSaveStorageKeyForSlot,
|
|
readManualSave,
|
|
listManualSaveSlots,
|
|
writeManualSave,
|
|
restoreManualSave,
|
|
ManualSaveStorageError
|
|
} = await server.ssrLoadModule(
|
|
'/src/game/state/manualSaveStorage.ts'
|
|
);
|
|
const {
|
|
battleSaveStorageBaseKey,
|
|
battleSaveStorageSlotKey,
|
|
legacyFirstBattleSaveStorageKey
|
|
} = await server.ssrLoadModule(
|
|
'/src/game/state/battleSaveStorage.ts'
|
|
);
|
|
|
|
assert.equal(manualSaveSlotCount, 3);
|
|
assert.throws(
|
|
() => manualSaveStorageKeyForSlot(0),
|
|
(error) =>
|
|
error instanceof ManualSaveStorageError &&
|
|
error.code === 'invalid-slot'
|
|
);
|
|
|
|
const firstBattleId =
|
|
'first-battle-zhuo-commandery';
|
|
const secondBattleId =
|
|
'second-battle-yellow-turban-pursuit';
|
|
const sourceCampaign =
|
|
createInitialCampaignState();
|
|
sourceCampaign.step = 'first-battle';
|
|
sourceCampaign.activeSaveSlot = 3;
|
|
sourceCampaign.gold = 321;
|
|
sourceCampaign.inventory = {
|
|
bean: 2,
|
|
salve: 1
|
|
};
|
|
sourceCampaign.battleResumeGeneration = 7;
|
|
sourceCampaign.updatedAt =
|
|
'2026-07-29T01:00:00.000Z';
|
|
setCampaignState(sourceCampaign);
|
|
|
|
const battle = createBattleSave({
|
|
battleId: firstBattleId,
|
|
campaignStep: 'first-battle',
|
|
battleResumeGeneration: 7,
|
|
turnNumber: 4,
|
|
activeFaction: 'enemy'
|
|
});
|
|
const autosaveBeforeManualWrite =
|
|
storage.snapshot();
|
|
const written = writeManualSave(2, {
|
|
campaign: sourceCampaign,
|
|
battle,
|
|
context: 'battle'
|
|
});
|
|
assert.equal(written.slot, 2);
|
|
assert.equal(written.context, 'battle');
|
|
assert.equal(
|
|
written.campaign.activeSaveSlot,
|
|
3,
|
|
'Writing a protected manual record must not rewrite its source campaign lane.'
|
|
);
|
|
assertStorageEntriesEqual(
|
|
storage.snapshot(),
|
|
autosaveBeforeManualWrite,
|
|
(key) =>
|
|
key !== manualSaveStorageKeyForSlot(2),
|
|
'Writing a manual save changed an autosave key.'
|
|
);
|
|
|
|
const summaries = listManualSaveSlots();
|
|
const slotTwoSummary = summaries.find(
|
|
(summary) => summary.slot === 2
|
|
);
|
|
assert.deepEqual(
|
|
{
|
|
exists: slotTwoSummary?.exists,
|
|
status: slotTwoSummary?.status,
|
|
context: slotTwoSummary?.context,
|
|
gold: slotTwoSummary?.gold,
|
|
inventoryCount:
|
|
slotTwoSummary?.inventoryCount,
|
|
battleId: slotTwoSummary?.battleId,
|
|
turnNumber: slotTwoSummary?.turnNumber,
|
|
activeFaction:
|
|
slotTwoSummary?.activeFaction
|
|
},
|
|
{
|
|
exists: true,
|
|
status: 'valid',
|
|
context: 'battle',
|
|
gold: 321,
|
|
inventoryCount: 3,
|
|
battleId: firstBattleId,
|
|
turnNumber: 4,
|
|
activeFaction: 'enemy'
|
|
}
|
|
);
|
|
assert.equal(
|
|
summaries.find((summary) => summary.slot === 1)
|
|
?.status,
|
|
'empty'
|
|
);
|
|
|
|
const protectedSlotTwoRaw = storage.getItem(
|
|
manualSaveStorageKeyForSlot(2)
|
|
);
|
|
const replacementCampaign =
|
|
createInitialCampaignState();
|
|
replacementCampaign.step = 'second-battle';
|
|
replacementCampaign.activeSaveSlot = 2;
|
|
replacementCampaign.gold = 9;
|
|
replacementCampaign.battleResumeGeneration = 12;
|
|
setCampaignState(replacementCampaign);
|
|
const firstSlotTwoKey = battleSaveStorageSlotKey(
|
|
firstBattleId,
|
|
2
|
|
);
|
|
const secondSlotTwoKey = battleSaveStorageSlotKey(
|
|
secondBattleId,
|
|
2
|
|
);
|
|
const unrelatedSlotThreeKey =
|
|
battleSaveStorageSlotKey(
|
|
secondBattleId,
|
|
3
|
|
);
|
|
storage.setItem(
|
|
firstSlotTwoKey,
|
|
'old-first-battle-autosave'
|
|
);
|
|
storage.setItem(
|
|
secondSlotTwoKey,
|
|
'old-second-battle-autosave'
|
|
);
|
|
storage.setItem(
|
|
unrelatedSlotThreeKey,
|
|
'unrelated-slot-three'
|
|
);
|
|
|
|
const restored = restoreManualSave(2);
|
|
assert.equal(restored.activeSaveSlot, 2);
|
|
assert.equal(restored.step, 'first-battle');
|
|
assert.equal(restored.gold, 321);
|
|
assert.equal(
|
|
JSON.parse(
|
|
storage.getItem(campaignStorageKey)
|
|
).activeSaveSlot,
|
|
2
|
|
);
|
|
assert.equal(
|
|
JSON.parse(
|
|
storage.getItem(campaignSaveStorageKey(2))
|
|
).gold,
|
|
321
|
|
);
|
|
assert.equal(
|
|
JSON.parse(storage.getItem(firstSlotTwoKey))
|
|
.turnNumber,
|
|
4
|
|
);
|
|
assert.equal(
|
|
storage.getItem(secondSlotTwoKey),
|
|
null,
|
|
'Restore retained a stale battle autosave in the destination lane.'
|
|
);
|
|
assert.equal(
|
|
storage.getItem(unrelatedSlotThreeKey),
|
|
'unrelated-slot-three',
|
|
'Restore changed a battle autosave in another lane.'
|
|
);
|
|
assert.equal(
|
|
storage.getItem(manualSaveStorageKeyForSlot(2)),
|
|
protectedSlotTwoRaw,
|
|
'Restore changed the protected manual record bytes.'
|
|
);
|
|
assert.equal(
|
|
readManualSave(2)?.campaign.activeSaveSlot,
|
|
3,
|
|
'Restore mutated the campaign stored inside the protected manual envelope.'
|
|
);
|
|
|
|
const campCampaign = createInitialCampaignState();
|
|
campCampaign.step = 'first-camp';
|
|
campCampaign.activeSaveSlot = 1;
|
|
campCampaign.gold = 144;
|
|
writeManualSave(1, {
|
|
campaign: campCampaign,
|
|
context: 'camp'
|
|
});
|
|
assert.equal(readManualSave(1)?.battle, undefined);
|
|
assert.throws(
|
|
() =>
|
|
writeManualSave(3, {
|
|
campaign: campCampaign,
|
|
battle,
|
|
context: 'camp'
|
|
}),
|
|
(error) =>
|
|
error instanceof ManualSaveStorageError &&
|
|
error.code === 'invalid-save'
|
|
);
|
|
|
|
const slotOneCampaignRaw =
|
|
'{"auto":"slot-one-before"}';
|
|
const currentCampaignRaw =
|
|
'{"auto":"current-before"}';
|
|
const slotOneBattleKey =
|
|
battleSaveStorageSlotKey(firstBattleId, 1);
|
|
const slotOneBattleBaseKey =
|
|
battleSaveStorageBaseKey(firstBattleId);
|
|
const slotOneSecondBattleKey =
|
|
battleSaveStorageSlotKey(secondBattleId, 1);
|
|
const slotOneSecondBattleBaseKey =
|
|
battleSaveStorageBaseKey(secondBattleId);
|
|
storage.setItem(
|
|
campaignStorageKey,
|
|
currentCampaignRaw
|
|
);
|
|
storage.setItem(
|
|
campaignSaveStorageKey(1),
|
|
slotOneCampaignRaw
|
|
);
|
|
storage.setItem(
|
|
slotOneBattleKey,
|
|
'slot-one-first-before'
|
|
);
|
|
storage.setItem(
|
|
slotOneBattleBaseKey,
|
|
'base-first-before'
|
|
);
|
|
storage.setItem(
|
|
legacyFirstBattleSaveStorageKey,
|
|
'legacy-first-before'
|
|
);
|
|
storage.setItem(
|
|
slotOneSecondBattleKey,
|
|
'slot-one-second-before'
|
|
);
|
|
storage.setItem(
|
|
slotOneSecondBattleBaseKey,
|
|
'base-second-before'
|
|
);
|
|
const beforeFailedRestore = storage.snapshot();
|
|
storage.failNextSet(campaignSaveStorageKey(1));
|
|
|
|
assert.throws(
|
|
() => restoreManualSave(1),
|
|
(error) =>
|
|
error instanceof ManualSaveStorageError &&
|
|
error.code === 'restore-failed' &&
|
|
error.rollbackSucceeded === true
|
|
);
|
|
assert.deepEqual(
|
|
storage.snapshot(),
|
|
beforeFailedRestore,
|
|
'A failed restore did not roll back every campaign, battle, compatibility, and manual byte.'
|
|
);
|
|
|
|
const protectedSlotOneRaw = storage.getItem(
|
|
manualSaveStorageKeyForSlot(1)
|
|
);
|
|
storage.failNextSet(
|
|
manualSaveStorageKeyForSlot(1)
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
writeManualSave(1, {
|
|
campaign: campCampaign,
|
|
context: 'camp'
|
|
}),
|
|
(error) =>
|
|
error instanceof ManualSaveStorageError &&
|
|
error.code === 'write-failed' &&
|
|
error.rollbackSucceeded === true
|
|
);
|
|
assert.equal(
|
|
storage.getItem(manualSaveStorageKeyForSlot(1)),
|
|
protectedSlotOneRaw,
|
|
'A failed overwrite changed the existing protected manual record.'
|
|
);
|
|
|
|
storage.setItem(
|
|
manualSaveStorageKeyForSlot(3),
|
|
JSON.stringify({
|
|
version: 99,
|
|
slot: 3,
|
|
savedAt: '2026-07-29T02:00:00.000Z',
|
|
context: 'camp',
|
|
campaign: campCampaign
|
|
})
|
|
);
|
|
assert.equal(readManualSave(3), undefined);
|
|
assert.deepEqual(
|
|
listManualSaveSlots().find(
|
|
(summary) => summary.slot === 3
|
|
),
|
|
{
|
|
slot: 3,
|
|
exists: true,
|
|
status: 'invalid'
|
|
},
|
|
'Unsupported manual-save bytes must remain visibly occupied rather than masquerading as an empty slot.'
|
|
);
|
|
|
|
console.log(
|
|
'Verified protected manual-save envelopes, summaries, context/version/slot validation, autosave-write isolation, exact campaign+battle restore, stale battle cleanup, manual-byte invariance, and full rollback after write failure.'
|
|
);
|
|
} finally {
|
|
await server.close();
|
|
delete globalThis.window;
|
|
}
|
|
|
|
function createBattleSave(overrides = {}) {
|
|
return {
|
|
version: 1,
|
|
battleId: 'first-battle-zhuo-commandery',
|
|
campaignStep: 'first-battle',
|
|
battleResumeGeneration: 0,
|
|
savedAt: '2026-07-29T01:05:00.000Z',
|
|
turnNumber: 1,
|
|
activeFaction: 'ally',
|
|
rosterTab: 'ally',
|
|
actedUnitIds: [],
|
|
attackIntents: [],
|
|
battleLog: [],
|
|
units: [],
|
|
bonds: [],
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function assertStorageEntriesEqual(
|
|
current,
|
|
expected,
|
|
include,
|
|
message
|
|
) {
|
|
const currentFiltered = Object.fromEntries(
|
|
Object.entries(current).filter(([key]) =>
|
|
include(key)
|
|
)
|
|
);
|
|
const expectedFiltered = Object.fromEntries(
|
|
Object.entries(expected).filter(([key]) =>
|
|
include(key)
|
|
)
|
|
);
|
|
assert.deepEqual(
|
|
currentFiltered,
|
|
expectedFiltered,
|
|
message
|
|
);
|
|
}
|
|
|
|
function createStorage() {
|
|
const values = new Map();
|
|
const failingSetCounts = new Map();
|
|
return {
|
|
get length() {
|
|
return values.size;
|
|
},
|
|
key(index) {
|
|
return [...values.keys()][index] ?? null;
|
|
},
|
|
getItem(key) {
|
|
return values.has(String(key))
|
|
? values.get(String(key))
|
|
: null;
|
|
},
|
|
setItem(key, value) {
|
|
const normalizedKey = String(key);
|
|
const remaining =
|
|
failingSetCounts.get(normalizedKey) ?? 0;
|
|
if (remaining > 0) {
|
|
if (remaining === 1) {
|
|
failingSetCounts.delete(normalizedKey);
|
|
} else {
|
|
failingSetCounts.set(
|
|
normalizedKey,
|
|
remaining - 1
|
|
);
|
|
}
|
|
throw new DOMException(
|
|
'Injected manual-save write refusal',
|
|
'QuotaExceededError'
|
|
);
|
|
}
|
|
values.set(normalizedKey, String(value));
|
|
},
|
|
removeItem(key) {
|
|
values.delete(String(key));
|
|
},
|
|
clear() {
|
|
values.clear();
|
|
failingSetCounts.clear();
|
|
},
|
|
failNextSet(key) {
|
|
const normalizedKey = String(key);
|
|
failingSetCounts.set(
|
|
normalizedKey,
|
|
(failingSetCounts.get(normalizedKey) ??
|
|
0) + 1
|
|
);
|
|
},
|
|
snapshot() {
|
|
return Object.fromEntries(
|
|
[...values.entries()].sort(([left], [right]) =>
|
|
left.localeCompare(right)
|
|
)
|
|
);
|
|
}
|
|
};
|
|
}
|