diff --git a/src/main.ts b/src/main.ts index d81cd62..fd764fe 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,5 +2,5 @@ import { hostGame } from "./engine/internal/host.ts"; import { game } from "./game.ts"; import { getStateManager } from "./statemanager.ts"; -getStateManager().startFirstGame(); +getStateManager().startOrLoadFirstGame(); hostGame(game); diff --git a/src/playerprogress.ts b/src/playerprogress.ts index b1e3232..bd923ab 100644 --- a/src/playerprogress.ts +++ b/src/playerprogress.ts @@ -1,6 +1,12 @@ import { ALL_STATS, Skill, Stat, SuccessorOption, Wish } from "./datatypes.ts"; import { getSkills } from "./skills.ts"; import { getThralls, ItemStage, LifeStage, Thrall } from "./thralls.ts"; +import { SaveFileV1, mustBeSaveFileV1 } from "./saveformat.ts"; + +interface NewRoundConfig { + asSuccessor: SuccessorOption; + withWish: Wish | null; +} export class PlayerProgress { #name: string; @@ -20,25 +26,65 @@ export class PlayerProgress { #thrallsObtainedItem: number[]; #thrallsDeliveredItem: number[]; - constructor(asSuccessor: SuccessorOption, withWish: Wish | null) { - this.#name = asSuccessor.name; - this.#thrallTemplate = asSuccessor.template.id; - this.#nImprovements = asSuccessor.nImprovements; - this.#stats = { ...asSuccessor.stats }; - this.#talents = { ...asSuccessor.talents }; - this.#isInPenance = asSuccessor.inPenance; - this.#wish = withWish; - this.#exp = 0; - this.#blood = 0; - this.#itemsPurloined = 0; - this.#skillsLearned = []; - this.#untrimmedSkillsAvailable = []; - this.#thrallsUnlocked = []; - this.#thrallDamage = {}; - this.#thrallsObtainedItem = []; - this.#thrallsDeliveredItem = []; + constructor(args: NewRoundConfig | SaveFileV1) { + if ("asSuccessor" in args) { + //asSuccessor: SuccessorOption, withWish: Wish | null) { + const config = args as NewRoundConfig; + const asSuccessor = config.asSuccessor; + this.#name = asSuccessor.name; + this.#thrallTemplate = asSuccessor.template.id; + this.#nImprovements = asSuccessor.nImprovements; + this.#stats = { ...asSuccessor.stats }; + this.#talents = { ...asSuccessor.talents }; + this.#isInPenance = asSuccessor.inPenance; + this.#wish = config.withWish; + this.#exp = 0; + this.#blood = 0; + this.#itemsPurloined = 0; + this.#skillsLearned = []; + this.#untrimmedSkillsAvailable = []; + this.#thrallsUnlocked = []; + this.#thrallDamage = {}; + this.#thrallsObtainedItem = []; + this.#thrallsDeliveredItem = []; - this.refill(); + this.refill(); + } else { + const file = mustBeSaveFileV1(args); + this.#name = file.name; + this.#thrallTemplate = file.thrallTemplateId; + this.#nImprovements = file.nImprovements; + this.#stats = { + AGI: file.stats.agi, + INT: file.stats.int, + CHA: file.stats.cha, + PSI: file.stats.psi, + }; + this.#talents = { + AGI: file.talents.agi, + INT: file.talents.int, + CHA: file.talents.cha, + PSI: file.talents.psi, + }; + (this.#isInPenance = file.isInPenance), + (this.#wish = file.wishId >= 0 ? { id: file.wishId } : null); + this.#exp = file.exp; + this.#blood = file.blood; + this.#itemsPurloined = file.itemsPurloined; + this.#skillsLearned = file.skillsLearned; + this.#untrimmedSkillsAvailable = file.untrimmedSkillsAvailableIds.map( + (id) => { + return { id: id }; + }, + ); + this.#thrallsUnlocked = file.thrallsUnlocked; + this.#thrallDamage = {}; + for (let i = 0; i < file.thrallDamage.length; ++i) { + this.#thrallDamage[i] = file.thrallDamage[i]; + } + this.#thrallsObtainedItem = file.thrallsObtainedItem; + this.#thrallsDeliveredItem = file.thrallsDeliveredItem; + } } applyEndOfTurn() { @@ -206,7 +252,11 @@ export class PlayerProgress { return skillsAvailable.slice(0, 6); } - getLearnedSkills() { + getUntrimmedAvailableSkillIds(): number[] { + return this.#untrimmedSkillsAvailable.map((s) => s.id); + } + + getLearnedSkills(): Skill[] { let learnedSkills = []; for (let s of this.#skillsLearned.values()) { learnedSkills.push({ id: s }); @@ -214,6 +264,10 @@ export class PlayerProgress { return learnedSkills; } + getRawLearnedSkills(): number[] { + return [...this.#skillsLearned]; + } + getStats() { return { ...this.#stats }; } @@ -233,6 +287,10 @@ export class PlayerProgress { return this.#thrallsUnlocked.indexOf(thrall.id) != -1; } + getUnlockedThrallIds(): number[] { + return [...this.#thrallsUnlocked]; + } + damageThrall(thrall: Thrall, amount: number) { if (amount <= 0.0) { throw new Error(`damage must be some positive amount, not ${amount}`); @@ -246,6 +304,10 @@ export class PlayerProgress { (this.#thrallDamage[thrall.id] ?? 0.0) + amount; } + getThrallDamage(thrall: Thrall): number { + return this.#thrallDamage[thrall.id] ?? 0.0; + } + getThrallLifeStage(thrall: Thrall): LifeStage { let damage = this.#thrallDamage[thrall.id] ?? 0; if (damage < 0.5) { @@ -270,6 +332,10 @@ export class PlayerProgress { this.#thrallsObtainedItem.push(thrall.id); } + getThrallObtainedItemIds(): number[] { + return [...this.#thrallsObtainedItem]; + } + deliverThrallItem(thrall: Thrall) { if (this.#thrallsDeliveredItem.indexOf(thrall.id) != -1) { return; @@ -277,6 +343,10 @@ export class PlayerProgress { this.#thrallsDeliveredItem.push(thrall.id); } + getThrallDeliveredItemIds(): number[] { + return [...this.#thrallsDeliveredItem]; + } + getThrallItemStage(thrall: Thrall): ItemStage { if (this.#thrallsDeliveredItem.indexOf(thrall.id) != -1) { return ItemStage.Delivered; @@ -308,7 +378,11 @@ export function initPlayerProgress( asSuccessor: SuccessorOption, withWish: Wish | null, ) { - active = new PlayerProgress(asSuccessor, withWish); + active = new PlayerProgress({ asSuccessor: asSuccessor, withWish: withWish }); +} + +export function rehydratePlayerProgress(savefile: SaveFileV1) { + active = new PlayerProgress(savefile); } export function getPlayerProgress(): PlayerProgress { diff --git a/src/save.ts b/src/save.ts new file mode 100644 index 0000000..46d0e36 --- /dev/null +++ b/src/save.ts @@ -0,0 +1,147 @@ +import { getPlayerProgress } from "./playerprogress"; +import { getStateManager } from "./statemanager"; +import { getThralls } from "./thralls"; +import { SaveFileV1, mustBeSaveFileV1 } from "./saveformat"; +import { getHuntMode } from "./huntmode.ts"; + +export interface SaveFile { + version: string; + revision: number; +} + +type SaveSlot = "FLEDGLING_SLOT_1" | "FLEDGLING_SLOT_2"; + +/// 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. +export 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 saveGame() { + 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(); + const huntMode = getHuntMode(); + 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.getTalent("AGI"), + int: progress.getTalent("INT"), + cha: progress.getTalent("CHA"), + psi: progress.getTalent("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(), + depth: huntMode.getDepth(), + }; +} + +function saveIntoSlot(slot: SaveSlot) { + localStorage.setItem(slot, JSON.stringify(extractCurrentState())); +} + +export function wipeSaves() { + localStorage.removeItem("FLEDGLING_SLOT_1"); + localStorage.removeItem("FLEDGLING_SLOT_2"); +} diff --git a/src/saveformat.ts b/src/saveformat.ts new file mode 100644 index 0000000..762d93d --- /dev/null +++ b/src/saveformat.ts @@ -0,0 +1,185 @@ +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[]; + depth: number; +} + +/// 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"), + depth: mustGetNumber(obj, "depth"), + }; +} + +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; +} diff --git a/src/statemanager.ts b/src/statemanager.ts index a30cdb3..35e1efa 100644 --- a/src/statemanager.ts +++ b/src/statemanager.ts @@ -1,4 +1,8 @@ -import { getPlayerProgress, initPlayerProgress } from "./playerprogress.ts"; +import { + getPlayerProgress, + initPlayerProgress, + rehydratePlayerProgress, +} from "./playerprogress.ts"; import { getHuntMode, HuntMode, initHuntMode } from "./huntmode.ts"; import { getVNModal } from "./vnmodal.ts"; import { getScorer } from "./scorer.ts"; @@ -10,20 +14,46 @@ import { openingScene } from "./openingscene.ts"; import { generateName } from "./namegen.ts"; import { photogenicThralls } from "./thralls.ts"; import { choose } from "./utils.ts"; +import { SaveFileV1 } from "./saveformat.ts"; +import { readBestSave, saveGame } from "./save.ts"; const N_TURNS: number = 9; export class StateManager { #turn: number; + #revision: number; - constructor() { - this.#turn = 1; + constructor(file?: SaveFileV1) { + this.#turn = file?.turn ?? 1; + this.#revision = file?.revision ?? 1; } getTurn(): number { return this.#turn; } + nextRevision(): number { + this.#revision++; + return this.#revision; + } + + startOrLoadFirstGame() { + let save = readBestSave(); + if (save.file != null || save.error != null) { + const file = save.file; + const error = save.error; + getVNModal().play([ + { + type: "saveGameScreen", + file: file, + error: error, + }, + ]); + return; + } + + this.startFirstGame(); + } startFirstGame() { getVNModal().play([ ...openingScene, @@ -57,7 +87,17 @@ export class StateManager { sndSleep.play({ bgm: true }); } + resumeGame(saveFile: SaveFileV1) { + // hack: prepare depth which advance() uses + this.#turn = saveFile.turn; + this.#revision = saveFile.revision; + rehydratePlayerProgress(saveFile); + initHuntMode(new HuntMode(saveFile.depth, generateManor())); + this.advance(); + } + advance() { + saveGame(); if (this.#turn + 1 <= N_TURNS) { this.#turn += 1; getPlayerProgress().applyEndOfTurn(); diff --git a/src/thralls.ts b/src/thralls.ts index 9dc6ebe..edbb45e 100644 --- a/src/thralls.ts +++ b/src/thralls.ts @@ -52,6 +52,10 @@ class ThrallsTable { } return thralls; } + + get length(): number { + return this.#thralls.length; + } } export type ThrallData = { label: string; diff --git a/src/vnmodal.ts b/src/vnmodal.ts index 7afd81a..a16a1e6 100644 --- a/src/vnmodal.ts +++ b/src/vnmodal.ts @@ -1,8 +1,13 @@ import { D, I } from "./engine/public.ts"; -import { AlignX, AlignY, Point } from "./engine/datatypes.ts"; +import { AlignX, AlignY, Point, Rect, Size } from "./engine/datatypes.ts"; import { withCamera } from "./layout.ts"; import { VNScene, VNSceneMessage, VNScenePart } from "./vnscene.ts"; import { C } from "./colors.ts"; +import { DrawPile } from "./drawpile.ts"; +import { wipeSaves } from "./save.ts"; +import { SaveFileV1 } from "./saveformat.ts"; +import { addButton } from "./button.ts"; +import { getStateManager } from "./statemanager.ts"; const WIDTH = 384; const HEIGHT = 384; @@ -90,6 +95,8 @@ function createCathexis(part: VNScenePart): SceneCathexis { case "callback": part?.callback(); return new SkipCathexis(); + case "saveGameScreen": + return new SaveGameCathexis(part.file, part.error); } } @@ -112,7 +119,6 @@ class SceneMessageCathexis { let firstFrame = !this.#gotOneFrame; this.#gotOneFrame = true; - // TODO: SFX if (!firstFrame && I.isMouseClicked("leftMouse")) { this.#done = true; } @@ -152,3 +158,88 @@ class SkipCathexis { throw new Error("shouldn't ever be drawn"); } } + +class SaveGameCathexis { + #drawpile: DrawPile; + #file: SaveFileV1 | null; + #error: string | null; + #done: boolean; + + constructor(file: SaveFileV1 | null, error: string | null) { + this.#drawpile = new DrawPile(); + this.#file = file; + this.#error = error; + this.#done = false; + } + + isDone() { + return this.#done; + } + + update() { + let name = this.#file?.name; + let turn = this.#file?.turn ?? 0; + let turnText = turn < 9 ? `${name}, Turn ${turn + 1}` : "Sentence of Fate"; + this.#drawpile.clear(); + this.#drawpile.add(0, () => { + D.drawText( + this.#error && this.#file + ? `A save was invalid. Continue from an alternate save? + +${this.#error}` + : this.#error + ? `Your save was invalid: + +${this.#error}` + : "Resume from save?", + new Point(WIDTH / 2, HEIGHT / 2), + C.FG_BOLD, + { + alignX: AlignX.Center, + alignY: AlignY.Middle, + forceWidth: WIDTH, + }, + ); + }); + + addButton( + this.#drawpile, + "Clear Save", + new Rect(new Point(0, HEIGHT - 32), new Size(128, 32)), + this.#file != null, + () => { + wipeSaves(); + this.#file = null; + }, + ); + if (this.#file) { + let file = this.#file; + addButton( + this.#drawpile, + `Continue (${turnText})`, + new Rect(new Point(128, HEIGHT - 32), new Size(WIDTH - 128, 32)), + true, + () => { + getStateManager().resumeGame(file); + this.#done = true; + }, + ); + } else { + addButton( + this.#drawpile, + `Start New Game`, + new Rect(new Point(128, HEIGHT - 32), new Size(WIDTH - 128, 32)), + true, + () => { + getStateManager().startFirstGame(); + this.#done = true; + }, + ); + } + this.#drawpile.executeOnClick(); + } + + draw() { + this.#drawpile.draw(); + } +} diff --git a/src/vnscene.ts b/src/vnscene.ts index 765c56f..90f3faa 100644 --- a/src/vnscene.ts +++ b/src/vnscene.ts @@ -1,4 +1,5 @@ import { Sound } from "./sound.ts"; +import { SaveFileV1 } from "./saveformat.ts"; export type VNSceneMessage = { type: "message"; @@ -11,9 +12,18 @@ export type VNSceneCallback = { callback: () => void; }; +export type VNSceneSaveGameScreen = { + type: "saveGameScreen"; + file: SaveFileV1 | null; + error: string | null; +}; + export type VNSceneBasisPart = string | VNSceneMessage | VNSceneCallback; export type VNSceneBasis = VNSceneBasisPart[]; -export type VNScenePart = VNSceneMessage | VNSceneCallback; +export type VNScenePart = + | VNSceneMessage + | VNSceneCallback + | VNSceneSaveGameScreen; export type VNScene = VNScenePart[]; export function compile(basis: VNSceneBasis): VNScene {