anthodev/xeno-team-builderpublic Fork
⑂ 67df298
Commits
⬇ Clone ▾
git clone https://git.rickub.com/anthodev/xeno-team-builder.git
git clone ssh://git@rickub.com/anthodev/xeno-team-builder.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat: autosave current build to localStorage

Cedric ANTHONY committed 2026-08-11T20:24:09+02:00 Browse files
67df298 parent: 2865d59
modified .gitignore +1 -0
@@ -41,4 +41,5 @@ next-env.d.ts
4141 /outputs/
4242 /work/
4343
44+docs/
4445 .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 {
1414 classRoute,
1515 editClassRank,
1616 legalAvatarWeaponPairs,
17- normalizeClassRanks,
1817 } from "@/src/lib/engine";
18+import {
19+ DEFAULT_BUILD_DRAFT,
20+ loadBuildDraft,
21+ saveBuildDraft,
22+ type BuildDraft,
23+} from "@/src/lib/buildPersistence";
1924 import type { CombatMode, Lang, WeaponOverrides } from "@/src/lib/types";
2025
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+
2144 export default function TeamBuilder() {
2245 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,
2850 ]);
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+ }));
3759 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+ }, []);
4488
4589 const t = copy[lang];
4690 useEffect(() => {
@@ -74,6 +118,47 @@ export default function TeamBuilder() {
74118 ...(selectedMeleeWeapon ? { melee: selectedMeleeWeapon } : {}),
75119 };
76120 }, [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]);
77162 const contextualResult = useMemo(
78163 () =>
79164 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+}