Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 43 additions & 7 deletions lib/achievement-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,49 @@ const ACHIEVEMENT_NAMES: Record<AchievementId, string> = {
phaselq_100: "PHASELQ ×100",
};

/** Stellar G-addresses are case-insensitive on input; store them under one canonical uppercase key. */
function walletKey(wallet: string): string {
return wallet.trim().toUpperCase();
}

function mergeEntries(
a: WalletAchievements,
b: WalletAchievements,
): WalletAchievements {
const byId = new Map<AchievementId, Achievement>();
for (const ach of [...a.unlocked, ...b.unlocked]) {
const prev = byId.get(ach.id);
if (!prev || ach.unlocked_at < prev.unlocked_at) byId.set(ach.id, ach);
}
const max = (x?: number, y?: number) =>
x === undefined ? y : y === undefined ? x : Math.max(x, y);
const latest = (b.last_daily ?? 0) > (a.last_daily ?? 0) ? b : a;
return {
unlocked: [...byId.values()],
mint_count: max(a.mint_count, b.mint_count),
daily_streak: latest.daily_streak,
last_daily: latest.last_daily,
total_upvotes: max(a.total_upvotes, b.total_upvotes),
follower_count: max(a.follower_count, b.follower_count),
narrator_count: max(a.narrator_count, b.narrator_count),
};
}

async function readStore(): Promise<AchievementStore> {
let raw: AchievementStore;
try {
return JSON.parse(
raw = JSON.parse(
await readFile(serverDataJsonPath("achievements"), "utf8"),
) as AchievementStore;
} catch {
return {};
}
const store: AchievementStore = {};
for (const [wallet, entry] of Object.entries(raw)) {
const key = walletKey(wallet);
store[key] = store[key] ? mergeEntries(store[key]!, entry) : entry;
}
return store;
}

async function writeStore(data: AchievementStore): Promise<void> {
Expand All @@ -71,20 +106,21 @@ function ensureEntry(
store: AchievementStore,
wallet: string,
): WalletAchievements {
if (!store[wallet]) store[wallet] = { unlocked: [] };
return store[wallet]!;
const key = walletKey(wallet);
if (!store[key]) store[key] = { unlocked: [] };
return store[key]!;
}

export async function getAchievements(wallet: string): Promise<Achievement[]> {
const store = await readStore();
return store[wallet]?.unlocked ?? [];
return store[walletKey(wallet)]?.unlocked ?? [];
}

export async function getWalletData(
wallet: string,
): Promise<WalletAchievements> {
const store = await readStore();
return store[wallet] ?? { unlocked: [] };
return store[walletKey(wallet)] ?? { unlocked: [] };
}

export async function unlockAchievement(
Expand All @@ -96,7 +132,7 @@ export async function unlockAchievement(
const entry = ensureEntry(store, wallet);
if (entry.unlocked.some((a) => a.id === id)) return false; // idempotent
entry.unlocked.push({ id, unlocked_at: Date.now(), tx_evidence: evidence });
store[wallet] = entry;
store[walletKey(wallet)] = entry;
await writeStore(store);
// Notify (fire-and-forget)
void createNotification(wallet, "achievement_unlocked", {
Expand Down Expand Up @@ -190,7 +226,7 @@ export async function checkAndUnlock(
await tryUnlock("phaselq_100");
}

store[wallet] = entry;
store[walletKey(wallet)] = entry;
await writeStore(store);
return newUnlocks;
}
Expand Down
58 changes: 58 additions & 0 deletions tests/achievement-wallet-case.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* #279: achievement-store must key wallets case-insensitively (G... vs g...)
* Run: npx tsx tests/achievement-wallet-case.test.ts
*/
import assert from "node:assert/strict"
import { mkdtemp, readFile, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"

const WALLET = "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H"

async function main() {
const dir = await mkdtemp(path.join(os.tmpdir(), "phase-ach-"))
process.env.PHASE_SERVER_DATA_DIR = dir
const file = path.join(dir, "achievements.json")

const { checkAndUnlock, getAchievements, getWalletData, unlockAchievement } =
await import("@/lib/achievement-store")

// Writes via lowercase and uppercase land in one entry
await unlockAchievement(WALLET.toLowerCase(), "first_mint")
assert.equal(await unlockAchievement(WALLET, "first_mint"), false)
await checkAndUnlock(` ${WALLET.toLowerCase()} `, { signal_posted: true })
const stored = JSON.parse(await readFile(file, "utf8"))
assert.deepEqual(Object.keys(stored), [WALLET])
assert.deepEqual(
(await getAchievements(WALLET.toLowerCase())).map((a) => a.id).sort(),
["first_mint", "signal_pioneer"],
)
console.log("✓ G... and g... resolve to one entry")

// Legacy split entries are merged on read
await writeFile(
file,
JSON.stringify({
[WALLET]: { unlocked: [{ id: "first_mint", unlocked_at: 200 }], mint_count: 2 },
[WALLET.toLowerCase()]: {
unlocked: [
{ id: "first_mint", unlocked_at: 100 },
{ id: "collector_5", unlocked_at: 150 },
],
mint_count: 5,
},
}),
)
const merged = await getWalletData(WALLET)
assert.equal(merged.mint_count, 5)
assert.deepEqual(
merged.unlocked.map((a) => [a.id, a.unlocked_at]).sort(),
[["collector_5", 150], ["first_mint", 100]],
)
console.log("✓ legacy mixed-case entries merge")
}

main().catch((err) => {
console.error(err)
process.exit(1)
})
Loading