feat: autosave current build to localStorage
67df298 parent: 2865d59 modified
.gitignore +1 -0 | @@ -41,4 +41,5 @@ next-env.d.ts | ||
| 41 | 41 | /outputs/ |
| 42 | 42 | /work/ |
| 43 | 43 | |
| 44 | +docs/ | |
| 44 | 45 | .pi-subagents/ |
| @@ -41,4 +41,5 @@ next-env.d.ts | |||
| 41 | /outputs/ | 41 | /outputs/ |
| 42 | /work/ | 42 | /work/ |
| 43 | 43 | ||
| 44 | +docs/ | ||
| 44 | .pi-subagents/ | 45 | .pi-subagents/ |
modified
src/components/TeamBuilder.tsx +105 -20 | @@ -14,33 +14,77 @@ import { | ||
| 14 | 14 | classRoute, |
| 15 | 15 | editClassRank, |
| 16 | 16 | legalAvatarWeaponPairs, |
| 17 | - normalizeClassRanks, | |
| 18 | 17 | } from "@/src/lib/engine"; |
| 18 | +import { | |
| 19 | + DEFAULT_BUILD_DRAFT, | |
| 20 | + loadBuildDraft, | |
| 21 | + saveBuildDraft, | |
| 22 | + type BuildDraft, | |
| 23 | +} from "@/src/lib/buildPersistence"; | |
| 19 | 24 | import type { CombatMode, Lang, WeaponOverrides } from "@/src/lib/types"; |
| 20 | 25 | |
| 26 | +const BUILD_AUTOSAVE_DELAY_MS = 250; | |
| 27 | + | |
| 28 | +function loadBrowserBuild(): BuildDraft { | |
| 29 | + try { | |
| 30 | + return loadBuildDraft(window.localStorage); | |
| 31 | + } catch { | |
| 32 | + return DEFAULT_BUILD_DRAFT; | |
| 33 | + } | |
| 34 | +} | |
| 35 | + | |
| 36 | +function saveBrowserBuild(build: BuildDraft): void { | |
| 37 | + try { | |
| 38 | + saveBuildDraft(window.localStorage, build); | |
| 39 | + } catch { | |
| 40 | + // Browser privacy settings can make localStorage itself inaccessible. | |
| 41 | + } | |
| 42 | +} | |
| 43 | + | |
| 21 | 44 | export default function TeamBuilder() { |
| 22 | 45 | const [lang, setLang] = useState<Lang>("en"); |
| 23 | - const [avatarLevel, setAvatarLevel] = useState(30); | |
| 24 | - const [activeClass, setActiveClass] = useState("duelist"); | |
| 25 | - const [team, setTeam] = useState<[string, string, string]>(["", "", ""]); | |
| 26 | - const [personalRanks, setPersonalRanks] = useState<[number, number, number]>([ | |
| 27 | - 20, 20, 20, | |
| 46 | + const [avatarLevel, setAvatarLevel] = useState(DEFAULT_BUILD_DRAFT.avatarLevel); | |
| 47 | + const [activeClass, setActiveClass] = useState(DEFAULT_BUILD_DRAFT.activeClass); | |
| 48 | + const [team, setTeam] = useState<[string, string, string]>(() => [ | |
| 49 | + ...DEFAULT_BUILD_DRAFT.team, | |
| 28 | 50 | ]); |
| 29 | - const [classRanks, setClassRanks] = useState<Record<string, number>>(() => | |
| 30 | - normalizeClassRanks({ | |
| 31 | - drifter: 10, | |
| 32 | - striker: 10, | |
| 33 | - "samurai-gunner": 10, | |
| 34 | - duelist: 10, | |
| 35 | - }), | |
| 36 | - ); | |
| 51 | + const [personalRanks, setPersonalRanks] = useState<[ | |
| 52 | + number, | |
| 53 | + number, | |
| 54 | + number, | |
| 55 | + ]>(() => [...DEFAULT_BUILD_DRAFT.personalRanks]); | |
| 56 | + const [classRanks, setClassRanks] = useState<Record<string, number>>(() => ({ | |
| 57 | + ...DEFAULT_BUILD_DRAFT.classRanks, | |
| 58 | + })); | |
| 37 | 59 | const [showRanks, setShowRanks] = useState(false); |
| 38 | - const [rangedWeapon, setRangedWeapon] = useState(""); | |
| 39 | - const [meleeWeapon, setMeleeWeapon] = useState(""); | |
| 40 | - const [combatMode, setCombatMode] = useState<CombatMode>("general"); | |
| 41 | - const [hasSkellLicense, setHasSkellLicense] = useState(false); | |
| 42 | - const [postgameFrames, setPostgameFrames] = useState(false); | |
| 43 | - const [assumeSpecialUnlocks, setAssumeSpecialUnlocks] = useState(false); | |
| 60 | + const [rangedWeapon, setRangedWeapon] = useState(DEFAULT_BUILD_DRAFT.rangedWeapon); | |
| 61 | + const [meleeWeapon, setMeleeWeapon] = useState(DEFAULT_BUILD_DRAFT.meleeWeapon); | |
| 62 | + const [combatMode, setCombatMode] = useState<CombatMode>(DEFAULT_BUILD_DRAFT.combatMode); | |
| 63 | + const [hasSkellLicense, setHasSkellLicense] = useState(DEFAULT_BUILD_DRAFT.hasSkellLicense); | |
| 64 | + const [postgameFrames, setPostgameFrames] = useState(DEFAULT_BUILD_DRAFT.postgameFrames); | |
| 65 | + const [assumeSpecialUnlocks, setAssumeSpecialUnlocks] = useState( | |
| 66 | + DEFAULT_BUILD_DRAFT.assumeSpecialUnlocks, | |
| 67 | + ); | |
| 68 | + const [buildStorageReady, setBuildStorageReady] = useState(false); | |
| 69 | + | |
| 70 | + useEffect(() => { | |
| 71 | + const timeout = window.setTimeout(() => { | |
| 72 | + const savedBuild = loadBrowserBuild(); | |
| 73 | + setAvatarLevel(savedBuild.avatarLevel); | |
| 74 | + setActiveClass(savedBuild.activeClass); | |
| 75 | + setTeam(savedBuild.team); | |
| 76 | + setPersonalRanks(savedBuild.personalRanks); | |
| 77 | + setClassRanks(savedBuild.classRanks); | |
| 78 | + setRangedWeapon(savedBuild.rangedWeapon); | |
| 79 | + setMeleeWeapon(savedBuild.meleeWeapon); | |
| 80 | + setCombatMode(savedBuild.combatMode); | |
| 81 | + setHasSkellLicense(savedBuild.hasSkellLicense); | |
| 82 | + setPostgameFrames(savedBuild.postgameFrames); | |
| 83 | + setAssumeSpecialUnlocks(savedBuild.assumeSpecialUnlocks); | |
| 84 | + setBuildStorageReady(true); | |
| 85 | + }, 0); | |
| 86 | + return () => window.clearTimeout(timeout); | |
| 87 | + }, []); | |
| 44 | 88 | |
| 45 | 89 | const t = copy[lang]; |
| 46 | 90 | useEffect(() => { |
| @@ -74,6 +118,47 @@ export default function TeamBuilder() { | ||
| 74 | 118 | ...(selectedMeleeWeapon ? { melee: selectedMeleeWeapon } : {}), |
| 75 | 119 | }; |
| 76 | 120 | }, [selectedRangedWeapon, selectedMeleeWeapon]); |
| 121 | + const currentBuild = useMemo<BuildDraft>( | |
| 122 | + () => ({ | |
| 123 | + avatarLevel, | |
| 124 | + activeClass, | |
| 125 | + team, | |
| 126 | + personalRanks, | |
| 127 | + classRanks, | |
| 128 | + rangedWeapon: selectedRangedWeapon, | |
| 129 | + meleeWeapon: selectedMeleeWeapon, | |
| 130 | + combatMode, | |
| 131 | + hasSkellLicense, | |
| 132 | + postgameFrames, | |
| 133 | + assumeSpecialUnlocks, | |
| 134 | + }), | |
| 135 | + [ | |
| 136 | + avatarLevel, | |
| 137 | + activeClass, | |
| 138 | + team, | |
| 139 | + personalRanks, | |
| 140 | + classRanks, | |
| 141 | + selectedRangedWeapon, | |
| 142 | + selectedMeleeWeapon, | |
| 143 | + combatMode, | |
| 144 | + hasSkellLicense, | |
| 145 | + postgameFrames, | |
| 146 | + assumeSpecialUnlocks, | |
| 147 | + ], | |
| 148 | + ); | |
| 149 | + useEffect(() => { | |
| 150 | + if (!buildStorageReady) return; | |
| 151 | + const timeout = window.setTimeout( | |
| 152 | + () => saveBrowserBuild(currentBuild), | |
| 153 | + BUILD_AUTOSAVE_DELAY_MS, | |
| 154 | + ); | |
| 155 | + const flushBuild = () => saveBrowserBuild(currentBuild); | |
| 156 | + window.addEventListener("pagehide", flushBuild); | |
| 157 | + return () => { | |
| 158 | + window.clearTimeout(timeout); | |
| 159 | + window.removeEventListener("pagehide", flushBuild); | |
| 160 | + }; | |
| 161 | + }, [buildStorageReady, currentBuild]); | |
| 77 | 162 | const contextualResult = useMemo( |
| 78 | 163 | () => |
| 79 | 164 | recommendContextualTeams({ |
| @@ -14,33 +14,77 @@ import { | |||
| 14 | classRoute, | 14 | classRoute, |
| 15 | editClassRank, | 15 | editClassRank, |
| 16 | legalAvatarWeaponPairs, | 16 | legalAvatarWeaponPairs, |
| 17 | - normalizeClassRanks, | ||
| 18 | } from "@/src/lib/engine"; | 17 | } from "@/src/lib/engine"; |
| 18 | +import { | ||
| 19 | + DEFAULT_BUILD_DRAFT, | ||
| 20 | + loadBuildDraft, | ||
| 21 | + saveBuildDraft, | ||
| 22 | + type BuildDraft, | ||
| 23 | +} from "@/src/lib/buildPersistence"; | ||
| 19 | import type { CombatMode, Lang, WeaponOverrides } from "@/src/lib/types"; | 24 | import type { CombatMode, Lang, WeaponOverrides } from "@/src/lib/types"; |
| 20 | 25 | ||
| 26 | +const BUILD_AUTOSAVE_DELAY_MS = 250; | ||
| 27 | + | ||
| 28 | +function loadBrowserBuild(): BuildDraft { | ||
| 29 | + try { | ||
| 30 | + return loadBuildDraft(window.localStorage); | ||
| 31 | + } catch { | ||
| 32 | + return DEFAULT_BUILD_DRAFT; | ||
| 33 | + } | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +function saveBrowserBuild(build: BuildDraft): void { | ||
| 37 | + try { | ||
| 38 | + saveBuildDraft(window.localStorage, build); | ||
| 39 | + } catch { | ||
| 40 | + // Browser privacy settings can make localStorage itself inaccessible. | ||
| 41 | + } | ||
| 42 | +} | ||
| 43 | + | ||
| 21 | export default function TeamBuilder() { | 44 | export default function TeamBuilder() { |
| 22 | const [lang, setLang] = useState<Lang>("en"); | 45 | const [lang, setLang] = useState<Lang>("en"); |
| 23 | - const [avatarLevel, setAvatarLevel] = useState(30); | 46 | + const [avatarLevel, setAvatarLevel] = useState(DEFAULT_BUILD_DRAFT.avatarLevel); |
| 24 | - const [activeClass, setActiveClass] = useState("duelist"); | 47 | + const [activeClass, setActiveClass] = useState(DEFAULT_BUILD_DRAFT.activeClass); |
| 25 | - const [team, setTeam] = useState<[string, string, string]>(["", "", ""]); | 48 | + const [team, setTeam] = useState<[string, string, string]>(() => [ |
| 26 | - const [personalRanks, setPersonalRanks] = useState<[number, number, number]>([ | 49 | + ...DEFAULT_BUILD_DRAFT.team, |
| 27 | - 20, 20, 20, | ||
| 28 | ]); | 50 | ]); |
| 29 | - const [classRanks, setClassRanks] = useState<Record<string, number>>(() => | 51 | + const [personalRanks, setPersonalRanks] = useState<[ |
| 30 | - normalizeClassRanks({ | 52 | + number, |
| 31 | - drifter: 10, | 53 | + number, |
| 32 | - striker: 10, | 54 | + number, |
| 33 | - "samurai-gunner": 10, | 55 | + ]>(() => [...DEFAULT_BUILD_DRAFT.personalRanks]); |
| 34 | - duelist: 10, | 56 | + const [classRanks, setClassRanks] = useState<Record<string, number>>(() => ({ |
| 35 | - }), | 57 | + ...DEFAULT_BUILD_DRAFT.classRanks, |
| 36 | - ); | 58 | + })); |
| 37 | const [showRanks, setShowRanks] = useState(false); | 59 | const [showRanks, setShowRanks] = useState(false); |
| 38 | - const [rangedWeapon, setRangedWeapon] = useState(""); | 60 | + const [rangedWeapon, setRangedWeapon] = useState(DEFAULT_BUILD_DRAFT.rangedWeapon); |
| 39 | - const [meleeWeapon, setMeleeWeapon] = useState(""); | 61 | + const [meleeWeapon, setMeleeWeapon] = useState(DEFAULT_BUILD_DRAFT.meleeWeapon); |
| 40 | - const [combatMode, setCombatMode] = useState<CombatMode>("general"); | 62 | + const [combatMode, setCombatMode] = useState<CombatMode>(DEFAULT_BUILD_DRAFT.combatMode); |
| 41 | - const [hasSkellLicense, setHasSkellLicense] = useState(false); | 63 | + const [hasSkellLicense, setHasSkellLicense] = useState(DEFAULT_BUILD_DRAFT.hasSkellLicense); |
| 42 | - const [postgameFrames, setPostgameFrames] = useState(false); | 64 | + const [postgameFrames, setPostgameFrames] = useState(DEFAULT_BUILD_DRAFT.postgameFrames); |
| 43 | - const [assumeSpecialUnlocks, setAssumeSpecialUnlocks] = useState(false); | 65 | + const [assumeSpecialUnlocks, setAssumeSpecialUnlocks] = useState( |
| 66 | + DEFAULT_BUILD_DRAFT.assumeSpecialUnlocks, | ||
| 67 | + ); | ||
| 68 | + const [buildStorageReady, setBuildStorageReady] = useState(false); | ||
| 69 | + | ||
| 70 | + useEffect(() => { | ||
| 71 | + const timeout = window.setTimeout(() => { | ||
| 72 | + const savedBuild = loadBrowserBuild(); | ||
| 73 | + setAvatarLevel(savedBuild.avatarLevel); | ||
| 74 | + setActiveClass(savedBuild.activeClass); | ||
| 75 | + setTeam(savedBuild.team); | ||
| 76 | + setPersonalRanks(savedBuild.personalRanks); | ||
| 77 | + setClassRanks(savedBuild.classRanks); | ||
| 78 | + setRangedWeapon(savedBuild.rangedWeapon); | ||
| 79 | + setMeleeWeapon(savedBuild.meleeWeapon); | ||
| 80 | + setCombatMode(savedBuild.combatMode); | ||
| 81 | + setHasSkellLicense(savedBuild.hasSkellLicense); | ||
| 82 | + setPostgameFrames(savedBuild.postgameFrames); | ||
| 83 | + setAssumeSpecialUnlocks(savedBuild.assumeSpecialUnlocks); | ||
| 84 | + setBuildStorageReady(true); | ||
| 85 | + }, 0); | ||
| 86 | + return () => window.clearTimeout(timeout); | ||
| 87 | + }, []); | ||
| 44 | 88 | ||
| 45 | const t = copy[lang]; | 89 | const t = copy[lang]; |
| 46 | useEffect(() => { | 90 | useEffect(() => { |
| @@ -74,6 +118,47 @@ export default function TeamBuilder() { | |||
| 74 | ...(selectedMeleeWeapon ? { melee: selectedMeleeWeapon } : {}), | 118 | ...(selectedMeleeWeapon ? { melee: selectedMeleeWeapon } : {}), |
| 75 | }; | 119 | }; |
| 76 | }, [selectedRangedWeapon, selectedMeleeWeapon]); | 120 | }, [selectedRangedWeapon, selectedMeleeWeapon]); |
| 121 | + const currentBuild = useMemo<BuildDraft>( | ||
| 122 | + () => ({ | ||
| 123 | + avatarLevel, | ||
| 124 | + activeClass, | ||
| 125 | + team, | ||
| 126 | + personalRanks, | ||
| 127 | + classRanks, | ||
| 128 | + rangedWeapon: selectedRangedWeapon, | ||
| 129 | + meleeWeapon: selectedMeleeWeapon, | ||
| 130 | + combatMode, | ||
| 131 | + hasSkellLicense, | ||
| 132 | + postgameFrames, | ||
| 133 | + assumeSpecialUnlocks, | ||
| 134 | + }), | ||
| 135 | + [ | ||
| 136 | + avatarLevel, | ||
| 137 | + activeClass, | ||
| 138 | + team, | ||
| 139 | + personalRanks, | ||
| 140 | + classRanks, | ||
| 141 | + selectedRangedWeapon, | ||
| 142 | + selectedMeleeWeapon, | ||
| 143 | + combatMode, | ||
| 144 | + hasSkellLicense, | ||
| 145 | + postgameFrames, | ||
| 146 | + assumeSpecialUnlocks, | ||
| 147 | + ], | ||
| 148 | + ); | ||
| 149 | + useEffect(() => { | ||
| 150 | + if (!buildStorageReady) return; | ||
| 151 | + const timeout = window.setTimeout( | ||
| 152 | + () => saveBrowserBuild(currentBuild), | ||
| 153 | + BUILD_AUTOSAVE_DELAY_MS, | ||
| 154 | + ); | ||
| 155 | + const flushBuild = () => saveBrowserBuild(currentBuild); | ||
| 156 | + window.addEventListener("pagehide", flushBuild); | ||
| 157 | + return () => { | ||
| 158 | + window.clearTimeout(timeout); | ||
| 159 | + window.removeEventListener("pagehide", flushBuild); | ||
| 160 | + }; | ||
| 161 | + }, [buildStorageReady, currentBuild]); | ||
| 77 | const contextualResult = useMemo( | 162 | const contextualResult = useMemo( |
| 78 | () => | 163 | () => |
| 79 | recommendContextualTeams({ | 164 | recommendContextualTeams({ |
added
src/lib/buildPersistence.test.ts +130 -0 | new file mode 100644 | ||
| @@ -0,0 +1,130 @@ | ||
| 1 | +import assert from "node:assert/strict"; | |
| 2 | +import test from "node:test"; | |
| 3 | +import { normalizeClassRanks } from "@/src/lib/engine"; | |
| 4 | +import { | |
| 5 | + BUILD_STORAGE_KEY, | |
| 6 | + BUILD_STORAGE_VERSION, | |
| 7 | + DEFAULT_BUILD_DRAFT, | |
| 8 | + loadBuildDraft, | |
| 9 | + saveBuildDraft, | |
| 10 | + type BuildDraft, | |
| 11 | + type BuildStorage, | |
| 12 | +} from "./buildPersistence"; | |
| 13 | + | |
| 14 | +class MemoryStorage implements BuildStorage { | |
| 15 | + readonly values = new Map<string, string>(); | |
| 16 | + | |
| 17 | + getItem(key: string): string | null { | |
| 18 | + return this.values.get(key) ?? null; | |
| 19 | + } | |
| 20 | + | |
| 21 | + setItem(key: string, value: string): void { | |
| 22 | + this.values.set(key, value); | |
| 23 | + } | |
| 24 | +} | |
| 25 | + | |
| 26 | +const validBuild: BuildDraft = { | |
| 27 | + avatarLevel: 62, | |
| 28 | + activeClass: "full-metal-jaguar", | |
| 29 | + team: ["Elma", "Lin", "Doug"], | |
| 30 | + personalRanks: [12, 13, 14], | |
| 31 | + classRanks: normalizeClassRanks({ | |
| 32 | + drifter: 10, | |
| 33 | + commando: 10, | |
| 34 | + "winged-viper": 10, | |
| 35 | + "full-metal-jaguar": 8, | |
| 36 | + }), | |
| 37 | + rangedWeapon: "Dual Guns", | |
| 38 | + meleeWeapon: "Dual Swords", | |
| 39 | + combatMode: "skell", | |
| 40 | + hasSkellLicense: true, | |
| 41 | + postgameFrames: true, | |
| 42 | + assumeSpecialUnlocks: true, | |
| 43 | +}; | |
| 44 | + | |
| 45 | +test("round-trips a valid build through versioned storage", () => { | |
| 46 | + const storage = new MemoryStorage(); | |
| 47 | + | |
| 48 | + assert.equal(saveBuildDraft(storage, validBuild), true); | |
| 49 | + assert.deepEqual(loadBuildDraft(storage), validBuild); | |
| 50 | + | |
| 51 | + const payload: unknown = JSON.parse( | |
| 52 | + storage.values.get(BUILD_STORAGE_KEY) ?? "null", | |
| 53 | + ); | |
| 54 | + assert.ok(payload && typeof payload === "object" && "version" in payload); | |
| 55 | + assert.equal(payload.version, BUILD_STORAGE_VERSION); | |
| 56 | +}); | |
| 57 | + | |
| 58 | +test("sanitizes invalid persisted fields before restoring them", () => { | |
| 59 | + const storage = new MemoryStorage(); | |
| 60 | + storage.values.set( | |
| 61 | + BUILD_STORAGE_KEY, | |
| 62 | + JSON.stringify({ | |
| 63 | + version: BUILD_STORAGE_VERSION, | |
| 64 | + build: { | |
| 65 | + avatarLevel: 120, | |
| 66 | + activeClass: "unknown-class", | |
| 67 | + team: ["Elma", "Elma", "Unknown companion"], | |
| 68 | + personalRanks: [-10, 999, null], | |
| 69 | + classRanks: { drifter: 4, duelist: -2, unknown: 10 }, | |
| 70 | + rangedWeapon: "Monado", | |
| 71 | + meleeWeapon: "Energy Sword", | |
| 72 | + combatMode: "flight", | |
| 73 | + hasSkellLicense: false, | |
| 74 | + postgameFrames: true, | |
| 75 | + assumeSpecialUnlocks: "yes", | |
| 76 | + }, | |
| 77 | + }), | |
| 78 | + ); | |
| 79 | + | |
| 80 | + const build = loadBuildDraft(storage); | |
| 81 | + | |
| 82 | + assert.equal(build.avatarLevel, 99); | |
| 83 | + assert.equal(build.activeClass, DEFAULT_BUILD_DRAFT.activeClass); | |
| 84 | + assert.deepEqual(build.team, ["Elma", "", ""]); | |
| 85 | + assert.deepEqual(build.personalRanks, [3, 20, 20]); | |
| 86 | + assert.equal(build.classRanks.duelist, 1); | |
| 87 | + assert.equal(build.classRanks.drifter, 10); | |
| 88 | + assert.equal(build.classRanks.unknown, undefined); | |
| 89 | + assert.equal(build.rangedWeapon, ""); | |
| 90 | + assert.equal(build.meleeWeapon, ""); | |
| 91 | + assert.equal(build.combatMode, "general"); | |
| 92 | + assert.equal(build.hasSkellLicense, false); | |
| 93 | + assert.equal(build.postgameFrames, false); | |
| 94 | + assert.equal(build.assumeSpecialUnlocks, false); | |
| 95 | +}); | |
| 96 | + | |
| 97 | +test("falls back safely for empty, malformed, unsupported, or unreadable storage", () => { | |
| 98 | + const empty = new MemoryStorage(); | |
| 99 | + assert.deepEqual(loadBuildDraft(empty), DEFAULT_BUILD_DRAFT); | |
| 100 | + | |
| 101 | + empty.values.set(BUILD_STORAGE_KEY, "not-json"); | |
| 102 | + assert.deepEqual(loadBuildDraft(empty), DEFAULT_BUILD_DRAFT); | |
| 103 | + | |
| 104 | + empty.values.set( | |
| 105 | + BUILD_STORAGE_KEY, | |
| 106 | + JSON.stringify({ version: 999, build: validBuild }), | |
| 107 | + ); | |
| 108 | + assert.deepEqual(loadBuildDraft(empty), DEFAULT_BUILD_DRAFT); | |
| 109 | + | |
| 110 | + const unreadable: BuildStorage = { | |
| 111 | + getItem() { | |
| 112 | + throw new Error("storage disabled"); | |
| 113 | + }, | |
| 114 | + setItem() {}, | |
| 115 | + }; | |
| 116 | + assert.deepEqual(loadBuildDraft(unreadable), DEFAULT_BUILD_DRAFT); | |
| 117 | +}); | |
| 118 | + | |
| 119 | +test("reports storage write failures without throwing", () => { | |
| 120 | + const unwritable: BuildStorage = { | |
| 121 | + getItem() { | |
| 122 | + return null; | |
| 123 | + }, | |
| 124 | + setItem() { | |
| 125 | + throw new Error("quota exceeded"); | |
| 126 | + }, | |
| 127 | + }; | |
| 128 | + | |
| 129 | + assert.equal(saveBuildDraft(unwritable, validBuild), false); | |
| 130 | +}); | |
| new file mode 100644 | |||
| @@ -0,0 +1,130 @@ | |||
| 1 | +import assert from "node:assert/strict"; | ||
| 2 | +import test from "node:test"; | ||
| 3 | +import { normalizeClassRanks } from "@/src/lib/engine"; | ||
| 4 | +import { | ||
| 5 | + BUILD_STORAGE_KEY, | ||
| 6 | + BUILD_STORAGE_VERSION, | ||
| 7 | + DEFAULT_BUILD_DRAFT, | ||
| 8 | + loadBuildDraft, | ||
| 9 | + saveBuildDraft, | ||
| 10 | + type BuildDraft, | ||
| 11 | + type BuildStorage, | ||
| 12 | +} from "./buildPersistence"; | ||
| 13 | + | ||
| 14 | +class MemoryStorage implements BuildStorage { | ||
| 15 | + readonly values = new Map<string, string>(); | ||
| 16 | + | ||
| 17 | + getItem(key: string): string | null { | ||
| 18 | + return this.values.get(key) ?? null; | ||
| 19 | + } | ||
| 20 | + | ||
| 21 | + setItem(key: string, value: string): void { | ||
| 22 | + this.values.set(key, value); | ||
| 23 | + } | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +const validBuild: BuildDraft = { | ||
| 27 | + avatarLevel: 62, | ||
| 28 | + activeClass: "full-metal-jaguar", | ||
| 29 | + team: ["Elma", "Lin", "Doug"], | ||
| 30 | + personalRanks: [12, 13, 14], | ||
| 31 | + classRanks: normalizeClassRanks({ | ||
| 32 | + drifter: 10, | ||
| 33 | + commando: 10, | ||
| 34 | + "winged-viper": 10, | ||
| 35 | + "full-metal-jaguar": 8, | ||
| 36 | + }), | ||
| 37 | + rangedWeapon: "Dual Guns", | ||
| 38 | + meleeWeapon: "Dual Swords", | ||
| 39 | + combatMode: "skell", | ||
| 40 | + hasSkellLicense: true, | ||
| 41 | + postgameFrames: true, | ||
| 42 | + assumeSpecialUnlocks: true, | ||
| 43 | +}; | ||
| 44 | + | ||
| 45 | +test("round-trips a valid build through versioned storage", () => { | ||
| 46 | + const storage = new MemoryStorage(); | ||
| 47 | + | ||
| 48 | + assert.equal(saveBuildDraft(storage, validBuild), true); | ||
| 49 | + assert.deepEqual(loadBuildDraft(storage), validBuild); | ||
| 50 | + | ||
| 51 | + const payload: unknown = JSON.parse( | ||
| 52 | + storage.values.get(BUILD_STORAGE_KEY) ?? "null", | ||
| 53 | + ); | ||
| 54 | + assert.ok(payload && typeof payload === "object" && "version" in payload); | ||
| 55 | + assert.equal(payload.version, BUILD_STORAGE_VERSION); | ||
| 56 | +}); | ||
| 57 | + | ||
| 58 | +test("sanitizes invalid persisted fields before restoring them", () => { | ||
| 59 | + const storage = new MemoryStorage(); | ||
| 60 | + storage.values.set( | ||
| 61 | + BUILD_STORAGE_KEY, | ||
| 62 | + JSON.stringify({ | ||
| 63 | + version: BUILD_STORAGE_VERSION, | ||
| 64 | + build: { | ||
| 65 | + avatarLevel: 120, | ||
| 66 | + activeClass: "unknown-class", | ||
| 67 | + team: ["Elma", "Elma", "Unknown companion"], | ||
| 68 | + personalRanks: [-10, 999, null], | ||
| 69 | + classRanks: { drifter: 4, duelist: -2, unknown: 10 }, | ||
| 70 | + rangedWeapon: "Monado", | ||
| 71 | + meleeWeapon: "Energy Sword", | ||
| 72 | + combatMode: "flight", | ||
| 73 | + hasSkellLicense: false, | ||
| 74 | + postgameFrames: true, | ||
| 75 | + assumeSpecialUnlocks: "yes", | ||
| 76 | + }, | ||
| 77 | + }), | ||
| 78 | + ); | ||
| 79 | + | ||
| 80 | + const build = loadBuildDraft(storage); | ||
| 81 | + | ||
| 82 | + assert.equal(build.avatarLevel, 99); | ||
| 83 | + assert.equal(build.activeClass, DEFAULT_BUILD_DRAFT.activeClass); | ||
| 84 | + assert.deepEqual(build.team, ["Elma", "", ""]); | ||
| 85 | + assert.deepEqual(build.personalRanks, [3, 20, 20]); | ||
| 86 | + assert.equal(build.classRanks.duelist, 1); | ||
| 87 | + assert.equal(build.classRanks.drifter, 10); | ||
| 88 | + assert.equal(build.classRanks.unknown, undefined); | ||
| 89 | + assert.equal(build.rangedWeapon, ""); | ||
| 90 | + assert.equal(build.meleeWeapon, ""); | ||
| 91 | + assert.equal(build.combatMode, "general"); | ||
| 92 | + assert.equal(build.hasSkellLicense, false); | ||
| 93 | + assert.equal(build.postgameFrames, false); | ||
| 94 | + assert.equal(build.assumeSpecialUnlocks, false); | ||
| 95 | +}); | ||
| 96 | + | ||
| 97 | +test("falls back safely for empty, malformed, unsupported, or unreadable storage", () => { | ||
| 98 | + const empty = new MemoryStorage(); | ||
| 99 | + assert.deepEqual(loadBuildDraft(empty), DEFAULT_BUILD_DRAFT); | ||
| 100 | + | ||
| 101 | + empty.values.set(BUILD_STORAGE_KEY, "not-json"); | ||
| 102 | + assert.deepEqual(loadBuildDraft(empty), DEFAULT_BUILD_DRAFT); | ||
| 103 | + | ||
| 104 | + empty.values.set( | ||
| 105 | + BUILD_STORAGE_KEY, | ||
| 106 | + JSON.stringify({ version: 999, build: validBuild }), | ||
| 107 | + ); | ||
| 108 | + assert.deepEqual(loadBuildDraft(empty), DEFAULT_BUILD_DRAFT); | ||
| 109 | + | ||
| 110 | + const unreadable: BuildStorage = { | ||
| 111 | + getItem() { | ||
| 112 | + throw new Error("storage disabled"); | ||
| 113 | + }, | ||
| 114 | + setItem() {}, | ||
| 115 | + }; | ||
| 116 | + assert.deepEqual(loadBuildDraft(unreadable), DEFAULT_BUILD_DRAFT); | ||
| 117 | +}); | ||
| 118 | + | ||
| 119 | +test("reports storage write failures without throwing", () => { | ||
| 120 | + const unwritable: BuildStorage = { | ||
| 121 | + getItem() { | ||
| 122 | + return null; | ||
| 123 | + }, | ||
| 124 | + setItem() { | ||
| 125 | + throw new Error("quota exceeded"); | ||
| 126 | + }, | ||
| 127 | + }; | ||
| 128 | + | ||
| 129 | + assert.equal(saveBuildDraft(unwritable, validBuild), false); | ||
| 130 | +}); | ||
added
src/lib/buildPersistence.ts +202 -0 | new file mode 100644 | ||
| @@ -0,0 +1,202 @@ | ||
| 1 | +import { classes } from "@/src/data/classes"; | |
| 2 | +import { companions } from "@/src/data/companions"; | |
| 3 | +import { legalAvatarWeaponPairs, normalizeClassRanks } from "@/src/lib/engine"; | |
| 4 | +import type { CombatMode } from "@/src/lib/types"; | |
| 5 | + | |
| 6 | +export const BUILD_STORAGE_VERSION = 1; | |
| 7 | +export const BUILD_STORAGE_KEY = "xeno-team-builder:build:v1"; | |
| 8 | + | |
| 9 | +export interface BuildStorage { | |
| 10 | + getItem(key: string): string | null; | |
| 11 | + setItem(key: string, value: string): void; | |
| 12 | +} | |
| 13 | + | |
| 14 | +export interface BuildDraft { | |
| 15 | + avatarLevel: number; | |
| 16 | + activeClass: string; | |
| 17 | + team: [string, string, string]; | |
| 18 | + personalRanks: [number, number, number]; | |
| 19 | + classRanks: Record<string, number>; | |
| 20 | + rangedWeapon: string; | |
| 21 | + meleeWeapon: string; | |
| 22 | + combatMode: CombatMode; | |
| 23 | + hasSkellLicense: boolean; | |
| 24 | + postgameFrames: boolean; | |
| 25 | + assumeSpecialUnlocks: boolean; | |
| 26 | +} | |
| 27 | + | |
| 28 | +const defaultClassRanks = normalizeClassRanks({ | |
| 29 | + drifter: 10, | |
| 30 | + striker: 10, | |
| 31 | + "samurai-gunner": 10, | |
| 32 | + duelist: 10, | |
| 33 | +}); | |
| 34 | + | |
| 35 | +export const DEFAULT_BUILD_DRAFT: BuildDraft = { | |
| 36 | + avatarLevel: 30, | |
| 37 | + activeClass: "duelist", | |
| 38 | + team: ["", "", ""], | |
| 39 | + personalRanks: [20, 20, 20], | |
| 40 | + classRanks: defaultClassRanks, | |
| 41 | + rangedWeapon: "", | |
| 42 | + meleeWeapon: "", | |
| 43 | + combatMode: "general", | |
| 44 | + hasSkellLicense: false, | |
| 45 | + postgameFrames: false, | |
| 46 | + assumeSpecialUnlocks: false, | |
| 47 | +}; | |
| 48 | + | |
| 49 | +const classIds = new Set(classes.map((entry) => entry.id)); | |
| 50 | +const companionByName = new Map( | |
| 51 | + companions.map((companion) => [companion.name, companion]), | |
| 52 | +); | |
| 53 | + | |
| 54 | +function isRecord(value: unknown): value is Record<string, unknown> { | |
| 55 | + return typeof value === "object" && value !== null && !Array.isArray(value); | |
| 56 | +} | |
| 57 | + | |
| 58 | +function clampInteger( | |
| 59 | + value: unknown, | |
| 60 | + minimum: number, | |
| 61 | + maximum: number, | |
| 62 | + fallback: number, | |
| 63 | +): number { | |
| 64 | + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; | |
| 65 | + return Math.max(minimum, Math.min(maximum, Math.trunc(value))); | |
| 66 | +} | |
| 67 | + | |
| 68 | +function cloneDefaultBuild(): BuildDraft { | |
| 69 | + return { | |
| 70 | + ...DEFAULT_BUILD_DRAFT, | |
| 71 | + team: [...DEFAULT_BUILD_DRAFT.team], | |
| 72 | + personalRanks: [...DEFAULT_BUILD_DRAFT.personalRanks], | |
| 73 | + classRanks: { ...DEFAULT_BUILD_DRAFT.classRanks }, | |
| 74 | + }; | |
| 75 | +} | |
| 76 | + | |
| 77 | +function sanitizeTeam(value: unknown): [string, string, string] { | |
| 78 | + const source = Array.isArray(value) ? value : DEFAULT_BUILD_DRAFT.team; | |
| 79 | + const seen = new Set<string>(); | |
| 80 | + | |
| 81 | + return [0, 1, 2].map((index) => { | |
| 82 | + const name = source[index]; | |
| 83 | + if ( | |
| 84 | + typeof name !== "string" || | |
| 85 | + !companionByName.has(name) || | |
| 86 | + seen.has(name) | |
| 87 | + ) | |
| 88 | + return ""; | |
| 89 | + seen.add(name); | |
| 90 | + return name; | |
| 91 | + }) as [string, string, string]; | |
| 92 | +} | |
| 93 | + | |
| 94 | +function sanitizeClassRanks( | |
| 95 | + value: unknown, | |
| 96 | + activeClass: string, | |
| 97 | +): Record<string, number> { | |
| 98 | + if (!isRecord(value)) { | |
| 99 | + if (activeClass === DEFAULT_BUILD_DRAFT.activeClass) | |
| 100 | + return { ...DEFAULT_BUILD_DRAFT.classRanks }; | |
| 101 | + return normalizeClassRanks({ [activeClass]: 1 }); | |
| 102 | + } | |
| 103 | + | |
| 104 | + const ranks: Record<string, number> = {}; | |
| 105 | + for (const classId of classIds) { | |
| 106 | + const rank = value[classId]; | |
| 107 | + if (typeof rank === "number" && Number.isFinite(rank)) | |
| 108 | + ranks[classId] = rank; | |
| 109 | + } | |
| 110 | + ranks[activeClass] = clampInteger(ranks[activeClass], 1, 10, 1); | |
| 111 | + return normalizeClassRanks(ranks); | |
| 112 | +} | |
| 113 | + | |
| 114 | +function sanitizeBuildDraft(value: unknown): BuildDraft { | |
| 115 | + if (!isRecord(value)) return cloneDefaultBuild(); | |
| 116 | + | |
| 117 | + const activeClass = | |
| 118 | + typeof value.activeClass === "string" && classIds.has(value.activeClass) | |
| 119 | + ? value.activeClass | |
| 120 | + : DEFAULT_BUILD_DRAFT.activeClass; | |
| 121 | + const classRanks = sanitizeClassRanks(value.classRanks, activeClass); | |
| 122 | + const team = sanitizeTeam(value.team); | |
| 123 | + const personalRankSource = Array.isArray(value.personalRanks) | |
| 124 | + ? value.personalRanks | |
| 125 | + : DEFAULT_BUILD_DRAFT.personalRanks; | |
| 126 | + const personalRanks = team.map((name, index) => { | |
| 127 | + const companion = companionByName.get(name); | |
| 128 | + return clampInteger( | |
| 129 | + personalRankSource[index], | |
| 130 | + companion?.startRank ?? 0, | |
| 131 | + companion?.maxRank ?? 20, | |
| 132 | + DEFAULT_BUILD_DRAFT.personalRanks[index], | |
| 133 | + ); | |
| 134 | + }) as [number, number, number]; | |
| 135 | + | |
| 136 | + const legalWeaponPairs = legalAvatarWeaponPairs(activeClass, classRanks); | |
| 137 | + const legalRangedWeapons = new Set( | |
| 138 | + legalWeaponPairs.map((pair) => pair.ranged), | |
| 139 | + ); | |
| 140 | + const legalMeleeWeapons = new Set(legalWeaponPairs.map((pair) => pair.melee)); | |
| 141 | + const hasSkellLicense = value.hasSkellLicense === true; | |
| 142 | + | |
| 143 | + return { | |
| 144 | + avatarLevel: clampInteger( | |
| 145 | + value.avatarLevel, | |
| 146 | + 1, | |
| 147 | + 99, | |
| 148 | + DEFAULT_BUILD_DRAFT.avatarLevel, | |
| 149 | + ), | |
| 150 | + activeClass, | |
| 151 | + team, | |
| 152 | + personalRanks, | |
| 153 | + classRanks, | |
| 154 | + rangedWeapon: | |
| 155 | + typeof value.rangedWeapon === "string" && | |
| 156 | + legalRangedWeapons.has(value.rangedWeapon) | |
| 157 | + ? value.rangedWeapon | |
| 158 | + : "", | |
| 159 | + meleeWeapon: | |
| 160 | + typeof value.meleeWeapon === "string" && | |
| 161 | + legalMeleeWeapons.has(value.meleeWeapon) | |
| 162 | + ? value.meleeWeapon | |
| 163 | + : "", | |
| 164 | + combatMode: value.combatMode === "skell" ? "skell" : "general", | |
| 165 | + hasSkellLicense, | |
| 166 | + postgameFrames: hasSkellLicense && value.postgameFrames === true, | |
| 167 | + assumeSpecialUnlocks: value.assumeSpecialUnlocks === true, | |
| 168 | + }; | |
| 169 | +} | |
| 170 | + | |
| 171 | +export function loadBuildDraft(storage: BuildStorage): BuildDraft { | |
| 172 | + try { | |
| 173 | + const serialized = storage.getItem(BUILD_STORAGE_KEY); | |
| 174 | + if (serialized === null) return cloneDefaultBuild(); | |
| 175 | + | |
| 176 | + const payload: unknown = JSON.parse(serialized); | |
| 177 | + if (!isRecord(payload) || payload.version !== BUILD_STORAGE_VERSION) { | |
| 178 | + return cloneDefaultBuild(); | |
| 179 | + } | |
| 180 | + return sanitizeBuildDraft(payload.build); | |
| 181 | + } catch { | |
| 182 | + return cloneDefaultBuild(); | |
| 183 | + } | |
| 184 | +} | |
| 185 | + | |
| 186 | +export function saveBuildDraft( | |
| 187 | + storage: BuildStorage, | |
| 188 | + build: BuildDraft, | |
| 189 | +): boolean { | |
| 190 | + try { | |
| 191 | + storage.setItem( | |
| 192 | + BUILD_STORAGE_KEY, | |
| 193 | + JSON.stringify({ | |
| 194 | + version: BUILD_STORAGE_VERSION, | |
| 195 | + build: sanitizeBuildDraft(build), | |
| 196 | + }), | |
| 197 | + ); | |
| 198 | + return true; | |
| 199 | + } catch { | |
| 200 | + return false; | |
| 201 | + } | |
| 202 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,202 @@ | |||
| 1 | +import { classes } from "@/src/data/classes"; | ||
| 2 | +import { companions } from "@/src/data/companions"; | ||
| 3 | +import { legalAvatarWeaponPairs, normalizeClassRanks } from "@/src/lib/engine"; | ||
| 4 | +import type { CombatMode } from "@/src/lib/types"; | ||
| 5 | + | ||
| 6 | +export const BUILD_STORAGE_VERSION = 1; | ||
| 7 | +export const BUILD_STORAGE_KEY = "xeno-team-builder:build:v1"; | ||
| 8 | + | ||
| 9 | +export interface BuildStorage { | ||
| 10 | + getItem(key: string): string | null; | ||
| 11 | + setItem(key: string, value: string): void; | ||
| 12 | +} | ||
| 13 | + | ||
| 14 | +export interface BuildDraft { | ||
| 15 | + avatarLevel: number; | ||
| 16 | + activeClass: string; | ||
| 17 | + team: [string, string, string]; | ||
| 18 | + personalRanks: [number, number, number]; | ||
| 19 | + classRanks: Record<string, number>; | ||
| 20 | + rangedWeapon: string; | ||
| 21 | + meleeWeapon: string; | ||
| 22 | + combatMode: CombatMode; | ||
| 23 | + hasSkellLicense: boolean; | ||
| 24 | + postgameFrames: boolean; | ||
| 25 | + assumeSpecialUnlocks: boolean; | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +const defaultClassRanks = normalizeClassRanks({ | ||
| 29 | + drifter: 10, | ||
| 30 | + striker: 10, | ||
| 31 | + "samurai-gunner": 10, | ||
| 32 | + duelist: 10, | ||
| 33 | +}); | ||
| 34 | + | ||
| 35 | +export const DEFAULT_BUILD_DRAFT: BuildDraft = { | ||
| 36 | + avatarLevel: 30, | ||
| 37 | + activeClass: "duelist", | ||
| 38 | + team: ["", "", ""], | ||
| 39 | + personalRanks: [20, 20, 20], | ||
| 40 | + classRanks: defaultClassRanks, | ||
| 41 | + rangedWeapon: "", | ||
| 42 | + meleeWeapon: "", | ||
| 43 | + combatMode: "general", | ||
| 44 | + hasSkellLicense: false, | ||
| 45 | + postgameFrames: false, | ||
| 46 | + assumeSpecialUnlocks: false, | ||
| 47 | +}; | ||
| 48 | + | ||
| 49 | +const classIds = new Set(classes.map((entry) => entry.id)); | ||
| 50 | +const companionByName = new Map( | ||
| 51 | + companions.map((companion) => [companion.name, companion]), | ||
| 52 | +); | ||
| 53 | + | ||
| 54 | +function isRecord(value: unknown): value is Record<string, unknown> { | ||
| 55 | + return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 56 | +} | ||
| 57 | + | ||
| 58 | +function clampInteger( | ||
| 59 | + value: unknown, | ||
| 60 | + minimum: number, | ||
| 61 | + maximum: number, | ||
| 62 | + fallback: number, | ||
| 63 | +): number { | ||
| 64 | + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; | ||
| 65 | + return Math.max(minimum, Math.min(maximum, Math.trunc(value))); | ||
| 66 | +} | ||
| 67 | + | ||
| 68 | +function cloneDefaultBuild(): BuildDraft { | ||
| 69 | + return { | ||
| 70 | + ...DEFAULT_BUILD_DRAFT, | ||
| 71 | + team: [...DEFAULT_BUILD_DRAFT.team], | ||
| 72 | + personalRanks: [...DEFAULT_BUILD_DRAFT.personalRanks], | ||
| 73 | + classRanks: { ...DEFAULT_BUILD_DRAFT.classRanks }, | ||
| 74 | + }; | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +function sanitizeTeam(value: unknown): [string, string, string] { | ||
| 78 | + const source = Array.isArray(value) ? value : DEFAULT_BUILD_DRAFT.team; | ||
| 79 | + const seen = new Set<string>(); | ||
| 80 | + | ||
| 81 | + return [0, 1, 2].map((index) => { | ||
| 82 | + const name = source[index]; | ||
| 83 | + if ( | ||
| 84 | + typeof name !== "string" || | ||
| 85 | + !companionByName.has(name) || | ||
| 86 | + seen.has(name) | ||
| 87 | + ) | ||
| 88 | + return ""; | ||
| 89 | + seen.add(name); | ||
| 90 | + return name; | ||
| 91 | + }) as [string, string, string]; | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +function sanitizeClassRanks( | ||
| 95 | + value: unknown, | ||
| 96 | + activeClass: string, | ||
| 97 | +): Record<string, number> { | ||
| 98 | + if (!isRecord(value)) { | ||
| 99 | + if (activeClass === DEFAULT_BUILD_DRAFT.activeClass) | ||
| 100 | + return { ...DEFAULT_BUILD_DRAFT.classRanks }; | ||
| 101 | + return normalizeClassRanks({ [activeClass]: 1 }); | ||
| 102 | + } | ||
| 103 | + | ||
| 104 | + const ranks: Record<string, number> = {}; | ||
| 105 | + for (const classId of classIds) { | ||
| 106 | + const rank = value[classId]; | ||
| 107 | + if (typeof rank === "number" && Number.isFinite(rank)) | ||
| 108 | + ranks[classId] = rank; | ||
| 109 | + } | ||
| 110 | + ranks[activeClass] = clampInteger(ranks[activeClass], 1, 10, 1); | ||
| 111 | + return normalizeClassRanks(ranks); | ||
| 112 | +} | ||
| 113 | + | ||
| 114 | +function sanitizeBuildDraft(value: unknown): BuildDraft { | ||
| 115 | + if (!isRecord(value)) return cloneDefaultBuild(); | ||
| 116 | + | ||
| 117 | + const activeClass = | ||
| 118 | + typeof value.activeClass === "string" && classIds.has(value.activeClass) | ||
| 119 | + ? value.activeClass | ||
| 120 | + : DEFAULT_BUILD_DRAFT.activeClass; | ||
| 121 | + const classRanks = sanitizeClassRanks(value.classRanks, activeClass); | ||
| 122 | + const team = sanitizeTeam(value.team); | ||
| 123 | + const personalRankSource = Array.isArray(value.personalRanks) | ||
| 124 | + ? value.personalRanks | ||
| 125 | + : DEFAULT_BUILD_DRAFT.personalRanks; | ||
| 126 | + const personalRanks = team.map((name, index) => { | ||
| 127 | + const companion = companionByName.get(name); | ||
| 128 | + return clampInteger( | ||
| 129 | + personalRankSource[index], | ||
| 130 | + companion?.startRank ?? 0, | ||
| 131 | + companion?.maxRank ?? 20, | ||
| 132 | + DEFAULT_BUILD_DRAFT.personalRanks[index], | ||
| 133 | + ); | ||
| 134 | + }) as [number, number, number]; | ||
| 135 | + | ||
| 136 | + const legalWeaponPairs = legalAvatarWeaponPairs(activeClass, classRanks); | ||
| 137 | + const legalRangedWeapons = new Set( | ||
| 138 | + legalWeaponPairs.map((pair) => pair.ranged), | ||
| 139 | + ); | ||
| 140 | + const legalMeleeWeapons = new Set(legalWeaponPairs.map((pair) => pair.melee)); | ||
| 141 | + const hasSkellLicense = value.hasSkellLicense === true; | ||
| 142 | + | ||
| 143 | + return { | ||
| 144 | + avatarLevel: clampInteger( | ||
| 145 | + value.avatarLevel, | ||
| 146 | + 1, | ||
| 147 | + 99, | ||
| 148 | + DEFAULT_BUILD_DRAFT.avatarLevel, | ||
| 149 | + ), | ||
| 150 | + activeClass, | ||
| 151 | + team, | ||
| 152 | + personalRanks, | ||
| 153 | + classRanks, | ||
| 154 | + rangedWeapon: | ||
| 155 | + typeof value.rangedWeapon === "string" && | ||
| 156 | + legalRangedWeapons.has(value.rangedWeapon) | ||
| 157 | + ? value.rangedWeapon | ||
| 158 | + : "", | ||
| 159 | + meleeWeapon: | ||
| 160 | + typeof value.meleeWeapon === "string" && | ||
| 161 | + legalMeleeWeapons.has(value.meleeWeapon) | ||
| 162 | + ? value.meleeWeapon | ||
| 163 | + : "", | ||
| 164 | + combatMode: value.combatMode === "skell" ? "skell" : "general", | ||
| 165 | + hasSkellLicense, | ||
| 166 | + postgameFrames: hasSkellLicense && value.postgameFrames === true, | ||
| 167 | + assumeSpecialUnlocks: value.assumeSpecialUnlocks === true, | ||
| 168 | + }; | ||
| 169 | +} | ||
| 170 | + | ||
| 171 | +export function loadBuildDraft(storage: BuildStorage): BuildDraft { | ||
| 172 | + try { | ||
| 173 | + const serialized = storage.getItem(BUILD_STORAGE_KEY); | ||
| 174 | + if (serialized === null) return cloneDefaultBuild(); | ||
| 175 | + | ||
| 176 | + const payload: unknown = JSON.parse(serialized); | ||
| 177 | + if (!isRecord(payload) || payload.version !== BUILD_STORAGE_VERSION) { | ||
| 178 | + return cloneDefaultBuild(); | ||
| 179 | + } | ||
| 180 | + return sanitizeBuildDraft(payload.build); | ||
| 181 | + } catch { | ||
| 182 | + return cloneDefaultBuild(); | ||
| 183 | + } | ||
| 184 | +} | ||
| 185 | + | ||
| 186 | +export function saveBuildDraft( | ||
| 187 | + storage: BuildStorage, | ||
| 188 | + build: BuildDraft, | ||
| 189 | +): boolean { | ||
| 190 | + try { | ||
| 191 | + storage.setItem( | ||
| 192 | + BUILD_STORAGE_KEY, | ||
| 193 | + JSON.stringify({ | ||
| 194 | + version: BUILD_STORAGE_VERSION, | ||
| 195 | + build: sanitizeBuildDraft(build), | ||
| 196 | + }), | ||
| 197 | + ); | ||
| 198 | + return true; | ||
| 199 | + } catch { | ||
| 200 | + return false; | ||
| 201 | + } | ||
| 202 | +} | ||