Skip to content

Instantly share code, notes, and snippets.

@viridia
Created January 31, 2023 19:17
Show Gist options
  • Save viridia/b0b64123746c95e252baeef11db801c5 to your computer and use it in GitHub Desktop.
Save viridia/b0b64123746c95e252baeef11db801c5 to your computer and use it in GitHub Desktop.
Example of Structural Sharing for saved games.
export class SavedGameStore<T extends object> {
private prev: { [key: string]: string } = {};
private next: { [key: string]: string } = {};
constructor(private options: ISaveGameOptions = {}) {}
/** Return a list of saved games. */
public list(): ISavedGameRoot[] {
/** Get all the local storage keys that begin with the word 'save'. */
return Object.keys(localStorage)
.filter(key => key.startsWith(SAVE_GAME_PREFIX))
.map(key => {
const sg = localStorage.getItem(key);
if (sg) {
try {
const json = JSON.parse(sg) as ISavedGameRootSer;
return { ...json, key, time: new Date(json.time) };
} catch (e) {
return undefined;
}
}
return undefined;
})
.filter(isDefined)
.sort(elapsedOrder);
}
public get<K extends keyof T>(key: K): T[K] | null {
return this.getChunk(key as string) as T[K];
}
public set<K extends keyof T>(key: K, data: T[K]) {
const chunkId = key as string;
const prevData = this.getChunk(chunkId);
if (!equals(prevData, data)) {
const json = JSON.stringify(data);
for (let seed = 0; ; seed++) {
// Version number is hash of chunk content
const chunkKey = `${CHUNK_PREFIX}${chunkId}:${quickHash(json, seed)}`;
if (chunkKey in localStorage) {
// Collision, try again.
continue;
}
this.next[chunkId] = chunkKey;
localStorage.setItem(chunkKey, JSON.stringify(data));
return;
}
} else {
delete this.next[chunkId];
}
}
/** Load a saved game. This only loads the root index, which updates the chunk id maps. */
public load(saveId: string): ISavedGameRoot | null {
invariant(saveId.startsWith(SAVE_GAME_PREFIX));
const sg = localStorage.getItem(saveId);
if (sg) {
try {
const json = JSON.parse(sg) as ISavedGameRootSer;
this.prev = json.chunks;
this.next = {};
return { ...json, key: saveId, time: new Date(json.time) };
} catch (e) {
console.error('Invalid save game format:', saveId);
}
}
return null;
}
/** Save a saved game. This stores the root index.
@param saveName Display name of this save ('Autosave', etc.).
@param elapsed Elapsed play time, in seconds.
@param character Name of character.
@param type Save type - quick save, autosave, etc.
*/
public save(saveName: string, elapsed: number, character: string, type: SaveType): string {
const sg: ISavedGameRootSer = {
name: saveName,
type,
elapsed,
character,
time: new Date().toUTCString(),
chunks: { ...this.prev, ...this.next },
};
let nextIndex = 0;
for (const key of Object.keys(localStorage)) {
const m = key.match(/save_(\d+)/);
if (m) {
nextIndex = Math.max(nextIndex, Number(m[1]) + 1);
}
}
const rootKey = `${SAVE_GAME_PREFIX}${nextIndex}`;
localStorage.setItem(rootKey, JSON.stringify(sg));
// localStorage.setItem(LAST_SAVE_GAME_KEY, rootKey);
// This save becomes the basis for the next save.
this.prev = sg.chunks;
this.next = {};
return rootKey;
}
/** Delete a saved game by id. */
public deleteSave(saveId: string) {
localStorage.removeItem(saveId);
this.prune();
}
/** Remove all save chunks that are no longer referenced. */
public prune(): void {
const { maxAutoSaves = MAX_AUTOSAVES, maxQuickSaves = MAX_QUICKSAVES } = this.options;
const list = this.list();
// Remove the older autosaves and quicksaves.
let numAutosaves = 0;
let numQuicksaves = 0;
for (let i = 0; i < list.length; ) {
const sg = list[i];
if (sg.type === 'auto') {
numAutosaves++;
if (numAutosaves > maxAutoSaves) {
localStorage.removeItem(sg.key);
list.splice(i, 1);
} else {
i++;
}
} else if (sg.type === 'quick') {
numQuicksaves++;
if (numQuicksaves > maxQuickSaves) {
localStorage.removeItem(sg.key);
list.splice(i, 1);
} else {
i++;
}
} else {
i++;
}
}
// Look for chunks that are no longer referenced.
const unusedChunks = new Set(
Object.keys(localStorage).filter(key => key.startsWith(CHUNK_PREFIX))
);
for (const saveGame of list) {
Object.values(saveGame.chunks).forEach(chunkId => unusedChunks.delete(chunkId));
}
// Remove chunks from local storage.
for (const chunkId of unusedChunks) {
localStorage.removeItem(chunkId);
}
}
private getChunk(key: string): T | null {
const chunkId = this.next[key] ?? this.prev[key];
if (chunkId) {
const sg = localStorage.getItem(chunkId);
if (sg) {
try {
return JSON.parse(sg) as T;
} catch (e) {
console.error('Invalid chunk format:', chunkId);
}
}
}
return null;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment