feat: add protected manual save records
This commit is contained in:
442
scripts/verify-manual-save-storage.mjs
Normal file
442
scripts/verify-manual-save-storage.mjs
Normal file
@@ -0,0 +1,442 @@
|
||||
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)
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user