Route source-classified activities into an independent Miliastra lane and show dated battle passes once under Cycles. Genshin's About tab can disable TCG tracking without deleting progress or history. Activate routing and presentation together so no intermediate version moves saved progress while still rendering its old owner. Preserve identities, clock revisions and deletion timestamps across late writes and backups.
188 lines
5.5 KiB
TypeScript
188 lines
5.5 KiB
TypeScript
import { withReaderLock } from "./lock.ts";
|
|
import { refreshAutomaticClocks } from "./clock.ts";
|
|
import { reconcileLegacy } from "./legacy.ts";
|
|
import type { LaneId } from "../../../shared/custom.ts";
|
|
import { migrateLegacy } from "./migration.ts";
|
|
import {
|
|
ReaderStateSchema,
|
|
READER_KEYS,
|
|
type AccountId,
|
|
type ReaderState,
|
|
type SaveResult,
|
|
} from "./schema.ts";
|
|
import { reduceReader, type ReaderAction } from "./reducer.ts";
|
|
import { routeReaderContent } from "./contentRouting.ts";
|
|
export interface ReaderStore {
|
|
getSnapshot(): ReaderState;
|
|
subscribe(listener: () => void): () => void;
|
|
dispatch(action: ReaderAction): Promise<SaveResult>;
|
|
getError(): SaveResult | null;
|
|
dispose(): void;
|
|
}
|
|
export function tryWriteJson(
|
|
storage: Storage,
|
|
key: string,
|
|
value: unknown,
|
|
): SaveResult {
|
|
try {
|
|
storage.setItem(key, JSON.stringify(value));
|
|
return { ok: true };
|
|
} catch (e) {
|
|
return {
|
|
ok: false,
|
|
reason:
|
|
e instanceof DOMException && e.name === "QuotaExceededError"
|
|
? "quota"
|
|
: "unavailable",
|
|
};
|
|
}
|
|
}
|
|
export function readLegacy(storage: Storage): Record<string, string> {
|
|
const raw: Record<string, string> = {};
|
|
for (let i = 0; i < storage.length; i++) {
|
|
const key = storage.key(i);
|
|
if (key?.startsWith("gacha-tracker:v1:")) {
|
|
const value = storage.getItem(key);
|
|
if (value !== null) raw[key] = value;
|
|
}
|
|
}
|
|
return raw;
|
|
}
|
|
export function initializeReader(
|
|
storage: Storage,
|
|
games: readonly LaneId[],
|
|
at: string,
|
|
mint: () => AccountId,
|
|
): { ok: true; state: ReaderState } | { ok: false; reason: string } {
|
|
try {
|
|
const saved = storage.getItem(READER_KEYS.reader);
|
|
if (saved !== null) {
|
|
const parsed = ReaderStateSchema.safeParse(JSON.parse(saved));
|
|
if (!parsed.success) return { ok: false, reason: "invalid" };
|
|
const next = routeReaderContent(refreshAutomaticClocks(
|
|
reconcileLegacy(parsed.data, readLegacy(storage), at),
|
|
at,
|
|
));
|
|
if (next !== parsed.data) {
|
|
const result = tryWriteJson(storage, READER_KEYS.reader, next);
|
|
if (!result.ok) return result;
|
|
}
|
|
return { ok: true, state: next };
|
|
}
|
|
const raw = readLegacy(storage);
|
|
const recovered = tryWriteJson(storage, READER_KEYS.migrationRecovery, {
|
|
at,
|
|
legacy: raw,
|
|
});
|
|
if (!recovered.ok) return recovered;
|
|
const state = migrateLegacy(raw, games, at, mint);
|
|
const result = tryWriteJson(storage, READER_KEYS.reader, state);
|
|
return result.ok ? { ok: true, state } : result;
|
|
} catch {
|
|
return { ok: false, reason: "unavailable" };
|
|
}
|
|
}
|
|
export function createReaderStore(
|
|
storage: Storage,
|
|
initial: ReaderState,
|
|
): ReaderStore {
|
|
let state = initial,
|
|
error: SaveResult | null = null;
|
|
const listeners = new Set<() => void>();
|
|
let queued = Promise.resolve();
|
|
const notify = () => {
|
|
for (const f of listeners) f();
|
|
};
|
|
const persisted = (): ReaderState | null => {
|
|
const raw = storage.getItem(READER_KEYS.reader);
|
|
if (raw === null) return null;
|
|
const value = ReaderStateSchema.safeParse(JSON.parse(raw));
|
|
if (!value.success) throw new Error("invalid");
|
|
return value.data;
|
|
};
|
|
const apply = (action: ReaderAction): SaveResult => {
|
|
try {
|
|
const current = persisted() ?? state;
|
|
const next = routeReaderContent(reduceReader(current, action));
|
|
if (next === current) {
|
|
state = current;
|
|
notify();
|
|
return { ok: true };
|
|
}
|
|
const parsed = ReaderStateSchema.safeParse(next);
|
|
if (!parsed.success) return { ok: false, reason: "invalid" };
|
|
const result = tryWriteJson(storage, READER_KEYS.reader, next);
|
|
if (!result.ok) return result;
|
|
const confirmed = persisted();
|
|
if (
|
|
confirmed?.revision !== next.revision ||
|
|
!(confirmed.operations as string[] | undefined)?.includes(action.opId)
|
|
)
|
|
return { ok: false, reason: "conflict" };
|
|
state = next;
|
|
notify();
|
|
return result;
|
|
} catch (e) {
|
|
return {
|
|
ok: false,
|
|
reason:
|
|
e instanceof Error && e.message === "conflict"
|
|
? "conflict"
|
|
: e instanceof Error && e.message === "invalid"
|
|
? "invalid"
|
|
: "unavailable",
|
|
};
|
|
}
|
|
};
|
|
const onStorage = (e: StorageEvent) => {
|
|
if (e.key !== READER_KEYS.reader) return;
|
|
try {
|
|
const next = persisted();
|
|
if (next) {
|
|
state = next;
|
|
notify();
|
|
}
|
|
} catch {
|
|
error = { ok: false, reason: "invalid" };
|
|
notify();
|
|
}
|
|
};
|
|
if (typeof window !== "undefined")
|
|
window.addEventListener("storage", onStorage);
|
|
return {
|
|
getSnapshot: () => state,
|
|
getError: () => error,
|
|
subscribe: (f) => {
|
|
listeners.add(f);
|
|
return () => {
|
|
listeners.delete(f);
|
|
};
|
|
},
|
|
dispatch: (input) => {
|
|
const action: ReaderAction =
|
|
input.type === "account" && state.accounts[input.account.id]
|
|
? { ...input, baseAccount: state.accounts[input.account.id]! }
|
|
: input;
|
|
const run = async () => {
|
|
let result: SaveResult;
|
|
try {
|
|
result = await withReaderLock(() => apply(action));
|
|
} catch {
|
|
result = { ok: false, reason: "unavailable" };
|
|
}
|
|
error = result.ok ? null : result;
|
|
notify();
|
|
return result;
|
|
};
|
|
const result = queued.then(run, run);
|
|
queued = result.then(() => {});
|
|
return result;
|
|
},
|
|
dispose: () => {
|
|
if (typeof window !== "undefined")
|
|
window.removeEventListener("storage", onStorage);
|
|
listeners.clear();
|
|
},
|
|
};
|
|
}
|