initial commit

This commit is contained in:
Robert Perce 2025-05-15 07:42:32 -05:00
commit 803205ee7f
59 changed files with 3437 additions and 0 deletions

182
src/lib/Model.ts Normal file
View file

@ -0,0 +1,182 @@
import "reflect-metadata";
import { Database } from "bun:sqlite";
const fieldsKey = Symbol.for("custom:fields");
const tableKey = Symbol.for("custom:table");
type Sqlable = null | number | string | boolean | bigint;
export function column<This extends Object>(columnName?: string) {
return function (instance: This, propertyName: string) {
const col = columnName ?? propertyName;
const columns = Reflect.getMetadata(fieldsKey, instance.constructor) ?? {};
columns[propertyName] = col;
Reflect.defineMetadata(fieldsKey, columns, instance.constructor);
};
}
export function table(tableName: string) {
return Reflect.metadata(tableKey, tableName);
}
export abstract class Model {
private static _db: Database;
public static get db() {
return (Model._db ??= new Database("file.sqlite3"));
}
public loaded = false;
public constructor(public readonly id: DbId) {}
load() {
const table = Reflect.getMetadata(tableKey, this.constructor);
const fields = Object.entries(
Reflect.getMetadata(fieldsKey, this.constructor),
);
const columns = fields.map(([_, colname]) => colname);
const results = Model.db
.prepare(`select ${columns.join(",")} from ${table} where id=?`)
.values(this.id);
if (results.length === 0) throw new Error("invalid id");
fields.forEach(([property], idx) => {
// @ts-ignore
this[property] = results[0][idx];
});
this.loaded = true;
}
save() {
const table = Reflect.getMetadata(tableKey, this.constructor);
const fields = Object.entries(
Reflect.getMetadata(fieldsKey, this.constructor),
);
const columns = fields.map(([_, colname]) => colname);
Model.db
.prepare(
`update ${table} set ${columns.map((c) => `${c}=?`).join(",")} where id=?`,
)
.run(
...fields.map(
([property]) => this[property as keyof typeof this] as Sqlable,
),
this.id,
);
return this;
}
static builder(Ctor: Function, obj: { [key: string]: Sqlable }) {
const table = Reflect.getMetadata(tableKey, Ctor);
const fields = Object.entries(Reflect.getMetadata(fieldsKey, Ctor));
const columns = fields.map(([_, colname]) => colname);
return {
...obj,
save() {
const { lastInsertRowid } = Model.db
.prepare(
`insert into ${table}(${columns.join(",")}) values (${columns.map((_) => "?").join(",")})`,
)
.run(
...fields.map(
([property]) => obj[property as keyof typeof obj] as Sqlable,
),
);
return lastInsertRowid;
},
};
}
static load(_id: DbId) {
throw new Error("only implemented in concrete subclasses");
}
static requiredFields: {} | null;
}
export const loadInterceptor = {
get<T extends Model>(target: T, property: string) {
if (property === "id") return target.id;
if (!target.loaded) target.load();
return target[property as keyof T];
},
set<T extends Model>(target: T, property: string, value: T[keyof T]) {
if (!target.loaded) target.load();
target[property as keyof T] = value;
return true;
},
};
export function create<
T extends Model,
const U extends { [key: string]: Sqlable },
>(
Ctor: (new (id: DbId) => T) & { requiredFields: U | null },
obj: Exclude<typeof Ctor.requiredFields, null>,
): T {
const id = Model.builder(Ctor, obj).save();
return new Proxy<T>(new Ctor(id), loadInterceptor);
}
export function findMany<
T extends Model,
const U extends { [key: string]: Sqlable },
>(
Ctor: (new (id: DbId) => T) & { requiredFields: U | null },
obj: Partial<Exclude<typeof Ctor.requiredFields, null>> = {},
): T[] {
const table = Reflect.getMetadata(tableKey, Ctor);
const fields = Reflect.getMetadata(fieldsKey, Ctor);
const properties = Object.keys(obj)
.filter((key) => key in fields)
.filter((key) => obj[key] !== undefined);
const columns = properties.map((prop) => fields[prop]);
const values = properties.map((prop) => obj[prop] as Sqlable);
const whereClause =
properties.length === 0
? ""
: ` where ${columns.map((c) => `${c}=?`).join(" and ")}`;
const ids = Model.db
.prepare(`select id from ${table}${whereClause}`)
.values(...values);
return ids.map(([id]) => new Proxy<T>(new Ctor(id as DbId), loadInterceptor));
}
export function find<
T extends Model,
const U extends { [key: string]: Sqlable },
>(
Ctor: (new (id: DbId) => T) & { requiredFields: U | null },
obj: Partial<Exclude<typeof Ctor.requiredFields, null>>,
): T {
const matches = findMany(Ctor, obj);
if (matches.length !== 1) {
throw new Error("expected exactly one match; found " + matches.length);
}
return matches[0];
}
export function findOrCreate<
T extends Model,
const U extends { [key: string]: Sqlable },
>(
Ctor: (new (id: DbId) => T) & { requiredFields: U | null },
findObj: Partial<Exclude<typeof Ctor.requiredFields, null>>,
addlCreateObj: Partial<Exclude<typeof Ctor.requiredFields, null>>,
): T {
try {
return find(Ctor, findObj);
} catch (e: any) {
if (!e.message.startsWith("expected exactly")) throw e;
}
const fullObj = { ...findObj, ...addlCreateObj };
return create(Ctor, fullObj as Exclude<typeof Ctor.requiredFields, null>);
}

