Save system: ceremonial PR #42
| @@ -206,7 +206,11 @@ export class PlayerProgress { | |||||||
|     return skillsAvailable.slice(0, 6); |     return skillsAvailable.slice(0, 6); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   getLearnedSkills() { |   getUntrimmedAvailableSkillIds(): number[] { | ||||||
|  |     return this.#untrimmedSkillsAvailable.map((s) => s.id); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   getLearnedSkills() : Skill[] { | ||||||
|     let learnedSkills = []; |     let learnedSkills = []; | ||||||
|     for (let s of this.#skillsLearned.values()) { |     for (let s of this.#skillsLearned.values()) { | ||||||
|       learnedSkills.push({ id: s }); |       learnedSkills.push({ id: s }); | ||||||
| @@ -214,6 +218,10 @@ export class PlayerProgress { | |||||||
|     return learnedSkills; |     return learnedSkills; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   getRawLearnedSkills() : number[] { | ||||||
|  |     return [...this.#skillsLearned]; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   getStats() { |   getStats() { | ||||||
|     return { ...this.#stats }; |     return { ...this.#stats }; | ||||||
|   } |   } | ||||||
| @@ -233,6 +241,10 @@ export class PlayerProgress { | |||||||
|     return this.#thrallsUnlocked.indexOf(thrall.id) != -1; |     return this.#thrallsUnlocked.indexOf(thrall.id) != -1; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   getUnlockedThrallIds() : number[] { | ||||||
|  |     return [...this.#thrallsUnlocked]; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   damageThrall(thrall: Thrall, amount: number) { |   damageThrall(thrall: Thrall, amount: number) { | ||||||
|     if (amount <= 0.0) { |     if (amount <= 0.0) { | ||||||
|       throw new Error(`damage must be some positive amount, not ${amount}`); |       throw new Error(`damage must be some positive amount, not ${amount}`); | ||||||
| @@ -246,6 +258,10 @@ export class PlayerProgress { | |||||||
|       (this.#thrallDamage[thrall.id] ?? 0.0) + amount; |       (this.#thrallDamage[thrall.id] ?? 0.0) + amount; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   getThrallDamage(thrall: Thrall): number { | ||||||
|  |     return this.#thrallDamage[thrall.id] ?? 0.0; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   getThrallLifeStage(thrall: Thrall): LifeStage { |   getThrallLifeStage(thrall: Thrall): LifeStage { | ||||||
|     let damage = this.#thrallDamage[thrall.id] ?? 0; |     let damage = this.#thrallDamage[thrall.id] ?? 0; | ||||||
|     if (damage < 0.5) { |     if (damage < 0.5) { | ||||||
| @@ -270,6 +286,10 @@ export class PlayerProgress { | |||||||
|     this.#thrallsObtainedItem.push(thrall.id); |     this.#thrallsObtainedItem.push(thrall.id); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   getThrallObtainedItemIds() : number[] { | ||||||
|  |     return [...this.#thrallsObtainedItem]; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   deliverThrallItem(thrall: Thrall) { |   deliverThrallItem(thrall: Thrall) { | ||||||
|     if (this.#thrallsDeliveredItem.indexOf(thrall.id) != -1) { |     if (this.#thrallsDeliveredItem.indexOf(thrall.id) != -1) { | ||||||
|       return; |       return; | ||||||
| @@ -277,6 +297,10 @@ export class PlayerProgress { | |||||||
|     this.#thrallsDeliveredItem.push(thrall.id); |     this.#thrallsDeliveredItem.push(thrall.id); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   getThrallDeliveredItemIds() : number[] { | ||||||
|  |     return [...this.#thrallsDeliveredItem]; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   getThrallItemStage(thrall: Thrall): ItemStage { |   getThrallItemStage(thrall: Thrall): ItemStage { | ||||||
|     if (this.#thrallsDeliveredItem.indexOf(thrall.id) != -1) { |     if (this.#thrallsDeliveredItem.indexOf(thrall.id) != -1) { | ||||||
|       return ItemStage.Delivered; |       return ItemStage.Delivered; | ||||||
|   | |||||||
							
								
								
									
										321
									
								
								src/save.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										321
									
								
								src/save.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,321 @@ | |||||||
|  | import { getPlayerProgress } from "./playerprogress" | ||||||
|  | import { getStateManager } from "./statemanager"; | ||||||
|  | import { getThralls } from "./thralls"; | ||||||
|  |  | ||||||
|  | export interface SaveFile { | ||||||
|  |     version: string; | ||||||
|  |     revision: number; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface StatCounterV1{ | ||||||
|  |     agi: number; | ||||||
|  |     int: number; | ||||||
|  |     cha: number; | ||||||
|  |     psi: number; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface SaveFileV1 { | ||||||
|  |     version: "fledgling_save_v1"; | ||||||
|  |     revision: number; | ||||||
|  |  | ||||||
|  |     turn: number; | ||||||
|  |  | ||||||
|  |     name: string; | ||||||
|  |     thrallTemplateId: number; | ||||||
|  |     nImprovements: number; | ||||||
|  |     stats: StatCounterV1; | ||||||
|  |     talents: StatCounterV1; | ||||||
|  |     isInPenance: boolean; | ||||||
|  |     wishId: number;  // negative: Wish is absent | ||||||
|  |     exp: number; | ||||||
|  |     blood: number; | ||||||
|  |     itemsPurloined: number; | ||||||
|  |     skillsLearned: number[]; | ||||||
|  |     untrimmedSkillsAvailableIds: number[]; | ||||||
|  |     thrallsUnlocked: number[]; | ||||||
|  |     thrallDamage: number[];  // 0: thrall is absent or undamaged | ||||||
|  |     thrallsObtainedItem: number[]; | ||||||
|  |     thrallsDeliveredItem: number[]; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export type SaveSlot = "FLEDGLING_SLOT_1" | "FLEDGLING_SLOT_2"; | ||||||
|  |  | ||||||
|  | /// Checks whether obj is a valid save file, as far as we can tell, and returns | ||||||
|  | /// it unchanged if it is, or throws an error if it's not valid. | ||||||
|  | export function mustBeSaveFileV1(obj: unknown): SaveFileV1 { | ||||||
|  |     if (obj === undefined || obj === null) { | ||||||
|  |         throw new Error("nonexistent"); | ||||||
|  |     } | ||||||
|  |     if (typeof(obj) !== "object") { | ||||||
|  |         throw new Error(`not an object; was ${typeof(obj)}`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (!("version" in obj)) { | ||||||
|  |         throw new Error("no magic number"); | ||||||
|  |     } | ||||||
|  |     if (obj.version !== "fledgling_save_v1") { | ||||||
|  |         throw new Error(`bad magic number: ${obj.version}`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return { | ||||||
|  |         version: "fledgling_save_v1", | ||||||
|  |         revision: mustGetNumber(obj, "revision"), | ||||||
|  |         turn: mustGetNumber(obj, "turn"), | ||||||
|  |         name: mustGetString(obj, "name"), | ||||||
|  |         thrallTemplateId: mustGetNumber(obj, "thrallTemplateId"), | ||||||
|  |         nImprovements: mustGetNumber(obj, "nImprovements"), | ||||||
|  |         stats: mustGetStatCounterV1(obj, "stats"), | ||||||
|  |         talents: mustGetStatCounterV1(obj, "talents"), | ||||||
|  |         isInPenance: mustGetBoolean(obj, "isInPenance"), | ||||||
|  |         wishId: mustGetNumber(obj, "wishId"), | ||||||
|  |         exp: mustGetNumber(obj, "exp"), | ||||||
|  |         blood: mustGetNumber(obj, "blood"), | ||||||
|  |         itemsPurloined: mustGetNumber(obj, "itemsPurloined"), | ||||||
|  |         skillsLearned: mustGetNumberArray(obj, "skillsLearned"), | ||||||
|  |         untrimmedSkillsAvailableIds: mustGetNumberArray(obj, "untrimmedSkillsAvailableIds"), | ||||||
|  |         thrallsUnlocked: mustGetNumberArray(obj, "thrallsUnlocked"), | ||||||
|  |         thrallDamage: mustGetNumberArray(obj, "thrallDamage"), | ||||||
|  |         thrallsObtainedItem: mustGetNumberArray(obj, "thrallsObtainedItem"), | ||||||
|  |         thrallsDeliveredItem: mustGetNumberArray(obj, "thrallsDeliveredItem"), | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function mustGetNumber(obj: object, key: string) : number { | ||||||
|  |     if (obj === null || obj === undefined) { | ||||||
|  |         throw new Error("container absent"); | ||||||
|  |     } | ||||||
|  |     if (typeof(obj) !== "object") { | ||||||
|  |         throw new Error(`container was not an object; was ${typeof(obj)}`); | ||||||
|  |     } | ||||||
|  |     if (!(key in obj)) { | ||||||
|  |         throw new Error(`missing number: ${key}`); | ||||||
|  |     } | ||||||
|  |     const dict = obj as {[key:string]:any}; | ||||||
|  |     const val = dict[key]; | ||||||
|  |     if (typeof(val) !== "number") { | ||||||
|  |         throw new Error(`not a number: ${key}: ${val}`); | ||||||
|  |     } | ||||||
|  |     return val; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function mustGetString(obj: object, key: string) : string { | ||||||
|  |     if (obj === null || obj === undefined) { | ||||||
|  |         throw new Error("container absent"); | ||||||
|  |     } | ||||||
|  |     if (typeof(obj) !== "object") { | ||||||
|  |         throw new Error(`container was not an object; was ${typeof(obj)}`); | ||||||
|  |     } | ||||||
|  |     if (!(key in obj)) { | ||||||
|  |         throw new Error(`missing number: ${key}`); | ||||||
|  |     } | ||||||
|  |     const dict = obj as {[key:string]:any}; | ||||||
|  |     const val = dict[key]; | ||||||
|  |     if (typeof(val) !== "string") { | ||||||
|  |         throw new Error(`not a string: ${key}: ${val}`); | ||||||
|  |     } | ||||||
|  |     return val; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function mustGetStatCounterV1(obj: object, key: string) : StatCounterV1 { | ||||||
|  |     if (obj === null || obj === undefined) { | ||||||
|  |         throw new Error("container absent"); | ||||||
|  |     } | ||||||
|  |     if (typeof(obj) !== "object") { | ||||||
|  |         throw new Error(`container was not an object; was ${typeof(obj)}`); | ||||||
|  |     } | ||||||
|  |     if (!(key in obj)) { | ||||||
|  |         throw new Error(`missing number: ${key}`); | ||||||
|  |     } | ||||||
|  |     const dict = obj as {[key:string]:any}; | ||||||
|  |     const val = dict[key]; | ||||||
|  |     if (typeof(val) !== "object") { | ||||||
|  |         throw new Error(`not an object: ${key}: ${val}`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |         return { | ||||||
|  |             agi: mustGetNumber(val, "agi"), | ||||||
|  |             int: mustGetNumber(val, "int"), | ||||||
|  |             cha: mustGetNumber(val, "cha"), | ||||||
|  |             psi: mustGetNumber(val, "psi"), | ||||||
|  |         }; | ||||||
|  |     } catch (e) { | ||||||
|  |         let message = "unrecognizable error"; | ||||||
|  |         if (e instanceof Error) { | ||||||
|  |             message = e.message; | ||||||
|  |         } | ||||||
|  |         throw new Error(`reading ${key}: ${message}`); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function mustGetBoolean(obj: object, key: string) : boolean { | ||||||
|  |     if (obj === null || obj === undefined) { | ||||||
|  |         throw new Error("container absent"); | ||||||
|  |     } | ||||||
|  |     if (typeof(obj) !== "object") { | ||||||
|  |         throw new Error(`container was not an object; was ${typeof(obj)}`); | ||||||
|  |     } | ||||||
|  |     if (!(key in obj)) { | ||||||
|  |         throw new Error(`missing number: ${key}`); | ||||||
|  |     } | ||||||
|  |     const dict = obj as {[key:string]:any}; | ||||||
|  |     const val = dict[key]; | ||||||
|  |     if (typeof(val) !== "boolean") { | ||||||
|  |         throw new Error(`not boolean: ${key}: ${val}`); | ||||||
|  |     } | ||||||
|  |     return val; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function mustGetNumberArray(obj: object, key: string) : number[] { | ||||||
|  |     if (obj === null || obj === undefined) { | ||||||
|  |         throw new Error("container absent"); | ||||||
|  |     } | ||||||
|  |     if (typeof(obj) !== "object") { | ||||||
|  |         throw new Error(`container was not an object; was ${typeof(obj)}`); | ||||||
|  |     } | ||||||
|  |     if (!(key in obj)) { | ||||||
|  |         throw new Error(`missing number: ${key}`); | ||||||
|  |     } | ||||||
|  |     const dict = obj as {[key:string]:any}; | ||||||
|  |     const val = dict[key]; | ||||||
|  |     if (typeof(val) !== "object") { | ||||||
|  |         throw new Error(`not an object: ${key}: ${val}`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     for (const x of val) { | ||||||
|  |         if (typeof(x) !== "number") { | ||||||
|  |             throw new Error(`contained non-number item in ${key}: ${val}`); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |     return val; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// The result of attempting to load a V1 save file. | ||||||
|  | interface SaveFileV1LoadResult { | ||||||
|  |     // If present and valid, the loaded file. | ||||||
|  |     file : SaveFileV1 | null; | ||||||
|  |  | ||||||
|  |     /// A file loading error, if any. If `file` is present, this refers | ||||||
|  |     /// to an error reading from the *other* slot.  | ||||||
|  |     error: string | null; | ||||||
|  |  | ||||||
|  |     /// The slot this file was loaded from, or that a load attempt failed from. | ||||||
|  |     /// If multiple load attempts failed and none succeeded, this refers to | ||||||
|  |     /// any one attempted slot. | ||||||
|  |     slot: SaveSlot; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function readFromSlot(slot: SaveSlot): SaveFileV1LoadResult { | ||||||
|  |     var serialized = localStorage.getItem(slot); | ||||||
|  |     if (serialized === null) { | ||||||
|  |         return { | ||||||
|  |             file: null, | ||||||
|  |             error: null, | ||||||
|  |             slot: slot, | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |     try { | ||||||
|  |         return { | ||||||
|  |             file: mustBeSaveFileV1(JSON.parse(serialized)), | ||||||
|  |             error: null, | ||||||
|  |             slot: slot, | ||||||
|  |         }; | ||||||
|  |     } catch (e) { | ||||||
|  |         let message = "unidentifiable error"; | ||||||
|  |         if (e instanceof Error) { | ||||||
|  |             message = e.message; | ||||||
|  |         } | ||||||
|  |         return { | ||||||
|  |             file: null, | ||||||
|  |             error: message, | ||||||
|  |             slot: slot, | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// Reads the more recent valid save file, if either is valid. If no save files | ||||||
|  | /// are present, `file` and `error` will both be absent. If an invalid save | ||||||
|  | /// file is discovered, `error` will refer to the issue(s) detected. | ||||||
|  | function readBestSave(): SaveFileV1LoadResult { | ||||||
|  |     const from1 = readFromSlot("FLEDGLING_SLOT_1"); | ||||||
|  |     const from2 = readFromSlot("FLEDGLING_SLOT_2"); | ||||||
|  |     if (from1.file && from2.file) { | ||||||
|  |         return (from1.file.revision > from2.file.revision) ? from1 : from2; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     var errors : string[] = []; | ||||||
|  |     if (from1.error) { | ||||||
|  |         errors = ["slot 1 error: " + from1.error]; | ||||||
|  |     } | ||||||
|  |     if (from2.error) { | ||||||
|  |         errors.push("slot 2 error: " + from2.error); | ||||||
|  |     } | ||||||
|  |     var msg : string | null = errors.length > 0 ? errors.join("\n") : null; | ||||||
|  |     if (from1.file) { | ||||||
|  |         return { | ||||||
|  |             file: from1.file, | ||||||
|  |             error: msg, | ||||||
|  |             slot: "FLEDGLING_SLOT_1", | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |     return { | ||||||
|  |         file: from2.file, | ||||||
|  |         error: msg, | ||||||
|  |         slot: "FLEDGLING_SLOT_2", | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function save() { | ||||||
|  |     const targetSlot : SaveSlot = (readBestSave().slot === "FLEDGLING_SLOT_1") ? "FLEDGLING_SLOT_2" : "FLEDGLING_SLOT_1"; | ||||||
|  |     return saveIntoSlot(targetSlot); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function extractCurrentState() : SaveFileV1 { | ||||||
|  |     const progress = getPlayerProgress(); | ||||||
|  |     const stateManager = getStateManager(); | ||||||
|  |     var thrallDamage : number[] = []; | ||||||
|  |     const nThralls = getThralls().length; | ||||||
|  |     for (let i = 0; i < nThralls; ++i) { | ||||||
|  |         thrallDamage.push(progress.getThrallDamage({id: i})); | ||||||
|  |     } | ||||||
|  |     return { | ||||||
|  |         version: "fledgling_save_v1", | ||||||
|  |         revision: stateManager.nextRevision(), | ||||||
|  |         turn: stateManager.getTurn(), | ||||||
|  |         name: progress.name, | ||||||
|  |         thrallTemplateId: progress.template.id, | ||||||
|  |         nImprovements: progress.nImprovements, | ||||||
|  |         stats: { | ||||||
|  |             agi: progress.getStat("AGI"), | ||||||
|  |             int: progress.getStat("INT"), | ||||||
|  |             cha: progress.getStat("CHA"), | ||||||
|  |             psi: progress.getStat("PSI"), | ||||||
|  |         }, | ||||||
|  |         talents: { | ||||||
|  |             agi: progress.getStat("AGI"), | ||||||
|  |             int: progress.getStat("INT"), | ||||||
|  |             cha: progress.getStat("CHA"), | ||||||
|  |             psi: progress.getStat("PSI"), | ||||||
|  |         }, | ||||||
|  |         isInPenance: progress.isInPenance, | ||||||
|  |         wishId: progress.getWish()?.id ?? -1, | ||||||
|  |         exp: progress.getExperience(), | ||||||
|  |         blood: progress.getBlood(), | ||||||
|  |         itemsPurloined: progress.getItemsPurloined(), | ||||||
|  |         skillsLearned: progress.getRawLearnedSkills(), | ||||||
|  |         untrimmedSkillsAvailableIds: progress.getUntrimmedAvailableSkillIds(), | ||||||
|  |         thrallsUnlocked: progress.getUnlockedThrallIds(), | ||||||
|  |         thrallDamage: thrallDamage, | ||||||
|  |         thrallsObtainedItem: progress.getThrallObtainedItemIds(), | ||||||
|  |         thrallsDeliveredItem: progress.getThrallDeliveredItemIds(), | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function saveIntoSlot(slot: SaveSlot) { | ||||||
|  |     localStorage.setItem(slot, JSON.stringify(extractCurrentState())); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function wipeSaves() { | ||||||
|  |     localStorage.removeItem("FLEDGLING_SLOT_1"); | ||||||
|  |     localStorage.removeItem("FLEDGLING_SLOT_2"); | ||||||
|  | } | ||||||
| @@ -52,6 +52,10 @@ class ThrallsTable { | |||||||
|     } |     } | ||||||
|     return thralls; |     return thralls; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   get length(): number { | ||||||
|  |     return this.#thralls.length; | ||||||
|  |   } | ||||||
| } | } | ||||||
| export type ThrallData = { | export type ThrallData = { | ||||||
|   label: string; |   label: string; | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user