import { initDb, query, transaction } from "./db.ts";
import {
type HeroProgression,
initialProgression,
parseProgression,
serializeProgression,
} from "./progression.ts";
export type ClassId = "maiden" | "witch" | "assassin";
export interface Hero {
userId: number;
name: string;
class: ClassId;
level: number;
anima: number;
progression: HeroProgression;
deck: string[];
collection: Record<string, number>;
}
interface HeroRow {
user_id: number | string;
name: string;
class: string;
level: number | string;
anima: number | string;
progression: string;
deck: string;
}
function parseDeck(value: string): string[] {
let deck: string[] = [];
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) {
deck = parsed.filter((c): c is string => typeof c === "string");
}
} catch {
deck = [];
}
return deck;
}
function rowToHero(row: HeroRow): Omit<Hero, "collection"> {
return {
userId: Number(row.user_id),
name: String(row.name),
class: String(row.class) as ClassId,
level: Number(row.level),
anima: Number(row.anima),
progression: parseProgression(row.progression, Number(row.level)),
deck: parseDeck(row.deck),
};
}
function cardCounts(cards: string[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const id of cards) counts[id] = (counts[id] ?? 0) + 1;
return counts;
}
async function getCollection(
userId: number,
deck: string[],
): Promise<Record<string, number>> {
let result = await query(
"SELECT card_id, quantity FROM card_collection WHERE user_id = ?",
[userId],
);
if (result.rows.length === 0 && deck.length > 0) {
const tx = await transaction();
try {
for (const [cardId, quantity] of Object.entries(cardCounts(deck))) {
await tx.execute({
sql:
`INSERT OR IGNORE INTO card_collection (user_id, card_id, quantity)
VALUES (?, ?, ?)`,
args: [userId, cardId, quantity],
});
}
await tx.commit();
} catch (error) {
await tx.rollback();
throw error;
} finally {
tx.close();
}
result = await query(
"SELECT card_id, quantity FROM card_collection WHERE user_id = ?",
[userId],
);
}
return Object.fromEntries(
result.rows.map((row) => [String(row.card_id), Number(row.quantity)]),
);
}
export async function getHero(userId: number): Promise<Hero | null> {
await initDb();
const result = await query("SELECT * FROM heroes WHERE user_id = ?", [
userId,
]);
const row = result.rows[0];
if (!row) return null;
const hero = rowToHero(row as unknown as HeroRow);
const serializedProgression = serializeProgression(hero.progression);
if (String(row.progression) !== serializedProgression) {
await query(
"UPDATE heroes SET progression = ? WHERE user_id = ? AND progression = ?",
[serializedProgression, userId, String(row.progression)],
);
}
return { ...hero, collection: await getCollection(userId, hero.deck) };
}
const CLASS_IDS: ClassId[] = ["maiden", "witch", "assassin"];
export async function createHero(
userId: number,
name: string,
klass: ClassId,
startingDeck: string[],
): Promise<Hero> {
await initDb();
await query(
`INSERT INTO heroes (user_id, name, class, level, anima, progression, deck)
VALUES (?, ?, ?, 1, 40, ?, ?)
ON CONFLICT(user_id) DO NOTHING`,
[
userId,
name,
klass,
serializeProgression(initialProgression()),
JSON.stringify(startingDeck),
],
);
const hero = await getHero(userId);
if (!hero) throw new Error("Failed to create hero");
return hero;
}
export async function saveHeroProgress(
userId: number,
progress: {
anima: number;
level: number;
progression: HeroProgression;
deck: string[];
expectedAnima: number;
expectedLevel: number;
expectedProgression: HeroProgression;
expectedDeck: string[];
},
): Promise<boolean> {
await initDb();
const anima = Math.max(0, Math.floor(progress.anima));
const level = Math.max(1, Math.min(999, Math.floor(progress.level)));
const deck = progress.deck
.filter((c) => typeof c === "string" && c.length < 64)
.slice(0, 80);
await getCollection(userId, progress.expectedDeck);
const previousCounts = cardCounts(progress.expectedDeck);
const nextCounts = cardCounts(deck);
const cardIds = new Set([
...Object.keys(previousCounts),
...Object.keys(nextCounts),
]);
const tx = await transaction();
try {
const update = await tx.execute({
sql: `UPDATE heroes SET anima = ?, level = ?, progression = ?, deck = ?
WHERE user_id = ? AND anima = ? AND level = ? AND progression = ? AND deck = ?`,
args: [
anima,
level,
serializeProgression(progress.progression),
JSON.stringify(deck),
userId,
progress.expectedAnima,
progress.expectedLevel,
serializeProgression(progress.expectedProgression),
JSON.stringify(progress.expectedDeck),
],
});
if (update.rowsAffected !== 1) {
await tx.rollback();
return false;
}
for (const cardId of cardIds) {
const delta = (nextCounts[cardId] ?? 0) - (previousCounts[cardId] ?? 0);
if (delta > 0) {
await tx.execute({
sql: `INSERT INTO card_collection (user_id, card_id, quantity)
VALUES (?, ?, ?)
ON CONFLICT(user_id, card_id)
DO UPDATE SET quantity = quantity + excluded.quantity`,
args: [userId, cardId, delta],
});
} else if (delta < 0) {
await tx.execute({
sql: `UPDATE card_collection
SET quantity = MAX(0, quantity + ?)
WHERE user_id = ? AND card_id = ?`,
args: [delta, userId, cardId],
});
}
}
await tx.commit();
return true;
} catch (error) {
await tx.rollback();
throw error;
} finally {
tx.close();
}
}
export async function saveHeroDeck(
userId: number,
deck: string[],
): Promise<boolean> {
await initDb();
const requested = cardCounts(deck);
const tx = await transaction();
try {
const result = await tx.execute({
sql: "SELECT card_id, quantity FROM card_collection WHERE user_id = ?",
args: [userId],
});
const owned = Object.fromEntries(
result.rows.map((row) => [String(row.card_id), Number(row.quantity)]),
);
if (
Object.entries(requested).some(([id, count]) => count > (owned[id] ?? 0))
) {
await tx.rollback();
return false;
}
await tx.execute({
sql: "UPDATE heroes SET deck = ? WHERE user_id = ?",
args: [JSON.stringify(deck), userId],
});
await tx.commit();
return true;
} catch (error) {
await tx.rollback();
throw error;
} finally {
tx.close();
}
}
export function isClassId(value: unknown): value is ClassId {
return typeof value === "string" && CLASS_IDS.includes(value as ClassId);
}