Back to Browser Side Quest
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.

src/client/storage.ts
// 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();
    }
  }
}