18
src/lib/Page.ts Normal file
View file

@ -0,0 +1,18 @@
import { Model, column, table, loadInterceptor } from "@/lib/Model";
@table("pages")
export class Page extends Model {
@column() public name!: string;
@column() public route!: string;
@column("reset_schedule") public resetSchedule!: string;
static load(id: DbId): Page {
return new Proxy<Page>(new Page(id), loadInterceptor);
}
static requiredFields: {
name: string;
route: string;
resetSchedule: string;
} | null = null;
}

59
src/lib/Quest.spec.ts Normal file
View file

@ -0,0 +1,59 @@
import { describe, test, expect } from "bun:test";
import { Database } from "bun:sqlite";
import { squish } from "./tags";
import { Model } from "./Model";
import { Quest } from "./Quest";
import { Questline } from "./Questline";
describe("Quest", () => {
test("can create and save", () => {
Model["_db"] = new Database(":memory:");
Model.db.exec("PRAGMA journal_mode = WAL;");
Model.db.exec("PRAGMA foreign_keys;");
Model.db
.prepare(
squish`
create table if not exists questlines(
id integer primary key autoincrement,
name text
)
`,
)
.run();
Model.db
.prepare(
squish`
create table if not exists quests(
id integer primary key autoincrement,
coins_reward number,
reward text,
questline_id integer,
completed bool,
claimed bool,
foreign key(questline_id) references questlines(id)
)
`,
)
.run();
Model.create(Questline, { name: "Dailies" });
const quest = Model.create(Quest, {
coinsReward: 10,
questlineId: 1,
completed: false,
claimed: false,
});
const sameQuest = Model.find(Quest, { questlineId: 1 });
sameQuest.coinsReward = 20;
sameQuest.save();
quest.load();
expect(quest.coinsReward).toEqual(20);
});
});

41
src/lib/Quest.ts Normal file
View file

@ -0,0 +1,41 @@
import { type Slug, Slugs } from "@/lib/slug";
import { Model, table, column, loadInterceptor } from "@/lib/Model";
import { Questline } from "@/lib/Questline";
@table("quests")
export class Quest extends Model {
@column() public name!: string;
@column() public completed!: boolean;
@column() public claimed!: boolean;
@column("sort_order") public sortOrder: number | undefined;
@column("coins_reward") public coinsReward!: number;
@column("questline_id") public questlineId!: DbId;
public readonly slug: Slug;
public constructor(id: DbId) {
super(id);
this.slug = Slugs.encode(this.id);
}
public get questline() {
return new Questline(this.questlineId);
}
///
static load(id: DbId): Quest {
return new Proxy<Quest>(new Quest(id), loadInterceptor);
}
static requiredFields: {
name: string;
coinsReward: number;
questlineId: DbId;
completed: boolean;
claimed: boolean;
} | null = null;
static build(obj: Exclude<typeof Quest.requiredFields, null>) {
return Model.builder(this, obj);
}
}

