initial commit
This commit is contained in:
commit
803205ee7f
59 changed files with 3437 additions and 0 deletions
182
src/lib/Model.ts
Normal file
182
src/lib/Model.ts
Normal 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
18
src/lib/Page.ts
Normal 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
59
src/lib/Quest.spec.ts
Normal 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
41
src/lib/Quest.ts
Normal 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
20
src/lib/Questline.ts
Normal 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
5
src/lib/Reward.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export const RewardState = {
|
||||
deleted: 0,
|
||||
inactive: 1,
|
||||
active: 2,
|
||||
};
|
||||
6
src/lib/auth.ts
Normal file
6
src/lib/auth.ts
Normal 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
1
src/lib/common.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
type DbId = number | bigint;
|
||||
169
src/lib/db_init.ts
Normal file
169
src/lib/db_init.ts
Normal 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
7
src/lib/gacha_pull.ts
Normal 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
39
src/lib/slug.ts
Normal 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
17
src/lib/tags.ts
Normal 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();
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue