SOURCE / PINNED RELEASE
Made of little things.
Browser Side Quest
- Release
- b9d42f5768e4…
- Author-recorded commit
- c267690145fa…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/browser-side-quest
Archive hash verified: 6d4b90b8a5f328f7…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Port of BrowserQuest client/js/storage.js (MPL-2.0).
// Same data shape as the original localStorage blob, persisted through the
// napplet SDK storage domain. Without that domain progress lives in memory only.
import { STORAGE_DOMAIN, storage } from '@napplet/sdk';
import { runtimeHasDomain } from '../domain-availability.js';
const KEY = 'bq-data';
export interface SaveData {
hasAlreadyPlayed: boolean;
player: { name: string; weapon: string; armor: string; image: string };
achievements: {
unlocked: number[];
ratCount: number;
skeletonCount: number;
totalKills: number;
totalDmg: number;
totalRevives: number;
};
}
const freshData = (): SaveData => ({
hasAlreadyPlayed: false,
player: { name: '', weapon: '', armor: '', image: '' },
achievements: {
unlocked: [],
ratCount: 0,
skeletonCount: 0,
totalKills: 0,
totalDmg: 0,
totalRevives: 0,
},
});
function isSaveData(value: unknown): value is SaveData {
const v = value as SaveData;
return (
!!v &&
typeof v === 'object' &&
typeof v.hasAlreadyPlayed === 'boolean' &&
!!v.player &&
typeof v.player.name === 'string' &&
!!v.achievements &&
Array.isArray(v.achievements.unlocked)
);
}
export class GameStorage {
data: SaveData = freshData();
persistent = false;
lastError: string | null = null;
private pending: Promise<void> = Promise.resolve();
/** Load the saved character. Never throws: falls back to a fresh in-memory save. */
async load(): Promise<void> {
this.persistent = runtimeHasDomain(STORAGE_DOMAIN);
if (!this.persistent) return;
try {
const raw = await storage.getItem(KEY);
if (raw) {
const parsed: unknown = JSON.parse(raw);
if (isSaveData(parsed)) this.data = { ...freshData(), ...parsed, achievements: { ...freshData().achievements, ...parsed.achievements } };
}
} catch (error) {
this.lastError = error instanceof Error ? error.message : String(error);
}
}
/** Writes are serialized so a slow write can never land after a newer one. */
save(): void {
if (!this.persistent) return;
const snapshot = JSON.stringify(this.data);
this.pending = this.pending.then(async () => {
try {
await storage.setItem(KEY, snapshot);
this.lastError = null;
} catch (error) {
this.lastError = error instanceof Error ? error.message : String(error);
}
});
}
clear(): void {
this.data = freshData();
if (!this.persistent) return;
this.pending = this.pending.then(async () => {
try {
await storage.removeItem(KEY);
} catch (error) {
this.lastError = error instanceof Error ? error.message : String(error);
}
});
}
hasAlreadyPlayed(): boolean {
return this.data.hasAlreadyPlayed;
}
initPlayer(name: string): void {
this.data.hasAlreadyPlayed = true;
this.setPlayerName(name);
}
setPlayerName(name: string): void {
this.data.player.name = name;
this.save();
}
savePlayer(img: string, armor: string, weapon: string): void {
this.data.player.image = img;
this.data.player.armor = armor;
this.data.player.weapon = weapon;
this.save();
}
hasUnlockedAchievement(id: number): boolean {
return this.data.achievements.unlocked.includes(id);
}
unlockAchievement(id: number): boolean {
if (!this.hasUnlockedAchievement(id)) {
this.data.achievements.unlocked.push(id);
this.save();
return true;
}
return false;
}
getAchievementCount(): number {
return this.data.achievements.unlocked.length;
}
getRatCount(): number {
return this.data.achievements.ratCount;
}
incrementRatCount(): void {
if (this.data.achievements.ratCount < 10) {
this.data.achievements.ratCount++;
this.save();
}
}
getSkeletonCount(): number {
return this.data.achievements.skeletonCount;
}
incrementSkeletonCount(): void {
if (this.data.achievements.skeletonCount < 10) {
this.data.achievements.skeletonCount++;
this.save();
}
}
getTotalDamageTaken(): number {
return this.data.achievements.totalDmg;
}
addDamage(damage: number): void {
if (this.data.achievements.totalDmg < 5000) {
this.data.achievements.totalDmg += damage;
this.save();
}
}
getTotalKills(): number {
return this.data.achievements.totalKills;
}
incrementTotalKills(): void {
if (this.data.achievements.totalKills < 50) {
this.data.achievements.totalKills++;
this.save();
}
}
getTotalRevives(): number {
return this.data.achievements.totalRevives;
}
incrementRevives(): void {
if (this.data.achievements.totalRevives < 5) {
this.data.achievements.totalRevives++;
this.save();
}
}
}