20
src/lib/Questline.ts Normal file
View file

@ -0,0 +1,20 @@
import { Model, column, table, loadInterceptor } from "@/lib/Model";
@table("questlines")
export class Questline extends Model {
@column() public name!: string;
@column() public claimed!: boolean;
@column("coins_reward") public coinsReward!: number;
@column("page_id") public pageId!: number;
static load(id: DbId): Questline {
return new Proxy<Questline>(new Questline(id), loadInterceptor);
}
static requiredFields: {
name: string;
claimed: boolean;
coinsReward: number;
pageId: DbId;
} | null = null;
}

5
src/lib/Reward.ts Normal file
View file

@ -0,0 +1,5 @@
export const RewardState = {
deleted: 0,
inactive: 1,
active: 2,
};

6
src/lib/auth.ts Normal file
View file

@ -0,0 +1,6 @@
import { betterAuth } from "better-auth";
import { Database } from "bun:sqlite";
export const auth = betterAuth({
database: new Database("./auth.db"),
})

1
src/lib/common.d.ts vendored Normal file
View file

@ -0,0 +1 @@
type DbId = number | bigint;

169
src/lib/db_init.ts Normal file
View file

@ -0,0 +1,169 @@
import { Model, findOrCreate } from "./Model";
import { Questline } from "./Questline";
import { Quest } from "./Quest";
import { Page } from "./Page";
import { squish } from "./tags";
export default {
name: "db init",
hooks: {
"astro:server:setup": () => {
Model.db.exec("PRAGMA journal_mode = WAL;");
Model.db.exec("PRAGMA foreign_keys;");
const sql = (str: string) => Model.db.prepare(str).run();
sql(squish`
create table if not exists pages(
id integer primary key autoincrement,
name text unique,
route text unique,
reset_schedule text
)
`);
sql(squish`
create table if not exists questlines(
id integer primary key autoincrement,
coins_reward integer,
claimed bool,
name text unique,
page_id integer,
foreign key(page_id) references page(id)
)
`);
sql(squish`
create table if not exists quests(
id integer primary key autoincrement,
name text,
coins_reward integer,
questline_id integer,
completed bool,
claimed bool,
sort_order integer,
foreign key(questline_id) references questlines(id)
unique(name, questline_id)
)
`);
sql(squish`
create table if not exists events(
id integer primary key autoincrement,
record_time datetime not null default current_timestamp,
kind text,
quest_id integer,
questline_id integer,
coins_claimed integer,
coins_spent integer,
gems_claimed integer,
gems_spent integer,
reward_id integer,
foreign key(quest_id) references quests(id)
foreign key(questline_id) references questlines(id)
foreign key(reward_id) references rewards(id)
)
`);
sql(squish`
create table if not exists rewards(
id integer primary key autoincrement,
state integer not null default 2,
name text,
link text,
cents integer,
gems_cost integer,
rarity real,
chance real,
sort_order integer
);
`);
sql(squish`
create table if not exists inventory(
id integer primary key autoincrement,
reward_id integer,
state text,
foreign key(reward_id) references rewards(id)
)
`);
sql(squish`
create trigger if not exists insert_reward_chances after insert on rewards
begin
update rewards set gems_cost = cents * 2 where id = NEW.id;
update rewards set rarity = 100 / pow(gems_cost / 100, 2) where id = NEW.id;
update rewards set chance = rarity / (select sum(rarity) from rewards);
end
`);
sql(squish`
create trigger if not exists update_reward_chances after update of cents on rewards
begin
update rewards set gems_cost = cents * 2 where id = NEW.id;
update rewards set rarity = 100 / pow(gems_cost / 100, 2) where id = NEW.id;
update rewards set chance = rarity / (select sum(rarity) from rewards);
end
`);
sql(squish`
create trigger if not exists pull_rewards_to_inventory after update of reward_id on events
when NEW.kind = 'gacha_pull'
begin
insert into inventory (reward_id) values (NEW.reward_id);
end
`);
let dailyPageId = findOrCreate(Page, { name: "Daily", route: "" }, {}).id;
let questlineId = findOrCreate(
Questline,
{ name: "Mornings", pageId: dailyPageId },
{ coinsReward: 20, claimed: false },
).id;
findOrCreate(
Quest,
{ name: "Bed", questlineId },
{ coinsReward: 10, completed: false, claimed: false },
);
findOrCreate(
Quest,
{ name: "Brush", questlineId },
{ coinsReward: 10, completed: false, claimed: false },
);
findOrCreate(
Quest,
{ name: "Laundry", questlineId },
{ coinsReward: 15, completed: false, claimed: false },
);
findOrCreate(
Quest,
{ name: "Outside", questlineId },
{ coinsReward: 20, completed: false, claimed: false },
);
findOrCreate(
Quest,
{ name: "Yoga", questlineId },
{ coinsReward: 25, completed: false, claimed: false },
);
questlineId = findOrCreate(
Questline,
{ name: "Dailies", pageId: dailyPageId },
{ coinsReward: 20, claimed: false },
).id;
findOrCreate(
Quest,
{ name: "Cleandish", questlineId },
{ coinsReward: 15, completed: false, claimed: false },
);
findOrCreate(
Quest,
{ name: "Floss", questlineId },
{ coinsReward: 10, completed: false, claimed: false },
);
findOrCreate(
Quest,
{ name: "Read-a-page", questlineId },
{ coinsReward: 5, completed: false, claimed: false },
);
findOrCreate(Page, { name: "Main Room", route: "main" }, {});
},
},
};

