feat: add protected manual save records

This commit is contained in:
2026-07-29 08:49:29 +09:00
parent d04accc9e1
commit 624eb2c219
11 changed files with 4458 additions and 201 deletions

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