7
src/lib/gacha_pull.ts Normal file
View file

@ -0,0 +1,7 @@
export const pullChances = [
{ chance: 0.4030, gems: 25, cdf: 0.4030, name: 'common' },
{ chance: 0.2970, gems: 80, cdf: 0.7000, name: 'rare' },
{ chance: 0.1775, gems: 135, cdf: 0.8775, name: 'epic' },
{ chance: 0.0725, gems: 280, cdf: 0.9500, name: 'legendary' },
{ chance: 0.0500, cdf: 1.0000, name: 'reward' },
] as const;

39
src/lib/slug.ts Normal file
View file

@ -0,0 +1,39 @@
import Sqids from "sqids";
export type Slug = string & { readonly __tag: unique symbol };
export class Slugs {
private static instance: Slugs;
private sqids!: Sqids;
private constructor() {
if (Slugs.instance) return Slugs.instance;
Slugs.instance = this;
this.sqids = new Sqids({
alphabet: "dbnixSPYJNy5hvsqCWIHwEKTFutV6r4Xa32okL8RQUj7Am9DcpgfZezMBG",
});
}
static encode(num: number | bigint): Slug {
const sqids = new Slugs().sqids;
const thirtyTwoBitMax = BigInt(0xffffffff);
if (typeof num === "bigint" && num > thirtyTwoBitMax) {
const arr = [];
while (num > 0) {
arr.push(Number(num & thirtyTwoBitMax));
num >>= 32n;
}
return sqids.encode(arr) as Slug;
} else {
return sqids.encode([Number(num)]) as Slug;
}
}
static decode(slug: Slug): number | bigint {
const ns = new Slugs().sqids.decode(slug);
if (ns.length === 1) return ns[0];
return ns.reduceRight((big, each) => (big << 32n) + BigInt(each), 0n);
}
}

17
src/lib/tags.ts Normal file
View file

@ -0,0 +1,17 @@
import unraw from "unraw";
export const squish = (strings: TemplateStringsArray, ...values: string[]) => {
const firstIndent = strings.raw[0].match(/\n(\s*)/)?.[1];
return String.raw(
{
raw: strings.raw.map((raw) => {
// remove indent up to that of the first line
if (firstIndent) raw = raw.replaceAll(`\n${firstIndent}`, "\n");
// collapse newlines not immediately surrounded by more whitespace
return unraw(raw.replace(/(?<=\S)\n(?=\S)/g, " "));
}),
},
...values,
).trim();
};